diff --git a/coverage/coverage.svg b/coverage/coverage.svg index f2dead0c..7089c515 100644 --- a/coverage/coverage.svg +++ b/coverage/coverage.svg @@ -1,5 +1,5 @@ - - Coverage: 85.58% + + Coverage: 85.32% @@ -13,8 +13,8 @@ \ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/bin/main.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/bin/main.rs.html index 7658ccfc..aea6d5ec 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/bin/main.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/bin/main.rs.html @@ -1,4 +1,4 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/bin/main.rs
Line
Count
Source
1
//! Command-line interface for skim fuzzy finder.
2
//!
3
//! This binary provides the `sk` command-line tool for fuzzy finding and filtering.
4
#![cfg_attr(coverage, allow(unused_features), feature(coverage_attribute))]
5
6
extern crate clap;
7
extern crate env_logger;
8
extern crate log;
9
extern crate shlex;
10
extern crate skim;
11
12
use eyre::{Result, eyre};
13
#[cfg(feature = "listen")]
14
use interprocess::bound_util::RefWrite;
15
#[cfg(feature = "listen")]
16
use interprocess::local_socket::ToNsName as _;
17
#[cfg(feature = "listen")]
18
use interprocess::local_socket::traits::Stream as _;
19
use log::trace;
20
#[cfg(feature = "listen")]
21
use skim::binds::parse_action_chain;
22
use skim::reader::CommandCollector;
23
use std::fs::File;
24
use std::io;
25
use std::io::{BufReader, BufWriter, IsTerminal, Write};
26
27
use skim::prelude::*;
28
29
71
fn init_logger(opts: &SkimOptions) {
30
71
    let target = if let Some(
ref log_file1
) = opts.log_file.as_ref().or(std::env::var("SKIM_LOG_FILE").ok().as_ref()) {
  Branch (30:25): [Folded - Ignored]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/bin/main.rs
Line
Count
Source
1
//! Command-line interface for skim fuzzy finder.
2
//!
3
//! This binary provides the `sk` command-line tool for fuzzy finding and filtering.
4
#![cfg_attr(coverage, allow(unused_features), feature(coverage_attribute))]
5
6
extern crate clap;
7
extern crate env_logger;
8
extern crate log;
9
extern crate shlex;
10
extern crate skim;
11
12
use eyre::{Result, eyre};
13
#[cfg(feature = "listen")]
14
use interprocess::bound_util::RefWrite;
15
#[cfg(feature = "listen")]
16
use interprocess::local_socket::ToNsName as _;
17
#[cfg(feature = "listen")]
18
use interprocess::local_socket::traits::Stream as _;
19
use log::trace;
20
#[cfg(feature = "listen")]
21
use skim::binds::parse_action_chain;
22
use skim::reader::CommandCollector;
23
use std::fs::File;
24
use std::io;
25
use std::io::{BufReader, BufWriter, IsTerminal, Write};
26
27
use skim::prelude::*;
28
29
71
fn init_logger(opts: &SkimOptions) {
30
71
    let target = if let Some(
ref log_file1
) = opts.log_file.as_ref().or(std::env::var("SKIM_LOG_FILE").ok().as_ref()) {
  Branch (30:25): [Folded - Ignored]
 
  Branch (30:25): [True: 1, False: 70]
 
31
1
        env_logger::Target::Pipe(Box::new(File::create(log_file).expect("Failed to create log file")))
32
    } else {
33
70
        env_logger::Target::Stdout
34
    };
35
36
71
    let env_var = "SKIM_LOG";
37
38
71
    let format = |buf: &mut env_logger::fmt::Formatter, record: &log::Record<'_>| 
{37
39
37
        writeln!(
40
37
            buf,
41
            "[{} {} {} ({}:{})] [{}/{:?}] {}",
42
37
            buf.timestamp_nanos(),
43
37
            record.level().as_str(),
44
37
            record.module_path().unwrap_or("sk"),
45
37
            record.file().unwrap_or_default(),
46
37
            record.line().unwrap_or_default(),
47
37
            std::thread::current().name().unwrap_or("?"),
48
37
            std::thread::current().id(),
49
37
            record.args()
50
        )
51
37
    };
52
53
71
    if let Some(
level0
) = opts.log_level {
  Branch (53:12): [Folded - Ignored]
 
  Branch (53:12): [True: 0, False: 71]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/binds.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/binds.rs.html
index d8a4ef1c..886a046d 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/binds.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/binds.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/binds.rs
Line
Count
Source
1
//! Key binding configuration and parsing.
2
//!
3
//! This module provides utilities for parsing and managing keyboard shortcuts
4
//! and their associated actions in skim.
5
6
use std::collections::HashMap;
7
use std::ops::{Deref, DerefMut};
8
9
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10
use eyre::{Result, eyre};
11
12
use crate::tui::actions::{self, Action};
13
14
/// Synthetic events that skim fires internally and that can be bound to actions
15
/// via the keymap, exactly like a real key press.
16
///
17
/// The keymap is keyed by crossterm's [`KeyEvent`], which cannot express
18
/// "the query changed" or "reading finished" directly. Each variant is
19
/// therefore represented *transparently* as a reserved function-key code in the
20
/// high-`F` range (`F(248)`–`F(255)`) that no real terminal ever emits.
21
/// Giving these reserved codes named variants keeps them in one place instead
22
/// of scattering magic function-key literals across the codebase, and lets
23
/// [`parse_key`] accept every friendly event name.
24
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
25
pub enum SkimEvent {
26
    /// Fired once, when skim has started up and entered its event loop.
27
    Start,
28
    /// Fired when the reader finishes producing items (once per read; a
29
    /// `reload` starts a new read and fires it again).
30
    Load,
31
    /// Fired whenever the query changes.
32
    Change,
33
    /// Fired when filtering for the current query completes and the result
34
    /// list is ready.
35
    Result,
36
    /// Fired when the focused item changes (cursor movement or a result update).
37
    Focus,
38
    /// Fired when a completed search yields no matches.
39
    Zero,
40
    /// Fired when a completed search yields exactly one match.
41
    One,
42
    /// Fired after two left mouse-button presses no more than 500 ms apart.
43
    DoubleClick,
44
}
45
46
impl SkimEvent {
47
    /// The reserved [`KeyCode`] used to route this event through the keymap.
48
    #[must_use]
49
4.53k
    pub const fn key_code(self) -> KeyCode {
50
4.53k
        match self {
51
360
            SkimEvent::Change => KeyCode::F(255),
52
382
            SkimEvent::Start => KeyCode::F(254),
53
400
            SkimEvent::Load => KeyCode::F(253),
54
731
            SkimEvent::Result => KeyCode::F(252),
55
499
            SkimEvent::Focus => KeyCode::F(251),
56
164
            SkimEvent::Zero => KeyCode::F(250),
57
227
            SkimEvent::One => KeyCode::F(249),
58
1.76k
            SkimEvent::DoubleClick => KeyCode::F(248),
59
        }
60
4.53k
    }
61
62
    /// The reserved [`KeyEvent`] used to route this event through the keymap.
63
    #[must_use]
64
4.53k
    pub const fn key_event(self) -> KeyEvent {
65
4.53k
        KeyEvent::new(self.key_code(), KeyModifiers::NONE)
66
4.53k
    }
67
68
    /// Parses an event name (`start`, `load`, `change`) into a [`SkimEvent`].
69
    ///
70
    /// Returns `None` if the name is not a recognised event.
71
    #[must_use]
72
187
    pub fn from_name(name: &str) -> Option<Self> {
73
187
        match name {
74
187
            "start" => 
Some(SkimEvent::Start)13
,
75
174
            "load" => 
Some(SkimEvent::Load)7
,
76
167
            "change" => 
Some(SkimEvent::Change)6
,
77
161
            "result" => 
Some(SkimEvent::Result)4
,
78
157
            "focus" => 
Some(SkimEvent::Focus)4
,
79
153
            "zero" => 
Some(SkimEvent::Zero)4
,
80
149
            "one" => 
Some(SkimEvent::One)4
,
81
145
            "double-click" => 
Some(SkimEvent::DoubleClick)3
,
82
142
            _ => None,
83
        }
84
187
    }
85
}
86
87
impl From<SkimEvent> for KeyEvent {
88
2.71k
    fn from(event: SkimEvent) -> Self {
89
2.71k
        event.key_event()
90
2.71k
    }
91
}
92
93
/// A map of key events to their associated actions
94
#[derive(Clone, Debug)]
95
pub struct KeyMap(pub HashMap<KeyEvent, Vec<Action>>);
96
97
impl Deref for KeyMap {
98
    type Target = HashMap<KeyEvent, Vec<Action>>;
99
100
3.17k
    fn deref(&self) -> &Self::Target {
101
3.17k
        &self.0
102
3.17k
    }
103
}
104
impl DerefMut for KeyMap {
105
112
    fn deref_mut(&mut self) -> &mut Self::Target {
106
112
        &mut self.0
107
112
    }
108
}
109
110
impl From<&str> for KeyMap {
111
3
    fn from(value: &str) -> Self {
112
3
        parse_keymaps(split_top_level(value, ',').into_iter())
113
3
    }
114
}
115
116
impl Default for KeyMap {
117
1.41k
    fn default() -> Self {
118
1.41k
        get_default_key_map()
119
1.41k
    }
120
}
121
122
impl KeyMap {
123
    /// Adds keymaps from a comma-separated string.
124
464
    pub(crate) fn add_keymaps_str(&mut self, source: &str) {
125
464
        self.add_keymaps(split_top_level(source, ',').into_iter());
126
464
    }
127
128
    /// Adds keymaps from the source, parsing them using `parse_keymap`
129
468
    pub fn add_keymaps<'a, T>(&mut self, source: T)
130
468
    where
131
468
        T: Iterator<Item = &'a str>,
132
    {
133
479
        for map in 
source468
{
134
479
            if let Ok((
key54
,
action_chain54
)) = parse_keymap(map) {
  Branch (134:20): [True: 35, False: 425]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/binds.rs
Line
Count
Source
1
//! Key binding configuration and parsing.
2
//!
3
//! This module provides utilities for parsing and managing keyboard shortcuts
4
//! and their associated actions in skim.
5
6
use std::collections::HashMap;
7
use std::ops::{Deref, DerefMut};
8
9
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10
use eyre::{Result, eyre};
11
12
use crate::tui::actions::{self, Action};
13
14
/// Synthetic events that skim fires internally and that can be bound to actions
15
/// via the keymap, exactly like a real key press.
16
///
17
/// The keymap is keyed by crossterm's [`KeyEvent`], which cannot express
18
/// "the query changed" or "reading finished" directly. Each variant is
19
/// therefore represented *transparently* as a reserved function-key code in the
20
/// high-`F` range (`F(248)`–`F(255)`) that no real terminal ever emits.
21
/// Giving these reserved codes named variants keeps them in one place instead
22
/// of scattering magic function-key literals across the codebase, and lets
23
/// [`parse_key`] accept every friendly event name.
24
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
25
pub enum SkimEvent {
26
    /// Fired once, when skim has started up and entered its event loop.
27
    Start,
28
    /// Fired when the reader finishes producing items (once per read; a
29
    /// `reload` starts a new read and fires it again).
30
    Load,
31
    /// Fired whenever the query changes.
32
    Change,
33
    /// Fired when filtering for the current query completes and the result
34
    /// list is ready.
35
    Result,
36
    /// Fired when the focused item changes (cursor movement or a result update).
37
    Focus,
38
    /// Fired when a completed search yields no matches.
39
    Zero,
40
    /// Fired when a completed search yields exactly one match.
41
    One,
42
    /// Fired after two left mouse-button presses no more than 500 ms apart.
43
    DoubleClick,
44
}
45
46
impl SkimEvent {
47
    /// The reserved [`KeyCode`] used to route this event through the keymap.
48
    #[must_use]
49
4.52k
    pub const fn key_code(self) -> KeyCode {
50
4.52k
        match self {
51
359
            SkimEvent::Change => KeyCode::F(255),
52
382
            SkimEvent::Start => KeyCode::F(254),
53
399
            SkimEvent::Load => KeyCode::F(253),
54
730
            SkimEvent::Result => KeyCode::F(252),
55
499
            SkimEvent::Focus => KeyCode::F(251),
56
164
            SkimEvent::Zero => KeyCode::F(250),
57
226
            SkimEvent::One => KeyCode::F(249),
58
1.76k
            SkimEvent::DoubleClick => KeyCode::F(248),
59
        }
60
4.52k
    }
61
62
    /// The reserved [`KeyEvent`] used to route this event through the keymap.
63
    #[must_use]
64
4.52k
    pub const fn key_event(self) -> KeyEvent {
65
4.52k
        KeyEvent::new(self.key_code(), KeyModifiers::NONE)
66
4.52k
    }
67
68
    /// Parses an event name (`start`, `load`, `change`) into a [`SkimEvent`].
69
    ///
70
    /// Returns `None` if the name is not a recognised event.
71
    #[must_use]
72
187
    pub fn from_name(name: &str) -> Option<Self> {
73
187
        match name {
74
187
            "start" => 
Some(SkimEvent::Start)13
,
75
174
            "load" => 
Some(SkimEvent::Load)7
,
76
167
            "change" => 
Some(SkimEvent::Change)6
,
77
161
            "result" => 
Some(SkimEvent::Result)4
,
78
157
            "focus" => 
Some(SkimEvent::Focus)4
,
79
153
            "zero" => 
Some(SkimEvent::Zero)4
,
80
149
            "one" => 
Some(SkimEvent::One)4
,
81
145
            "double-click" => 
Some(SkimEvent::DoubleClick)3
,
82
142
            _ => None,
83
        }
84
187
    }
85
}
86
87
impl From<SkimEvent> for KeyEvent {
88
2.71k
    fn from(event: SkimEvent) -> Self {
89
2.71k
        event.key_event()
90
2.71k
    }
91
}
92
93
/// A map of key events to their associated actions
94
#[derive(Clone, Debug)]
95
pub struct KeyMap(pub HashMap<KeyEvent, Vec<Action>>);
96
97
impl Deref for KeyMap {
98
    type Target = HashMap<KeyEvent, Vec<Action>>;
99
100
3.16k
    fn deref(&self) -> &Self::Target {
101
3.16k
        &self.0
102
3.16k
    }
103
}
104
impl DerefMut for KeyMap {
105
112
    fn deref_mut(&mut self) -> &mut Self::Target {
106
112
        &mut self.0
107
112
    }
108
}
109
110
impl From<&str> for KeyMap {
111
3
    fn from(value: &str) -> Self {
112
3
        parse_keymaps(split_top_level(value, ',').into_iter())
113
3
    }
114
}
115
116
impl Default for KeyMap {
117
1.41k
    fn default() -> Self {
118
1.41k
        get_default_key_map()
119
1.41k
    }
120
}
121
122
impl KeyMap {
123
    /// Adds keymaps from a comma-separated string.
124
464
    pub(crate) fn add_keymaps_str(&mut self, source: &str) {
125
464
        self.add_keymaps(split_top_level(source, ',').into_iter());
126
464
    }
127
128
    /// Adds keymaps from the source, parsing them using `parse_keymap`
129
468
    pub fn add_keymaps<'a, T>(&mut self, source: T)
130
468
    where
131
468
        T: Iterator<Item = &'a str>,
132
    {
133
479
        for map in 
source468
{
134
479
            if let Ok((
key54
,
action_chain54
)) = parse_keymap(map) {
  Branch (134:20): [True: 35, False: 425]
 
  Branch (134:20): [True: 17, False: 0]
   Branch (134:20): [True: 2, False: 0]
 
135
54
                self.bind(key, action_chain)
136
54
                    .unwrap_or_else(|err| 
debug!5
("Failed to bind key {map}: {err}"));
137
            } else {
138
425
                debug!("Failed to parse key: {map}");
139
            }
140
        }
141
468
    }
142
54
    fn bind(&mut self, key: &str, action_chain: Vec<Action>) -> Result<()> {
143
54
        let 
key49
= parse_key(key)
?5
;
144
145
        // remove the key for existing keymap;
146
49
        let _ = self.remove(&key);
147
49
        self.entry(key).or_insert(action_chain);
148
49
        Ok(())
149
54
    }
150
}
151
152
/// Returns the default key bindings for skim
153
#[rustfmt::skip]
154
#[must_use]
155
1.42k
pub fn get_default_key_map() -> KeyMap {
156
1.42k
    let mut ret = HashMap::new();
157
158
1.42k
    ret.insert(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), vec![Action::Down(1)]);
159
1.42k
    ret.insert(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), vec![Action::Up(1)]);
160
1.42k
    ret.insert(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE), vec![Action::PageUp(1)]);
161
1.42k
    ret.insert(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE), vec![Action::PageDown(1)]);
162
1.42k
    ret.insert(KeyEvent::new(KeyCode::End, KeyModifiers::NONE), vec![Action::EndOfLine]);
163
1.42k
    ret.insert(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE), vec![Action::BeginningOfLine]);
164
1.42k
    ret.insert(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE), vec![Action::DeleteChar]);
165
1.42k
    ret.insert(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), vec![Action::Toggle, Action::Down(1)]);
166
1.42k
    ret.insert(KeyEvent::new(KeyCode::BackTab, KeyModifiers::all()), vec![Action::Toggle, Action::Up(1)]);
167
1.42k
    ret.insert(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), vec![Action::Abort]);
168
1.42k
    ret.insert(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), vec![Action::Accept(None)]);
169
1.42k
    ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), vec![Action::BackwardChar]);
170
1.42k
    ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), vec![Action::ForwardChar]);
171
1.42k
    ret.insert(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), vec![Action::BackwardDeleteChar]);
172
1.42k
    ret.insert(SkimEvent::DoubleClick.key_event(), vec![Action::Accept(None)]);
173
174
175
1.42k
    ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT), vec![Action::BackwardWord]);
176
1.42k
    ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT), vec![Action::ForwardWord]);
177
1.42k
    ret.insert(KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT), vec![Action::PreviewUp(1)]);
178
1.42k
    ret.insert(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT), vec![Action::PreviewDown(1)]);
179
1.42k
    ret.insert(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT), vec![Action::Toggle, Action::Up(1)]);
180
1.42k
    ret.insert(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT), vec![Action::Toggle, Action::Up(1)]);
181
1.42k
    ret.insert(KeyEvent::new(KeyCode::Home, KeyModifiers::SHIFT), vec![Action::BeginningOfLine]);
182
183
184
1.42k
    ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL), vec![Action::BackwardWord]);
185
1.42k
    ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL), vec![Action::ForwardWord]);
186
187
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), vec![Action::BeginningOfLine]);
188
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL), vec![Action::BackwardChar]);
189
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), vec![Action::Abort]);
190
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), vec![Action::Abort]);
191
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), vec![Action::EndOfLine]);
192
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), vec![Action::ForwardChar]);
193
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL), vec![Action::Abort]);
194
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), vec![Action::BackwardDeleteChar]);
195
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), vec![Action::Down(1)]);
196
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), vec![Action::Up(1)]);
197
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL), vec![Action::ClearScreen]);
198
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), vec![Action::Down(1)]);
199
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), vec![Action::Up(1)]);
200
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL), vec![Action::ToggleInteractive]);
201
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), vec![Action::RotateMode]);
202
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), vec![Action::UnixLineDiscard]);
203
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), vec![Action::UnixWordRubout]);
204
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), vec![Action::Yank]);
205
206
207
1.42k
    ret.insert(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT), vec![Action::BackwardKillWord]);
208
209
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT), vec![Action::BackwardWord]);
210
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT), vec![Action::KillWord]);
211
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT), vec![Action::ForwardWord]);
212
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::ALT), vec![Action::ScrollLeft(1)]);
213
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::ALT), vec![Action::ScrollRight(1)]);
214
215
1.42k
    KeyMap(ret)
216
1.42k
}
217
218
/// Parses a key str into a crossterm `KeyEvent`.
219
///
220
/// In addition to keyboard names, accepts all names recognized by
221
/// [`SkimEvent::from_name`], including `change`, `start`, and `double-click`.
222
///
223
/// # Errors
224
/// Returns an error if the key string is empty, contains an unknown modifier,
225
/// or does not correspond to a recognised key name.
226
169
pub fn parse_key(key: &str) -> Result<KeyEvent> {
227
169
    if key.is_empty() {
  Branch (227:8): [True: 0, False: 70]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/engine/all.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/engine/all.rs.html
index dc7cdde0..d3db9cdf 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/engine/all.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/engine/all.rs.html
@@ -1 +1 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/all.rs
Line
Count
Source
1
use std::fmt::{Display, Error, Formatter};
2
use std::sync::Arc;
3
4
use crate::item::RankBuilder;
5
use crate::{MatchEngine, MatchRange, MatchResult, SkimItem};
6
7
//------------------------------------------------------------------------------
8
#[derive(Debug)]
9
pub struct MatchAllEngine {
10
    rank_builder: Arc<RankBuilder>,
11
}
12
13
impl MatchAllEngine {
14
436
    pub fn builder() -> Self {
15
436
        Self {
16
436
            rank_builder: Default::default(),
17
436
        }
18
436
    }
19
20
434
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
21
434
        self.rank_builder = rank_builder;
22
434
        self
23
434
    }
24
25
436
    pub fn build(self) -> Self {
26
436
        self
27
436
    }
28
}
29
30
impl MatchEngine for MatchAllEngine {
31
1.34k
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
32
1.34k
        let item_text = item.text();
33
1.34k
        Some(MatchResult {
34
1.34k
            rank: self.rank_builder.build_rank(0, 0, 0, &item_text),
35
1.34k
            matched_range: MatchRange::ByteRange(0, 0),
36
1.34k
        })
37
1.34k
    }
38
}
39
40
impl Display for MatchAllEngine {
41
1
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
42
1
        write!(f, "Noop")
43
1
    }
44
}
45
46
#[cfg(test)]
47
#[cfg_attr(coverage, coverage(off))]
48
mod tests {
49
    use super::*;
50
51
    #[test]
52
    fn matches_every_item_with_empty_range() {
53
        let engine = MatchAllEngine::builder().build();
54
        let result = engine.match_item(&"anything".to_string()).unwrap();
55
        assert_eq!(result.matched_range, MatchRange::ByteRange(0, 0));
56
    }
57
58
    #[test]
59
    fn rank_builder_override_is_used() {
60
        let engine = MatchAllEngine::builder()
61
            .rank_builder(Arc::new(RankBuilder::default()))
62
            .build();
63
        assert!(engine.match_item(&"x".to_string()).is_some());
64
    }
65
66
    #[test]
67
    fn display_is_noop() {
68
        let engine = MatchAllEngine::builder().build();
69
        assert_eq!(format!("{engine}"), "Noop");
70
    }
71
}
\ No newline at end of file +

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/all.rs
Line
Count
Source
1
use std::fmt::{Display, Error, Formatter};
2
use std::sync::Arc;
3
4
use crate::item::RankBuilder;
5
use crate::{MatchEngine, MatchRange, MatchResult, SkimItem};
6
7
//------------------------------------------------------------------------------
8
#[derive(Debug)]
9
pub struct MatchAllEngine {
10
    rank_builder: Arc<RankBuilder>,
11
}
12
13
impl MatchAllEngine {
14
425
    pub fn builder() -> Self {
15
425
        Self {
16
425
            rank_builder: Default::default(),
17
425
        }
18
425
    }
19
20
423
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
21
423
        self.rank_builder = rank_builder;
22
423
        self
23
423
    }
24
25
425
    pub fn build(self) -> Self {
26
425
        self
27
425
    }
28
}
29
30
impl MatchEngine for MatchAllEngine {
31
1.34k
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
32
1.34k
        let item_text = item.text();
33
1.34k
        Some(MatchResult {
34
1.34k
            rank: self.rank_builder.build_rank(0, 0, 0, &item_text),
35
1.34k
            matched_range: MatchRange::ByteRange(0, 0),
36
1.34k
        })
37
1.34k
    }
38
}
39
40
impl Display for MatchAllEngine {
41
1
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
42
1
        write!(f, "Noop")
43
1
    }
44
}
45
46
#[cfg(test)]
47
#[cfg_attr(coverage, coverage(off))]
48
mod tests {
49
    use super::*;
50
51
    #[test]
52
    fn matches_every_item_with_empty_range() {
53
        let engine = MatchAllEngine::builder().build();
54
        let result = engine.match_item(&"anything".to_string()).unwrap();
55
        assert_eq!(result.matched_range, MatchRange::ByteRange(0, 0));
56
    }
57
58
    #[test]
59
    fn rank_builder_override_is_used() {
60
        let engine = MatchAllEngine::builder()
61
            .rank_builder(Arc::new(RankBuilder::default()))
62
            .build();
63
        assert!(engine.match_item(&"x".to_string()).is_some());
64
    }
65
66
    #[test]
67
    fn display_is_noop() {
68
        let engine = MatchAllEngine::builder().build();
69
        assert_eq!(format!("{engine}"), "Noop");
70
    }
71
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/engine/andor.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/engine/andor.rs.html index ca54b181..01a1d489 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/engine/andor.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/engine/andor.rs.html @@ -1,4 +1,4 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/andor.rs
Line
Count
Source
1
use std::fmt::{Display, Error, Formatter};
2
3
use crate::fuzzy_matcher::MatchIndices;
4
use crate::{MatchEngine, MatchRange, MatchResult, SkimItem};
5
6
//------------------------------------------------------------------------------
7
// OrEngine, a combinator
8
pub struct OrEngine {
9
    engines: Vec<Box<dyn MatchEngine>>,
10
}
11
12
impl OrEngine {
13
30
    pub fn builder() -> Self {
14
30
        Self { engines: vec![] }
15
30
    }
16
17
29
    pub fn engines(mut self, mut engines: Vec<Box<dyn MatchEngine>>) -> Self {
18
29
        self.engines.append(&mut engines);
19
29
        self
20
29
    }
21
22
30
    pub fn build(self) -> Self {
23
30
        self
24
30
    }
25
}
26
27
impl MatchEngine for OrEngine {
28
18
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
29
18
        let result = self
30
18
            .engines
31
18
            .iter()
32
32
            .
map18
(|e| e.match_item(item))
33
32
            .
max_by_key18
(|res| res.as_ref().map(|matched| matched.rank.score));
34
35
18
        result
?2
36
18
    }
37
}
38
39
impl Display for OrEngine {
40
4
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
41
4
        write!(
42
4
            f,
43
            "(Or: {})",
44
4
            self.engines
45
4
                .iter()
46
7
                .
map4
(|e| format!("{e}"))
47
4
                .collect::<Vec<_>>()
48
4
                .join(", ")
49
        )
50
4
    }
51
}
52
53
//------------------------------------------------------------------------------
54
// AndEngine, a combinator
55
pub struct AndEngine {
56
    engines: Vec<Box<dyn MatchEngine>>,
57
}
58
59
impl AndEngine {
60
427
    pub fn builder() -> Self {
61
427
        Self { engines: vec![] }
62
427
    }
63
64
426
    pub fn engines(mut self, mut engines: Vec<Box<dyn MatchEngine>>) -> Self {
65
426
        self.engines.append(&mut engines);
66
426
        self
67
426
    }
68
69
427
    pub fn build(self) -> Self {
70
427
        self
71
427
    }
72
73
3
    fn merge_matched_items(items: Vec<MatchResult>, text: &str) -> MatchResult {
74
3
        let mut ranges = MatchIndices::new();
75
3
        let mut rank = crate::Rank {
76
3
            score: 0,
77
3
            begin: i32::MAX,
78
3
            end: i32::MIN,
79
3
            ..items[0].rank
80
3
        };
81
6
        for item in 
items3
{
82
6
            match item.matched_range {
83
2
                MatchRange::ByteRange(..) => {
84
2
                    ranges.extend(item.range_char_indices(text));
85
2
                }
86
2
                MatchRange::CharRange(start, end) => {
87
2
                    ranges.extend(start..end);
88
2
                }
89
2
                MatchRange::Chars(vec) => {
90
2
                    ranges.extend(vec.iter().copied());
91
2
                }
92
            }
93
6
            rank.score = rank.score.saturating_add(item.rank.score);
94
6
            rank.begin = rank.begin.min(item.rank.begin);
95
6
            rank.end = rank.end.max(item.rank.end);
96
        }
97
98
3
        ranges.sort_unstable();
99
3
        ranges.dedup();
100
3
        MatchResult {
101
3
            rank,
102
3
            matched_range: MatchRange::Chars(ranges),
103
3
        }
104
3
    }
105
}
106
107
impl MatchEngine for AndEngine {
108
51.5k
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
109
        // Fast path: single sub-engine — skip merge entirely.
110
51.5k
        if self.engines.len() == 1 {
  Branch (110:12): [True: 51.5k, False: 48]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/andor.rs
Line
Count
Source
1
use std::fmt::{Display, Error, Formatter};
2
3
use crate::fuzzy_matcher::MatchIndices;
4
use crate::{MatchEngine, MatchRange, MatchResult, SkimItem};
5
6
//------------------------------------------------------------------------------
7
// OrEngine, a combinator
8
pub struct OrEngine {
9
    engines: Vec<Box<dyn MatchEngine>>,
10
}
11
12
impl OrEngine {
13
30
    pub fn builder() -> Self {
14
30
        Self { engines: vec![] }
15
30
    }
16
17
29
    pub fn engines(mut self, mut engines: Vec<Box<dyn MatchEngine>>) -> Self {
18
29
        self.engines.append(&mut engines);
19
29
        self
20
29
    }
21
22
30
    pub fn build(self) -> Self {
23
30
        self
24
30
    }
25
}
26
27
impl MatchEngine for OrEngine {
28
18
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
29
18
        let result = self
30
18
            .engines
31
18
            .iter()
32
32
            .
map18
(|e| e.match_item(item))
33
32
            .
max_by_key18
(|res| res.as_ref().map(|matched| matched.rank.score));
34
35
18
        result
?2
36
18
    }
37
}
38
39
impl Display for OrEngine {
40
4
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
41
4
        write!(
42
4
            f,
43
            "(Or: {})",
44
4
            self.engines
45
4
                .iter()
46
7
                .
map4
(|e| format!("{e}"))
47
4
                .collect::<Vec<_>>()
48
4
                .join(", ")
49
        )
50
4
    }
51
}
52
53
//------------------------------------------------------------------------------
54
// AndEngine, a combinator
55
pub struct AndEngine {
56
    engines: Vec<Box<dyn MatchEngine>>,
57
}
58
59
impl AndEngine {
60
422
    pub fn builder() -> Self {
61
422
        Self { engines: vec![] }
62
422
    }
63
64
421
    pub fn engines(mut self, mut engines: Vec<Box<dyn MatchEngine>>) -> Self {
65
421
        self.engines.append(&mut engines);
66
421
        self
67
421
    }
68
69
422
    pub fn build(self) -> Self {
70
422
        self
71
422
    }
72
73
3
    fn merge_matched_items(items: Vec<MatchResult>, text: &str) -> MatchResult {
74
3
        let mut ranges = MatchIndices::new();
75
3
        let mut rank = crate::Rank {
76
3
            score: 0,
77
3
            begin: i32::MAX,
78
3
            end: i32::MIN,
79
3
            ..items[0].rank
80
3
        };
81
6
        for item in 
items3
{
82
6
            match item.matched_range {
83
2
                MatchRange::ByteRange(..) => {
84
2
                    ranges.extend(item.range_char_indices(text));
85
2
                }
86
2
                MatchRange::CharRange(start, end) => {
87
2
                    ranges.extend(start..end);
88
2
                }
89
2
                MatchRange::Chars(vec) => {
90
2
                    ranges.extend(vec.iter().copied());
91
2
                }
92
            }
93
6
            rank.score = rank.score.saturating_add(item.rank.score);
94
6
            rank.begin = rank.begin.min(item.rank.begin);
95
6
            rank.end = rank.end.max(item.rank.end);
96
        }
97
98
3
        ranges.sort_unstable();
99
3
        ranges.dedup();
100
3
        MatchResult {
101
3
            rank,
102
3
            matched_range: MatchRange::Chars(ranges),
103
3
        }
104
3
    }
105
}
106
107
impl MatchEngine for AndEngine {
108
51.5k
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
109
        // Fast path: single sub-engine — skip merge entirely.
110
51.5k
        if self.engines.len() == 1 {
  Branch (110:12): [True: 51.5k, False: 48]
 
  Branch (110:12): [True: 8, False: 3]
 
111
51.5k
            return self.engines[0].match_item(item);
112
51
        }
113
114
51
        let mut results = vec![];
115
52
        for engine in 
&self.engines51
{
116
52
            let 
result3
= engine.match_item(item)
?49
;
117
3
            results.push(result);
118
        }
119
120
2
        if results.is_empty() {
  Branch (120:12): [True: 0, False: 0]
 
  Branch (120:12): [True: 1, False: 1]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/engine/exact.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/engine/exact.rs.html
index ed5fb809..3ebb31f8 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/engine/exact.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/engine/exact.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/exact.rs
Line
Count
Source
1
use crate::engine::util::{contains_upper, regex_match};
2
use crate::item::RankBuilder;
3
use crate::{CaseMatching, MatchEngine, MatchRange, MatchResult, SkimItem};
4
use regex::{Regex, escape};
5
use std::cmp::min;
6
use std::fmt::{Display, Error, Formatter};
7
use std::sync::Arc;
8
9
//------------------------------------------------------------------------------
10
// Exact engine
11
#[derive(Debug, Copy, Clone, Default)]
12
#[allow(clippy::struct_excessive_bools)]
13
pub struct ExactMatchingParam {
14
    pub prefix: bool,
15
    pub postfix: bool,
16
    pub inverse: bool,
17
    pub case: CaseMatching,
18
    __non_exhaustive: bool,
19
}
20
21
#[derive(Debug)]
22
pub struct ExactEngine {
23
    #[allow(dead_code)]
24
    query: String,
25
    query_regex: Option<Regex>,
26
    rank_builder: Arc<RankBuilder>,
27
    inverse: bool,
28
}
29
30
impl ExactEngine {
31
60
    pub fn builder(query: &str, param: ExactMatchingParam) -> Self {
32
60
        let case_sensitive = match param.case {
33
3
            CaseMatching::Respect => true,
34
6
            CaseMatching::Ignore => false,
35
51
            CaseMatching::Smart => contains_upper(query),
36
        };
37
38
60
        let mut query_builder = String::new();
39
60
        if !case_sensitive {
  Branch (39:12): [True: 17, False: 0]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/exact.rs
Line
Count
Source
1
use crate::engine::util::{contains_upper, regex_match};
2
use crate::item::RankBuilder;
3
use crate::{CaseMatching, MatchEngine, MatchRange, MatchResult, SkimItem};
4
use regex::{Regex, escape};
5
use std::cmp::min;
6
use std::fmt::{Display, Error, Formatter};
7
use std::sync::Arc;
8
9
//------------------------------------------------------------------------------
10
// Exact engine
11
#[derive(Debug, Copy, Clone, Default)]
12
#[allow(clippy::struct_excessive_bools)]
13
pub struct ExactMatchingParam {
14
    pub prefix: bool,
15
    pub postfix: bool,
16
    pub inverse: bool,
17
    pub case: CaseMatching,
18
    __non_exhaustive: bool,
19
}
20
21
#[derive(Debug)]
22
pub struct ExactEngine {
23
    #[allow(dead_code)]
24
    query: String,
25
    query_regex: Option<Regex>,
26
    rank_builder: Arc<RankBuilder>,
27
    inverse: bool,
28
}
29
30
impl ExactEngine {
31
60
    pub fn builder(query: &str, param: ExactMatchingParam) -> Self {
32
60
        let case_sensitive = match param.case {
33
3
            CaseMatching::Respect => true,
34
6
            CaseMatching::Ignore => false,
35
51
            CaseMatching::Smart => contains_upper(query),
36
        };
37
38
60
        let mut query_builder = String::new();
39
60
        if !case_sensitive {
  Branch (39:12): [True: 17, False: 0]
 
  Branch (39:12): [True: 39, False: 4]
 
40
56
            query_builder.push_str("(?i)");
41
56
        
}4
42
43
60
        if param.prefix {
  Branch (43:12): [True: 5, False: 12]
 
  Branch (43:12): [True: 6, False: 37]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/engine/factory.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/engine/factory.rs.html
index db8918b9..d2f4dd50 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/engine/factory.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/engine/factory.rs.html
@@ -1,21 +1,21 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/factory.rs
Line
Count
Source
1
use regex::Regex;
2
3
use crate::engine::all::MatchAllEngine;
4
use crate::engine::andor::{AndEngine, OrEngine};
5
use crate::engine::exact::{ExactEngine, ExactMatchingParam};
6
use crate::engine::fuzzy::{FuzzyAlgorithm, FuzzyEngine};
7
use crate::engine::regexp::RegexEngine;
8
use crate::item::RankBuilder;
9
use crate::{CaseMatching, MatchEngine, MatchEngineFactory, Typos};
10
use std::sync::{Arc, LazyLock};
11
12
186
static RE_OR_WITH_SPACES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r" *\|+ *").unwrap());
13
14
//------------------------------------------------------------------------------
15
// Exact engine factory
16
/// Factory for creating exact or fuzzy match engines based on configuration
17
pub struct ExactOrFuzzyEngineFactory {
18
    exact_mode: bool,
19
    fuzzy_algorithm: FuzzyAlgorithm,
20
    rank_builder: Arc<RankBuilder>,
21
    typos: Typos,
22
    filter_mode: bool,
23
    last_match: bool,
24
}
25
26
impl ExactOrFuzzyEngineFactory {
27
    /// Creates a new builder with default settings
28
    #[must_use]
29
537
    pub fn builder() -> Self {
30
537
        Self {
31
537
            exact_mode: false,
32
537
            fuzzy_algorithm: FuzzyAlgorithm::SkimV2,
33
537
            rank_builder: Default::default(),
34
537
            typos: Typos::Disabled,
35
537
            filter_mode: false,
36
537
            last_match: false,
37
537
        }
38
537
    }
39
40
    /// Sets whether to use exact matching mode
41
    #[must_use]
42
394
    pub fn exact_mode(mut self, exact_mode: bool) -> Self {
43
394
        self.exact_mode = exact_mode;
44
394
        self
45
394
    }
46
47
    /// Sets the fuzzy matching algorithm to use
48
    #[must_use]
49
394
    pub fn fuzzy_algorithm(mut self, fuzzy_algorithm: FuzzyAlgorithm) -> Self {
50
394
        self.fuzzy_algorithm = fuzzy_algorithm;
51
394
        self
52
394
    }
53
54
    /// Sets the rank builder for scoring matches
55
    #[must_use]
56
394
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
57
394
        self.rank_builder = rank_builder;
58
394
        self
59
394
    }
60
61
    /// Sets the typo tolerance configuration
62
    ///
63
    /// - `Typos::Disabled`: no typo tolerance
64
    /// - `Typos::Smart`: adaptive typo tolerance (`pattern_length` / 4)
65
    /// - `Typos::Fixed(n)`: exactly n typos allowed
66
    #[must_use]
67
394
    pub fn typos(mut self, typos: Typos) -> Self {
68
394
        self.typos = typos;
69
394
        self
70
394
    }
71
72
    /// Sets filter mode (skips per-character match indices for faster matching)
73
    #[must_use]
74
394
    pub fn filter_mode(mut self, filter_mode: bool) -> Self {
75
394
        self.filter_mode = filter_mode;
76
394
        self
77
394
    }
78
79
    /// When true, prefer the last (rightmost) occurrence on tied scores
80
    #[must_use]
81
394
    pub fn last_match(mut self, last_match: bool) -> Self {
82
394
        self.last_match = last_match;
83
394
        self
84
394
    }
85
86
    /// Builds the factory (currently a no-op, returns self)
87
    #[must_use]
88
537
    pub fn build(self) -> Self {
89
537
        self
90
537
    }
91
}
92
93
impl MatchEngineFactory for ExactOrFuzzyEngineFactory {
94
1.00k
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
95
        // 'abc => match exact "abc"
96
        // ^abc => starts with "abc"
97
        // abc$ => ends with "abc"
98
        // ^abc$ => match exact "abc"
99
        // !^abc => items not starting with "abc"
100
        // !abc$ => items not ending with "abc"
101
        // !^abc$ => not "abc"
102
103
1.00k
        let mut query = query;
104
1.00k
        let mut exact = self.exact_mode;
105
1.00k
        let mut param = ExactMatchingParam::default();
106
1.00k
        param.case = case;
107
108
1.00k
        if query.starts_with('\'') {
  Branch (108:12): [True: 5, False: 955]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/factory.rs
Line
Count
Source
1
use regex::Regex;
2
3
use crate::engine::all::MatchAllEngine;
4
use crate::engine::andor::{AndEngine, OrEngine};
5
use crate::engine::exact::{ExactEngine, ExactMatchingParam};
6
use crate::engine::fuzzy::{FuzzyAlgorithm, FuzzyEngine};
7
use crate::engine::regexp::RegexEngine;
8
use crate::item::RankBuilder;
9
use crate::{CaseMatching, MatchEngine, MatchEngineFactory, Typos};
10
use std::sync::{Arc, LazyLock};
11
12
185
static RE_OR_WITH_SPACES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r" *\|+ *").unwrap());
13
14
//------------------------------------------------------------------------------
15
// Exact engine factory
16
/// Factory for creating exact or fuzzy match engines based on configuration
17
pub struct ExactOrFuzzyEngineFactory {
18
    exact_mode: bool,
19
    fuzzy_algorithm: FuzzyAlgorithm,
20
    rank_builder: Arc<RankBuilder>,
21
    typos: Typos,
22
    filter_mode: bool,
23
    last_match: bool,
24
}
25
26
impl ExactOrFuzzyEngineFactory {
27
    /// Creates a new builder with default settings
28
    #[must_use]
29
537
    pub fn builder() -> Self {
30
537
        Self {
31
537
            exact_mode: false,
32
537
            fuzzy_algorithm: FuzzyAlgorithm::SkimV2,
33
537
            rank_builder: Default::default(),
34
537
            typos: Typos::Disabled,
35
537
            filter_mode: false,
36
537
            last_match: false,
37
537
        }
38
537
    }
39
40
    /// Sets whether to use exact matching mode
41
    #[must_use]
42
394
    pub fn exact_mode(mut self, exact_mode: bool) -> Self {
43
394
        self.exact_mode = exact_mode;
44
394
        self
45
394
    }
46
47
    /// Sets the fuzzy matching algorithm to use
48
    #[must_use]
49
394
    pub fn fuzzy_algorithm(mut self, fuzzy_algorithm: FuzzyAlgorithm) -> Self {
50
394
        self.fuzzy_algorithm = fuzzy_algorithm;
51
394
        self
52
394
    }
53
54
    /// Sets the rank builder for scoring matches
55
    #[must_use]
56
394
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
57
394
        self.rank_builder = rank_builder;
58
394
        self
59
394
    }
60
61
    /// Sets the typo tolerance configuration
62
    ///
63
    /// - `Typos::Disabled`: no typo tolerance
64
    /// - `Typos::Smart`: adaptive typo tolerance (`pattern_length` / 4)
65
    /// - `Typos::Fixed(n)`: exactly n typos allowed
66
    #[must_use]
67
394
    pub fn typos(mut self, typos: Typos) -> Self {
68
394
        self.typos = typos;
69
394
        self
70
394
    }
71
72
    /// Sets filter mode (skips per-character match indices for faster matching)
73
    #[must_use]
74
394
    pub fn filter_mode(mut self, filter_mode: bool) -> Self {
75
394
        self.filter_mode = filter_mode;
76
394
        self
77
394
    }
78
79
    /// When true, prefer the last (rightmost) occurrence on tied scores
80
    #[must_use]
81
394
    pub fn last_match(mut self, last_match: bool) -> Self {
82
394
        self.last_match = last_match;
83
394
        self
84
394
    }
85
86
    /// Builds the factory (currently a no-op, returns self)
87
    #[must_use]
88
537
    pub fn build(self) -> Self {
89
537
        self
90
537
    }
91
}
92
93
impl MatchEngineFactory for ExactOrFuzzyEngineFactory {
94
997
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
95
        // 'abc => match exact "abc"
96
        // ^abc => starts with "abc"
97
        // abc$ => ends with "abc"
98
        // ^abc$ => match exact "abc"
99
        // !^abc => items not starting with "abc"
100
        // !abc$ => items not ending with "abc"
101
        // !^abc$ => not "abc"
102
103
997
        let mut query = query;
104
997
        let mut exact = self.exact_mode;
105
997
        let mut param = ExactMatchingParam::default();
106
997
        param.case = case;
107
108
997
        if query.starts_with('\'') {
  Branch (108:12): [True: 5, False: 943]
 
  Branch (108:12): [True: 2, False: 47]
-
109
7
            exact = !exact;
110
7
            query = &query[1..];
111
1.00k
        }
112
113
1.00k
        if query.starts_with('!') {
  Branch (113:12): [True: 8, False: 952]
+
109
7
            exact = !exact;
110
7
            query = &query[1..];
111
990
        }
112
113
997
        if query.starts_with('!') {
  Branch (113:12): [True: 8, False: 940]
 
  Branch (113:12): [True: 4, False: 45]
-
114
12
            query = &query[1..];
115
12
            exact = true;
116
12
            param.inverse = true;
117
997
        }
118
119
1.00k
        if query.is_empty() {
  Branch (119:12): [True: 411, False: 549]
+
114
12
            query = &query[1..];
115
12
            exact = true;
116
12
            param.inverse = true;
117
985
        }
118
119
997
        if query.is_empty() {
  Branch (119:12): [True: 400, False: 548]
 
  Branch (119:12): [True: 22, False: 27]
-
120
            // if only "!" was provided, will still show all items
121
433
            return Box::new(
122
433
                MatchAllEngine::builder()
123
433
                    .rank_builder(self.rank_builder.clone())
124
433
                    .build(),
125
433
            );
126
576
        }
127
128
576
        if query.starts_with('^') {
  Branch (128:12): [True: 5, False: 544]
+
120
            // if only "!" was provided, will still show all items
121
422
            return Box::new(
122
422
                MatchAllEngine::builder()
123
422
                    .rank_builder(self.rank_builder.clone())
124
422
                    .build(),
125
422
            );
126
575
        }
127
128
575
        if query.starts_with('^') {
  Branch (128:12): [True: 5, False: 543]
 
  Branch (128:12): [True: 5, False: 22]
-
129
10
            query = &query[1..];
130
10
            exact = true;
131
10
            param.prefix = true;
132
566
        }
133
134
576
        if query.ends_with('$') {
  Branch (134:12): [True: 1, False: 548]
+
129
10
            query = &query[1..];
130
10
            exact = true;
131
10
            param.prefix = true;
132
565
        }
133
134
575
        if query.ends_with('$') {
  Branch (134:12): [True: 1, False: 547]
 
  Branch (134:12): [True: 6, False: 21]
-
135
7
            query = &query[..(query.len() - 1)];
136
7
            exact = true;
137
7
            param.postfix = true;
138
569
        }
139
140
576
        if exact {
  Branch (140:12): [True: 17, False: 532]
+
135
7
            query = &query[..(query.len() - 1)];
136
7
            exact = true;
137
7
            param.postfix = true;
138
568
        }
139
140
575
        if exact {
  Branch (140:12): [True: 17, False: 531]
 
  Branch (140:12): [True: 12, False: 15]
-
141
29
            Box::new(
142
29
                ExactEngine::builder(query, param)
143
29
                    .rank_builder(self.rank_builder.clone())
144
29
                    .build(),
145
29
            )
146
        } else {
147
547
            Box::new(
148
547
                FuzzyEngine::builder()
149
547
                    .query(query)
150
547
                    .algorithm(self.fuzzy_algorithm)
151
547
                    .case(case)
152
547
                    .typos(self.typos)
153
547
                    .filter_mode(self.filter_mode)
154
547
                    .last_match(self.last_match)
155
547
                    .rank_builder(self.rank_builder.clone())
156
547
                    .build(),
157
547
            )
158
        }
159
1.00k
    }
160
}
161
162
//------------------------------------------------------------------------------
163
/// Factory for creating AND/OR composite match engines
164
pub struct AndOrEngineFactory {
165
    inner: Box<dyn MatchEngineFactory>,
166
}
167
168
impl AndOrEngineFactory {
169
    /// Creates a new AND/OR engine factory wrapping another factory
170
397
    pub fn new(factory: impl MatchEngineFactory + 'static) -> Self {
171
397
        Self {
172
397
            inner: Box::new(factory),
173
397
        }
174
397
    }
175
176
836
    fn parse_andor(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
177
836
        if query.trim().is_empty() {
  Branch (177:12): [True: 397, False: 416]
+
141
29
            Box::new(
142
29
                ExactEngine::builder(query, param)
143
29
                    .rank_builder(self.rank_builder.clone())
144
29
                    .build(),
145
29
            )
146
        } else {
147
546
            Box::new(
148
546
                FuzzyEngine::builder()
149
546
                    .query(query)
150
546
                    .algorithm(self.fuzzy_algorithm)
151
546
                    .case(case)
152
546
                    .typos(self.typos)
153
546
                    .filter_mode(self.filter_mode)
154
546
                    .last_match(self.last_match)
155
546
                    .rank_builder(self.rank_builder.clone())
156
546
                    .build(),
157
546
            )
158
        }
159
997
    }
160
}
161
162
//------------------------------------------------------------------------------
163
/// Factory for creating AND/OR composite match engines
164
pub struct AndOrEngineFactory {
165
    inner: Box<dyn MatchEngineFactory>,
166
}
167
168
impl AndOrEngineFactory {
169
    /// Creates a new AND/OR engine factory wrapping another factory
170
397
    pub fn new(factory: impl MatchEngineFactory + 'static) -> Self {
171
397
        Self {
172
397
            inner: Box::new(factory),
173
397
        }
174
397
    }
175
176
820
    fn parse_andor(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
177
820
        if query.trim().is_empty() {
  Branch (177:12): [True: 386, False: 411]
 
  Branch (177:12): [True: 16, False: 7]
-
178
413
            return self.inner.create_engine_with_case(query, case);
179
423
        }
180
423
        let and_engines = RE_OR_WITH_SPACES
181
423
            .replace_all(&Self::mask_escape_space(query), "|")
182
423
            .split(' ')
183
521
            .
filter_map423
(|and_term| {
184
521
                if and_term.is_empty() {
  Branch (184:20): [True: 2, False: 506]
+
178
402
            return self.inner.create_engine_with_case(query, case);
179
418
        }
180
418
        let and_engines = RE_OR_WITH_SPACES
181
418
            .replace_all(&Self::mask_escape_space(query), "|")
182
418
            .split(' ')
183
520
            .
filter_map418
(|and_term| {
184
520
                if and_term.is_empty() {
  Branch (184:20): [True: 2, False: 505]
 
  Branch (184:20): [True: 1, False: 12]
-
185
3
                    return None;
186
518
                }
187
518
                let or_engines = and_term
188
518
                    .split('|')
189
574
                    .
filter_map518
(|term| {
190
574
                        if term.is_empty() {
  Branch (190:28): [True: 26, False: 532]
+
185
3
                    return None;
186
517
                }
187
517
                let or_engines = and_term
188
517
                    .split('|')
189
573
                    .
filter_map517
(|term| {
190
573
                        if term.is_empty() {
  Branch (190:28): [True: 26, False: 531]
 
  Branch (190:28): [True: 1, False: 15]
-
191
27
                            return None;
192
547
                        }
193
547
                        debug!("Creating Or engine for {term}");
194
547
                        Some(
195
547
                            self.inner
196
547
                                .create_engine_with_case(&Self::unmask_escape_space(term), case),
197
547
                        )
198
574
                    })
199
518
                    .collect::<Vec<_>>();
200
518
                debug!("Building or matcher engine from Ors");
201
518
                if or_engines.len() == 1 {
  Branch (201:20): [True: 483, False: 23]
+
191
27
                            return None;
192
546
                        }
193
546
                        debug!("Creating Or engine for {term}");
194
546
                        Some(
195
546
                            self.inner
196
546
                                .create_engine_with_case(&Self::unmask_escape_space(term), case),
197
546
                        )
198
573
                    })
199
517
                    .collect::<Vec<_>>();
200
517
                debug!("Building or matcher engine from Ors");
201
517
                if or_engines.len() == 1 {
  Branch (201:20): [True: 482, False: 23]
 
  Branch (201:20): [True: 9, False: 3]
-
202
492
                    return Some(or_engines.into_iter().next().unwrap());
203
26
                }
204
26
                Some(Box::new(OrEngine::builder().engines(or_engines).build()) as Box<dyn MatchEngine>)
205
521
            })
206
423
            .collect();
207
423
        debug!("Creating and matcher engine from Ors");
208
423
        Box::new(AndEngine::builder().engines(and_engines).build())
209
836
    }
210
211
423
    fn mask_escape_space(string: &str) -> String {
212
423
        string.replace("\\ ", "\0")
213
423
    }
214
215
547
    fn unmask_escape_space(string: &str) -> String {
216
547
        string.replace('\0', " ")
217
547
    }
218
}
219
220
impl MatchEngineFactory for AndOrEngineFactory {
221
836
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
222
836
        self.parse_andor(query, case)
223
836
    }
224
}
225
226
//------------------------------------------------------------------------------
227
/// Factory for creating regex-based match engines
228
pub struct RegexEngineFactory {
229
    rank_builder: Arc<RankBuilder>,
230
}
231
232
impl RegexEngineFactory {
233
    /// Creates a new builder with default settings
234
    #[must_use]
235
5
    pub fn builder() -> Self {
236
5
        Self {
237
5
            rank_builder: Default::default(),
238
5
        }
239
5
    }
240
241
    /// Sets the rank builder for scoring matches
242
    #[must_use]
243
1
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
244
1
        self.rank_builder = rank_builder;
245
1
        self
246
1
    }
247
248
    /// Builds the factory (currently a no-op, returns self)
249
    #[must_use]
250
1
    pub fn build(self) -> Self {
251
1
        self
252
1
    }
253
}
254
255
impl MatchEngineFactory for RegexEngineFactory {
256
5
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
257
5
        Box::new(
258
5
            RegexEngine::builder(query, case)
259
5
                .rank_builder(self.rank_builder.clone())
260
5
                .build(),
261
5
        )
262
5
    }
263
}
264
265
#[cfg(test)]
266
#[cfg_attr(coverage, coverage(off))]
267
mod test {
268
    #[test]
269
    fn test_engine_factory() {
270
        use super::*;
271
        let exact_or_fuzzy = ExactOrFuzzyEngineFactory::builder().build();
272
        let x = exact_or_fuzzy.create_engine("'abc");
273
        assert_eq!(format!("{x}"), "(Exact|(?i)abc)");
274
275
        let x = exact_or_fuzzy.create_engine("^abc");
276
        assert_eq!(format!("{x}"), "(Exact|(?i)^abc)");
277
278
        let x = exact_or_fuzzy.create_engine("abc$");
279
        assert_eq!(format!("{x}"), "(Exact|(?i)abc$)");
280
281
        let x = exact_or_fuzzy.create_engine("^abc$");
282
        assert_eq!(format!("{x}"), "(Exact|(?i)^abc$)");
283
284
        let x = exact_or_fuzzy.create_engine("!abc");
285
        assert_eq!(format!("{x}"), "(Exact|!(?i)abc)");
286
287
        let x = exact_or_fuzzy.create_engine("!^abc");
288
        assert_eq!(format!("{x}"), "(Exact|!(?i)^abc)");
289
290
        let x = exact_or_fuzzy.create_engine("!abc$");
291
        assert_eq!(format!("{x}"), "(Exact|!(?i)abc$)");
292
293
        let x = exact_or_fuzzy.create_engine("!^abc$");
294
        assert_eq!(format!("{x}"), "(Exact|!(?i)^abc$)");
295
296
        let regex_factory = RegexEngineFactory::builder();
297
        let and_or_factory = AndOrEngineFactory::new(exact_or_fuzzy);
298
299
        let x = and_or_factory.create_engine("'abc | def ^gh ij | kl mn");
300
        assert_eq!(
301
            format!("{x}"),
302
            "(And: (Or: (Exact|(?i)abc), (Fuzzy: def)), (Exact|(?i)^gh), (Or: (Fuzzy: ij), (Fuzzy: kl)), (Fuzzy: mn))"
303
        );
304
305
        let x = regex_factory.create_engine("'abc | def ^gh ij | kl mn");
306
        assert_eq!(format!("{x}"), "(Regex: 'abc | def ^gh ij | kl mn)");
307
308
        let x = and_or_factory.create_engine("readme .md$ | .markdown$");
309
        assert_eq!(
310
            format!("{x}"),
311
            "(And: (Fuzzy: readme), (Or: (Exact|(?i)\\.md$), (Exact|(?i)\\.markdown$)))"
312
        );
313
    }
314
315
    #[test]
316
    fn andor_skips_empty_and_terms() {
317
        use super::*;
318
        // Two consecutive spaces produce an empty "and" term between `a` and `b`,
319
        // which must be filtered out, leaving a plain two-clause AND.
320
        let factory = AndOrEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build());
321
        let engine = factory.create_engine("a  b");
322
        assert_eq!(format!("{engine}"), "(And: (Fuzzy: a), (Fuzzy: b))");
323
    }
324
325
    #[test]
326
    fn andor_skips_empty_or_terms() {
327
        use super::*;
328
        // A leading `|` splits into an empty "or" term and `abc`; the empty term
329
        // must be dropped, collapsing to a single fuzzy clause.
330
        let factory = AndOrEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build());
331
        let engine = factory.create_engine("|abc");
332
        assert_eq!(format!("{engine}"), "(And: (Fuzzy: abc))");
333
        // It still behaves like a plain `abc` fuzzy match.
334
        assert!(engine.match_item(&"xabcy".to_string()).is_some());
335
        assert!(engine.match_item(&"zzz".to_string()).is_none());
336
    }
337
338
    #[test]
339
    fn regex_factory_with_rank_builder() {
340
        use super::*;
341
        // Exercise the `rank_builder` and `build` chaining on RegexEngineFactory.
342
        let factory = RegexEngineFactory::builder()
343
            .rank_builder(Arc::new(RankBuilder::default()))
344
            .build();
345
        let engine = factory.create_engine("ab.");
346
        assert_eq!(format!("{engine}"), "(Regex: ab.)");
347
    }
348
}
\ No newline at end of file +
202
491
                    return Some(or_engines.into_iter().next().unwrap());
203
26
                }
204
26
                Some(Box::new(OrEngine::builder().engines(or_engines).build()) as Box<dyn MatchEngine>)
205
520
            })
206
418
            .collect();
207
418
        debug!("Creating and matcher engine from Ors");
208
418
        Box::new(AndEngine::builder().engines(and_engines).build())
209
820
    }
210
211
418
    fn mask_escape_space(string: &str) -> String {
212
418
        string.replace("\\ ", "\0")
213
418
    }
214
215
546
    fn unmask_escape_space(string: &str) -> String {
216
546
        string.replace('\0', " ")
217
546
    }
218
}
219
220
impl MatchEngineFactory for AndOrEngineFactory {
221
820
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
222
820
        self.parse_andor(query, case)
223
820
    }
224
}
225
226
//------------------------------------------------------------------------------
227
/// Factory for creating regex-based match engines
228
pub struct RegexEngineFactory {
229
    rank_builder: Arc<RankBuilder>,
230
}
231
232
impl RegexEngineFactory {
233
    /// Creates a new builder with default settings
234
    #[must_use]
235
5
    pub fn builder() -> Self {
236
5
        Self {
237
5
            rank_builder: Default::default(),
238
5
        }
239
5
    }
240
241
    /// Sets the rank builder for scoring matches
242
    #[must_use]
243
1
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
244
1
        self.rank_builder = rank_builder;
245
1
        self
246
1
    }
247
248
    /// Builds the factory (currently a no-op, returns self)
249
    #[must_use]
250
1
    pub fn build(self) -> Self {
251
1
        self
252
1
    }
253
}
254
255
impl MatchEngineFactory for RegexEngineFactory {
256
5
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
257
5
        Box::new(
258
5
            RegexEngine::builder(query, case)
259
5
                .rank_builder(self.rank_builder.clone())
260
5
                .build(),
261
5
        )
262
5
    }
263
}
264
265
#[cfg(test)]
266
#[cfg_attr(coverage, coverage(off))]
267
mod test {
268
    #[test]
269
    fn test_engine_factory() {
270
        use super::*;
271
        let exact_or_fuzzy = ExactOrFuzzyEngineFactory::builder().build();
272
        let x = exact_or_fuzzy.create_engine("'abc");
273
        assert_eq!(format!("{x}"), "(Exact|(?i)abc)");
274
275
        let x = exact_or_fuzzy.create_engine("^abc");
276
        assert_eq!(format!("{x}"), "(Exact|(?i)^abc)");
277
278
        let x = exact_or_fuzzy.create_engine("abc$");
279
        assert_eq!(format!("{x}"), "(Exact|(?i)abc$)");
280
281
        let x = exact_or_fuzzy.create_engine("^abc$");
282
        assert_eq!(format!("{x}"), "(Exact|(?i)^abc$)");
283
284
        let x = exact_or_fuzzy.create_engine("!abc");
285
        assert_eq!(format!("{x}"), "(Exact|!(?i)abc)");
286
287
        let x = exact_or_fuzzy.create_engine("!^abc");
288
        assert_eq!(format!("{x}"), "(Exact|!(?i)^abc)");
289
290
        let x = exact_or_fuzzy.create_engine("!abc$");
291
        assert_eq!(format!("{x}"), "(Exact|!(?i)abc$)");
292
293
        let x = exact_or_fuzzy.create_engine("!^abc$");
294
        assert_eq!(format!("{x}"), "(Exact|!(?i)^abc$)");
295
296
        let regex_factory = RegexEngineFactory::builder();
297
        let and_or_factory = AndOrEngineFactory::new(exact_or_fuzzy);
298
299
        let x = and_or_factory.create_engine("'abc | def ^gh ij | kl mn");
300
        assert_eq!(
301
            format!("{x}"),
302
            "(And: (Or: (Exact|(?i)abc), (Fuzzy: def)), (Exact|(?i)^gh), (Or: (Fuzzy: ij), (Fuzzy: kl)), (Fuzzy: mn))"
303
        );
304
305
        let x = regex_factory.create_engine("'abc | def ^gh ij | kl mn");
306
        assert_eq!(format!("{x}"), "(Regex: 'abc | def ^gh ij | kl mn)");
307
308
        let x = and_or_factory.create_engine("readme .md$ | .markdown$");
309
        assert_eq!(
310
            format!("{x}"),
311
            "(And: (Fuzzy: readme), (Or: (Exact|(?i)\\.md$), (Exact|(?i)\\.markdown$)))"
312
        );
313
    }
314
315
    #[test]
316
    fn andor_skips_empty_and_terms() {
317
        use super::*;
318
        // Two consecutive spaces produce an empty "and" term between `a` and `b`,
319
        // which must be filtered out, leaving a plain two-clause AND.
320
        let factory = AndOrEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build());
321
        let engine = factory.create_engine("a  b");
322
        assert_eq!(format!("{engine}"), "(And: (Fuzzy: a), (Fuzzy: b))");
323
    }
324
325
    #[test]
326
    fn andor_skips_empty_or_terms() {
327
        use super::*;
328
        // A leading `|` splits into an empty "or" term and `abc`; the empty term
329
        // must be dropped, collapsing to a single fuzzy clause.
330
        let factory = AndOrEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build());
331
        let engine = factory.create_engine("|abc");
332
        assert_eq!(format!("{engine}"), "(And: (Fuzzy: abc))");
333
        // It still behaves like a plain `abc` fuzzy match.
334
        assert!(engine.match_item(&"xabcy".to_string()).is_some());
335
        assert!(engine.match_item(&"zzz".to_string()).is_none());
336
    }
337
338
    #[test]
339
    fn regex_factory_with_rank_builder() {
340
        use super::*;
341
        // Exercise the `rank_builder` and `build` chaining on RegexEngineFactory.
342
        let factory = RegexEngineFactory::builder()
343
            .rank_builder(Arc::new(RankBuilder::default()))
344
            .build();
345
        let engine = factory.create_engine("ab.");
346
        assert_eq!(format!("{engine}"), "(Regex: ab.)");
347
    }
348
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/engine/fuzzy.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/engine/fuzzy.rs.html index a71f76cc..0d36f8d7 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/engine/fuzzy.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/engine/fuzzy.rs.html @@ -1,11 +1,11 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/fuzzy.rs
Line
Count
Source
1
use std::cmp::min;
2
use std::fmt::{Display, Error, Formatter};
3
use std::sync::Arc;
4
5
use crate::fuzzy_matcher::FuzzyMatcher;
6
use crate::fuzzy_matcher::arinae::ArinaeMatcher;
7
use crate::fuzzy_matcher::clangd::ClangdMatcher;
8
#[cfg(feature = "frizbee")]
9
use crate::fuzzy_matcher::frizbee::FrizbeeMatcher;
10
use crate::fuzzy_matcher::fzy::FzyMatcher;
11
use crate::fuzzy_matcher::skim::SkimMatcherV2;
12
13
use crate::item::RankBuilder;
14
use crate::{CaseMatching, MatchEngine, MatchRange, MatchResult, SkimItem, Typos};
15
16
//------------------------------------------------------------------------------
17
/// Fuzzy matching algorithm to use
18
#[derive(Debug, Copy, Clone, Default, PartialEq)]
19
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
20
#[cfg_attr(feature = "cli", clap(rename_all = "snake_case"))]
21
pub enum FuzzyAlgorithm {
22
    /// Arinae: typo-resistant & natural algorithm, default
23
    #[cfg_attr(feature = "cli", clap(alias = "ari"))]
24
    #[default]
25
    Arinae,
26
    /// Clangd fuzzy matching algorithm
27
    Clangd,
28
    /// Fzy matching algorithm (<https://github.com/jhawthorn/fzy>)
29
    Fzy,
30
    /// Frizbee matching algorithm, typo resistant
31
    #[cfg(feature = "frizbee")]
32
    Frizbee,
33
    /// Previous skim fuzzy matching algorithm (v2)
34
    SkimV2,
35
}
36
37
const BYTES_1M: usize = 1024 * 1024 * 1024;
38
39
//------------------------------------------------------------------------------
40
// Fuzzy engine
41
#[derive(Default)]
42
pub struct FuzzyEngineBuilder {
43
    query: String,
44
    case: CaseMatching,
45
    algorithm: FuzzyAlgorithm,
46
    rank_builder: Arc<RankBuilder>,
47
    /// Typo tolerance configuration:
48
    /// - `Typos::Disabled`: no typo tolerance
49
    /// - `Typos::Smart`: adaptive (`pattern_length` / 4)
50
    /// - `Typos::Fixed(n)`: exactly n typos allowed
51
    typos: Typos,
52
    /// When true, prefer the last (rightmost) occurrence on tied scores.
53
    last_match: bool,
54
}
55
56
impl FuzzyEngineBuilder {
57
572
    pub fn query(mut self, query: &str) -> Self {
58
572
        self.query = query.to_string();
59
572
        self
60
572
    }
61
62
559
    pub fn case(mut self, case: CaseMatching) -> Self {
63
559
        self.case = case;
64
559
        self
65
559
    }
66
67
562
    pub fn algorithm(mut self, algorithm: FuzzyAlgorithm) -> Self {
68
562
        self.algorithm = algorithm;
69
562
        self
70
562
    }
71
72
547
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
73
547
        self.rank_builder = rank_builder;
74
547
        self
75
547
    }
76
77
552
    pub fn typos(mut self, typos: Typos) -> Self {
78
552
        self.typos = typos;
79
552
        self
80
552
    }
81
82
    /// No-op: `fuzzy_match_range` is now always used (`ByteRange`).
83
    /// Kept for API backward compatibility.
84
547
    pub fn filter_mode(self, _filter_mode: bool) -> Self {
85
547
        self
86
547
    }
87
88
547
    pub fn last_match(mut self, last_match: bool) -> Self {
89
547
        self.last_match = last_match;
90
547
        self
91
547
    }
92
93
    /// Compute the effective `max_typos` for the given query.
94
    ///
95
    /// - `Typos::Disabled` → `None` (no typo tolerance)
96
    /// - `Typos::Smart` → adaptive: `Some(query.chars().count() / 4)`
97
    /// - `Typos::Fixed(n)` → `Some(n)`
98
572
    fn effective_max_typos(&self) -> Option<usize> {
99
572
        match self.typos {
100
564
            Typos::Disabled => None,
101
6
            Typos::Smart => Some(self.query.chars().count().saturating_div(4)),
102
2
            Typos::Fixed(n) => Some(n),
103
        }
104
572
    }
105
106
    #[allow(deprecated)]
107
569
    pub fn build(self) -> FuzzyEngine {
108
        #[allow(unused_mut)]
109
569
        let mut algorithm = self.algorithm;
110
569
        let max_typos = self.effective_max_typos();
111
569
        let matcher: Box<dyn FuzzyMatcher> = match algorithm {
112
            FuzzyAlgorithm::SkimV2 => {
113
17
                let matcher = SkimMatcherV2::default().element_limit(BYTES_1M);
114
17
                let matcher = match self.case {
115
1
                    CaseMatching::Respect => matcher.respect_case(),
116
1
                    CaseMatching::Ignore => matcher.ignore_case(),
117
15
                    CaseMatching::Smart => matcher.smart_case(),
118
                };
119
17
                debug!("Initialized SkimV2 algorithm");
120
17
                Box::new(matcher)
121
            }
122
            FuzzyAlgorithm::Clangd => {
123
5
                let matcher = ClangdMatcher::default();
124
5
                let matcher = match self.case {
125
1
                    CaseMatching::Respect => matcher.respect_case(),
126
1
                    CaseMatching::Ignore => matcher.ignore_case(),
127
3
                    CaseMatching::Smart => matcher.smart_case(),
128
                };
129
5
                debug!("Initialized Clangd algorithm");
130
5
                Box::new(matcher)
131
            }
132
            #[cfg(feature = "frizbee")]
133
4
            FuzzyAlgorithm::Frizbee => Box::new(FrizbeeMatcher::default().case(self.case).max_typos(max_typos)),
134
            FuzzyAlgorithm::Fzy => {
135
6
                let matcher = FzyMatcher::default().max_typos(max_typos);
136
6
                let matcher = match self.case {
137
1
                    CaseMatching::Respect => matcher.respect_case(),
138
1
                    CaseMatching::Ignore => matcher.ignore_case(),
139
4
                    CaseMatching::Smart => matcher.smart_case(),
140
                };
141
6
                debug!("Initialized Fzy algorithm (max_typos: {max_typos:?})");
142
6
                Box::new(matcher)
143
            }
144
            FuzzyAlgorithm::Arinae => {
145
537
                let matcher = ArinaeMatcher::new(self.case, !
matches!2
(self.typos, Typos::Disabled), self.last_match);
146
537
                debug!("Initialized Arinae algorithm");
147
537
                Box::new(matcher)
148
            }
149
        };
150
151
569
        FuzzyEngine {
152
569
            matcher,
153
569
            query: self.query,
154
569
            rank_builder: self.rank_builder,
155
569
        }
156
569
    }
157
}
158
159
/// The fuzzy matching engine
160
pub struct FuzzyEngine {
161
    query: String,
162
    matcher: Box<dyn FuzzyMatcher>,
163
    rank_builder: Arc<RankBuilder>,
164
}
165
166
impl FuzzyEngine {
167
    /// Returns a default builder for chaining
168
    #[must_use]
169
572
    pub fn builder() -> FuzzyEngineBuilder {
170
572
        FuzzyEngineBuilder::default()
171
572
    }
172
}
173
174
impl MatchEngine for FuzzyEngine {
175
51.5k
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
176
51.5k
        let item_text = item.text();
177
51.5k
        let default_range = [(0, item_text.len())];
178
179
51.5k
        let mut best: Option<(i64, Vec<usize>)> = None;
180
51.5k
        for &(
start51.5k
,
end51.5k
) in item.get_matching_ranges().unwrap_or(&default_range) {
181
51.5k
            let start = min(start, item_text.len());
182
51.5k
            let end = min(end, item_text.len());
183
184
51.5k
            let result = if self.query.is_empty() {
  Branch (184:29): [True: 0, False: 51.5k]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/fuzzy.rs
Line
Count
Source
1
use std::cmp::min;
2
use std::fmt::{Display, Error, Formatter};
3
use std::sync::Arc;
4
5
use crate::fuzzy_matcher::FuzzyMatcher;
6
use crate::fuzzy_matcher::arinae::ArinaeMatcher;
7
use crate::fuzzy_matcher::clangd::ClangdMatcher;
8
#[cfg(feature = "frizbee")]
9
use crate::fuzzy_matcher::frizbee::FrizbeeMatcher;
10
use crate::fuzzy_matcher::fzy::FzyMatcher;
11
use crate::fuzzy_matcher::skim::SkimMatcherV2;
12
13
use crate::item::RankBuilder;
14
use crate::{CaseMatching, MatchEngine, MatchRange, MatchResult, SkimItem, Typos};
15
16
//------------------------------------------------------------------------------
17
/// Fuzzy matching algorithm to use
18
#[derive(Debug, Copy, Clone, Default, PartialEq)]
19
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
20
#[cfg_attr(feature = "cli", clap(rename_all = "snake_case"))]
21
pub enum FuzzyAlgorithm {
22
    /// Arinae: typo-resistant & natural algorithm, default
23
    #[cfg_attr(feature = "cli", clap(alias = "ari"))]
24
    #[default]
25
    Arinae,
26
    /// Clangd fuzzy matching algorithm
27
    Clangd,
28
    /// Fzy matching algorithm (<https://github.com/jhawthorn/fzy>)
29
    Fzy,
30
    /// Frizbee matching algorithm, typo resistant
31
    #[cfg(feature = "frizbee")]
32
    Frizbee,
33
    /// Previous skim fuzzy matching algorithm (v2)
34
    SkimV2,
35
}
36
37
const BYTES_1M: usize = 1024 * 1024 * 1024;
38
39
//------------------------------------------------------------------------------
40
// Fuzzy engine
41
#[derive(Default)]
42
pub struct FuzzyEngineBuilder {
43
    query: String,
44
    case: CaseMatching,
45
    algorithm: FuzzyAlgorithm,
46
    rank_builder: Arc<RankBuilder>,
47
    /// Typo tolerance configuration:
48
    /// - `Typos::Disabled`: no typo tolerance
49
    /// - `Typos::Smart`: adaptive (`pattern_length` / 4)
50
    /// - `Typos::Fixed(n)`: exactly n typos allowed
51
    typos: Typos,
52
    /// When true, prefer the last (rightmost) occurrence on tied scores.
53
    last_match: bool,
54
}
55
56
impl FuzzyEngineBuilder {
57
571
    pub fn query(mut self, query: &str) -> Self {
58
571
        self.query = query.to_string();
59
571
        self
60
571
    }
61
62
558
    pub fn case(mut self, case: CaseMatching) -> Self {
63
558
        self.case = case;
64
558
        self
65
558
    }
66
67
561
    pub fn algorithm(mut self, algorithm: FuzzyAlgorithm) -> Self {
68
561
        self.algorithm = algorithm;
69
561
        self
70
561
    }
71
72
546
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
73
546
        self.rank_builder = rank_builder;
74
546
        self
75
546
    }
76
77
551
    pub fn typos(mut self, typos: Typos) -> Self {
78
551
        self.typos = typos;
79
551
        self
80
551
    }
81
82
    /// No-op: `fuzzy_match_range` is now always used (`ByteRange`).
83
    /// Kept for API backward compatibility.
84
546
    pub fn filter_mode(self, _filter_mode: bool) -> Self {
85
546
        self
86
546
    }
87
88
546
    pub fn last_match(mut self, last_match: bool) -> Self {
89
546
        self.last_match = last_match;
90
546
        self
91
546
    }
92
93
    /// Compute the effective `max_typos` for the given query.
94
    ///
95
    /// - `Typos::Disabled` → `None` (no typo tolerance)
96
    /// - `Typos::Smart` → adaptive: `Some(query.chars().count() / 4)`
97
    /// - `Typos::Fixed(n)` → `Some(n)`
98
571
    fn effective_max_typos(&self) -> Option<usize> {
99
571
        match self.typos {
100
565
            Typos::Disabled => None,
101
4
            Typos::Smart => Some(self.query.chars().count().saturating_div(4)),
102
2
            Typos::Fixed(n) => Some(n),
103
        }
104
571
    }
105
106
    #[allow(deprecated)]
107
568
    pub fn build(self) -> FuzzyEngine {
108
        #[allow(unused_mut)]
109
568
        let mut algorithm = self.algorithm;
110
568
        let max_typos = self.effective_max_typos();
111
568
        let matcher: Box<dyn FuzzyMatcher> = match algorithm {
112
            FuzzyAlgorithm::SkimV2 => {
113
16
                let matcher = SkimMatcherV2::default().element_limit(BYTES_1M);
114
16
                let matcher = match self.case {
115
1
                    CaseMatching::Respect => matcher.respect_case(),
116
1
                    CaseMatching::Ignore => matcher.ignore_case(),
117
14
                    CaseMatching::Smart => matcher.smart_case(),
118
                };
119
16
                debug!("Initialized SkimV2 algorithm");
120
16
                Box::new(matcher)
121
            }
122
            FuzzyAlgorithm::Clangd => {
123
4
                let matcher = ClangdMatcher::default();
124
4
                let matcher = match self.case {
125
1
                    CaseMatching::Respect => matcher.respect_case(),
126
1
                    CaseMatching::Ignore => matcher.ignore_case(),
127
2
                    CaseMatching::Smart => matcher.smart_case(),
128
                };
129
4
                debug!("Initialized Clangd algorithm");
130
4
                Box::new(matcher)
131
            }
132
            #[cfg(feature = "frizbee")]
133
3
            FuzzyAlgorithm::Frizbee => Box::new(FrizbeeMatcher::default().case(self.case).max_typos(max_typos)),
134
            FuzzyAlgorithm::Fzy => {
135
5
                let matcher = FzyMatcher::default().max_typos(max_typos);
136
5
                let matcher = match self.case {
137
1
                    CaseMatching::Respect => matcher.respect_case(),
138
1
                    CaseMatching::Ignore => matcher.ignore_case(),
139
3
                    CaseMatching::Smart => matcher.smart_case(),
140
                };
141
5
                debug!("Initialized Fzy algorithm (max_typos: {max_typos:?})");
142
5
                Box::new(matcher)
143
            }
144
            FuzzyAlgorithm::Arinae => {
145
540
                let matcher = ArinaeMatcher::new(self.case, !
matches!2
(self.typos, Typos::Disabled), self.last_match);
146
540
                debug!("Initialized Arinae algorithm");
147
540
                Box::new(matcher)
148
            }
149
        };
150
151
568
        FuzzyEngine {
152
568
            matcher,
153
568
            query: self.query,
154
568
            rank_builder: self.rank_builder,
155
568
        }
156
568
    }
157
}
158
159
/// The fuzzy matching engine
160
pub struct FuzzyEngine {
161
    query: String,
162
    matcher: Box<dyn FuzzyMatcher>,
163
    rank_builder: Arc<RankBuilder>,
164
}
165
166
impl FuzzyEngine {
167
    /// Returns a default builder for chaining
168
    #[must_use]
169
571
    pub fn builder() -> FuzzyEngineBuilder {
170
571
        FuzzyEngineBuilder::default()
171
571
    }
172
}
173
174
impl MatchEngine for FuzzyEngine {
175
51.5k
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
176
51.5k
        let item_text = item.text();
177
51.5k
        let default_range = [(0, item_text.len())];
178
179
51.5k
        let mut best: Option<(i64, Vec<usize>)> = None;
180
51.5k
        for &(
start51.5k
,
end51.5k
) in item.get_matching_ranges().unwrap_or(&default_range) {
181
51.5k
            let start = min(start, item_text.len());
182
51.5k
            let end = min(end, item_text.len());
183
184
51.5k
            let result = if self.query.is_empty() {
  Branch (184:29): [True: 0, False: 51.5k]
 
  Branch (184:29): [True: 1, False: 34]
 
185
1
                Some((0i64, vec![]))
186
51.5k
            } else if item_text[start..end].is_empty() {
  Branch (186:23): [True: 64, False: 51.4k]
 
  Branch (186:23): [True: 3, False: 31]
 
187
67
                None
188
            } else {
189
51.5k
                self.matcher
190
51.5k
                    .fuzzy_indices(&item_text[start..end], &self.query)
191
51.5k
                    .map(|(s, indices)| 
{50.6k
192
50.6k
                        let offset = if start != 0 {
  Branch (192:41): [True: 10, False: 50.5k]
 
  Branch (192:41): [True: 1, False: 26]
-
193
11
                            item_text[..start].chars().count()
194
                        } else {
195
50.6k
                            0
196
                        };
197
51.3k
                        let 
indices50.6k
=
indices50.6k
.
into_iter50.6k
().
map50.6k
(|i| i + offset).
collect50.6k
();
198
50.6k
                        (s, indices)
199
50.6k
                    })
200
            };
201
202
51.5k
            if result.is_some() {
  Branch (202:16): [True: 50.6k, False: 956]
+
193
11
                            item_text[..start].chars().count()
194
                        } else {
195
50.6k
                            0
196
                        };
197
51.3k
                        let 
indices50.6k
=
indices50.6k
.
into_iter50.6k
().
map50.6k
(|i| i + offset).
collect50.6k
();
198
50.6k
                        (s, indices)
199
50.6k
                    })
200
            };
201
202
51.5k
            if result.is_some() {
  Branch (202:16): [True: 50.5k, False: 954]
 
  Branch (202:16): [True: 28, False: 7]
-
203
50.6k
                best = result;
204
50.6k
                break;
205
963
            }
206
        }
207
208
51.5k
        let (
score50.6k
,
indices50.6k
) = best
?964
;
209
50.6k
        let begin = indices.first().copied().unwrap_or(0);
210
50.6k
        let end_excl = indices.last().map_or(0, |&i| 
i50.6k
+ 1);
211
212
50.6k
        let matched_range = if indices.is_empty() {
  Branch (212:32): [True: 0, False: 50.6k]
+
203
50.6k
                best = result;
204
50.6k
                break;
205
961
            }
206
        }
207
208
51.5k
        let (
score50.6k
,
indices50.6k
) = best
?962
;
209
50.6k
        let begin = indices.first().copied().unwrap_or(0);
210
50.6k
        let end_excl = indices.last().map_or(0, |&i| 
i50.6k
+ 1);
211
212
50.6k
        let matched_range = if indices.is_empty() {
  Branch (212:32): [True: 0, False: 50.5k]
 
  Branch (212:32): [True: 1, False: 27]
 
213
1
            MatchRange::CharRange(0, 0)
214
        } else {
215
50.6k
            MatchRange::Chars(indices)
216
        };
217
218
50.6k
        Some(MatchResult {
219
50.6k
            rank: self
220
50.6k
                .rank_builder
221
50.6k
                .build_rank(i32::try_from(score).unwrap_or(i32::MAX), begin, end_excl, &item_text),
222
50.6k
            matched_range,
223
50.6k
        })
224
51.5k
    }
225
}
226
227
impl Display for FuzzyEngine {
228
10
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
229
10
        write!(f, "(Fuzzy: {})", self.query)
230
10
    }
231
}
232
233
#[cfg(test)]
234
#[cfg_attr(coverage, coverage(off))]
235
mod tests {
236
    use super::*;
237
    use std::borrow::Cow;
238
239
    /// A test item that exposes explicit `get_matching_ranges`, letting us drive
240
    /// the per-range loop in `match_item` (empty ranges, non-zero offsets, …).
241
    struct RangedItem {
242
        text: String,
243
        ranges: Vec<(usize, usize)>,
244
    }
245
246
    impl SkimItem for RangedItem {
247
        fn text(&self) -> Cow<'_, str> {
248
            Cow::Borrowed(&self.text)
249
        }
250
251
        fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> {
252
            Some(&self.ranges)
253
        }
254
    }
255
256
    #[test]
257
    fn effective_max_typos_per_variant() {
258
        let disabled = FuzzyEngine::builder().query("hello").typos(Typos::Disabled);
259
        assert_eq!(disabled.effective_max_typos(), None);
260
261
        let smart = FuzzyEngine::builder().query("hello").typos(Typos::Smart);
262
        assert_eq!(smart.effective_max_typos(), Some(1)); // 5 / 4 = 1
263
264
        let fixed = FuzzyEngine::builder().query("hello").typos(Typos::Fixed(3));
265
        assert_eq!(fixed.effective_max_typos(), Some(3));
266
    }
267
268
    /// Every algorithm × case combination should build and match a basic query.
269
    #[test]
270
    fn builds_every_algorithm_and_case() {
271
        let algorithms = [
272
            FuzzyAlgorithm::SkimV2,
273
            FuzzyAlgorithm::Clangd,
274
            FuzzyAlgorithm::Fzy,
275
            FuzzyAlgorithm::Arinae,
276
        ];
277
        let cases = [CaseMatching::Respect, CaseMatching::Ignore, CaseMatching::Smart];
278
        for algo in algorithms {
279
            for case in cases {
280
                let engine = FuzzyEngine::builder().query("foo").algorithm(algo).case(case).build();
281
                assert!(
282
                    engine.match_item(&"foobar".to_string()).is_some(),
283
                    "algo {algo:?} case {case:?} should match"
284
                );
285
            }
286
        }
287
    }
288
289
    #[test]
290
    fn empty_query_yields_empty_char_range() {
291
        let engine = FuzzyEngine::builder().query("").build();
292
        let result = engine.match_item(&"anything".to_string()).unwrap();
293
        assert_eq!(result.matched_range, MatchRange::CharRange(0, 0));
294
    }
295
296
    #[test]
297
    fn matching_query_yields_char_indices() {
298
        let engine = FuzzyEngine::builder().query("fb").build();
299
        let result = engine.match_item(&"foobar".to_string()).unwrap();
300
        assert!(matches!(result.matched_range, MatchRange::Chars(_)));
301
    }
302
303
    #[test]
304
    fn no_match_returns_none() {
305
        let engine = FuzzyEngine::builder().query("zzz").build();
306
        assert!(engine.match_item(&"foobar".to_string()).is_none());
307
    }
308
309
    /// An empty matching range (start == end) with a non-empty query must be
310
    /// skipped; a subsequent non-empty range can still match.
311
    #[test]
312
    fn empty_matching_range_is_skipped_then_later_range_matches() {
313
        let item = RangedItem {
314
            text: "foobar".to_string(),
315
            ranges: vec![(0, 0), (0, 6)],
316
        };
317
        let engine = FuzzyEngine::builder().query("fb").build();
318
        let result = engine.match_item(&item).expect("second range should match");
319
        assert!(matches!(result.matched_range, MatchRange::Chars(_)));
320
    }
321
322
    /// When every matching range is empty, the query cannot match anywhere.
323
    #[test]
324
    fn only_empty_matching_ranges_yields_no_match() {
325
        let item = RangedItem {
326
            text: "foobar".to_string(),
327
            ranges: vec![(0, 0), (3, 3)],
328
        };
329
        let engine = FuzzyEngine::builder().query("f").build();
330
        assert!(engine.match_item(&item).is_none());
331
    }
332
333
    /// A matching range that starts after byte 0 must offset the reported
334
    /// character indices by the number of characters skipped before it.
335
    #[test]
336
    fn nonzero_start_offsets_char_indices() {
337
        // Bytes 2..8 of "xxfoobar" are "foobar"; the leading "xx" is two chars.
338
        let item = RangedItem {
339
            text: "xxfoobar".to_string(),
340
            ranges: vec![(2, 8)],
341
        };
342
        let engine = FuzzyEngine::builder().query("fb").build();
343
        let result = engine.match_item(&item).expect("should match within range");
344
        let MatchRange::Chars(indices) = result.matched_range else {
345
            panic!("expected Chars range, got {:?}", result.matched_range);
346
        };
347
        // 'f' sits at char index 2 in the full text; nothing before the range.
348
        assert!(indices.iter().all(|&i| i >= 2), "indices not offset: {indices:?}");
349
        assert!(indices.contains(&2), "expected 'f' at char index 2: {indices:?}");
350
    }
351
352
    /// Building the Arinae matcher exercises the `matches!(typos, Disabled)`
353
    /// branch in both directions (typos on and off).
354
    #[test]
355
    fn builds_arinae_with_and_without_typos() {
356
        for typos in [Typos::Disabled, Typos::Fixed(1)] {
357
            let engine = FuzzyEngine::builder()
358
                .query("foo")
359
                .algorithm(FuzzyAlgorithm::Arinae)
360
                .typos(typos)
361
                .build();
362
            assert!(
363
                engine.match_item(&"foobar".to_string()).is_some(),
364
                "Arinae with {typos:?} should match"
365
            );
366
        }
367
    }
368
369
    #[cfg(feature = "frizbee")]
370
    #[test]
371
    fn builds_frizbee_algorithm() {
372
        let engine = FuzzyEngine::builder()
373
            .query("foo")
374
            .algorithm(FuzzyAlgorithm::Frizbee)
375
            .build();
376
        assert!(engine.match_item(&"foobar".to_string()).is_some());
377
    }
378
379
    #[test]
380
    fn display_shows_query() {
381
        let engine = FuzzyEngine::builder().query("foo").build();
382
        assert_eq!(format!("{engine}"), "(Fuzzy: foo)");
383
    }
384
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/engine/normalized.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/engine/normalized.rs.html index 1261bc8d..bbed787a 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/engine/normalized.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/engine/normalized.rs.html @@ -1,3 +1,3 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/normalized.rs
Line
Count
Source
1
//! Normalized match engine for matching with Unicode normalization (removing diacritics).
2
//!
3
//! This engine wraps another engine and normalizes both the query and item text before matching,
4
//! then maps the results back to the original text.
5
6
use std::borrow::Cow;
7
use std::fmt::{Display, Error, Formatter};
8
9
use crate::engine::util::{
10
    map_byte_range_to_original, map_char_indices_to_original, normalize_with_byte_mapping, normalize_with_char_mapping,
11
};
12
use crate::{CaseMatching, MatchEngine, MatchEngineFactory, MatchRange, MatchResult, SkimItem};
13
14
/// Engine that normalizes text before matching
15
pub struct NormalizedEngine {
16
    /// The underlying engine to match normalized text
17
    inner: Box<dyn MatchEngine>,
18
}
19
20
impl NormalizedEngine {
21
    /// Creates a new normalized match engine
22
93
    pub fn new(inner: Box<dyn MatchEngine>) -> Self {
23
93
        Self { inner }
24
93
    }
25
}
26
27
impl MatchEngine for NormalizedEngine {
28
293
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
29
293
        let item_text = item.text();
30
31
        // Normalize the item text
32
293
        let (normalized_text, char_mapping) = normalize_with_char_mapping(&item_text);
33
293
        let (_, byte_mapping) = normalize_with_byte_mapping(&item_text);
34
35
        // Create a wrapper item with normalized text
36
293
        let normalized_item: &dyn SkimItem = &NormalizedItem(normalized_text);
37
38
        // Match using the inner engine
39
293
        let 
mut result200
= self.inner.match_item(normalized_item)
?93
;
40
41
        // Map the matched range back to the original text
42
200
        result.matched_range = match result.matched_range {
43
113
            MatchRange::Chars(indices) => MatchRange::Chars(map_char_indices_to_original(&indices, &char_mapping)),
44
2
            MatchRange::CharRange(start, end) => {
45
2
                let orig_start = char_mapping.get(start).copied().unwrap_or(start);
46
2
                let orig_end = if end > 0 {
  Branch (46:35): [True: 0, False: 0]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/normalized.rs
Line
Count
Source
1
//! Normalized match engine for matching with Unicode normalization (removing diacritics).
2
//!
3
//! This engine wraps another engine and normalizes both the query and item text before matching,
4
//! then maps the results back to the original text.
5
6
use std::borrow::Cow;
7
use std::fmt::{Display, Error, Formatter};
8
9
use crate::engine::util::{
10
    map_byte_range_to_original, map_char_indices_to_original, normalize_with_byte_mapping, normalize_with_char_mapping,
11
};
12
use crate::{CaseMatching, MatchEngine, MatchEngineFactory, MatchRange, MatchResult, SkimItem};
13
14
/// Engine that normalizes text before matching
15
pub struct NormalizedEngine {
16
    /// The underlying engine to match normalized text
17
    inner: Box<dyn MatchEngine>,
18
}
19
20
impl NormalizedEngine {
21
    /// Creates a new normalized match engine
22
92
    pub fn new(inner: Box<dyn MatchEngine>) -> Self {
23
92
        Self { inner }
24
92
    }
25
}
26
27
impl MatchEngine for NormalizedEngine {
28
293
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
29
293
        let item_text = item.text();
30
31
        // Normalize the item text
32
293
        let (normalized_text, char_mapping) = normalize_with_char_mapping(&item_text);
33
293
        let (_, byte_mapping) = normalize_with_byte_mapping(&item_text);
34
35
        // Create a wrapper item with normalized text
36
293
        let normalized_item: &dyn SkimItem = &NormalizedItem(normalized_text);
37
38
        // Match using the inner engine
39
293
        let 
mut result200
= self.inner.match_item(normalized_item)
?93
;
40
41
        // Map the matched range back to the original text
42
200
        result.matched_range = match result.matched_range {
43
113
            MatchRange::Chars(indices) => MatchRange::Chars(map_char_indices_to_original(&indices, &char_mapping)),
44
2
            MatchRange::CharRange(start, end) => {
45
2
                let orig_start = char_mapping.get(start).copied().unwrap_or(start);
46
2
                let orig_end = if end > 0 {
  Branch (46:35): [True: 0, False: 0]
   Branch (46:35): [True: 1, False: 1]
-
47
1
                    char_mapping.get(end - 1).copied().map_or(end, |e| e + 1)
48
                } else {
49
1
                    0
50
                };
51
2
                MatchRange::CharRange(orig_start, orig_end)
52
            }
53
85
            MatchRange::ByteRange(start, end) => {
54
85
                let (orig_start, orig_end) = map_byte_range_to_original(start, end, &byte_mapping, &item_text);
55
85
                MatchRange::ByteRange(orig_start, orig_end)
56
            }
57
        };
58
59
200
        Some(result)
60
293
    }
61
}
62
63
impl Display for NormalizedEngine {
64
1
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
65
1
        write!(f, "(Normalized: {})", self.inner)
66
1
    }
67
}
68
69
/// Simple string wrapper implementing `SkimItem` for normalized matching
70
struct NormalizedItem(String);
71
72
impl SkimItem for NormalizedItem {
73
290
    fn text(&self) -> Cow<'_, str> {
74
290
        Cow::Borrowed(&self.0)
75
290
    }
76
}
77
78
//------------------------------------------------------------------------------
79
// NormalizedEngineFactory - wraps another factory and handles normalization
80
81
/// Factory that handles normalization by wrapping another engine factory
82
pub struct NormalizedEngineFactory {
83
    inner: Box<dyn MatchEngineFactory>,
84
}
85
86
impl NormalizedEngineFactory {
87
    /// Creates a new normalized engine factory
88
13
    pub fn new(inner: impl MatchEngineFactory + 'static) -> Self {
89
13
        Self { inner: Box::new(inner) }
90
13
    }
91
}
92
93
impl MatchEngineFactory for NormalizedEngineFactory {
94
87
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
95
        // Normalize the query
96
87
        let (normalized_query, _) = normalize_with_char_mapping(query);
97
98
        // Create the inner engine with the normalized query
99
87
        let inner_engine = self.inner.create_engine_with_case(&normalized_query, case);
100
101
        // Wrap it in a NormalizedEngine
102
87
        Box::new(NormalizedEngine::new(inner_engine))
103
87
    }
104
}
105
106
#[cfg(test)]
107
#[cfg_attr(coverage, coverage(off))]
108
mod tests {
109
    use super::*;
110
    use crate::engine::exact::{ExactEngine, ExactMatchingParam};
111
    use crate::prelude::ExactOrFuzzyEngineFactory;
112
113
    #[test]
114
    fn matches_through_diacritics() {
115
        // Inner exact engine searches for the ASCII form; the normalized engine
116
        // strips the accent from the item text before matching.
117
        let inner = Box::new(ExactEngine::builder("cafe", ExactMatchingParam::default()).build());
118
        let engine = NormalizedEngine::new(inner);
119
        let result = engine.match_item(&"café".to_string());
120
        assert!(result.is_some());
121
    }
122
123
    #[test]
124
    fn no_match_returns_none() {
125
        let inner = Box::new(ExactEngine::builder("zzz", ExactMatchingParam::default()).build());
126
        let engine = NormalizedEngine::new(inner);
127
        assert!(engine.match_item(&"café".to_string()).is_none());
128
    }
129
130
    #[test]
131
    fn display_includes_inner_engine() {
132
        let inner = Box::new(ExactEngine::builder("x", ExactMatchingParam::default()).build());
133
        let engine = NormalizedEngine::new(inner);
134
        assert!(format!("{engine}").starts_with("(Normalized:"));
135
    }
136
137
    #[test]
138
    fn factory_creates_normalized_engine() {
139
        let factory = NormalizedEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build());
140
        let engine = factory.create_engine_with_case("cafe", CaseMatching::Smart);
141
        // The accented item should match the normalized query.
142
        assert!(engine.match_item(&"café".to_string()).is_some());
143
    }
144
145
    /// Inner engine that always returns a fixed `CharRange`, so the normalized
146
    /// engine's `CharRange` remapping branch is exercised.
147
    struct CharRangeStub(usize, usize);
148
149
    impl MatchEngine for CharRangeStub {
150
        fn match_item(&self, _item: &dyn SkimItem) -> Option<MatchResult> {
151
            Some(MatchResult {
152
                rank: crate::Rank::default(),
153
                matched_range: MatchRange::CharRange(self.0, self.1),
154
            })
155
        }
156
    }
157
158
    impl Display for CharRangeStub {
159
        fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
160
            write!(f, "CharRangeStub")
161
        }
162
    }
163
164
    #[test]
165
    fn char_range_is_mapped_back_to_original() {
166
        // café → cafe is a 1:1 normalization, so the char range is unchanged.
167
        let engine = NormalizedEngine::new(Box::new(CharRangeStub(1, 3)));
168
        let result = engine.match_item(&"café".to_string()).unwrap();
169
        assert_eq!(result.matched_range, MatchRange::CharRange(1, 3));
170
    }
171
172
    #[test]
173
    fn empty_char_range_maps_to_zero() {
174
        // An empty range (end == 0) maps straight back to (0, 0).
175
        let engine = NormalizedEngine::new(Box::new(CharRangeStub(0, 0)));
176
        let result = engine.match_item(&"café".to_string()).unwrap();
177
        assert_eq!(result.matched_range, MatchRange::CharRange(0, 0));
178
    }
179
180
    /// Inner engine returning fixed `Chars` indices, exercising the
181
    /// `map_char_indices_to_original` remapping branch.
182
    struct CharsStub(Vec<usize>);
183
184
    impl MatchEngine for CharsStub {
185
        fn match_item(&self, _item: &dyn SkimItem) -> Option<MatchResult> {
186
            Some(MatchResult {
187
                rank: crate::Rank::default(),
188
                matched_range: MatchRange::Chars(self.0.clone()),
189
            })
190
        }
191
    }
192
193
    impl Display for CharsStub {
194
        fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
195
            write!(f, "CharsStub")
196
        }
197
    }
198
199
    #[test]
200
    fn chars_indices_are_mapped_back_to_original() {
201
        // 1:1 normalization (café → cafe) keeps the char indices unchanged.
202
        let engine = NormalizedEngine::new(Box::new(CharsStub(vec![0, 2])));
203
        let result = engine.match_item(&"café".to_string()).unwrap();
204
        assert_eq!(result.matched_range, MatchRange::Chars(vec![0, 2]));
205
    }
206
}
\ No newline at end of file +
47
1
                    char_mapping.get(end - 1).copied().map_or(end, |e| e + 1)
48
                } else {
49
1
                    0
50
                };
51
2
                MatchRange::CharRange(orig_start, orig_end)
52
            }
53
85
            MatchRange::ByteRange(start, end) => {
54
85
                let (orig_start, orig_end) = map_byte_range_to_original(start, end, &byte_mapping, &item_text);
55
85
                MatchRange::ByteRange(orig_start, orig_end)
56
            }
57
        };
58
59
200
        Some(result)
60
293
    }
61
}
62
63
impl Display for NormalizedEngine {
64
1
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
65
1
        write!(f, "(Normalized: {})", self.inner)
66
1
    }
67
}
68
69
/// Simple string wrapper implementing `SkimItem` for normalized matching
70
struct NormalizedItem(String);
71
72
impl SkimItem for NormalizedItem {
73
290
    fn text(&self) -> Cow<'_, str> {
74
290
        Cow::Borrowed(&self.0)
75
290
    }
76
}
77
78
//------------------------------------------------------------------------------
79
// NormalizedEngineFactory - wraps another factory and handles normalization
80
81
/// Factory that handles normalization by wrapping another engine factory
82
pub struct NormalizedEngineFactory {
83
    inner: Box<dyn MatchEngineFactory>,
84
}
85
86
impl NormalizedEngineFactory {
87
    /// Creates a new normalized engine factory
88
13
    pub fn new(inner: impl MatchEngineFactory + 'static) -> Self {
89
13
        Self { inner: Box::new(inner) }
90
13
    }
91
}
92
93
impl MatchEngineFactory for NormalizedEngineFactory {
94
86
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
95
        // Normalize the query
96
86
        let (normalized_query, _) = normalize_with_char_mapping(query);
97
98
        // Create the inner engine with the normalized query
99
86
        let inner_engine = self.inner.create_engine_with_case(&normalized_query, case);
100
101
        // Wrap it in a NormalizedEngine
102
86
        Box::new(NormalizedEngine::new(inner_engine))
103
86
    }
104
}
105
106
#[cfg(test)]
107
#[cfg_attr(coverage, coverage(off))]
108
mod tests {
109
    use super::*;
110
    use crate::engine::exact::{ExactEngine, ExactMatchingParam};
111
    use crate::prelude::ExactOrFuzzyEngineFactory;
112
113
    #[test]
114
    fn matches_through_diacritics() {
115
        // Inner exact engine searches for the ASCII form; the normalized engine
116
        // strips the accent from the item text before matching.
117
        let inner = Box::new(ExactEngine::builder("cafe", ExactMatchingParam::default()).build());
118
        let engine = NormalizedEngine::new(inner);
119
        let result = engine.match_item(&"café".to_string());
120
        assert!(result.is_some());
121
    }
122
123
    #[test]
124
    fn no_match_returns_none() {
125
        let inner = Box::new(ExactEngine::builder("zzz", ExactMatchingParam::default()).build());
126
        let engine = NormalizedEngine::new(inner);
127
        assert!(engine.match_item(&"café".to_string()).is_none());
128
    }
129
130
    #[test]
131
    fn display_includes_inner_engine() {
132
        let inner = Box::new(ExactEngine::builder("x", ExactMatchingParam::default()).build());
133
        let engine = NormalizedEngine::new(inner);
134
        assert!(format!("{engine}").starts_with("(Normalized:"));
135
    }
136
137
    #[test]
138
    fn factory_creates_normalized_engine() {
139
        let factory = NormalizedEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build());
140
        let engine = factory.create_engine_with_case("cafe", CaseMatching::Smart);
141
        // The accented item should match the normalized query.
142
        assert!(engine.match_item(&"café".to_string()).is_some());
143
    }
144
145
    /// Inner engine that always returns a fixed `CharRange`, so the normalized
146
    /// engine's `CharRange` remapping branch is exercised.
147
    struct CharRangeStub(usize, usize);
148
149
    impl MatchEngine for CharRangeStub {
150
        fn match_item(&self, _item: &dyn SkimItem) -> Option<MatchResult> {
151
            Some(MatchResult {
152
                rank: crate::Rank::default(),
153
                matched_range: MatchRange::CharRange(self.0, self.1),
154
            })
155
        }
156
    }
157
158
    impl Display for CharRangeStub {
159
        fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
160
            write!(f, "CharRangeStub")
161
        }
162
    }
163
164
    #[test]
165
    fn char_range_is_mapped_back_to_original() {
166
        // café → cafe is a 1:1 normalization, so the char range is unchanged.
167
        let engine = NormalizedEngine::new(Box::new(CharRangeStub(1, 3)));
168
        let result = engine.match_item(&"café".to_string()).unwrap();
169
        assert_eq!(result.matched_range, MatchRange::CharRange(1, 3));
170
    }
171
172
    #[test]
173
    fn empty_char_range_maps_to_zero() {
174
        // An empty range (end == 0) maps straight back to (0, 0).
175
        let engine = NormalizedEngine::new(Box::new(CharRangeStub(0, 0)));
176
        let result = engine.match_item(&"café".to_string()).unwrap();
177
        assert_eq!(result.matched_range, MatchRange::CharRange(0, 0));
178
    }
179
180
    /// Inner engine returning fixed `Chars` indices, exercising the
181
    /// `map_char_indices_to_original` remapping branch.
182
    struct CharsStub(Vec<usize>);
183
184
    impl MatchEngine for CharsStub {
185
        fn match_item(&self, _item: &dyn SkimItem) -> Option<MatchResult> {
186
            Some(MatchResult {
187
                rank: crate::Rank::default(),
188
                matched_range: MatchRange::Chars(self.0.clone()),
189
            })
190
        }
191
    }
192
193
    impl Display for CharsStub {
194
        fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
195
            write!(f, "CharsStub")
196
        }
197
    }
198
199
    #[test]
200
    fn chars_indices_are_mapped_back_to_original() {
201
        // 1:1 normalization (café → cafe) keeps the char indices unchanged.
202
        let engine = NormalizedEngine::new(Box::new(CharsStub(vec![0, 2])));
203
        let result = engine.match_item(&"café".to_string()).unwrap();
204
        assert_eq!(result.matched_range, MatchRange::Chars(vec![0, 2]));
205
    }
206
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/engine/regexp.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/engine/regexp.rs.html index 6f51e205..fb764b3b 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/engine/regexp.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/engine/regexp.rs.html @@ -1,4 +1,4 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/regexp.rs
Line
Count
Source
1
use std::fmt::{Display, Error, Formatter};
2
use std::sync::Arc;
3
4
use regex::Regex;
5
6
use crate::engine::util::regex_match;
7
use crate::item::RankBuilder;
8
use crate::{CaseMatching, MatchEngine, MatchRange, MatchResult, SkimItem};
9
use std::cmp::min;
10
11
//------------------------------------------------------------------------------
12
// Regular Expression engine
13
#[derive(Debug)]
14
pub struct RegexEngine {
15
    query_regex: Option<Regex>,
16
    rank_builder: Arc<RankBuilder>,
17
}
18
19
impl RegexEngine {
20
12
    pub fn builder(query: &str, case: CaseMatching) -> Self {
21
12
        let mut query_builder = String::new();
22
23
12
        match case {
24
1
            CaseMatching::Ignore => query_builder.push_str("(?i)"),
25
11
            CaseMatching::Respect | CaseMatching::Smart => {}
26
        }
27
28
12
        query_builder.push_str(query);
29
30
12
        RegexEngine {
31
12
            query_regex: Regex::new(&query_builder).ok(),
32
12
            rank_builder: Default::default(),
33
12
        }
34
12
    }
35
36
5
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
37
5
        self.rank_builder = rank_builder;
38
5
        self
39
5
    }
40
41
12
    pub fn build(self) -> Self {
42
12
        self
43
12
    }
44
}
45
46
impl MatchEngine for RegexEngine {
47
9
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
48
9
        let mut matched_result = None;
49
9
        let item_text = item.text();
50
9
        let default_range = [(0, item_text.len())];
51
9
        for &(start, end) in item.get_matching_ranges().unwrap_or(&default_range) {
52
9
            let start = min(start, item_text.len());
53
9
            let end = min(end, item_text.len());
54
9
            if self.query_regex.is_none() {
  Branch (54:16): [True: 0, False: 1]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/regexp.rs
Line
Count
Source
1
use std::fmt::{Display, Error, Formatter};
2
use std::sync::Arc;
3
4
use regex::Regex;
5
6
use crate::engine::util::regex_match;
7
use crate::item::RankBuilder;
8
use crate::{CaseMatching, MatchEngine, MatchRange, MatchResult, SkimItem};
9
use std::cmp::min;
10
11
//------------------------------------------------------------------------------
12
// Regular Expression engine
13
#[derive(Debug)]
14
pub struct RegexEngine {
15
    query_regex: Option<Regex>,
16
    rank_builder: Arc<RankBuilder>,
17
}
18
19
impl RegexEngine {
20
12
    pub fn builder(query: &str, case: CaseMatching) -> Self {
21
12
        let mut query_builder = String::new();
22
23
12
        match case {
24
1
            CaseMatching::Ignore => query_builder.push_str("(?i)"),
25
11
            CaseMatching::Respect | CaseMatching::Smart => {}
26
        }
27
28
12
        query_builder.push_str(query);
29
30
12
        RegexEngine {
31
12
            query_regex: Regex::new(&query_builder).ok(),
32
12
            rank_builder: Default::default(),
33
12
        }
34
12
    }
35
36
5
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
37
5
        self.rank_builder = rank_builder;
38
5
        self
39
5
    }
40
41
12
    pub fn build(self) -> Self {
42
12
        self
43
12
    }
44
}
45
46
impl MatchEngine for RegexEngine {
47
9
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
48
9
        let mut matched_result = None;
49
9
        let item_text = item.text();
50
9
        let default_range = [(0, item_text.len())];
51
9
        for &(start, end) in item.get_matching_ranges().unwrap_or(&default_range) {
52
9
            let start = min(start, item_text.len());
53
9
            let end = min(end, item_text.len());
54
9
            if self.query_regex.is_none() {
  Branch (54:16): [True: 0, False: 1]
 
  Branch (54:16): [True: 1, False: 7]
 
55
1
                matched_result = Some((0, 0));
56
1
                break;
57
8
            }
58
59
            matched_result =
60
8
                regex_match(&item_text[start..end], self.query_regex.as_ref()).map(|(s, e)| (
s + start6
,
e + start6
));
61
62
8
            if matched_result.is_some() {
  Branch (62:16): [True: 1, False: 0]
 
  Branch (62:16): [True: 5, False: 2]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/engine/split.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/engine/split.rs.html
index 6ec65dc8..94f11497 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/engine/split.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/engine/split.rs.html
@@ -1,7 +1,7 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/split.rs
Line
Count
Source
1
//! Split match engine for matching against different parts of items based on a delimiter.
2
//!
3
//! This engine splits both the query and item text on a delimiter character, then matches
4
//! the query parts against the corresponding item parts.
5
6
use crate::fuzzy_matcher::MatchIndices;
7
use crate::{MatchEngine, MatchEngineFactory, MatchRange, MatchResult, SkimItem};
8
use std::fmt::{Display, Error, Formatter};
9
10
/// Engine that matches by splitting query and item on a delimiter
11
pub struct SplitMatchEngine {
12
    /// The engine to match the "before delimiter" part
13
    before_engine: Box<dyn MatchEngine>,
14
    /// The engine to match the "after delimiter" part  
15
    after_engine: Box<dyn MatchEngine>,
16
    /// The delimiter character used for splitting
17
    delimiter: char,
18
}
19
20
impl SplitMatchEngine {
21
    /// Creates a new split match engine
22
39
    pub fn new(before_engine: Box<dyn MatchEngine>, after_engine: Box<dyn MatchEngine>, delimiter: char) -> Self {
23
39
        Self {
24
39
            before_engine,
25
39
            after_engine,
26
39
            delimiter,
27
39
        }
28
39
    }
29
}
30
31
impl MatchEngine for SplitMatchEngine {
32
88
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
33
88
        let text = item.text();
34
35
        // Find the delimiter in the item text (by char position)
36
291
        let 
delimiter_char_idx79
=
text.chars()88
.
position88
(|c| c == self.delimiter)
?9
;
37
38
        // Get byte position for slicing
39
79
        let delimiter_byte_pos = text.char_indices().nth(delimiter_char_idx).map(|(i, _)| i)
?0
;
40
41
79
        let text_before = &text[..delimiter_byte_pos];
42
79
        let text_after = &text[delimiter_byte_pos + self.delimiter.len_utf8()..];
43
44
        // Create wrapper items for each part
45
79
        let before_item: &dyn SkimItem = &StringItem(text_before.to_string());
46
79
        let after_item: &dyn SkimItem = &StringItem(text_after.to_string());
47
48
        // Match both parts
49
79
        let 
before_result47
= self.before_engine.match_item(before_item)
?32
;
50
47
        let 
after_result42
= self.after_engine.match_item(after_item)
?5
;
51
52
        // Combine the results - use rank from first result (like AndEngine does)
53
42
        let rank = before_result.rank;
54
55
42
        let mut combined_indices: MatchIndices = match before_result.matched_range {
56
30
            MatchRange::Chars(indices) => indices,
57
1
            MatchRange::CharRange(start, end) => (start..end).collect(),
58
11
            MatchRange::ByteRange(start, end) => {
59
                // Convert byte range to char indices for the before part
60
11
                text_before
61
11
                    .char_indices()
62
11
                    .enumerate()
63
31
                    .
filter11
(|(_, (byte_idx, _))| *byte_idx >= start &&
*byte_idx < end30
)
  Branch (63:50): [True: 24, False: 0]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/split.rs
Line
Count
Source
1
//! Split match engine for matching against different parts of items based on a delimiter.
2
//!
3
//! This engine splits both the query and item text on a delimiter character, then matches
4
//! the query parts against the corresponding item parts.
5
6
use crate::fuzzy_matcher::MatchIndices;
7
use crate::{MatchEngine, MatchEngineFactory, MatchRange, MatchResult, SkimItem};
8
use std::fmt::{Display, Error, Formatter};
9
10
/// Engine that matches by splitting query and item on a delimiter
11
pub struct SplitMatchEngine {
12
    /// The engine to match the "before delimiter" part
13
    before_engine: Box<dyn MatchEngine>,
14
    /// The engine to match the "after delimiter" part  
15
    after_engine: Box<dyn MatchEngine>,
16
    /// The delimiter character used for splitting
17
    delimiter: char,
18
}
19
20
impl SplitMatchEngine {
21
    /// Creates a new split match engine
22
39
    pub fn new(before_engine: Box<dyn MatchEngine>, after_engine: Box<dyn MatchEngine>, delimiter: char) -> Self {
23
39
        Self {
24
39
            before_engine,
25
39
            after_engine,
26
39
            delimiter,
27
39
        }
28
39
    }
29
}
30
31
impl MatchEngine for SplitMatchEngine {
32
88
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
33
88
        let text = item.text();
34
35
        // Find the delimiter in the item text (by char position)
36
291
        let 
delimiter_char_idx79
=
text.chars()88
.
position88
(|c| c == self.delimiter)
?9
;
37
38
        // Get byte position for slicing
39
79
        let delimiter_byte_pos = text.char_indices().nth(delimiter_char_idx).map(|(i, _)| i)
?0
;
40
41
79
        let text_before = &text[..delimiter_byte_pos];
42
79
        let text_after = &text[delimiter_byte_pos + self.delimiter.len_utf8()..];
43
44
        // Create wrapper items for each part
45
79
        let before_item: &dyn SkimItem = &StringItem(text_before.to_string());
46
79
        let after_item: &dyn SkimItem = &StringItem(text_after.to_string());
47
48
        // Match both parts
49
79
        let 
before_result47
= self.before_engine.match_item(before_item)
?32
;
50
47
        let 
after_result42
= self.after_engine.match_item(after_item)
?5
;
51
52
        // Combine the results - use rank from first result (like AndEngine does)
53
42
        let rank = before_result.rank;
54
55
42
        let mut combined_indices: MatchIndices = match before_result.matched_range {
56
30
            MatchRange::Chars(indices) => indices,
57
1
            MatchRange::CharRange(start, end) => (start..end).collect(),
58
11
            MatchRange::ByteRange(start, end) => {
59
                // Convert byte range to char indices for the before part
60
11
                text_before
61
11
                    .char_indices()
62
11
                    .enumerate()
63
31
                    .
filter11
(|(_, (byte_idx, _))| *byte_idx >= start &&
*byte_idx < end30
)
  Branch (63:50): [True: 24, False: 0]
 
  Branch (63:50): [True: 6, False: 1]
 
64
11
                    .map(|(char_idx, _)| char_idx)
65
11
                    .collect()
66
            }
67
        };
68
69
        // Offset for the "after" part: delimiter_char_idx + 1 (to skip the delimiter)
70
42
        let offset = delimiter_char_idx + 1;
71
72
42
        let after_indices: MatchIndices = match after_result.matched_range {
73
46
            MatchRange::Chars(
indices27
) =>
indices27
.
into_iter27
().
map27
(|i| i + offset).
collect27
(),
74
2
            MatchRange::CharRange(
start1
,
end1
) => (
start..end1
).
map1
(|i| i + offset).
collect1
(),
75
14
            MatchRange::ByteRange(start, end) => {
76
                // Convert byte range to char indices for the after part
77
14
                text_after
78
14
                    .char_indices()
79
14
                    .enumerate()
80
40
                    .
filter14
(|(_, (byte_idx, _))| *byte_idx >= start &&
*byte_idx < end39
)
  Branch (80:50): [True: 33, False: 0]
 
  Branch (80:50): [True: 6, False: 1]
-
81
14
                    .map(|(char_idx, _)| 
char_idx5
+
offset5
)
82
14
                    .collect()
83
            }
84
        };
85
86
42
        combined_indices.extend(after_indices);
87
42
        combined_indices.sort_unstable();
88
42
        combined_indices.dedup();
89
90
42
        Some(MatchResult {
91
42
            rank,
92
42
            matched_range: MatchRange::Chars(combined_indices),
93
42
        })
94
88
    }
95
}
96
97
impl Display for SplitMatchEngine {
98
1
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
99
1
        write!(
100
1
            f,
101
            "(Split[{}]: {} | {})",
102
            self.delimiter, self.before_engine, self.after_engine
103
        )
104
1
    }
105
}
106
107
/// Simple string wrapper implementing `SkimItem` for split matching
108
struct StringItem(String);
109
110
impl SkimItem for StringItem {
111
120
    fn text(&self) -> std::borrow::Cow<'_, str> {
112
120
        std::borrow::Cow::Borrowed(&self.0)
113
120
    }
114
}
115
116
//------------------------------------------------------------------------------
117
// SplitMatchEngineFactory - wraps another factory and handles split matching
118
119
/// Factory that handles split matching by wrapping another engine factory
120
pub struct SplitMatchEngineFactory {
121
    inner: Box<dyn MatchEngineFactory>,
122
    delimiter: char,
123
}
124
125
impl SplitMatchEngineFactory {
126
    /// Creates a new split match engine factory
127
12
    pub fn new(inner: impl MatchEngineFactory + 'static, delimiter: char) -> Self {
128
12
        Self {
129
12
            inner: Box::new(inner),
130
12
            delimiter,
131
12
        }
132
12
    }
133
}
134
135
impl MatchEngineFactory for SplitMatchEngineFactory {
136
64
    fn create_engine_with_case(&self, query: &str, case: crate::CaseMatching) -> Box<dyn MatchEngine> {
137
        // Check if the query contains the delimiter
138
64
        if let Some(
delimiter_pos32
) = query.find(self.delimiter) {
  Branch (138:16): [True: 31, False: 31]
+
81
14
                    .map(|(char_idx, _)| 
char_idx5
+
offset5
)
82
14
                    .collect()
83
            }
84
        };
85
86
42
        combined_indices.extend(after_indices);
87
42
        combined_indices.sort_unstable();
88
42
        combined_indices.dedup();
89
90
42
        Some(MatchResult {
91
42
            rank,
92
42
            matched_range: MatchRange::Chars(combined_indices),
93
42
        })
94
88
    }
95
}
96
97
impl Display for SplitMatchEngine {
98
1
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
99
1
        write!(
100
1
            f,
101
            "(Split[{}]: {} | {})",
102
            self.delimiter, self.before_engine, self.after_engine
103
        )
104
1
    }
105
}
106
107
/// Simple string wrapper implementing `SkimItem` for split matching
108
struct StringItem(String);
109
110
impl SkimItem for StringItem {
111
120
    fn text(&self) -> std::borrow::Cow<'_, str> {
112
120
        std::borrow::Cow::Borrowed(&self.0)
113
120
    }
114
}
115
116
//------------------------------------------------------------------------------
117
// SplitMatchEngineFactory - wraps another factory and handles split matching
118
119
/// Factory that handles split matching by wrapping another engine factory
120
pub struct SplitMatchEngineFactory {
121
    inner: Box<dyn MatchEngineFactory>,
122
    delimiter: char,
123
}
124
125
impl SplitMatchEngineFactory {
126
    /// Creates a new split match engine factory
127
11
    pub fn new(inner: impl MatchEngineFactory + 'static, delimiter: char) -> Self {
128
11
        Self {
129
11
            inner: Box::new(inner),
130
11
            delimiter,
131
11
        }
132
11
    }
133
}
134
135
impl MatchEngineFactory for SplitMatchEngineFactory {
136
63
    fn create_engine_with_case(&self, query: &str, case: crate::CaseMatching) -> Box<dyn MatchEngine> {
137
        // Check if the query contains the delimiter
138
63
        if let Some(
delimiter_pos32
) = query.find(self.delimiter) {
  Branch (138:16): [True: 31, False: 30]
 
  Branch (138:16): [True: 1, False: 1]
-
139
32
            let query_before = &query[..delimiter_pos];
140
32
            let query_after = &query[delimiter_pos + self.delimiter.len_utf8()..];
141
142
            // Create engines for each part using the inner factory
143
32
            let before_engine = self.inner.create_engine_with_case(query_before, case);
144
32
            let after_engine = self.inner.create_engine_with_case(query_after, case);
145
146
32
            Box::new(SplitMatchEngine::new(before_engine, after_engine, self.delimiter))
147
        } else {
148
            // No delimiter in query, pass through to inner factory
149
32
            self.inner.create_engine_with_case(query, case)
150
        }
151
64
    }
152
}
153
154
#[cfg(test)]
155
#[cfg_attr(coverage, coverage(off))]
156
mod tests {
157
    use super::*;
158
    use crate::engine::exact::{ExactEngine, ExactMatchingParam};
159
    use crate::prelude::ExactOrFuzzyEngineFactory;
160
161
    /// A stub engine that returns a fixed match range, regardless of the item.
162
    struct StubEngine(MatchRange);
163
164
    impl MatchEngine for StubEngine {
165
        fn match_item(&self, _item: &dyn SkimItem) -> Option<MatchResult> {
166
            Some(MatchResult {
167
                rank: crate::Rank::default(),
168
                matched_range: self.0.clone(),
169
            })
170
        }
171
    }
172
173
    impl Display for StubEngine {
174
        fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
175
            write!(f, "Stub")
176
        }
177
    }
178
179
    fn exact(query: &str) -> Box<dyn MatchEngine> {
180
        Box::new(ExactEngine::builder(query, ExactMatchingParam::default()).build())
181
    }
182
183
    #[test]
184
    fn no_delimiter_in_item_returns_none() {
185
        let engine = SplitMatchEngine::new(exact("a"), exact("b"), ':');
186
        assert!(engine.match_item(&"no delimiter here".to_string()).is_none());
187
    }
188
189
    #[test]
190
    fn matches_both_sides_of_delimiter() {
191
        let engine = SplitMatchEngine::new(exact("ab"), exact("cd"), ':');
192
        let result = engine.match_item(&"ab:cd".to_string());
193
        assert!(result.is_some());
194
    }
195
196
    #[test]
197
    fn char_range_results_are_offset_and_combined() {
198
        // Before matches chars 0..2 of "ab"; after matches chars 0..2 of "cd"
199
        // which become 3..5 after the delimiter offset.
200
        let engine = SplitMatchEngine::new(
201
            Box::new(StubEngine(MatchRange::CharRange(0, 2))),
202
            Box::new(StubEngine(MatchRange::CharRange(0, 2))),
203
            ':',
204
        );
205
        let result = engine.match_item(&"ab:cd".to_string()).unwrap();
206
        assert_eq!(result.matched_range, MatchRange::Chars(vec![0, 1, 3, 4]));
207
    }
208
209
    #[test]
210
    fn after_engine_failure_returns_none() {
211
        let engine = SplitMatchEngine::new(exact("ab"), exact("zzz"), ':');
212
        assert!(engine.match_item(&"ab:cd".to_string()).is_none());
213
    }
214
215
    #[test]
216
    fn byte_range_results_drop_chars_outside_range() {
217
        // ByteRange(1, 2) over the three-char "abc"/"def" parts covers only the
218
        // middle byte: 'a'/'d' (byte 0) fail the `>= start` check, 'c'/'f'
219
        // (byte 2) fail the `< end` check, so only 'b'/'e' survive.
220
        let engine = SplitMatchEngine::new(
221
            Box::new(StubEngine(MatchRange::ByteRange(1, 2))),
222
            Box::new(StubEngine(MatchRange::ByteRange(1, 2))),
223
            ':',
224
        );
225
        let result = engine.match_item(&"abc:def".to_string()).unwrap();
226
        // before: char 1 ('b'); after: char 1 of "def" offset past "abc:" → char 5 ('e').
227
        assert_eq!(result.matched_range, MatchRange::Chars(vec![1, 5]));
228
    }
229
230
    #[test]
231
    fn byte_range_results_include_chars_within_range() {
232
        // ByteRange(0, 2) covers both chars of each part, so nothing is dropped.
233
        let engine = SplitMatchEngine::new(
234
            Box::new(StubEngine(MatchRange::ByteRange(0, 2))),
235
            Box::new(StubEngine(MatchRange::ByteRange(0, 2))),
236
            ':',
237
        );
238
        let result = engine.match_item(&"ab:cd".to_string()).unwrap();
239
        assert_eq!(result.matched_range, MatchRange::Chars(vec![0, 1, 3, 4]));
240
    }
241
242
    #[test]
243
    fn display_shows_both_engines_and_delimiter() {
244
        let engine = SplitMatchEngine::new(exact("a"), exact("b"), ':');
245
        let s = format!("{engine}");
246
        assert!(s.starts_with("(Split[:]:"));
247
    }
248
249
    #[test]
250
    fn factory_without_delimiter_passes_through() {
251
        let factory = SplitMatchEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build(), ':');
252
        let engine = factory.create_engine_with_case("foo", crate::CaseMatching::Smart);
253
        // Plain query, no delimiter → behaves like the inner engine.
254
        assert!(engine.match_item(&"foobar".to_string()).is_some());
255
    }
256
257
    #[test]
258
    fn factory_with_delimiter_builds_split_engine() {
259
        let factory = SplitMatchEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build(), ':');
260
        let engine = factory.create_engine_with_case("ab:cd", crate::CaseMatching::Smart);
261
        assert!(engine.match_item(&"ab:cd".to_string()).is_some());
262
        assert!(engine.match_item(&"ab:xy".to_string()).is_none());
263
    }
264
}
\ No newline at end of file +
139
32
            let query_before = &query[..delimiter_pos];
140
32
            let query_after = &query[delimiter_pos + self.delimiter.len_utf8()..];
141
142
            // Create engines for each part using the inner factory
143
32
            let before_engine = self.inner.create_engine_with_case(query_before, case);
144
32
            let after_engine = self.inner.create_engine_with_case(query_after, case);
145
146
32
            Box::new(SplitMatchEngine::new(before_engine, after_engine, self.delimiter))
147
        } else {
148
            // No delimiter in query, pass through to inner factory
149
31
            self.inner.create_engine_with_case(query, case)
150
        }
151
63
    }
152
}
153
154
#[cfg(test)]
155
#[cfg_attr(coverage, coverage(off))]
156
mod tests {
157
    use super::*;
158
    use crate::engine::exact::{ExactEngine, ExactMatchingParam};
159
    use crate::prelude::ExactOrFuzzyEngineFactory;
160
161
    /// A stub engine that returns a fixed match range, regardless of the item.
162
    struct StubEngine(MatchRange);
163
164
    impl MatchEngine for StubEngine {
165
        fn match_item(&self, _item: &dyn SkimItem) -> Option<MatchResult> {
166
            Some(MatchResult {
167
                rank: crate::Rank::default(),
168
                matched_range: self.0.clone(),
169
            })
170
        }
171
    }
172
173
    impl Display for StubEngine {
174
        fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
175
            write!(f, "Stub")
176
        }
177
    }
178
179
    fn exact(query: &str) -> Box<dyn MatchEngine> {
180
        Box::new(ExactEngine::builder(query, ExactMatchingParam::default()).build())
181
    }
182
183
    #[test]
184
    fn no_delimiter_in_item_returns_none() {
185
        let engine = SplitMatchEngine::new(exact("a"), exact("b"), ':');
186
        assert!(engine.match_item(&"no delimiter here".to_string()).is_none());
187
    }
188
189
    #[test]
190
    fn matches_both_sides_of_delimiter() {
191
        let engine = SplitMatchEngine::new(exact("ab"), exact("cd"), ':');
192
        let result = engine.match_item(&"ab:cd".to_string());
193
        assert!(result.is_some());
194
    }
195
196
    #[test]
197
    fn char_range_results_are_offset_and_combined() {
198
        // Before matches chars 0..2 of "ab"; after matches chars 0..2 of "cd"
199
        // which become 3..5 after the delimiter offset.
200
        let engine = SplitMatchEngine::new(
201
            Box::new(StubEngine(MatchRange::CharRange(0, 2))),
202
            Box::new(StubEngine(MatchRange::CharRange(0, 2))),
203
            ':',
204
        );
205
        let result = engine.match_item(&"ab:cd".to_string()).unwrap();
206
        assert_eq!(result.matched_range, MatchRange::Chars(vec![0, 1, 3, 4]));
207
    }
208
209
    #[test]
210
    fn after_engine_failure_returns_none() {
211
        let engine = SplitMatchEngine::new(exact("ab"), exact("zzz"), ':');
212
        assert!(engine.match_item(&"ab:cd".to_string()).is_none());
213
    }
214
215
    #[test]
216
    fn byte_range_results_drop_chars_outside_range() {
217
        // ByteRange(1, 2) over the three-char "abc"/"def" parts covers only the
218
        // middle byte: 'a'/'d' (byte 0) fail the `>= start` check, 'c'/'f'
219
        // (byte 2) fail the `< end` check, so only 'b'/'e' survive.
220
        let engine = SplitMatchEngine::new(
221
            Box::new(StubEngine(MatchRange::ByteRange(1, 2))),
222
            Box::new(StubEngine(MatchRange::ByteRange(1, 2))),
223
            ':',
224
        );
225
        let result = engine.match_item(&"abc:def".to_string()).unwrap();
226
        // before: char 1 ('b'); after: char 1 of "def" offset past "abc:" → char 5 ('e').
227
        assert_eq!(result.matched_range, MatchRange::Chars(vec![1, 5]));
228
    }
229
230
    #[test]
231
    fn byte_range_results_include_chars_within_range() {
232
        // ByteRange(0, 2) covers both chars of each part, so nothing is dropped.
233
        let engine = SplitMatchEngine::new(
234
            Box::new(StubEngine(MatchRange::ByteRange(0, 2))),
235
            Box::new(StubEngine(MatchRange::ByteRange(0, 2))),
236
            ':',
237
        );
238
        let result = engine.match_item(&"ab:cd".to_string()).unwrap();
239
        assert_eq!(result.matched_range, MatchRange::Chars(vec![0, 1, 3, 4]));
240
    }
241
242
    #[test]
243
    fn display_shows_both_engines_and_delimiter() {
244
        let engine = SplitMatchEngine::new(exact("a"), exact("b"), ':');
245
        let s = format!("{engine}");
246
        assert!(s.starts_with("(Split[:]:"));
247
    }
248
249
    #[test]
250
    fn factory_without_delimiter_passes_through() {
251
        let factory = SplitMatchEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build(), ':');
252
        let engine = factory.create_engine_with_case("foo", crate::CaseMatching::Smart);
253
        // Plain query, no delimiter → behaves like the inner engine.
254
        assert!(engine.match_item(&"foobar".to_string()).is_some());
255
    }
256
257
    #[test]
258
    fn factory_with_delimiter_builds_split_engine() {
259
        let factory = SplitMatchEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build(), ':');
260
        let engine = factory.create_engine_with_case("ab:cd", crate::CaseMatching::Smart);
261
        assert!(engine.match_item(&"ab:cd".to_string()).is_some());
262
        assert!(engine.match_item(&"ab:xy".to_string()).is_none());
263
    }
264
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/engine/util.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/engine/util.rs.html index b9284235..3c09000d 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/engine/util.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/engine/util.rs.html @@ -1,6 +1,6 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/util.rs
Line
Count
Source
1
use crate::fuzzy_matcher::MatchIndices;
2
use regex::Regex;
3
use unicode_normalization::UnicodeNormalization;
4
5
/// Normalize a string and return a mapping from normalized char indices to original char indices.
6
///
7
/// Returns (`normalized_string`, mapping) where mapping[i] gives the original char index
8
/// for the i-th character in the normalized string.
9
380
pub fn normalize_with_char_mapping(s: &str) -> (String, Vec<usize>) {
10
380
    let mut normalized = String::new();
11
380
    let mut mapping = Vec::new();
12
13
1.78k
    for (orig_char_idx, orig_char) in 
s380
.
chars380
().
enumerate380
() {
14
        // Decompose this character into NFD form
15
1.93k
        for decomposed_char in 
orig_char1.78k
.
nfd1.78k
() {
16
1.93k
            if !unicode_normalization::char::is_combining_mark(decomposed_char) {
  Branch (16:16): [True: 1.74k, False: 146]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/util.rs
Line
Count
Source
1
use crate::fuzzy_matcher::MatchIndices;
2
use regex::Regex;
3
use unicode_normalization::UnicodeNormalization;
4
5
/// Normalize a string and return a mapping from normalized char indices to original char indices.
6
///
7
/// Returns (`normalized_string`, mapping) where mapping[i] gives the original char index
8
/// for the i-th character in the normalized string.
9
379
pub fn normalize_with_char_mapping(s: &str) -> (String, Vec<usize>) {
10
379
    let mut normalized = String::new();
11
379
    let mut mapping = Vec::new();
12
13
1.78k
    for (orig_char_idx, orig_char) in 
s379
.
chars379
().
enumerate379
() {
14
        // Decompose this character into NFD form
15
1.93k
        for decomposed_char in 
orig_char1.78k
.
nfd1.78k
() {
16
1.93k
            if !unicode_normalization::char::is_combining_mark(decomposed_char) {
  Branch (16:16): [True: 1.74k, False: 146]
 
  Branch (16:16): [True: 37, False: 6]
-
17
1.78k
                normalized.push(decomposed_char);
18
1.78k
                mapping.push(orig_char_idx);
19
1.78k
            
}152
20
        }
21
    }
22
23
380
    (normalized, mapping)
24
380
}
25
26
/// Map character indices from normalized string back to original string.
27
///
28
/// Given indices into a normalized string and the char mapping from `normalize_with_char_mapping`,
29
/// returns the corresponding indices in the original string.
30
113
pub fn map_char_indices_to_original(normalized_indices: &[usize], char_mapping: &[usize]) -> MatchIndices {
31
113
    normalized_indices
32
113
        .iter()
33
327
        .
filter_map113
(|&idx| char_mapping.get(idx).copied())
34
113
        .collect()
35
113
}
36
37
/// Normalize a string and return a mapping from normalized byte positions to original byte positions.
38
///
39
/// Returns (`normalized_string`, `byte_mapping`) where `byte_mapping`[i] gives the original byte position
40
/// for the i-th byte in the normalized string.
41
293
pub fn normalize_with_byte_mapping(s: &str) -> (String, Vec<usize>) {
42
293
    let mut normalized = String::new();
43
293
    let mut byte_mapping = Vec::new();
44
45
1.56k
    for (orig_byte_pos, orig_char) in 
s293
.
char_indices293
() {
46
        // Decompose this character into NFD form
47
1.71k
        for decomposed_char in 
orig_char1.56k
.
nfd1.56k
() {
48
1.71k
            if !unicode_normalization::char::is_combining_mark(decomposed_char) {
  Branch (48:16): [True: 1.53k, False: 145]
+
17
1.78k
                normalized.push(decomposed_char);
18
1.78k
                mapping.push(orig_char_idx);
19
1.78k
            
}152
20
        }
21
    }
22
23
379
    (normalized, mapping)
24
379
}
25
26
/// Map character indices from normalized string back to original string.
27
///
28
/// Given indices into a normalized string and the char mapping from `normalize_with_char_mapping`,
29
/// returns the corresponding indices in the original string.
30
113
pub fn map_char_indices_to_original(normalized_indices: &[usize], char_mapping: &[usize]) -> MatchIndices {
31
113
    normalized_indices
32
113
        .iter()
33
327
        .
filter_map113
(|&idx| char_mapping.get(idx).copied())
34
113
        .collect()
35
113
}
36
37
/// Normalize a string and return a mapping from normalized byte positions to original byte positions.
38
///
39
/// Returns (`normalized_string`, `byte_mapping`) where `byte_mapping`[i] gives the original byte position
40
/// for the i-th byte in the normalized string.
41
293
pub fn normalize_with_byte_mapping(s: &str) -> (String, Vec<usize>) {
42
293
    let mut normalized = String::new();
43
293
    let mut byte_mapping = Vec::new();
44
45
1.56k
    for (orig_byte_pos, orig_char) in 
s293
.
char_indices293
() {
46
        // Decompose this character into NFD form
47
1.71k
        for decomposed_char in 
orig_char1.56k
.
nfd1.56k
() {
48
1.71k
            if !unicode_normalization::char::is_combining_mark(decomposed_char) {
  Branch (48:16): [True: 1.53k, False: 145]
 
  Branch (48:16): [True: 30, False: 6]
 
49
1.56k
                let char_start = normalized.len();
50
1.56k
                normalized.push(decomposed_char);
51
                // Map each byte of the decomposed char to the original byte position
52
1.63k
                for _ in 
char_start1.56k
..normalized.len() {
53
1.63k
                    byte_mapping.push(orig_byte_pos);
54
1.63k
                }
55
151
            }
56
        }
57
    }
58
59
293
    (normalized, byte_mapping)
60
293
}
61
62
/// Map a byte range from normalized string back to original string.
63
///
64
/// Given a (start, end) byte range in a normalized string and the byte mapping,
65
/// returns the corresponding (start, end) byte range in the original string.
66
90
pub fn map_byte_range_to_original(
67
90
    normalized_start: usize,
68
90
    normalized_end: usize,
69
90
    byte_mapping: &[usize],
70
90
    original_str: &str,
71
90
) -> (usize, usize) {
72
90
    if byte_mapping.is_empty() || 
normalized_start89
>= byte_mapping.len() {
  Branch (72:8): [True: 0, False: 83]
   Branch (72:35): [True: 0, False: 83]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/field.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/field.rs.html
index 42392c1c..c2a5c98e 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/field.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/field.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/field.rs
Line
Count
Source
1
//! Field extraction and parsing utilities.
2
//!
3
//! This module provides utilities for parsing field ranges and extracting
4
//! fields from text based on delimiters.
5
6
use regex::Regex;
7
use std::cmp::{max, min};
8
use std::sync::LazyLock;
9
10
static FIELD_RANGE: LazyLock<Regex> =
11
64
    LazyLock::new(|| Regex::new(r"^(?P<left>-?\d+)?(?P<sep>\.\.)?(?P<right>-?\d+)?$").unwrap());
12
13
/// Represents a range of fields to extract from text
14
#[derive(PartialEq, Eq, Clone, Debug)]
15
pub enum FieldRange {
16
    /// A single field at the given index
17
    Single(i32),
18
    /// All fields from the start up to and including the given index
19
    LeftInf(i32),
20
    /// All fields from the given index to the end
21
    RightInf(i32),
22
    /// Fields between two indices (inclusive)
23
    Both(i32, i32),
24
}
25
26
/// Parses one side of a field range. The regex only ever hands us `-?\d+`, so the
27
/// single failure mode is overflowing `i32`; saturate instead of silently falling
28
/// back to a different field.
29
95
fn parse_index(s: &str) -> i32 {
30
95
    s.parse()
31
95
        .unwrap_or(if s.starts_with('-') { 
i32::MIN22
} else {
i32::MAX73
})
  Branch (31:23): [True: 12, False: 52]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/field.rs
Line
Count
Source
1
//! Field extraction and parsing utilities.
2
//!
3
//! This module provides utilities for parsing field ranges and extracting
4
//! fields from text based on delimiters.
5
6
use regex::Regex;
7
use std::cmp::{max, min};
8
use std::sync::LazyLock;
9
10
static FIELD_RANGE: LazyLock<Regex> =
11
64
    LazyLock::new(|| Regex::new(r"^(?P<left>-?\d+)?(?P<sep>\.\.)?(?P<right>-?\d+)?$").unwrap());
12
13
/// Represents a range of fields to extract from text
14
#[derive(PartialEq, Eq, Clone, Debug)]
15
pub enum FieldRange {
16
    /// A single field at the given index
17
    Single(i32),
18
    /// All fields from the start up to and including the given index
19
    LeftInf(i32),
20
    /// All fields from the given index to the end
21
    RightInf(i32),
22
    /// Fields between two indices (inclusive)
23
    Both(i32, i32),
24
}
25
26
/// Parses one side of a field range. The regex only ever hands us `-?\d+`, so the
27
/// single failure mode is overflowing `i32`; saturate instead of silently falling
28
/// back to a different field.
29
95
fn parse_index(s: &str) -> i32 {
30
95
    s.parse()
31
95
        .unwrap_or(if s.starts_with('-') { 
i32::MIN22
} else {
i32::MAX73
})
  Branch (31:23): [True: 12, False: 52]
 
  Branch (31:23): [True: 10, False: 21]
 
32
95
}
33
34
impl FieldRange {
35
    /// Parses a field range from a string (e.g., "1", "1..", "..10", "1..10")
36
    #[allow(clippy::should_implement_trait)]
37
95
    pub fn from_str(range: &str) -> Option<FieldRange> {
38
        use self::FieldRange::{Both, LeftInf, RightInf, Single};
39
40
        // "1", "1..", "..10", "1..10", etc.
41
95
        let opt_caps = FIELD_RANGE.captures(range);
42
95
        if let Some(
caps88
) = opt_caps {
  Branch (42:16): [True: 59, False: 0]
 
  Branch (42:16): [True: 29, False: 7]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/algo.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/algo.rs.html
index 8075a14b..7782fc13 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/algo.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/algo.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/algo.rs
Line
Count
Source
1
//! Arinae's algo itself
2
3
use std::cell::RefCell;
4
5
use thread_local::ThreadLocal;
6
7
use crate::fuzzy_matcher::{IndexType, MatchIndices};
8
9
use super::banding::{BandingInfo, typo_vband_row};
10
use super::constants::{
11
    CONSECUTIVE_BONUS, GAP_EXTEND, GAP_OPEN, MATCH_BONUS, MAX_PAT_LEN, MISMATCH_PENALTY, TYPO_PENALTY,
12
};
13
use super::{Atom, CELL_ZERO, Cell, Dir, SWMatrix, Score};
14
15
/// Core cell scoring kernel shared by both score-only and full DP.
16
///
17
/// Computes the best score and direction for a single DP cell from its
18
/// three neighbours (diagonal, up, left). The caller is responsible for
19
/// fetching the neighbour values from whatever storage layout it uses.
20
///
21
/// Returns `(best_score, direction)`. The direction is `Dir::None` when
22
/// `best_score <= 0`.
23
///
24
/// This function is written in a branchless style: all scoring arithmetic
25
/// uses `bool as Score` multipliers and `max` instead of if/else, and the
26
/// final direction is selected via a branchless cascade of conditional moves.
27
#[inline(always)]
28
#[allow(clippy::too_many_arguments)]
29
#[allow(clippy::fn_params_excessive_bools)]
30
63.5k
fn compute_cell<const ALLOW_TYPOS: bool>(
31
63.5k
    is_match: bool,
32
63.5k
    is_first: bool,
33
63.5k
    bonus_j: Score,
34
63.5k
    diag_score: Score,
35
63.5k
    diag_was_diag: bool,
36
63.5k
    up_score: Score,
37
63.5k
    left_score: Score,
38
63.5k
    left_was_diag: bool,
39
63.5k
) -> (Score, Dir) {
40
    // --- Bonus (branchless) ---
41
    // consecutive bonus added when diag_was_diag, first-char multiplier doubles the bonus.
42
    // `bool as Score` is 0 or 1 — no branch.
43
63.5k
    let bonus = (bonus_j + CONSECUTIVE_BONUS * Score::from(diag_was_diag)) * (1 + Score::from(is_first));
44
45
    // --- DIAGONAL (branchless) ---
46
    // Match path: diag_score + MATCH_BONUS + bonus, masked by is_match.
47
    // Mismatch path (typos only): diag_score - MISMATCH_PENALTY, masked by !is_match.
48
63.5k
    let match_val = (diag_score + MATCH_BONUS + bonus) * Score::from(is_match);
49
63.5k
    let mismatch_val = if ALLOW_TYPOS {
  Branch (49:27): [True: 0, False: 55.8k]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/algo.rs
Line
Count
Source
1
//! Arinae's algo itself
2
3
use std::cell::RefCell;
4
5
use thread_local::ThreadLocal;
6
7
use crate::fuzzy_matcher::{IndexType, MatchIndices};
8
9
use super::banding::{BandingInfo, typo_vband_row};
10
use super::constants::{
11
    CONSECUTIVE_BONUS, GAP_EXTEND, GAP_OPEN, MATCH_BONUS, MAX_PAT_LEN, MISMATCH_PENALTY, TYPO_PENALTY,
12
};
13
use super::{Atom, CELL_ZERO, Cell, Dir, SWMatrix, Score};
14
15
/// Core cell scoring kernel shared by both score-only and full DP.
16
///
17
/// Computes the best score and direction for a single DP cell from its
18
/// three neighbours (diagonal, up, left). The caller is responsible for
19
/// fetching the neighbour values from whatever storage layout it uses.
20
///
21
/// Returns `(best_score, direction)`. The direction is `Dir::None` when
22
/// `best_score <= 0`.
23
///
24
/// This function is written in a branchless style: all scoring arithmetic
25
/// uses `bool as Score` multipliers and `max` instead of if/else, and the
26
/// final direction is selected via a branchless cascade of conditional moves.
27
#[inline(always)]
28
#[allow(clippy::too_many_arguments)]
29
#[allow(clippy::fn_params_excessive_bools)]
30
63.5k
fn compute_cell<const ALLOW_TYPOS: bool>(
31
63.5k
    is_match: bool,
32
63.5k
    is_first: bool,
33
63.5k
    bonus_j: Score,
34
63.5k
    diag_score: Score,
35
63.5k
    diag_was_diag: bool,
36
63.5k
    up_score: Score,
37
63.5k
    left_score: Score,
38
63.5k
    left_was_diag: bool,
39
63.5k
) -> (Score, Dir) {
40
    // --- Bonus (branchless) ---
41
    // consecutive bonus added when diag_was_diag, first-char multiplier doubles the bonus.
42
    // `bool as Score` is 0 or 1 — no branch.
43
63.5k
    let bonus = (bonus_j + CONSECUTIVE_BONUS * Score::from(diag_was_diag)) * (1 + Score::from(is_first));
44
45
    // --- DIAGONAL (branchless) ---
46
    // Match path: diag_score + MATCH_BONUS + bonus, masked by is_match.
47
    // Mismatch path (typos only): diag_score - MISMATCH_PENALTY, masked by !is_match.
48
63.5k
    let match_val = (diag_score + MATCH_BONUS + bonus) * Score::from(is_match);
49
63.5k
    let mismatch_val = if ALLOW_TYPOS {
  Branch (49:27): [True: 0, False: 55.8k]
 
  Branch (49:27): [True: 2.94k, False: 0]
 
  Branch (49:27): [True: 0, False: 3.18k]
 
  Branch (49:27): [True: 1.58k, False: 0]
@@ -26,7 +26,7 @@
   Branch (79:34): [True: 0, False: 0]
 
  Branch (79:19): [True: 1.58k, False: 0]
   Branch (79:34): [True: 1.36k, False: 216]
-
80
81
    // Branchless cascade: select dir as integer.
82
    // Dir encoding: None=0, Diag=1, Up=2, Left=3.
83
    // Base is Left(3); subtract 1 if Up wins, subtract 2 if Diag wins.
84
63.5k
    let dir_bits: u8 = Dir::Left as u8 - u8::from(up_wins) - u8::from(diag_wins) * 2;
85
    // If best <= 0, force Dir::None (0) — achieved by ANDing with all-zeros.
86
63.5k
    let positive = best > 0;
87
    // When positive: dir_bits; when not: 0 (Dir::None).
88
63.5k
    let dir_val = dir_bits & u8::from(positive).wrapping_neg();
89
90
    // SAFETY: dir_val is in 0..=3 because of the construction above.
91
63.5k
    let dir: Dir = unsafe { std::mem::transmute(dir_val) };
92
93
63.5k
    (best, dir)
94
63.5k
}
95
96
// ---------------------------------------------------------------------------
97
// Full DP with traceback — packed Cell (u32 = score + dir)
98
// ---------------------------------------------------------------------------
99
100
/// Full DP for byte slices using packed cells.
101
///
102
/// Implements two pruning strategies:
103
///
104
/// 1. **Row-range banding** – for each row `i` only compute columns
105
///    `j_lo..=j_hi` that can participate in a valid alignment.
106
///    - Exact mode: bounded by precomputed first/last match columns.
107
///    - Typo mode: bounded by diagonal ± bandwidth.
108
///
109
/// 2. **Interpair max-score pruning** – after processing a row, if no
110
///    column produced a non-zero score, all active alignments for this
111
///    and subsequent rows are dead (since UP/LEFT can only propagate
112
///    existing scores). We track this and allow early termination.
113
#[allow(clippy::too_many_lines)]
114
#[allow(clippy::too_many_arguments)]
115
50.6k
pub(super) fn full_dp<const ALLOW_TYPOS: bool, const COMPUTE_INDICES: bool, C: Atom>(
116
50.6k
    cho: &[C],
117
50.6k
    pat: &[C],
118
50.6k
    bonuses: &[Score],
119
50.6k
    respect_case: bool,
120
50.6k
    full_buf: &ThreadLocal<RefCell<SWMatrix>>,
121
50.6k
    indices_buf: &ThreadLocal<RefCell<MatchIndices>>,
122
50.6k
    use_last_match: bool,
123
50.6k
    banding: &BandingInfo,
124
50.6k
) -> Option<(Score, MatchIndices)> {
125
50.6k
    let n = pat.len();
126
50.6k
    let m = cho.len();
127
128
50.6k
    let j_start = banding.j_first; // earliest match — skip columns before this
129
130
    // Column offset: the matrix stores only columns from j_start onward.
131
    // Matrix column 0 is the left wall (all zeros); matrix column `jm`
132
    // corresponds to original 1-indexed column `j = jm + j_start - 1`.
133
50.6k
    let col_off = j_start - 1; // subtract from original j to get matrix col
134
50.6k
    let mcols = m - col_off + 1; // matrix columns: 0 ..= (m - col_off)
135
136
50.6k
    let mut buf = full_buf
137
50.6k
        .get_or(|| 
RefCell::new354
(
SWMatrix::zero354
(
n + 1354
,
mcols354
)))
138
50.6k
        .borrow_mut();
139
50.6k
    buf.resize(n + 1, mcols);
140
141
    // Hoist pointer and stride before initialization to use raw access.
142
50.6k
    let base_ptr = buf.data.as_mut_ptr();
143
50.6k
    let cols = buf.cols;
144
145
    // Initialize row 0 to CELL_ZERO (all-zero bytes: score=0, dir=None=0).
146
    // Column 0 of each subsequent row is also CELL_ZERO.
147
    // SAFETY: base_ptr points to a valid allocation of (n+1)*cols Cells.
148
    unsafe {
149
        // Row 0: mcols contiguous Cells starting at base_ptr.
150
50.6k
        std::ptr::write_bytes(base_ptr, 0, mcols);
151
        // Column 0 of rows 1..=n: one Cell per row, stride = cols.
152
51.5k
        for i in 
1..=n50.6k
{
153
51.5k
            *base_ptr.add(i * cols) = CELL_ZERO;
154
51.5k
        }
155
    }
156
157
    // base_ptr and cols already set above
158
159
    // Pre-extract row bounds once (avoids repeated unwrap inside the loop).
160
    // For exact mode we copy the arrays out; for typo mode these are unused.
161
50.6k
    let (row_lo_arr, row_hi_arr) = if ALLOW_TYPOS {
  Branch (161:39): [True: 0, False: 0]
+
80
81
    // Branchless cascade: select dir as integer.
82
    // Dir encoding: None=0, Diag=1, Up=2, Left=3.
83
    // Base is Left(3); subtract 1 if Up wins, subtract 2 if Diag wins.
84
63.5k
    let dir_bits: u8 = Dir::Left as u8 - u8::from(up_wins) - u8::from(diag_wins) * 2;
85
    // If best <= 0, force Dir::None (0) — achieved by ANDing with all-zeros.
86
63.5k
    let positive = best > 0;
87
    // When positive: dir_bits; when not: 0 (Dir::None).
88
63.5k
    let dir_val = dir_bits & u8::from(positive).wrapping_neg();
89
90
    // SAFETY: dir_val is in 0..=3 because of the construction above.
91
63.5k
    let dir: Dir = unsafe { std::mem::transmute(dir_val) };
92
93
63.5k
    (best, dir)
94
63.5k
}
95
96
// ---------------------------------------------------------------------------
97
// Full DP with traceback — packed Cell (u32 = score + dir)
98
// ---------------------------------------------------------------------------
99
100
/// Full DP for byte slices using packed cells.
101
///
102
/// Implements two pruning strategies:
103
///
104
/// 1. **Row-range banding** – for each row `i` only compute columns
105
///    `j_lo..=j_hi` that can participate in a valid alignment.
106
///    - Exact mode: bounded by precomputed first/last match columns.
107
///    - Typo mode: bounded by diagonal ± bandwidth.
108
///
109
/// 2. **Interpair max-score pruning** – after processing a row, if no
110
///    column produced a non-zero score, all active alignments for this
111
///    and subsequent rows are dead (since UP/LEFT can only propagate
112
///    existing scores). We track this and allow early termination.
113
#[allow(clippy::too_many_lines)]
114
#[allow(clippy::too_many_arguments)]
115
50.6k
pub(super) fn full_dp<const ALLOW_TYPOS: bool, const COMPUTE_INDICES: bool, C: Atom>(
116
50.6k
    cho: &[C],
117
50.6k
    pat: &[C],
118
50.6k
    bonuses: &[Score],
119
50.6k
    respect_case: bool,
120
50.6k
    full_buf: &ThreadLocal<RefCell<SWMatrix>>,
121
50.6k
    indices_buf: &ThreadLocal<RefCell<MatchIndices>>,
122
50.6k
    use_last_match: bool,
123
50.6k
    banding: &BandingInfo,
124
50.6k
) -> Option<(Score, MatchIndices)> {
125
50.6k
    let n = pat.len();
126
50.6k
    let m = cho.len();
127
128
50.6k
    let j_start = banding.j_first; // earliest match — skip columns before this
129
130
    // Column offset: the matrix stores only columns from j_start onward.
131
    // Matrix column 0 is the left wall (all zeros); matrix column `jm`
132
    // corresponds to original 1-indexed column `j = jm + j_start - 1`.
133
50.6k
    let col_off = j_start - 1; // subtract from original j to get matrix col
134
50.6k
    let mcols = m - col_off + 1; // matrix columns: 0 ..= (m - col_off)
135
136
50.6k
    let mut buf = full_buf
137
50.6k
        .get_or(|| 
RefCell::new353
(
SWMatrix::zero353
(
n + 1353
,
mcols353
)))
138
50.6k
        .borrow_mut();
139
50.6k
    buf.resize(n + 1, mcols);
140
141
    // Hoist pointer and stride before initialization to use raw access.
142
50.6k
    let base_ptr = buf.data.as_mut_ptr();
143
50.6k
    let cols = buf.cols;
144
145
    // Initialize row 0 to CELL_ZERO (all-zero bytes: score=0, dir=None=0).
146
    // Column 0 of each subsequent row is also CELL_ZERO.
147
    // SAFETY: base_ptr points to a valid allocation of (n+1)*cols Cells.
148
    unsafe {
149
        // Row 0: mcols contiguous Cells starting at base_ptr.
150
50.6k
        std::ptr::write_bytes(base_ptr, 0, mcols);
151
        // Column 0 of rows 1..=n: one Cell per row, stride = cols.
152
51.5k
        for i in 
1..=n50.6k
{
153
51.5k
            *base_ptr.add(i * cols) = CELL_ZERO;
154
51.5k
        }
155
    }
156
157
    // base_ptr and cols already set above
158
159
    // Pre-extract row bounds once (avoids repeated unwrap inside the loop).
160
    // For exact mode we copy the arrays out; for typo mode these are unused.
161
50.6k
    let (row_lo_arr, row_hi_arr) = if ALLOW_TYPOS {
  Branch (161:39): [True: 0, False: 0]
 
  Branch (161:39): [True: 0, False: 0]
 
  Branch (161:39): [True: 0, False: 38]
 
  Branch (161:39): [True: 0, False: 50.4k]
@@ -330,7 +330,7 @@
 
  Branch (313:8): [True: 15, False: 0]
 
  Branch (313:8): [True: 0, False: 0]
 
  Branch (313:8): [True: 0, False: 13]
-
314
        // Traceback — j walks in original 1-indexed space, convert to matrix
315
        // column for buf access; output indices in original 0-indexed space.
316
        // Reuse a thread-local Vec to avoid per-call allocation.
317
50.6k
        let indices_ref_cell = indices_buf.get_or(|| 
RefCell::new322
(
Vec::new322
()));
318
50.6k
        let mut indices_ref = indices_ref_cell.borrow_mut();
319
50.6k
        indices_ref.clear();
320
50.6k
        let mut i = n;
321
50.6k
        let mut j = best_j;
322
50.6k
        let mut true_matches = 0usize;
323
324
102k
        while i > 0 && 
j >= j_start51.5k
{
  Branch (324:15): [True: 0, False: 0]
+
314
        // Traceback — j walks in original 1-indexed space, convert to matrix
315
        // column for buf access; output indices in original 0-indexed space.
316
        // Reuse a thread-local Vec to avoid per-call allocation.
317
50.6k
        let indices_ref_cell = indices_buf.get_or(|| 
RefCell::new321
(
Vec::new321
()));
318
50.6k
        let mut indices_ref = indices_ref_cell.borrow_mut();
319
50.6k
        indices_ref.clear();
320
50.6k
        let mut i = n;
321
50.6k
        let mut j = best_j;
322
50.6k
        let mut true_matches = 0usize;
323
324
102k
        while i > 0 && 
j >= j_start51.5k
{
  Branch (324:15): [True: 0, False: 0]
   Branch (324:24): [True: 0, False: 0]
 
  Branch (324:15): [True: 0, False: 0]
   Branch (324:24): [True: 0, False: 0]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/atom.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/atom.rs.html
index c8e89084..081a336e 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/atom.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/atom.rs.html
@@ -1,6 +1,6 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/atom.rs
Line
Count
Source
1
//! Byte/Char helpers
2
use super::Score;
3
use super::constants::SEPARATOR_TABLE;
4
use crate::fuzzy_matcher::util::char_equal;
5
use memchr::{memchr, memrchr};
6
7
pub(super) trait Atom: PartialEq + Into<char> + Copy {
8
    #[inline(always)]
9
725k
    fn eq(self, other: Self, respect_case: bool) -> bool
10
725k
    where
11
725k
        Self: PartialEq + Sized,
12
    {
13
725k
        if respect_case {
  Branch (13:12): [True: 1.07k, False: 718k]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/atom.rs
Line
Count
Source
1
//! Byte/Char helpers
2
use super::Score;
3
use super::constants::SEPARATOR_TABLE;
4
use crate::fuzzy_matcher::util::char_equal;
5
use memchr::{memchr, memrchr};
6
7
pub(super) trait Atom: PartialEq + Into<char> + Copy {
8
    #[inline(always)]
9
725k
    fn eq(self, other: Self, respect_case: bool) -> bool
10
725k
    where
11
725k
        Self: PartialEq + Sized,
12
    {
13
725k
        if respect_case {
  Branch (13:12): [True: 1.07k, False: 718k]
 
  Branch (13:12): [True: 122, False: 6.23k]
-
14
1.19k
            self == other
15
        } else {
16
724k
            self.eq_ignore_case(other)
17
        }
18
725k
    }
19
    fn eq_ignore_case(self, other: Self) -> bool;
20
    fn is_lowercase(self) -> bool;
21
22
    /// Return the index of the first occurrence of `self` in `haystack`,
23
    /// or `None` if not found.
24
    ///
25
    /// Implementations may override this with a SIMD-backed search (e.g.
26
    /// `memchr` for `u8` in case-sensitive mode).
27
    #[inline(always)]
28
18
    fn find_first_in(self, haystack: &[Self], respect_case: bool) -> Option<usize> {
29
26
        
haystack.iter()18
.
position18
(|&c| self.eq(c, respect_case))
30
18
    }
31
32
    /// Return the index of the last occurrence of `self` in `haystack`,
33
    /// or `None` if not found.
34
    ///
35
    /// Implementations may override this with a SIMD-backed search (e.g.
36
    /// `memrchr` for `u8` in case-sensitive mode).
37
    #[inline(always)]
38
131
    fn find_last_in(self, haystack: &[Self], respect_case: bool) -> Option<usize> {
39
275
        
haystack.iter()131
.
rposition131
(|&c| self.eq(c, respect_case))
40
131
    }
41
42
    /// Return the word-separator bonus for this character, or `0` if it is not
43
    /// a separator.  Uses a table lookup — a single bounds check replaces
44
    /// several branches and the returned value encodes both *whether* the
45
    /// character is a separator and *how much* bonus it carries.
46
    #[inline(always)]
47
559k
    fn separator_bonus(self) -> Score {
48
559k
        let ch = self.into() as usize;
49
        // For ch < 128 we do a table lookup; for ch >= 128 we return 0.
50
        // The `get` returns None for out-of-range, and `copied().unwrap_or(0)` is
51
        // typically compiled as a conditional move (branchless).
52
559k
        SEPARATOR_TABLE.get(ch).copied().unwrap_or(0)
53
559k
    }
54
}
55
56
impl Atom for u8 {
57
    #[inline(always)]
58
724k
    fn eq_ignore_case(self, b: Self) -> bool {
59
724k
        self.eq_ignore_ascii_case(&b)
60
724k
    }
61
    #[inline(always)]
62
819k
    fn is_lowercase(self) -> bool {
63
819k
        self.is_ascii_lowercase()
64
819k
    }
65
66
    /// Case-sensitive search uses SIMD-backed `memchr`; case-insensitive
67
    /// falls back to the generic scalar loop.
68
    #[inline(always)]
69
170
    fn find_first_in(self, haystack: &[Self], respect_case: bool) -> Option<usize> {
70
170
        if respect_case {
  Branch (70:12): [True: 0, False: 90]
+
14
1.19k
            self == other
15
        } else {
16
724k
            self.eq_ignore_case(other)
17
        }
18
725k
    }
19
    fn eq_ignore_case(self, other: Self) -> bool;
20
    fn is_lowercase(self) -> bool;
21
22
    /// Return the index of the first occurrence of `self` in `haystack`,
23
    /// or `None` if not found.
24
    ///
25
    /// Implementations may override this with a SIMD-backed search (e.g.
26
    /// `memchr` for `u8` in case-sensitive mode).
27
    #[inline(always)]
28
18
    fn find_first_in(self, haystack: &[Self], respect_case: bool) -> Option<usize> {
29
26
        
haystack.iter()18
.
position18
(|&c| self.eq(c, respect_case))
30
18
    }
31
32
    /// Return the index of the last occurrence of `self` in `haystack`,
33
    /// or `None` if not found.
34
    ///
35
    /// Implementations may override this with a SIMD-backed search (e.g.
36
    /// `memrchr` for `u8` in case-sensitive mode).
37
    #[inline(always)]
38
131
    fn find_last_in(self, haystack: &[Self], respect_case: bool) -> Option<usize> {
39
275
        
haystack.iter()131
.
rposition131
(|&c| self.eq(c, respect_case))
40
131
    }
41
42
    /// Return the word-separator bonus for this character, or `0` if it is not
43
    /// a separator.  Uses a table lookup — a single bounds check replaces
44
    /// several branches and the returned value encodes both *whether* the
45
    /// character is a separator and *how much* bonus it carries.
46
    #[inline(always)]
47
558k
    fn separator_bonus(self) -> Score {
48
558k
        let ch = self.into() as usize;
49
        // For ch < 128 we do a table lookup; for ch >= 128 we return 0.
50
        // The `get` returns None for out-of-range, and `copied().unwrap_or(0)` is
51
        // typically compiled as a conditional move (branchless).
52
558k
        SEPARATOR_TABLE.get(ch).copied().unwrap_or(0)
53
558k
    }
54
}
55
56
impl Atom for u8 {
57
    #[inline(always)]
58
724k
    fn eq_ignore_case(self, b: Self) -> bool {
59
724k
        self.eq_ignore_ascii_case(&b)
60
724k
    }
61
    #[inline(always)]
62
819k
    fn is_lowercase(self) -> bool {
63
819k
        self.is_ascii_lowercase()
64
819k
    }
65
66
    /// Case-sensitive search uses SIMD-backed `memchr`; case-insensitive
67
    /// falls back to the generic scalar loop.
68
    #[inline(always)]
69
170
    fn find_first_in(self, haystack: &[Self], respect_case: bool) -> Option<usize> {
70
170
        if respect_case {
  Branch (70:12): [True: 0, False: 90]
 
  Branch (70:12): [True: 2, False: 78]
 
71
2
            memchr(self, haystack)
72
        } else {
73
            // Case-insensitive: compare lowercase. Also try the uppercase variant
74
            // so a single `memchr` can be used for each case variant.
75
168
            let lo = self.to_ascii_lowercase();
76
168
            let hi = self.to_ascii_uppercase();
77
168
            if lo == hi {
  Branch (77:16): [True: 0, False: 90]
 
  Branch (77:16): [True: 2, False: 76]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/banding.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/banding.rs.html
index 80a9a91a..e9140c21 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/banding.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/banding.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/banding.rs
Line
Count
Source
1
//! Banding utils
2
//! Banding is the process of calculating the pertinent parts of the matrix to our specific
3
//! computation to avoid computing every cell
4
5
use super::atom::Atom;
6
use super::constants::{MAX_PAT_LEN, TYPO_BAND_SLACK};
7
use super::helpers::{compute_last_match_cols, compute_row_col_bounds, find_first_char};
8
9
/// Precomputed banding information shared by both score-only and full DP.
10
#[derive(Clone)]
11
pub(super) struct BandingInfo {
12
    /// Per-row column bounds (only present in exact mode).
13
    pub(super) row_bounds: Option<([usize; MAX_PAT_LEN], [usize; MAX_PAT_LEN])>,
14
    /// 1-indexed column of the first match of `pat[0]` in `cho`.
15
    pub(super) j_first: usize,
16
    /// Bandwidth for typo-mode diagonal banding (0 in exact mode).
17
    pub(super) bandwidth: usize,
18
    /// Minimum number of true (non-substitution) matches to accept.
19
    pub(super) min_true_matches: usize,
20
}
21
22
/// Compute banding information for the DP. Returns `None` if the pattern
23
/// cannot possibly match (e.g. a pattern character has no occurrence).
24
51.3k
pub(super) fn compute_banding<const ALLOW_TYPOS: bool, C: Atom>(
25
51.3k
    pat: &[C],
26
51.3k
    cho: &[C],
27
51.3k
    respect_case: bool,
28
51.3k
) -> Option<BandingInfo> {
29
51.3k
    let n = pat.len();
30
51.3k
    let m = cho.len();
31
32
51.3k
    let (
j_first50.6k
,
row_bounds50.6k
) = if ALLOW_TYPOS {
  Branch (32:36): [True: 0, False: 55]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/banding.rs
Line
Count
Source
1
//! Banding utils
2
//! Banding is the process of calculating the pertinent parts of the matrix to our specific
3
//! computation to avoid computing every cell
4
5
use super::atom::Atom;
6
use super::constants::{MAX_PAT_LEN, TYPO_BAND_SLACK};
7
use super::helpers::{compute_last_match_cols, compute_row_col_bounds, find_first_char};
8
9
/// Precomputed banding information shared by both score-only and full DP.
10
#[derive(Clone)]
11
pub(super) struct BandingInfo {
12
    /// Per-row column bounds (only present in exact mode).
13
    pub(super) row_bounds: Option<([usize; MAX_PAT_LEN], [usize; MAX_PAT_LEN])>,
14
    /// 1-indexed column of the first match of `pat[0]` in `cho`.
15
    pub(super) j_first: usize,
16
    /// Bandwidth for typo-mode diagonal banding (0 in exact mode).
17
    pub(super) bandwidth: usize,
18
    /// Minimum number of true (non-substitution) matches to accept.
19
    pub(super) min_true_matches: usize,
20
}
21
22
/// Compute banding information for the DP. Returns `None` if the pattern
23
/// cannot possibly match (e.g. a pattern character has no occurrence).
24
51.3k
pub(super) fn compute_banding<const ALLOW_TYPOS: bool, C: Atom>(
25
51.3k
    pat: &[C],
26
51.3k
    cho: &[C],
27
51.3k
    respect_case: bool,
28
51.3k
) -> Option<BandingInfo> {
29
51.3k
    let n = pat.len();
30
51.3k
    let m = cho.len();
31
32
51.3k
    let (
j_first50.6k
,
row_bounds50.6k
) = if ALLOW_TYPOS {
  Branch (32:36): [True: 0, False: 55]
 
  Branch (32:36): [True: 0, False: 51.0k]
 
  Branch (32:36): [True: 0, False: 0]
 
  Branch (32:36): [True: 41, False: 0]
@@ -6,7 +6,7 @@
 
  Branch (32:36): [True: 0, False: 75]
 
  Branch (32:36): [True: 8, False: 0]
 
  Branch (32:36): [True: 35, False: 0]
-
33
84
        (find_first_char(pat, cho, respect_case)
?0
, None)
34
    } else {
35
51.2k
        let 
fm50.5k
= compute_first_match_cols(pat, cho, respect_case)
?650
;
36
50.5k
        let lm = compute_last_match_cols(pat, cho, respect_case)
?0
;
37
50.5k
        (fm[0], Some(compute_row_col_bounds(n, m, &fm, &lm)))
38
    };
39
40
50.6k
    let bandwidth = if ALLOW_TYPOS { 
n + TYPO_BAND_SLACK84
} else {
050.5k
};
  Branch (40:24): [True: 0, False: 38]
+
33
84
        (find_first_char(pat, cho, respect_case)
?0
, None)
34
    } else {
35
51.2k
        let 
fm50.5k
= compute_first_match_cols(pat, cho, respect_case)
?648
;
36
50.5k
        let lm = compute_last_match_cols(pat, cho, respect_case)
?0
;
37
50.5k
        (fm[0], Some(compute_row_col_bounds(n, m, &fm, &lm)))
38
    };
39
40
50.6k
    let bandwidth = if ALLOW_TYPOS { 
n + TYPO_BAND_SLACK84
} else {
050.5k
};
  Branch (40:24): [True: 0, False: 38]
 
  Branch (40:24): [True: 0, False: 50.4k]
 
  Branch (40:24): [True: 0, False: 0]
 
  Branch (40:24): [True: 41, False: 0]
@@ -26,4 +26,4 @@
   Branch (73:8): [True: 0, False: 51.0k]
 
  Branch (73:8): [True: 1, False: 14]
   Branch (73:8): [True: 2, False: 73]
-
74
3
        return None;
75
51.2k
    }
76
51.2k
    let mut first = [0usize; MAX_PAT_LEN];
77
51.2k
    let mut start = 0usize; // search from this choice index onward
78
52.1k
    for i in 
0..n51.2k
{
79
608k
        let 
found52.1k
=
cho[start..].iter()52.1k
.
position52.1k
(|&c| pat[i].eq(c, respect_case));
80
        {
81
52.1k
            let 
pos51.4k
= found
?647
;
82
51.4k
            first[i] = start + pos + 1; // 1-indexed column
83
51.4k
            start = start + pos + 1; // next char must be strictly after
84
        }
85
    }
86
50.5k
    Some(first)
87
51.2k
}
\ No newline at end of file +
74
3
        return None;
75
51.2k
    }
76
51.2k
    let mut first = [0usize; MAX_PAT_LEN];
77
51.2k
    let mut start = 0usize; // search from this choice index onward
78
52.1k
    for i in 
0..n51.2k
{
79
608k
        let 
found52.1k
=
cho[start..].iter()52.1k
.
position52.1k
(|&c| pat[i].eq(c, respect_case));
80
        {
81
52.1k
            let 
pos51.4k
= found
?645
;
82
51.4k
            first[i] = start + pos + 1; // 1-indexed column
83
51.4k
            start = start + pos + 1; // next char must be strictly after
84
        }
85
    }
86
50.5k
    Some(first)
87
51.2k
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/helpers.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/helpers.rs.html index 7ebea811..f70f7cf4 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/helpers.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/helpers.rs.html @@ -1,4 +1,4 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/helpers.rs
Line
Count
Source
1
//! &[dyn Atom] manipulation helpers
2
3
use super::Atom;
4
use super::constants::MAX_PAT_LEN;
5
6
/// Find the 1-indexed column of the first occurrence of `pat[0]` in `cho`.
7
///
8
/// Returns `None` if `pat[0]` is not found anywhere (caller should return
9
/// `None`). The position defines the start of the V-shaped banding envelope.
10
/// Uses SIMD-backed `find_first_in` for `u8` slices.
11
#[inline(always)]
12
84
pub(super) fn find_first_char<C: Atom>(pat: &[C], cho: &[C], respect_case: bool) -> Option<usize> {
13
84
    pat[0].find_first_in(cho, respect_case).map(|idx| idx + 1) // 1-indexed
14
84
}
15
16
/// Compute the last column (1-indexed) at which each pattern character can be
17
/// matched, scanning from the end. Used to tighten the diagonal upper bound.
18
50.5k
pub(super) fn compute_last_match_cols<C: Atom>(
19
50.5k
    pat: &[C],
20
50.5k
    cho: &[C],
21
50.5k
    respect_case: bool,
22
50.5k
) -> Option<[usize; MAX_PAT_LEN]> {
23
50.5k
    let n = pat.len();
24
    // Patterns longer than MAX_PAT_LEN cannot be handled by the stack-allocated
25
    // banding arrays.  Return None so the caller skips this choice gracefully.
26
50.5k
    if n > MAX_PAT_LEN {
  Branch (26:8): [True: 0, False: 38]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/helpers.rs
Line
Count
Source
1
//! &[dyn Atom] manipulation helpers
2
3
use super::Atom;
4
use super::constants::MAX_PAT_LEN;
5
6
/// Find the 1-indexed column of the first occurrence of `pat[0]` in `cho`.
7
///
8
/// Returns `None` if `pat[0]` is not found anywhere (caller should return
9
/// `None`). The position defines the start of the V-shaped banding envelope.
10
/// Uses SIMD-backed `find_first_in` for `u8` slices.
11
#[inline(always)]
12
84
pub(super) fn find_first_char<C: Atom>(pat: &[C], cho: &[C], respect_case: bool) -> Option<usize> {
13
84
    pat[0].find_first_in(cho, respect_case).map(|idx| idx + 1) // 1-indexed
14
84
}
15
16
/// Compute the last column (1-indexed) at which each pattern character can be
17
/// matched, scanning from the end. Used to tighten the diagonal upper bound.
18
50.5k
pub(super) fn compute_last_match_cols<C: Atom>(
19
50.5k
    pat: &[C],
20
50.5k
    cho: &[C],
21
50.5k
    respect_case: bool,
22
50.5k
) -> Option<[usize; MAX_PAT_LEN]> {
23
50.5k
    let n = pat.len();
24
    // Patterns longer than MAX_PAT_LEN cannot be handled by the stack-allocated
25
    // banding arrays.  Return None so the caller skips this choice gracefully.
26
50.5k
    if n > MAX_PAT_LEN {
  Branch (26:8): [True: 0, False: 38]
   Branch (26:8): [True: 0, False: 50.4k]
 
  Branch (26:8): [True: 0, False: 9]
   Branch (26:8): [True: 1, False: 67]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/matrix.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/matrix.rs.html
index 08a0e85b..1021997a 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/matrix.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/matrix.rs.html
@@ -1,3 +1,3 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/matrix.rs
Line
Count
Source
1
//! Base structs for the matching algorithm: Cell & `SWMatrix`
2
3
use super::Score;
4
5
/// Direction the optimal path took to reach a cell.
6
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7
#[repr(u8)]
8
#[allow(dead_code)] // variants are constructed via transmute from bits
9
pub(super) enum Dir {
10
    /// No valid path (score == 0).
11
    ///
12
    /// Assigned tag 0 so that `Cell::new(0, Dir::None)` encodes as all-zero
13
    /// bits, allowing boundary rows/columns to be bulk-zeroed with
14
    /// `write_bytes(0)` instead of a scalar loop.
15
    None = 0,
16
    /// Diagonal: match or mismatch (came from [i-1][j-1])
17
    Diag = 1,
18
    /// Up: gap in choice (came from [i-1][j], skip pattern char)
19
    Up = 2,
20
    /// Left: gap in pattern (came from [i][j-1], skip choice char)
21
    Left = 3,
22
}
23
24
/// Packed cell stored as a `u32`: bits [15:0] = score (as u16 bitcast from
25
/// i16), bits [17:16] = direction tag.  This gives 4 bytes per cell with no
26
/// padding and enables branchless direction extraction via bitmask.
27
#[derive(Copy, Clone)]
28
pub(super) struct Cell(u32);
29
30
pub(super) const CELL_ZERO: Cell = Cell::new(0, Dir::None);
31
32
impl std::fmt::Debug for Cell {
33
1
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34
1
        f.debug_struct("Cell")
35
1
            .field("score", &self.score())
36
1
            .field("dir", &self.dir())
37
1
            .finish()
38
1
    }
39
}
40
41
impl Cell {
42
    #[inline(always)]
43
63.5k
    pub(super) const fn new(score: Score, dir: Dir) -> Cell {
44
        // Store score as u16 bits in low 16 bits, dir in bits 16-17.
45
63.5k
        Cell((score.cast_unsigned() as u32) | ((dir as u32) << 16))
46
63.5k
    }
47
    #[inline(always)]
48
183k
    pub(super) fn score(self) -> Score {
49
        // Truncation is intentional: low 16 bits store the score as a bitcast i16.
50
        #[allow(clippy::cast_possible_truncation)]
51
183k
        let low16 = self.0 as u16;
52
183k
        low16.cast_signed()
53
183k
    }
54
    #[inline(always)]
55
51.6k
    pub(super) fn dir(self) -> Dir {
56
        // SAFETY: Dir has repr(u8) with values 0..=3 and we only ever store
57
        // valid Dir values in bits 16-17. Truncation from u32 to u8 is intentional.
58
        #[allow(clippy::cast_possible_truncation)]
59
51.6k
        let tag = (self.0 >> 16) as u8 & 0x3;
60
51.6k
        unsafe { std::mem::transmute(tag) }
61
51.6k
    }
62
    /// Branchless check: true when dir == Diag (tag 1).
63
    #[inline(always)]
64
127k
    pub(super) fn is_diag(self) -> bool {
65
127k
        (self.0 >> 16) & 0x3 == 1
66
127k
    }
67
}
68
69
#[derive(Default, Debug)]
70
pub(super) struct SWMatrix {
71
    pub(super) data: Vec<Cell>,
72
    pub(super) cols: usize,
73
    pub(super) rows: usize,
74
}
75
76
impl SWMatrix {
77
368
    pub fn zero(rows: usize, cols: usize) -> Self {
78
368
        let mut res = SWMatrix::default();
79
368
        res.resize(rows, cols);
80
368
        res
81
368
    }
82
51.0k
    pub fn resize(&mut self, rows: usize, cols: usize) {
83
51.0k
        let needed = rows * cols;
84
51.0k
        if needed > self.data.len() {
  Branch (84:12): [True: 342, False: 50.5k]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/matrix.rs
Line
Count
Source
1
//! Base structs for the matching algorithm: Cell & `SWMatrix`
2
3
use super::Score;
4
5
/// Direction the optimal path took to reach a cell.
6
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7
#[repr(u8)]
8
#[allow(dead_code)] // variants are constructed via transmute from bits
9
pub(super) enum Dir {
10
    /// No valid path (score == 0).
11
    ///
12
    /// Assigned tag 0 so that `Cell::new(0, Dir::None)` encodes as all-zero
13
    /// bits, allowing boundary rows/columns to be bulk-zeroed with
14
    /// `write_bytes(0)` instead of a scalar loop.
15
    None = 0,
16
    /// Diagonal: match or mismatch (came from [i-1][j-1])
17
    Diag = 1,
18
    /// Up: gap in choice (came from [i-1][j], skip pattern char)
19
    Up = 2,
20
    /// Left: gap in pattern (came from [i][j-1], skip choice char)
21
    Left = 3,
22
}
23
24
/// Packed cell stored as a `u32`: bits [15:0] = score (as u16 bitcast from
25
/// i16), bits [17:16] = direction tag.  This gives 4 bytes per cell with no
26
/// padding and enables branchless direction extraction via bitmask.
27
#[derive(Copy, Clone)]
28
pub(super) struct Cell(u32);
29
30
pub(super) const CELL_ZERO: Cell = Cell::new(0, Dir::None);
31
32
impl std::fmt::Debug for Cell {
33
1
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34
1
        f.debug_struct("Cell")
35
1
            .field("score", &self.score())
36
1
            .field("dir", &self.dir())
37
1
            .finish()
38
1
    }
39
}
40
41
impl Cell {
42
    #[inline(always)]
43
63.5k
    pub(super) const fn new(score: Score, dir: Dir) -> Cell {
44
        // Store score as u16 bits in low 16 bits, dir in bits 16-17.
45
63.5k
        Cell((score.cast_unsigned() as u32) | ((dir as u32) << 16))
46
63.5k
    }
47
    #[inline(always)]
48
183k
    pub(super) fn score(self) -> Score {
49
        // Truncation is intentional: low 16 bits store the score as a bitcast i16.
50
        #[allow(clippy::cast_possible_truncation)]
51
183k
        let low16 = self.0 as u16;
52
183k
        low16.cast_signed()
53
183k
    }
54
    #[inline(always)]
55
51.6k
    pub(super) fn dir(self) -> Dir {
56
        // SAFETY: Dir has repr(u8) with values 0..=3 and we only ever store
57
        // valid Dir values in bits 16-17. Truncation from u32 to u8 is intentional.
58
        #[allow(clippy::cast_possible_truncation)]
59
51.6k
        let tag = (self.0 >> 16) as u8 & 0x3;
60
51.6k
        unsafe { std::mem::transmute(tag) }
61
51.6k
    }
62
    /// Branchless check: true when dir == Diag (tag 1).
63
    #[inline(always)]
64
127k
    pub(super) fn is_diag(self) -> bool {
65
127k
        (self.0 >> 16) & 0x3 == 1
66
127k
    }
67
}
68
69
#[derive(Default, Debug)]
70
pub(super) struct SWMatrix {
71
    pub(super) data: Vec<Cell>,
72
    pub(super) cols: usize,
73
    pub(super) rows: usize,
74
}
75
76
impl SWMatrix {
77
367
    pub fn zero(rows: usize, cols: usize) -> Self {
78
367
        let mut res = SWMatrix::default();
79
367
        res.resize(rows, cols);
80
367
        res
81
367
    }
82
51.0k
    pub fn resize(&mut self, rows: usize, cols: usize) {
83
51.0k
        let needed = rows * cols;
84
51.0k
        if needed > self.data.len() {
  Branch (84:12): [True: 341, False: 50.5k]
   Branch (84:12): [True: 82, False: 122]
-
85
424
            self.data.resize(needed, CELL_ZERO);
86
50.6k
        }
87
51.0k
        self.rows = rows;
88
51.0k
        self.cols = cols;
89
51.0k
    }
90
}
91
92
#[cfg(test)]
93
#[cfg_attr(coverage, coverage(off))]
94
mod tests {
95
    use super::*;
96
97
    #[test]
98
    fn cell_packs_score_and_direction() {
99
        let cell = Cell::new(42, Dir::Diag);
100
        assert_eq!(cell.score(), 42);
101
        assert_eq!(cell.dir(), Dir::Diag);
102
        assert!(cell.is_diag());
103
104
        // Negative scores round-trip through the i16 bitcast.
105
        let neg = Cell::new(-7, Dir::Up);
106
        assert_eq!(neg.score(), -7);
107
        assert_eq!(neg.dir(), Dir::Up);
108
        assert!(!neg.is_diag());
109
    }
110
111
    #[test]
112
    fn cell_zero_is_none_direction() {
113
        assert_eq!(CELL_ZERO.score(), 0);
114
        assert_eq!(CELL_ZERO.dir(), Dir::None);
115
    }
116
117
    #[test]
118
    fn cell_debug_shows_score_and_dir() {
119
        let s = format!("{:?}", Cell::new(5, Dir::Left));
120
        assert!(s.contains("Cell"));
121
        assert!(s.contains("score"));
122
        assert!(s.contains("Left"));
123
    }
124
125
    #[test]
126
    fn matrix_zero_and_resize_grow() {
127
        let mut m = SWMatrix::zero(2, 3);
128
        assert_eq!(m.rows, 2);
129
        assert_eq!(m.cols, 3);
130
        assert!(m.data.len() >= 6);
131
132
        // Growing increases the backing storage.
133
        m.resize(4, 4);
134
        assert_eq!(m.rows, 4);
135
        assert_eq!(m.cols, 4);
136
        assert!(m.data.len() >= 16);
137
138
        // Shrinking keeps the (larger) allocation but updates dims.
139
        m.resize(1, 1);
140
        assert_eq!(m.rows, 1);
141
        assert_eq!(m.cols, 1);
142
    }
143
}
\ No newline at end of file +
85
423
            self.data.resize(needed, CELL_ZERO);
86
50.6k
        }
87
51.0k
        self.rows = rows;
88
51.0k
        self.cols = cols;
89
51.0k
    }
90
}
91
92
#[cfg(test)]
93
#[cfg_attr(coverage, coverage(off))]
94
mod tests {
95
    use super::*;
96
97
    #[test]
98
    fn cell_packs_score_and_direction() {
99
        let cell = Cell::new(42, Dir::Diag);
100
        assert_eq!(cell.score(), 42);
101
        assert_eq!(cell.dir(), Dir::Diag);
102
        assert!(cell.is_diag());
103
104
        // Negative scores round-trip through the i16 bitcast.
105
        let neg = Cell::new(-7, Dir::Up);
106
        assert_eq!(neg.score(), -7);
107
        assert_eq!(neg.dir(), Dir::Up);
108
        assert!(!neg.is_diag());
109
    }
110
111
    #[test]
112
    fn cell_zero_is_none_direction() {
113
        assert_eq!(CELL_ZERO.score(), 0);
114
        assert_eq!(CELL_ZERO.dir(), Dir::None);
115
    }
116
117
    #[test]
118
    fn cell_debug_shows_score_and_dir() {
119
        let s = format!("{:?}", Cell::new(5, Dir::Left));
120
        assert!(s.contains("Cell"));
121
        assert!(s.contains("score"));
122
        assert!(s.contains("Left"));
123
    }
124
125
    #[test]
126
    fn matrix_zero_and_resize_grow() {
127
        let mut m = SWMatrix::zero(2, 3);
128
        assert_eq!(m.rows, 2);
129
        assert_eq!(m.cols, 3);
130
        assert!(m.data.len() >= 6);
131
132
        // Growing increases the backing storage.
133
        m.resize(4, 4);
134
        assert_eq!(m.rows, 4);
135
        assert_eq!(m.cols, 4);
136
        assert!(m.data.len() >= 16);
137
138
        // Shrinking keeps the (larger) allocation but updates dims.
139
        m.resize(1, 1);
140
        assert_eq!(m.rows, 1);
141
        assert_eq!(m.cols, 1);
142
    }
143
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/mod.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/mod.rs.html index 41120c1d..e35ca72c 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/mod.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/mod.rs.html @@ -1,8 +1,8 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/mod.rs
Line
Count
Source
1
//! Arinae fuzzy matching algorithm.
2
//!
3
//! Uses a Smith-Waterman local alignment approach with affine gap penalties
4
//! and context-sensitive bonuses.
5
//!
6
//! ## Key design choices
7
//!
8
//! - **Single score per cell** (u16 saturating) plus a 2-bit direction tag
9
//!   for traceback. Gap open vs extend is tracked via the direction tag.
10
//! - **Semi-global alignment**: the pattern must be fully consumed, but
11
//!   alignment can start/end at any position in the choice string.
12
//!
13
//!
14
//! ## Pruning strategies
15
//!
16
//! - **Row-range banding**: each DP cell is only computed when the row/column
17
//!   pair falls within the feasible alignment band. In exact mode the band is
18
//!   derived from precomputed first/last match columns for each pattern
19
//!   character; in typo mode a diagonal ± bandwidth envelope is used.
20
//! - **Interpair max-score pruning**: after processing a column (score-only)
21
//!   or row (full DP), if all cells are zero for several consecutive
22
//!   iterations, the alignment is dead and we terminate early.
23
24
#![allow(clippy::inline_always)]
25
26
mod algo;
27
mod atom;
28
mod banding;
29
mod constants;
30
mod helpers;
31
mod matrix;
32
mod prefilter;
33
#[cfg(test)]
34
mod tests;
35
36
use std::cell::RefCell;
37
38
use thread_local::ThreadLocal;
39
40
use self::algo::{full_dp, range_dp};
41
use self::atom::Atom;
42
use self::banding::{BandingInfo, compute_banding};
43
use self::constants::{CAMEL_CASE_BONUS, START_OF_STRING_BONUS};
44
use self::prefilter::cheap_typo_prefilter;
45
46
use self::matrix::{CELL_ZERO, Cell, Dir, SWMatrix};
47
use crate::CaseMatching;
48
use crate::fuzzy_matcher::{FuzzyMatcher, MatchIndices, ScoreType};
49
50
type Score = i16;
51
52
50.6k
fn precompute_bonuses<C: Atom>(cho: &[C], buf: &mut Vec<Score>) {
53
    // Reset length (O(1), no deallocation) then fill with fresh values.
54
50.6k
    buf.clear();
55
    // The first character always gets START_OF_STRING_BONUS.
56
    // Subsequent characters get a bonus based on the previous character:
57
    //   - separator_bonus() when the previous char is a separator (the exact
58
    //     bonus depends on the separator — see SEPARATOR_TABLE in constants.rs),
59
    //   - CAMEL_CASE_BONUS when transitioning from lowercase to non-lowercase.
60
    // Using a safe iterator lets the compiler auto-vectorise the loop.
61
559k
    let 
bonus_iter50.6k
=
std::iter::once50.6k
(START_OF_STRING_BONUS).
chain50.6k
(
cho50.6k
.
windows50.6k
(2).
map50.6k
(|w| {
62
559k
        let prev = w[0];
63
559k
        let cur = w[1];
64
559k
        prev.separator_bonus() + CAMEL_CASE_BONUS * Score::from(prev.is_lowercase() && 
!cur.is_lowercase()208k
)
  Branch (64:65): [True: 111, False: 40]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/mod.rs
Line
Count
Source
1
//! Arinae fuzzy matching algorithm.
2
//!
3
//! Uses a Smith-Waterman local alignment approach with affine gap penalties
4
//! and context-sensitive bonuses.
5
//!
6
//! ## Key design choices
7
//!
8
//! - **Single score per cell** (u16 saturating) plus a 2-bit direction tag
9
//!   for traceback. Gap open vs extend is tracked via the direction tag.
10
//! - **Semi-global alignment**: the pattern must be fully consumed, but
11
//!   alignment can start/end at any position in the choice string.
12
//!
13
//!
14
//! ## Pruning strategies
15
//!
16
//! - **Row-range banding**: each DP cell is only computed when the row/column
17
//!   pair falls within the feasible alignment band. In exact mode the band is
18
//!   derived from precomputed first/last match columns for each pattern
19
//!   character; in typo mode a diagonal ± bandwidth envelope is used.
20
//! - **Interpair max-score pruning**: after processing a column (score-only)
21
//!   or row (full DP), if all cells are zero for several consecutive
22
//!   iterations, the alignment is dead and we terminate early.
23
24
#![allow(clippy::inline_always)]
25
26
mod algo;
27
mod atom;
28
mod banding;
29
mod constants;
30
mod helpers;
31
mod matrix;
32
mod prefilter;
33
#[cfg(test)]
34
mod tests;
35
36
use std::cell::RefCell;
37
38
use thread_local::ThreadLocal;
39
40
use self::algo::{full_dp, range_dp};
41
use self::atom::Atom;
42
use self::banding::{BandingInfo, compute_banding};
43
use self::constants::{CAMEL_CASE_BONUS, START_OF_STRING_BONUS};
44
use self::prefilter::cheap_typo_prefilter;
45
46
use self::matrix::{CELL_ZERO, Cell, Dir, SWMatrix};
47
use crate::CaseMatching;
48
use crate::fuzzy_matcher::{FuzzyMatcher, MatchIndices, ScoreType};
49
50
type Score = i16;
51
52
50.6k
fn precompute_bonuses<C: Atom>(cho: &[C], buf: &mut Vec<Score>) {
53
    // Reset length (O(1), no deallocation) then fill with fresh values.
54
50.6k
    buf.clear();
55
    // The first character always gets START_OF_STRING_BONUS.
56
    // Subsequent characters get a bonus based on the previous character:
57
    //   - separator_bonus() when the previous char is a separator (the exact
58
    //     bonus depends on the separator — see SEPARATOR_TABLE in constants.rs),
59
    //   - CAMEL_CASE_BONUS when transitioning from lowercase to non-lowercase.
60
    // Using a safe iterator lets the compiler auto-vectorise the loop.
61
558k
    let 
bonus_iter50.6k
=
std::iter::once50.6k
(START_OF_STRING_BONUS).
chain50.6k
(
cho50.6k
.
windows50.6k
(2).
map50.6k
(|w| {
62
558k
        let prev = w[0];
63
558k
        let cur = w[1];
64
558k
        prev.separator_bonus() + CAMEL_CASE_BONUS * Score::from(prev.is_lowercase() && 
!cur.is_lowercase()208k
)
  Branch (64:65): [True: 111, False: 40]
   Branch (64:65): [True: 206k, False: 350k]
 
  Branch (64:65): [True: 127, False: 36]
   Branch (64:65): [True: 1.07k, False: 252]
-
65
559k
    }));
66
50.6k
    buf.extend(bonus_iter);
67
50.6k
}
68
69
/// Arinae fuzzy matcher: Smith-Waterman local alignment with affine gap
70
/// penalties and context-sensitive bonuses.
71
#[derive(Debug, Default)]
72
pub struct ArinaeMatcher {
73
    pub(crate) case: CaseMatching,
74
    pub(crate) allow_typos: bool,
75
    pub(crate) use_last_match: bool,
76
77
    full_buf: ThreadLocal<RefCell<SWMatrix>>,
78
    indices_buf: ThreadLocal<RefCell<MatchIndices>>,
79
    #[allow(clippy::type_complexity)]
80
    char_buf: ThreadLocal<RefCell<(Vec<char>, Vec<char>)>>,
81
    bonus_buf: ThreadLocal<RefCell<Vec<Score>>>,
82
}
83
84
impl ArinaeMatcher {
85
    /// Create a new `ArinaeMatcher` with the given settings.
86
    #[must_use]
87
538
    pub fn new(case: CaseMatching, allow_typos: bool, use_last_match: bool) -> Self {
88
538
        Self {
89
538
            case,
90
538
            allow_typos,
91
538
            use_last_match,
92
538
            ..Default::default()
93
538
        }
94
538
    }
95
96
    #[inline(always)]
97
51.3k
    fn respect_case<C: Atom>(&self, pattern: &[C]) -> bool {
98
51.3k
        self.case == CaseMatching::Respect
  Branch (98:9): [True: 0, False: 55]
+
65
558k
    }));
66
50.6k
    buf.extend(bonus_iter);
67
50.6k
}
68
69
/// Arinae fuzzy matcher: Smith-Waterman local alignment with affine gap
70
/// penalties and context-sensitive bonuses.
71
#[derive(Debug, Default)]
72
pub struct ArinaeMatcher {
73
    pub(crate) case: CaseMatching,
74
    pub(crate) allow_typos: bool,
75
    pub(crate) use_last_match: bool,
76
77
    full_buf: ThreadLocal<RefCell<SWMatrix>>,
78
    indices_buf: ThreadLocal<RefCell<MatchIndices>>,
79
    #[allow(clippy::type_complexity)]
80
    char_buf: ThreadLocal<RefCell<(Vec<char>, Vec<char>)>>,
81
    bonus_buf: ThreadLocal<RefCell<Vec<Score>>>,
82
}
83
84
impl ArinaeMatcher {
85
    /// Create a new `ArinaeMatcher` with the given settings.
86
    #[must_use]
87
541
    pub fn new(case: CaseMatching, allow_typos: bool, use_last_match: bool) -> Self {
88
541
        Self {
89
541
            case,
90
541
            allow_typos,
91
541
            use_last_match,
92
541
            ..Default::default()
93
541
        }
94
541
    }
95
96
    #[inline(always)]
97
51.3k
    fn respect_case<C: Atom>(&self, pattern: &[C]) -> bool {
98
51.3k
        self.case == CaseMatching::Respect
  Branch (98:9): [True: 0, False: 55]
 
  Branch (98:9): [True: 9, False: 51.1k]
 
  Branch (98:9): [True: 0, False: 26]
 
  Branch (98:9): [True: 3, False: 111]
@@ -20,7 +20,7 @@
   Branch (137:32): [True: 2, False: 28]
 
138
10
            return None;
139
51.2k
        }
140
141
        // Compute banding BEFORE bonuses: the banding check (subsequence scan) is
142
        // a fast SIMD operation that rejects ~70% of items early.  For those items
143
        // we never allocate or fill the bonus buffer, saving an O(m) write pass.
144
51.2k
        let 
banding50.6k
= if self.allow_typos {
  Branch (144:26): [True: 41, False: 51.0k]
 
  Branch (144:26): [True: 28, False: 68]
-
145
69
            compute_banding::<true, C>(pat, cho, respect_case)
?0
146
        } else {
147
51.1k
            compute_banding::<false, C>(pat, cho, respect_case)
?627
148
        };
149
150
        // Only compute bonuses for items that survive the banding check.
151
50.6k
        let mut bonus_buf = self.bonus_buf.get_or(|| 
RefCell::new327
(
Vec::new327
())).borrow_mut();
152
50.6k
        precompute_bonuses(cho, &mut bonus_buf);
153
154
50.6k
        self.dispatch_dp(cho, pat, &bonus_buf, respect_case, compute_indices, &banding)
155
51.2k
    }
156
157
51.3k
    fn run(&self, choice: &str, pattern: &str, compute_indices: bool) -> Option<(ScoreType, MatchIndices)> {
158
51.3k
        if pattern.is_empty() {
  Branch (158:12): [True: 0, False: 51.1k]
+
145
69
            compute_banding::<true, C>(pat, cho, respect_case)
?0
146
        } else {
147
51.1k
            compute_banding::<false, C>(pat, cho, respect_case)
?625
148
        };
149
150
        // Only compute bonuses for items that survive the banding check.
151
50.6k
        let mut bonus_buf = self.bonus_buf.get_or(|| 
RefCell::new326
(
Vec::new326
())).borrow_mut();
152
50.6k
        precompute_bonuses(cho, &mut bonus_buf);
153
154
50.6k
        self.dispatch_dp(cho, pat, &bonus_buf, respect_case, compute_indices, &banding)
155
51.2k
    }
156
157
51.3k
    fn run(&self, choice: &str, pattern: &str, compute_indices: bool) -> Option<(ScoreType, MatchIndices)> {
158
51.3k
        if pattern.is_empty() {
  Branch (158:12): [True: 0, False: 51.1k]
 
  Branch (158:12): [True: 2, False: 114]
 
159
2
            return Some((0, MatchIndices::new()));
160
51.3k
        }
161
51.3k
        if choice.is_empty() {
  Branch (161:12): [True: 0, False: 51.1k]
 
  Branch (161:12): [True: 1, False: 113]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/prefilter.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/prefilter.rs.html
index 9afd588b..11cd5292 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/prefilter.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/prefilter.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/prefilter.rs
Line
Count
Source
1
//! Prefilters running before the algo to optimize performance on unmatchable items
2
3
use super::Atom;
4
use super::constants::MAX_PAT_LEN;
5
6
/// Cheap prefilter for typo-tolerant matching.
7
///
8
/// Rejects choices that clearly cannot produce a positive score in the DP.
9
/// The prefilter is intentionally lenient — false positives are fine (the DP
10
/// will reject them), but false negatives lose valid matches.
11
///
12
/// Strategy:
13
///   1. The first pattern character must appear somewhere in the choice at
14
///      position `j_first` (anchoring the alignment).
15
///   2. Of the remaining `n - 1` pattern characters, at least
16
///      `floor((n - 1) / 2)` must also appear (unordered, as a multiset) in
17
///      `choice[j_first..]` — the window the DP actually examines.
18
///
19
/// Scoping the tail check to `choice[j_first..]` is strictly correct: the
20
/// typo-mode DP band starts at `j_first` for every row (bandwidth = n + 4
21
/// always exceeds n - 1, so the left clamp always hits `j_first`). Any tail
22
/// character that only exists before `j_first` can never contribute a true
23
/// diagonal match in the DP; counting it would be a false positive.
24
///
25
/// We use a multiset frequency check rather than an ordered greedy scan.
26
/// An ordered scan causes false negatives when a greedily-consumed character
27
/// advances the cursor past positions where later characters could still match.
28
///
29
/// For the ASCII (`u8`) path the tail frequency table is built in a single
30
/// O(m) sequential pass over the window, then queried in O(n). For the `char`
31
/// path we fall back to a small O(n) linear-search table seeded from the
32
/// tail, queried via a scalar scan of the window — still a single O(m) pass.
33
99
pub(super) fn cheap_typo_prefilter<C: Atom>(pattern: &[C], choice: &[C], respect_case: bool) -> bool {
34
99
    let n = pattern.len();
35
99
    let m = choice.len();
36
37
    // A pattern much longer than the choice cannot match.
38
99
    if n > m + 2 {
  Branch (38:8): [True: 0, False: 0]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/prefilter.rs
Line
Count
Source
1
//! Prefilters running before the algo to optimize performance on unmatchable items
2
3
use super::Atom;
4
use super::constants::MAX_PAT_LEN;
5
6
/// Cheap prefilter for typo-tolerant matching.
7
///
8
/// Rejects choices that clearly cannot produce a positive score in the DP.
9
/// The prefilter is intentionally lenient — false positives are fine (the DP
10
/// will reject them), but false negatives lose valid matches.
11
///
12
/// Strategy:
13
///   1. The first pattern character must appear somewhere in the choice at
14
///      position `j_first` (anchoring the alignment).
15
///   2. Of the remaining `n - 1` pattern characters, at least
16
///      `floor((n - 1) / 2)` must also appear (unordered, as a multiset) in
17
///      `choice[j_first..]` — the window the DP actually examines.
18
///
19
/// Scoping the tail check to `choice[j_first..]` is strictly correct: the
20
/// typo-mode DP band starts at `j_first` for every row (bandwidth = n + 4
21
/// always exceeds n - 1, so the left clamp always hits `j_first`). Any tail
22
/// character that only exists before `j_first` can never contribute a true
23
/// diagonal match in the DP; counting it would be a false positive.
24
///
25
/// We use a multiset frequency check rather than an ordered greedy scan.
26
/// An ordered scan causes false negatives when a greedily-consumed character
27
/// advances the cursor past positions where later characters could still match.
28
///
29
/// For the ASCII (`u8`) path the tail frequency table is built in a single
30
/// O(m) sequential pass over the window, then queried in O(n). For the `char`
31
/// path we fall back to a small O(n) linear-search table seeded from the
32
/// tail, queried via a scalar scan of the window — still a single O(m) pass.
33
99
pub(super) fn cheap_typo_prefilter<C: Atom>(pattern: &[C], choice: &[C], respect_case: bool) -> bool {
34
99
    let n = pattern.len();
35
99
    let m = choice.len();
36
37
    // A pattern much longer than the choice cannot match.
38
99
    if n > m + 2 {
  Branch (38:8): [True: 0, False: 0]
 
  Branch (38:8): [True: 0, False: 49]
 
  Branch (38:8): [True: 2, False: 9]
 
  Branch (38:8): [True: 1, False: 38]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/clangd.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/clangd.rs.html
index 3ad31045..c74942c2 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/clangd.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/clangd.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/clangd.rs
Line
Count
Source
1
//! The fuzzy matching algorithm used in clangd.
2
//! https://github.com/llvm-mirror/clang-tools-extra/blob/master/clangd/FuzzyMatch.cpp
3
//!
4
//! # Example:
5
//! ```
6
//! use skim::fuzzy_matcher::FuzzyMatcher;
7
//! use skim::fuzzy_matcher::clangd::ClangdMatcher;
8
//!
9
//! let matcher = ClangdMatcher::default();
10
//!
11
//! assert_eq!(None, matcher.fuzzy_match("abc", "abx"));
12
//! assert!(matcher.fuzzy_match("axbycz", "abc").is_some());
13
//! assert!(matcher.fuzzy_match("axbycz", "xyz").is_some());
14
//!
15
//! let (score, indices) = matcher.fuzzy_indices("axbycz", "abc").unwrap();
16
//! assert_eq!(indices, [0, 2, 4]);
17
//!
18
//! ```
19
//!
20
//! Algorithm modified from
21
//! https://github.com/llvm-mirror/clang-tools-extra/blob/master/clangd/FuzzyMatch.cpp
22
//! Also check: https://github.com/lewang/flx/issues/98
23
use crate::fuzzy_matcher::util::{CharRole, CharType, char_equal, char_role, char_type_of, cheap_matches};
24
use crate::fuzzy_matcher::{FuzzyMatcher, IndexType, MatchIndices, ScoreType};
25
use std::cell::RefCell;
26
use std::cmp::max;
27
use thread_local::ThreadLocal;
28
29
#[derive(Eq, PartialEq, Debug, Copy, Clone)]
30
enum CaseMatching {
31
    Respect,
32
    Ignore,
33
    Smart,
34
}
35
36
#[derive(Debug)]
37
/// Fuzzy matcher using the clangd algorithm
38
pub struct ClangdMatcher {
39
    case: CaseMatching,
40
41
    use_cache: bool,
42
43
    c_cache: ThreadLocal<RefCell<Vec<char>>>, // vector to store the characters of choice
44
    p_cache: ThreadLocal<RefCell<Vec<char>>>, // vector to store the characters of pattern
45
}
46
47
impl Default for ClangdMatcher {
48
20
    fn default() -> Self {
49
20
        Self {
50
20
            case: CaseMatching::Ignore,
51
20
            use_cache: true,
52
20
            c_cache: ThreadLocal::new(),
53
20
            p_cache: ThreadLocal::new(),
54
20
        }
55
20
    }
56
}
57
58
impl ClangdMatcher {
59
    /// Sets the matcher to ignore case when matching
60
    #[must_use]
61
11
    pub fn ignore_case(mut self) -> Self {
62
11
        self.case = CaseMatching::Ignore;
63
11
        self
64
11
    }
65
66
    /// Sets the matcher to use smart case (case insensitive unless pattern contains uppercase)
67
    #[must_use]
68
4
    pub fn smart_case(mut self) -> Self {
69
4
        self.case = CaseMatching::Smart;
70
4
        self
71
4
    }
72
73
    /// Sets the matcher to respect case when matching
74
    #[must_use]
75
2
    pub fn respect_case(mut self) -> Self {
76
2
        self.case = CaseMatching::Respect;
77
2
        self
78
2
    }
79
80
    /// Enables or disables caching for improved performance
81
    #[must_use]
82
2
    pub fn use_cache(mut self, use_cache: bool) -> Self {
83
2
        self.use_cache = use_cache;
84
2
        self
85
2
    }
86
87
53
    fn contains_upper(string: &str) -> bool {
88
203
        for ch in 
string53
.
chars53
() {
89
203
            if ch.is_uppercase() {
  Branch (89:16): [True: 0, False: 196]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/clangd.rs
Line
Count
Source
1
//! The fuzzy matching algorithm used in clangd.
2
//! https://github.com/llvm-mirror/clang-tools-extra/blob/master/clangd/FuzzyMatch.cpp
3
//!
4
//! # Example:
5
//! ```
6
//! use skim::fuzzy_matcher::FuzzyMatcher;
7
//! use skim::fuzzy_matcher::clangd::ClangdMatcher;
8
//!
9
//! let matcher = ClangdMatcher::default();
10
//!
11
//! assert_eq!(None, matcher.fuzzy_match("abc", "abx"));
12
//! assert!(matcher.fuzzy_match("axbycz", "abc").is_some());
13
//! assert!(matcher.fuzzy_match("axbycz", "xyz").is_some());
14
//!
15
//! let (score, indices) = matcher.fuzzy_indices("axbycz", "abc").unwrap();
16
//! assert_eq!(indices, [0, 2, 4]);
17
//!
18
//! ```
19
//!
20
//! Algorithm modified from
21
//! https://github.com/llvm-mirror/clang-tools-extra/blob/master/clangd/FuzzyMatch.cpp
22
//! Also check: https://github.com/lewang/flx/issues/98
23
use crate::fuzzy_matcher::util::{CharRole, CharType, char_equal, char_role, char_type_of, cheap_matches};
24
use crate::fuzzy_matcher::{FuzzyMatcher, IndexType, MatchIndices, ScoreType};
25
use std::cell::RefCell;
26
use std::cmp::max;
27
use thread_local::ThreadLocal;
28
29
#[derive(Eq, PartialEq, Debug, Copy, Clone)]
30
enum CaseMatching {
31
    Respect,
32
    Ignore,
33
    Smart,
34
}
35
36
#[derive(Debug)]
37
/// Fuzzy matcher using the clangd algorithm
38
pub struct ClangdMatcher {
39
    case: CaseMatching,
40
41
    use_cache: bool,
42
43
    c_cache: ThreadLocal<RefCell<Vec<char>>>, // vector to store the characters of choice
44
    p_cache: ThreadLocal<RefCell<Vec<char>>>, // vector to store the characters of pattern
45
}
46
47
impl Default for ClangdMatcher {
48
19
    fn default() -> Self {
49
19
        Self {
50
19
            case: CaseMatching::Ignore,
51
19
            use_cache: true,
52
19
            c_cache: ThreadLocal::new(),
53
19
            p_cache: ThreadLocal::new(),
54
19
        }
55
19
    }
56
}
57
58
impl ClangdMatcher {
59
    /// Sets the matcher to ignore case when matching
60
    #[must_use]
61
11
    pub fn ignore_case(mut self) -> Self {
62
11
        self.case = CaseMatching::Ignore;
63
11
        self
64
11
    }
65
66
    /// Sets the matcher to use smart case (case insensitive unless pattern contains uppercase)
67
    #[must_use]
68
3
    pub fn smart_case(mut self) -> Self {
69
3
        self.case = CaseMatching::Smart;
70
3
        self
71
3
    }
72
73
    /// Sets the matcher to respect case when matching
74
    #[must_use]
75
2
    pub fn respect_case(mut self) -> Self {
76
2
        self.case = CaseMatching::Respect;
77
2
        self
78
2
    }
79
80
    /// Enables or disables caching for improved performance
81
    #[must_use]
82
2
    pub fn use_cache(mut self, use_cache: bool) -> Self {
83
2
        self.use_cache = use_cache;
84
2
        self
85
2
    }
86
87
53
    fn contains_upper(string: &str) -> bool {
88
203
        for ch in 
string53
.
chars53
() {
89
203
            if ch.is_uppercase() {
  Branch (89:16): [True: 0, False: 196]
 
  Branch (89:16): [True: 2, False: 5]
 
90
2
                return true;
91
201
            }
92
        }
93
94
51
        false
95
53
    }
96
97
115
    fn is_case_sensitive(&self, pattern: &str) -> bool {
98
115
        match self.case {
99
3
            CaseMatching::Respect => true,
100
59
            CaseMatching::Ignore => false,
101
53
            CaseMatching::Smart => Self::contains_upper(pattern),
102
        }
103
115
    }
104
}
105
106
impl FuzzyMatcher for ClangdMatcher {
107
68
    fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(ScoreType, MatchIndices)> {
108
68
        let case_sensitive = self.is_case_sensitive(pattern);
109
110
68
        let mut choice_chars = self.c_cache.get_or(|| 
RefCell::new11
(
Vec::new11
())).borrow_mut();
111
68
        let mut pattern_chars = self.p_cache.get_or(|| 
RefCell::new11
(
Vec::new11
())).borrow_mut();
112
113
68
        choice_chars.clear();
114
943
        for char in 
choice68
.
chars68
() {
115
943
            choice_chars.push(char);
116
943
        }
117
118
68
        pattern_chars.clear();
119
245
        for char in 
pattern68
.
chars68
() {
120
245
            pattern_chars.push(char);
121
245
        }
122
123
68
        cheap_matches(&choice_chars, &pattern_chars, case_sensitive)
?55
;
124
125
13
        let num_pattern_chars = pattern_chars.len();
126
13
        let num_choice_chars = choice_chars.len();
127
128
13
        let dp = build_graph(&choice_chars, &pattern_chars, false, case_sensitive);
129
130
        // search backwards for the matched indices
131
13
        let mut indices_reverse = Vec::with_capacity(num_pattern_chars);
132
13
        let cell = dp[num_pattern_chars][num_choice_chars];
133
134
13
        let (mut last_action, score) = if cell.hit > cell.missed {
  Branch (134:43): [True: 0, False: 2]
 
  Branch (134:43): [True: 3, False: 8]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/frizbee.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/frizbee.rs.html
index 5e1acd82..bd0d7bbf 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/frizbee.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/frizbee.rs.html
@@ -1 +1 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/frizbee.rs
Line
Count
Source
1
//! Matcher using <https://crates.io/crates/frizbee>
2
use std::cell::RefCell;
3
4
use frizbee::{Config, Matcher};
5
6
use crate::CaseMatching;
7
use crate::fuzzy_matcher::{FuzzyMatcher, MatchIndices, ScoreType};
8
9
thread_local! {
10
    /// One reusable frizbee Matcher per thread
11
    static LOCAL_MATCHER: RefCell<Option<Matcher>> = const { RefCell::new(None) };
12
}
13
14
/// Matcher using frizbee,
15
/// the same one that `blink.cmp` uses in neovim
16
/// credits to @saghen
17
pub struct FrizbeeMatcher {
18
    config: Config,
19
}
20
impl Default for FrizbeeMatcher {
21
13
    fn default() -> Self {
22
13
        Self {
23
13
            config: Config {
24
13
                casing: CaseMatching::Respect.into(),
25
13
                ..Config::default()
26
13
            },
27
13
        }
28
13
    }
29
}
30
31
impl FrizbeeMatcher {
32
    /// Set the max typos to use
33
    #[must_use]
34
5
    pub fn max_typos(mut self, typos: Option<usize>) -> Self {
35
5
        self.config.max_typos = Some(typos.map_or(0, |x| 
u16::try_from3
(
x3
).
unwrap_or3
(u16::MAX)));
36
5
        self
37
5
    }
38
39
    /// Set the case matching strategy
40
    #[must_use]
41
10
    pub fn case(mut self, case: CaseMatching) -> Self {
42
10
        self.config.casing = case.into();
43
10
        self
44
10
    }
45
46
    /// Run `f` with a thread-local matcher configured with this matcher's config
47
    /// and the given needle.
48
111
    fn with_matcher<R>(&self, pattern: &str, f: impl FnOnce(&mut Matcher) -> R) -> R {
49
111
        LOCAL_MATCHER.with(|cell| {
50
111
            let mut slot = cell.borrow_mut();
51
52
111
            let matcher = slot.get_or_insert_with(|| 
Matcher::new11
("",
&self.config11
));
53
111
            matcher.set_config(self.config.clone());
54
111
            matcher.set_pattern(pattern);
55
111
            f(matcher)
56
111
        })
57
111
    }
58
}
59
60
impl FuzzyMatcher for FrizbeeMatcher {
61
105
    fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(ScoreType, MatchIndices)> {
62
105
        self.with_matcher(pattern, |m| {
63
105
            m.match_one_indices(choice, 0).map(|mut hit| 
{32
64
32
                hit.indices.reverse();
65
                (
66
32
                    hit.score.into(),
67
100
                    
hit.indices32
.
into_iter32
().
map32
(|index| index as usize).
collect32
(),
68
                )
69
32
            })
70
105
        })
71
105
    }
72
73
6
    fn fuzzy_match(&self, choice: &str, pattern: &str) -> Option<i64> {
74
6
        self.with_matcher(pattern, |m| m.match_one(choice, 0).map(|hit| hit.score.into()))
75
6
    }
76
}
77
78
impl From<CaseMatching> for frizbee::CaseMatching {
79
23
    fn from(case: CaseMatching) -> Self {
80
23
        match case {
81
15
            CaseMatching::Respect => frizbee::CaseMatching::Respect,
82
2
            CaseMatching::Ignore => frizbee::CaseMatching::Ignore,
83
6
            CaseMatching::Smart => frizbee::CaseMatching::Smart,
84
        }
85
23
    }
86
}
87
88
#[cfg(test)]
89
#[cfg_attr(coverage, coverage(off))]
90
mod tests {
91
    use super::*;
92
    use crate::fuzzy_matcher::FuzzyMatcher;
93
94
    #[test]
95
    fn matches_subsequence() {
96
        let m = FrizbeeMatcher::default();
97
        assert!(m.fuzzy_match("foobar", "foo").is_some());
98
        assert!(m.fuzzy_indices("foobar", "foo").is_some());
99
    }
100
101
    #[test]
102
    fn respect_case_variant() {
103
        let m = FrizbeeMatcher::default().case(CaseMatching::Respect);
104
        assert!(m.fuzzy_indices("FooBar", "Foo").is_some());
105
    }
106
107
    #[test]
108
    fn smart_case_variant() {
109
        let m = FrizbeeMatcher::default().case(CaseMatching::Smart);
110
        // Uppercase pattern triggers the case bonus branch.
111
        assert!(m.fuzzy_indices("FooBar", "Foo").is_some());
112
        // Lowercase pattern -> no bonus.
113
        assert!(m.fuzzy_indices("foobar", "foo").is_some());
114
    }
115
116
    #[test]
117
    fn ignore_case_variant() {
118
        let m = FrizbeeMatcher::default().case(CaseMatching::Ignore);
119
        assert!(m.fuzzy_match("FOOBAR", "foo").is_some());
120
    }
121
122
    #[test]
123
    fn max_typos_tolerates_mismatch() {
124
        let m = FrizbeeMatcher::default().max_typos(Some(1));
125
        assert!(m.fuzzy_match("foobar", "fxo").is_some());
126
    }
127
128
    #[test]
129
    fn fuzzy_indices_ignore_case() {
130
        // Ignore case → matching_case_bonus is 0 in fuzzy_indices.
131
        let m = FrizbeeMatcher::default().case(CaseMatching::Ignore);
132
        assert!(m.fuzzy_indices("FOOBAR", "foo").is_some());
133
    }
134
135
    #[test]
136
    fn fuzzy_indices_no_match_returns_none() {
137
        // A non-subsequence pattern exercises the None branch.
138
        let m = FrizbeeMatcher::default();
139
        assert!(m.fuzzy_indices("foobar", "zzz").is_none());
140
    }
141
142
    #[test]
143
    fn fuzzy_match_respect_and_smart_case() {
144
        // fuzzy_match (score-only) across the Respect and Smart case arms.
145
        let respect = FrizbeeMatcher::default().case(CaseMatching::Respect);
146
        assert!(respect.fuzzy_match("FooBar", "Foo").is_some());
147
148
        let smart = FrizbeeMatcher::default().case(CaseMatching::Smart);
149
        assert!(smart.fuzzy_match("FooBar", "Foo").is_some());
150
        assert!(smart.fuzzy_match("foobar", "foo").is_some());
151
    }
152
}
\ No newline at end of file +

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/frizbee.rs
Line
Count
Source
1
//! Matcher using <https://crates.io/crates/frizbee>
2
use std::cell::RefCell;
3
4
use frizbee::{Config, Matcher};
5
6
use crate::CaseMatching;
7
use crate::fuzzy_matcher::{FuzzyMatcher, MatchIndices, ScoreType};
8
9
thread_local! {
10
    /// One reusable frizbee Matcher per thread
11
    static LOCAL_MATCHER: RefCell<Option<Matcher>> = const { RefCell::new(None) };
12
}
13
14
/// Matcher using frizbee,
15
/// the same one that `blink.cmp` uses in neovim
16
/// credits to @saghen
17
pub struct FrizbeeMatcher {
18
    config: Config,
19
}
20
impl Default for FrizbeeMatcher {
21
12
    fn default() -> Self {
22
12
        Self {
23
12
            config: Config {
24
12
                casing: CaseMatching::Respect.into(),
25
12
                ..Config::default()
26
12
            },
27
12
        }
28
12
    }
29
}
30
31
impl FrizbeeMatcher {
32
    /// Set the max typos to use
33
    #[must_use]
34
4
    pub fn max_typos(mut self, typos: Option<usize>) -> Self {
35
4
        self.config.max_typos = Some(typos.map_or(0, |x| 
u16::try_from2
(
x2
).
unwrap_or2
(u16::MAX)));
36
4
        self
37
4
    }
38
39
    /// Set the case matching strategy
40
    #[must_use]
41
9
    pub fn case(mut self, case: CaseMatching) -> Self {
42
9
        self.config.casing = case.into();
43
9
        self
44
9
    }
45
46
    /// Run `f` with a thread-local matcher configured with this matcher's config
47
    /// and the given needle.
48
111
    fn with_matcher<R>(&self, pattern: &str, f: impl FnOnce(&mut Matcher) -> R) -> R {
49
111
        LOCAL_MATCHER.with(|cell| {
50
111
            let mut slot = cell.borrow_mut();
51
52
111
            let matcher = slot.get_or_insert_with(|| 
Matcher::new11
("",
&self.config11
));
53
111
            matcher.set_config(self.config.clone());
54
111
            matcher.set_pattern(pattern);
55
111
            f(matcher)
56
111
        })
57
111
    }
58
}
59
60
impl FuzzyMatcher for FrizbeeMatcher {
61
105
    fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(ScoreType, MatchIndices)> {
62
105
        self.with_matcher(pattern, |m| {
63
105
            m.match_one_indices(choice, 0).map(|mut hit| 
{32
64
32
                hit.indices.reverse();
65
                (
66
32
                    hit.score.into(),
67
100
                    
hit.indices32
.
into_iter32
().
map32
(|index| index as usize).
collect32
(),
68
                )
69
32
            })
70
105
        })
71
105
    }
72
73
6
    fn fuzzy_match(&self, choice: &str, pattern: &str) -> Option<i64> {
74
6
        self.with_matcher(pattern, |m| m.match_one(choice, 0).map(|hit| hit.score.into()))
75
6
    }
76
}
77
78
impl From<CaseMatching> for frizbee::CaseMatching {
79
21
    fn from(case: CaseMatching) -> Self {
80
21
        match case {
81
14
            CaseMatching::Respect => frizbee::CaseMatching::Respect,
82
2
            CaseMatching::Ignore => frizbee::CaseMatching::Ignore,
83
5
            CaseMatching::Smart => frizbee::CaseMatching::Smart,
84
        }
85
21
    }
86
}
87
88
#[cfg(test)]
89
#[cfg_attr(coverage, coverage(off))]
90
mod tests {
91
    use super::*;
92
    use crate::fuzzy_matcher::FuzzyMatcher;
93
94
    #[test]
95
    fn matches_subsequence() {
96
        let m = FrizbeeMatcher::default();
97
        assert!(m.fuzzy_match("foobar", "foo").is_some());
98
        assert!(m.fuzzy_indices("foobar", "foo").is_some());
99
    }
100
101
    #[test]
102
    fn respect_case_variant() {
103
        let m = FrizbeeMatcher::default().case(CaseMatching::Respect);
104
        assert!(m.fuzzy_indices("FooBar", "Foo").is_some());
105
    }
106
107
    #[test]
108
    fn smart_case_variant() {
109
        let m = FrizbeeMatcher::default().case(CaseMatching::Smart);
110
        // Uppercase pattern triggers the case bonus branch.
111
        assert!(m.fuzzy_indices("FooBar", "Foo").is_some());
112
        // Lowercase pattern -> no bonus.
113
        assert!(m.fuzzy_indices("foobar", "foo").is_some());
114
    }
115
116
    #[test]
117
    fn ignore_case_variant() {
118
        let m = FrizbeeMatcher::default().case(CaseMatching::Ignore);
119
        assert!(m.fuzzy_match("FOOBAR", "foo").is_some());
120
    }
121
122
    #[test]
123
    fn max_typos_tolerates_mismatch() {
124
        let m = FrizbeeMatcher::default().max_typos(Some(1));
125
        assert!(m.fuzzy_match("foobar", "fxo").is_some());
126
    }
127
128
    #[test]
129
    fn fuzzy_indices_ignore_case() {
130
        // Ignore case → matching_case_bonus is 0 in fuzzy_indices.
131
        let m = FrizbeeMatcher::default().case(CaseMatching::Ignore);
132
        assert!(m.fuzzy_indices("FOOBAR", "foo").is_some());
133
    }
134
135
    #[test]
136
    fn fuzzy_indices_no_match_returns_none() {
137
        // A non-subsequence pattern exercises the None branch.
138
        let m = FrizbeeMatcher::default();
139
        assert!(m.fuzzy_indices("foobar", "zzz").is_none());
140
    }
141
142
    #[test]
143
    fn fuzzy_match_respect_and_smart_case() {
144
        // fuzzy_match (score-only) across the Respect and Smart case arms.
145
        let respect = FrizbeeMatcher::default().case(CaseMatching::Respect);
146
        assert!(respect.fuzzy_match("FooBar", "Foo").is_some());
147
148
        let smart = FrizbeeMatcher::default().case(CaseMatching::Smart);
149
        assert!(smart.fuzzy_match("FooBar", "Foo").is_some());
150
        assert!(smart.fuzzy_match("foobar", "foo").is_some());
151
    }
152
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/fzy.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/fzy.rs.html index c5353658..205d0a9c 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/fzy.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/fzy.rs.html @@ -1,4 +1,4 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/fzy.rs
Line
Count
Source
1
//! Fuzzy matching algorithm based on fzy by John Hawthorn.
2
//! https://github.com/jhawthorn/fzy
3
//!
4
//! This implements fzy's scoring algorithm which treats fuzzy matching as a
5
//! modified edit-distance problem using dynamic programming (Needleman-Wunsch style).
6
//! It uses two DP matrices:
7
//! - `M[i][j]`: the best possible score using the first `i` chars of the needle
8
//!   and the first `j` chars of the haystack.
9
//! - `D[i][j]`: the best score that *ends with a match* at position `(i, j)`.
10
//!
11
//! This separation enables affine gap penalties: a constant cost to open a gap
12
//! and a linear cost for extending it, plus a bonus for consecutive matches.
13
//!
14
//! All scoring uses integer arithmetic with a ×200 scaling factor for performance.
15
//! The original fzy float constants map as follows:
16
//! - -0.005 → -1
17
//! - -0.01  → -2
18
//! - 0.6    → 120
19
//! - 0.7    → 140
20
//! - 0.8    → 160
21
//! - 0.9    → 180
22
//! - 1.0    → 200
23
//! - -1.5   → -300
24
//!
25
//! # Example:
26
//! ```
27
//! use skim::fuzzy_matcher::FuzzyMatcher;
28
//! use skim::fuzzy_matcher::fzy::FzyMatcher;
29
//!
30
//! let matcher = FzyMatcher::default();
31
//!
32
//! assert_eq!(None, matcher.fuzzy_match("abc", "abx"));
33
//! assert!(matcher.fuzzy_match("axbycz", "abc").is_some());
34
//!
35
//! let (score, indices) = matcher.fuzzy_indices("axbycz", "abc").unwrap();
36
//! assert_eq!(indices, [0, 2, 4]);
37
//! ```
38
39
use std::cell::RefCell;
40
41
use thread_local::ThreadLocal;
42
43
use crate::fuzzy_matcher::util::{char_equal, cheap_matches};
44
use crate::fuzzy_matcher::{FuzzyMatcher, IndexType, MatchIndices, ScoreType};
45
46
// ---------------------------------------------------------------------------
47
// Score constants (from fzy's config.def.h, scaled ×200 to integer)
48
// ---------------------------------------------------------------------------
49
50
/// Sentinel for "impossible" / uninitialized DP cells.
51
/// Uses `i64::MIN / 2` so that adding a penalty never overflows.
52
const SCORE_MIN: i64 = i64::MIN / 2;
53
54
/// Score for an exact-length match (`needle.len()` == `haystack.len()`).
55
const SCORE_MAX: i64 = i64::MAX / 2;
56
57
const SCORE_GAP_LEADING: i64 = -1; // -0.005 × 200
58
const SCORE_GAP_TRAILING: i64 = -1; // -0.005 × 200
59
const SCORE_GAP_INNER: i64 = -2; // -0.01  × 200
60
61
const SCORE_MATCH_CONSECUTIVE: i64 = 200; // 1.0 × 200
62
const SCORE_MATCH_SLASH: i64 = 180; // 0.9 × 200
63
const SCORE_MATCH_WORD: i64 = 160; // 0.8 × 200
64
const SCORE_MATCH_CAPITAL: i64 = 140; // 0.7 × 200
65
const SCORE_MATCH_DOT: i64 = 120; // 0.6 × 200
66
67
/// Penalty applied when a typo is used (substitution or needle-char deletion).
68
const SCORE_TYPO: i64 = -300; // -1.5 × 200
69
70
/// Maximum haystack length we will score.
71
const MATCH_MAX_LEN: usize = 1024;
72
73
/// Conversion factor from internal ×200 scores to skim's ×1000 convention.
74
/// `internal_score` × `SCORE_TO_SKIM` = `skim_score`
75
const SCORE_TO_SKIM: i64 = 5; // 1000 / 200
76
77
// ---------------------------------------------------------------------------
78
// Bonus computation
79
// ---------------------------------------------------------------------------
80
81
#[inline]
82
923
fn bonus_index(ch: char) -> usize {
83
923
    if ch.is_ascii_uppercase() {
  Branch (83:8): [True: 0, False: 418]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/fzy.rs
Line
Count
Source
1
//! Fuzzy matching algorithm based on fzy by John Hawthorn.
2
//! https://github.com/jhawthorn/fzy
3
//!
4
//! This implements fzy's scoring algorithm which treats fuzzy matching as a
5
//! modified edit-distance problem using dynamic programming (Needleman-Wunsch style).
6
//! It uses two DP matrices:
7
//! - `M[i][j]`: the best possible score using the first `i` chars of the needle
8
//!   and the first `j` chars of the haystack.
9
//! - `D[i][j]`: the best score that *ends with a match* at position `(i, j)`.
10
//!
11
//! This separation enables affine gap penalties: a constant cost to open a gap
12
//! and a linear cost for extending it, plus a bonus for consecutive matches.
13
//!
14
//! All scoring uses integer arithmetic with a ×200 scaling factor for performance.
15
//! The original fzy float constants map as follows:
16
//! - -0.005 → -1
17
//! - -0.01  → -2
18
//! - 0.6    → 120
19
//! - 0.7    → 140
20
//! - 0.8    → 160
21
//! - 0.9    → 180
22
//! - 1.0    → 200
23
//! - -1.5   → -300
24
//!
25
//! # Example:
26
//! ```
27
//! use skim::fuzzy_matcher::FuzzyMatcher;
28
//! use skim::fuzzy_matcher::fzy::FzyMatcher;
29
//!
30
//! let matcher = FzyMatcher::default();
31
//!
32
//! assert_eq!(None, matcher.fuzzy_match("abc", "abx"));
33
//! assert!(matcher.fuzzy_match("axbycz", "abc").is_some());
34
//!
35
//! let (score, indices) = matcher.fuzzy_indices("axbycz", "abc").unwrap();
36
//! assert_eq!(indices, [0, 2, 4]);
37
//! ```
38
39
use std::cell::RefCell;
40
41
use thread_local::ThreadLocal;
42
43
use crate::fuzzy_matcher::util::{char_equal, cheap_matches};
44
use crate::fuzzy_matcher::{FuzzyMatcher, IndexType, MatchIndices, ScoreType};
45
46
// ---------------------------------------------------------------------------
47
// Score constants (from fzy's config.def.h, scaled ×200 to integer)
48
// ---------------------------------------------------------------------------
49
50
/// Sentinel for "impossible" / uninitialized DP cells.
51
/// Uses `i64::MIN / 2` so that adding a penalty never overflows.
52
const SCORE_MIN: i64 = i64::MIN / 2;
53
54
/// Score for an exact-length match (`needle.len()` == `haystack.len()`).
55
const SCORE_MAX: i64 = i64::MAX / 2;
56
57
const SCORE_GAP_LEADING: i64 = -1; // -0.005 × 200
58
const SCORE_GAP_TRAILING: i64 = -1; // -0.005 × 200
59
const SCORE_GAP_INNER: i64 = -2; // -0.01  × 200
60
61
const SCORE_MATCH_CONSECUTIVE: i64 = 200; // 1.0 × 200
62
const SCORE_MATCH_SLASH: i64 = 180; // 0.9 × 200
63
const SCORE_MATCH_WORD: i64 = 160; // 0.8 × 200
64
const SCORE_MATCH_CAPITAL: i64 = 140; // 0.7 × 200
65
const SCORE_MATCH_DOT: i64 = 120; // 0.6 × 200
66
67
/// Penalty applied when a typo is used (substitution or needle-char deletion).
68
const SCORE_TYPO: i64 = -300; // -1.5 × 200
69
70
/// Maximum haystack length we will score.
71
const MATCH_MAX_LEN: usize = 1024;
72
73
/// Conversion factor from internal ×200 scores to skim's ×1000 convention.
74
/// `internal_score` × `SCORE_TO_SKIM` = `skim_score`
75
const SCORE_TO_SKIM: i64 = 5; // 1000 / 200
76
77
// ---------------------------------------------------------------------------
78
// Bonus computation
79
// ---------------------------------------------------------------------------
80
81
#[inline]
82
923
fn bonus_index(ch: char) -> usize {
83
923
    if ch.is_ascii_uppercase() {
  Branch (83:8): [True: 0, False: 418]
 
  Branch (83:8): [True: 46, False: 459]
 
84
46
        2
85
    } else {
86
877
        usize::from(ch.is_ascii_lowercase() || 
ch104
.
is_ascii_digit104
())
  Branch (86:21): [True: 344, False: 74]
 
  Branch (86:21): [True: 429, False: 30]
@@ -156,7 +156,7 @@
 
  Branch (671:8): [True: 13, False: 85]
 
672
13
        ScoreType::MAX / 2
673
109
    } else if score == SCORE_MIN {
  Branch (673:15): [True: 0, False: 24]
 
  Branch (673:15): [True: 1, False: 84]
-
674
1
        ScoreType::MIN / 2
675
    } else {
676
        // Saturate rather than panic: the DP sentinel (SCORE_MIN) can end up
677
        // offset by a few accumulated bonuses/penalties along a path that's
678
        // still effectively "no match" (see the fuzz-found case-folding bug
679
        // this fixed), landing close to but not exactly on SCORE_MIN/SCORE_MAX.
680
108
        score.saturating_mul(SCORE_TO_SKIM)
681
    }
682
122
}
683
684
// ---------------------------------------------------------------------------
685
// Public matcher struct
686
// ---------------------------------------------------------------------------
687
688
#[derive(Eq, PartialEq, Debug, Copy, Clone)]
689
enum CaseMatching {
690
    Respect,
691
    Ignore,
692
    Smart,
693
}
694
695
/// Fuzzy matcher using the fzy algorithm.
696
///
697
/// This is a clean reimplementation of the scoring algorithm from
698
/// [fzy](https://github.com/jhawthorn/fzy) by John Hawthorn.
699
///
700
/// Supports optional typo tolerance via [`max_typos`](Self::max_typos).
701
#[derive(Debug)]
702
pub struct FzyMatcher {
703
    case: CaseMatching,
704
    use_cache: bool,
705
    max_typos: Option<usize>,
706
    c_cache: ThreadLocal<RefCell<Vec<char>>>,
707
    p_cache: ThreadLocal<RefCell<Vec<char>>>,
708
    lc_cache: ThreadLocal<RefCell<Vec<char>>>,
709
    lp_cache: ThreadLocal<RefCell<Vec<char>>>,
710
    typo_bufs: ThreadLocal<RefCell<TypoDpBuffers>>,
711
}
712
713
impl Default for FzyMatcher {
714
70
    fn default() -> Self {
715
70
        Self {
716
70
            case: CaseMatching::Ignore,
717
70
            use_cache: true,
718
70
            max_typos: None,
719
70
            c_cache: ThreadLocal::new(),
720
70
            p_cache: ThreadLocal::new(),
721
70
            lc_cache: ThreadLocal::new(),
722
70
            lp_cache: ThreadLocal::new(),
723
70
            typo_bufs: ThreadLocal::new(),
724
70
        }
725
70
    }
726
}
727
728
impl FzyMatcher {
729
    /// Sets the matcher to ignore case when matching.
730
    #[must_use]
731
57
    pub fn ignore_case(mut self) -> Self {
732
57
        self.case = CaseMatching::Ignore;
733
57
        self
734
57
    }
735
736
    /// Sets the matcher to use smart case.
737
    #[must_use]
738
5
    pub fn smart_case(mut self) -> Self {
739
5
        self.case = CaseMatching::Smart;
740
5
        self
741
5
    }
742
743
    /// Sets the matcher to respect case exactly.
744
    #[must_use]
745
6
    pub fn respect_case(mut self) -> Self {
746
6
        self.case = CaseMatching::Respect;
747
6
        self
748
6
    }
749
750
    /// Enables or disables thread-local caching.
751
    #[must_use]
752
2
    pub fn use_cache(mut self, use_cache: bool) -> Self {
753
2
        self.use_cache = use_cache;
754
2
        self
755
2
    }
756
757
    /// Sets the maximum number of typos allowed during matching.
758
    ///
759
    /// - `None` (default): strict subsequence matching with no typos.
760
    /// - `Some(n)`: allows up to `n` typos.
761
    #[must_use]
762
36
    pub fn max_typos(mut self, max_typos: Option<usize>) -> Self {
763
36
        self.max_typos = max_typos;
764
36
        self
765
36
    }
766
767
102
    fn contains_upper(string: &str) -> bool {
768
102
        string.chars().any(char::is_uppercase)
769
102
    }
770
771
231
    fn is_case_sensitive(&self, pattern: &str) -> bool {
772
231
        match self.case {
773
15
            CaseMatching::Respect => true,
774
114
            CaseMatching::Ignore => false,
775
102
            CaseMatching::Smart => Self::contains_upper(pattern),
776
        }
777
231
    }
778
}
779
780
impl FuzzyMatcher for FzyMatcher {
781
148
    fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(ScoreType, MatchIndices)> {
782
148
        let case_sensitive = self.is_case_sensitive(pattern);
783
784
148
        let mut choice_chars = self.c_cache.get_or(|| 
RefCell::new24
(
Vec::new24
())).borrow_mut();
785
148
        let mut pattern_chars = self.p_cache.get_or(|| 
RefCell::new24
(
Vec::new24
())).borrow_mut();
786
787
148
        choice_chars.clear();
788
148
        choice_chars.extend(choice.chars());
789
148
        pattern_chars.clear();
790
148
        pattern_chars.extend(pattern.chars());
791
792
148
        match self.max_typos {
793
            None => {
794
76
                cheap_matches(&choice_chars, &pattern_chars, case_sensitive)
?54
;
795
22
                let mut positions = Vec::with_capacity(pattern_chars.len());
796
22
                let s = fzy_score(&pattern_chars, &choice_chars, case_sensitive, Some(&mut positions))
?0
;
797
22
                Some((internal_to_skim_score(s), MatchIndices::from(positions)))
798
            }
799
72
            Some(max_t) => {
800
                // Fast path: try exact subsequence match first
801
72
                if cheap_matches(&choice_chars, &pattern_chars, case_sensitive).is_some() {
  Branch (801:20): [True: 2, False: 47]
+
674
1
        ScoreType::MIN / 2
675
    } else {
676
        // Saturate rather than panic: the DP sentinel (SCORE_MIN) can end up
677
        // offset by a few accumulated bonuses/penalties along a path that's
678
        // still effectively "no match" (see the fuzz-found case-folding bug
679
        // this fixed), landing close to but not exactly on SCORE_MIN/SCORE_MAX.
680
108
        score.saturating_mul(SCORE_TO_SKIM)
681
    }
682
122
}
683
684
// ---------------------------------------------------------------------------
685
// Public matcher struct
686
// ---------------------------------------------------------------------------
687
688
#[derive(Eq, PartialEq, Debug, Copy, Clone)]
689
enum CaseMatching {
690
    Respect,
691
    Ignore,
692
    Smart,
693
}
694
695
/// Fuzzy matcher using the fzy algorithm.
696
///
697
/// This is a clean reimplementation of the scoring algorithm from
698
/// [fzy](https://github.com/jhawthorn/fzy) by John Hawthorn.
699
///
700
/// Supports optional typo tolerance via [`max_typos`](Self::max_typos).
701
#[derive(Debug)]
702
pub struct FzyMatcher {
703
    case: CaseMatching,
704
    use_cache: bool,
705
    max_typos: Option<usize>,
706
    c_cache: ThreadLocal<RefCell<Vec<char>>>,
707
    p_cache: ThreadLocal<RefCell<Vec<char>>>,
708
    lc_cache: ThreadLocal<RefCell<Vec<char>>>,
709
    lp_cache: ThreadLocal<RefCell<Vec<char>>>,
710
    typo_bufs: ThreadLocal<RefCell<TypoDpBuffers>>,
711
}
712
713
impl Default for FzyMatcher {
714
69
    fn default() -> Self {
715
69
        Self {
716
69
            case: CaseMatching::Ignore,
717
69
            use_cache: true,
718
69
            max_typos: None,
719
69
            c_cache: ThreadLocal::new(),
720
69
            p_cache: ThreadLocal::new(),
721
69
            lc_cache: ThreadLocal::new(),
722
69
            lp_cache: ThreadLocal::new(),
723
69
            typo_bufs: ThreadLocal::new(),
724
69
        }
725
69
    }
726
}
727
728
impl FzyMatcher {
729
    /// Sets the matcher to ignore case when matching.
730
    #[must_use]
731
57
    pub fn ignore_case(mut self) -> Self {
732
57
        self.case = CaseMatching::Ignore;
733
57
        self
734
57
    }
735
736
    /// Sets the matcher to use smart case.
737
    #[must_use]
738
4
    pub fn smart_case(mut self) -> Self {
739
4
        self.case = CaseMatching::Smart;
740
4
        self
741
4
    }
742
743
    /// Sets the matcher to respect case exactly.
744
    #[must_use]
745
6
    pub fn respect_case(mut self) -> Self {
746
6
        self.case = CaseMatching::Respect;
747
6
        self
748
6
    }
749
750
    /// Enables or disables thread-local caching.
751
    #[must_use]
752
2
    pub fn use_cache(mut self, use_cache: bool) -> Self {
753
2
        self.use_cache = use_cache;
754
2
        self
755
2
    }
756
757
    /// Sets the maximum number of typos allowed during matching.
758
    ///
759
    /// - `None` (default): strict subsequence matching with no typos.
760
    /// - `Some(n)`: allows up to `n` typos.
761
    #[must_use]
762
35
    pub fn max_typos(mut self, max_typos: Option<usize>) -> Self {
763
35
        self.max_typos = max_typos;
764
35
        self
765
35
    }
766
767
102
    fn contains_upper(string: &str) -> bool {
768
102
        string.chars().any(char::is_uppercase)
769
102
    }
770
771
231
    fn is_case_sensitive(&self, pattern: &str) -> bool {
772
231
        match self.case {
773
15
            CaseMatching::Respect => true,
774
114
            CaseMatching::Ignore => false,
775
102
            CaseMatching::Smart => Self::contains_upper(pattern),
776
        }
777
231
    }
778
}
779
780
impl FuzzyMatcher for FzyMatcher {
781
148
    fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(ScoreType, MatchIndices)> {
782
148
        let case_sensitive = self.is_case_sensitive(pattern);
783
784
148
        let mut choice_chars = self.c_cache.get_or(|| 
RefCell::new24
(
Vec::new24
())).borrow_mut();
785
148
        let mut pattern_chars = self.p_cache.get_or(|| 
RefCell::new24
(
Vec::new24
())).borrow_mut();
786
787
148
        choice_chars.clear();
788
148
        choice_chars.extend(choice.chars());
789
148
        pattern_chars.clear();
790
148
        pattern_chars.extend(pattern.chars());
791
792
148
        match self.max_typos {
793
            None => {
794
76
                cheap_matches(&choice_chars, &pattern_chars, case_sensitive)
?54
;
795
22
                let mut positions = Vec::with_capacity(pattern_chars.len());
796
22
                let s = fzy_score(&pattern_chars, &choice_chars, case_sensitive, Some(&mut positions))
?0
;
797
22
                Some((internal_to_skim_score(s), MatchIndices::from(positions)))
798
            }
799
72
            Some(max_t) => {
800
                // Fast path: try exact subsequence match first
801
72
                if cheap_matches(&choice_chars, &pattern_chars, case_sensitive).is_some() {
  Branch (801:20): [True: 2, False: 47]
 
  Branch (801:20): [True: 3, False: 20]
 
802
5
                    let mut positions = Vec::with_capacity(pattern_chars.len());
803
5
                    if let Some(
s3
) = fzy_score(&pattern_chars, &choice_chars, case_sensitive, Some(&mut positions)) {
  Branch (803:28): [True: 2, False: 0]
 
  Branch (803:28): [True: 1, False: 2]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/mod.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/mod.rs.html
index 7d4e35ed..943e2ff3 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/mod.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/mod.rs.html
@@ -1 +1 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/mod.rs
Line
Count
Source
1
//! Fuzzy matching algorithms and implementations.
2
//!
3
//! This module provides different fuzzy matching algorithms including
4
//! skim's own algorithm and clangd's algorithm for matching text patterns.
5
6
/// Arinae fuzzy matching algorithm (Smith-Waterman with affine gaps)
7
pub mod arinae;
8
/// Clangd fuzzy matching algorithm
9
pub mod clangd;
10
#[cfg(feature = "frizbee")]
11
pub mod frizbee;
12
/// Fzy fuzzy matching algorithm
13
pub mod fzy;
14
/// Skim fuzzy matching algorithm
15
pub mod skim;
16
mod util;
17
18
pub(crate) type IndexType = usize;
19
pub(crate) type ScoreType = i64;
20
21
pub(crate) type MatchIndices = Vec<IndexType>;
22
23
/// Trait for fuzzy matching text patterns against choices
24
pub trait FuzzyMatcher: Send + Sync {
25
    /// fuzzy match choice with pattern, and return the score & matched indices of characters
26
    fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(i64, MatchIndices)>;
27
28
    /// fuzzy match choice with pattern, and return the score of matching
29
2
    fn fuzzy_match(&self, choice: &str, pattern: &str) -> Option<i64> {
30
2
        self.fuzzy_indices(choice, pattern).map(|(score, _)| score)
31
2
    }
32
33
    /// Fuzzy match and return (score, `begin_char_index`, `end_char_index`) without
34
    /// computing per-character match indices. This avoids the Vec allocation and
35
    /// traceback that `fuzzy_indices` requires, making it much faster for ranking.
36
    ///
37
    /// `begin` is the character index of the first matched pattern character,
38
    /// `end` is the character index of the last matched pattern character.
39
    ///
40
    /// Default implementation falls back to `fuzzy_indices`.
41
5
    fn fuzzy_match_range(&self, choice: &str, pattern: &str) -> Option<(i64, usize, usize)> {
42
5
        self.fuzzy_indices(choice, pattern).map(|(score, indices)| 
{3
43
3
            let begin = indices.first().copied().unwrap_or(0);
44
3
            let end = indices.last().copied().unwrap_or(0);
45
3
            (score, begin, end)
46
3
        })
47
5
    }
48
}
49
50
#[cfg(test)]
51
#[cfg_attr(coverage, coverage(off))]
52
mod tests {
53
    use super::*;
54
55
    /// A matcher that only implements `fuzzy_indices`, so it exercises the
56
    /// default `fuzzy_match` / `fuzzy_match_range` implementations.
57
    struct StubMatcher;
58
59
    impl FuzzyMatcher for StubMatcher {
60
        fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(i64, MatchIndices)> {
61
            if pattern.is_empty() {
62
                return Some((0, vec![]));
63
            }
64
            // Match only when the pattern is a prefix of the choice.
65
            choice
66
                .starts_with(pattern)
67
                .then(|| (10, (0..pattern.chars().count()).collect()))
68
        }
69
    }
70
71
    #[test]
72
    fn default_fuzzy_match_uses_indices_score() {
73
        assert_eq!(StubMatcher.fuzzy_match("hello", "he"), Some(10));
74
        assert_eq!(StubMatcher.fuzzy_match("hello", "xy"), None);
75
    }
76
77
    #[test]
78
    fn default_fuzzy_match_range_spans_first_to_last() {
79
        assert_eq!(StubMatcher.fuzzy_match_range("hello", "hel"), Some((10, 0, 2)));
80
        assert_eq!(StubMatcher.fuzzy_match_range("hello", "zz"), None);
81
    }
82
83
    #[test]
84
    fn default_fuzzy_match_range_empty_indices_default_to_zero() {
85
        // Empty pattern yields an empty index list, so begin/end fall back to 0.
86
        assert_eq!(StubMatcher.fuzzy_match_range("hello", ""), Some((0, 0, 0)));
87
    }
88
89
    /// Regression test for a fuzzer-found panic (fuzz target `fuzzy_match`).
90
    ///
91
    /// 'İ' (U+0130) lowercases to two chars, and `char_equal` used to be
92
    /// asymmetric for such characters. `cheap_matches` compares
93
    /// `(choice, pattern)` while the matchers' `allow_match` helpers compare
94
    /// `(pattern, choice)`, so the cheap pre-filter accepted a candidate the DP
95
    /// then refused to match. The clangd matcher's backtracking loop walked off
96
    /// the start of its matrix, panicking with "attempt to subtract with
97
    /// overflow" in debug and an out-of-bounds index in release.
98
    #[test]
99
    fn multichar_lowercase_does_not_panic() {
100
        use crate::fuzzy_matcher::clangd::ClangdMatcher;
101
        use crate::fuzzy_matcher::fzy::FzyMatcher;
102
        use crate::fuzzy_matcher::skim::SkimMatcherV2;
103
104
        let skim = SkimMatcherV2::default();
105
        let fzy = FzyMatcher::default();
106
        let clangd = ClangdMatcher::default();
107
        let matchers: [(&str, &dyn FuzzyMatcher); 3] = [("skim", &skim), ("fzy", &fzy), ("clangd", &clangd)];
108
109
        // The exact crashing input from the fuzz artifact, plus related shapes.
110
        let cases = [
111
            ("Jİ:I", "İ:İ"),
112
            ("I", "İ"),
113
            ("İ", "I"),
114
            ("i", "İ"),
115
            ("İ", "i"),
116
            ("Jİ:Iİ", "İİ"),
117
            ("straße", "STRASSE"),
118
            ("ffly", "ffl"),
119
        ];
120
121
        for (choice, pattern) in cases {
122
            let num_chars = choice.chars().count();
123
            for (name, matcher) in matchers {
124
                // Must not panic, and any returned index must be a valid char
125
                // index into `choice` (the invariant asserted by the fuzzer).
126
                if let Some((_score, indices)) = matcher.fuzzy_indices(choice, pattern) {
127
                    for idx in indices {
128
                        assert!(
129
                            idx < num_chars,
130
                            "{name}: match index {idx} out of bounds for {choice:?} ({num_chars} chars)"
131
                        );
132
                    }
133
                }
134
            }
135
        }
136
    }
137
}
\ No newline at end of file +

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/mod.rs
Line
Count
Source
1
//! Fuzzy matching algorithms and implementations.
2
//!
3
//! This module provides different fuzzy matching algorithms including
4
//! skim's own algorithm and clangd's algorithm for matching text patterns.
5
6
/// Arinae fuzzy matching algorithm (Smith-Waterman with affine gaps)
7
pub mod arinae;
8
/// Clangd fuzzy matching algorithm
9
pub mod clangd;
10
#[cfg(feature = "frizbee")]
11
pub mod frizbee;
12
/// Fzy fuzzy matching algorithm
13
pub mod fzy;
14
/// Skim fuzzy matching algorithm
15
pub mod skim;
16
mod util;
17
18
pub(crate) type IndexType = usize;
19
pub(crate) type ScoreType = i64;
20
21
pub(crate) type MatchIndices = Vec<IndexType>;
22
23
/// Trait for fuzzy matching text patterns against choices
24
pub trait FuzzyMatcher: Send + Sync {
25
    /// fuzzy match choice with pattern, and return the score & matched indices of characters
26
    fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(i64, MatchIndices)>;
27
28
    /// fuzzy match choice with pattern, and return the score of matching
29
2
    fn fuzzy_match(&self, choice: &str, pattern: &str) -> Option<i64> {
30
2
        self.fuzzy_indices(choice, pattern).map(|(score, _)| score)
31
2
    }
32
33
    /// Fuzzy match and return (score, `begin_char_index`, `end_char_index`) without
34
    /// computing per-character match indices. This avoids the Vec allocation and
35
    /// traceback that `fuzzy_indices` requires, making it much faster for ranking.
36
    ///
37
    /// `begin` is the character index of the first matched pattern character,
38
    /// `end` is the character index of the last matched pattern character.
39
    ///
40
    /// Default implementation falls back to `fuzzy_indices`.
41
5
    fn fuzzy_match_range(&self, choice: &str, pattern: &str) -> Option<(i64, usize, usize)> {
42
5
        self.fuzzy_indices(choice, pattern).map(|(score, indices)| 
{3
43
3
            let begin = indices.first().copied().unwrap_or(0);
44
3
            let end = indices.last().copied().unwrap_or(0);
45
3
            (score, begin, end)
46
3
        })
47
5
    }
48
}
49
50
#[cfg(test)]
51
#[cfg_attr(coverage, coverage(off))]
52
mod tests {
53
    use super::*;
54
55
    /// A matcher that only implements `fuzzy_indices`, so it exercises the
56
    /// default `fuzzy_match` / `fuzzy_match_range` implementations.
57
    struct StubMatcher;
58
59
    impl FuzzyMatcher for StubMatcher {
60
        fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(i64, MatchIndices)> {
61
            if pattern.is_empty() {
62
                return Some((0, vec![]));
63
            }
64
            // Match only when the pattern is a prefix of the choice.
65
            choice
66
                .starts_with(pattern)
67
                .then(|| (10, (0..pattern.chars().count()).collect()))
68
        }
69
    }
70
71
    #[test]
72
    fn default_fuzzy_match_uses_indices_score() {
73
        assert_eq!(StubMatcher.fuzzy_match("hello", "he"), Some(10));
74
        assert_eq!(StubMatcher.fuzzy_match("hello", "xy"), None);
75
    }
76
77
    #[test]
78
    fn default_fuzzy_match_range_spans_first_to_last() {
79
        assert_eq!(StubMatcher.fuzzy_match_range("hello", "hel"), Some((10, 0, 2)));
80
        assert_eq!(StubMatcher.fuzzy_match_range("hello", "zz"), None);
81
    }
82
83
    #[test]
84
    fn default_fuzzy_match_range_empty_indices_default_to_zero() {
85
        // Empty pattern yields an empty index list, so begin/end fall back to 0.
86
        assert_eq!(StubMatcher.fuzzy_match_range("hello", ""), Some((0, 0, 0)));
87
    }
88
89
    /// Regression test for a fuzzer-found panic (fuzz target `fuzzy_match`).
90
    ///
91
    /// 'İ' (U+0130) lowercases to two chars, and `char_equal` used to be
92
    /// asymmetric for such characters. `cheap_matches` compares
93
    /// `(choice, pattern)` while the matchers' `allow_match` helpers compare
94
    /// `(pattern, choice)`, so the cheap pre-filter accepted a candidate the DP
95
    /// then refused to match. The clangd matcher's backtracking loop walked off
96
    /// the start of its matrix, panicking with "attempt to subtract with
97
    /// overflow" in debug and an out-of-bounds index in release.
98
    #[test]
99
    fn multichar_lowercase_does_not_panic() {
100
        use crate::fuzzy_matcher::clangd::ClangdMatcher;
101
        use crate::fuzzy_matcher::fzy::FzyMatcher;
102
        use crate::fuzzy_matcher::skim::SkimMatcherV2;
103
104
        let skim = SkimMatcherV2::default();
105
        let fzy = FzyMatcher::default();
106
        let clangd = ClangdMatcher::default();
107
        let matchers: [(&str, &dyn FuzzyMatcher); 3] = [("skim", &skim), ("fzy", &fzy), ("clangd", &clangd)];
108
109
        // The exact crashing input from the fuzz artifact, plus related shapes.
110
        let cases = [
111
            ("Jİ:I", "İ:İ"),
112
            ("I", "İ"),
113
            ("İ", "I"),
114
            ("i", "İ"),
115
            ("İ", "i"),
116
            ("Jİ:Iİ", "İİ"),
117
            ("straße", "STRASSE"),
118
            ("ffly", "ffl"),
119
        ];
120
121
        for (choice, pattern) in cases {
122
            let num_chars = choice.chars().count();
123
            for (name, matcher) in matchers {
124
                // Must not panic, and any returned index must be a valid char
125
                // index into `choice` (the invariant asserted by the fuzzer).
126
                if let Some((_score, indices)) = matcher.fuzzy_indices(choice, pattern) {
127
                    for idx in indices {
128
                        assert!(
129
                            idx < num_chars,
130
                            "{name}: match index {idx} out of bounds for {choice:?} ({num_chars} chars)"
131
                        );
132
                    }
133
                }
134
            }
135
        }
136
    }
137
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/skim.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/skim.rs.html index 8de0ee3e..2324a97c 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/skim.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/skim.rs.html @@ -1,8 +1,8 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/skim.rs
Line
Count
Source
1
//! The fuzzy matching algorithm used by skim
2
//!
3
//! # Example:
4
//! ```
5
//! use skim::fuzzy_matcher::FuzzyMatcher;
6
//! use skim::fuzzy_matcher::skim::SkimMatcherV2;
7
//!
8
//! let matcher = SkimMatcherV2::default();
9
//! assert_eq!(None, matcher.fuzzy_match("abc", "abx"));
10
//! assert!(matcher.fuzzy_match("axbycz", "abc").is_some());
11
//! assert!(matcher.fuzzy_match("axbycz", "xyz").is_some());
12
//!
13
//! let (score, indices) = matcher.fuzzy_indices("axbycz", "abc").unwrap();
14
//! assert_eq!(indices, [0, 2, 4]);
15
//! ```
16
17
#![allow(deprecated)]
18
19
use std::cell::RefCell;
20
use std::cmp::max;
21
use std::fmt::Formatter;
22
23
use thread_local::ThreadLocal;
24
25
use super::skim::Movement::{Match, Skip};
26
use super::util::{char_equal, cheap_matches};
27
use super::{FuzzyMatcher, IndexType, MatchIndices, ScoreType};
28
29
#[derive(Copy, Clone, Debug)]
30
/// Configuration for skim's scoring algorithm
31
pub struct SkimScoreConfig {
32
    /// Score for each matched character
33
    pub score_match: i32,
34
    /// Penalty for starting a gap (unmatched characters)
35
    pub gap_start: i32,
36
    /// Penalty for extending a gap
37
    pub gap_extension: i32,
38
39
    /// The first character in the typed pattern usually has more significance
40
    /// than the rest so it's important that it appears at special positions where
41
    /// bonus points are given. e.g. "to-go" vs. "ongoing" on "og" or on "ogo".
42
    /// The amount of the extra bonus should be limited so that the gap penalty is
43
    /// still respected.
44
    pub bonus_first_char_multiplier: i32,
45
46
    /// We prefer matches at the beginning of a word, but the bonus should not be
47
    /// too great to prevent the longer acronym matches from always winning over
48
    /// shorter fuzzy matches. The bonus point here was specifically chosen that
49
    /// the bonus is cancelled when the gap between the acronyms grows over
50
    /// 8 characters, which is approximately the average length of the words found
51
    /// in web2 dictionary and my file system.
52
    pub bonus_head: i32,
53
54
    /// Just like `bonus_head`, but its breakage of word is not that strong, so it should
55
    /// be slighter less then `bonus_head`
56
    pub bonus_break: i32,
57
58
    /// Edge-triggered bonus for matches in camelCase words.
59
    /// Compared to word-boundary case, they don't accompany single-character gaps
60
    /// (e.g. `FooBar` vs. foo-bar), so we deduct bonus point accordingly.
61
    pub bonus_camel: i32,
62
63
    /// Minimum bonus point given to characters in consecutive chunks.
64
    /// Note that bonus points for consecutive matches shouldn't have needed if we
65
    /// used fixed match score as in the original algorithm.
66
    pub bonus_consecutive: i32,
67
68
    /// Skim will match case-sensitively if the pattern contains ASCII upper case,
69
    /// If case of case insensitive match, the penalty will be given to case mismatch
70
    pub penalty_case_mismatch: i32,
71
}
72
73
impl Default for SkimScoreConfig {
74
39
    fn default() -> Self {
75
39
        let score_match = 16;
76
39
        let gap_start = -3;
77
39
        let gap_extension = -1;
78
39
        let bonus_first_char_multiplier = 2;
79
80
39
        Self {
81
39
            score_match,
82
39
            gap_start,
83
39
            gap_extension,
84
39
            bonus_first_char_multiplier,
85
39
            bonus_head: score_match / 2,
86
39
            bonus_break: score_match / 2 + gap_extension,
87
39
            bonus_camel: score_match / 2 + 2 * gap_extension,
88
39
            bonus_consecutive: -(gap_start + gap_extension),
89
39
            penalty_case_mismatch: gap_extension * 2,
90
39
        }
91
39
    }
92
}
93
94
#[derive(Debug, Copy, Clone, PartialEq)]
95
enum Movement {
96
    Match,
97
    Skip,
98
}
99
100
/// Inner state of the score matrix
101
// Implementation detail: tried to pad to 16B
102
// will store the m and p matrix together
103
#[derive(Clone, Debug)]
104
struct MatrixCell {
105
    pub m_move: Movement,
106
    pub m_score: i32,
107
    pub p_move: Movement,
108
    pub p_score: i32, // The max score of align pattern[..i] & choice[..j]
109
110
    // temporary fields (make use the rest of the padding)
111
    pub matched: bool,
112
    pub bonus: i32,
113
}
114
115
const MATRIX_CELL_NEG_INFINITY: i32 = i16::MIN as i32;
116
117
impl Default for MatrixCell {
118
129
    fn default() -> Self {
119
129
        Self {
120
129
            m_move: Skip,
121
129
            m_score: MATRIX_CELL_NEG_INFINITY,
122
129
            p_move: Skip,
123
129
            p_score: MATRIX_CELL_NEG_INFINITY,
124
129
            matched: false,
125
129
            bonus: 0,
126
129
        }
127
129
    }
128
}
129
130
impl MatrixCell {
131
1.28k
    pub fn reset(&mut self) {
132
1.28k
        self.m_move = Skip;
133
1.28k
        self.m_score = MATRIX_CELL_NEG_INFINITY;
134
1.28k
        self.p_move = Skip;
135
1.28k
        self.p_score = MATRIX_CELL_NEG_INFINITY;
136
1.28k
        self.bonus = 0;
137
1.28k
        self.matched = false;
138
1.28k
    }
139
}
140
141
/// Simulate a 1-D vector as 2-D matrix
142
struct ScoreMatrix<'a> {
143
    matrix: &'a mut [MatrixCell],
144
    pub rows: usize,
145
    pub cols: usize,
146
}
147
148
impl<'a> ScoreMatrix<'a> {
149
    /// given a matrix, extend it to be (rows x cols) and fill in as `init_val`
150
129
    pub fn new(matrix: &'a mut Vec<MatrixCell>, rows: usize, cols: usize) -> Self {
151
129
        matrix.resize(rows * cols, MatrixCell::default());
152
129
        ScoreMatrix { matrix, rows, cols }
153
129
    }
154
155
    #[inline]
156
2.68k
    fn get_index(&self, row: usize, col: usize) -> usize {
157
2.68k
        row * self.cols + col
158
2.68k
    }
159
160
129
    fn get_row(&self, row: usize) -> &[MatrixCell] {
161
129
        let start = row * self.cols;
162
129
        &self.matrix[start..start + self.cols]
163
129
    }
164
}
165
166
impl std::ops::Index<(usize, usize)> for ScoreMatrix<'_> {
167
    type Output = MatrixCell;
168
169
490
    fn index(&self, index: (usize, usize)) -> &Self::Output {
170
490
        &self.matrix[self.get_index(index.0, index.1)]
171
490
    }
172
}
173
174
impl std::ops::IndexMut<(usize, usize)> for ScoreMatrix<'_> {
175
2.19k
    fn index_mut(&mut self, index: (usize, usize)) -> &mut Self::Output {
176
2.19k
        &mut self.matrix[self.get_index(index.0, index.1)]
177
2.19k
    }
178
}
179
180
impl std::fmt::Debug for ScoreMatrix<'_> {
181
5
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
182
5
        let _ = writeln!(f, "M score:");
183
15
        for row in 
0..self.rows5
{
184
114
            for col in 
0..self.cols15
{
185
114
                let cell = &self[(row, col)];
186
114
                write!(
187
114
                    f,
188
                    "{:4}/{}  ",
189
114
                    if cell.m_score == MATRIX_CELL_NEG_INFINITY {
  Branch (189:24): [True: 0, False: 0]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/skim.rs
Line
Count
Source
1
//! The fuzzy matching algorithm used by skim
2
//!
3
//! # Example:
4
//! ```
5
//! use skim::fuzzy_matcher::FuzzyMatcher;
6
//! use skim::fuzzy_matcher::skim::SkimMatcherV2;
7
//!
8
//! let matcher = SkimMatcherV2::default();
9
//! assert_eq!(None, matcher.fuzzy_match("abc", "abx"));
10
//! assert!(matcher.fuzzy_match("axbycz", "abc").is_some());
11
//! assert!(matcher.fuzzy_match("axbycz", "xyz").is_some());
12
//!
13
//! let (score, indices) = matcher.fuzzy_indices("axbycz", "abc").unwrap();
14
//! assert_eq!(indices, [0, 2, 4]);
15
//! ```
16
17
#![allow(deprecated)]
18
19
use std::cell::RefCell;
20
use std::cmp::max;
21
use std::fmt::Formatter;
22
23
use thread_local::ThreadLocal;
24
25
use super::skim::Movement::{Match, Skip};
26
use super::util::{char_equal, cheap_matches};
27
use super::{FuzzyMatcher, IndexType, MatchIndices, ScoreType};
28
29
#[derive(Copy, Clone, Debug)]
30
/// Configuration for skim's scoring algorithm
31
pub struct SkimScoreConfig {
32
    /// Score for each matched character
33
    pub score_match: i32,
34
    /// Penalty for starting a gap (unmatched characters)
35
    pub gap_start: i32,
36
    /// Penalty for extending a gap
37
    pub gap_extension: i32,
38
39
    /// The first character in the typed pattern usually has more significance
40
    /// than the rest so it's important that it appears at special positions where
41
    /// bonus points are given. e.g. "to-go" vs. "ongoing" on "og" or on "ogo".
42
    /// The amount of the extra bonus should be limited so that the gap penalty is
43
    /// still respected.
44
    pub bonus_first_char_multiplier: i32,
45
46
    /// We prefer matches at the beginning of a word, but the bonus should not be
47
    /// too great to prevent the longer acronym matches from always winning over
48
    /// shorter fuzzy matches. The bonus point here was specifically chosen that
49
    /// the bonus is cancelled when the gap between the acronyms grows over
50
    /// 8 characters, which is approximately the average length of the words found
51
    /// in web2 dictionary and my file system.
52
    pub bonus_head: i32,
53
54
    /// Just like `bonus_head`, but its breakage of word is not that strong, so it should
55
    /// be slighter less then `bonus_head`
56
    pub bonus_break: i32,
57
58
    /// Edge-triggered bonus for matches in camelCase words.
59
    /// Compared to word-boundary case, they don't accompany single-character gaps
60
    /// (e.g. `FooBar` vs. foo-bar), so we deduct bonus point accordingly.
61
    pub bonus_camel: i32,
62
63
    /// Minimum bonus point given to characters in consecutive chunks.
64
    /// Note that bonus points for consecutive matches shouldn't have needed if we
65
    /// used fixed match score as in the original algorithm.
66
    pub bonus_consecutive: i32,
67
68
    /// Skim will match case-sensitively if the pattern contains ASCII upper case,
69
    /// If case of case insensitive match, the penalty will be given to case mismatch
70
    pub penalty_case_mismatch: i32,
71
}
72
73
impl Default for SkimScoreConfig {
74
38
    fn default() -> Self {
75
38
        let score_match = 16;
76
38
        let gap_start = -3;
77
38
        let gap_extension = -1;
78
38
        let bonus_first_char_multiplier = 2;
79
80
38
        Self {
81
38
            score_match,
82
38
            gap_start,
83
38
            gap_extension,
84
38
            bonus_first_char_multiplier,
85
38
            bonus_head: score_match / 2,
86
38
            bonus_break: score_match / 2 + gap_extension,
87
38
            bonus_camel: score_match / 2 + 2 * gap_extension,
88
38
            bonus_consecutive: -(gap_start + gap_extension),
89
38
            penalty_case_mismatch: gap_extension * 2,
90
38
        }
91
38
    }
92
}
93
94
#[derive(Debug, Copy, Clone, PartialEq)]
95
enum Movement {
96
    Match,
97
    Skip,
98
}
99
100
/// Inner state of the score matrix
101
// Implementation detail: tried to pad to 16B
102
// will store the m and p matrix together
103
#[derive(Clone, Debug)]
104
struct MatrixCell {
105
    pub m_move: Movement,
106
    pub m_score: i32,
107
    pub p_move: Movement,
108
    pub p_score: i32, // The max score of align pattern[..i] & choice[..j]
109
110
    // temporary fields (make use the rest of the padding)
111
    pub matched: bool,
112
    pub bonus: i32,
113
}
114
115
const MATRIX_CELL_NEG_INFINITY: i32 = i16::MIN as i32;
116
117
impl Default for MatrixCell {
118
129
    fn default() -> Self {
119
129
        Self {
120
129
            m_move: Skip,
121
129
            m_score: MATRIX_CELL_NEG_INFINITY,
122
129
            p_move: Skip,
123
129
            p_score: MATRIX_CELL_NEG_INFINITY,
124
129
            matched: false,
125
129
            bonus: 0,
126
129
        }
127
129
    }
128
}
129
130
impl MatrixCell {
131
1.28k
    pub fn reset(&mut self) {
132
1.28k
        self.m_move = Skip;
133
1.28k
        self.m_score = MATRIX_CELL_NEG_INFINITY;
134
1.28k
        self.p_move = Skip;
135
1.28k
        self.p_score = MATRIX_CELL_NEG_INFINITY;
136
1.28k
        self.bonus = 0;
137
1.28k
        self.matched = false;
138
1.28k
    }
139
}
140
141
/// Simulate a 1-D vector as 2-D matrix
142
struct ScoreMatrix<'a> {
143
    matrix: &'a mut [MatrixCell],
144
    pub rows: usize,
145
    pub cols: usize,
146
}
147
148
impl<'a> ScoreMatrix<'a> {
149
    /// given a matrix, extend it to be (rows x cols) and fill in as `init_val`
150
129
    pub fn new(matrix: &'a mut Vec<MatrixCell>, rows: usize, cols: usize) -> Self {
151
129
        matrix.resize(rows * cols, MatrixCell::default());
152
129
        ScoreMatrix { matrix, rows, cols }
153
129
    }
154
155
    #[inline]
156
2.68k
    fn get_index(&self, row: usize, col: usize) -> usize {
157
2.68k
        row * self.cols + col
158
2.68k
    }
159
160
129
    fn get_row(&self, row: usize) -> &[MatrixCell] {
161
129
        let start = row * self.cols;
162
129
        &self.matrix[start..start + self.cols]
163
129
    }
164
}
165
166
impl std::ops::Index<(usize, usize)> for ScoreMatrix<'_> {
167
    type Output = MatrixCell;
168
169
490
    fn index(&self, index: (usize, usize)) -> &Self::Output {
170
490
        &self.matrix[self.get_index(index.0, index.1)]
171
490
    }
172
}
173
174
impl std::ops::IndexMut<(usize, usize)> for ScoreMatrix<'_> {
175
2.19k
    fn index_mut(&mut self, index: (usize, usize)) -> &mut Self::Output {
176
2.19k
        &mut self.matrix[self.get_index(index.0, index.1)]
177
2.19k
    }
178
}
179
180
impl std::fmt::Debug for ScoreMatrix<'_> {
181
5
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
182
5
        let _ = writeln!(f, "M score:");
183
15
        for row in 
0..self.rows5
{
184
114
            for col in 
0..self.cols15
{
185
114
                let cell = &self[(row, col)];
186
114
                write!(
187
114
                    f,
188
                    "{:4}/{}  ",
189
114
                    if cell.m_score == MATRIX_CELL_NEG_INFINITY {
  Branch (189:24): [True: 0, False: 0]
 
  Branch (189:24): [True: 100, False: 14]
 
190
100
                        -999
191
                    } else {
192
14
                        cell.m_score
193
                    },
194
114
                    match cell.m_move {
195
0
                        Match => 'M',
196
114
                        Skip => 'S',
197
                    }
198
0
                )?;
199
            }
200
15
            writeln!(f)
?0
;
201
        }
202
203
5
        let _ = writeln!(f, "P score:");
204
15
        for row in 
0..self.rows5
{
205
114
            for col in 
0..self.cols15
{
206
114
                let cell = &self[(row, col)];
207
114
                write!(
208
114
                    f,
209
                    "{:4}/{}  ",
210
114
                    if cell.p_score == MATRIX_CELL_NEG_INFINITY {
  Branch (210:24): [True: 0, False: 0]
 
  Branch (210:24): [True: 32, False: 82]
-
211
32
                        -999
212
                    } else {
213
82
                        cell.p_score
214
                    },
215
114
                    match cell.p_move {
216
12
                        Match => 'M',
217
102
                        Skip => 'S',
218
                    }
219
0
                )?;
220
            }
221
15
            writeln!(f)
?0
;
222
        }
223
224
5
        Ok(())
225
5
    }
226
}
227
228
/// We categorize characters into types:
229
///
230
/// - Empty(E): the start of string
231
/// - Upper(U): the ascii upper case
232
/// - lower(L): the ascii lower case & other unicode characters
233
/// - number(N): ascii number
234
/// - hard separator(S): clearly separate the content: ` ` `/` `\` `|` `(` `)` `[` `]` `{` `}`
235
/// - soft separator(s): other ascii punctuation, e.g. `!` `"` `#` `$`, ...
236
#[derive(Debug, PartialEq, Copy, Clone)]
237
enum CharType {
238
    Empty,
239
    Upper,
240
    Lower,
241
    Number,
242
    HardSep,
243
    SoftSep,
244
}
245
246
impl CharType {
247
1.65k
    pub fn of(ch: char) -> Self {
248
1.65k
        match ch {
249
138
            '\0' => CharType::Empty,
250
22
            ' ' | '/' | '\\' | '|' | '(' | ')' | '[' | ']' | '{' | '}' => CharType::HardSep,
251
1.49k
            '!'..='\'' | '*'..='.' | 
':'..='@'1.19k
|
'^'..='`'1.01k
| '~' =>
CharType::SoftSep104
,
252
1.38k
            '0'..='9' => 
CharType::Number265
,
253
1.12k
            'A'..='Z' => 
CharType::Upper174
,
254
947
            _ => CharType::Lower,
255
        }
256
1.65k
    }
257
}
258
259
/// Ref: <https://github.com/llvm-mirror/clang-tools-extra/blob/master/clangd/FuzzyMatch.cpp>
260
///
261
///
262
/// ```text
263
/// +-----------+--------------+-------+
264
/// | Example   | Chars | Type | Role  |
265
/// +-----------+--------------+-------+
266
/// | (f)oo     | ^fo   | Ell  | Head  |
267
/// | (F)oo     | ^Fo   | EUl  | Head  |
268
/// | Foo/(B)ar | /Ba   | SUl  | Head  |
269
/// | Foo/(b)ar | /ba   | Sll  | Head  |
270
/// | Foo.(B)ar | .Ba   | SUl  | Break |
271
/// | Foo(B)ar  | oBa   | lUl  | Camel |
272
/// | 123(B)ar  | 3Ba   | nUl  | Camel |
273
/// | F(o)oBar  | Foo   | Ull  | Tail  |
274
/// | H(T)TP    | HTT   | UUU  | Tail  |
275
/// | others    |       |      | Tail  |
276
/// +-----------+--------------+-------+
277
#[derive(Debug, PartialEq, Copy, Clone)]
278
enum CharRole {
279
    Head,
280
    Tail,
281
    Camel,
282
    Break,
283
}
284
285
impl CharRole {
286
825
    pub fn of_type(prev: CharType, cur: CharType) -> Self {
287
825
        match (prev, cur) {
288
149
            (CharType::Empty | CharType::HardSep, _) => CharRole::Head,
289
52
            (CharType::SoftSep, _) => CharRole::Break,
290
18
            (CharType::Lower | CharType::Number, CharType::Upper) => CharRole::Camel,
291
606
            _ => CharRole::Tail,
292
        }
293
825
    }
294
}
295
296
#[derive(Eq, PartialEq, Debug, Copy, Clone)]
297
enum CaseMatching {
298
    Respect,
299
    Ignore,
300
    Smart,
301
}
302
303
/// Fuzzy matching is a sub problem is sequence alignment.
304
/// Specifically what we'd like to implement is sequence alignment with affine gap penalty.
305
/// Ref: <https://www.cs.cmu.edu>/~ckingsf/bioinfo-lectures/gaps.pdf
306
///
307
/// Given `pattern`(i) and `choice`(j), we'll maintain 2 score matrix:
308
///
309
/// ```text
310
/// M[i][j] = match(i, j) + max(M[i-1][j-1] + consecutive, P[i-1][j-1])
311
/// M[i][j] = -infinity if p[i][j] do not match
312
///
313
/// M[i][j] means the score of best alignment of p[..=i] and c[..=j] ending with match/mismatch e.g.:
314
///
315
/// c: [.........]b
316
/// p: [.........]b
317
///
318
/// So that p[..=i-1] and c[..=j-1] could be any alignment
319
///
320
/// P[i][j] = max(M[i][j-k]-gap(k)) for k in 1..j
321
///
322
/// P[i][j] means the score of best alignment of p[..=i] and c[..=j] where c[j] is not matched.
323
/// So that we need to search through all the previous matches, and calculate the gap.
324
///
325
///   (j-k)--.   j
326
/// c: [....]bcdef
327
/// p: [....]b----
328
///          i
329
/// ```
330
///
331
/// Note that the above is O(n^3) in the worst case. However the above algorithm uses a general gap
332
/// penalty, but we use affine gap: `gap = gap_start + k * gap_extend` where:
333
/// - u: the cost of starting of gap
334
/// - v: the cost of extending a gap by one more space.
335
///
336
/// So that we could optimize the algorithm by:
337
///
338
/// ```text
339
/// P[i][j] = max(gap_start + gap_extend + M[i][j-1], gap_extend + P[i][j-1])
340
/// ```
341
///
342
/// Besides, since we are doing fuzzy matching, we'll prefer some pattern over others.
343
/// So we'll calculate in-place bonus for each character. e.g. bonus for camel cases.
344
///
345
/// In summary:
346
///
347
/// ```text
348
/// B[j] = in_place_bonus_of(j)
349
/// M[i][j] = match(i, j) + max(M[i-1][j-1] + consecutive, P[i-1][j-1])
350
/// M[i][j] = -infinity if p[i] and c[j] do not match
351
/// P[i][j] = max(gap_start + gap_extend + M[i][j-1], gap_extend + P[i][j-1])
352
/// ```
353
#[derive(Debug)]
354
pub struct SkimMatcherV2 {
355
    debug: bool,
356
357
    score_config: SkimScoreConfig,
358
    element_limit: usize,
359
    case: CaseMatching,
360
    use_cache: bool,
361
362
    m_cache: ThreadLocal<RefCell<Vec<MatrixCell>>>,
363
    c_cache: ThreadLocal<RefCell<Vec<char>>>, // vector to store the characters of choice
364
    p_cache: ThreadLocal<RefCell<Vec<char>>>, // vector to store the characters of pattern
365
}
366
367
impl Default for SkimMatcherV2 {
368
38
    fn default() -> Self {
369
38
        Self {
370
38
            debug: false,
371
38
            score_config: SkimScoreConfig::default(),
372
38
            element_limit: 0,
373
38
            case: CaseMatching::Smart,
374
38
            use_cache: true,
375
38
376
38
            m_cache: ThreadLocal::new(),
377
38
            c_cache: ThreadLocal::new(),
378
38
            p_cache: ThreadLocal::new(),
379
38
        }
380
38
    }
381
}
382
383
impl SkimMatcherV2 {
384
    /// Sets the scoring configuration for the matcher
385
    #[must_use]
386
1
    pub fn score_config(mut self, score_config: SkimScoreConfig) -> Self {
387
1
        self.score_config = score_config;
388
1
        self
389
1
    }
390
391
    /// Sets the maximum number of elements to process
392
    #[must_use]
393
19
    pub fn element_limit(mut self, elements: usize) -> Self {
394
19
        self.element_limit = elements;
395
19
        self
396
19
    }
397
398
    /// Sets the matcher to ignore case when matching
399
    #[must_use]
400
5
    pub fn ignore_case(mut self) -> Self {
401
5
        self.case = CaseMatching::Ignore;
402
5
        self
403
5
    }
404
405
    /// Sets the matcher to use smart case (case insensitive unless pattern contains uppercase)
406
    #[must_use]
407
16
    pub fn smart_case(mut self) -> Self {
408
16
        self.case = CaseMatching::Smart;
409
16
        self
410
16
    }
411
412
    /// Sets the matcher to respect case when matching
413
    #[must_use]
414
2
    pub fn respect_case(mut self) -> Self {
415
2
        self.case = CaseMatching::Respect;
416
2
        self
417
2
    }
418
419
    /// Enables or disables caching for improved performance
420
    #[must_use]
421
2
    pub fn use_cache(mut self, use_cache: bool) -> Self {
422
2
        self.use_cache = use_cache;
423
2
        self
424
2
    }
425
426
    /// Enables or disables debug mode
427
    #[must_use]
428
2
    pub fn debug(mut self, debug: bool) -> Self {
429
2
        self.debug = debug;
430
2
        self
431
2
    }
432
433
    /// Build the score matrix using the algorithm described above
434
129
    fn build_score_matrix(
435
129
        &self,
436
129
        m: &mut ScoreMatrix,
437
129
        choice: &[char],
438
129
        pattern: &[char],
439
129
        first_match_indices: &[usize],
440
129
        compressed: bool,
441
129
        case_sensitive: bool,
442
129
    ) {
443
129
        let mut in_place_bonuses = vec![0; m.cols];
444
445
129
        self.build_in_place_bonus(choice, &mut in_place_bonuses);
446
447
        // need to reset M[row][first_match] & M[i][j-1]
448
129
        m[(0, 0)].reset();
449
248
        for i in 
1..m.rows129
{
450
248
            m[(i, first_match_indices[i - 1])].reset();
451
248
        }
452
453
909
        for j in 
0..m.cols129
{
454
909
            // p[0][j]: the score of best alignment of p[] and c[..=j] where c[j] is not matched
455
909
            m[(0, j)].reset();
456
909
            m[(0, j)].p_score = self.score_config.gap_extension;
457
909
        }
458
459
        // update the matrix;
460
368
        for (i, &p_ch) in 
pattern129
.
iter129
().
enumerate129
() {
461
368
            let row = Self::adjust_row_idx(i + 1, compressed);
462
368
            let row_prev = Self::adjust_row_idx(i, compressed);
463
368
            let to_skip = first_match_indices[i];
464
465
            // Pre-calculate base indices to reduce repeated index calculations
466
368
            let row_base = row * m.cols;
467
368
            let row_prev_base = row_prev * m.cols;
468
469
1.54k
            for (j, &c_ch) in 
choice[to_skip..]368
.
iter368
().
enumerate368
() {
470
1.54k
                let col = to_skip + j + 1;
471
1.54k
                let col_prev = to_skip + j;
472
473
                // Use pre-calculated bases to reduce index calculations
474
1.54k
                let idx_cur = row_base + col;
475
1.54k
                let idx_last = row_base + col_prev;
476
1.54k
                let idx_prev = row_prev_base + col_prev;
477
478
                // Cache in_place_bonus lookup to avoid repeated array access
479
1.54k
                let in_place_bonus = in_place_bonuses[col];
480
481
                // update M matrix
482
                // M[i][j] = match(i, j) + max(M[i-1][j-1], P[i-1][j-1])
483
1.54k
                if let Some(
cur_match_score427
) = self.calculate_match_score(c_ch, p_ch, case_sensitive) {
  Branch (483:24): [True: 13, False: 86]
+
211
32
                        -999
212
                    } else {
213
82
                        cell.p_score
214
                    },
215
114
                    match cell.p_move {
216
12
                        Match => 'M',
217
102
                        Skip => 'S',
218
                    }
219
0
                )?;
220
            }
221
15
            writeln!(f)
?0
;
222
        }
223
224
5
        Ok(())
225
5
    }
226
}
227
228
/// We categorize characters into types:
229
///
230
/// - Empty(E): the start of string
231
/// - Upper(U): the ascii upper case
232
/// - lower(L): the ascii lower case & other unicode characters
233
/// - number(N): ascii number
234
/// - hard separator(S): clearly separate the content: ` ` `/` `\` `|` `(` `)` `[` `]` `{` `}`
235
/// - soft separator(s): other ascii punctuation, e.g. `!` `"` `#` `$`, ...
236
#[derive(Debug, PartialEq, Copy, Clone)]
237
enum CharType {
238
    Empty,
239
    Upper,
240
    Lower,
241
    Number,
242
    HardSep,
243
    SoftSep,
244
}
245
246
impl CharType {
247
1.65k
    pub fn of(ch: char) -> Self {
248
1.65k
        match ch {
249
138
            '\0' => CharType::Empty,
250
22
            ' ' | '/' | '\\' | '|' | '(' | ')' | '[' | ']' | '{' | '}' => CharType::HardSep,
251
1.49k
            '!'..='\'' | '*'..='.' | 
':'..='@'1.19k
|
'^'..='`'1.01k
| '~' =>
CharType::SoftSep104
,
252
1.38k
            '0'..='9' => 
CharType::Number265
,
253
1.12k
            'A'..='Z' => 
CharType::Upper174
,
254
947
            _ => CharType::Lower,
255
        }
256
1.65k
    }
257
}
258
259
/// Ref: <https://github.com/llvm-mirror/clang-tools-extra/blob/master/clangd/FuzzyMatch.cpp>
260
///
261
///
262
/// ```text
263
/// +-----------+--------------+-------+
264
/// | Example   | Chars | Type | Role  |
265
/// +-----------+--------------+-------+
266
/// | (f)oo     | ^fo   | Ell  | Head  |
267
/// | (F)oo     | ^Fo   | EUl  | Head  |
268
/// | Foo/(B)ar | /Ba   | SUl  | Head  |
269
/// | Foo/(b)ar | /ba   | Sll  | Head  |
270
/// | Foo.(B)ar | .Ba   | SUl  | Break |
271
/// | Foo(B)ar  | oBa   | lUl  | Camel |
272
/// | 123(B)ar  | 3Ba   | nUl  | Camel |
273
/// | F(o)oBar  | Foo   | Ull  | Tail  |
274
/// | H(T)TP    | HTT   | UUU  | Tail  |
275
/// | others    |       |      | Tail  |
276
/// +-----------+--------------+-------+
277
#[derive(Debug, PartialEq, Copy, Clone)]
278
enum CharRole {
279
    Head,
280
    Tail,
281
    Camel,
282
    Break,
283
}
284
285
impl CharRole {
286
825
    pub fn of_type(prev: CharType, cur: CharType) -> Self {
287
825
        match (prev, cur) {
288
149
            (CharType::Empty | CharType::HardSep, _) => CharRole::Head,
289
52
            (CharType::SoftSep, _) => CharRole::Break,
290
18
            (CharType::Lower | CharType::Number, CharType::Upper) => CharRole::Camel,
291
606
            _ => CharRole::Tail,
292
        }
293
825
    }
294
}
295
296
#[derive(Eq, PartialEq, Debug, Copy, Clone)]
297
enum CaseMatching {
298
    Respect,
299
    Ignore,
300
    Smart,
301
}
302
303
/// Fuzzy matching is a sub problem is sequence alignment.
304
/// Specifically what we'd like to implement is sequence alignment with affine gap penalty.
305
/// Ref: <https://www.cs.cmu.edu>/~ckingsf/bioinfo-lectures/gaps.pdf
306
///
307
/// Given `pattern`(i) and `choice`(j), we'll maintain 2 score matrix:
308
///
309
/// ```text
310
/// M[i][j] = match(i, j) + max(M[i-1][j-1] + consecutive, P[i-1][j-1])
311
/// M[i][j] = -infinity if p[i][j] do not match
312
///
313
/// M[i][j] means the score of best alignment of p[..=i] and c[..=j] ending with match/mismatch e.g.:
314
///
315
/// c: [.........]b
316
/// p: [.........]b
317
///
318
/// So that p[..=i-1] and c[..=j-1] could be any alignment
319
///
320
/// P[i][j] = max(M[i][j-k]-gap(k)) for k in 1..j
321
///
322
/// P[i][j] means the score of best alignment of p[..=i] and c[..=j] where c[j] is not matched.
323
/// So that we need to search through all the previous matches, and calculate the gap.
324
///
325
///   (j-k)--.   j
326
/// c: [....]bcdef
327
/// p: [....]b----
328
///          i
329
/// ```
330
///
331
/// Note that the above is O(n^3) in the worst case. However the above algorithm uses a general gap
332
/// penalty, but we use affine gap: `gap = gap_start + k * gap_extend` where:
333
/// - u: the cost of starting of gap
334
/// - v: the cost of extending a gap by one more space.
335
///
336
/// So that we could optimize the algorithm by:
337
///
338
/// ```text
339
/// P[i][j] = max(gap_start + gap_extend + M[i][j-1], gap_extend + P[i][j-1])
340
/// ```
341
///
342
/// Besides, since we are doing fuzzy matching, we'll prefer some pattern over others.
343
/// So we'll calculate in-place bonus for each character. e.g. bonus for camel cases.
344
///
345
/// In summary:
346
///
347
/// ```text
348
/// B[j] = in_place_bonus_of(j)
349
/// M[i][j] = match(i, j) + max(M[i-1][j-1] + consecutive, P[i-1][j-1])
350
/// M[i][j] = -infinity if p[i] and c[j] do not match
351
/// P[i][j] = max(gap_start + gap_extend + M[i][j-1], gap_extend + P[i][j-1])
352
/// ```
353
#[derive(Debug)]
354
pub struct SkimMatcherV2 {
355
    debug: bool,
356
357
    score_config: SkimScoreConfig,
358
    element_limit: usize,
359
    case: CaseMatching,
360
    use_cache: bool,
361
362
    m_cache: ThreadLocal<RefCell<Vec<MatrixCell>>>,
363
    c_cache: ThreadLocal<RefCell<Vec<char>>>, // vector to store the characters of choice
364
    p_cache: ThreadLocal<RefCell<Vec<char>>>, // vector to store the characters of pattern
365
}
366
367
impl Default for SkimMatcherV2 {
368
37
    fn default() -> Self {
369
37
        Self {
370
37
            debug: false,
371
37
            score_config: SkimScoreConfig::default(),
372
37
            element_limit: 0,
373
37
            case: CaseMatching::Smart,
374
37
            use_cache: true,
375
37
376
37
            m_cache: ThreadLocal::new(),
377
37
            c_cache: ThreadLocal::new(),
378
37
            p_cache: ThreadLocal::new(),
379
37
        }
380
37
    }
381
}
382
383
impl SkimMatcherV2 {
384
    /// Sets the scoring configuration for the matcher
385
    #[must_use]
386
1
    pub fn score_config(mut self, score_config: SkimScoreConfig) -> Self {
387
1
        self.score_config = score_config;
388
1
        self
389
1
    }
390
391
    /// Sets the maximum number of elements to process
392
    #[must_use]
393
18
    pub fn element_limit(mut self, elements: usize) -> Self {
394
18
        self.element_limit = elements;
395
18
        self
396
18
    }
397
398
    /// Sets the matcher to ignore case when matching
399
    #[must_use]
400
5
    pub fn ignore_case(mut self) -> Self {
401
5
        self.case = CaseMatching::Ignore;
402
5
        self
403
5
    }
404
405
    /// Sets the matcher to use smart case (case insensitive unless pattern contains uppercase)
406
    #[must_use]
407
15
    pub fn smart_case(mut self) -> Self {
408
15
        self.case = CaseMatching::Smart;
409
15
        self
410
15
    }
411
412
    /// Sets the matcher to respect case when matching
413
    #[must_use]
414
2
    pub fn respect_case(mut self) -> Self {
415
2
        self.case = CaseMatching::Respect;
416
2
        self
417
2
    }
418
419
    /// Enables or disables caching for improved performance
420
    #[must_use]
421
2
    pub fn use_cache(mut self, use_cache: bool) -> Self {
422
2
        self.use_cache = use_cache;
423
2
        self
424
2
    }
425
426
    /// Enables or disables debug mode
427
    #[must_use]
428
2
    pub fn debug(mut self, debug: bool) -> Self {
429
2
        self.debug = debug;
430
2
        self
431
2
    }
432
433
    /// Build the score matrix using the algorithm described above
434
129
    fn build_score_matrix(
435
129
        &self,
436
129
        m: &mut ScoreMatrix,
437
129
        choice: &[char],
438
129
        pattern: &[char],
439
129
        first_match_indices: &[usize],
440
129
        compressed: bool,
441
129
        case_sensitive: bool,
442
129
    ) {
443
129
        let mut in_place_bonuses = vec![0; m.cols];
444
445
129
        self.build_in_place_bonus(choice, &mut in_place_bonuses);
446
447
        // need to reset M[row][first_match] & M[i][j-1]
448
129
        m[(0, 0)].reset();
449
248
        for i in 
1..m.rows129
{
450
248
            m[(i, first_match_indices[i - 1])].reset();
451
248
        }
452
453
909
        for j in 
0..m.cols129
{
454
909
            // p[0][j]: the score of best alignment of p[] and c[..=j] where c[j] is not matched
455
909
            m[(0, j)].reset();
456
909
            m[(0, j)].p_score = self.score_config.gap_extension;
457
909
        }
458
459
        // update the matrix;
460
368
        for (i, &p_ch) in 
pattern129
.
iter129
().
enumerate129
() {
461
368
            let row = Self::adjust_row_idx(i + 1, compressed);
462
368
            let row_prev = Self::adjust_row_idx(i, compressed);
463
368
            let to_skip = first_match_indices[i];
464
465
            // Pre-calculate base indices to reduce repeated index calculations
466
368
            let row_base = row * m.cols;
467
368
            let row_prev_base = row_prev * m.cols;
468
469
1.54k
            for (j, &c_ch) in 
choice[to_skip..]368
.
iter368
().
enumerate368
() {
470
1.54k
                let col = to_skip + j + 1;
471
1.54k
                let col_prev = to_skip + j;
472
473
                // Use pre-calculated bases to reduce index calculations
474
1.54k
                let idx_cur = row_base + col;
475
1.54k
                let idx_last = row_base + col_prev;
476
1.54k
                let idx_prev = row_prev_base + col_prev;
477
478
                // Cache in_place_bonus lookup to avoid repeated array access
479
1.54k
                let in_place_bonus = in_place_bonuses[col];
480
481
                // update M matrix
482
                // M[i][j] = match(i, j) + max(M[i-1][j-1], P[i-1][j-1])
483
1.54k
                if let Some(
cur_match_score427
) = self.calculate_match_score(c_ch, p_ch, case_sensitive) {
  Branch (483:24): [True: 13, False: 86]
 
  Branch (483:24): [True: 414, False: 1.03k]
 
484
427
                    let prev_cell = &m.matrix[idx_prev];
485
427
                    let prev_match_score = prev_cell.m_score;
486
427
                    let prev_skip_score = prev_cell.p_score;
487
488
427
                    let prev_match_bonus = m.matrix[idx_last].bonus;
489
490
427
                    let consecutive_bonus = max(
491
427
                        prev_match_bonus,
492
427
                        max(in_place_bonus, self.score_config.bonus_consecutive),
493
                    );
494
427
                    m.matrix[idx_last].bonus = consecutive_bonus;
495
496
427
                    let score_match = prev_match_score + consecutive_bonus;
497
427
                    let score_skip = prev_skip_score + in_place_bonus;
498
499
427
                    let cur_cell = &mut m.matrix[idx_cur];
500
427
                    if score_match >= score_skip {
  Branch (500:24): [True: 3, False: 10]
 
  Branch (500:24): [True: 167, False: 247]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/util.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/util.rs.html
index 7bd9fd9f..1c6d6b7e 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/util.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/util.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/util.rs
Line
Count
Source
1
use super::{FuzzyMatcher, IndexType, ScoreType};
2
3
10.5k
pub fn cheap_matches(choice: &[char], pattern: &[char], case_sensitive: bool) -> Option<Vec<usize>> {
4
10.5k
    let mut first_match_indices = vec![];
5
10.5k
    let mut pattern_iter = pattern.iter().peekable();
6
47.8k
    for (idx, &c) in 
choice10.5k
.
iter10.5k
().
enumerate10.5k
() {
7
47.8k
        match pattern_iter.peek() {
8
47.6k
            Some(&&p) => {
9
47.6k
                if char_equal(c, p, case_sensitive) {
  Branch (9:20): [True: 396, False: 2.96k]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/util.rs
Line
Count
Source
1
use super::{FuzzyMatcher, IndexType, ScoreType};
2
3
10.5k
pub fn cheap_matches(choice: &[char], pattern: &[char], case_sensitive: bool) -> Option<Vec<usize>> {
4
10.5k
    let mut first_match_indices = vec![];
5
10.5k
    let mut pattern_iter = pattern.iter().peekable();
6
47.8k
    for (idx, &c) in 
choice10.5k
.
iter10.5k
().
enumerate10.5k
() {
7
47.8k
        match pattern_iter.peek() {
8
47.6k
            Some(&&p) => {
9
47.6k
                if char_equal(c, p, case_sensitive) {
  Branch (9:20): [True: 396, False: 2.96k]
 
  Branch (9:20): [True: 4.72k, False: 39.5k]
 
10
5.12k
                    first_match_indices.push(idx);
11
5.12k
                    let _ = pattern_iter.next();
12
42.5k
                }
13
            }
14
160
            None => break,
15
        }
16
    }
17
18
10.5k
    if pattern_iter.peek().is_none() {
  Branch (18:8): [True: 8, False: 188]
 
  Branch (18:8): [True: 266, False: 10.0k]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/helper/item.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/helper/item.rs.html
index b079ba7a..3924a7fd 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/helper/item.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/helper/item.rs.html
@@ -1,16 +1,16 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/helper/item.rs
Line
Count
Source
1
//! Skim item helpers
2
//! Including the `DefaultSkimItem`
3
use crate::field::{FieldRange, parse_matching_fields, parse_transform_fields};
4
use crate::tui::util::merge_styles;
5
use crate::{DisplayContext, Matches, SkimItem};
6
use ansi_to_tui::IntoText;
7
use ratatui::text::{Line, Span};
8
use regex::Regex;
9
use std::borrow::Cow;
10
11
//------------------------------------------------------------------------------
12
/// An item will store everything that one line input will need to be operated and displayed.
13
///
14
/// What's special about an item?
15
/// The simplest version of an item is a line of string, but things are getting more complex:
16
/// - The conversion of lower/upper case is slow in rust, because it involds unicode.
17
/// - We may need to interpret the ANSI codes in the text.
18
/// - The text can be transformed and limited while searching.
19
///
20
/// About the ANSI, we made assumption that it is linewise, that means no ANSI codes will affect
21
/// more than one line.
22
#[derive(Debug)]
23
pub struct DefaultSkimItem {
24
    /// The text that will be shown on screen.
25
    text: Box<str>,
26
27
    /// Metadata containing miscellaneous fields when special options are used
28
    metadata: Option<Box<DefaultSkimItemMetadata>>,
29
}
30
31
/// Additional metadata for a `SkimItem`
32
#[derive(Debug, Default)]
33
pub struct DefaultSkimItemMetadata {
34
    /// The text that will be output when user press `enter`
35
    /// `Some(..)` => the original input is transformed, could not output `text` directly
36
    /// `None` => that it is safe to output `text` directly
37
    orig_text: Option<Box<str>>,
38
39
    /// The text stripped of all ansi sequences, used for matching
40
    /// Will be Some when ANSI is enabled, None otherwise
41
    stripped_text: Option<Box<str>>,
42
43
    /// A mapping of positions from stripped text to original text.
44
    /// Each element is (`byte_position`, `char_position`) in the original raw text.
45
    /// Will be empty if ansi is disabled.
46
    ansi_info: Option<Vec<(usize, usize)>>,
47
48
    /// The ranges on which to perform matching
49
    matching_ranges: Option<Vec<(usize, usize)>>,
50
51
    /// Byte ranges (in the display/matching text) of fields hidden via `--hide-nth`.
52
    /// Characters inside these ranges are removed from the rendered line and ignored
53
    /// for match highlighting and horizontal scrolling, but remain part of the text
54
    /// used for matching so they stay searchable.
55
    hidden_ranges: Option<Vec<(usize, usize)>>,
56
57
    /// Whether the item should be disabled or not
58
    disabled: bool,
59
}
60
61
impl DefaultSkimItem {
62
    /// Create a new `DefaultSkimItem` from text
63
    #[must_use]
64
52.0k
    pub fn new(
65
52.0k
        orig_text: &str,
66
52.0k
        ansi_enabled: bool,
67
52.0k
        trans_fields: &[FieldRange],
68
52.0k
        matching_fields: &[FieldRange],
69
52.0k
        delimiter: &Regex,
70
52.0k
    ) -> Self {
71
52.0k
        let using_transform_fields = !trans_fields.is_empty();
72
52.0k
        let contains_ansi = Self::contains_ansi_escape(orig_text);
73
74
        //        transformed | ANSI             | output
75
        //------------------------------------------------------
76
        //                    +- T -> trans+ANSI | ANSI
77
        //                    |                  |
78
        //      +- T -> trans +- F -> trans      | orig
79
        // orig |                                |
80
        //      +- F -> orig  +- T -> ANSI     ==| ANSI
81
        //                    |                  |
82
        //                    +- F -> orig       | orig
83
84
52.0k
        let (mut orig_text, mut temp_text): (Option<String>, Box<str>) = match (using_transform_fields, ansi_enabled) {
85
            (true, true) => {
86
1
                let transformed = parse_transform_fields(delimiter, orig_text, trans_fields);
87
1
                (Some(orig_text.into()), Box::from(transformed))
88
            }
89
            (true, false) => {
90
21
                let transformed = parse_transform_fields(delimiter, &escape_ansi(orig_text), trans_fields);
91
21
                (Some(orig_text.into()), Box::from(transformed))
92
            }
93
3
            (false, false) if contains_ansi => (None, escape_ansi(orig_text).into()),
  Branch (93:31): [True: 2, False: 51.9k]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/helper/item.rs
Line
Count
Source
1
//! Skim item helpers
2
//! Including the `DefaultSkimItem`
3
use crate::field::{FieldRange, parse_matching_fields, parse_transform_fields};
4
use crate::tui::util::merge_styles;
5
use crate::{DisplayContext, Matches, SkimItem};
6
use ansi_to_tui::IntoText;
7
use ratatui::text::{Line, Span};
8
use regex::Regex;
9
use std::borrow::Cow;
10
11
//------------------------------------------------------------------------------
12
/// An item will store everything that one line input will need to be operated and displayed.
13
///
14
/// What's special about an item?
15
/// The simplest version of an item is a line of string, but things are getting more complex:
16
/// - The conversion of lower/upper case is slow in rust, because it involds unicode.
17
/// - We may need to interpret the ANSI codes in the text.
18
/// - The text can be transformed and limited while searching.
19
///
20
/// About the ANSI, we made assumption that it is linewise, that means no ANSI codes will affect
21
/// more than one line.
22
#[derive(Debug)]
23
pub struct DefaultSkimItem {
24
    /// The text that will be shown on screen.
25
    text: Box<str>,
26
27
    /// Metadata containing miscellaneous fields when special options are used
28
    metadata: Option<Box<DefaultSkimItemMetadata>>,
29
}
30
31
/// Additional metadata for a `SkimItem`
32
#[derive(Debug, Default)]
33
pub struct DefaultSkimItemMetadata {
34
    /// The text that will be output when user press `enter`
35
    /// `Some(..)` => the original input is transformed, could not output `text` directly
36
    /// `None` => that it is safe to output `text` directly
37
    orig_text: Option<Box<str>>,
38
39
    /// The text stripped of all ansi sequences, used for matching
40
    /// Will be Some when ANSI is enabled, None otherwise
41
    stripped_text: Option<Box<str>>,
42
43
    /// A mapping of positions from stripped text to original text.
44
    /// Each element is (`byte_position`, `char_position`) in the original raw text.
45
    /// Will be empty if ansi is disabled.
46
    ansi_info: Option<Vec<(usize, usize)>>,
47
48
    /// The ranges on which to perform matching
49
    matching_ranges: Option<Vec<(usize, usize)>>,
50
51
    /// Byte ranges (in the display/matching text) of fields hidden via `--hide-nth`.
52
    /// Characters inside these ranges are removed from the rendered line and ignored
53
    /// for match highlighting and horizontal scrolling, but remain part of the text
54
    /// used for matching so they stay searchable.
55
    hidden_ranges: Option<Vec<(usize, usize)>>,
56
57
    /// Whether the item should be disabled or not
58
    disabled: bool,
59
}
60
61
impl DefaultSkimItem {
62
    /// Create a new `DefaultSkimItem` from text
63
    #[must_use]
64
52.0k
    pub fn new(
65
52.0k
        orig_text: &str,
66
52.0k
        ansi_enabled: bool,
67
52.0k
        trans_fields: &[FieldRange],
68
52.0k
        matching_fields: &[FieldRange],
69
52.0k
        delimiter: &Regex,
70
52.0k
    ) -> Self {
71
52.0k
        let using_transform_fields = !trans_fields.is_empty();
72
52.0k
        let contains_ansi = Self::contains_ansi_escape(orig_text);
73
74
        //        transformed | ANSI             | output
75
        //------------------------------------------------------
76
        //                    +- T -> trans+ANSI | ANSI
77
        //                    |                  |
78
        //      +- T -> trans +- F -> trans      | orig
79
        // orig |                                |
80
        //      +- F -> orig  +- T -> ANSI     ==| ANSI
81
        //                    |                  |
82
        //                    +- F -> orig       | orig
83
84
52.0k
        let (mut orig_text, mut temp_text): (Option<String>, Box<str>) = match (using_transform_fields, ansi_enabled) {
85
            (true, true) => {
86
1
                let transformed = parse_transform_fields(delimiter, orig_text, trans_fields);
87
1
                (Some(orig_text.into()), Box::from(transformed))
88
            }
89
            (true, false) => {
90
21
                let transformed = parse_transform_fields(delimiter, &escape_ansi(orig_text), trans_fields);
91
21
                (Some(orig_text.into()), Box::from(transformed))
92
            }
93
3
            (false, false) if contains_ansi => (None, escape_ansi(orig_text).into()),
  Branch (93:31): [True: 2, False: 52.0k]
 
  Branch (93:31): [True: 1, False: 39]
-
94
52.0k
            (false, true | false) => (None, Box::from(orig_text)),
95
        };
96
97
        // Keep track of whether we have null bytes for special handling
98
52.0k
        let has_null_bytes = memchr::memchr(b'\0', temp_text.as_bytes()).is_some();
99
100
        // Preserve original text with null bytes for output if needed
101
52.0k
        if has_null_bytes && 
orig_text6
.
is_none6
() {
  Branch (101:12): [True: 5, False: 51.9k]
+
94
52.0k
            (false, true | false) => (None, Box::from(orig_text)),
95
        };
96
97
        // Keep track of whether we have null bytes for special handling
98
52.0k
        let has_null_bytes = memchr::memchr(b'\0', temp_text.as_bytes()).is_some();
99
100
        // Preserve original text with null bytes for output if needed
101
52.0k
        if has_null_bytes && 
orig_text6
.
is_none6
() {
  Branch (101:12): [True: 5, False: 52.0k]
   Branch (101:30): [True: 4, False: 1]
 
  Branch (101:12): [True: 1, False: 53]
   Branch (101:30): [True: 1, False: 0]
-
102
5
            orig_text = Some(temp_text.to_string());
103
52.0k
        }
104
105
        // Strip null bytes from text used for display and matching
106
        // Null bytes are control characters that cause rendering issues (zero-width)
107
        // They are preserved in orig_text for output
108
52.0k
        if has_null_bytes {
  Branch (108:12): [True: 5, False: 51.9k]
+
102
5
            orig_text = Some(temp_text.to_string());
103
52.0k
        }
104
105
        // Strip null bytes from text used for display and matching
106
        // Null bytes are control characters that cause rendering issues (zero-width)
107
        // They are preserved in orig_text for output
108
52.0k
        if has_null_bytes {
  Branch (108:12): [True: 5, False: 52.0k]
 
  Branch (108:12): [True: 1, False: 53]
-
109
6
            temp_text = temp_text.to_string().replace('\0', "").into_boxed_str();
110
52.0k
        }
111
112
52.0k
        let (stripped_text, ansi_info) = if ansi_enabled && 
contains_ansi27
{
  Branch (112:45): [True: 14, False: 51.9k]
-  Branch (112:61): [True: 10, False: 4]
+
109
6
            temp_text = temp_text.to_string().replace('\0', "").into_boxed_str();
110
52.0k
        }
111
112
52.0k
        let (stripped_text, ansi_info) = if ansi_enabled && 
contains_ansi24
{
  Branch (112:45): [True: 11, False: 52.0k]
+  Branch (112:61): [True: 8, False: 3]
 
  Branch (112:45): [True: 13, False: 41]
   Branch (112:61): [True: 13, False: 0]
-
113
23
            let (stripped, info) = strip_ansi(&temp_text);
114
23
            (Some(stripped), Some(info))
115
        } else {
116
52.0k
            (None, None)
117
        };
118
119
        // Calculate matching ranges on text WITHOUT null bytes (after stripping)
120
        // This ensures the byte positions match the actual text used for matching
121
52.0k
        let matching_ranges = if matching_fields.is_empty() {
  Branch (121:34): [True: 51.9k, False: 23]
+
113
21
            let (stripped, info) = strip_ansi(&temp_text);
114
21
            (Some(stripped), Some(info))
115
        } else {
116
52.0k
            (None, None)
117
        };
118
119
        // Calculate matching ranges on text WITHOUT null bytes (after stripping)
120
        // This ensures the byte positions match the actual text used for matching
121
52.0k
        let matching_ranges = if matching_fields.is_empty() {
  Branch (121:34): [True: 52.0k, False: 23]
 
  Branch (121:34): [True: 52, False: 2]
 
122
52.0k
            None
123
        } else {
124
            // Use stripped text for matching ranges when ANSI is enabled
125
25
            let text_for_matching = if let Some(
stripped1
) = stripped_text.as_ref() {
  Branch (125:44): [True: 0, False: 23]
 
  Branch (125:44): [True: 1, False: 1]
@@ -22,55 +22,55 @@
 
  Branch (147:28): [True: 1, False: 0]
 
148
                    {
149
                        // Strip null bytes from this field
150
2
                        let cleaned_field = field_text.replace('\0', "");
151
152
                        // Find this cleaned field in the cleaned full text
153
2
                        if let Some(pos) = text_for_matching.find(&cleaned_field) {
  Branch (153:32): [True: 1, False: 0]
 
  Branch (153:32): [True: 1, False: 0]
-
154
2
                            adjusted_ranges.push((pos, pos + cleaned_field.len()));
155
2
                        
}0
156
0
                    }
157
                }
158
2
                Some(adjusted_ranges)
159
            } else {
160
23
                Some(parse_matching_fields(delimiter, text_for_matching, matching_fields))
161
            }
162
        };
163
164
52.0k
        let metadata =
165
52.0k
            if orig_text.is_some() || 
stripped_text52.0k
.
is_some52.0k
() ||
ansi_info51.9k
.
is_some51.9k
() ||
matching_ranges51.9k
.
is_some51.9k
() {
  Branch (165:16): [True: 24, False: 51.9k]
-  Branch (165:39): [True: 10, False: 51.9k]
-  Branch (165:66): [True: 0, False: 51.9k]
+
154
2
                            adjusted_ranges.push((pos, pos + cleaned_field.len()));
155
2
                        
}0
156
0
                    }
157
                }
158
2
                Some(adjusted_ranges)
159
            } else {
160
23
                Some(parse_matching_fields(delimiter, text_for_matching, matching_fields))
161
            }
162
        };
163
164
52.0k
        let metadata =
165
52.0k
            if orig_text.is_some() || 
stripped_text52.0k
.
is_some52.0k
() ||
ansi_info52.0k
.
is_some52.0k
() ||
matching_ranges52.0k
.
is_some52.0k
() {
  Branch (165:16): [True: 24, False: 52.0k]
+  Branch (165:39): [True: 8, False: 52.0k]
+  Branch (165:66): [True: 0, False: 52.0k]
   Branch (165:89): [True: 22, False: 51.9k]
 
  Branch (165:16): [True: 3, False: 51]
   Branch (165:39): [True: 12, False: 39]
   Branch (165:66): [True: 0, False: 39]
   Branch (165:89): [True: 0, False: 39]
-
166
71
                Some(Box::new(DefaultSkimItemMetadata {
167
71
                    orig_text: orig_text.map(std::string::String::into_boxed_str),
168
71
                    stripped_text: stripped_text.map(std::string::String::into_boxed_str),
169
71
                    ansi_info,
170
71
                    matching_ranges,
171
71
                    hidden_ranges: None,
172
71
                    disabled: false,
173
71
                }))
174
            } else {
175
51.9k
                None
176
            };
177
178
52.0k
        DefaultSkimItem {
179
52.0k
            text: temp_text,
180
52.0k
            metadata,
181
52.0k
        }
182
52.0k
    }
183
184
    /// Builder-style setter for the fields hidden from display (via `--hide-nth`).
185
    ///
186
    /// The fields are resolved against the item's display/matching text — which is
187
    /// exactly what [`text()`](Self::text) returns (the ANSI-stripped text under
188
    /// `--ansi`, otherwise the raw text) — so this must be called after construction.
189
    /// The requested fields stay part of `text()` (and therefore searchable); they are
190
    /// only removed from the rendered line and ignored for highlighting and hscroll.
191
    ///
192
    /// A no-op when `hidden_fields` is empty or resolves to no ranges.
193
    #[must_use]
194
52.0k
    pub fn hidden_fields(mut self, hidden_fields: &[FieldRange], delimiter: &Regex) -> Self {
195
52.0k
        if hidden_fields.is_empty() {
  Branch (195:12): [True: 51.9k, False: 9]
+
166
69
                Some(Box::new(DefaultSkimItemMetadata {
167
69
                    orig_text: orig_text.map(std::string::String::into_boxed_str),
168
69
                    stripped_text: stripped_text.map(std::string::String::into_boxed_str),
169
69
                    ansi_info,
170
69
                    matching_ranges,
171
69
                    hidden_ranges: None,
172
69
                    disabled: false,
173
69
                }))
174
            } else {
175
52.0k
                None
176
            };
177
178
52.0k
        DefaultSkimItem {
179
52.0k
            text: temp_text,
180
52.0k
            metadata,
181
52.0k
        }
182
52.0k
    }
183
184
    /// Builder-style setter for the fields hidden from display (via `--hide-nth`).
185
    ///
186
    /// The fields are resolved against the item's display/matching text — which is
187
    /// exactly what [`text()`](Self::text) returns (the ANSI-stripped text under
188
    /// `--ansi`, otherwise the raw text) — so this must be called after construction.
189
    /// The requested fields stay part of `text()` (and therefore searchable); they are
190
    /// only removed from the rendered line and ignored for highlighting and hscroll.
191
    ///
192
    /// A no-op when `hidden_fields` is empty or resolves to no ranges.
193
    #[must_use]
194
52.0k
    pub fn hidden_fields(mut self, hidden_fields: &[FieldRange], delimiter: &Regex) -> Self {
195
52.0k
        if hidden_fields.is_empty() {
  Branch (195:12): [True: 52.0k, False: 9]
 
  Branch (195:12): [True: 36, False: 5]
 
196
52.0k
            return self;
197
14
        }
198
        // Resolve the ranges before touching `self.metadata`; the `text()` borrow must
199
        // end before the mutable borrow below.
200
14
        let ranges = {
201
14
            let text = self.text();
202
14
            normalize_ranges(&parse_matching_fields(delimiter, text.as_ref(), hidden_fields))
203
        };
204
14
        if !ranges.is_empty() {
  Branch (204:12): [True: 9, False: 0]
 
  Branch (204:12): [True: 5, False: 0]
-
205
14
            self.metadata.get_or_insert_default().hidden_ranges = Some(ranges);
206
14
        
}0
207
14
        self
208
52.0k
    }
209
210
52.0k
    fn contains_ansi_escape(s: &str) -> bool {
211
52.0k
        memchr::memchr(b'\x1b', s.as_bytes()).is_some()
212
52.0k
    }
213
214
    /// Mark the item as disabled
215
2
    pub fn disable(&mut self) {
216
2
        self.metadata.get_or_insert_default().disabled = true;
217
2
    }
218
219
    /// Getter for `stripped_text` stored in the metadata
220
    #[must_use]
221
64.9k
    pub fn stripped_text(&self) -> Option<&str> {
222
64.9k
        if let Some(
meta1.25k
) = &self.metadata
  Branch (222:16): [True: 1.23k, False: 63.6k]
+
205
14
            self.metadata.get_or_insert_default().hidden_ranges = Some(ranges);
206
14
        
}0
207
14
        self
208
52.0k
    }
209
210
52.0k
    fn contains_ansi_escape(s: &str) -> bool {
211
52.0k
        memchr::memchr(b'\x1b', s.as_bytes()).is_some()
212
52.0k
    }
213
214
    /// Mark the item as disabled
215
2
    pub fn disable(&mut self) {
216
2
        self.metadata.get_or_insert_default().disabled = true;
217
2
    }
218
219
    /// Getter for `stripped_text` stored in the metadata
220
    #[must_use]
221
64.9k
    pub fn stripped_text(&self) -> Option<&str> {
222
64.9k
        if let Some(
meta1.24k
) = &self.metadata
  Branch (222:16): [True: 1.22k, False: 63.6k]
 
  Branch (222:16): [True: 20, False: 24]
-
223
1.25k
            && let Some(
stripped_text325
) = &meta.stripped_text
  Branch (223:20): [True: 310, False: 926]
+
223
1.24k
            && let Some(
stripped_text315
) = &meta.stripped_text
  Branch (223:20): [True: 300, False: 926]
 
  Branch (223:20): [True: 15, False: 5]
-
224
        {
225
325
            Some(stripped_text.as_ref())
226
        } else {
227
64.6k
            None
228
        }
229
64.9k
    }
230
231
    /// Getter for `orig_text` stored in metadata
232
    #[must_use]
233
50.1k
    pub fn orig_text(&self) -> Option<&str> {
234
50.1k
        if let Some(
meta7
) = &self.metadata
  Branch (234:16): [True: 7, False: 50.1k]
+
224
        {
225
315
            Some(stripped_text.as_ref())
226
        } else {
227
64.5k
            None
228
        }
229
64.9k
    }
230
231
    /// Getter for `orig_text` stored in metadata
232
    #[must_use]
233
50.1k
    pub fn orig_text(&self) -> Option<&str> {
234
50.1k
        if let Some(
meta7
) = &self.metadata
  Branch (234:16): [True: 7, False: 50.1k]
 
  Branch (234:16): [True: 0, False: 0]
 
235
7
            && let Some(
orig5
) = &meta.orig_text
  Branch (235:20): [True: 5, False: 2]
 
  Branch (235:20): [True: 0, False: 0]
-
236
        {
237
5
            Some(orig.as_ref())
238
        } else {
239
50.1k
            None
240
        }
241
50.1k
    }
242
243
    /// Getter for `ansi_info` stored in metadata
244
    #[must_use]
245
6.45k
    pub fn ansi_info(&self) -> Option<&Vec<(usize, usize)>> {
246
6.45k
        if let Some(
meta391
) = &self.metadata
  Branch (246:16): [True: 379, False: 6.06k]
+
236
        {
237
5
            Some(orig.as_ref())
238
        } else {
239
50.1k
            None
240
        }
241
50.1k
    }
242
243
    /// Getter for `ansi_info` stored in metadata
244
    #[must_use]
245
6.44k
    pub fn ansi_info(&self) -> Option<&Vec<(usize, usize)>> {
246
6.44k
        if let Some(
meta388
) = &self.metadata
  Branch (246:16): [True: 376, False: 6.05k]
 
  Branch (246:16): [True: 12, False: 0]
-
247
391
            && let Some(
info105
) = &meta.ansi_info
  Branch (247:20): [True: 95, False: 284]
+
247
388
            && let Some(
info102
) = &meta.ansi_info
  Branch (247:20): [True: 92, False: 284]
 
  Branch (247:20): [True: 10, False: 2]
-
248
        {
249
105
            Some(info)
250
        } else {
251
6.35k
            None
252
        }
253
6.45k
    }
254
255
    /// Getter for `matching_ranges` stored in metadata
256
    #[must_use]
257
51.2k
    pub fn matching_ranges(&self) -> Option<&[(usize, usize)]> {
258
51.2k
        if let Some(
meta75
) = &self.metadata {
  Branch (258:16): [True: 72, False: 51.2k]
+
248
        {
249
102
            Some(info)
250
        } else {
251
6.34k
            None
252
        }
253
6.44k
    }
254
255
    /// Getter for `matching_ranges` stored in metadata
256
    #[must_use]
257
51.2k
    pub fn matching_ranges(&self) -> Option<&[(usize, usize)]> {
258
51.2k
        if let Some(
meta73
) = &self.metadata {
  Branch (258:16): [True: 70, False: 51.2k]
 
  Branch (258:16): [True: 3, False: 0]
-
259
75
            meta.matching_ranges.as_ref().map(|v| 
v.as_ref()39
as
&[(usize, usize)]39
)
260
        } else {
261
51.2k
            None
262
        }
263
51.2k
    }
264
265
    /// Getter for `hidden_ranges` stored in metadata
266
    #[must_use]
267
12.8k
    pub fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
268
12.8k
        if let Some(
meta769
) = &self.metadata {
  Branch (268:16): [True: 758, False: 12.0k]
+
259
73
            meta.matching_ranges.as_ref().map(|v| 
v.as_ref()39
as
&[(usize, usize)]39
)
260
        } else {
261
51.2k
            None
262
        }
263
51.2k
    }
264
265
    /// Getter for `hidden_ranges` stored in metadata
266
    #[must_use]
267
12.7k
    pub fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
268
12.7k
        if let Some(
meta763
) = &self.metadata {
  Branch (268:16): [True: 752, False: 12.0k]
 
  Branch (268:16): [True: 11, False: 0]
-
269
769
            meta.hidden_ranges.as_ref().map(|v| 
v.as_ref()95
as
&[(usize, usize)]95
)
270
        } else {
271
12.0k
            None
272
        }
273
12.8k
    }
274
}
275
276
impl DefaultSkimItem {
277
    /// Get the display text (with ANSI codes if present) for rendering purposes
278
    #[inline]
279
    #[allow(dead_code)]
280
    #[must_use]
281
1
    pub fn get_display_text(&self) -> &str {
282
1
        &self.text
283
1
    }
284
}
285
286
impl From<String> for DefaultSkimItem {
287
1
    fn from(value: String) -> Self {
288
1
        Self {
289
1
            text: Box::from(value),
290
1
            metadata: None,
291
1
        }
292
1
    }
293
}
294
295
impl SkimItem for DefaultSkimItem {
296
    #[inline]
297
64.9k
    fn text(&self) -> Cow<'_, str> {
298
        // Return stripped text for matching when ANSI is enabled
299
64.9k
        if let Some(
stripped298
) = self.stripped_text() {
  Branch (299:16): [True: 286, False: 64.5k]
+
269
763
            meta.hidden_ranges.as_ref().map(|v| 
v.as_ref()95
as
&[(usize, usize)]95
)
270
        } else {
271
12.0k
            None
272
        }
273
12.7k
    }
274
}
275
276
impl DefaultSkimItem {
277
    /// Get the display text (with ANSI codes if present) for rendering purposes
278
    #[inline]
279
    #[allow(dead_code)]
280
    #[must_use]
281
1
    pub fn get_display_text(&self) -> &str {
282
1
        &self.text
283
1
    }
284
}
285
286
impl From<String> for DefaultSkimItem {
287
1
    fn from(value: String) -> Self {
288
1
        Self {
289
1
            text: Box::from(value),
290
1
            metadata: None,
291
1
        }
292
1
    }
293
}
294
295
impl SkimItem for DefaultSkimItem {
296
    #[inline]
297
64.8k
    fn text(&self) -> Cow<'_, str> {
298
        // Return stripped text for matching when ANSI is enabled
299
64.8k
        if let Some(
stripped288
) = self.stripped_text() {
  Branch (299:16): [True: 276, False: 64.5k]
 
  Branch (299:16): [True: 12, False: 29]
-
300
298
            Cow::Borrowed(stripped)
301
        } else {
302
64.6k
            Cow::Borrowed(&self.text)
303
        }
304
64.9k
    }
305
306
50.1k
    fn output(&self) -> Cow<'_, str> {
307
50.1k
        if let Some(
orig5
) = self.orig_text() {
  Branch (307:16): [True: 5, False: 50.1k]
+
300
288
            Cow::Borrowed(stripped)
301
        } else {
302
64.5k
            Cow::Borrowed(&self.text)
303
        }
304
64.8k
    }
305
306
50.1k
    fn output(&self) -> Cow<'_, str> {
307
50.1k
        if let Some(
orig5
) = self.orig_text() {
  Branch (307:16): [True: 5, False: 50.1k]
 
  Branch (307:16): [True: 0, False: 0]
-
308
5
            Cow::Borrowed(orig)
309
        } else {
310
50.1k
            Cow::Borrowed(&self.text)
311
        }
312
50.1k
    }
313
314
51.2k
    fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> {
315
        // Return matching ranges if present in metadata
316
51.2k
        self.matching_ranges()
317
51.2k
    }
318
319
6.35k
    fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
320
6.35k
        self.hidden_ranges()
321
6.35k
    }
322
323
    // The display function handles ANSI stripping, field highlighting, and match
324
    // rendering in a single pass; splitting it would require duplicating context handling.
325
    #[allow(clippy::too_many_lines)]
326
6.45k
    fn display(&self, context: DisplayContext) -> Line<'_> {
327
        // If we have ANSI info, we need to handle ANSI codes properly and map matches
328
6.45k
        if self.ansi_info().is_some() {
  Branch (328:12): [True: 95, False: 6.35k]
+
308
5
            Cow::Borrowed(orig)
309
        } else {
310
50.1k
            Cow::Borrowed(&self.text)
311
        }
312
50.1k
    }
313
314
51.2k
    fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> {
315
        // Return matching ranges if present in metadata
316
51.2k
        self.matching_ranges()
317
51.2k
    }
318
319
6.33k
    fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
320
6.33k
        self.hidden_ranges()
321
6.33k
    }
322
323
    // The display function handles ANSI stripping, field highlighting, and match
324
    // rendering in a single pass; splitting it would require duplicating context handling.
325
    #[allow(clippy::too_many_lines)]
326
6.44k
    fn display(&self, context: DisplayContext) -> Line<'_> {
327
        // If we have ANSI info, we need to handle ANSI codes properly and map matches
328
6.44k
        if self.ansi_info().is_some() {
  Branch (328:12): [True: 92, False: 6.33k]
 
  Branch (328:12): [True: 8, False: 2]
-
329
            // Parse the ANSI text using ansi-to-tui to get proper styled spans
330
103
            let text_bytes = self.text.as_bytes().to_vec();
331
103
            let Ok(parsed_text) = text_bytes.into_text() else {
  Branch (331:17): [True: 95, False: 0]
+
329
            // Parse the ANSI text using ansi-to-tui to get proper styled spans
330
100
            let text_bytes = self.text.as_bytes().to_vec();
331
100
            let Ok(parsed_text) = text_bytes.into_text() else {
  Branch (331:17): [True: 92, False: 0]
 
  Branch (331:17): [True: 8, False: 0]
-
332
                // Fallback to plain text if parsing fails
333
0
                return context.to_line(Cow::Borrowed(&self.text));
334
            };
335
336
            // Extract all spans from the parsed text (should be a single line)
337
103
            let all_spans: Vec<Span> = parsed_text.lines.into_iter().flat_map(|line| line.spans).collect();
338
339
            // When fields are hidden (--hide-nth), drop the hidden characters from the
340
            // parsed spans while preserving their ANSI styling, and remap the match
341
            // positions into the resulting visible coordinate space. The remaining
342
            // highlighting logic then runs unchanged on visible-coordinate CharIndices.
343
103
            let (all_spans, matches) = if let Some(
hidden19
) = self.hidden_ranges() {
  Branch (343:47): [True: 17, False: 78]
+
332
                // Fallback to plain text if parsing fails
333
0
                return context.to_line(Cow::Borrowed(&self.text));
334
            };
335
336
            // Extract all spans from the parsed text (should be a single line)
337
100
            let all_spans: Vec<Span> = parsed_text.lines.into_iter().flat_map(|line| line.spans).collect();
338
339
            // When fields are hidden (--hide-nth), drop the hidden characters from the
340
            // parsed spans while preserving their ANSI styling, and remap the match
341
            // positions into the resulting visible coordinate space. The remaining
342
            // highlighting logic then runs unchanged on visible-coordinate CharIndices.
343
100
            let (all_spans, matches) = if let Some(
hidden19
) = self.hidden_ranges() {
  Branch (343:47): [True: 17, False: 75]
 
  Branch (343:47): [True: 2, False: 6]
-
344
19
                let stripped = self.text();
345
19
                let (_, map) = project_visible_text(stripped.as_ref(), hidden);
346
19
                let visible_spans = retain_visible_spans(all_spans, &map);
347
19
                let indices = project_match_indices(stripped.as_ref(), &context.matches, &map);
348
19
                (visible_spans, Matches::CharIndices(indices))
349
            } else {
350
84
                (all_spans, context.matches.clone())
351
            };
352
353
            // Now apply highlighting based on matched positions
354
            // We need to map match positions from stripped text to original text
355
103
            match matches {
356
75
                crate::Matches::CharIndices(ref indices) => {
357
                    // Indices are already in stripped text coordinates (same as parsed ANSI text)
358
                    // No need to remap since both matching and ANSI parsing strip the codes
359
75
                    let highlight_positions: std::collections::HashSet<usize> = indices.iter().copied().collect();
360
361
                    // Apply highlighting to characters at those positions
362
75
                    let mut new_spans = Vec::new();
363
75
                    let mut char_idx = 0;
364
365
153
                    for span in 
all_spans75
{
366
153
                        let mut current_content = String::new();
367
153
                        let mut highlighted_content = String::new();
368
153
                        let base_style = span.style;
369
370
638
                        for ch in 
span.content.chars()153
{
371
638
                            if highlight_positions.contains(&char_idx) {
  Branch (371:32): [True: 132, False: 490]
+
344
19
                let stripped = self.text();
345
19
                let (_, map) = project_visible_text(stripped.as_ref(), hidden);
346
19
                let visible_spans = retain_visible_spans(all_spans, &map);
347
19
                let indices = project_match_indices(stripped.as_ref(), &context.matches, &map);
348
19
                (visible_spans, Matches::CharIndices(indices))
349
            } else {
350
81
                (all_spans, context.matches.clone())
351
            };
352
353
            // Now apply highlighting based on matched positions
354
            // We need to map match positions from stripped text to original text
355
100
            match matches {
356
72
                crate::Matches::CharIndices(ref indices) => {
357
                    // Indices are already in stripped text coordinates (same as parsed ANSI text)
358
                    // No need to remap since both matching and ANSI parsing strip the codes
359
72
                    let highlight_positions: std::collections::HashSet<usize> = indices.iter().copied().collect();
360
361
                    // Apply highlighting to characters at those positions
362
72
                    let mut new_spans = Vec::new();
363
72
                    let mut char_idx = 0;
364
365
150
                    for span in 
all_spans72
{
366
150
                        let mut current_content = String::new();
367
150
                        let mut highlighted_content = String::new();
368
150
                        let base_style = span.style;
369
370
629
                        for ch in 
span.content.chars()150
{
371
629
                            if highlight_positions.contains(&char_idx) {
  Branch (371:32): [True: 129, False: 484]
 
  Branch (371:32): [True: 6, False: 10]
-
372
                                // Flush normal content if any
373
138
                                if !current_content.is_empty() {
  Branch (373:36): [True: 39, False: 93]
+
372
                                // Flush normal content if any
373
135
                                if !current_content.is_empty() {
  Branch (373:36): [True: 36, False: 93]
 
  Branch (373:36): [True: 0, False: 6]
-
374
39
                                    // Combine ANSI style with context base_style
375
39
                                    new_spans.push(Span::styled(
376
39
                                        current_content.clone(),
377
39
                                        merge_styles(context.base_style, base_style),
378
39
                                    ));
379
39
                                    current_content.clear();
380
99
                                }
381
138
                                highlighted_content.push(ch);
382
                            } else {
383
                                // Flush highlighted content if any
384
500
                                if !highlighted_content.is_empty() {
  Branch (384:36): [True: 30, False: 460]
+
374
36
                                    // Combine ANSI style with context base_style
375
36
                                    new_spans.push(Span::styled(
376
36
                                        current_content.clone(),
377
36
                                        merge_styles(context.base_style, base_style),
378
36
                                    ));
379
36
                                    current_content.clear();
380
99
                                }
381
135
                                highlighted_content.push(ch);
382
                            } else {
383
                                // Flush highlighted content if any
384
494
                                if !highlighted_content.is_empty() {
  Branch (384:36): [True: 30, False: 454]
 
  Branch (384:36): [True: 2, False: 8]
-
385
32
                                    // Combine styles: use highlight bg, preserve ANSI fg and modifiers
386
32
                                    new_spans.push(Span::styled(
387
32
                                        highlighted_content.clone(),
388
32
                                        merge_styles(base_style, context.matched_style),
389
32
                                    ));
390
32
                                    highlighted_content.clear();
391
468
                                }
392
500
                                current_content.push(ch);
393
                            }
394
638
                            char_idx += 1;
395
                        }
396
397
                        // Flush remaining content
398
153
                        if !current_content.is_empty() {
  Branch (398:28): [True: 124, False: 24]
+
385
32
                                    // Combine styles: use highlight bg, preserve ANSI fg and modifiers
386
32
                                    new_spans.push(Span::styled(
387
32
                                        highlighted_content.clone(),
388
32
                                        merge_styles(base_style, context.matched_style),
389
32
                                    ));
390
32
                                    highlighted_content.clear();
391
462
                                }
392
494
                                current_content.push(ch);
393
                            }
394
629
                            char_idx += 1;
395
                        }
396
397
                        // Flush remaining content
398
150
                        if !current_content.is_empty() {
  Branch (398:28): [True: 124, False: 21]
 
  Branch (398:28): [True: 4, False: 1]
-
399
128
                            // Combine ANSI style with context base_style
400
128
                            new_spans.push(Span::styled(
401
128
                                current_content,
402
128
                                merge_styles(context.base_style, base_style),
403
128
                            ));
404
128
                        
}25
405
153
                        if !highlighted_content.is_empty() {
  Branch (405:28): [True: 24, False: 124]
+
399
128
                            // Combine ANSI style with context base_style
400
128
                            new_spans.push(Span::styled(
401
128
                                current_content,
402
128
                                merge_styles(context.base_style, base_style),
403
128
                            ));
404
128
                        
}22
405
150
                        if !highlighted_content.is_empty() {
  Branch (405:28): [True: 21, False: 124]
 
  Branch (405:28): [True: 1, False: 4]
-
406
25
                            // Combine styles: use highlight bg, preserve ANSI fg and modifiers
407
25
                            new_spans.push(Span::styled(
408
25
                                highlighted_content,
409
25
                                merge_styles(base_style, context.matched_style),
410
25
                            ));
411
128
                        }
412
                    }
413
414
75
                    Line::from(new_spans)
415
                }
416
2
                crate::Matches::CharRange(start, end) => {
417
                    // Positions are already in stripped text coordinates (same as parsed ANSI text)
418
                    // No need to remap since both matching and ANSI parsing strip the codes
419
420
                    // Apply highlighting to the range
421
2
                    let mut new_spans = Vec::new();
422
2
                    let mut char_idx = 0;
423
424
3
                    for span in 
all_spans2
{
425
3
                        let mut before = String::new();
426
3
                        let mut highlighted = String::new();
427
3
                        let mut after = String::new();
428
3
                        let base_style = span.style;
429
430
15
                        for ch in 
span.content.chars()3
{
431
15
                            if char_idx < start {
  Branch (431:32): [True: 0, False: 0]
+
406
22
                            // Combine styles: use highlight bg, preserve ANSI fg and modifiers
407
22
                            new_spans.push(Span::styled(
408
22
                                highlighted_content,
409
22
                                merge_styles(base_style, context.matched_style),
410
22
                            ));
411
128
                        }
412
                    }
413
414
72
                    Line::from(new_spans)
415
                }
416
2
                crate::Matches::CharRange(start, end) => {
417
                    // Positions are already in stripped text coordinates (same as parsed ANSI text)
418
                    // No need to remap since both matching and ANSI parsing strip the codes
419
420
                    // Apply highlighting to the range
421
2
                    let mut new_spans = Vec::new();
422
2
                    let mut char_idx = 0;
423
424
3
                    for span in 
all_spans2
{
425
3
                        let mut before = String::new();
426
3
                        let mut highlighted = String::new();
427
3
                        let mut after = String::new();
428
3
                        let base_style = span.style;
429
430
15
                        for ch in 
span.content.chars()3
{
431
15
                            if char_idx < start {
  Branch (431:32): [True: 0, False: 0]
 
  Branch (431:32): [True: 7, False: 8]
 
432
7
                                before.push(ch);
433
8
                            } else if char_idx < end {
  Branch (433:39): [True: 0, False: 0]
 
  Branch (433:39): [True: 6, False: 2]
@@ -90,19 +90,19 @@
 
  Branch (493:28): [True: 1, False: 0]
 
494
1
                            // Combine ANSI style with context matched_style
495
1
                            new_spans.push(Span::styled(
496
1
                                highlighted,
497
1
                                merge_styles(base_style, context.matched_style),
498
1
                            ));
499
36
                        }
500
37
                        if !after.is_empty() {
  Branch (500:28): [True: 36, False: 0]
 
  Branch (500:28): [True: 1, False: 0]
-
501
37
                            // Combine ANSI style with context base_style
502
37
                            new_spans.push(Span::styled(after, merge_styles(context.base_style, base_style)));
503
37
                        
}0
504
                    }
505
506
25
                    Line::from(new_spans)
507
                }
508
1
                crate::Matches::None => Line::from(all_spans),
509
            }
510
6.35k
        } else if let Some(
hidden30
) = self.hidden_ranges() {
  Branch (510:23): [True: 28, False: 6.32k]
+
501
37
                            // Combine ANSI style with context base_style
502
37
                            new_spans.push(Span::styled(after, merge_styles(context.base_style, base_style)));
503
37
                        
}0
504
                    }
505
506
25
                    Line::from(new_spans)
507
                }
508
1
                crate::Matches::None => Line::from(all_spans),
509
            }
510
6.34k
        } else if let Some(
hidden30
) = self.hidden_ranges() {
  Branch (510:23): [True: 28, False: 6.31k]
 
  Branch (510:23): [True: 2, False: 0]
-
511
            // Non-ANSI hidden path: remove the hidden characters and remap the match
512
            // highlight positions into the visible coordinate space.
513
30
            let (visible, map) = project_visible_text(&self.text, hidden);
514
30
            let indices = project_match_indices(&self.text, &context.matches, &map);
515
30
            DisplayContext {
516
30
                score: context.score,
517
30
                matches: Matches::CharIndices(indices),
518
30
                container_width: context.container_width,
519
30
                base_style: context.base_style,
520
30
                matched_style: context.matched_style,
521
30
            }
522
30
            .to_line(Cow::Owned(visible))
523
        } else {
524
            // No ANSI mapping needed, use text as-is
525
6.32k
            context.to_line(Cow::Borrowed(&self.text))
526
        }
527
6.45k
    }
528
529
63.5k
    fn disabled(&self) -> bool {
530
63.5k
        self.metadata.as_ref().is_some_and(|x| x.disabled)
531
63.5k
    }
532
}
533
534
/// Strip ANSI escape sequences from a string
535
///
536
/// This function removes all ANSI escape codes (CSI sequences, OSC sequences, etc.)
537
/// from the input string, leaving only the visible text.
538
///
539
/// Returns the stripped string as well as a mapping of positions. Each element in the
540
/// mapping vector is a tuple `(byte_position, char_position)` where:
541
/// - `byte_position`: The byte offset in the original raw string
542
/// - `char_position`: The character index in the original raw string
543
///
544
/// For the character at position `i` in the stripped string:
545
/// - `mapping[i].0` gives its byte position in the original string
546
/// - `mapping[i].1` gives its character index in the original string
547
///
548
/// Examples of ANSI codes that are stripped:
549
/// - `\x1b[31m` (set foreground color to red)
550
/// - `\x1b[01;32m` (bold green)
551
/// - `\x1b[0m` (reset)
552
/// - `\x1b]0;title\x07` (OSC sequences)
553
#[must_use]
554
2.86k
pub fn strip_ansi(text: &str) -> (String, Vec<(usize, usize)>) {
555
2.86k
    let mut result = String::with_capacity(text.len());
556
2.86k
    let mut index_mapping = Vec::new();
557
2.86k
    let mut chars = text.char_indices().peekable();
558
2.86k
    let mut char_idx = 0;
559
560
9.52k
    while let Some((
byte_pos6.66k
,
ch6.66k
)) = chars.next() {
  Branch (560:15): [True: 6.27k, False: 2.77k]
+
511
            // Non-ANSI hidden path: remove the hidden characters and remap the match
512
            // highlight positions into the visible coordinate space.
513
30
            let (visible, map) = project_visible_text(&self.text, hidden);
514
30
            let indices = project_match_indices(&self.text, &context.matches, &map);
515
30
            DisplayContext {
516
30
                score: context.score,
517
30
                matches: Matches::CharIndices(indices),
518
30
                container_width: context.container_width,
519
30
                base_style: context.base_style,
520
30
                matched_style: context.matched_style,
521
30
            }
522
30
            .to_line(Cow::Owned(visible))
523
        } else {
524
            // No ANSI mapping needed, use text as-is
525
6.31k
            context.to_line(Cow::Borrowed(&self.text))
526
        }
527
6.44k
    }
528
529
63.5k
    fn disabled(&self) -> bool {
530
63.5k
        self.metadata.as_ref().is_some_and(|x| x.disabled)
531
63.5k
    }
532
}
533
534
/// Strip ANSI escape sequences from a string
535
///
536
/// This function removes all ANSI escape codes (CSI sequences, OSC sequences, etc.)
537
/// from the input string, leaving only the visible text.
538
///
539
/// Returns the stripped string as well as a mapping of positions. Each element in the
540
/// mapping vector is a tuple `(byte_position, char_position)` where:
541
/// - `byte_position`: The byte offset in the original raw string
542
/// - `char_position`: The character index in the original raw string
543
///
544
/// For the character at position `i` in the stripped string:
545
/// - `mapping[i].0` gives its byte position in the original string
546
/// - `mapping[i].1` gives its character index in the original string
547
///
548
/// Examples of ANSI codes that are stripped:
549
/// - `\x1b[31m` (set foreground color to red)
550
/// - `\x1b[01;32m` (bold green)
551
/// - `\x1b[0m` (reset)
552
/// - `\x1b]0;title\x07` (OSC sequences)
553
#[must_use]
554
2.85k
pub fn strip_ansi(text: &str) -> (String, Vec<(usize, usize)>) {
555
2.85k
    let mut result = String::with_capacity(text.len());
556
2.85k
    let mut index_mapping = Vec::new();
557
2.85k
    let mut chars = text.char_indices().peekable();
558
2.85k
    let mut char_idx = 0;
559
560
9.50k
    while let Some((
byte_pos6.64k
,
ch6.64k
)) = chars.next() {
  Branch (560:15): [True: 6.25k, False: 2.76k]
 
  Branch (560:15): [True: 390, False: 90]
-
561
6.66k
        if ch == '\x1b' {
  Branch (561:12): [True: 32, False: 6.24k]
+
561
6.64k
        if ch == '\x1b' {
  Branch (561:12): [True: 28, False: 6.23k]
 
  Branch (561:12): [True: 54, False: 336]
-
562
            // ESC sequence detected
563
86
            if let Some(&(_, 
next_ch85
)) = chars.peek() {
  Branch (563:20): [True: 32, False: 0]
+
562
            // ESC sequence detected
563
82
            if let Some(&(_, 
next_ch81
)) = chars.peek() {
  Branch (563:20): [True: 28, False: 0]
 
  Branch (563:20): [True: 53, False: 1]
-
564
85
                match next_ch {
565
                    '[' => {
566
                        // CSI sequence: ESC [ ... (ending with a letter)
567
80
                        chars.next(); // consume '['
568
80
                        char_idx += 1;
569
223
                        while let Some(&(_, c)) = chars.peek() {
  Branch (569:35): [True: 88, False: 0]
+
564
81
                match next_ch {
565
                    '[' => {
566
                        // CSI sequence: ESC [ ... (ending with a letter)
567
76
                        chars.next(); // consume '['
568
76
                        char_idx += 1;
569
213
                        while let Some(&(_, c)) = chars.peek() {
  Branch (569:35): [True: 78, False: 0]
 
  Branch (569:35): [True: 135, False: 0]
-
570
223
                            chars.next();
571
223
                            char_idx += 1;
572
223
                            if c.is_ascii_alphabetic() {
  Branch (572:32): [True: 32, False: 56]
+
570
213
                            chars.next();
571
213
                            char_idx += 1;
572
213
                            if c.is_ascii_alphabetic() {
  Branch (572:32): [True: 28, False: 50]
 
  Branch (572:32): [True: 48, False: 87]
-
573
80
                                break;
574
143
                            }
575
                        }
576
                    }
577
                    ']' => {
578
                        // OSC sequence: ESC ] ... (ending with BEL or ESC \)
579
2
                        chars.next(); // consume ']'
580
2
                        char_idx += 1;
581
30
                        while let Some((_, c)) = chars.next() {
  Branch (581:35): [True: 0, False: 0]
+
573
76
                                break;
574
137
                            }
575
                        }
576
                    }
577
                    ']' => {
578
                        // OSC sequence: ESC ] ... (ending with BEL or ESC \)
579
2
                        chars.next(); // consume ']'
580
2
                        char_idx += 1;
581
30
                        while let Some((_, c)) = chars.next() {
  Branch (581:35): [True: 0, False: 0]
 
  Branch (581:35): [True: 30, False: 0]
 
582
30
                            char_idx += 1;
583
30
                            if c == '\x07' {
  Branch (583:32): [True: 0, False: 0]
 
  Branch (583:32): [True: 1, False: 29]
@@ -110,7 +110,7 @@
 
  Branch (587:32): [True: 1, False: 28]
 
588
1
                                && let Some(&(_, '\\')) = chars.peek()
  Branch (588:40): [True: 0, False: 0]
 
  Branch (588:40): [True: 1, False: 0]
-
589
                            {
590
1
                                chars.next(); // consume '\'
591
1
                                char_idx += 1;
592
1
                                break;
593
28
                            }
594
                        }
595
                    }
596
2
                    '(' | ')' | '#' | '%' => {
597
2
                        // Other escape sequences
598
2
                        chars.next(); // consume the next char
599
2
                        char_idx += 1;
600
2
                        chars.next(); // and one more
601
2
                        char_idx += 1;
602
2
                    }
603
1
                    _ => {
604
1
                        // Unknown escape sequence, consume next char
605
1
                        chars.next();
606
1
                        char_idx += 1;
607
1
                    }
608
                }
609
1
            }
610
6.57k
        } else {
611
6.57k
            result.push(ch);
612
6.57k
            index_mapping.push((byte_pos, char_idx));
613
6.57k
        }
614
6.66k
        char_idx += 1;
615
    }
616
617
2.86k
    (result, index_mapping)
618
2.86k
}
619
620
/// Replace the ANSI ESC code by a ?
621
///
622
/// Unsafe: bytes are parsed back from the original string or b'?'
623
/// No risk associated
624
24
fn escape_ansi(raw: &str) -> String {
625
254
    unsafe { 
String::from_utf8_unchecked24
(
raw24
.
bytes24
().
map24
(|b| if b == 27 {
b'?'6
} else {
b248
}).
collect24
()) }
  Branch (625:65): [True: 4, False: 222]
+
589
                            {
590
1
                                chars.next(); // consume '\'
591
1
                                char_idx += 1;
592
1
                                break;
593
28
                            }
594
                        }
595
                    }
596
2
                    '(' | ')' | '#' | '%' => {
597
2
                        // Other escape sequences
598
2
                        chars.next(); // consume the next char
599
2
                        char_idx += 1;
600
2
                        chars.next(); // and one more
601
2
                        char_idx += 1;
602
2
                    }
603
1
                    _ => {
604
1
                        // Unknown escape sequence, consume next char
605
1
                        chars.next();
606
1
                        char_idx += 1;
607
1
                    }
608
                }
609
1
            }
610
6.56k
        } else {
611
6.56k
            result.push(ch);
612
6.56k
            index_mapping.push((byte_pos, char_idx));
613
6.56k
        }
614
6.64k
        char_idx += 1;
615
    }
616
617
2.85k
    (result, index_mapping)
618
2.85k
}
619
620
/// Replace the ANSI ESC code by a ?
621
///
622
/// Unsafe: bytes are parsed back from the original string or b'?'
623
/// No risk associated
624
24
fn escape_ansi(raw: &str) -> String {
625
254
    unsafe { 
String::from_utf8_unchecked24
(
raw24
.
bytes24
().
map24
(|b| if b == 27 {
b'?'6
} else {
b248
}).
collect24
()) }
  Branch (625:65): [True: 4, False: 222]
 
  Branch (625:65): [True: 2, False: 26]
 
626
24
}
627
628
/// Sort and merge a list of byte ranges into a canonical, non-overlapping form.
629
///
630
/// Empty ranges are dropped. Overlapping or touching ranges are merged so callers
631
/// can iterate the result assuming disjoint, ascending ranges.
632
#[must_use]
633
19
pub(crate) fn normalize_ranges(ranges: &[(usize, usize)]) -> Vec<(usize, usize)> {
634
23
    let 
mut sorted19
:
Vec<(usize, usize)>19
=
ranges19
.
iter19
().
copied19
().
filter19
(|(s, e)| e > s).
collect19
();
635
19
    sorted.sort_unstable();
636
637
19
    let mut merged: Vec<(usize, usize)> = Vec::with_capacity(sorted.len());
638
22
    for (start, end) in 
sorted19
{
639
22
        if let Some(
last4
) = merged.last_mut()
  Branch (639:16): [True: 1, False: 9]
 
  Branch (639:16): [True: 3, False: 9]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/helper/item_reader.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/helper/item_reader.rs.html
index 82c203b5..f4b47d14 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/helper/item_reader.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/helper/item_reader.rs.html
@@ -1,28 +1,28 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/helper/item_reader.rs
Line
Count
Source
1
//! Helper utilities for converting input sources into skim item streams.
2
3
use std::collections::BTreeMap;
4
use std::error::Error;
5
use std::io::{BufRead, BufReader};
6
use std::process::{Child, Stdio};
7
use std::sync::Arc;
8
use std::sync::atomic::{AtomicUsize, Ordering};
9
use std::thread;
10
11
use crate::thread_pool::ThreadPool;
12
13
/// Size of the read buffer used by the parallel I/O reader thread.
14
const PARALLEL_READ_BUF_SIZE: usize = 256 * 1024;
15
16
use regex::Regex;
17
18
use crate::field::FieldRange;
19
use crate::helper::item::DefaultSkimItem;
20
use crate::reader::CommandCollector;
21
use crate::{SkimItem, SkimItemReceiver, SkimItemSender, SkimOptions};
22
23
const DELIMITER_STR: &str = r"[\t\n ]+";
24
const READ_BUFFER_SIZE: usize = 1024;
25
26
/// Options for configuring how items are read and parsed
27
#[derive(Debug)]
28
pub struct SkimItemReaderOption {
29
    buf_size: usize,
30
    use_ansi_color: bool,
31
    transform_fields: Vec<FieldRange>,
32
    matching_fields: Vec<FieldRange>,
33
    hidden_fields: Vec<FieldRange>,
34
    delimiter: Regex,
35
    line_ending: u8,
36
    show_error: bool,
37
    disable_pattern: Option<Regex>,
38
}
39
40
impl Default for SkimItemReaderOption {
41
886
    fn default() -> Self {
42
886
        Self {
43
886
            buf_size: READ_BUFFER_SIZE,
44
886
            line_ending: b'\n',
45
886
            use_ansi_color: false,
46
886
            transform_fields: Vec::new(),
47
886
            matching_fields: Vec::new(),
48
886
            hidden_fields: Vec::new(),
49
886
            delimiter: Regex::new(DELIMITER_STR).unwrap(),
50
886
            show_error: false,
51
886
            disable_pattern: None,
52
886
        }
53
886
    }
54
}
55
56
impl SkimItemReaderOption {
57
    /// Creates reader options from skim options
58
    #[must_use]
59
354
    pub fn from_options(options: &SkimOptions) -> Self {
60
        Self {
61
            buf_size: READ_BUFFER_SIZE,
62
354
            line_ending: if options.read0 { 
b'\0'4
} else {
b'\n'350
},
  Branch (62:29): [True: 3, False: 349]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/helper/item_reader.rs
Line
Count
Source
1
//! Helper utilities for converting input sources into skim item streams.
2
3
use std::collections::BTreeMap;
4
use std::error::Error;
5
use std::io::{BufRead, BufReader};
6
use std::process::{Child, Stdio};
7
use std::sync::Arc;
8
use std::sync::atomic::{AtomicUsize, Ordering};
9
use std::thread;
10
11
use crate::thread_pool::ThreadPool;
12
13
/// Size of the read buffer used by the parallel I/O reader thread.
14
const PARALLEL_READ_BUF_SIZE: usize = 256 * 1024;
15
16
use regex::Regex;
17
18
use crate::field::FieldRange;
19
use crate::helper::item::DefaultSkimItem;
20
use crate::reader::CommandCollector;
21
use crate::{SkimItem, SkimItemReceiver, SkimItemSender, SkimOptions};
22
23
const DELIMITER_STR: &str = r"[\t\n ]+";
24
const READ_BUFFER_SIZE: usize = 1024;
25
26
/// Options for configuring how items are read and parsed
27
#[derive(Debug)]
28
pub struct SkimItemReaderOption {
29
    buf_size: usize,
30
    use_ansi_color: bool,
31
    transform_fields: Vec<FieldRange>,
32
    matching_fields: Vec<FieldRange>,
33
    hidden_fields: Vec<FieldRange>,
34
    delimiter: Regex,
35
    line_ending: u8,
36
    show_error: bool,
37
    disable_pattern: Option<Regex>,
38
}
39
40
impl Default for SkimItemReaderOption {
41
886
    fn default() -> Self {
42
886
        Self {
43
886
            buf_size: READ_BUFFER_SIZE,
44
886
            line_ending: b'\n',
45
886
            use_ansi_color: false,
46
886
            transform_fields: Vec::new(),
47
886
            matching_fields: Vec::new(),
48
886
            hidden_fields: Vec::new(),
49
886
            delimiter: Regex::new(DELIMITER_STR).unwrap(),
50
886
            show_error: false,
51
886
            disable_pattern: None,
52
886
        }
53
886
    }
54
}
55
56
impl SkimItemReaderOption {
57
    /// Creates reader options from skim options
58
    #[must_use]
59
355
    pub fn from_options(options: &SkimOptions) -> Self {
60
        Self {
61
            buf_size: READ_BUFFER_SIZE,
62
355
            line_ending: if options.read0 { 
b'\0'4
} else {
b'\n'351
},
  Branch (62:29): [True: 3, False: 350]
 
  Branch (62:29): [True: 1, False: 1]
-
63
354
            use_ansi_color: options.ansi,
64
354
            transform_fields: options
65
354
                .with_nth
66
354
                .iter()
67
354
                .filter_map(|f| if 
f353
.
is_empty353
() {
None334
} else {
FieldRange::from_str19
(
f19
)
}353
)
  Branch (67:36): [True: 334, False: 19]
+
63
355
            use_ansi_color: options.ansi,
64
355
            transform_fields: options
65
355
                .with_nth
66
355
                .iter()
67
355
                .filter_map(|f| if 
f354
.
is_empty354
() {
None335
} else {
FieldRange::from_str19
(
f19
)
}354
)
  Branch (67:36): [True: 335, False: 19]
 
  Branch (67:36): [True: 0, False: 0]
-
68
354
                .collect(),
69
354
            matching_fields: options
70
354
                .nth
71
354
                .iter()
72
355
                .
filter_map354
(|f| if f.is_empty() {
None333
} else {
FieldRange::from_str22
(
f22
) })
  Branch (72:36): [True: 333, False: 22]
+
68
355
                .collect(),
69
355
            matching_fields: options
70
355
                .nth
71
355
                .iter()
72
356
                .
filter_map355
(|f| if f.is_empty() {
None334
} else {
FieldRange::from_str22
(
f22
) })
  Branch (72:36): [True: 334, False: 22]
 
  Branch (72:36): [True: 0, False: 0]
-
73
354
                .collect(),
74
354
            hidden_fields: options
75
354
                .hide_nth
76
354
                .iter()
77
354
                .filter_map(|f| if 
f353
.
is_empty353
() {
None343
} else {
FieldRange::from_str10
(
f10
)
}353
)
  Branch (77:36): [True: 343, False: 10]
+
73
355
                .collect(),
74
355
            hidden_fields: options
75
355
                .hide_nth
76
355
                .iter()
77
355
                .filter_map(|f| if 
f354
.
is_empty354
() {
None344
} else {
FieldRange::from_str10
(
f10
)
}354
)
  Branch (77:36): [True: 344, False: 10]
 
  Branch (77:36): [True: 0, False: 0]
-
78
354
                .collect(),
79
354
            delimiter: options.delimiter.clone(),
80
354
            show_error: options.show_cmd_error,
81
354
            disable_pattern: options.disable_pattern.clone(),
82
        }
83
354
    }
84
85
    /// Sets the buffer size for reading
86
    #[must_use]
87
1
    pub fn buf_size(mut self, buf_size: usize) -> Self {
88
1
        self.buf_size = buf_size;
89
1
        self
90
1
    }
91
92
    /// Sets the line ending character (default: '\n')
93
    #[must_use]
94
1
    pub fn line_ending(mut self, line_ending: u8) -> Self {
95
1
        self.line_ending = line_ending;
96
1
        self
97
1
    }
98
99
    /// Enables or disables ANSI color code parsing
100
    #[must_use]
101
2
    pub fn ansi(mut self, enable: bool) -> Self {
102
2
        self.use_ansi_color = enable;
103
2
        self
104
2
    }
105
106
    /// Sets the field delimiter regex
107
    #[must_use]
108
1
    pub fn delimiter(mut self, delimiter: Regex) -> Self {
109
1
        self.delimiter = delimiter;
110
1
        self
111
1
    }
112
113
    /// Sets the fields to display (transform) from the input
114
    #[must_use]
115
2
    pub fn with_nth<'a, T>(mut self, with_nth: T) -> Self
116
2
    where
117
2
        T: Iterator<Item = &'a str>,
118
    {
119
2
        self.transform_fields = with_nth.filter_map(FieldRange::from_str).collect();
120
2
        self
121
2
    }
122
123
    /// Sets the transform fields directly
124
    #[must_use]
125
1
    pub fn transform_fields(mut self, transform_fields: Vec<FieldRange>) -> Self {
126
1
        self.transform_fields = transform_fields;
127
1
        self
128
1
    }
129
130
    /// Sets the fields to use for matching
131
    #[must_use]
132
1
    pub fn nth<'a, T>(mut self, nth: T) -> Self
133
1
    where
134
1
        T: Iterator<Item = &'a str>,
135
    {
136
1
        self.matching_fields = nth.filter_map(FieldRange::from_str).collect();
137
1
        self
138
1
    }
139
140
    /// Sets the matching fields directly
141
    #[must_use]
142
1
    pub fn matching_fields(mut self, matching_fields: Vec<FieldRange>) -> Self {
143
1
        self.matching_fields = matching_fields;
144
1
        self
145
1
    }
146
147
    /// Sets the fields to hide from display (while keeping them searchable)
148
    #[must_use]
149
0
    pub fn hide_nth<'a, T>(mut self, hide_nth: T) -> Self
150
0
    where
151
0
        T: Iterator<Item = &'a str>,
152
    {
153
0
        self.hidden_fields = hide_nth.filter_map(FieldRange::from_str).collect();
154
0
        self
155
0
    }
156
157
    /// Sets the hidden fields directly
158
    #[must_use]
159
0
    pub fn hidden_fields(mut self, hidden_fields: Vec<FieldRange>) -> Self {
160
0
        self.hidden_fields = hidden_fields;
161
0
        self
162
0
    }
163
164
    /// Enables reading null-terminated lines instead of newline-terminated
165
    #[must_use]
166
4
    pub fn read0(mut self, enable: bool) -> Self {
167
4
        if enable {
  Branch (167:12): [Folded - Ignored]
+
78
355
                .collect(),
79
355
            delimiter: options.delimiter.clone(),
80
355
            show_error: options.show_cmd_error,
81
355
            disable_pattern: options.disable_pattern.clone(),
82
        }
83
355
    }
84
85
    /// Sets the buffer size for reading
86
    #[must_use]
87
1
    pub fn buf_size(mut self, buf_size: usize) -> Self {
88
1
        self.buf_size = buf_size;
89
1
        self
90
1
    }
91
92
    /// Sets the line ending character (default: '\n')
93
    #[must_use]
94
1
    pub fn line_ending(mut self, line_ending: u8) -> Self {
95
1
        self.line_ending = line_ending;
96
1
        self
97
1
    }
98
99
    /// Enables or disables ANSI color code parsing
100
    #[must_use]
101
2
    pub fn ansi(mut self, enable: bool) -> Self {
102
2
        self.use_ansi_color = enable;
103
2
        self
104
2
    }
105
106
    /// Sets the field delimiter regex
107
    #[must_use]
108
1
    pub fn delimiter(mut self, delimiter: Regex) -> Self {
109
1
        self.delimiter = delimiter;
110
1
        self
111
1
    }
112
113
    /// Sets the fields to display (transform) from the input
114
    #[must_use]
115
2
    pub fn with_nth<'a, T>(mut self, with_nth: T) -> Self
116
2
    where
117
2
        T: Iterator<Item = &'a str>,
118
    {
119
2
        self.transform_fields = with_nth.filter_map(FieldRange::from_str).collect();
120
2
        self
121
2
    }
122
123
    /// Sets the transform fields directly
124
    #[must_use]
125
1
    pub fn transform_fields(mut self, transform_fields: Vec<FieldRange>) -> Self {
126
1
        self.transform_fields = transform_fields;
127
1
        self
128
1
    }
129
130
    /// Sets the fields to use for matching
131
    #[must_use]
132
1
    pub fn nth<'a, T>(mut self, nth: T) -> Self
133
1
    where
134
1
        T: Iterator<Item = &'a str>,
135
    {
136
1
        self.matching_fields = nth.filter_map(FieldRange::from_str).collect();
137
1
        self
138
1
    }
139
140
    /// Sets the matching fields directly
141
    #[must_use]
142
1
    pub fn matching_fields(mut self, matching_fields: Vec<FieldRange>) -> Self {
143
1
        self.matching_fields = matching_fields;
144
1
        self
145
1
    }
146
147
    /// Sets the fields to hide from display (while keeping them searchable)
148
    #[must_use]
149
0
    pub fn hide_nth<'a, T>(mut self, hide_nth: T) -> Self
150
0
    where
151
0
        T: Iterator<Item = &'a str>,
152
    {
153
0
        self.hidden_fields = hide_nth.filter_map(FieldRange::from_str).collect();
154
0
        self
155
0
    }
156
157
    /// Sets the hidden fields directly
158
    #[must_use]
159
0
    pub fn hidden_fields(mut self, hidden_fields: Vec<FieldRange>) -> Self {
160
0
        self.hidden_fields = hidden_fields;
161
0
        self
162
0
    }
163
164
    /// Enables reading null-terminated lines instead of newline-terminated
165
    #[must_use]
166
4
    pub fn read0(mut self, enable: bool) -> Self {
167
4
        if enable {
  Branch (167:12): [Folded - Ignored]
 
  Branch (167:12): [True: 3, False: 1]
-
168
3
            self.line_ending = b'\0';
169
3
        } else {
170
1
            self.line_ending = b'\n';
171
1
        }
172
4
        self
173
4
    }
174
175
    /// Sets whether to show command errors
176
    #[must_use]
177
2
    pub fn show_error(mut self, show_error: bool) -> Self {
178
2
        self.show_error = show_error;
179
2
        self
180
2
    }
181
182
    /// Builds the options (currently a no-op, returns self)
183
    #[must_use]
184
8
    pub fn build(self) -> Self {
185
8
        self
186
8
    }
187
}
188
189
/// Reader for converting various input sources into streams of skim items
190
pub struct SkimItemReader {
191
    option: Arc<SkimItemReaderOption>,
192
    /// Thread pool used for chunk-processing jobs.  Reader and matcher share
193
    /// this pool so they compete for the same thread budget rather than each
194
    /// spawning their own OS threads.  Defaults to a private pool sized to the
195
    /// number of logical CPUs; callers can replace it with a shared pool via
196
    /// [`with_thread_pool`](Self::with_thread_pool) or
197
    /// [`set_thread_pool`](Self::set_thread_pool).
198
    thread_pool: Arc<ThreadPool>,
199
}
200
201
1.23k
fn default_thread_pool() -> Arc<ThreadPool> {
202
1.23k
    let n = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
203
1.23k
    let (reader_threads, _) = crate::thread_pool::partition_threads(n);
204
1.23k
    Arc::new(ThreadPool::new(reader_threads))
205
1.23k
}
206
207
impl Default for SkimItemReader {
208
878
    fn default() -> Self {
209
878
        Self {
210
878
            option: Arc::new(Default::default()),
211
878
            thread_pool: default_thread_pool(),
212
878
        }
213
878
    }
214
}
215
216
impl SkimItemReader {
217
    /// Creates a new item reader with the given options
218
    #[must_use]
219
358
    pub fn new(option: SkimItemReaderOption) -> Self {
220
358
        Self {
221
358
            option: Arc::new(option),
222
358
            thread_pool: default_thread_pool(),
223
358
        }
224
358
    }
225
226
    /// Sets the reader options
227
    #[must_use]
228
1
    pub fn option(mut self, option: SkimItemReaderOption) -> Self {
229
1
        self.option = Arc::new(option);
230
1
        self
231
1
    }
232
233
    /// Replaces the thread pool used for chunk-processing.  Pass the matcher's
234
    /// pool here so that reader and matcher share the same thread budget.
235
    #[must_use]
236
1
    pub fn with_thread_pool(mut self, pool: Arc<ThreadPool>) -> Self {
237
1
        self.thread_pool = pool;
238
1
        self
239
1
    }
240
241
    /// Like [`with_thread_pool`] but takes `&mut self` — useful when the pool
242
    /// is only available after construction (e.g. injected from the app).
243
1
    pub fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
244
1
        self.thread_pool = pool;
245
1
    }
246
}
247
248
impl SkimItemReader {
249
    /// Converts a `BufRead` source into a stream of skim items using the
250
    /// parallel pipeline.
251
362
    pub fn of_bufread(&self, source: impl BufRead + Send + 'static) -> SkimItemReceiver {
252
362
        self.parallel_bufread(source, None, &Arc::new(AtomicUsize::new(0))).0
253
362
    }
254
255
    /// Core parallel reader pipeline.
256
    ///
257
    /// All input — whether a plain pipe, a `--ansi`-decorated stream, or one
258
    /// with `--nth`/`--with-nth` field transforms — goes through the same four
259
    /// stages.  Every per-line operation inside `DefaultSkimItem::new` is
260
    /// stateless and purely functional, so chunks can be processed concurrently
261
    /// without any coordination beyond sequence reordering.
262
    ///
263
    /// Pipeline:
264
    ///
265
    /// 1. **I/O thread** (dedicated) — reads large byte chunks (~256 KB) from
266
    ///    `source`, splitting on line boundaries, and sends them tagged with
267
    ///    monotonic sequence numbers into a bounded channel.
268
    /// 2. **Dispatcher thread** (dedicated, lightweight) — drains that channel
269
    ///    and submits one pool job per chunk.  The bounded channel provides
270
    ///    natural back-pressure on the I/O thread when the pool is busy.
271
    /// 3. **Pool jobs** — parse lines, validate UTF-8, apply ANSI stripping and
272
    ///    field transforms, and create `DefaultSkimItem` + `Arc` per line.
273
    ///    Because these jobs share the same pool as the matcher, reader and
274
    ///    matcher compete for the same thread budget rather than over-subscribing
275
    ///    available CPU cores.
276
    /// 4. **Reorder thread** (dedicated) — collects `(seq, items)` from pool
277
    ///    jobs and emits them in sequence order so downstream index assignment
278
    ///    and `--tac` behaviour are correct.
279
    ///
280
    /// When `child` is `Some`, a **killer thread** is also spawned.  It waits
281
    /// on `rx_interrupt` and kills the child process on request (or when the
282
    /// reader is dropped).  This thread participates in `components_to_stop`
283
    /// accounting so that [`ReaderControl::kill`] waits for it to finish.
284
    ///
285
    /// Returns `(rx_item, tx_interrupt)`.  The caller must send on `tx_interrupt`
286
    /// to signal shutdown; the killer thread (if any) will then kill the child.
287
435
    fn parallel_bufread(
288
435
        &self,
289
435
        source: impl BufRead + Send + 'static,
290
435
        child: Option<Child>,
291
435
        components_to_stop: &Arc<AtomicUsize>,
292
435
    ) -> (SkimItemReceiver, crate::prelude::Sender<i32>) {
293
435
        let (tx_item, rx_item): (SkimItemSender, SkimItemReceiver) = kanal::bounded(1024 * 1024);
294
435
        let option = self.option.clone();
295
435
        let pool = Arc::clone(&self.thread_pool);
296
297
435
        let num_threads = pool.num_threads();
298
435
        let (tx_chunks, rx_chunks) = kanal::bounded::<(usize, Vec<u8>)>(num_threads * 4);
299
435
        let (tx_results, rx_results) = kanal::bounded::<(usize, Vec<Arc<dyn SkimItem>>)>(num_threads * 4);
300
301
435
        let line_ending = option.line_ending;
302
303
        // Stage 1: I/O thread.
304
435
        Self::spawn_io_reader(source, tx_chunks, line_ending);
305
306
        // Stage 2: dispatcher thread — bridges the bounded channel to the pool.
307
435
        thread::spawn(move || {
308
846
            while let Ok((
seq411
,
chunk411
)) = rx_chunks.recv() {
  Branch (308:23): [True: 20, False: 69]
-
  Branch (308:23): [True: 30, False: 30]
+
168
3
            self.line_ending = b'\0';
169
3
        } else {
170
1
            self.line_ending = b'\n';
171
1
        }
172
4
        self
173
4
    }
174
175
    /// Sets whether to show command errors
176
    #[must_use]
177
2
    pub fn show_error(mut self, show_error: bool) -> Self {
178
2
        self.show_error = show_error;
179
2
        self
180
2
    }
181
182
    /// Builds the options (currently a no-op, returns self)
183
    #[must_use]
184
8
    pub fn build(self) -> Self {
185
8
        self
186
8
    }
187
}
188
189
/// Reader for converting various input sources into streams of skim items
190
pub struct SkimItemReader {
191
    option: Arc<SkimItemReaderOption>,
192
    /// Thread pool used for chunk-processing jobs.  Reader and matcher share
193
    /// this pool so they compete for the same thread budget rather than each
194
    /// spawning their own OS threads.  Defaults to a private pool sized to the
195
    /// number of logical CPUs; callers can replace it with a shared pool via
196
    /// [`with_thread_pool`](Self::with_thread_pool) or
197
    /// [`set_thread_pool`](Self::set_thread_pool).
198
    thread_pool: Arc<ThreadPool>,
199
}
200
201
1.23k
fn default_thread_pool() -> Arc<ThreadPool> {
202
1.23k
    let n = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
203
1.23k
    let (reader_threads, _) = crate::thread_pool::partition_threads(n);
204
1.23k
    Arc::new(ThreadPool::new(reader_threads))
205
1.23k
}
206
207
impl Default for SkimItemReader {
208
878
    fn default() -> Self {
209
878
        Self {
210
878
            option: Arc::new(Default::default()),
211
878
            thread_pool: default_thread_pool(),
212
878
        }
213
878
    }
214
}
215
216
impl SkimItemReader {
217
    /// Creates a new item reader with the given options
218
    #[must_use]
219
359
    pub fn new(option: SkimItemReaderOption) -> Self {
220
359
        Self {
221
359
            option: Arc::new(option),
222
359
            thread_pool: default_thread_pool(),
223
359
        }
224
359
    }
225
226
    /// Sets the reader options
227
    #[must_use]
228
1
    pub fn option(mut self, option: SkimItemReaderOption) -> Self {
229
1
        self.option = Arc::new(option);
230
1
        self
231
1
    }
232
233
    /// Replaces the thread pool used for chunk-processing.  Pass the matcher's
234
    /// pool here so that reader and matcher share the same thread budget.
235
    #[must_use]
236
1
    pub fn with_thread_pool(mut self, pool: Arc<ThreadPool>) -> Self {
237
1
        self.thread_pool = pool;
238
1
        self
239
1
    }
240
241
    /// Like [`with_thread_pool`] but takes `&mut self` — useful when the pool
242
    /// is only available after construction (e.g. injected from the app).
243
1
    pub fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
244
1
        self.thread_pool = pool;
245
1
    }
246
}
247
248
impl SkimItemReader {
249
    /// Converts a `BufRead` source into a stream of skim items using the
250
    /// parallel pipeline.
251
363
    pub fn of_bufread(&self, source: impl BufRead + Send + 'static) -> SkimItemReceiver {
252
363
        self.parallel_bufread(source, None, &Arc::new(AtomicUsize::new(0))).0
253
363
    }
254
255
    /// Core parallel reader pipeline.
256
    ///
257
    /// All input — whether a plain pipe, a `--ansi`-decorated stream, or one
258
    /// with `--nth`/`--with-nth` field transforms — goes through the same four
259
    /// stages.  Every per-line operation inside `DefaultSkimItem::new` is
260
    /// stateless and purely functional, so chunks can be processed concurrently
261
    /// without any coordination beyond sequence reordering.
262
    ///
263
    /// Pipeline:
264
    ///
265
    /// 1. **I/O thread** (dedicated) — reads large byte chunks (~256 KB) from
266
    ///    `source`, splitting on line boundaries, and sends them tagged with
267
    ///    monotonic sequence numbers into a bounded channel.
268
    /// 2. **Dispatcher thread** (dedicated, lightweight) — drains that channel
269
    ///    and submits one pool job per chunk.  The bounded channel provides
270
    ///    natural back-pressure on the I/O thread when the pool is busy.
271
    /// 3. **Pool jobs** — parse lines, validate UTF-8, apply ANSI stripping and
272
    ///    field transforms, and create `DefaultSkimItem` + `Arc` per line.
273
    ///    Because these jobs share the same pool as the matcher, reader and
274
    ///    matcher compete for the same thread budget rather than over-subscribing
275
    ///    available CPU cores.
276
    /// 4. **Reorder thread** (dedicated) — collects `(seq, items)` from pool
277
    ///    jobs and emits them in sequence order so downstream index assignment
278
    ///    and `--tac` behaviour are correct.
279
    ///
280
    /// When `child` is `Some`, a **killer thread** is also spawned.  It waits
281
    /// on `rx_interrupt` and kills the child process on request (or when the
282
    /// reader is dropped).  This thread participates in `components_to_stop`
283
    /// accounting so that [`ReaderControl::kill`] waits for it to finish.
284
    ///
285
    /// Returns `(rx_item, tx_interrupt)`.  The caller must send on `tx_interrupt`
286
    /// to signal shutdown; the killer thread (if any) will then kill the child.
287
435
    fn parallel_bufread(
288
435
        &self,
289
435
        source: impl BufRead + Send + 'static,
290
435
        child: Option<Child>,
291
435
        components_to_stop: &Arc<AtomicUsize>,
292
435
    ) -> (SkimItemReceiver, crate::prelude::Sender<i32>) {
293
435
        let (tx_item, rx_item): (SkimItemSender, SkimItemReceiver) = kanal::bounded(1024 * 1024);
294
435
        let option = self.option.clone();
295
435
        let pool = Arc::clone(&self.thread_pool);
296
297
435
        let num_threads = pool.num_threads();
298
435
        let (tx_chunks, rx_chunks) = kanal::bounded::<(usize, Vec<u8>)>(num_threads * 4);
299
435
        let (tx_results, rx_results) = kanal::bounded::<(usize, Vec<Arc<dyn SkimItem>>)>(num_threads * 4);
300
301
435
        let line_ending = option.line_ending;
302
303
        // Stage 1: I/O thread.
304
435
        Self::spawn_io_reader(source, tx_chunks, line_ending);
305
306
        // Stage 2: dispatcher thread — bridges the bounded channel to the pool.
307
435
        thread::spawn(move || {
308
846
            while let Ok((
seq411
,
chunk411
)) = rx_chunks.recv() {
  Branch (308:23): [True: 19, False: 68]
+
  Branch (308:23): [True: 32, False: 32]
 
  Branch (308:23): [True: 2, False: 2]
 
  Branch (308:23): [True: 24, False: 24]
 
  Branch (308:23): [True: 12, False: 12]
 
  Branch (308:23): [True: 2, False: 2]
-
  Branch (308:23): [True: 10, False: 10]
+
  Branch (308:23): [True: 9, False: 9]
 
  Branch (308:23): [True: 1, False: 1]
-
  Branch (308:23): [True: 7, False: 7]
+
  Branch (308:23): [True: 6, False: 6]
 
  Branch (308:23): [True: 34, False: 34]
 
  Branch (308:23): [True: 2, False: 1]
 
  Branch (308:23): [True: 1, False: 1]
-
  Branch (308:23): [True: 9, False: 9]
+
  Branch (308:23): [True: 10, False: 10]
 
  Branch (308:23): [True: 54, False: 31]
-
  Branch (308:23): [True: 3, False: 3]
+
  Branch (308:23): [True: 4, False: 4]
 
  Branch (308:23): [True: 5, False: 5]
 
  Branch (308:23): [True: 4, False: 4]
 
  Branch (308:23): [True: 13, False: 13]
@@ -31,35 +31,35 @@
 
  Branch (308:23): [True: 5, False: 5]
 
  Branch (308:23): [True: 113, False: 113]
 
  Branch (308:23): [True: 4, False: 3]
-
  Branch (308:23): [True: 10, False: 10]
+
  Branch (308:23): [True: 9, False: 9]
 
  Branch (308:23): [True: 22, False: 22]
 
  Branch (308:23): [True: 12, False: 12]
-
309
411
                let tx = tx_results.clone();
310
411
                let opt = option.clone();
311
411
                pool.spawn(move || {
312
411
                    let result = Self::process_chunk(seq, &chunk, &opt);
313
411
                    let _ = tx.send(result);
314
411
                });
315
            }
316
            // rx_chunks closed → all chunks dispatched; tx_results dropped here
317
            // so the reorder thread exits once the last pool job finishes.
318
435
        });
319
320
        // A zero-capacity channel used as a completion signal: the reorder
321
        // thread drops its sender when it exits, closing the channel.  The
322
        // killer thread waits on either this signal (natural EOF) or on the
323
        // external interrupt (early termination request).
324
435
        let (tx_pipeline_done, rx_pipeline_done) = kanal::bounded::<()>(0);
325
326
        // Stage 4: reorder thread.
327
435
        Self::spawn_reorder_thread(rx_results, tx_item.clone(), tx_pipeline_done);
328
329
        // Killer thread: exits when the pipeline drains naturally (child process
330
        // reached EOF) OR when it receives an explicit interrupt signal.
331
        //
332
        // This thread participates in `components_to_stop` accounting so that
333
        // [`ReaderControl::is_done`] correctly waits for cleanup to complete.
334
435
        let (tx_interrupt, rx_interrupt) = crate::prelude::bounded::<i32>(8);
335
435
        let components_to_stop_killer = components_to_stop.clone();
336
435
        components_to_stop.fetch_add(1, Ordering::SeqCst);
337
435
        thread::spawn(move || {
338
435
            debug!("parallel reader: killer thread start");
339
340
            // Wait for either a kill request or the pipeline finishing naturally.
341
            // kanal doesn't have a multi-channel select, so we poll with a short
342
            // timeout.  Both channels are bounded so this never busy-spins in
343
            // practice: the kill path is rare, and the done path fires quickly.
344
            loop {
345
512
                if rx_interrupt.try_recv().is_ok_and(|v| 
v98
.
is_some98
()) {
  Branch (345:20): [True: 0, False: 76]
-
  Branch (345:20): [True: 0, False: 33]
+
309
411
                let tx = tx_results.clone();
310
411
                let opt = option.clone();
311
411
                pool.spawn(move || {
312
411
                    let result = Self::process_chunk(seq, &chunk, &opt);
313
411
                    let _ = tx.send(result);
314
411
                });
315
            }
316
            // rx_chunks closed → all chunks dispatched; tx_results dropped here
317
            // so the reorder thread exits once the last pool job finishes.
318
435
        });
319
320
        // A zero-capacity channel used as a completion signal: the reorder
321
        // thread drops its sender when it exits, closing the channel.  The
322
        // killer thread waits on either this signal (natural EOF) or on the
323
        // external interrupt (early termination request).
324
435
        let (tx_pipeline_done, rx_pipeline_done) = kanal::bounded::<()>(0);
325
326
        // Stage 4: reorder thread.
327
435
        Self::spawn_reorder_thread(rx_results, tx_item.clone(), tx_pipeline_done);
328
329
        // Killer thread: exits when the pipeline drains naturally (child process
330
        // reached EOF) OR when it receives an explicit interrupt signal.
331
        //
332
        // This thread participates in `components_to_stop` accounting so that
333
        // [`ReaderControl::is_done`] correctly waits for cleanup to complete.
334
435
        let (tx_interrupt, rx_interrupt) = crate::prelude::bounded::<i32>(8);
335
435
        let components_to_stop_killer = components_to_stop.clone();
336
435
        components_to_stop.fetch_add(1, Ordering::SeqCst);
337
435
        thread::spawn(move || 
{434
338
434
            debug!("parallel reader: killer thread start");
339
340
            // Wait for either a kill request or the pipeline finishing naturally.
341
            // kanal doesn't have a multi-channel select, so we poll with a short
342
            // timeout.  Both channels are bounded so this never busy-spins in
343
            // practice: the kill path is rare, and the done path fires quickly.
344
            loop {
345
558
                if rx_interrupt.try_recv().is_ok_and(|v| 
v101
.
is_some101
()) {
  Branch (345:20): [True: 0, False: 86]
+
  Branch (345:20): [True: 0, False: 44]
 
  Branch (345:20): [True: 0, False: 2]
 
  Branch (345:20): [True: 0, False: 24]
 
  Branch (345:20): [True: 0, False: 14]
-
  Branch (345:20): [True: 0, False: 2]
-
  Branch (345:20): [True: 0, False: 13]
-
  Branch (345:20): [True: 0, False: 1]
-
  Branch (345:20): [True: 0, False: 9]
-
  Branch (345:20): [True: 0, False: 38]
-
  Branch (345:20): [True: 0, False: 1]
-
  Branch (345:20): [True: 0, False: 1]
-
  Branch (345:20): [True: 0, False: 11]
-
  Branch (345:20): [True: 0, False: 44]
-
  Branch (345:20): [True: 0, False: 4]
-
  Branch (345:20): [True: 0, False: 7]
-
  Branch (345:20): [True: 0, False: 8]
-
  Branch (345:20): [True: 0, False: 13]
-
  Branch (345:20): [True: 0, False: 1]
-
  Branch (345:20): [True: 0, False: 11]
-
  Branch (345:20): [True: 0, False: 5]
-
  Branch (345:20): [True: 0, False: 138]
 
  Branch (345:20): [True: 0, False: 3]
 
  Branch (345:20): [True: 0, False: 12]
-
  Branch (345:20): [True: 0, False: 26]
-
  Branch (345:20): [True: 0, False: 15]
+
  Branch (345:20): [True: 0, False: 1]
+
  Branch (345:20): [True: 0, False: 6]
+
  Branch (345:20): [True: 0, False: 41]
+
  Branch (345:20): [True: 0, False: 1]
+
  Branch (345:20): [True: 0, False: 1]
+
  Branch (345:20): [True: 0, False: 11]
+
  Branch (345:20): [True: 0, False: 39]
+
  Branch (345:20): [True: 0, False: 4]
+
  Branch (345:20): [True: 0, False: 10]
+
  Branch (345:20): [True: 0, False: 4]
+
  Branch (345:20): [True: 0, False: 24]
+
  Branch (345:20): [True: 0, False: 1]
+
  Branch (345:20): [True: 0, False: 11]
+
  Branch (345:20): [True: 0, False: 6]
+
  Branch (345:20): [True: 0, False: 158]
+
  Branch (345:20): [True: 0, False: 3]
+
  Branch (345:20): [True: 0, False: 13]
+
  Branch (345:20): [True: 0, False: 27]
+
  Branch (345:20): [True: 0, False: 12]
 
346
                    // Explicit kill: terminate the child immediately.
347
0
                    if let Some(mut c) = child {
  Branch (347:28): [True: 0, False: 0]
 
  Branch (347:28): [True: 0, False: 0]
 
  Branch (347:28): [True: 0, False: 0]
@@ -86,47 +86,47 @@
 
  Branch (347:28): [True: 0, False: 0]
 
  Branch (347:28): [True: 0, False: 0]
 
  Branch (347:28): [True: 0, False: 0]
-
348
0
                        let _ = c.kill();
349
0
                        let _ = c.wait();
350
0
                    }
351
0
                    break;
352
512
                }
353
                // Channel closed = reorder thread exited = pipeline drained.
354
512
                match rx_pipeline_done.recv_timeout(std::time::Duration::from_millis(1)) {
355
1
                    Ok(()) => break,
356
                    Err(kanal::ReceiveErrorTimeout::Closed | kanal::ReceiveErrorTimeout::SendClosed) => {
357
                        // Natural EOF: child already exited; just reap if present.
358
434
                        if let Some(
mut c73
) = child {
  Branch (358:32): [True: 69, False: 0]
-
  Branch (358:32): [True: 0, False: 30]
+
348
0
                        let _ = c.kill();
349
0
                        let _ = c.wait();
350
0
                    }
351
0
                    break;
352
558
                }
353
                // Channel closed = reorder thread exited = pipeline drained.
354
558
                match rx_pipeline_done.recv_timeout(std::time::Duration::from_millis(1)) {
355
0
                    Ok(()) => break,
356
                    Err(kanal::ReceiveErrorTimeout::Closed | kanal::ReceiveErrorTimeout::SendClosed) => {
357
                        // Natural EOF: child already exited; just reap if present.
358
434
                        if let Some(
mut c71
) = child {
  Branch (358:32): [True: 68, False: 0]
+
  Branch (358:32): [True: 0, False: 32]
 
  Branch (358:32): [True: 0, False: 2]
 
  Branch (358:32): [True: 0, False: 24]
 
  Branch (358:32): [True: 0, False: 12]
 
  Branch (358:32): [True: 0, False: 2]
-
  Branch (358:32): [True: 0, False: 10]
+
  Branch (358:32): [True: 0, False: 9]
 
  Branch (358:32): [True: 0, False: 1]
-
  Branch (358:32): [True: 0, False: 7]
+
  Branch (358:32): [True: 0, False: 6]
 
  Branch (358:32): [True: 0, False: 34]
 
  Branch (358:32): [True: 0, False: 1]
 
  Branch (358:32): [True: 0, False: 1]
-
  Branch (358:32): [True: 0, False: 9]
+
  Branch (358:32): [True: 0, False: 10]
 
  Branch (358:32): [True: 0, False: 31]
-
  Branch (358:32): [True: 0, False: 3]
+
  Branch (358:32): [True: 0, False: 4]
 
  Branch (358:32): [True: 0, False: 5]
-
  Branch (358:32): [True: 4, False: 0]
-
  Branch (358:32): [True: 0, False: 12]
+
  Branch (358:32): [True: 3, False: 0]
+
  Branch (358:32): [True: 0, False: 13]
 
  Branch (358:32): [True: 0, False: 1]
 
  Branch (358:32): [True: 0, False: 11]
 
  Branch (358:32): [True: 0, False: 5]
 
  Branch (358:32): [True: 0, False: 113]
 
  Branch (358:32): [True: 0, False: 3]
-
  Branch (358:32): [True: 0, False: 10]
+
  Branch (358:32): [True: 0, False: 9]
 
  Branch (358:32): [True: 0, False: 22]
 
  Branch (358:32): [True: 0, False: 12]
-
359
73
                            let _ = c.wait();
360
361
                        }
361
434
                        break;
362
                    }
363
77
                    Err(kanal::ReceiveErrorTimeout::Timeout) => {
364
77
                        // Neither signal yet — loop.
365
77
                    }
366
                }
367
            }
368
369
435
            components_to_stop_killer.fetch_sub(1, Ordering::SeqCst);
370
435
            debug!("parallel reader: killer thread stop");
371
435
        });
372
373
435
        (rx_item, tx_interrupt)
374
435
    }
375
376
    /// Stage 1 of the parallel reader: reads large byte chunks from `source`,
377
    /// splitting on line boundaries, and sends them to workers.
378
435
    fn spawn_io_reader(
379
435
        source: impl BufRead + Send + 'static,
380
435
        tx_chunks: kanal::Sender<(usize, Vec<u8>)>,
381
435
        line_ending: u8,
382
435
    ) {
383
435
        thread::spawn(move || {
384
435
            debug!("parallel reader: I/O thread start");
385
386
435
            let mut source = source;
387
435
            let mut leftover: Vec<u8> = Vec::new();
388
435
            let mut seq = 0usize;
389
435
            let mut read_buf = vec![0u8; PARALLEL_READ_BUF_SIZE];
390
391
            loop {
392
826
                let 
n391
= match std::io::Read::read(&mut source, &mut read_buf) {
393
                    Ok(0) => {
394
                        // EOF — flush any remaining leftover as the final chunk.
395
435
                        if !leftover.is_empty() {
  Branch (395:28): [True: 5, False: 64]
-
  Branch (395:28): [True: 0, False: 30]
+
359
71
                            let _ = c.wait();
360
363
                        }
361
434
                        break;
362
                    }
363
124
                    Err(kanal::ReceiveErrorTimeout::Timeout) => {
364
124
                        // Neither signal yet — loop.
365
124
                    }
366
                }
367
            }
368
369
434
            components_to_stop_killer.fetch_sub(1, Ordering::SeqCst);
370
434
            debug!("parallel reader: killer thread stop");
371
434
        });
372
373
435
        (rx_item, tx_interrupt)
374
435
    }
375
376
    /// Stage 1 of the parallel reader: reads large byte chunks from `source`,
377
    /// splitting on line boundaries, and sends them to workers.
378
435
    fn spawn_io_reader(
379
435
        source: impl BufRead + Send + 'static,
380
435
        tx_chunks: kanal::Sender<(usize, Vec<u8>)>,
381
435
        line_ending: u8,
382
435
    ) {
383
435
        thread::spawn(move || {
384
435
            debug!("parallel reader: I/O thread start");
385
386
435
            let mut source = source;
387
435
            let mut leftover: Vec<u8> = Vec::new();
388
435
            let mut seq = 0usize;
389
435
            let mut read_buf = vec![0u8; PARALLEL_READ_BUF_SIZE];
390
391
            loop {
392
826
                let 
n391
= match std::io::Read::read(&mut source, &mut read_buf) {
393
                    Ok(0) => {
394
                        // EOF — flush any remaining leftover as the final chunk.
395
435
                        if !leftover.is_empty() {
  Branch (395:28): [True: 5, False: 63]
+
  Branch (395:28): [True: 0, False: 32]
 
  Branch (395:28): [True: 0, False: 2]
 
  Branch (395:28): [True: 0, False: 24]
 
  Branch (395:28): [True: 0, False: 12]
 
  Branch (395:28): [True: 0, False: 2]
-
  Branch (395:28): [True: 0, False: 10]
+
  Branch (395:28): [True: 0, False: 9]
 
  Branch (395:28): [True: 0, False: 1]
-
  Branch (395:28): [True: 0, False: 7]
+
  Branch (395:28): [True: 0, False: 6]
 
  Branch (395:28): [True: 0, False: 34]
 
  Branch (395:28): [True: 1, False: 0]
 
  Branch (395:28): [True: 0, False: 1]
-
  Branch (395:28): [True: 0, False: 9]
+
  Branch (395:28): [True: 0, False: 10]
 
  Branch (395:28): [True: 20, False: 11]
-
  Branch (395:28): [True: 0, False: 3]
+
  Branch (395:28): [True: 0, False: 4]
 
  Branch (395:28): [True: 0, False: 5]
 
  Branch (395:28): [True: 0, False: 4]
 
  Branch (395:28): [True: 0, False: 13]
@@ -135,7 +135,7 @@
 
  Branch (395:28): [True: 0, False: 5]
 
  Branch (395:28): [True: 0, False: 113]
 
  Branch (395:28): [True: 3, False: 0]
-
  Branch (395:28): [True: 0, False: 10]
+
  Branch (395:28): [True: 0, False: 9]
 
  Branch (395:28): [True: 0, False: 22]
 
  Branch (395:28): [True: 0, False: 12]
 
396
29
                            let _ = tx_chunks.send((seq, std::mem::take(&mut leftover)));
397
406
                        }
398
435
                        break;
399
                    }
400
391
                    Ok(n) => n,
401
0
                    Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
  Branch (401:35): [True: 0, False: 0]
@@ -190,21 +190,21 @@
 
  Branch (404:28): [True: 0, False: 0]
 
  Branch (404:28): [True: 0, False: 0]
 
  Branch (404:28): [True: 0, False: 0]
-
405
0
                            let _ = tx_chunks.send((seq, std::mem::take(&mut leftover)));
406
0
                        }
407
0
                        break;
408
                    }
409
                };
410
411
                // Combine leftover from previous iteration with fresh data.
412
391
                let data = if leftover.is_empty() {
  Branch (412:31): [True: 16, False: 0]
-
  Branch (412:31): [True: 30, False: 0]
+
405
0
                            let _ = tx_chunks.send((seq, std::mem::take(&mut leftover)));
406
0
                        }
407
0
                        break;
408
                    }
409
                };
410
411
                // Combine leftover from previous iteration with fresh data.
412
391
                let data = if leftover.is_empty() {
  Branch (412:31): [True: 15, False: 0]
+
  Branch (412:31): [True: 32, False: 0]
 
  Branch (412:31): [True: 2, False: 0]
 
  Branch (412:31): [True: 24, False: 0]
 
  Branch (412:31): [True: 12, False: 0]
 
  Branch (412:31): [True: 2, False: 0]
-
  Branch (412:31): [True: 10, False: 0]
+
  Branch (412:31): [True: 9, False: 0]
 
  Branch (412:31): [True: 1, False: 0]
-
  Branch (412:31): [True: 7, False: 0]
+
  Branch (412:31): [True: 6, False: 0]
 
  Branch (412:31): [True: 34, False: 0]
 
  Branch (412:31): [True: 1, False: 0]
 
  Branch (412:31): [True: 1, False: 0]
-
  Branch (412:31): [True: 9, False: 0]
+
  Branch (412:31): [True: 10, False: 0]
 
  Branch (412:31): [True: 31, False: 9]
-
  Branch (412:31): [True: 3, False: 0]
+
  Branch (412:31): [True: 4, False: 0]
 
  Branch (412:31): [True: 5, False: 0]
 
  Branch (412:31): [True: 4, False: 0]
 
  Branch (412:31): [True: 13, False: 0]
@@ -213,24 +213,24 @@
 
  Branch (412:31): [True: 5, False: 0]
 
  Branch (412:31): [True: 113, False: 0]
 
  Branch (412:31): [True: 3, False: 0]
-
  Branch (412:31): [True: 10, False: 0]
+
  Branch (412:31): [True: 9, False: 0]
 
  Branch (412:31): [True: 22, False: 0]
 
  Branch (412:31): [True: 12, False: 0]
-
413
382
                    read_buf[..n].to_vec()
414
                } else {
415
9
                    let mut combined = std::mem::take(&mut leftover);
416
9
                    combined.extend_from_slice(&read_buf[..n]);
417
9
                    combined
418
                };
419
420
                // Split at the last newline: everything up to it forms a
421
                // complete-line chunk; the remainder carries over.
422
391
                if let Some(
last_nl382
) = memchr::memrchr(line_ending, &data) {
  Branch (422:24): [True: 15, False: 1]
-
  Branch (422:24): [True: 30, False: 0]
+
413
382
                    read_buf[..n].to_vec()
414
                } else {
415
9
                    let mut combined = std::mem::take(&mut leftover);
416
9
                    combined.extend_from_slice(&read_buf[..n]);
417
9
                    combined
418
                };
419
420
                // Split at the last newline: everything up to it forms a
421
                // complete-line chunk; the remainder carries over.
422
391
                if let Some(
last_nl382
) = memchr::memrchr(line_ending, &data) {
  Branch (422:24): [True: 14, False: 1]
+
  Branch (422:24): [True: 32, False: 0]
 
  Branch (422:24): [True: 2, False: 0]
 
  Branch (422:24): [True: 24, False: 0]
 
  Branch (422:24): [True: 12, False: 0]
 
  Branch (422:24): [True: 2, False: 0]
-
  Branch (422:24): [True: 10, False: 0]
+
  Branch (422:24): [True: 9, False: 0]
 
  Branch (422:24): [True: 1, False: 0]
-
  Branch (422:24): [True: 7, False: 0]
+
  Branch (422:24): [True: 6, False: 0]
 
  Branch (422:24): [True: 34, False: 0]
 
  Branch (422:24): [True: 1, False: 0]
 
  Branch (422:24): [True: 1, False: 0]
-
  Branch (422:24): [True: 9, False: 0]
+
  Branch (422:24): [True: 10, False: 0]
 
  Branch (422:24): [True: 34, False: 6]
-
  Branch (422:24): [True: 3, False: 0]
+
  Branch (422:24): [True: 4, False: 0]
 
  Branch (422:24): [True: 5, False: 0]
 
  Branch (422:24): [True: 4, False: 0]
 
  Branch (422:24): [True: 13, False: 0]
@@ -239,24 +239,24 @@
 
  Branch (422:24): [True: 5, False: 0]
 
  Branch (422:24): [True: 113, False: 0]
 
  Branch (422:24): [True: 1, False: 2]
-
  Branch (422:24): [True: 10, False: 0]
+
  Branch (422:24): [True: 9, False: 0]
 
  Branch (422:24): [True: 22, False: 0]
 
  Branch (422:24): [True: 12, False: 0]
-
423
382
                    leftover = data[last_nl + 1..].to_vec();
424
382
                    let mut chunk = data;
425
382
                    chunk.truncate(last_nl + 1);
426
382
                    if tx_chunks.send((seq, chunk)).is_err() {
  Branch (426:24): [True: 0, False: 15]
-
  Branch (426:24): [True: 0, False: 30]
+
423
382
                    leftover = data[last_nl + 1..].to_vec();
424
382
                    let mut chunk = data;
425
382
                    chunk.truncate(last_nl + 1);
426
382
                    if tx_chunks.send((seq, chunk)).is_err() {
  Branch (426:24): [True: 0, False: 14]
+
  Branch (426:24): [True: 0, False: 32]
 
  Branch (426:24): [True: 0, False: 2]
 
  Branch (426:24): [True: 0, False: 24]
 
  Branch (426:24): [True: 0, False: 12]
 
  Branch (426:24): [True: 0, False: 2]
-
  Branch (426:24): [True: 0, False: 10]
-
  Branch (426:24): [True: 0, False: 1]
-
  Branch (426:24): [True: 0, False: 7]
-
  Branch (426:24): [True: 0, False: 34]
-
  Branch (426:24): [True: 0, False: 1]
-
  Branch (426:24): [True: 0, False: 1]
 
  Branch (426:24): [True: 0, False: 9]
+
  Branch (426:24): [True: 0, False: 1]
+
  Branch (426:24): [True: 0, False: 6]
 
  Branch (426:24): [True: 0, False: 34]
-
  Branch (426:24): [True: 0, False: 3]
+
  Branch (426:24): [True: 0, False: 1]
+
  Branch (426:24): [True: 0, False: 1]
+
  Branch (426:24): [True: 0, False: 10]
+
  Branch (426:24): [True: 0, False: 34]
+
  Branch (426:24): [True: 0, False: 4]
 
  Branch (426:24): [True: 0, False: 5]
 
  Branch (426:24): [True: 0, False: 4]
 
  Branch (426:24): [True: 0, False: 13]
@@ -265,14 +265,14 @@
 
  Branch (426:24): [True: 0, False: 5]
 
  Branch (426:24): [True: 0, False: 113]
 
  Branch (426:24): [True: 0, False: 1]
-
  Branch (426:24): [True: 0, False: 10]
+
  Branch (426:24): [True: 0, False: 9]
 
  Branch (426:24): [True: 0, False: 22]
 
  Branch (426:24): [True: 0, False: 12]
 
427
0
                        break;
428
382
                    }
429
382
                    seq += 1;
430
9
                } else {
431
9
                    // No newline at all — accumulate for the next read.
432
9
                    leftover = data;
433
9
                }
434
            }
435
436
435
            debug!("parallel reader: I/O thread stop (sent {seq} chunks)");
437
435
        });
438
435
    }
439
440
    /// Parses a raw byte chunk into a tagged batch of items.
441
411
    fn process_chunk(seq: usize, chunk: &[u8], opt: &SkimItemReaderOption) -> (usize, Vec<Arc<dyn SkimItem>>) {
442
411
        let mut items = Vec::new();
443
411
        let line_ending = opt.line_ending;
444
445
        // Chunks produced by the I/O thread end with the line-ending delimiter
446
        // (except possibly the final leftover at EOF).  `split()` would produce
447
        // a spurious trailing empty segment in that case, so we trim the
448
        // trailing delimiter first.  After trimming, every segment — including
449
        // empty ones — maps 1:1 to an input line.
450
411
        let chunk_trimmed: &[u8] = if chunk.last() == Some(&line_ending) {
  Branch (450:39): [True: 364, False: 29]
 
  Branch (450:39): [True: 18, False: 0]
-
451
382
            &chunk[..chunk.len() - 1]
452
        } else {
453
29
            chunk
454
        };
455
456
673k
        for 
line_bytes52.0k
in
chunk_trimmed411
.
split411
(|&b: &u8| b == line_ending) {
457
            // Strip optional \r for \r\n endings.
458
52.0k
            let line_bytes: &[u8] = line_bytes.strip_suffix(b"\r").unwrap_or(line_bytes);
459
52.0k
            let Ok(
line52.0k
) = std::str::from_utf8(line_bytes) else {
  Branch (459:17): [True: 51.9k, False: 0]
+
451
382
            &chunk[..chunk.len() - 1]
452
        } else {
453
29
            chunk
454
        };
455
456
672k
        for 
line_bytes52.0k
in
chunk_trimmed411
.
split411
(|&b: &u8| b == line_ending) {
457
            // Strip optional \r for \r\n endings.
458
52.0k
            let line_bytes: &[u8] = line_bytes.strip_suffix(b"\r").unwrap_or(line_bytes);
459
52.0k
            let Ok(
line52.0k
) = std::str::from_utf8(line_bytes) else {
  Branch (459:17): [True: 52.0k, False: 0]
 
  Branch (459:17): [True: 36, False: 1]
-
460
1
                continue;
461
            };
462
52.0k
            let mut item = DefaultSkimItem::new(
463
52.0k
                line,
464
52.0k
                opt.use_ansi_color,
465
52.0k
                &opt.transform_fields,
466
52.0k
                &opt.matching_fields,
467
52.0k
                &opt.delimiter,
468
            )
469
52.0k
            .hidden_fields(&opt.hidden_fields, &opt.delimiter);
470
52.0k
            if opt.disable_pattern.as_ref().is_some_and(|re| 
re2
.
is_match2
(
line2
)) {
  Branch (470:16): [True: 0, False: 51.9k]
+
460
1
                continue;
461
            };
462
52.0k
            let mut item = DefaultSkimItem::new(
463
52.0k
                line,
464
52.0k
                opt.use_ansi_color,
465
52.0k
                &opt.transform_fields,
466
52.0k
                &opt.matching_fields,
467
52.0k
                &opt.delimiter,
468
            )
469
52.0k
            .hidden_fields(&opt.hidden_fields, &opt.delimiter);
470
52.0k
            if opt.disable_pattern.as_ref().is_some_and(|re| 
re2
.
is_match2
(
line2
)) {
  Branch (470:16): [True: 0, False: 52.0k]
 
  Branch (470:16): [True: 1, False: 35]
 
471
1
                item.disable();
472
52.0k
            }
473
52.0k
            items.push(Arc::new(item) as Arc<dyn SkimItem>);
474
        }
475
476
411
        (seq, items)
477
411
    }
478
479
    /// Stage 4: receives item batches from workers and emits them through the
480
    /// downstream channel in the original sequence order.  Drops
481
    /// `tx_pipeline_done` on exit to signal the killer thread that the
482
    /// pipeline has drained naturally.
483
435
    fn spawn_reorder_thread(
484
435
        rx_results: kanal::Receiver<(usize, Vec<Arc<dyn SkimItem>>)>,
485
435
        tx_item: SkimItemSender,
486
435
        tx_pipeline_done: kanal::Sender<()>,
487
435
    ) {
488
435
        thread::spawn(move || {
489
435
            debug!("parallel reader: reorder thread start");
490
435
            let mut expected = 0usize;
491
435
            let mut pending: BTreeMap<usize, Vec<Arc<dyn SkimItem>>> = BTreeMap::new();
492
493
846
            while let Ok((
seq411
,
items411
)) = rx_results.recv() {
  Branch (493:23): [True: 393, False: 417]
 
  Branch (493:23): [True: 18, False: 18]
@@ -284,6 +284,6 @@
 
  Branch (504:23): [True: 0, False: 18]
 
505
0
                if pending.remove(&seq).is_some_and(|batch| tx_item.send(batch).is_err()) {
  Branch (505:20): [True: 0, False: 0]
 
  Branch (505:20): [True: 0, False: 0]
-
506
0
                    return;
507
0
                }
508
            }
509
            // Dropping tx_pipeline_done closes the channel, waking the killer
510
            // thread so it can decrement components_to_stop.
511
435
            drop(tx_pipeline_done);
512
435
            debug!("parallel reader: reorder thread stop");
513
435
        });
514
435
    }
515
}
516
517
impl CommandCollector for SkimItemReader {
518
73
    fn invoke(
519
73
        &mut self,
520
73
        cmd: &str,
521
73
        components_to_stop: Arc<AtomicUsize>,
522
73
    ) -> (SkimItemReceiver, crate::prelude::Sender<i32>) {
523
73
        let send_error = self.option.show_error;
524
73
        let (child, source) = get_command_output(cmd, send_error).expect("command not found");
525
73
        self.parallel_bufread(source, child, &components_to_stop)
526
73
    }
527
528
390
    fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
529
390
        self.thread_pool = pool;
530
390
    }
531
}
532
533
type CommandOutput = (Option<Child>, Box<dyn BufRead + Send>);
534
535
73
fn get_command_output(cmd: &str, send_error: bool) -> Result<CommandOutput, Box<dyn Error>> {
536
73
    let (reader, writer) = std::io::pipe()
?0
;
537
73
    let mut command = crate::shell_cmd(cmd);
538
73
    command.stdout(writer.try_clone()
?0
);
539
73
    if send_error {
  Branch (539:8): [True: 0, False: 69]
+
506
0
                    return;
507
0
                }
508
            }
509
            // Dropping tx_pipeline_done closes the channel, waking the killer
510
            // thread so it can decrement components_to_stop.
511
435
            drop(tx_pipeline_done);
512
435
            debug!("parallel reader: reorder thread stop");
513
435
        });
514
435
    }
515
}
516
517
impl CommandCollector for SkimItemReader {
518
72
    fn invoke(
519
72
        &mut self,
520
72
        cmd: &str,
521
72
        components_to_stop: Arc<AtomicUsize>,
522
72
    ) -> (SkimItemReceiver, crate::prelude::Sender<i32>) {
523
72
        let send_error = self.option.show_error;
524
72
        let (child, source) = get_command_output(cmd, send_error).expect("command not found");
525
72
        self.parallel_bufread(source, child, &components_to_stop)
526
72
    }
527
528
390
    fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
529
390
        self.thread_pool = pool;
530
390
    }
531
}
532
533
type CommandOutput = (Option<Child>, Box<dyn BufRead + Send>);
534
535
72
fn get_command_output(cmd: &str, send_error: bool) -> Result<CommandOutput, Box<dyn Error>> {
536
72
    let (reader, writer) = std::io::pipe()
?0
;
537
72
    let mut command = crate::shell_cmd(cmd);
538
72
    command.stdout(writer.try_clone()
?0
);
539
72
    if send_error {
  Branch (539:8): [True: 0, False: 68]
 
  Branch (539:8): [True: 1, False: 3]
-
540
1
        trace!("redirecting stderr to the output");
541
1
        command.stderr(writer);
542
72
    } else {
543
72
        command.stderr(Stdio::null());
544
72
    }
545
546
73
    Ok((command.spawn().ok(), Box::new(BufReader::new(reader))))
547
73
}
548
549
#[cfg(test)]
550
#[path = "item_reader_tests.rs"]
551
mod tests;
\ No newline at end of file +
540
1
        trace!("redirecting stderr to the output");
541
1
        command.stderr(writer);
542
71
    } else {
543
71
        command.stderr(Stdio::null());
544
71
    }
545
546
72
    Ok((command.spawn().ok(), Box::new(BufReader::new(reader))))
547
72
}
548
549
#[cfg(test)]
550
#[path = "item_reader_tests.rs"]
551
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/helper/selector.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/helper/selector.rs.html index 5799adf9..e338187c 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/helper/selector.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/helper/selector.rs.html @@ -1,10 +1,10 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/helper/selector.rs
Line
Count
Source
1
use std::collections::HashSet;
2
3
use regex::Regex;
4
5
use crate::{Selector, SkimItem};
6
7
/// Default implementation of the selector trait for pre-selecting items
8
#[derive(Debug, Default)]
9
pub struct DefaultSkimSelector {
10
    first_n: usize,
11
    regex: Option<Regex>,
12
    preset: Option<HashSet<String>>,
13
}
14
15
impl DefaultSkimSelector {
16
    /// Selects the first N items
17
    #[must_use]
18
18
    pub fn first_n(mut self, first_n: usize) -> Self {
19
18
        trace!("select first_n: {first_n}");
20
18
        self.first_n = first_n;
21
18
        self
22
18
    }
23
24
    /// Selects items whose text matches any of the preset strings
25
    #[must_use]
26
17
    pub fn preset(mut self, preset: impl IntoIterator<Item = String>) -> Self {
27
17
        if self.preset.is_none() {
  Branch (27:12): [True: 15, False: 0]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/helper/selector.rs
Line
Count
Source
1
use std::collections::HashSet;
2
3
use regex::Regex;
4
5
use crate::{Selector, SkimItem};
6
7
/// Default implementation of the selector trait for pre-selecting items
8
#[derive(Debug, Default)]
9
pub struct DefaultSkimSelector {
10
    first_n: usize,
11
    regex: Option<Regex>,
12
    preset: Option<HashSet<String>>,
13
}
14
15
impl DefaultSkimSelector {
16
    /// Selects the first N items
17
    #[must_use]
18
19
    pub fn first_n(mut self, first_n: usize) -> Self {
19
19
        trace!("select first_n: {first_n}");
20
19
        self.first_n = first_n;
21
19
        self
22
19
    }
23
24
    /// Selects items whose text matches any of the preset strings
25
    #[must_use]
26
18
    pub fn preset(mut self, preset: impl IntoIterator<Item = String>) -> Self {
27
18
        if self.preset.is_none() {
  Branch (27:12): [True: 16, False: 0]
 
  Branch (27:12): [True: 2, False: 0]
-
28
17
            self.preset = Some(HashSet::new());
29
17
        
}0
30
31
17
        if let Some(set) = self.preset.as_mut() {
  Branch (31:16): [True: 15, False: 0]
+
28
18
            self.preset = Some(HashSet::new());
29
18
        
}0
30
31
18
        if let Some(set) = self.preset.as_mut() {
  Branch (31:16): [True: 16, False: 0]
 
  Branch (31:16): [True: 2, False: 0]
-
32
17
            set.extend(preset);
33
17
        
}0
34
17
        self
35
17
    }
36
37
    /// Selects items whose text matches the given regex pattern
38
    #[must_use]
39
17
    pub fn regex(mut self, regex: &str) -> Self {
40
17
        trace!("select regex: {regex}");
41
17
        if !regex.is_empty() {
  Branch (41:12): [True: 2, False: 13]
+
32
18
            set.extend(preset);
33
18
        
}0
34
18
        self
35
18
    }
36
37
    /// Selects items whose text matches the given regex pattern
38
    #[must_use]
39
18
    pub fn regex(mut self, regex: &str) -> Self {
40
18
        trace!("select regex: {regex}");
41
18
        if !regex.is_empty() {
  Branch (41:12): [True: 2, False: 14]
 
  Branch (41:12): [True: 2, False: 0]
-
42
4
            self.regex = Regex::new(regex).ok();
43
13
        }
44
17
        self
45
17
    }
46
}
47
48
impl Selector for DefaultSkimSelector {
49
53
    fn should_select(&self, index: usize, item: &dyn SkimItem) -> bool {
50
53
        if item.disabled() {
  Branch (50:12): [True: 0, False: 34]
+
42
4
            self.regex = Regex::new(regex).ok();
43
14
        }
44
18
        self
45
18
    }
46
}
47
48
impl Selector for DefaultSkimSelector {
49
53
    fn should_select(&self, index: usize, item: &dyn SkimItem) -> bool {
50
53
        if item.disabled() {
  Branch (50:12): [True: 0, False: 34]
 
  Branch (50:12): [True: 1, False: 18]
 
51
1
            return false;
52
52
        }
53
52
        if self.first_n > index {
  Branch (53:12): [True: 22, False: 12]
 
  Branch (53:12): [True: 5, False: 13]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/item.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/item.rs.html
index 044e256c..89596ae3 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/item.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/item.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/item.rs
Line
Count
Source
1
//! Item representation and management.
2
//!
3
//! This module provides the core item types used by skim, including ranked items,
4
//! item pools for efficient storage, and ranking criteria for sorting matches.
5
use std::cmp::min;
6
use std::default::Default;
7
use std::hash::Hash;
8
use std::ops::Deref;
9
use std::sync::Arc;
10
use std::sync::atomic::{AtomicUsize, Ordering};
11
12
#[cfg(feature = "cli")]
13
use clap::ValueEnum;
14
#[cfg(feature = "cli")]
15
use clap::builder::PossibleValue;
16
17
use crate::spinlock::{SpinLock, SpinLockGuard};
18
use crate::{MatchRange, Rank, SkimItem};
19
use tokio::sync::Notify;
20
21
//------------------------------------------------------------------------------
22
23
/// Builder for creating rank values based on configurable criteria
24
#[derive(Debug)]
25
pub struct RankBuilder {
26
    criterion: Vec<RankCriteria>,
27
    tac: bool,
28
}
29
30
impl Default for RankBuilder {
31
3.86k
    fn default() -> Self {
32
3.86k
        Self {
33
3.86k
            criterion: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End],
34
3.86k
            tac: false,
35
3.86k
        }
36
3.86k
    }
37
}
38
39
impl RankBuilder {
40
    /// Creates a new rank builder with the given criteria
41
    #[must_use]
42
399
    pub fn new(mut criterion: Vec<RankCriteria>) -> Self {
43
399
        if !criterion.contains(&RankCriteria::Score) && 
!6
criterion6
.contains(&RankCriteria::NegScore) {
  Branch (43:12): [True: 1, False: 372]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/item.rs
Line
Count
Source
1
//! Item representation and management.
2
//!
3
//! This module provides the core item types used by skim, including ranked items,
4
//! item pools for efficient storage, and ranking criteria for sorting matches.
5
use std::cmp::min;
6
use std::default::Default;
7
use std::hash::Hash;
8
use std::ops::Deref;
9
use std::sync::Arc;
10
use std::sync::atomic::{AtomicUsize, Ordering};
11
12
#[cfg(feature = "cli")]
13
use clap::ValueEnum;
14
#[cfg(feature = "cli")]
15
use clap::builder::PossibleValue;
16
17
use crate::spinlock::{SpinLock, SpinLockGuard};
18
use crate::{MatchRange, Rank, SkimItem};
19
use tokio::sync::Notify;
20
21
//------------------------------------------------------------------------------
22
23
/// Builder for creating rank values based on configurable criteria
24
#[derive(Debug)]
25
pub struct RankBuilder {
26
    criterion: Vec<RankCriteria>,
27
    tac: bool,
28
}
29
30
impl Default for RankBuilder {
31
3.85k
    fn default() -> Self {
32
3.85k
        Self {
33
3.85k
            criterion: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End],
34
3.85k
            tac: false,
35
3.85k
        }
36
3.85k
    }
37
}
38
39
impl RankBuilder {
40
    /// Creates a new rank builder with the given criteria
41
    #[must_use]
42
399
    pub fn new(mut criterion: Vec<RankCriteria>) -> Self {
43
399
        if !criterion.contains(&RankCriteria::Score) && 
!6
criterion6
.contains(&RankCriteria::NegScore) {
  Branch (43:12): [True: 1, False: 372]
   Branch (43:57): [True: 0, False: 1]
 
  Branch (43:12): [True: 5, False: 21]
   Branch (43:57): [True: 4, False: 1]
@@ -6,25 +6,25 @@
 
  Branch (68:12): [True: 9, False: 1.72k]
 
69
49
            for (priority, criterion) in 
self.criterion.iter()17
.
take17
(5).
enumerate17
() {
70
49
                key[priority] = match criterion {
71
2
                    RankCriteria::Index => rank.index.saturating_neg(),
72
0
                    RankCriteria::NegIndex => rank.index,
73
47
                    _ => key[priority],
74
                };
75
            }
76
53.6k
        }
77
53.6k
        key[5] = if self.tac {
  Branch (77:21): [True: 8, False: 51.9k]
 
  Branch (77:21): [True: 9, False: 1.72k]
-
78
17
            rank.index.saturating_neg()
79
        } else {
80
53.6k
            rank.index
81
        };
82
53.6k
        key
83
53.6k
    }
84
85
    /// Computes the **character** index of the first character after the last path
86
    /// separator (`/` or `\`) in `text`.  Returns `0` when no separator is present.
87
    ///
88
    /// This must be a char index, not a byte offset: the `PathName` tiebreak
89
    /// subtracts it from [`Rank::begin`], which is a char index, so counting bytes
90
    /// here would mix units and mis-rank any path with a non-ASCII component.
91
52.0k
    fn path_name_offset(text: &str) -> i32 {
92
52.0k
        text.rfind(['/', '\\']).map_or(0, |pos| 
{197
93
197
            i32::try_from(text[..pos].chars().count())
94
197
                .unwrap_or(i32::MAX)
95
197
                .saturating_add(1)
96
197
        })
97
52.0k
    }
98
99
    /// Builds a `Rank` from raw match measurements.
100
    ///
101
    /// The values are stored as-is; the tiebreak ordering and sign-flipping are
102
    /// applied lazily by [`Rank::sort_key`] at comparison time.
103
    /// The `index` will be overridden later
104
    #[must_use]
105
52.0k
    pub fn build_rank(&self, score: i32, begin: usize, end: usize, item_text: &str) -> Rank {
106
52.0k
        Rank {
107
52.0k
            score,
108
52.0k
            begin: i32::try_from(begin).unwrap_or(i32::MAX),
109
52.0k
            end: i32::try_from(end).unwrap_or(i32::MAX),
110
52.0k
            length: i32::try_from(item_text.len()).unwrap_or(i32::MAX),
111
52.0k
            index: Default::default(),
112
52.0k
            path_name_offset: Self::path_name_offset(item_text),
113
52.0k
        }
114
52.0k
    }
115
}
116
117
impl Rank {
118
    /// Computes the ordered sort key for this rank given a slice of tiebreak criteria.
119
    ///
120
    /// Each criterion maps to one slot in the returned `[i32; 5]` array. Values are
121
    /// sign-flipped where necessary so that the array compares lexicographically with
122
    /// the "best" match sorting first (ascending order).
123
    #[must_use]
124
53.6k
    pub fn sort_key(&self, criteria: &[RankCriteria]) -> [i32; 5] {
125
53.6k
        let mut key = [0i32; 5];
126
160k
        for (priority, criterion) in 
criteria53.6k
.
iter53.6k
().
take53.6k
(5).
enumerate53.6k
() {
127
160k
            key[priority] = match criterion {
128
53.6k
                RankCriteria::Score => -self.score,
129
7
                RankCriteria::NegScore => self.score,
130
53.5k
                RankCriteria::Begin => self.begin,
131
8
                RankCriteria::NegBegin => -self.begin,
132
53.5k
                RankCriteria::End => self.end,
133
10
                RankCriteria::NegEnd => -self.end,
134
10
                RankCriteria::Length => self.length,
135
7
                RankCriteria::NegLength => -self.length,
136
13
                RankCriteria::Index => self.index,
137
7
                RankCriteria::NegIndex => -self.index,
138
                // PathName: prefer matches that fall within the filename portion (i.e. at or
139
                // after the last path separator).  `path_name_offset - begin` is <= 0 when the
140
                // match starts inside the filename, and positive when it starts in a directory
141
                // component.  Lower values sort first, so filename matches rank higher.
142
19
                RankCriteria::PathName => self.path_name_offset - self.begin,
143
12
                RankCriteria::NegPathName => self.begin - self.path_name_offset,
144
            };
145
        }
146
53.6k
        key
147
53.6k
    }
148
}
149
150
//------------------------------------------------------------------------------
151
/// An item that has been matched against a query
152
#[derive(Clone)]
153
pub struct MatchedItem {
154
    /// The underlying skim item
155
    pub item: Arc<dyn SkimItem>,
156
    /// Raw match measurements
157
    pub rank: Rank,
158
    /// Range of characters that matched the pattern
159
    pub matched_range: Option<MatchRange>,
160
    /// Sort key precomputed at construction time from `rank` and the tiebreak
161
    /// criteria. The sixth slot is the tac-aware implicit index tiebreak.
162
    /// Caching avoids recomputing it on every comparison during sort.
163
    sort_key: [i32; 6],
164
}
165
166
impl std::fmt::Debug for MatchedItem {
167
6
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168
6
        f.debug_struct("MatchedItem")
169
6
            .field("item", &self.item.text())
170
6
            .field("rank", &self.rank)
171
6
            .field("matched_range", &self.matched_range)
172
6
            .finish_non_exhaustive()
173
6
    }
174
}
175
176
impl Hash for MatchedItem {
177
274
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
178
274
        state.write_i32(self.rank.index);
179
274
        self.text().hash(state);
180
274
    }
181
}
182
183
impl Deref for MatchedItem {
184
    type Target = Arc<dyn SkimItem>;
185
186
54.8k
    fn deref(&self) -> &Self::Target {
187
54.8k
        &self.item
188
54.8k
    }
189
}
190
191
impl MatchedItem {
192
    /// Create a new `MatchedItem`, building the `sort_key` from the Rank and `RankBuilder`
193
53.6k
    pub fn new(
194
53.6k
        item: Arc<dyn SkimItem>,
195
53.6k
        rank: Rank,
196
53.6k
        matched_range: Option<MatchRange>,
197
53.6k
        rank_builder: &RankBuilder,
198
53.6k
    ) -> Self {
199
53.6k
        Self {
200
53.6k
            item,
201
53.6k
            rank,
202
53.6k
            matched_range,
203
53.6k
            sort_key: rank_builder.sort_key(&rank),
204
53.6k
        }
205
53.6k
    }
206
    /// Merge two sorted `Vec<MatchedItem>` lists into one, preserving sort order by rank.
207
    ///
208
    /// Both input lists must already be sorted by the same tiebreak criteria (ascending).
209
    /// The merge is O(n+m).
210
    #[must_use]
211
18
    pub fn sorted_merge(existing: Vec<MatchedItem>, incoming: Vec<MatchedItem>) -> Vec<MatchedItem> {
212
18
        if existing.is_empty() {
  Branch (212:12): [True: 9, False: 0]
+
78
17
            rank.index.saturating_neg()
79
        } else {
80
53.6k
            rank.index
81
        };
82
53.6k
        key
83
53.6k
    }
84
85
    /// Computes the **character** index of the first character after the last path
86
    /// separator (`/` or `\`) in `text`.  Returns `0` when no separator is present.
87
    ///
88
    /// This must be a char index, not a byte offset: the `PathName` tiebreak
89
    /// subtracts it from [`Rank::begin`], which is a char index, so counting bytes
90
    /// here would mix units and mis-rank any path with a non-ASCII component.
91
52.0k
    fn path_name_offset(text: &str) -> i32 {
92
52.0k
        text.rfind(['/', '\\']).map_or(0, |pos| 
{197
93
197
            i32::try_from(text[..pos].chars().count())
94
197
                .unwrap_or(i32::MAX)
95
197
                .saturating_add(1)
96
197
        })
97
52.0k
    }
98
99
    /// Builds a `Rank` from raw match measurements.
100
    ///
101
    /// The values are stored as-is; the tiebreak ordering and sign-flipping are
102
    /// applied lazily by [`Rank::sort_key`] at comparison time.
103
    /// The `index` will be overridden later
104
    #[must_use]
105
52.0k
    pub fn build_rank(&self, score: i32, begin: usize, end: usize, item_text: &str) -> Rank {
106
52.0k
        Rank {
107
52.0k
            score,
108
52.0k
            begin: i32::try_from(begin).unwrap_or(i32::MAX),
109
52.0k
            end: i32::try_from(end).unwrap_or(i32::MAX),
110
52.0k
            length: i32::try_from(item_text.len()).unwrap_or(i32::MAX),
111
52.0k
            index: Default::default(),
112
52.0k
            path_name_offset: Self::path_name_offset(item_text),
113
52.0k
        }
114
52.0k
    }
115
}
116
117
impl Rank {
118
    /// Computes the ordered sort key for this rank given a slice of tiebreak criteria.
119
    ///
120
    /// Each criterion maps to one slot in the returned `[i32; 5]` array. Values are
121
    /// sign-flipped where necessary so that the array compares lexicographically with
122
    /// the "best" match sorting first (ascending order).
123
    #[must_use]
124
53.6k
    pub fn sort_key(&self, criteria: &[RankCriteria]) -> [i32; 5] {
125
53.6k
        let mut key = [0i32; 5];
126
160k
        for (priority, criterion) in 
criteria53.6k
.
iter53.6k
().
take53.6k
(5).
enumerate53.6k
() {
127
160k
            key[priority] = match criterion {
128
53.6k
                RankCriteria::Score => -self.score,
129
7
                RankCriteria::NegScore => self.score,
130
53.5k
                RankCriteria::Begin => self.begin,
131
8
                RankCriteria::NegBegin => -self.begin,
132
53.5k
                RankCriteria::End => self.end,
133
10
                RankCriteria::NegEnd => -self.end,
134
10
                RankCriteria::Length => self.length,
135
7
                RankCriteria::NegLength => -self.length,
136
13
                RankCriteria::Index => self.index,
137
7
                RankCriteria::NegIndex => -self.index,
138
                // PathName: prefer matches that fall within the filename portion (i.e. at or
139
                // after the last path separator).  `path_name_offset - begin` is <= 0 when the
140
                // match starts inside the filename, and positive when it starts in a directory
141
                // component.  Lower values sort first, so filename matches rank higher.
142
19
                RankCriteria::PathName => self.path_name_offset - self.begin,
143
12
                RankCriteria::NegPathName => self.begin - self.path_name_offset,
144
            };
145
        }
146
53.6k
        key
147
53.6k
    }
148
}
149
150
//------------------------------------------------------------------------------
151
/// An item that has been matched against a query
152
#[derive(Clone)]
153
pub struct MatchedItem {
154
    /// The underlying skim item
155
    pub item: Arc<dyn SkimItem>,
156
    /// Raw match measurements
157
    pub rank: Rank,
158
    /// Range of characters that matched the pattern
159
    pub matched_range: Option<MatchRange>,
160
    /// Sort key precomputed at construction time from `rank` and the tiebreak
161
    /// criteria. The sixth slot is the tac-aware implicit index tiebreak.
162
    /// Caching avoids recomputing it on every comparison during sort.
163
    sort_key: [i32; 6],
164
}
165
166
impl std::fmt::Debug for MatchedItem {
167
6
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168
6
        f.debug_struct("MatchedItem")
169
6
            .field("item", &self.item.text())
170
6
            .field("rank", &self.rank)
171
6
            .field("matched_range", &self.matched_range)
172
6
            .finish_non_exhaustive()
173
6
    }
174
}
175
176
impl Hash for MatchedItem {
177
274
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
178
274
        state.write_i32(self.rank.index);
179
274
        self.text().hash(state);
180
274
    }
181
}
182
183
impl Deref for MatchedItem {
184
    type Target = Arc<dyn SkimItem>;
185
186
54.8k
    fn deref(&self) -> &Self::Target {
187
54.8k
        &self.item
188
54.8k
    }
189
}
190
191
impl MatchedItem {
192
    /// Create a new `MatchedItem`, building the `sort_key` from the Rank and `RankBuilder`
193
53.6k
    pub fn new(
194
53.6k
        item: Arc<dyn SkimItem>,
195
53.6k
        rank: Rank,
196
53.6k
        matched_range: Option<MatchRange>,
197
53.6k
        rank_builder: &RankBuilder,
198
53.6k
    ) -> Self {
199
53.6k
        Self {
200
53.6k
            item,
201
53.6k
            rank,
202
53.6k
            matched_range,
203
53.6k
            sort_key: rank_builder.sort_key(&rank),
204
53.6k
        }
205
53.6k
    }
206
    /// Merge two sorted `Vec<MatchedItem>` lists into one, preserving sort order by rank.
207
    ///
208
    /// Both input lists must already be sorted by the same tiebreak criteria (ascending).
209
    /// The merge is O(n+m).
210
    #[must_use]
211
17
    pub fn sorted_merge(existing: Vec<MatchedItem>, incoming: Vec<MatchedItem>) -> Vec<MatchedItem> {
212
17
        if existing.is_empty() {
  Branch (212:12): [True: 7, False: 1]
 
  Branch (212:12): [True: 1, False: 8]
-
213
10
            return incoming;
214
8
        }
215
8
        if incoming.is_empty() {
  Branch (215:12): [True: 0, False: 0]
+
213
8
            return incoming;
214
9
        }
215
9
        if incoming.is_empty() {
  Branch (215:12): [True: 0, False: 1]
 
  Branch (215:12): [True: 1, False: 7]
-
216
1
            return existing;
217
7
        }
218
219
        // Fast path: if all existing <= all incoming, we can append without merging.
220
        #[allow(clippy::missing_panics_doc)]
221
7
        if existing.last().unwrap() <= incoming.first().unwrap() {
  Branch (221:12): [True: 0, False: 0]
+
216
1
            return existing;
217
8
        }
218
219
        // Fast path: if all existing <= all incoming, we can append without merging.
220
        #[allow(clippy::missing_panics_doc)]
221
8
        if existing.last().unwrap() <= incoming.first().unwrap() {
  Branch (221:12): [True: 1, False: 0]
 
  Branch (221:12): [True: 2, False: 5]
-
222
2
            let mut out = existing;
223
2
            out.extend(incoming);
224
2
            return out;
225
5
        }
226
227
        // Fast path: if all incoming <= all existing, prepend without complex merge.
228
        #[allow(clippy::missing_panics_doc)]
229
5
        if incoming.last().unwrap() <= existing.first().unwrap() {
  Branch (229:12): [True: 0, False: 0]
+
222
3
            let mut out = existing;
223
3
            out.extend(incoming);
224
3
            return out;
225
5
        }
226
227
        // Fast path: if all incoming <= all existing, prepend without complex merge.
228
        #[allow(clippy::missing_panics_doc)]
229
5
        if incoming.last().unwrap() <= existing.first().unwrap() {
  Branch (229:12): [True: 0, False: 0]
 
  Branch (229:12): [True: 3, False: 2]
 
230
3
            let mut out = incoming;
231
3
            out.extend(existing);
232
3
            return out;
233
2
        }
234
235
2
        let mut merged = Vec::with_capacity(existing.len() + incoming.len());
236
2
        let mut a = existing.into_iter().peekable();
237
2
        let mut b = incoming.into_iter().peekable();
238
239
        loop {
240
7
            match (a.peek(), b.peek()) {
241
5
                (Some(av), Some(bv)) => {
242
5
                    if av <= bv {
  Branch (242:24): [True: 0, False: 0]
 
  Branch (242:24): [True: 3, False: 2]
-
243
3
                        #[allow(clippy::missing_panics_doc)]
244
3
                        merged.push(a.next().unwrap());
245
3
                    } else {
246
2
                        #[allow(clippy::missing_panics_doc)]
247
2
                        merged.push(b.next().unwrap());
248
2
                    }
249
                }
250
                (Some(_), None) => {
251
1
                    merged.extend(a);
252
1
                    break;
253
                }
254
                (None, _) => {
255
1
                    merged.extend(b);
256
1
                    break;
257
                }
258
            }
259
        }
260
261
2
        merged
262
18
    }
263
264
    /// Merge `incoming` into an already-sorted `existing` vector in-place.
265
    ///
266
    /// This function chooses between two strategies:
267
    /// - If `incoming` is small (≤ 256 items), insert them one-by-one using
268
    ///   binary search to find the insertion point.  Each insert is O(n) due
269
    ///   to element shifting, giving O(m·n) overall, but the constant factor
270
    ///   is small for tiny m and avoids any extra allocation.
271
    /// - Otherwise, perform a backwards in-place merge that writes the result
272
    ///   directly into `existing`'s buffer (after a single `reserve`).  This
273
    ///   is O(n+m) time with **zero additional heap allocation** beyond the
274
    ///   amortised `Vec::reserve`.
275
    ///
276
    /// `existing` must be sorted according to the same ordering used by
277
    /// `MatchedItem::cmp`.
278
59
    pub fn merge_into_sorted(existing: &mut Vec<MatchedItem>, incoming: Vec<MatchedItem>) {
279
        const SMALL_INSERT_THRESHOLD: usize = 256;
280
281
59
        if incoming.is_empty() {
  Branch (281:12): [True: 3, False: 53]
+
243
3
                        #[allow(clippy::missing_panics_doc)]
244
3
                        merged.push(a.next().unwrap());
245
3
                    } else {
246
2
                        #[allow(clippy::missing_panics_doc)]
247
2
                        merged.push(b.next().unwrap());
248
2
                    }
249
                }
250
                (Some(_), None) => {
251
1
                    merged.extend(a);
252
1
                    break;
253
                }
254
                (None, _) => {
255
1
                    merged.extend(b);
256
1
                    break;
257
                }
258
            }
259
        }
260
261
2
        merged
262
17
    }
263
264
    /// Merge `incoming` into an already-sorted `existing` vector in-place.
265
    ///
266
    /// This function chooses between two strategies:
267
    /// - If `incoming` is small (≤ 256 items), insert them one-by-one using
268
    ///   binary search to find the insertion point.  Each insert is O(n) due
269
    ///   to element shifting, giving O(m·n) overall, but the constant factor
270
    ///   is small for tiny m and avoids any extra allocation.
271
    /// - Otherwise, perform a backwards in-place merge that writes the result
272
    ///   directly into `existing`'s buffer (after a single `reserve`).  This
273
    ///   is O(n+m) time with **zero additional heap allocation** beyond the
274
    ///   amortised `Vec::reserve`.
275
    ///
276
    /// `existing` must be sorted according to the same ordering used by
277
    /// `MatchedItem::cmp`.
278
45
    pub fn merge_into_sorted(existing: &mut Vec<MatchedItem>, incoming: Vec<MatchedItem>) {
279
        const SMALL_INSERT_THRESHOLD: usize = 256;
280
281
45
        if incoming.is_empty() {
  Branch (281:12): [True: 3, False: 39]
 
  Branch (281:12): [True: 0, False: 3]
-
282
3
            return;
283
56
        }
284
285
        // When existing is empty, extend preserves any pre-allocated capacity
286
        // (e.g. a caller that did `Vec::with_capacity(total)` before a fold).
287
56
        if existing.is_empty() {
  Branch (287:12): [True: 52, False: 1]
+
282
3
            return;
283
42
        }
284
285
        // When existing is empty, extend preserves any pre-allocated capacity
286
        // (e.g. a caller that did `Vec::with_capacity(total)` before a fold).
287
42
        if existing.is_empty() {
  Branch (287:12): [True: 38, False: 1]
 
  Branch (287:12): [True: 0, False: 3]
-
288
52
            existing.extend(incoming);
289
52
            return;
290
4
        }
291
292
        // Fast path: all existing ≤ first incoming — just append.
293
        #[allow(clippy::missing_panics_doc)]
294
4
        if existing.last().unwrap() <= incoming.first().unwrap() {
  Branch (294:12): [True: 1, False: 0]
+
288
38
            existing.extend(incoming);
289
38
            return;
290
4
        }
291
292
        // Fast path: all existing ≤ first incoming — just append.
293
        #[allow(clippy::missing_panics_doc)]
294
4
        if existing.last().unwrap() <= incoming.first().unwrap() {
  Branch (294:12): [True: 1, False: 0]
 
  Branch (294:12): [True: 0, False: 3]
 
295
1
            existing.extend(incoming);
296
1
            return;
297
3
        }
298
299
3
        if incoming.len() <= SMALL_INSERT_THRESHOLD {
  Branch (299:12): [True: 0, False: 0]
 
  Branch (299:12): [True: 1, False: 2]
-
300
1
            for item in incoming {
301
2
                let 
pos1
=
existing1
.
binary_search_by1
(|e| e.cmp(&item)).
unwrap_or_else1
(|p| p);
302
1
                existing.insert(pos, item);
303
            }
304
2
        } else {
305
2
            Self::merge_backwards(existing, incoming);
306
2
        }
307
59
    }
308
309
    /// Merges `incoming` into `existing` in-place using a right-to-left merge.
310
    ///
311
    /// Both inputs must already be sorted.  After `existing.reserve(b_len)`,
312
    /// the buffer has room for all elements.  We then merge from the rightmost
313
    /// end of each run, writing the larger element at the write cursor which
314
    /// starts at `new_len - 1` and moves left.
315
    ///
316
    /// **Key invariant**: the write position is always strictly greater than
317
    /// the read position in `existing` while both runs have remaining elements
318
    /// (because `write - ai == remaining B elements > 0`), so
319
    /// `copy_nonoverlapping` never aliases.  Each element is moved exactly
320
    /// once.
321
    ///
322
    /// # Safety (internal)
323
    ///
324
    /// Uses `unsafe` for raw-pointer moves.  `MatchedItem::cmp` compares plain
325
    /// integer fields and cannot panic, so no element is leaked or
326
    /// double-dropped.  `incoming`'s backing allocation is freed with length 0
327
    /// after all its elements have been moved out.
328
2
    fn merge_backwards(existing: &mut Vec<MatchedItem>, incoming: Vec<MatchedItem>) {
329
2
        let a_len = existing.len();
330
2
        let b_len = incoming.len();
331
2
        let new_len = a_len + b_len;
332
333
2
        existing.reserve(b_len);
334
335
        // Decompose `incoming` so we can move elements out via raw pointers
336
        // and free the allocation separately.
337
2
        let (b_ptr, b_cap) = {
338
2
            let mut v = std::mem::ManuallyDrop::new(incoming);
339
2
            (v.as_mut_ptr(), v.capacity())
340
2
        };
341
342
        // SAFETY: see doc-comment above for the aliasing / move proof.
343
        unsafe {
344
2
            let a_ptr = existing.as_mut_ptr();
345
2
            let mut write = new_len;
346
2
            let mut ai = a_len;
347
2
            let mut bi = b_len;
348
349
901
            while ai > 0 && 
bi > 0900
{
  Branch (349:19): [True: 0, False: 0]
+
300
1
            for item in incoming {
301
2
                let 
pos1
=
existing1
.
binary_search_by1
(|e| e.cmp(&item)).
unwrap_or_else1
(|p| p);
302
1
                existing.insert(pos, item);
303
            }
304
2
        } else {
305
2
            Self::merge_backwards(existing, incoming);
306
2
        }
307
45
    }
308
309
    /// Merges `incoming` into `existing` in-place using a right-to-left merge.
310
    ///
311
    /// Both inputs must already be sorted.  After `existing.reserve(b_len)`,
312
    /// the buffer has room for all elements.  We then merge from the rightmost
313
    /// end of each run, writing the larger element at the write cursor which
314
    /// starts at `new_len - 1` and moves left.
315
    ///
316
    /// **Key invariant**: the write position is always strictly greater than
317
    /// the read position in `existing` while both runs have remaining elements
318
    /// (because `write - ai == remaining B elements > 0`), so
319
    /// `copy_nonoverlapping` never aliases.  Each element is moved exactly
320
    /// once.
321
    ///
322
    /// # Safety (internal)
323
    ///
324
    /// Uses `unsafe` for raw-pointer moves.  `MatchedItem::cmp` compares plain
325
    /// integer fields and cannot panic, so no element is leaked or
326
    /// double-dropped.  `incoming`'s backing allocation is freed with length 0
327
    /// after all its elements have been moved out.
328
2
    fn merge_backwards(existing: &mut Vec<MatchedItem>, incoming: Vec<MatchedItem>) {
329
2
        let a_len = existing.len();
330
2
        let b_len = incoming.len();
331
2
        let new_len = a_len + b_len;
332
333
2
        existing.reserve(b_len);
334
335
        // Decompose `incoming` so we can move elements out via raw pointers
336
        // and free the allocation separately.
337
2
        let (b_ptr, b_cap) = {
338
2
            let mut v = std::mem::ManuallyDrop::new(incoming);
339
2
            (v.as_mut_ptr(), v.capacity())
340
2
        };
341
342
        // SAFETY: see doc-comment above for the aliasing / move proof.
343
        unsafe {
344
2
            let a_ptr = existing.as_mut_ptr();
345
2
            let mut write = new_len;
346
2
            let mut ai = a_len;
347
2
            let mut bi = b_len;
348
349
901
            while ai > 0 && 
bi > 0900
{
  Branch (349:19): [True: 0, False: 0]
   Branch (349:29): [True: 0, False: 0]
 
  Branch (349:19): [True: 900, False: 1]
   Branch (349:29): [True: 899, False: 1]
@@ -34,8 +34,8 @@
 
  Branch (362:16): [True: 1, False: 1]
 
363
1
                std::ptr::copy_nonoverlapping(b_ptr, a_ptr, bi);
364
1
            }
365
366
2
            existing.set_len(new_len);
367
368
            // Free incoming's backing allocation; all elements were moved out.
369
2
            drop(Vec::from_raw_parts(b_ptr, 0, b_cap));
370
        }
371
2
    }
372
}
373
374
impl MatchedItem {
375
    /// Downcast the `MatchedItem` to the corresponding `SkimItem` struct
376
    #[must_use]
377
1
    pub fn downcast_item<T: SkimItem>(&self) -> Option<&T> {
378
1
        (*self.item).as_any().downcast_ref::<T>()
379
1
    }
380
}
381
382
use std::cmp::Ordering as CmpOrd;
383
384
impl PartialEq for MatchedItem {
385
302
    fn eq(&self, other: &Self) -> bool {
386
302
        self.text().eq(&other.text()) && 
self.rank.index178
.
eq178
(
&other.rank.index178
)
  Branch (386:9): [True: 160, False: 118]
 
  Branch (386:9): [True: 18, False: 6]
-
387
302
    }
388
}
389
390
impl std::cmp::Eq for MatchedItem {}
391
392
impl PartialOrd for MatchedItem {
393
5.20k
    fn partial_cmp(&self, other: &Self) -> Option<CmpOrd> {
394
5.20k
        Some(self.cmp(other))
395
5.20k
    }
396
}
397
398
impl Ord for MatchedItem {
399
5.20k
    fn cmp(&self, other: &Self) -> CmpOrd {
400
5.20k
        self.sort_key.cmp(&other.sort_key)
401
5.20k
    }
402
}
403
404
//------------------------------------------------------------------------------
405
const ITEM_POOL_CAPACITY: usize = 16384;
406
407
/// Thread-safe pool for storing and managing items efficiently
408
pub struct ItemPool {
409
    /// Total number of items in the pool
410
    length: AtomicUsize,
411
    /// The main pool of items
412
    pool: SpinLock<Vec<Arc<dyn SkimItem>>>,
413
    /// Number of items that were taken
414
    taken: AtomicUsize,
415
416
    /// Reserved first N lines as header
417
    reserved_items: SpinLock<Vec<Arc<dyn SkimItem>>>,
418
    /// Number of lines to reserve as header
419
    lines_to_reserve: usize,
420
    /// Reverse the order of items (--tac flag)
421
    tac: bool,
422
423
    /// Notified whenever new items are appended to the pool (async path).
424
    ///
425
    /// Listeners (e.g. the TUI event loop) can `await` this to wake up
426
    /// immediately when items arrive instead of waiting for the next
427
    /// periodic tick.
428
    pub items_available: Arc<Notify>,
429
}
430
431
impl Default for ItemPool {
432
145
    fn default() -> Self {
433
145
        Self {
434
145
            length: AtomicUsize::new(0),
435
145
            pool: SpinLock::new(Vec::with_capacity(ITEM_POOL_CAPACITY)),
436
145
            taken: AtomicUsize::new(0),
437
145
            reserved_items: SpinLock::new(Vec::new()),
438
145
            lines_to_reserve: 0,
439
145
            tac: false,
440
145
            items_available: Arc::new(Notify::new()),
441
145
        }
442
145
    }
443
}
444
445
impl ItemPool {
446
    /// Creates a new empty item pool
447
    #[must_use]
448
8
    pub fn new() -> Self {
449
8
        Self::default()
450
8
    }
451
452
    /// Creates a new item pool from skim options
453
    #[must_use]
454
393
    pub fn from_options(options: &crate::SkimOptions) -> Self {
455
393
        Self {
456
393
            length: AtomicUsize::new(0),
457
393
            pool: SpinLock::new(Vec::with_capacity(ITEM_POOL_CAPACITY)),
458
393
            taken: AtomicUsize::new(0),
459
393
            reserved_items: SpinLock::new(Vec::new()),
460
393
            lines_to_reserve: options.header_lines,
461
393
            tac: options.tac,
462
393
            items_available: Arc::new(Notify::new()),
463
393
        }
464
393
    }
465
466
    /// Returns the total number of items in the pool
467
2.63k
    pub fn len(&self) -> usize {
468
2.63k
        self.length.load(Ordering::SeqCst)
469
2.63k
    }
470
471
    /// Returns true if the pool contains no items
472
3
    pub fn is_empty(&self) -> bool {
473
3
        self.len() == 0
474
3
    }
475
476
    /// Returns the number of items that have not been taken yet
477
2.86k
    pub fn num_not_taken(&self) -> usize {
478
2.86k
        self.length.load(Ordering::SeqCst) - self.taken.load(Ordering::SeqCst)
479
2.86k
    }
480
481
    /// Returns the number of items that have been taken
482
841
    pub fn num_taken(&self) -> usize {
483
841
        self.taken.load(Ordering::SeqCst)
484
841
    }
485
486
    /// Clears all items from the pool and resets counters
487
45
    pub fn clear(&self) {
488
45
        let mut items = self.pool.lock();
489
45
        items.clear();
490
45
        let mut header_items = self.reserved_items.lock();
491
45
        header_items.clear();
492
45
        self.taken.store(0, Ordering::SeqCst);
493
45
        self.length.store(0, Ordering::SeqCst);
494
45
    }
495
496
    /// Resets the taken counter without clearing items
497
771
    pub fn reset(&self) {
498
        // lock to ensure consistency
499
771
        let _items = self.pool.lock();
500
501
771
        self.taken.store(0, Ordering::SeqCst);
502
771
    }
503
504
    /// append the items and return the `new_size` of the pool
505
427
    pub fn append(&self, mut items: Vec<Arc<dyn SkimItem>>) -> usize {
506
427
        let len = items.len();
507
427
        trace!("item pool, append {len} items");
508
427
        let mut pool = self.pool.lock();
509
427
        let mut header_items = self.reserved_items.lock();
510
511
427
        let to_reserve = self.lines_to_reserve - header_items.len();
512
427
        if to_reserve > 0 {
  Branch (512:12): [True: 17, False: 377]
+
387
302
    }
388
}
389
390
impl std::cmp::Eq for MatchedItem {}
391
392
impl PartialOrd for MatchedItem {
393
5.20k
    fn partial_cmp(&self, other: &Self) -> Option<CmpOrd> {
394
5.20k
        Some(self.cmp(other))
395
5.20k
    }
396
}
397
398
impl Ord for MatchedItem {
399
5.20k
    fn cmp(&self, other: &Self) -> CmpOrd {
400
5.20k
        self.sort_key.cmp(&other.sort_key)
401
5.20k
    }
402
}
403
404
//------------------------------------------------------------------------------
405
const ITEM_POOL_CAPACITY: usize = 16384;
406
407
/// Thread-safe pool for storing and managing items efficiently
408
pub struct ItemPool {
409
    /// Total number of items in the pool
410
    length: AtomicUsize,
411
    /// The main pool of items
412
    pool: SpinLock<Vec<Arc<dyn SkimItem>>>,
413
    /// Number of items that were taken
414
    taken: AtomicUsize,
415
416
    /// Reserved first N lines as header
417
    reserved_items: SpinLock<Vec<Arc<dyn SkimItem>>>,
418
    /// Number of lines to reserve as header
419
    lines_to_reserve: usize,
420
    /// Reverse the order of items (--tac flag)
421
    tac: bool,
422
423
    /// Notified whenever new items are appended to the pool (async path).
424
    ///
425
    /// Listeners (e.g. the TUI event loop) can `await` this to wake up
426
    /// immediately when items arrive instead of waiting for the next
427
    /// periodic tick.
428
    pub items_available: Arc<Notify>,
429
}
430
431
impl Default for ItemPool {
432
145
    fn default() -> Self {
433
145
        Self {
434
145
            length: AtomicUsize::new(0),
435
145
            pool: SpinLock::new(Vec::with_capacity(ITEM_POOL_CAPACITY)),
436
145
            taken: AtomicUsize::new(0),
437
145
            reserved_items: SpinLock::new(Vec::new()),
438
145
            lines_to_reserve: 0,
439
145
            tac: false,
440
145
            items_available: Arc::new(Notify::new()),
441
145
        }
442
145
    }
443
}
444
445
impl ItemPool {
446
    /// Creates a new empty item pool
447
    #[must_use]
448
8
    pub fn new() -> Self {
449
8
        Self::default()
450
8
    }
451
452
    /// Creates a new item pool from skim options
453
    #[must_use]
454
393
    pub fn from_options(options: &crate::SkimOptions) -> Self {
455
393
        Self {
456
393
            length: AtomicUsize::new(0),
457
393
            pool: SpinLock::new(Vec::with_capacity(ITEM_POOL_CAPACITY)),
458
393
            taken: AtomicUsize::new(0),
459
393
            reserved_items: SpinLock::new(Vec::new()),
460
393
            lines_to_reserve: options.header_lines,
461
393
            tac: options.tac,
462
393
            items_available: Arc::new(Notify::new()),
463
393
        }
464
393
    }
465
466
    /// Returns the total number of items in the pool
467
2.63k
    pub fn len(&self) -> usize {
468
2.63k
        self.length.load(Ordering::SeqCst)
469
2.63k
    }
470
471
    /// Returns true if the pool contains no items
472
3
    pub fn is_empty(&self) -> bool {
473
3
        self.len() == 0
474
3
    }
475
476
    /// Returns the number of items that have not been taken yet
477
2.88k
    pub fn num_not_taken(&self) -> usize {
478
2.88k
        self.length.load(Ordering::SeqCst) - self.taken.load(Ordering::SeqCst)
479
2.88k
    }
480
481
    /// Returns the number of items that have been taken
482
825
    pub fn num_taken(&self) -> usize {
483
825
        self.taken.load(Ordering::SeqCst)
484
825
    }
485
486
    /// Clears all items from the pool and resets counters
487
45
    pub fn clear(&self) {
488
45
        let mut items = self.pool.lock();
489
45
        items.clear();
490
45
        let mut header_items = self.reserved_items.lock();
491
45
        header_items.clear();
492
45
        self.taken.store(0, Ordering::SeqCst);
493
45
        self.length.store(0, Ordering::SeqCst);
494
45
    }
495
496
    /// Resets the taken counter without clearing items
497
770
    pub fn reset(&self) {
498
        // lock to ensure consistency
499
770
        let _items = self.pool.lock();
500
501
770
        self.taken.store(0, Ordering::SeqCst);
502
770
    }
503
504
    /// append the items and return the `new_size` of the pool
505
427
    pub fn append(&self, mut items: Vec<Arc<dyn SkimItem>>) -> usize {
506
427
        let len = items.len();
507
427
        trace!("item pool, append {len} items");
508
427
        let mut pool = self.pool.lock();
509
427
        let mut header_items = self.reserved_items.lock();
510
511
427
        let to_reserve = self.lines_to_reserve - header_items.len();
512
427
        if to_reserve > 0 {
  Branch (512:12): [True: 18, False: 376]
 
  Branch (512:12): [True: 1, False: 32]
-
513
18
            let to_reserve = min(to_reserve, items.len());
514
18
            // Split items: first part goes to header, rest to main pool
515
18
            let remaining = items.split_off(to_reserve);
516
18
517
18
            // Header items are always in input order, regardless of tac
518
18
            header_items.extend(items);
519
18
520
18
            pool.extend(remaining);
521
409
        } else {
522
409
            pool.extend(items);
523
409
        }
524
427
        self.length.store(pool.len(), Ordering::SeqCst);
525
427
        trace!("item pool, done append {len} items, total: {}", 
pool.len()2
);
526
427
        let new_len = pool.len();
527
427
        drop(pool);
528
427
        drop(header_items);
529
        // Wake any listener that is waiting for new items (e.g. the event loop
530
        // or the filter-mode loop) so it can restart the matcher immediately
531
        // instead of waiting for the next periodic tick.
532
427
        self.items_available.notify_one();
533
534
427
        new_len
535
427
    }
536
537
    /// Takes items from the pool, copying new items since last take and releasing lock immediately
538
844
    pub fn take(&self) -> Vec<Arc<dyn SkimItem>> {
539
844
        let guard = self.pool.lock();
540
844
        let taken = self.taken.swap(guard.len(), Ordering::SeqCst);
541
        // Copy the new items out so we can release the lock immediately
542
844
        let mut items = guard[taken..].to_vec();
543
844
        if self.tac {
  Branch (543:12): [True: 3, False: 811]
+
513
19
            let to_reserve = min(to_reserve, items.len());
514
19
            // Split items: first part goes to header, rest to main pool
515
19
            let remaining = items.split_off(to_reserve);
516
19
517
19
            // Header items are always in input order, regardless of tac
518
19
            header_items.extend(items);
519
19
520
19
            pool.extend(remaining);
521
408
        } else {
522
408
            pool.extend(items);
523
408
        }
524
427
        self.length.store(pool.len(), Ordering::SeqCst);
525
427
        trace!("item pool, done append {len} items, total: {}", 
pool.len()2
);
526
427
        let new_len = pool.len();
527
427
        drop(pool);
528
427
        drop(header_items);
529
        // Wake any listener that is waiting for new items (e.g. the event loop
530
        // or the filter-mode loop) so it can restart the matcher immediately
531
        // instead of waiting for the next periodic tick.
532
427
        self.items_available.notify_one();
533
534
427
        new_len
535
427
    }
536
537
    /// Takes items from the pool, copying new items since last take and releasing lock immediately
538
828
    pub fn take(&self) -> Vec<Arc<dyn SkimItem>> {
539
828
        let guard = self.pool.lock();
540
828
        let taken = self.taken.swap(guard.len(), Ordering::SeqCst);
541
        // Copy the new items out so we can release the lock immediately
542
828
        let mut items = guard[taken..].to_vec();
543
828
        if self.tac {
  Branch (543:12): [True: 5, False: 793]
 
  Branch (543:12): [True: 1, False: 29]
-
544
4
            items.reverse();
545
840
        }
546
844
        drop(guard); // Explicitly release lock
547
844
        items
548
844
    }
549
550
    /// Returns a copy of the reserved header items
551
2.68k
    pub fn reserved(&self) -> Vec<Arc<dyn SkimItem>> {
552
2.68k
        let guard = self.reserved_items.lock();
553
2.68k
        guard.clone()
554
2.68k
    }
555
}
556
557
/// Guard for accessing a slice of items from the pool
558
pub struct ItemPoolGuard<'a, T: Sized + 'a> {
559
    guard: SpinLockGuard<'a, Vec<T>>,
560
    start: usize,
561
}
562
563
impl<T: Sized> Deref for ItemPoolGuard<'_, T> {
564
    type Target = [T];
565
566
0
    fn deref(&self) -> &[T] {
567
0
        &self.guard[self.start..]
568
0
    }
569
}
570
571
//------------------------------------------------------------------------------
572
/// Criteria for ranking and sorting matched items
573
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
574
pub enum RankCriteria {
575
    /// Sort by match score (lower is better)
576
    Score,
577
    /// Sort by match score (higher is better)
578
    NegScore,
579
    /// Sort by beginning position of match
580
    Begin,
581
    /// Sort by beginning position of match (reversed)
582
    NegBegin,
583
    /// Sort by ending position of match
584
    End,
585
    /// Sort by ending position of match (reversed)
586
    NegEnd,
587
    /// Sort by item length
588
    Length,
589
    /// Sort by item length (reversed)
590
    NegLength,
591
    /// Sort by item index
592
    Index,
593
    /// Sort by item index (reversed)
594
    NegIndex,
595
    /// Give a bonus to matches that are after the last path separator (`/` or `\`)
596
    PathName,
597
    /// Give a bonus to matches that are after the last path separator (reversed)
598
    NegPathName,
599
}
600
601
#[cfg(feature = "cli")]
602
impl ValueEnum for RankCriteria {
603
1.40k
    fn value_variants<'a>() -> &'a [Self] {
604
        use RankCriteria::{
605
            Begin, End, Index, Length, NegBegin, NegEnd, NegIndex, NegLength, NegPathName, NegScore, PathName, Score,
606
        };
607
1.40k
        &[
608
1.40k
            Score,
609
1.40k
            NegScore,
610
1.40k
            Begin,
611
1.40k
            NegBegin,
612
1.40k
            End,
613
1.40k
            NegEnd,
614
1.40k
            Length,
615
1.40k
            NegLength,
616
1.40k
            Index,
617
1.40k
            NegIndex,
618
1.40k
            PathName,
619
1.40k
            NegPathName,
620
1.40k
        ]
621
1.40k
    }
622
623
4.39k
    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
624
        use RankCriteria::{
625
            Begin, End, Index, Length, NegBegin, NegEnd, NegIndex, NegLength, NegPathName, NegScore, PathName, Score,
626
        };
627
4.39k
        Some(match self {
628
1.40k
            Score => PossibleValue::new("score"),
629
936
            Begin => PossibleValue::new("begin"),
630
480
            End => PossibleValue::new("end"),
631
937
            NegScore => PossibleValue::new("-score"),
632
481
            NegBegin => PossibleValue::new("-begin"),
633
26
            NegEnd => PossibleValue::new("-end"),
634
25
            Length => PossibleValue::new("length"),
635
24
            NegLength => PossibleValue::new("-length"),
636
23
            Index => PossibleValue::new("index"),
637
22
            NegIndex => PossibleValue::new("-index"),
638
21
            PathName => PossibleValue::new("pathname"),
639
20
            NegPathName => PossibleValue::new("-pathname"),
640
        })
641
4.39k
    }
642
}
643
644
#[cfg(test)]
645
#[allow(clippy::field_reassign_with_default)]
646
#[path = "item_tests.rs"]
647
mod tests;
\ No newline at end of file +
544
6
            items.reverse();
545
822
        }
546
828
        drop(guard); // Explicitly release lock
547
828
        items
548
828
    }
549
550
    /// Returns a copy of the reserved header items
551
2.67k
    pub fn reserved(&self) -> Vec<Arc<dyn SkimItem>> {
552
2.67k
        let guard = self.reserved_items.lock();
553
2.67k
        guard.clone()
554
2.67k
    }
555
}
556
557
/// Guard for accessing a slice of items from the pool
558
pub struct ItemPoolGuard<'a, T: Sized + 'a> {
559
    guard: SpinLockGuard<'a, Vec<T>>,
560
    start: usize,
561
}
562
563
impl<T: Sized> Deref for ItemPoolGuard<'_, T> {
564
    type Target = [T];
565
566
0
    fn deref(&self) -> &[T] {
567
0
        &self.guard[self.start..]
568
0
    }
569
}
570
571
//------------------------------------------------------------------------------
572
/// Criteria for ranking and sorting matched items
573
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
574
pub enum RankCriteria {
575
    /// Sort by match score (lower is better)
576
    Score,
577
    /// Sort by match score (higher is better)
578
    NegScore,
579
    /// Sort by beginning position of match
580
    Begin,
581
    /// Sort by beginning position of match (reversed)
582
    NegBegin,
583
    /// Sort by ending position of match
584
    End,
585
    /// Sort by ending position of match (reversed)
586
    NegEnd,
587
    /// Sort by item length
588
    Length,
589
    /// Sort by item length (reversed)
590
    NegLength,
591
    /// Sort by item index
592
    Index,
593
    /// Sort by item index (reversed)
594
    NegIndex,
595
    /// Give a bonus to matches that are after the last path separator (`/` or `\`)
596
    PathName,
597
    /// Give a bonus to matches that are after the last path separator (reversed)
598
    NegPathName,
599
}
600
601
#[cfg(feature = "cli")]
602
impl ValueEnum for RankCriteria {
603
1.40k
    fn value_variants<'a>() -> &'a [Self] {
604
        use RankCriteria::{
605
            Begin, End, Index, Length, NegBegin, NegEnd, NegIndex, NegLength, NegPathName, NegScore, PathName, Score,
606
        };
607
1.40k
        &[
608
1.40k
            Score,
609
1.40k
            NegScore,
610
1.40k
            Begin,
611
1.40k
            NegBegin,
612
1.40k
            End,
613
1.40k
            NegEnd,
614
1.40k
            Length,
615
1.40k
            NegLength,
616
1.40k
            Index,
617
1.40k
            NegIndex,
618
1.40k
            PathName,
619
1.40k
            NegPathName,
620
1.40k
        ]
621
1.40k
    }
622
623
4.39k
    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
624
        use RankCriteria::{
625
            Begin, End, Index, Length, NegBegin, NegEnd, NegIndex, NegLength, NegPathName, NegScore, PathName, Score,
626
        };
627
4.39k
        Some(match self {
628
1.40k
            Score => PossibleValue::new("score"),
629
936
            Begin => PossibleValue::new("begin"),
630
480
            End => PossibleValue::new("end"),
631
937
            NegScore => PossibleValue::new("-score"),
632
481
            NegBegin => PossibleValue::new("-begin"),
633
26
            NegEnd => PossibleValue::new("-end"),
634
25
            Length => PossibleValue::new("length"),
635
24
            NegLength => PossibleValue::new("-length"),
636
23
            Index => PossibleValue::new("index"),
637
22
            NegIndex => PossibleValue::new("-index"),
638
21
            PathName => PossibleValue::new("pathname"),
639
20
            NegPathName => PossibleValue::new("-pathname"),
640
        })
641
4.39k
    }
642
}
643
644
#[cfg(test)]
645
#[allow(clippy::field_reassign_with_default)]
646
#[path = "item_tests.rs"]
647
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/lib.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/lib.rs.html index dce47763..2fddce73 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/lib.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/lib.rs.html @@ -1 +1 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/lib.rs
Line
Count
Source
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
//!
11
//! let options = SkimOptionsBuilder::default()
12
//!     .height("50%")
13
//!     .multi(true)
14
//!     .build()
15
//!     .unwrap();
16
//!
17
//! let output = Skim::run_items(
18
//!         options,
19
//!         ["awk", "bash", "csh", "dash", "fish", "ksh", "zsh"]
20
//!     ).unwrap();
21
//! ```
22
#![cfg_attr(coverage, feature(coverage_attribute))]
23
24
#[macro_use]
25
extern crate log;
26
27
#[global_allocator]
28
static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc;
29
30
use std::any::Any;
31
use std::borrow::Cow;
32
use std::fmt::Display;
33
use std::process::Command;
34
use std::sync::Arc;
35
36
use crate::fuzzy_matcher::MatchIndices;
37
use ratatui::style::Style;
38
use ratatui::text::{Line, Span};
39
40
pub use crate::engine::fuzzy::FuzzyAlgorithm;
41
pub use crate::item::RankCriteria;
42
pub use crate::options::SkimOptions;
43
pub use crate::output::{BinOptions, SkimOutput};
44
pub use crate::skim::*;
45
pub use crate::skim_item::SkimItem;
46
use crate::tui::Size;
47
pub use util::printf;
48
49
pub mod binds;
50
mod engine;
51
pub mod field;
52
pub mod fuzzy_matcher;
53
pub mod helper;
54
pub mod item;
55
pub mod matcher;
56
pub mod options;
57
mod output;
58
#[cfg(unix)]
59
pub mod popup;
60
pub mod prelude;
61
pub mod reader;
62
mod skim;
63
mod skim_item;
64
pub mod spinlock;
65
pub mod theme;
66
pub mod thread_pool;
67
pub mod tui;
68
mod util;
69
70
#[cfg(feature = "cli")]
71
pub mod manpage;
72
#[cfg(feature = "cli")]
73
pub mod shell;
74
75
/// Skim's default command when no `--cmd` flag is passed and `$SKIM_DEFAULT_COMMAND` is unset
76
#[cfg(unix)]
77
pub const SKIM_DEFAULT_COMMAND: &str = "find .";
78
/// Skim's default command when no `--cmd` flag is passed and `$SKIM_DEFAULT_COMMAND` is unset
79
#[cfg(windows)]
80
pub const SKIM_DEFAULT_COMMAND: &str = "dir /s /b /A:-D";
81
82
#[cfg(unix)]
83
117
fn shell_cmd(cmd: &str) -> Command {
84
117
    let mut c = Command::new("sh");
85
117
    c.arg("-c").arg(cmd);
86
117
    c
87
117
}
88
#[cfg(windows)]
89
fn shell_cmd(cmd: &str) -> Command {
90
    use std::os::windows::process::CommandExt as _;
91
    // `cmd.exe` does not parse its command line using MSVC rules, so the default
92
    // `Command::arg` escaping (quoting/backslash-escaping) corrupts shell
93
    // metacharacters like `|`, `&`, `>` and embedded quotes. Pass the command
94
    // string verbatim via `raw_arg` so cmd.exe sees exactly what the user wrote.
95
    let mut c = Command::new("cmd");
96
    c.arg("/c").raw_arg(cmd);
97
    c
98
}
99
100
//------------------------------------------------------------------------------
101
/// Trait for downcasting to concrete types from trait objects
102
pub trait AsAny {
103
    /// Returns a reference to the value as `Any`
104
    fn as_any(&self) -> &dyn Any;
105
    /// Returns a mutable reference to the value as `Any`
106
    fn as_any_mut(&mut self) -> &mut dyn Any;
107
}
108
109
impl<T: Any> AsAny for T {
110
2
    fn as_any(&self) -> &dyn Any {
111
2
        self
112
2
    }
113
114
1
    fn as_any_mut(&mut self) -> &mut dyn Any {
115
1
        self
116
1
    }
117
}
118
119
//------------------------------------------------------------------------------
120
// Display Context
121
#[derive(Default, Debug, Clone)]
122
/// Represents how a query matches an item
123
pub enum Matches {
124
    /// No matches
125
    #[default]
126
    None,
127
    /// Matches at specific character indices
128
    CharIndices(MatchIndices),
129
    /// Matches in a character range (start, end)
130
    CharRange(usize, usize),
131
    /// Matches in a byte range (start, end)
132
    ByteRange(usize, usize),
133
}
134
135
#[derive(Default, Clone)]
136
/// Context information for displaying an item
137
pub struct DisplayContext {
138
    /// The match score for this item
139
    pub score: i32,
140
    /// Where the query matched in the item
141
    pub matches: Matches,
142
    /// The width of the container to display in
143
    pub container_width: usize,
144
    /// The base style to apply to non-matched portions
145
    pub base_style: Style,
146
    /// The style to apply to matched portions
147
    pub matched_style: Style,
148
}
149
150
impl DisplayContext {
151
    /// Converts the context and text into a styled `Line` with highlighted matches
152
    ///
153
    /// # Panics
154
    ///
155
    /// Panics if the byte ranges in `Matches::ByteRange` do not align with valid UTF-8 boundaries.
156
    #[must_use]
157
6.46k
    pub fn to_line(self, cow: Cow<str>) -> Line {
158
6.46k
        let text: String = cow.into_owned();
159
160
        // Combine base_style with match style for highlighted text
161
        // Match style takes precedence for fg, but inherits bg from base if not set
162
6.46k
        match &self.matches {
163
1.63k
            Matches::CharIndices(indices) => {
164
1.63k
                let mut res = Line::default();
165
1.63k
                let mut chars = text.chars();
166
1.63k
                let mut prev_index = 0;
167
3.58k
                for &index in 
indices1.63k
{
168
3.58k
                    let span_content = chars.by_ref().take(index - prev_index);
169
3.58k
                    res.push_span(Span::styled(span_content.collect::<String>(), self.base_style));
170
3.58k
                    let highlighted_char = chars.next().unwrap_or_default().to_string();
171
3.58k
172
3.58k
                    res.push_span(Span::styled(
173
3.58k
                        highlighted_char,
174
3.58k
                        self.base_style.patch(self.matched_style),
175
3.58k
                    ));
176
3.58k
                    prev_index = index + 1;
177
3.58k
                }
178
1.63k
                res.push_span(Span::styled(chars.collect::<String>(), self.base_style));
179
1.63k
                res
180
            }
181
            // AnsiString::from((context.text, indices, context.highlight_attr)),
182
            #[allow(clippy::cast_possible_truncation)]
183
1
            Matches::CharRange(start, end) => {
184
1
                let mut chars = text.chars();
185
1
                let mut res = Line::default();
186
1
                res.push_span(Span::styled(
187
1
                    chars.by_ref().take(*start).collect::<String>(),
188
1
                    self.base_style,
189
                ));
190
1
                let highlighted_text = chars.by_ref().take(*end - *start).collect::<String>();
191
192
1
                res.push_span(Span::styled(
193
1
                    highlighted_text,
194
1
                    self.base_style.patch(self.matched_style),
195
                ));
196
1
                res.push_span(Span::styled(chars.collect::<String>(), self.base_style));
197
1
                res
198
            }
199
4.62k
            Matches::ByteRange(start, end) => {
200
4.62k
                let mut bytes = text.bytes();
201
4.62k
                let mut res = Line::default();
202
4.62k
                res.push_span(Span::styled(
203
4.62k
                    String::from_utf8(bytes.by_ref().take(*start).collect()).unwrap(),
204
4.62k
                    self.base_style,
205
                ));
206
4.62k
                let highlighted_bytes = bytes.by_ref().take(*end - *start).collect();
207
4.62k
                let highlighted_text = String::from_utf8(highlighted_bytes).unwrap();
208
209
4.62k
                res.push_span(Span::styled(
210
4.62k
                    highlighted_text,
211
4.62k
                    self.base_style.patch(self.matched_style),
212
                ));
213
4.62k
                res.push_span(Span::styled(
214
4.62k
                    String::from_utf8(bytes.collect()).unwrap(),
215
4.62k
                    self.base_style,
216
                ));
217
4.62k
                res
218
            }
219
198
            Matches::None => Line::from(vec![Span::styled(text, self.base_style)]),
220
        }
221
6.46k
    }
222
}
223
224
//------------------------------------------------------------------------------
225
// Preview Context
226
227
/// Context information for generating item previews
228
pub struct PreviewContext<'a> {
229
    /// The current search query
230
    pub query: &'a str,
231
    /// The current command query (for interactive mode)
232
    pub cmd_query: &'a str,
233
    /// Width of the preview window
234
    pub width: usize,
235
    /// Height of the preview window
236
    pub height: usize,
237
    /// Index of the current item
238
    pub current_index: usize,
239
    /// Text of the current selection
240
    pub current_selection: &'a str,
241
    /// selected item indices (may or may not include current item)
242
    pub selected_indices: &'a [usize],
243
    /// selected item texts (may or may not include current item)
244
    pub selections: &'a [&'a str],
245
}
246
247
//------------------------------------------------------------------------------
248
// Preview
249
250
/// Position and scroll information for preview display
251
#[derive(Default, Copy, Clone, Debug)]
252
pub struct PreviewPosition {
253
    /// Horizontal scroll position
254
    pub h_scroll: Size,
255
    /// Horizontal offset
256
    pub h_offset: Size,
257
    /// Vertical scroll position
258
    pub v_scroll: Size,
259
    /// Vertical offset
260
    pub v_offset: Size,
261
}
262
263
/// Defines how an item should be previewed
264
pub enum ItemPreview {
265
    /// execute the command and print the command's output
266
    Command(String),
267
    /// Display the prepared text(lines)
268
    Text(String),
269
    /// Display the colored text(lines)
270
    AnsiText(String),
271
    /// Execute a command and display output with position
272
    CommandWithPos(String, PreviewPosition),
273
    /// Display text with position
274
    TextWithPos(String, PreviewPosition),
275
    /// Display ANSI-colored text with position
276
    AnsiWithPos(String, PreviewPosition),
277
    /// Use global command settings to preview the item
278
    Global,
279
}
280
281
//==============================================================================
282
// A match engine will execute the matching algorithm
283
284
/// Case sensitivity mode for matching
285
#[derive(Eq, PartialEq, Debug, Copy, Clone, Default)]
286
#[cfg_attr(feature = "cli", derive(clap::ValueEnum), clap(rename_all = "snake_case"))]
287
pub enum CaseMatching {
288
    /// Case-sensitive matching
289
    Respect,
290
    /// Case-insensitive matching
291
    Ignore,
292
    /// Smart case: case-insensitive unless query contains uppercase
293
    #[default]
294
    Smart,
295
}
296
297
/// Typo tolerance configuration for fuzzy matching
298
///
299
/// Controls how many character mismatches (typos) are allowed when matching.
300
#[derive(Eq, PartialEq, Debug, Copy, Clone, Default)]
301
pub enum Typos {
302
    /// No typo tolerance — query must match exactly
303
    #[default]
304
    Disabled,
305
    /// Adaptive typo tolerance — allows `pattern_length / 4` typos
306
    Smart,
307
    /// Fixed typo tolerance — allows exactly `n` typos
308
    Fixed(usize),
309
}
310
311
impl From<usize> for Typos {
312
2
    fn from(n: usize) -> Self {
313
2
        match n {
314
1
            0 => Typos::Disabled,
315
1
            n => Typos::Fixed(n),
316
        }
317
2
    }
318
}
319
320
/// Represents the range of a match in an item
321
#[derive(PartialEq, Eq, Clone, Debug)]
322
pub enum MatchRange {
323
    /// Range of bytes (start, end)
324
    ByteRange(usize, usize),
325
    /// Range of character indices (start, end) — used by fuzzy matchers that
326
    /// operate on `char` arrays rather than raw bytes.
327
    CharRange(usize, usize),
328
    /// Individual character indices that matched
329
    Chars(MatchIndices),
330
}
331
332
/// Rank stores the raw match measurements used for sorting results.
333
///
334
/// Named fields preserve the semantic meaning of each value. The actual
335
/// sort key (taking into account the user-configured tiebreak criteria and
336
/// their direction) is computed lazily via [`Rank::sort_key`] rather than
337
/// being baked in at construction time.
338
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
339
pub struct Rank {
340
    /// Raw fuzzy/exact match score (higher is a better match)
341
    pub score: i32,
342
    /// Index of the first matched character (0-based)
343
    pub begin: i32,
344
    /// Index of the last matched character (0-based)
345
    pub end: i32,
346
    /// Length of the item text in bytes
347
    pub length: i32,
348
    /// Ordinal position of the item in the input stream
349
    pub index: i32,
350
    /// Byte offset of the first character after the last path separator (`/` or `\`).
351
    /// Equal to `0` when the item text contains no path separator.
352
    pub path_name_offset: i32,
353
}
354
355
/// Result of matching a query against an item
356
#[derive(Clone, Debug)]
357
pub struct MatchResult {
358
    /// The rank/score of this match
359
    pub rank: Rank,
360
    /// The range where the match occurred
361
    pub matched_range: MatchRange,
362
}
363
364
impl MatchResult {
365
    #[must_use]
366
    /// Converts the match range to character indices
367
5
    pub fn range_char_indices(&self, text: &str) -> MatchIndices {
368
5
        match &self.matched_range {
369
3
            &MatchRange::ByteRange(start, end) => {
370
3
                let first = text[..start].chars().count();
371
3
                let last = first + text[start..end].chars().count();
372
3
                (first..last).collect()
373
            }
374
1
            &MatchRange::CharRange(start, end) => (start..end).collect(),
375
1
            MatchRange::Chars(vec) => vec.clone(),
376
        }
377
5
    }
378
}
379
380
/// A matching engine that can match queries against items
381
pub trait MatchEngine: Sync + Send + Display {
382
    /// Matches an item against the query, returning a result if matched
383
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult>;
384
}
385
386
/// Factory for creating match engines
387
pub trait MatchEngineFactory {
388
    /// Creates a match engine with explicit case sensitivity
389
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine>;
390
    /// Creates a match engine with default case sensitivity
391
17
    fn create_engine(&self, query: &str) -> Box<dyn MatchEngine> {
392
17
        self.create_engine_with_case(query, CaseMatching::default())
393
17
    }
394
}
395
396
impl MatchEngineFactory for Box<dyn MatchEngineFactory> {
397
3.10k
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
398
3.10k
        (**self).create_engine_with_case(query, case)
399
3.10k
    }
400
}
401
402
//------------------------------------------------------------------------------
403
// Preselection
404
405
/// A selector that determines whether an item should be "pre-selected" in multi-selection mode
406
pub trait Selector {
407
    /// Returns true if the item at the given index should be pre-selected
408
    fn should_select(&self, index: usize, item: &dyn SkimItem) -> bool;
409
}
410
411
//------------------------------------------------------------------------------
412
/// Sender for streaming items to skim
413
pub type SkimItemSender = kanal::Sender<Vec<Arc<dyn SkimItem>>>;
414
/// Receiver for streaming items to skim
415
pub type SkimItemReceiver = kanal::Receiver<Vec<Arc<dyn SkimItem>>>;
416
417
#[cfg(test)]
418
#[path = "lib_tests.rs"]
419
mod tests;
\ No newline at end of file +

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/lib.rs
Line
Count
Source
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
//!
11
//! let options = SkimOptionsBuilder::default()
12
//!     .height("50%")
13
//!     .multi(true)
14
//!     .build()
15
//!     .unwrap();
16
//!
17
//! let output = Skim::run_items(
18
//!         options,
19
//!         ["awk", "bash", "csh", "dash", "fish", "ksh", "zsh"]
20
//!     ).unwrap();
21
//! ```
22
#![cfg_attr(coverage, feature(coverage_attribute))]
23
24
#[macro_use]
25
extern crate log;
26
27
#[global_allocator]
28
static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc;
29
30
use std::any::Any;
31
use std::borrow::Cow;
32
use std::fmt::Display;
33
use std::process::Command;
34
use std::sync::Arc;
35
36
use crate::fuzzy_matcher::MatchIndices;
37
use ratatui::style::Style;
38
use ratatui::text::{Line, Span};
39
40
pub use crate::engine::fuzzy::FuzzyAlgorithm;
41
pub use crate::item::RankCriteria;
42
pub use crate::options::SkimOptions;
43
pub use crate::output::{BinOptions, SkimOutput};
44
pub use crate::skim::*;
45
pub use crate::skim_item::SkimItem;
46
use crate::tui::Size;
47
pub use util::printf;
48
49
pub mod binds;
50
mod engine;
51
pub mod field;
52
pub mod fuzzy_matcher;
53
pub mod helper;
54
pub mod item;
55
pub mod matcher;
56
pub mod options;
57
mod output;
58
#[cfg(unix)]
59
pub mod popup;
60
pub mod prelude;
61
pub mod reader;
62
mod skim;
63
mod skim_item;
64
pub mod spinlock;
65
pub mod theme;
66
pub mod thread_pool;
67
pub mod tui;
68
mod util;
69
70
#[cfg(feature = "cli")]
71
pub mod manpage;
72
#[cfg(feature = "cli")]
73
pub mod shell;
74
75
/// Skim's default command when no `--cmd` flag is passed and `$SKIM_DEFAULT_COMMAND` is unset
76
#[cfg(unix)]
77
pub const SKIM_DEFAULT_COMMAND: &str = "find .";
78
/// Skim's default command when no `--cmd` flag is passed and `$SKIM_DEFAULT_COMMAND` is unset
79
#[cfg(windows)]
80
pub const SKIM_DEFAULT_COMMAND: &str = "dir /s /b /A:-D";
81
82
#[cfg(unix)]
83
116
fn shell_cmd(cmd: &str) -> Command {
84
116
    let mut c = Command::new("sh");
85
116
    c.arg("-c").arg(cmd);
86
116
    c
87
116
}
88
#[cfg(windows)]
89
fn shell_cmd(cmd: &str) -> Command {
90
    use std::os::windows::process::CommandExt as _;
91
    // `cmd.exe` does not parse its command line using MSVC rules, so the default
92
    // `Command::arg` escaping (quoting/backslash-escaping) corrupts shell
93
    // metacharacters like `|`, `&`, `>` and embedded quotes. Pass the command
94
    // string verbatim via `raw_arg` so cmd.exe sees exactly what the user wrote.
95
    let mut c = Command::new("cmd");
96
    c.arg("/c").raw_arg(cmd);
97
    c
98
}
99
100
//------------------------------------------------------------------------------
101
/// Trait for downcasting to concrete types from trait objects
102
pub trait AsAny {
103
    /// Returns a reference to the value as `Any`
104
    fn as_any(&self) -> &dyn Any;
105
    /// Returns a mutable reference to the value as `Any`
106
    fn as_any_mut(&mut self) -> &mut dyn Any;
107
}
108
109
impl<T: Any> AsAny for T {
110
2
    fn as_any(&self) -> &dyn Any {
111
2
        self
112
2
    }
113
114
1
    fn as_any_mut(&mut self) -> &mut dyn Any {
115
1
        self
116
1
    }
117
}
118
119
//------------------------------------------------------------------------------
120
// Display Context
121
#[derive(Default, Debug, Clone)]
122
/// Represents how a query matches an item
123
pub enum Matches {
124
    /// No matches
125
    #[default]
126
    None,
127
    /// Matches at specific character indices
128
    CharIndices(MatchIndices),
129
    /// Matches in a character range (start, end)
130
    CharRange(usize, usize),
131
    /// Matches in a byte range (start, end)
132
    ByteRange(usize, usize),
133
}
134
135
#[derive(Default, Clone)]
136
/// Context information for displaying an item
137
pub struct DisplayContext {
138
    /// The match score for this item
139
    pub score: i32,
140
    /// Where the query matched in the item
141
    pub matches: Matches,
142
    /// The width of the container to display in
143
    pub container_width: usize,
144
    /// The base style to apply to non-matched portions
145
    pub base_style: Style,
146
    /// The style to apply to matched portions
147
    pub matched_style: Style,
148
}
149
150
impl DisplayContext {
151
    /// Converts the context and text into a styled `Line` with highlighted matches
152
    ///
153
    /// # Panics
154
    ///
155
    /// Panics if the byte ranges in `Matches::ByteRange` do not align with valid UTF-8 boundaries.
156
    #[must_use]
157
6.45k
    pub fn to_line(self, cow: Cow<str>) -> Line {
158
6.45k
        let text: String = cow.into_owned();
159
160
        // Combine base_style with match style for highlighted text
161
        // Match style takes precedence for fg, but inherits bg from base if not set
162
6.45k
        match &self.matches {
163
1.64k
            Matches::CharIndices(indices) => {
164
1.64k
                let mut res = Line::default();
165
1.64k
                let mut chars = text.chars();
166
1.64k
                let mut prev_index = 0;
167
3.58k
                for &index in 
indices1.64k
{
168
3.58k
                    let span_content = chars.by_ref().take(index - prev_index);
169
3.58k
                    res.push_span(Span::styled(span_content.collect::<String>(), self.base_style));
170
3.58k
                    let highlighted_char = chars.next().unwrap_or_default().to_string();
171
3.58k
172
3.58k
                    res.push_span(Span::styled(
173
3.58k
                        highlighted_char,
174
3.58k
                        self.base_style.patch(self.matched_style),
175
3.58k
                    ));
176
3.58k
                    prev_index = index + 1;
177
3.58k
                }
178
1.64k
                res.push_span(Span::styled(chars.collect::<String>(), self.base_style));
179
1.64k
                res
180
            }
181
            // AnsiString::from((context.text, indices, context.highlight_attr)),
182
            #[allow(clippy::cast_possible_truncation)]
183
1
            Matches::CharRange(start, end) => {
184
1
                let mut chars = text.chars();
185
1
                let mut res = Line::default();
186
1
                res.push_span(Span::styled(
187
1
                    chars.by_ref().take(*start).collect::<String>(),
188
1
                    self.base_style,
189
                ));
190
1
                let highlighted_text = chars.by_ref().take(*end - *start).collect::<String>();
191
192
1
                res.push_span(Span::styled(
193
1
                    highlighted_text,
194
1
                    self.base_style.patch(self.matched_style),
195
                ));
196
1
                res.push_span(Span::styled(chars.collect::<String>(), self.base_style));
197
1
                res
198
            }
199
4.61k
            Matches::ByteRange(start, end) => {
200
4.61k
                let mut bytes = text.bytes();
201
4.61k
                let mut res = Line::default();
202
4.61k
                res.push_span(Span::styled(
203
4.61k
                    String::from_utf8(bytes.by_ref().take(*start).collect()).unwrap(),
204
4.61k
                    self.base_style,
205
                ));
206
4.61k
                let highlighted_bytes = bytes.by_ref().take(*end - *start).collect();
207
4.61k
                let highlighted_text = String::from_utf8(highlighted_bytes).unwrap();
208
209
4.61k
                res.push_span(Span::styled(
210
4.61k
                    highlighted_text,
211
4.61k
                    self.base_style.patch(self.matched_style),
212
                ));
213
4.61k
                res.push_span(Span::styled(
214
4.61k
                    String::from_utf8(bytes.collect()).unwrap(),
215
4.61k
                    self.base_style,
216
                ));
217
4.61k
                res
218
            }
219
204
            Matches::None => Line::from(vec![Span::styled(text, self.base_style)]),
220
        }
221
6.45k
    }
222
}
223
224
//------------------------------------------------------------------------------
225
// Preview Context
226
227
/// Context information for generating item previews
228
pub struct PreviewContext<'a> {
229
    /// The current search query
230
    pub query: &'a str,
231
    /// The current command query (for interactive mode)
232
    pub cmd_query: &'a str,
233
    /// Width of the preview window
234
    pub width: usize,
235
    /// Height of the preview window
236
    pub height: usize,
237
    /// Index of the current item
238
    pub current_index: usize,
239
    /// Text of the current selection
240
    pub current_selection: &'a str,
241
    /// selected item indices (may or may not include current item)
242
    pub selected_indices: &'a [usize],
243
    /// selected item texts (may or may not include current item)
244
    pub selections: &'a [&'a str],
245
}
246
247
//------------------------------------------------------------------------------
248
// Preview
249
250
/// Position and scroll information for preview display
251
#[derive(Default, Copy, Clone, Debug)]
252
pub struct PreviewPosition {
253
    /// Horizontal scroll position
254
    pub h_scroll: Size,
255
    /// Horizontal offset
256
    pub h_offset: Size,
257
    /// Vertical scroll position
258
    pub v_scroll: Size,
259
    /// Vertical offset
260
    pub v_offset: Size,
261
}
262
263
/// Defines how an item should be previewed
264
pub enum ItemPreview {
265
    /// execute the command and print the command's output
266
    Command(String),
267
    /// Display the prepared text(lines)
268
    Text(String),
269
    /// Display the colored text(lines)
270
    AnsiText(String),
271
    /// Execute a command and display output with position
272
    CommandWithPos(String, PreviewPosition),
273
    /// Display text with position
274
    TextWithPos(String, PreviewPosition),
275
    /// Display ANSI-colored text with position
276
    AnsiWithPos(String, PreviewPosition),
277
    /// Use global command settings to preview the item
278
    Global,
279
}
280
281
//==============================================================================
282
// A match engine will execute the matching algorithm
283
284
/// Case sensitivity mode for matching
285
#[derive(Eq, PartialEq, Debug, Copy, Clone, Default)]
286
#[cfg_attr(feature = "cli", derive(clap::ValueEnum), clap(rename_all = "snake_case"))]
287
pub enum CaseMatching {
288
    /// Case-sensitive matching
289
    Respect,
290
    /// Case-insensitive matching
291
    Ignore,
292
    /// Smart case: case-insensitive unless query contains uppercase
293
    #[default]
294
    Smart,
295
}
296
297
/// Typo tolerance configuration for fuzzy matching
298
///
299
/// Controls how many character mismatches (typos) are allowed when matching.
300
#[derive(Eq, PartialEq, Debug, Copy, Clone, Default)]
301
pub enum Typos {
302
    /// No typo tolerance — query must match exactly
303
    #[default]
304
    Disabled,
305
    /// Adaptive typo tolerance — allows `pattern_length / 4` typos
306
    Smart,
307
    /// Fixed typo tolerance — allows exactly `n` typos
308
    Fixed(usize),
309
}
310
311
impl From<usize> for Typos {
312
2
    fn from(n: usize) -> Self {
313
2
        match n {
314
1
            0 => Typos::Disabled,
315
1
            n => Typos::Fixed(n),
316
        }
317
2
    }
318
}
319
320
/// Represents the range of a match in an item
321
#[derive(PartialEq, Eq, Clone, Debug)]
322
pub enum MatchRange {
323
    /// Range of bytes (start, end)
324
    ByteRange(usize, usize),
325
    /// Range of character indices (start, end) — used by fuzzy matchers that
326
    /// operate on `char` arrays rather than raw bytes.
327
    CharRange(usize, usize),
328
    /// Individual character indices that matched
329
    Chars(MatchIndices),
330
}
331
332
/// Rank stores the raw match measurements used for sorting results.
333
///
334
/// Named fields preserve the semantic meaning of each value. The actual
335
/// sort key (taking into account the user-configured tiebreak criteria and
336
/// their direction) is computed lazily via [`Rank::sort_key`] rather than
337
/// being baked in at construction time.
338
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
339
pub struct Rank {
340
    /// Raw fuzzy/exact match score (higher is a better match)
341
    pub score: i32,
342
    /// Index of the first matched character (0-based)
343
    pub begin: i32,
344
    /// Index of the last matched character (0-based)
345
    pub end: i32,
346
    /// Length of the item text in bytes
347
    pub length: i32,
348
    /// Ordinal position of the item in the input stream
349
    pub index: i32,
350
    /// Byte offset of the first character after the last path separator (`/` or `\`).
351
    /// Equal to `0` when the item text contains no path separator.
352
    pub path_name_offset: i32,
353
}
354
355
/// Result of matching a query against an item
356
#[derive(Clone, Debug)]
357
pub struct MatchResult {
358
    /// The rank/score of this match
359
    pub rank: Rank,
360
    /// The range where the match occurred
361
    pub matched_range: MatchRange,
362
}
363
364
impl MatchResult {
365
    #[must_use]
366
    /// Converts the match range to character indices
367
5
    pub fn range_char_indices(&self, text: &str) -> MatchIndices {
368
5
        match &self.matched_range {
369
3
            &MatchRange::ByteRange(start, end) => {
370
3
                let first = text[..start].chars().count();
371
3
                let last = first + text[start..end].chars().count();
372
3
                (first..last).collect()
373
            }
374
1
            &MatchRange::CharRange(start, end) => (start..end).collect(),
375
1
            MatchRange::Chars(vec) => vec.clone(),
376
        }
377
5
    }
378
}
379
380
/// A matching engine that can match queries against items
381
pub trait MatchEngine: Sync + Send + Display {
382
    /// Matches an item against the query, returning a result if matched
383
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult>;
384
}
385
386
/// Factory for creating match engines
387
pub trait MatchEngineFactory {
388
    /// Creates a match engine with explicit case sensitivity
389
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine>;
390
    /// Creates a match engine with default case sensitivity
391
17
    fn create_engine(&self, query: &str) -> Box<dyn MatchEngine> {
392
17
        self.create_engine_with_case(query, CaseMatching::default())
393
17
    }
394
}
395
396
impl MatchEngineFactory for Box<dyn MatchEngineFactory> {
397
3.05k
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
398
3.05k
        (**self).create_engine_with_case(query, case)
399
3.05k
    }
400
}
401
402
//------------------------------------------------------------------------------
403
// Preselection
404
405
/// A selector that determines whether an item should be "pre-selected" in multi-selection mode
406
pub trait Selector {
407
    /// Returns true if the item at the given index should be pre-selected
408
    fn should_select(&self, index: usize, item: &dyn SkimItem) -> bool;
409
}
410
411
//------------------------------------------------------------------------------
412
/// Sender for streaming items to skim
413
pub type SkimItemSender = kanal::Sender<Vec<Arc<dyn SkimItem>>>;
414
/// Receiver for streaming items to skim
415
pub type SkimItemReceiver = kanal::Receiver<Vec<Arc<dyn SkimItem>>>;
416
417
#[cfg(test)]
418
#[path = "lib_tests.rs"]
419
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/manpage.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/manpage.rs.html index 7a1931d1..a6ded19e 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/manpage.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/manpage.rs.html @@ -1,4 +1,4 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/manpage.rs
Line
Count
Source
1
//! Provides what's needed to generate skim's man page
2
use std::fmt::Write as _;
3
use std::io::Write;
4
5
use clap::CommandFactory;
6
use clap_mangen::Man;
7
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8
use eyre::Result;
9
use roff::{Inline, Roff};
10
11
use crate::SkimOptions;
12
use crate::binds::{SkimEvent, get_default_key_map};
13
use crate::tui::actions::{ACTION_CATALOG, Action};
14
15
const THEME_SECTION: &str = "
16
Available themes:
17
    * none: base color scheme
18
    * molokai: molokai 256color
19
    * light: light 256color
20
    * 16: dark base16 theme
21
    * bw: black & white theme
22
    * dark | default: dark 256color, default value
23
    * all 4 catppuccin variants:
24
        * catppuccin-latte
25
        * catppuccin-macchiato
26
        * catppuccin-frappe
27
        * catppuccin-mocha
28
29
Available color names:
30
    * normal (or empty string): normal text
31
    * matched (or hl): matched text
32
    * current (or fg+): current line foreground
33
    * bg+: current line background (special case, always sets background)
34
    * current_match (or hl+): matched text in current line
35
    * query: query text
36
    * spinner: spinner character
37
    * info: info text (match count)
38
    * prompt: prompt text
39
    * cursor (or pointer): cursor/pointer
40
    * selected (or marker): selected item marker
41
    * header: header text
42
    * border: border lines
43
    * scrollbar: item list scrollbar thumb
44
45
Adding `-fg`, `_fg`, `-bg`, `_bg`, `-underline`, `_underline` sets the corresponding part of
46
the color. For instance, `normal-fg` (or simply `fg`) will set the foreground normal color.
47
48
Color formats:
49
    * 0-255: ANSI terminal color
50
    * #rrggbb: 24-bit color
51
52
Available attrs:
53
    * x | regular: resets the modifiers, use it before the others
54
    * b | bold
55
    * u | underline
56
    * c | crossed-out
57
    * d | dim
58
    * i | italic
59
    * r | reverse
60
61
Example: `--color '16,normal-fg:0+bold,matched-fg:#ffffff+u,cursor-bg:#deadbe'` will start with the
62
 base 16 theme and override it with a bold ANSI color 0 foreground (black), a hex ffffff (full
63
 white) underlined foreground for matched parts and a #deadbe (pale rose, apparently) cursor background.
64
";
65
66
const EXIT_CODES_SECTION: &str = "
67
* 0: success
68
* 1: no match
69
* 130: interrupt (ctrl-c or esc)
70
* others: error
71
";
72
73
const NORMAL_MODE_SS: &str = "
74
In normal mode, sk reads the input from stdin and displays the results interactively,
75
and the query is then used to fuzzily filter among the input lines.
76
";
77
78
const INTERACTIVE_MODE_SS: &str = "
79
Interactive mode is a special mode that allows you to run a command interactively and display
80
the results. It is enabled by the `--interactive` (or `-i`) option or by binding the
81
`toggle-interactive` action (default: <ctrl-q>).
82
The command is specified with the `--cmd` option.
83
84
Example: `sk --cmd 'rg {} --color=always' --interactive` will use `rg` to search for the query
85
in the current directory and display the results interactively.
86
";
87
88
const KEYS_SS: &str = "
89
* ctrl-[a-z]
90
* ctrl-space
91
* ctrl-alt-[a-z]
92
* alt-[a-zA-Z]
93
* alt-[0-9]
94
* f[1-12]
95
* enter
96
* space
97
* bspace      (bs)
98
* alt-up
99
* alt-down
100
* alt-left
101
* alt-right
102
* alt-enter
103
* alt-space
104
* alt-bspace  (alt-bs)
105
* alt-/
106
* tab
107
* btab        (shift-tab)
108
* esc
109
* del
110
* up
111
* down
112
* left
113
* right
114
* home
115
* end
116
* pgup
117
* pgdn
118
* shift-up
119
* shift-down
120
* shift-left
121
* shift-right
122
* alt-shift-up
123
* alt-shift-down
124
* alt-shift-left
125
* alt-shift-right
126
* double-click
127
* any single character
128
";
129
const BINDABLE_EVENTS_SS: &str = concat!(
130
    "\n",
131
    "* change: the query changes\n",
132
    "* start: skim enters its event loop; fired once\n",
133
    "* load: the reader and matcher finish consuming the current input; ",
134
    "fired once per read, including reloads\n",
135
    "* result: filtering for the current query completes\n",
136
    "* focus: the focused item changes because of cursor movement or a result update\n",
137
    "* zero: the input stream is complete and the final search has no matches\n",
138
    "* one: the input stream is complete and the final search has exactly one match\n",
139
);
140
141
const ACTION_BINDINGS_SS: &str = concat!(
142
    "\n",
143
    "Actions can also be used as binding triggers. A follow-up chain bound to an action name runs immediately ",
144
    "after that action. Use the `act-` prefix for action triggers; it is recommended to avoid ambiguity and ",
145
    "required when the action name is also a key, for example `act-up:last`.\n\n",
146
    "Follow-up chains use non-recursive (`noremap`) semantics: their actions do not trigger further action ",
147
    "bindings. Add `suppress` to skip the triggering action's default behavior, for example ",
148
    "`act-up:suppress+down`.\n",
149
);
150
151
#[cfg(feature = "listen")]
152
const REMOTE_SECTION: &str = "
153
skim can be controlled from other processes, using the `--listen` (and optionally `--remote`) flags.
154
155
To achieve this, run the server instance using `sk --listen optional_address` (the address defaults to `sk`).
156
It will then start listening on a named socket for instructions.
157
158
To send instructions, you can use `sk --remote optional_address` or any other tool that allows us to interact with such sockets,
159
such as `socat` on linux: `echo 'ToggleIn' | socat -u STDIN ABSTRACT-CONNECT:optional_address`. Instructions correspond to skim's Actions and need to be sent in Ron format.
160
When using `sk --remote`, pipe in action chains (see the KEYBINDS section), for instance `echo 'up+select-row' | sk --remote optional_address`
161
";
162
163
/// Renders the list of bindable actions from the action catalog.
164
///
165
/// The list is generated from `define_action_catalog!` in `src/tui/actions.rs`,
166
/// so a new action shows up here (with its doc comment) automatically.
167
7
fn actions_ss() -> String {
168
7
    let mut res = String::from("\n");
169
511
    for 
action504
in
ACTION_CATALOG7
.
iter7
().
filter7
(|action| action.is_bindable()) {
170
504
        let _ = writeln!(res, "* {}: {}", action.display_name(), action.summary());
171
504
    }
172
7
    res
173
7
}
174
175
/// Renders the runtime default keymap, keeping the manpage in sync with
176
/// [`get_default_key_map`].
177
7
fn default_keys_ss() -> String {
178
7
    let mut bindings = get_default_key_map()
179
7
        .iter()
180
336
        .
map7
(|(key, actions)| {
181
336
            let actions = actions.iter().map(Action::name).collect::<Vec<_>>().join("+");
182
336
            (key_name(key), actions)
183
336
        })
184
7
        .collect::<Vec<_>>();
185
1.77k
    
bindings7
.
sort_unstable_by7
(|left, right| left.0.cmp(&right.0));
186
187
7
    let mut res = String::from("\n");
188
336
    for (key, actions) in 
bindings7
{
189
336
        let _ = writeln!(res, "* {key}: {actions}");
190
336
    }
191
7
    res
192
7
}
193
194
336
fn key_name(key: &KeyEvent) -> String {
195
336
    if *key == SkimEvent::DoubleClick.key_event() {
  Branch (195:8): [True: 1, False: 47]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/manpage.rs
Line
Count
Source
1
//! Provides what's needed to generate skim's man page
2
use std::fmt::Write as _;
3
use std::io::Write;
4
5
use clap::CommandFactory;
6
use clap_mangen::Man;
7
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8
use eyre::Result;
9
use roff::{Inline, Roff};
10
11
use crate::SkimOptions;
12
use crate::binds::{SkimEvent, get_default_key_map};
13
use crate::tui::actions::{ACTION_CATALOG, Action};
14
15
const THEME_SECTION: &str = "
16
Available themes:
17
    * none: base color scheme
18
    * molokai: molokai 256color
19
    * light: light 256color
20
    * 16: dark base16 theme
21
    * bw: black & white theme
22
    * dark | default: dark 256color, default value
23
    * all 4 catppuccin variants:
24
        * catppuccin-latte
25
        * catppuccin-macchiato
26
        * catppuccin-frappe
27
        * catppuccin-mocha
28
29
Available color names:
30
    * normal (or empty string): normal text
31
    * matched (or hl): matched text
32
    * current (or fg+): current line foreground
33
    * bg+: current line background (special case, always sets background)
34
    * current_match (or hl+): matched text in current line
35
    * query: query text
36
    * spinner: spinner character
37
    * info: info text (match count)
38
    * prompt: prompt text
39
    * cursor (or pointer): cursor/pointer
40
    * selected (or marker): selected item marker
41
    * header: header text
42
    * border: border lines
43
    * scrollbar: item list scrollbar thumb
44
45
Adding `-fg`, `_fg`, `-bg`, `_bg`, `-underline`, `_underline` sets the corresponding part of
46
the color. For instance, `normal-fg` (or simply `fg`) will set the foreground normal color.
47
48
Color formats:
49
    * 0-255: ANSI terminal color
50
    * #rrggbb: 24-bit color
51
52
Available attrs:
53
    * x | regular: resets the modifiers, use it before the others
54
    * b | bold
55
    * u | underline
56
    * c | crossed-out
57
    * d | dim
58
    * i | italic
59
    * r | reverse
60
61
Example: `--color '16,normal-fg:0+bold,matched-fg:#ffffff+u,cursor-bg:#deadbe'` will start with the
62
 base 16 theme and override it with a bold ANSI color 0 foreground (black), a hex ffffff (full
63
 white) underlined foreground for matched parts and a #deadbe (pale rose, apparently) cursor background.
64
";
65
66
const EXIT_CODES_SECTION: &str = "
67
* 0: success
68
* 1: no match
69
* 130: interrupt (ctrl-c or esc)
70
* others: error
71
";
72
73
const NORMAL_MODE_SS: &str = "
74
In normal mode, sk reads the input from stdin and displays the results interactively,
75
and the query is then used to fuzzily filter among the input lines.
76
";
77
78
const INTERACTIVE_MODE_SS: &str = "
79
Interactive mode is a special mode that allows you to run a command interactively and display
80
the results. It is enabled by the `--interactive` (or `-i`) option or by binding the
81
`toggle-interactive` action (default: <ctrl-q>).
82
The command is specified with the `--cmd` option.
83
84
Example: `sk --cmd 'rg {} --color=always' --interactive` will use `rg` to search for the query
85
in the current directory and display the results interactively.
86
";
87
88
const KEYS_SS: &str = "
89
* ctrl-[a-z]
90
* ctrl-space
91
* ctrl-alt-[a-z]
92
* alt-[a-zA-Z]
93
* alt-[0-9]
94
* f[1-12]
95
* enter
96
* space
97
* bspace      (bs)
98
* alt-up
99
* alt-down
100
* alt-left
101
* alt-right
102
* alt-enter
103
* alt-space
104
* alt-bspace  (alt-bs)
105
* alt-/
106
* tab
107
* btab        (shift-tab)
108
* esc
109
* del
110
* up
111
* down
112
* left
113
* right
114
* home
115
* end
116
* pgup
117
* pgdn
118
* shift-up
119
* shift-down
120
* shift-left
121
* shift-right
122
* alt-shift-up
123
* alt-shift-down
124
* alt-shift-left
125
* alt-shift-right
126
* double-click
127
* any single character
128
";
129
const BINDABLE_EVENTS_SS: &str = concat!(
130
    "\n",
131
    "* change: the query changes\n",
132
    "* start: skim enters its event loop; fired once\n",
133
    "* load: the reader and matcher finish consuming the current input; ",
134
    "fired once per read, including reloads\n",
135
    "* result: filtering for the current query completes\n",
136
    "* focus: the focused item changes because of cursor movement or a result update\n",
137
    "* zero: the input stream is complete and the final search has no matches\n",
138
    "* one: the input stream is complete and the final search has exactly one match\n",
139
);
140
141
const ACTION_BINDINGS_SS: &str = concat!(
142
    "\n",
143
    "Actions can also be used as binding triggers. A follow-up chain bound to an action name runs immediately ",
144
    "after that action. Use the `act-` prefix for action triggers; it is recommended to avoid ambiguity and ",
145
    "required when the action name is also a key, for example `act-up:last`.\n\n",
146
    "Follow-up chains use non-recursive (`noremap`) semantics: their actions do not trigger further action ",
147
    "bindings. Add `suppress` to skip the triggering action's default behavior, for example ",
148
    "`act-up:suppress+down`.\n",
149
);
150
151
#[cfg(feature = "listen")]
152
const REMOTE_SECTION: &str = "
153
skim can be controlled from other processes, using the `--listen` (and optionally `--remote`) flags.
154
155
To achieve this, run the server instance using `sk --listen optional_address` (the address defaults to `sk`).
156
It will then start listening on a named socket for instructions.
157
158
To send instructions, you can use `sk --remote optional_address` or any other tool that allows us to interact with such sockets,
159
such as `socat` on linux: `echo 'ToggleIn' | socat -u STDIN ABSTRACT-CONNECT:optional_address`. Instructions correspond to skim's Actions and need to be sent in Ron format.
160
When using `sk --remote`, pipe in action chains (see the KEYBINDS section), for instance `echo 'up+select-row' | sk --remote optional_address`
161
";
162
163
/// Renders the list of bindable actions from the action catalog.
164
///
165
/// The list is generated from `define_action_catalog!` in `src/tui/actions.rs`,
166
/// so a new action shows up here (with its doc comment) automatically.
167
7
fn actions_ss() -> String {
168
7
    let mut res = String::from("\n");
169
511
    for 
action504
in
ACTION_CATALOG7
.
iter7
().
filter7
(|action| action.is_bindable()) {
170
504
        let _ = writeln!(res, "* {}: {}", action.display_name(), action.summary());
171
504
    }
172
7
    res
173
7
}
174
175
/// Renders the runtime default keymap, keeping the manpage in sync with
176
/// [`get_default_key_map`].
177
7
fn default_keys_ss() -> String {
178
7
    let mut bindings = get_default_key_map()
179
7
        .iter()
180
336
        .
map7
(|(key, actions)| {
181
336
            let actions = actions.iter().map(Action::name).collect::<Vec<_>>().join("+");
182
336
            (key_name(key), actions)
183
336
        })
184
7
        .collect::<Vec<_>>();
185
1.82k
    
bindings7
.
sort_unstable_by7
(|left, right| left.0.cmp(&right.0));
186
187
7
    let mut res = String::from("\n");
188
336
    for (key, actions) in 
bindings7
{
189
336
        let _ = writeln!(res, "* {key}: {actions}");
190
336
    }
191
7
    res
192
7
}
193
194
336
fn key_name(key: &KeyEvent) -> String {
195
336
    if *key == SkimEvent::DoubleClick.key_event() {
  Branch (195:8): [True: 1, False: 47]
 
  Branch (195:8): [True: 6, False: 282]
 
196
7
        return "double-click".to_string();
197
329
    }
198
199
    // Crossterm can report back-tab with every modifier set. Keep the familiar
200
    // binding spelling instead of exposing that terminal representation.
201
329
    if key.code == KeyCode::BackTab && 
key.modifiers == KeyModifiers::all()14
{
  Branch (201:8): [True: 2, False: 45]
   Branch (201:40): [True: 1, False: 1]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/matcher.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/matcher.rs.html
index f976e9a2..d3217361 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/matcher.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/matcher.rs.html
@@ -1,25 +1,25 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/matcher.rs
Line
Count
Source
1
//! This module contains the matching coordinator
2
use crate::thread_pool::{self, ThreadPool};
3
use crate::tui::item_list::{MergeStrategy, ProcessedItems};
4
use std::rc::Rc;
5
use std::sync::Arc;
6
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
7
8
use crate::engine::normalized::NormalizedEngineFactory;
9
use crate::engine::split::SplitMatchEngineFactory;
10
use crate::item::{ItemPool, MatchedItem, RankBuilder};
11
use crate::prelude::{AndOrEngineFactory, ExactOrFuzzyEngineFactory, RegexEngineFactory};
12
use crate::spinlock::SpinLock;
13
use crate::{CaseMatching, MatchEngineFactory, SkimItem, SkimOptions};
14
15
/// Merges per-worker match results and writes them into `processed_items`.
16
///
17
/// When `no_sort` is false, concatenates the pre-sorted worker results into a
18
/// single contiguous `Vec` and calls `sort()`.  Rust's stable sort (driftsort
19
/// since 1.81, a `TimSort` variant before that) detects the k pre-sorted runs
20
/// and merges them in O(n log k) on contiguous memory — benchmarking shows
21
/// this consistently outperforms tree-based or fold-based merge strategies
22
/// due to driftsort's cache-friendly single-buffer merge passes.
23
///
24
/// When `no_sort` is true, worker results arrive in chunk-index order and are
25
/// flattened without sorting.
26
///
27
/// Signals `needs_render` after writing so the UI picks up the new data.
28
51.9k
fn input_index(tac: bool, start: usize, batch_len: usize, batch_index: usize) -> usize {
29
51.9k
    debug_assert!(
batch_index < batch_len0
);
30
51.9k
    if tac {
  Branch (30:8): [True: 8, False: 51.9k]
-
  Branch (30:8): [True: 5, False: 21]
-
31
13
        start + batch_len - 1 - batch_index
32
    } else {
33
51.9k
        start + batch_index
34
    }
35
51.9k
}
36
37
833
fn merge_worker_results(
38
833
    worker_results: Vec<Vec<MatchedItem>>,
39
833
    no_sort: bool,
40
833
    processed_items: &SpinLock<Option<ProcessedItems>>,
41
833
    merge_strategy: MergeStrategy,
42
833
    needs_render: &AtomicBool,
43
833
) {
44
833
    let total_len: usize = worker_results.iter().map(Vec::len).sum();
45
833
    let mut items = Vec::with_capacity(total_len);
46
1.40k
    for chunk in 
worker_results833
{
47
1.40k
        items.extend(chunk);
48
1.40k
    }
49
50
833
    if !no_sort {
  Branch (50:8): [True: 807, False: 7]
-
  Branch (50:8): [True: 13, False: 6]
-
51
820
        // Each worker's sub-list is already sorted by `prepare`, so stable
52
820
        // sort detects the pre-existing runs and merges them efficiently.
53
820
        items.sort();
54
820
    
}13
55
56
833
    trace!("matcher stop, total matched: {}", 
items1
.
len1
());
57
58
    // Single lock, single write into processed_items.
59
833
    let mut guard = processed_items.lock();
60
833
    if 
matches!72
(merge_strategy, MergeStrategy::Replace) {
61
761
        *guard = Some(ProcessedItems {
62
761
            items,
63
761
            merge: MergeStrategy::Replace,
64
761
        });
65
761
        drop(guard);
66
761
        needs_render.store(true, Ordering::Relaxed);
67
761
        return;
68
72
    }
69
72
    match &mut *guard {
70
60
        Some(existing) => {
71
60
            if no_sort {
  Branch (71:16): [True: 2, False: 56]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/matcher.rs
Line
Count
Source
1
//! This module contains the matching coordinator
2
use crate::thread_pool::{self, ThreadPool};
3
use crate::tui::item_list::{MergeStrategy, ProcessedItems};
4
use std::rc::Rc;
5
use std::sync::Arc;
6
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
7
8
use crate::engine::normalized::NormalizedEngineFactory;
9
use crate::engine::split::SplitMatchEngineFactory;
10
use crate::item::{ItemPool, MatchedItem, RankBuilder};
11
use crate::prelude::{AndOrEngineFactory, ExactOrFuzzyEngineFactory, RegexEngineFactory};
12
use crate::spinlock::SpinLock;
13
use crate::{CaseMatching, MatchEngineFactory, SkimItem, SkimOptions};
14
15
/// Merges per-worker match results and writes them into `processed_items`.
16
///
17
/// When `no_sort` is false, concatenates the pre-sorted worker results into a
18
/// single contiguous `Vec` and calls `sort()`.  Rust's stable sort (driftsort
19
/// since 1.81, a `TimSort` variant before that) detects the k pre-sorted runs
20
/// and merges them in O(n log k) on contiguous memory — benchmarking shows
21
/// this consistently outperforms tree-based or fold-based merge strategies
22
/// due to driftsort's cache-friendly single-buffer merge passes.
23
///
24
/// When `no_sort` is true, worker results arrive in chunk-index order and are
25
/// flattened without sorting.
26
///
27
/// Signals `needs_render` after writing so the UI picks up the new data.
28
51.9k
fn input_index(tac: bool, start: usize, batch_len: usize, batch_index: usize) -> usize {
29
51.9k
    debug_assert!(
batch_index < batch_len0
);
30
51.9k
    if tac {
  Branch (30:8): [True: 8, False: 51.9k]
+
  Branch (30:8): [True: 5, False: 22]
+
31
13
        start + batch_len - 1 - batch_index
32
    } else {
33
51.9k
        start + batch_index
34
    }
35
51.9k
}
36
37
817
fn merge_worker_results(
38
817
    worker_results: Vec<Vec<MatchedItem>>,
39
817
    no_sort: bool,
40
817
    processed_items: &SpinLock<Option<ProcessedItems>>,
41
817
    merge_strategy: MergeStrategy,
42
817
    needs_render: &AtomicBool,
43
817
) {
44
817
    let total_len: usize = worker_results.iter().map(Vec::len).sum();
45
817
    let mut items = Vec::with_capacity(total_len);
46
1.40k
    for chunk in 
worker_results817
{
47
1.40k
        items.extend(chunk);
48
1.40k
    }
49
50
817
    if !no_sort {
  Branch (50:8): [True: 791, False: 7]
+
  Branch (50:8): [True: 14, False: 5]
+
51
805
        // Each worker's sub-list is already sorted by `prepare`, so stable
52
805
        // sort detects the pre-existing runs and merges them efficiently.
53
805
        items.sort();
54
805
    
}12
55
56
817
    trace!("matcher stop, total matched: {}", 
items1
.
len1
());
57
58
    // Single lock, single write into processed_items.
59
817
    let mut guard = processed_items.lock();
60
817
    if 
matches!57
(merge_strategy, MergeStrategy::Replace) {
61
760
        *guard = Some(ProcessedItems {
62
760
            items,
63
760
            merge: MergeStrategy::Replace,
64
760
        });
65
760
        drop(guard);
66
760
        needs_render.store(true, Ordering::Relaxed);
67
760
        return;
68
57
    }
69
57
    match &mut *guard {
70
46
        Some(existing) => {
71
46
            if no_sort {
  Branch (71:16): [True: 2, False: 42]
 
  Branch (71:16): [True: 2, False: 0]
-
72
4
                if 
matches!3
(merge_strategy, MergeStrategy::Prepend) {
73
1
                    items.append(&mut existing.items);
74
1
                    existing.items = items;
75
3
                } else {
76
3
                    existing.items.extend(items);
77
3
                }
78
56
            } else {
79
56
                // Both sides are fully sorted — one O(n+m) merge.
80
56
                MatchedItem::merge_into_sorted(&mut existing.items, items);
81
56
            }
82
        }
83
12
        None => {
84
12
            *guard = Some(ProcessedItems {
85
12
                items,
86
12
                merge: merge_strategy,
87
12
            });
88
12
        }
89
    }
90
    // Guard is dropped here, releasing the lock before we signal the render flag.
91
92
72
    needs_render.store(true, Ordering::Relaxed);
93
833
}
94
95
//==============================================================================
96
/// Control handle for a running matcher operation.
97
///
98
/// Provides methods to check status, retrieve results, and stop the matcher.
99
pub struct MatcherControl {
100
    stopped: Arc<AtomicBool>,
101
    interrupt: Arc<AtomicBool>,
102
    processed: Arc<AtomicUsize>,
103
    matched: Arc<AtomicUsize>,
104
}
105
106
impl Default for MatcherControl {
107
528
    fn default() -> Self {
108
528
        Self {
109
528
            stopped: Arc::new(AtomicBool::new(true)),
110
528
            interrupt: Arc::new(AtomicBool::new(false)),
111
528
            processed: Default::default(),
112
528
            matched: Default::default(),
113
528
        }
114
528
    }
115
}
116
117
impl MatcherControl {
118
    /// Returns the number of items that have been processed so far.
119
    #[must_use]
120
2.62k
    pub fn get_num_processed(&self) -> usize {
121
2.62k
        self.processed.load(Ordering::Relaxed)
122
2.62k
    }
123
124
    /// Returns the number of items that have matched so far.
125
    #[must_use]
126
760
    pub fn get_num_matched(&self) -> usize {
127
760
        self.matched.load(Ordering::Relaxed)
128
760
    }
129
130
    /// Signals the matcher to stop processing.
131
2.21k
    pub fn kill(&mut self) {
132
2.21k
        self.interrupt.store(true, Ordering::Relaxed);
133
2.21k
    }
134
135
    /// Returns true if the matcher has stopped (either completed or killed).
136
    #[must_use]
137
5.95k
    pub fn stopped(&self) -> bool {
138
5.95k
        self.stopped.load(Ordering::Relaxed)
139
5.95k
    }
140
}
141
142
impl Drop for MatcherControl {
143
1.36k
    fn drop(&mut self) {
144
1.36k
        self.kill();
145
1.36k
    }
146
}
147
148
//==============================================================================
149
/// The main matcher that coordinates fuzzy/exact matching of items against a query.
150
pub struct Matcher {
151
    engine_factory: Rc<dyn MatchEngineFactory>,
152
    case_matching: CaseMatching,
153
    /// The rank builder shared with all engines; used to attach criteria to `MatchedItem`s.
154
    pub rank_builder: Arc<RankBuilder>,
155
}
156
157
impl Matcher {
158
    /// Creates a new Matcher builder with the given engine factory.
159
532
    pub fn builder(engine_factory: Rc<dyn MatchEngineFactory>) -> Self {
160
532
        Self {
161
532
            engine_factory,
162
532
            case_matching: CaseMatching::default(),
163
532
            rank_builder: Arc::new(RankBuilder::default()),
164
532
        }
165
532
    }
166
167
    /// Sets the case matching mode (smart, ignore, or respect).
168
    #[must_use]
169
532
    pub fn case(mut self, case_matching: CaseMatching) -> Self {
170
532
        self.case_matching = case_matching;
171
532
        self
172
532
    }
173
174
    /// Sets the rank builder (carries tiebreak criteria).
175
    #[must_use]
176
395
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
177
395
        self.rank_builder = rank_builder;
178
395
        self
179
395
    }
180
181
    /// Finalizes the builder and returns the configured Matcher.
182
    #[must_use]
183
532
    pub fn build(self) -> Self {
184
532
        self
185
532
    }
186
187
    /// Creates a `MatchEngineFactory` from the given options.
188
    ///
189
    /// This is useful when you need the factory directly (e.g., for filter mode)
190
    /// without creating a full Matcher instance.
191
    #[must_use]
192
1
    pub fn create_engine_factory(options: &SkimOptions) -> Rc<dyn MatchEngineFactory> {
193
1
        Self::create_engine_factory_with_builder(options).0
194
1
    }
195
196
    /// Creates a `MatchEngineFactory` and the associated `RankBuilder` from the given options.
197
    ///
198
    /// Returns both so callers can attach the builder to `MatchedItem`s for lazy sort-key
199
    /// computation.
200
    #[must_use]
201
397
    pub fn create_engine_factory_with_builder(options: &SkimOptions) -> (Rc<dyn MatchEngineFactory>, Arc<RankBuilder>) {
202
397
        if options.regex {
  Branch (202:12): [True: 1, False: 373]
+
72
4
                if 
matches!3
(merge_strategy, MergeStrategy::Prepend) {
73
1
                    items.append(&mut existing.items);
74
1
                    existing.items = items;
75
3
                } else {
76
3
                    existing.items.extend(items);
77
3
                }
78
42
            } else {
79
42
                // Both sides are fully sorted — one O(n+m) merge.
80
42
                MatchedItem::merge_into_sorted(&mut existing.items, items);
81
42
            }
82
        }
83
11
        None => {
84
11
            *guard = Some(ProcessedItems {
85
11
                items,
86
11
                merge: merge_strategy,
87
11
            });
88
11
        }
89
    }
90
    // Guard is dropped here, releasing the lock before we signal the render flag.
91
92
57
    needs_render.store(true, Ordering::Relaxed);
93
817
}
94
95
//==============================================================================
96
/// Control handle for a running matcher operation.
97
///
98
/// Provides methods to check status, retrieve results, and stop the matcher.
99
pub struct MatcherControl {
100
    stopped: Arc<AtomicBool>,
101
    interrupt: Arc<AtomicBool>,
102
    processed: Arc<AtomicUsize>,
103
    matched: Arc<AtomicUsize>,
104
}
105
106
impl Default for MatcherControl {
107
528
    fn default() -> Self {
108
528
        Self {
109
528
            stopped: Arc::new(AtomicBool::new(true)),
110
528
            interrupt: Arc::new(AtomicBool::new(false)),
111
528
            processed: Default::default(),
112
528
            matched: Default::default(),
113
528
        }
114
528
    }
115
}
116
117
impl MatcherControl {
118
    /// Returns the number of items that have been processed so far.
119
    #[must_use]
120
2.61k
    pub fn get_num_processed(&self) -> usize {
121
2.61k
        self.processed.load(Ordering::Relaxed)
122
2.61k
    }
123
124
    /// Returns the number of items that have matched so far.
125
    #[must_use]
126
758
    pub fn get_num_matched(&self) -> usize {
127
758
        self.matched.load(Ordering::Relaxed)
128
758
    }
129
130
    /// Signals the matcher to stop processing.
131
2.18k
    pub fn kill(&mut self) {
132
2.18k
        self.interrupt.store(true, Ordering::Relaxed);
133
2.18k
    }
134
135
    /// Returns true if the matcher has stopped (either completed or killed).
136
    #[must_use]
137
5.76k
    pub fn stopped(&self) -> bool {
138
5.76k
        self.stopped.load(Ordering::Relaxed)
139
5.76k
    }
140
}
141
142
impl Drop for MatcherControl {
143
1.35k
    fn drop(&mut self) {
144
1.35k
        self.kill();
145
1.35k
    }
146
}
147
148
//==============================================================================
149
/// The main matcher that coordinates fuzzy/exact matching of items against a query.
150
pub struct Matcher {
151
    engine_factory: Rc<dyn MatchEngineFactory>,
152
    case_matching: CaseMatching,
153
    /// The rank builder shared with all engines; used to attach criteria to `MatchedItem`s.
154
    pub rank_builder: Arc<RankBuilder>,
155
}
156
157
impl Matcher {
158
    /// Creates a new Matcher builder with the given engine factory.
159
532
    pub fn builder(engine_factory: Rc<dyn MatchEngineFactory>) -> Self {
160
532
        Self {
161
532
            engine_factory,
162
532
            case_matching: CaseMatching::default(),
163
532
            rank_builder: Arc::new(RankBuilder::default()),
164
532
        }
165
532
    }
166
167
    /// Sets the case matching mode (smart, ignore, or respect).
168
    #[must_use]
169
532
    pub fn case(mut self, case_matching: CaseMatching) -> Self {
170
532
        self.case_matching = case_matching;
171
532
        self
172
532
    }
173
174
    /// Sets the rank builder (carries tiebreak criteria).
175
    #[must_use]
176
395
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
177
395
        self.rank_builder = rank_builder;
178
395
        self
179
395
    }
180
181
    /// Finalizes the builder and returns the configured Matcher.
182
    #[must_use]
183
532
    pub fn build(self) -> Self {
184
532
        self
185
532
    }
186
187
    /// Creates a `MatchEngineFactory` from the given options.
188
    ///
189
    /// This is useful when you need the factory directly (e.g., for filter mode)
190
    /// without creating a full Matcher instance.
191
    #[must_use]
192
1
    pub fn create_engine_factory(options: &SkimOptions) -> Rc<dyn MatchEngineFactory> {
193
1
        Self::create_engine_factory_with_builder(options).0
194
1
    }
195
196
    /// Creates a `MatchEngineFactory` and the associated `RankBuilder` from the given options.
197
    ///
198
    /// Returns both so callers can attach the builder to `MatchedItem`s for lazy sort-key
199
    /// computation.
200
    #[must_use]
201
397
    pub fn create_engine_factory_with_builder(options: &SkimOptions) -> (Rc<dyn MatchEngineFactory>, Arc<RankBuilder>) {
202
397
        if options.regex {
  Branch (202:12): [True: 1, False: 373]
 
  Branch (202:12): [True: 2, False: 21]
 
203
3
            let regex_factory = RegexEngineFactory::builder();
204
3
            let factory: Rc<dyn MatchEngineFactory> = if options.normalize {
  Branch (204:58): [True: 0, False: 1]
 
  Branch (204:58): [True: 1, False: 1]
-
205
1
                Rc::new(NormalizedEngineFactory::new(regex_factory))
206
            } else {
207
2
                Rc::new(regex_factory)
208
            };
209
3
            (factory, Arc::new(RankBuilder::default().tac(options.tac)))
210
        } else {
211
394
            let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()).tac(options.tac));
212
394
            log::debug!("Creating matcher for algo {:?}", options.algorithm);
213
394
            let fuzzy_engine_factory = ExactOrFuzzyEngineFactory::builder()
214
394
                .fuzzy_algorithm(options.algorithm)
215
394
                .exact_mode(options.exact)
216
394
                .typos(options.typos)
217
394
                .filter_mode(options.filter.is_some())
218
394
                .last_match(options.last_match)
219
394
                .rank_builder(rank_builder.clone())
220
394
                .build();
221
222
394
            let mut factory: Box<dyn MatchEngineFactory> = Box::new(fuzzy_engine_factory);
223
224
            // If split_match is enabled, wrap the fuzzy factory with SplitMatchEngineFactory
225
394
            if let Some(
delimiter10
) = options.split_match {
  Branch (225:20): [True: 10, False: 363]
+
205
1
                Rc::new(NormalizedEngineFactory::new(regex_factory))
206
            } else {
207
2
                Rc::new(regex_factory)
208
            };
209
3
            (factory, Arc::new(RankBuilder::default().tac(options.tac)))
210
        } else {
211
394
            let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()).tac(options.tac));
212
394
            log::debug!("Creating matcher for algo {:?}", options.algorithm);
213
394
            let fuzzy_engine_factory = ExactOrFuzzyEngineFactory::builder()
214
394
                .fuzzy_algorithm(options.algorithm)
215
394
                .exact_mode(options.exact)
216
394
                .typos(options.typos)
217
394
                .filter_mode(options.filter.is_some())
218
394
                .last_match(options.last_match)
219
394
                .rank_builder(rank_builder.clone())
220
394
                .build();
221
222
394
            let mut factory: Box<dyn MatchEngineFactory> = Box::new(fuzzy_engine_factory);
223
224
            // If split_match is enabled, wrap the fuzzy factory with SplitMatchEngineFactory
225
394
            if let Some(
delimiter9
) = options.split_match {
  Branch (225:20): [True: 9, False: 364]
 
  Branch (225:20): [True: 0, False: 21]
-
226
10
                factory = Box::new(SplitMatchEngineFactory::new(factory, delimiter));
227
384
            }
228
229
            // Wrap with AndOrEngineFactory so that queries like "foo:bar baz:qux" work
230
394
            factory = Box::new(AndOrEngineFactory::new(factory));
231
232
            // Wrap with NormalizedEngineFactory if normalization is requested
233
394
            if options.normalize {
  Branch (233:16): [True: 11, False: 362]
+
226
9
                factory = Box::new(SplitMatchEngineFactory::new(factory, delimiter));
227
385
            }
228
229
            // Wrap with AndOrEngineFactory so that queries like "foo:bar baz:qux" work
230
394
            factory = Box::new(AndOrEngineFactory::new(factory));
231
232
            // Wrap with NormalizedEngineFactory if normalization is requested
233
394
            if options.normalize {
  Branch (233:16): [True: 11, False: 362]
 
  Branch (233:16): [True: 0, False: 21]
-
234
11
                factory = Box::new(NormalizedEngineFactory::new(factory));
235
383
            }
236
237
394
            let factory: Rc<dyn MatchEngineFactory> = Rc::new(factory);
238
394
            (factory, rank_builder)
239
        }
240
397
    }
241
242
    /// Creates a Matcher configured from the given `SkimOptions`.
243
    #[must_use]
244
395
    pub fn from_options(options: &SkimOptions) -> Self {
245
395
        let (engine_factory, rank_builder) = Self::create_engine_factory_with_builder(options);
246
395
        Matcher::builder(engine_factory)
247
395
            .case(options.case)
248
395
            .rank_builder(rank_builder)
249
395
            .build()
250
395
    }
251
252
    /// Returns the case matching setting for this matcher.
253
    #[must_use]
254
1
    pub fn case_matching(&self) -> CaseMatching {
255
1
        self.case_matching
256
1
    }
257
258
    /// Returns a reference to the engine factory.
259
    #[must_use]
260
1
    pub fn engine_factory(&self) -> &Rc<dyn MatchEngineFactory> {
261
1
        &self.engine_factory
262
1
    }
263
264
    /// Runs the matcher on items from the pool in a background thread.
265
    ///
266
    /// When matching completes, the coordinator merges results directly into
267
    /// `processed_items` according to `merge_strategy`, then signals
268
    /// `needs_render` so the UI picks up the new data on its next tick.
269
    ///
270
    /// Returns a `MatcherControl` that can be used to monitor progress or
271
    /// stop the matcher.
272
    #[allow(clippy::too_many_arguments)]
273
838
    pub(crate) fn run(
274
838
        &self,
275
838
        query: &str,
276
838
        item_pool: &Arc<ItemPool>,
277
838
        thread_pool: &Arc<ThreadPool>,
278
838
        processed_items: Arc<SpinLock<Option<ProcessedItems>>>,
279
838
        merge_strategy: MergeStrategy,
280
838
        no_sort: bool,
281
838
        tac: bool,
282
838
        needs_render: Arc<AtomicBool>,
283
838
    ) -> MatcherControl {
284
838
        let matcher_engine = self.engine_factory.create_engine_with_case(query, self.case_matching);
285
838
        debug!("engine: {matcher_engine}");
286
838
        let stopped = Arc::new(AtomicBool::new(false));
287
838
        let stopped_clone = stopped.clone();
288
838
        let interrupt = Arc::new(AtomicBool::new(false));
289
838
        let interrupt_clone = interrupt.clone();
290
838
        let processed = Arc::new(AtomicUsize::new(0));
291
838
        let processed_clone = processed.clone();
292
838
        let matched = Arc::new(AtomicUsize::new(0));
293
838
        let matched_clone = matched.clone();
294
838
        let rank_builder = self.rank_builder.clone();
295
296
        // Take items synchronously before spawning to avoid a race condition:
297
        // if we took items inside the spawned closure, a subsequent restart_matcher()
298
        // could call kill() + reset() before the old closure runs, causing the old
299
        // closure to re-take items that should belong to the new matcher.
300
838
        let start = item_pool.num_taken();
301
838
        let items = item_pool.take();
302
838
        let total = items.len();
303
838
        trace!("matcher start, total: {total}");
304
305
        // The coordinator runs on a dedicated OS thread so it does not occupy
306
        // a pool slot while waiting for workers.  All pool threads are
307
        // therefore available for the parallel matching work.
308
838
        let num_workers = thread_pool.num_threads();
309
838
        let pool_for_work = Arc::clone(thread_pool);
310
311
838
        std::thread::spawn(move || {
312
            // Process items in parallel using a shared work queue.  Each worker
313
            // thread atomically grabs the next available chunk, processes it,
314
            // and immediately merges its partial results.  This means threads
315
            // that finish early automatically pick up more work, providing
316
            // natural load balancing.
317
            //
318
            // The chunk size controls the granularity of work distribution and
319
            // the frequency of atomic counter updates / interrupt checks.
320
            const CHUNK_SIZE: usize = 1 << 12;
321
322
            // Convert items into an Arc slice so all workers can share them.
323
838
            let shared_items: Arc<[Arc<dyn SkimItem>]> = items.into();
324
325
            // Clones for the process_chunk closure.
326
838
            let matcher_engine: Arc<dyn crate::MatchEngine> = Arc::from(matcher_engine);
327
838
            let interrupt_for_work = Arc::clone(&interrupt);
328
838
            let processed_for_work = Arc::clone(&processed);
329
838
            let matched_for_work = Arc::clone(&matched);
330
838
            let rank_builder_for_work = Arc::clone(&rank_builder);
331
332
838
            thread_pool::parallel_work_queue(
333
838
                &pool_for_work,
334
838
                num_workers,
335
838
                &shared_items,
336
                CHUNK_SIZE,
337
838
                no_sort,
338
                // identity – seed value for each worker's local accumulator
339
                Vec::<MatchedItem>::new,
340
                // process_chunk – called for each chunk; returns a Vec of matches
341
714
                move |chunk_start, chunk: &[Arc<dyn crate::SkimItem>]| {
342
                    // Check interrupt before processing this chunk.
343
714
                    if interrupt_for_work.load(Ordering::Relaxed) {
  Branch (343:24): [True: 0, False: 697]
-
  Branch (343:24): [True: 7, False: 10]
-
344
7
                        return Vec::new();
345
707
                    }
346
347
707
                    let mut local_matches = Vec::new();
348
707
                    let mut chunk_matched: usize = 0;
349
350
52.9k
                    for (i, item) in 
chunk707
.
iter707
().
enumerate707
() {
351
52.9k
                        if let Some(
match_result51.9k
) = matcher_engine.match_item(item.as_ref()) {
  Branch (351:32): [True: 51.9k, False: 970]
-
  Branch (351:32): [True: 19, False: 1]
-
352
51.9k
                            chunk_matched += 1;
353
51.9k
                            let mut rank = match_result.rank;
354
51.9k
                            let batch_index = chunk_start + i;
355
51.9k
                            // `take()` reverses each tac batch, so recover the
356
51.9k
                            // item's stable ordinal in the original input stream.
357
51.9k
                            let index = input_index(tac, start, total, batch_index);
358
51.9k
                            rank.index = i32::try_from(index).unwrap_or(i32::MAX);
359
51.9k
                            local_matches.push(MatchedItem::new(
360
51.9k
                                Arc::clone(item),
361
51.9k
                                rank,
362
51.9k
                                Some(match_result.matched_range),
363
51.9k
                                &rank_builder_for_work,
364
51.9k
                            ));
365
51.9k
                        
}971
366
                    }
367
368
                    // Flush counters for this chunk so the UI sees progress.
369
707
                    processed_for_work.fetch_add(chunk.len(), Ordering::Relaxed);
370
707
                    if chunk_matched > 0 {
  Branch (370:24): [True: 603, False: 94]
-
  Branch (370:24): [True: 10, False: 0]
-
371
613
                        matched_for_work.fetch_add(chunk_matched, Ordering::Relaxed);
372
613
                    
}94
373
374
707
                    local_matches
375
714
                },
376
                // reduce – accumulate chunk matches into the worker-local Vec.
377
                // No sorting here — that would be O(m²/chunk_size) per worker.
378
697
                |acc: &mut Vec<MatchedItem>, mut partial: Vec<MatchedItem>| {
379
697
                    if acc.len() >= partial.len() {
  Branch (379:24): [True: 94, False: 586]
-
  Branch (379:24): [True: 7, False: 10]
-
380
101
                        acc.extend(partial);
381
596
                    } else {
382
596
                        partial.append(acc);
383
596
                        *acc = partial;
384
596
                    }
385
697
                },
386
                // prepare – sort each worker's accumulator **on the worker
387
                // thread** so that sorting runs in parallel across all workers.
388
                // A single O((m/k)·log(m/k)) sort per worker is far cheaper
389
                // than sorting during reduce.
390
                // sort_unstable is used here because the worker's accumulator
391
                // has no pre-existing sorted runs (items were appended in
392
                // chunk order), so driftsort's run-detection overhead is pure
393
                // cost.  The final merge uses sort() so that driftsort can
394
                // exploit the k sorted runs produced by the workers.
395
1.39k
                |acc: &mut Vec<MatchedItem>| acc.sort_unstable(),
396
                // merge – concat pre-sorted worker results and sort().
397
                // Rust's stable sort detects the k sorted runs and merges
398
                // them in O(n log k), then writes into processed_items.
399
838
                |worker_results: Vec<Vec<MatchedItem>>| {
400
838
                    if interrupt.load(Ordering::SeqCst) {
  Branch (400:24): [True: 0, False: 814]
+
234
11
                factory = Box::new(NormalizedEngineFactory::new(factory));
235
383
            }
236
237
394
            let factory: Rc<dyn MatchEngineFactory> = Rc::new(factory);
238
394
            (factory, rank_builder)
239
        }
240
397
    }
241
242
    /// Creates a Matcher configured from the given `SkimOptions`.
243
    #[must_use]
244
395
    pub fn from_options(options: &SkimOptions) -> Self {
245
395
        let (engine_factory, rank_builder) = Self::create_engine_factory_with_builder(options);
246
395
        Matcher::builder(engine_factory)
247
395
            .case(options.case)
248
395
            .rank_builder(rank_builder)
249
395
            .build()
250
395
    }
251
252
    /// Returns the case matching setting for this matcher.
253
    #[must_use]
254
1
    pub fn case_matching(&self) -> CaseMatching {
255
1
        self.case_matching
256
1
    }
257
258
    /// Returns a reference to the engine factory.
259
    #[must_use]
260
1
    pub fn engine_factory(&self) -> &Rc<dyn MatchEngineFactory> {
261
1
        &self.engine_factory
262
1
    }
263
264
    /// Runs the matcher on items from the pool in a background thread.
265
    ///
266
    /// When matching completes, the coordinator merges results directly into
267
    /// `processed_items` according to `merge_strategy`, then signals
268
    /// `needs_render` so the UI picks up the new data on its next tick.
269
    ///
270
    /// Returns a `MatcherControl` that can be used to monitor progress or
271
    /// stop the matcher.
272
    #[allow(clippy::too_many_arguments)]
273
822
    pub(crate) fn run(
274
822
        &self,
275
822
        query: &str,
276
822
        item_pool: &Arc<ItemPool>,
277
822
        thread_pool: &Arc<ThreadPool>,
278
822
        processed_items: Arc<SpinLock<Option<ProcessedItems>>>,
279
822
        merge_strategy: MergeStrategy,
280
822
        no_sort: bool,
281
822
        tac: bool,
282
822
        needs_render: Arc<AtomicBool>,
283
822
    ) -> MatcherControl {
284
822
        let matcher_engine = self.engine_factory.create_engine_with_case(query, self.case_matching);
285
822
        debug!("engine: {matcher_engine}");
286
822
        let stopped = Arc::new(AtomicBool::new(false));
287
822
        let stopped_clone = stopped.clone();
288
822
        let interrupt = Arc::new(AtomicBool::new(false));
289
822
        let interrupt_clone = interrupt.clone();
290
822
        let processed = Arc::new(AtomicUsize::new(0));
291
822
        let processed_clone = processed.clone();
292
822
        let matched = Arc::new(AtomicUsize::new(0));
293
822
        let matched_clone = matched.clone();
294
822
        let rank_builder = self.rank_builder.clone();
295
296
        // Take items synchronously before spawning to avoid a race condition:
297
        // if we took items inside the spawned closure, a subsequent restart_matcher()
298
        // could call kill() + reset() before the old closure runs, causing the old
299
        // closure to re-take items that should belong to the new matcher.
300
822
        let start = item_pool.num_taken();
301
822
        let items = item_pool.take();
302
822
        let total = items.len();
303
822
        trace!("matcher start, total: {total}");
304
305
        // The coordinator runs on a dedicated OS thread so it does not occupy
306
        // a pool slot while waiting for workers.  All pool threads are
307
        // therefore available for the parallel matching work.
308
822
        let num_workers = thread_pool.num_threads();
309
822
        let pool_for_work = Arc::clone(thread_pool);
310
311
822
        std::thread::spawn(move || {
312
            // Process items in parallel using a shared work queue.  Each worker
313
            // thread atomically grabs the next available chunk, processes it,
314
            // and immediately merges its partial results.  This means threads
315
            // that finish early automatically pick up more work, providing
316
            // natural load balancing.
317
            //
318
            // The chunk size controls the granularity of work distribution and
319
            // the frequency of atomic counter updates / interrupt checks.
320
            const CHUNK_SIZE: usize = 1 << 12;
321
322
            // Convert items into an Arc slice so all workers can share them.
323
822
            let shared_items: Arc<[Arc<dyn SkimItem>]> = items.into();
324
325
            // Clones for the process_chunk closure.
326
822
            let matcher_engine: Arc<dyn crate::MatchEngine> = Arc::from(matcher_engine);
327
822
            let interrupt_for_work = Arc::clone(&interrupt);
328
822
            let processed_for_work = Arc::clone(&processed);
329
822
            let matched_for_work = Arc::clone(&matched);
330
822
            let rank_builder_for_work = Arc::clone(&rank_builder);
331
332
822
            thread_pool::parallel_work_queue(
333
822
                &pool_for_work,
334
822
                num_workers,
335
822
                &shared_items,
336
                CHUNK_SIZE,
337
822
                no_sort,
338
                // identity – seed value for each worker's local accumulator
339
                Vec::<MatchedItem>::new,
340
                // process_chunk – called for each chunk; returns a Vec of matches
341
714
                move |chunk_start, chunk: &[Arc<dyn crate::SkimItem>]| {
342
                    // Check interrupt before processing this chunk.
343
714
                    if interrupt_for_work.load(Ordering::Relaxed) {
  Branch (343:24): [True: 0, False: 697]
+
  Branch (343:24): [True: 6, False: 11]
+
344
6
                        return Vec::new();
345
708
                    }
346
347
708
                    let mut local_matches = Vec::new();
348
708
                    let mut chunk_matched: usize = 0;
349
350
52.9k
                    for (i, item) in 
chunk708
.
iter708
().
enumerate708
() {
351
52.9k
                        if let Some(
match_result51.9k
) = matcher_engine.match_item(item.as_ref()) {
  Branch (351:32): [True: 51.9k, False: 968]
+
  Branch (351:32): [True: 20, False: 1]
+
352
51.9k
                            chunk_matched += 1;
353
51.9k
                            let mut rank = match_result.rank;
354
51.9k
                            let batch_index = chunk_start + i;
355
51.9k
                            // `take()` reverses each tac batch, so recover the
356
51.9k
                            // item's stable ordinal in the original input stream.
357
51.9k
                            let index = input_index(tac, start, total, batch_index);
358
51.9k
                            rank.index = i32::try_from(index).unwrap_or(i32::MAX);
359
51.9k
                            local_matches.push(MatchedItem::new(
360
51.9k
                                Arc::clone(item),
361
51.9k
                                rank,
362
51.9k
                                Some(match_result.matched_range),
363
51.9k
                                &rank_builder_for_work,
364
51.9k
                            ));
365
51.9k
                        
}969
366
                    }
367
368
                    // Flush counters for this chunk so the UI sees progress.
369
708
                    processed_for_work.fetch_add(chunk.len(), Ordering::Relaxed);
370
708
                    if chunk_matched > 0 {
  Branch (370:24): [True: 603, False: 94]
+
  Branch (370:24): [True: 11, False: 0]
+
371
614
                        matched_for_work.fetch_add(chunk_matched, Ordering::Relaxed);
372
614
                    
}94
373
374
708
                    local_matches
375
714
                },
376
                // reduce – accumulate chunk matches into the worker-local Vec.
377
                // No sorting here — that would be O(m²/chunk_size) per worker.
378
697
                |acc: &mut Vec<MatchedItem>, mut partial: Vec<MatchedItem>| {
379
697
                    if acc.len() >= partial.len() {
  Branch (379:24): [True: 94, False: 586]
+
  Branch (379:24): [True: 6, False: 11]
+
380
100
                        acc.extend(partial);
381
597
                    } else {
382
597
                        partial.append(acc);
383
597
                        *acc = partial;
384
597
                    }
385
697
                },
386
                // prepare – sort each worker's accumulator **on the worker
387
                // thread** so that sorting runs in parallel across all workers.
388
                // A single O((m/k)·log(m/k)) sort per worker is far cheaper
389
                // than sorting during reduce.
390
                // sort_unstable is used here because the worker's accumulator
391
                // has no pre-existing sorted runs (items were appended in
392
                // chunk order), so driftsort's run-detection overhead is pure
393
                // cost.  The final merge uses sort() so that driftsort can
394
                // exploit the k sorted runs produced by the workers.
395
1.39k
                |acc: &mut Vec<MatchedItem>| acc.sort_unstable(),
396
                // merge – concat pre-sorted worker results and sort().
397
                // Rust's stable sort detects the k sorted runs and merges
398
                // them in O(n log k), then writes into processed_items.
399
822
                |worker_results: Vec<Vec<MatchedItem>>| {
400
822
                    if interrupt.load(Ordering::SeqCst) {
  Branch (400:24): [True: 0, False: 798]
 
  Branch (400:24): [True: 11, False: 13]
-
401
11
                        return;
402
827
                    }
403
404
827
                    merge_worker_results(worker_results, no_sort, &processed_items, merge_strategy, &needs_render);
405
838
                },
406
            );
407
838
            stopped.store(true, Ordering::Relaxed);
408
838
        });
409
410
838
        MatcherControl {
411
838
            stopped: stopped_clone,
412
838
            interrupt: interrupt_clone,
413
838
            matched: matched_clone,
414
838
            processed: processed_clone,
415
838
        }
416
838
    }
417
}
418
419
#[cfg(test)]
420
#[cfg_attr(coverage, coverage(off))]
421
mod tests {
422
    use super::*;
423
    use crate::Rank;
424
    use crate::item::RankBuilder;
425
    use crate::options::SkimOptionsBuilder;
426
427
    fn matched(text: &str, index: i32) -> MatchedItem {
428
        MatchedItem::new(
429
            Arc::new(text.to_string()),
430
            Rank {
431
                index,
432
                ..Default::default()
433
            },
434
            None,
435
            &RankBuilder::default(),
436
        )
437
    }
438
439
    #[test]
440
    fn from_options_exposes_case_and_factory() {
441
        let options = SkimOptionsBuilder::default()
442
            .case(CaseMatching::Ignore)
443
            .build()
444
            .unwrap();
445
        let matcher = Matcher::from_options(&options);
446
        assert_eq!(matcher.case_matching(), CaseMatching::Ignore);
447
        // The factory builds a working engine.
448
        let engine = matcher.engine_factory().create_engine("foo");
449
        assert!(engine.match_item(&"foobar".to_string()).is_some());
450
    }
451
452
    #[test]
453
    fn create_engine_factory_builds_fuzzy_engine() {
454
        let options = SkimOptions::default();
455
        let factory = Matcher::create_engine_factory(&options);
456
        let engine = factory.create_engine("fb");
457
        assert!(engine.match_item(&"foobar".to_string()).is_some());
458
    }
459
460
    #[test]
461
    fn create_engine_factory_regex_with_normalize() {
462
        let options = SkimOptionsBuilder::default()
463
            .regex(true)
464
            .normalize(true)
465
            .build()
466
            .unwrap();
467
        let (factory, _rank) = Matcher::create_engine_factory_with_builder(&options);
468
        let engine = factory.create_engine("ba.");
469
        assert!(engine.match_item(&"foobar".to_string()).is_some());
470
    }
471
472
    #[test]
473
    fn merge_worker_results_replace_sorts() {
474
        let processed = SpinLock::new(None);
475
        let needs_render = AtomicBool::new(false);
476
        let workers = vec![vec![matched("b", 1)], vec![matched("a", 0)]];
477
        merge_worker_results(workers, false, &processed, MergeStrategy::Replace, &needs_render);
478
479
        assert!(needs_render.load(Ordering::Relaxed));
480
        let guard = processed.lock();
481
        let items = &guard.as_ref().unwrap().items;
482
        assert_eq!(items.len(), 2);
483
    }
484
485
    #[test]
486
    fn merge_worker_results_no_sort_preserves_chunk_order() {
487
        let processed = SpinLock::new(None);
488
        let needs_render = AtomicBool::new(false);
489
        let workers = vec![
490
            vec![matched("a", 0), matched("b", 1)],
491
            vec![matched("c", 2), matched("d", 3)],
492
            vec![matched("e", 4), matched("f", 5)],
493
        ];
494
        merge_worker_results(workers, true, &processed, MergeStrategy::Replace, &needs_render);
495
496
        let guard = processed.lock();
497
        let items = &guard.as_ref().unwrap().items;
498
        let indexes: Vec<i32> = items.iter().map(|item| item.rank.index).collect();
499
        assert_eq!(indexes, vec![0, 1, 2, 3, 4, 5]);
500
    }
501
502
    #[test]
503
    fn merge_worker_results_append_no_sort_extends_existing() {
504
        let processed = SpinLock::new(None);
505
        let needs_render = AtomicBool::new(false);
506
507
        // First append establishes the existing list.
508
        merge_worker_results(
509
            vec![vec![matched("a", 0)]],
510
            true,
511
            &processed,
512
            MergeStrategy::Append,
513
            &needs_render,
514
        );
515
        // Second append with no_sort extends the existing list in place.
516
        merge_worker_results(
517
            vec![vec![matched("b", 1)]],
518
            true,
519
            &processed,
520
            MergeStrategy::Append,
521
            &needs_render,
522
        );
523
524
        let guard = processed.lock();
525
        assert_eq!(guard.as_ref().unwrap().items.len(), 2);
526
    }
527
528
    #[test]
529
    fn tac_input_index_spans_incremental_batches() {
530
        let first_batch: Vec<_> = (0..3).map(|index| input_index(true, 0, 3, index)).collect();
531
        let second_batch: Vec<_> = (0..2).map(|index| input_index(true, 3, 2, index)).collect();
532
533
        assert_eq!(first_batch, [2, 1, 0]);
534
        assert_eq!(second_batch, [4, 3]);
535
        assert_eq!(input_index(false, 3, 2, 0), 3);
536
        assert_eq!(input_index(false, 3, 2, 1), 4);
537
    }
538
539
    #[test]
540
    fn merge_worker_results_prepend_no_sort_places_new_batch_first() {
541
        let processed = SpinLock::new(None);
542
        let needs_render = AtomicBool::new(false);
543
544
        merge_worker_results(
545
            vec![vec![matched("c", 2), matched("b", 1), matched("a", 0)]],
546
            true,
547
            &processed,
548
            MergeStrategy::Prepend,
549
            &needs_render,
550
        );
551
        merge_worker_results(
552
            vec![vec![matched("e", 4), matched("d", 3)]],
553
            true,
554
            &processed,
555
            MergeStrategy::Prepend,
556
            &needs_render,
557
        );
558
559
        let guard = processed.lock();
560
        let indexes: Vec<i32> = guard
561
            .as_ref()
562
            .unwrap()
563
            .items
564
            .iter()
565
            .map(|item| item.rank.index)
566
            .collect();
567
        assert_eq!(indexes, [4, 3, 2, 1, 0]);
568
    }
569
}
\ No newline at end of file +
401
11
                        return;
402
811
                    }
403
404
811
                    merge_worker_results(worker_results, no_sort, &processed_items, merge_strategy, &needs_render);
405
822
                },
406
            );
407
822
            stopped.store(true, Ordering::Relaxed);
408
822
        });
409
410
822
        MatcherControl {
411
822
            stopped: stopped_clone,
412
822
            interrupt: interrupt_clone,
413
822
            matched: matched_clone,
414
822
            processed: processed_clone,
415
822
        }
416
822
    }
417
}
418
419
#[cfg(test)]
420
#[cfg_attr(coverage, coverage(off))]
421
mod tests {
422
    use super::*;
423
    use crate::Rank;
424
    use crate::item::RankBuilder;
425
    use crate::options::SkimOptionsBuilder;
426
427
    fn matched(text: &str, index: i32) -> MatchedItem {
428
        MatchedItem::new(
429
            Arc::new(text.to_string()),
430
            Rank {
431
                index,
432
                ..Default::default()
433
            },
434
            None,
435
            &RankBuilder::default(),
436
        )
437
    }
438
439
    #[test]
440
    fn from_options_exposes_case_and_factory() {
441
        let options = SkimOptionsBuilder::default()
442
            .case(CaseMatching::Ignore)
443
            .build()
444
            .unwrap();
445
        let matcher = Matcher::from_options(&options);
446
        assert_eq!(matcher.case_matching(), CaseMatching::Ignore);
447
        // The factory builds a working engine.
448
        let engine = matcher.engine_factory().create_engine("foo");
449
        assert!(engine.match_item(&"foobar".to_string()).is_some());
450
    }
451
452
    #[test]
453
    fn create_engine_factory_builds_fuzzy_engine() {
454
        let options = SkimOptions::default();
455
        let factory = Matcher::create_engine_factory(&options);
456
        let engine = factory.create_engine("fb");
457
        assert!(engine.match_item(&"foobar".to_string()).is_some());
458
    }
459
460
    #[test]
461
    fn create_engine_factory_regex_with_normalize() {
462
        let options = SkimOptionsBuilder::default()
463
            .regex(true)
464
            .normalize(true)
465
            .build()
466
            .unwrap();
467
        let (factory, _rank) = Matcher::create_engine_factory_with_builder(&options);
468
        let engine = factory.create_engine("ba.");
469
        assert!(engine.match_item(&"foobar".to_string()).is_some());
470
    }
471
472
    #[test]
473
    fn merge_worker_results_replace_sorts() {
474
        let processed = SpinLock::new(None);
475
        let needs_render = AtomicBool::new(false);
476
        let workers = vec![vec![matched("b", 1)], vec![matched("a", 0)]];
477
        merge_worker_results(workers, false, &processed, MergeStrategy::Replace, &needs_render);
478
479
        assert!(needs_render.load(Ordering::Relaxed));
480
        let guard = processed.lock();
481
        let items = &guard.as_ref().unwrap().items;
482
        assert_eq!(items.len(), 2);
483
    }
484
485
    #[test]
486
    fn merge_worker_results_no_sort_preserves_chunk_order() {
487
        let processed = SpinLock::new(None);
488
        let needs_render = AtomicBool::new(false);
489
        let workers = vec![
490
            vec![matched("a", 0), matched("b", 1)],
491
            vec![matched("c", 2), matched("d", 3)],
492
            vec![matched("e", 4), matched("f", 5)],
493
        ];
494
        merge_worker_results(workers, true, &processed, MergeStrategy::Replace, &needs_render);
495
496
        let guard = processed.lock();
497
        let items = &guard.as_ref().unwrap().items;
498
        let indexes: Vec<i32> = items.iter().map(|item| item.rank.index).collect();
499
        assert_eq!(indexes, vec![0, 1, 2, 3, 4, 5]);
500
    }
501
502
    #[test]
503
    fn merge_worker_results_append_no_sort_extends_existing() {
504
        let processed = SpinLock::new(None);
505
        let needs_render = AtomicBool::new(false);
506
507
        // First append establishes the existing list.
508
        merge_worker_results(
509
            vec![vec![matched("a", 0)]],
510
            true,
511
            &processed,
512
            MergeStrategy::Append,
513
            &needs_render,
514
        );
515
        // Second append with no_sort extends the existing list in place.
516
        merge_worker_results(
517
            vec![vec![matched("b", 1)]],
518
            true,
519
            &processed,
520
            MergeStrategy::Append,
521
            &needs_render,
522
        );
523
524
        let guard = processed.lock();
525
        assert_eq!(guard.as_ref().unwrap().items.len(), 2);
526
    }
527
528
    #[test]
529
    fn tac_input_index_spans_incremental_batches() {
530
        let first_batch: Vec<_> = (0..3).map(|index| input_index(true, 0, 3, index)).collect();
531
        let second_batch: Vec<_> = (0..2).map(|index| input_index(true, 3, 2, index)).collect();
532
533
        assert_eq!(first_batch, [2, 1, 0]);
534
        assert_eq!(second_batch, [4, 3]);
535
        assert_eq!(input_index(false, 3, 2, 0), 3);
536
        assert_eq!(input_index(false, 3, 2, 1), 4);
537
    }
538
539
    #[test]
540
    fn merge_worker_results_prepend_no_sort_places_new_batch_first() {
541
        let processed = SpinLock::new(None);
542
        let needs_render = AtomicBool::new(false);
543
544
        merge_worker_results(
545
            vec![vec![matched("c", 2), matched("b", 1), matched("a", 0)]],
546
            true,
547
            &processed,
548
            MergeStrategy::Prepend,
549
            &needs_render,
550
        );
551
        merge_worker_results(
552
            vec![vec![matched("e", 4), matched("d", 3)]],
553
            true,
554
            &processed,
555
            MergeStrategy::Prepend,
556
            &needs_render,
557
        );
558
559
        let guard = processed.lock();
560
        let indexes: Vec<i32> = guard
561
            .as_ref()
562
            .unwrap()
563
            .items
564
            .iter()
565
            .map(|item| item.rank.index)
566
            .collect();
567
        assert_eq!(indexes, [4, 3, 2, 1, 0]);
568
    }
569
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/options.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/options.rs.html index ab394108..2a424d66 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/options.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/options.rs.html @@ -1,14 +1,14 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/options.rs
Line
Count
Source
1
//! Configuration options for skim.
2
//!
3
//! This module provides the `SkimOptions` struct and builder for configuring
4
//! all aspects of skim's behavior, including search, display, layout, and interaction settings.
5
6
use std::cell::RefCell;
7
use std::rc::Rc;
8
9
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10
use derive_builder::Builder;
11
#[cfg(feature = "image")]
12
use ratatui_image::picker::Picker;
13
use regex::Regex;
14
15
use crate::binds::KeyMap;
16
use crate::item::RankCriteria;
17
use crate::prelude::SkimItemReader;
18
use crate::reader::CommandCollector;
19
use crate::tui::actions::Action;
20
use crate::tui::options::{PreviewLayout, TuiLayout};
21
use crate::tui::statusline::{Info, InfoDisplay};
22
use crate::tui::{BorderType, PreviewCallback};
23
use crate::util::read_file_lines;
24
use crate::{CaseMatching, FuzzyAlgorithm, Selector, Typos};
25
26
const MIN_HEIGHT_PARSE_ERROR: &str = "min-height needs to be a non-negative integer";
27
28
482
pub(crate) fn parse_min_height(s: &str) -> Result<u16, String> {
29
482
    s.parse().map_err(|_| 
MIN_HEIGHT_PARSE_ERROR5
.
to_string5
())
30
482
}
31
32
#[cfg(feature = "cli")]
33
468
fn parse_min_height_value(s: &str) -> Result<String, String> {
34
468
    parse_min_height(s).map(|_| 
s466
.
to_string466
())
35
468
}
36
37
#[cfg(feature = "cli")]
38
/// Custom value parser for delimiter that handles escape sequences
39
467
fn parse_delimiter_value(s: &str) -> Result<Regex, String> {
40
467
    let unescaped = crate::util::unescape_delimiter(s);
41
467
    Regex::new(&unescaped).map_err(|e| 
format!0
("Invalid regex delimiter: {e}"))
42
467
}
43
44
/// Custom value parser for border
45
///
46
/// Any undefined value falls back to [`BorderType::Plain`] (see the `FromStr` impl in `tui`)
47
/// instead of producing a parse error like the default `ValueEnum` parser would, while still
48
/// advertising the known variants in `--help` and shell completions by delegating
49
/// [`possible_values`](clap::builder::TypedValueParser::possible_values) to
50
/// [`BorderType`]'s [`ValueEnum`](clap::ValueEnum) members.
51
#[cfg(feature = "cli")]
52
#[derive(Clone)]
53
struct BorderValueParser;
54
55
#[cfg(feature = "cli")]
56
impl clap::builder::TypedValueParser for BorderValueParser {
57
    type Value = BorderType;
58
59
465
    fn parse_ref(
60
465
        &self,
61
465
        _cmd: &clap::Command,
62
465
        _arg: Option<&clap::Arg>,
63
465
        value: &std::ffi::OsStr,
64
465
    ) -> Result<Self::Value, clap::Error> {
65
        // `FromStr for BorderType` is infallible: unknown values map to `BorderType::Plain`.
66
465
        Ok(value.to_string_lossy().parse().unwrap_or(BorderType::Plain))
67
465
    }
68
69
17
    fn possible_values(&self) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
70
17
        Some(Box::new(
71
17
            <BorderType as clap::ValueEnum>::value_variants()
72
17
                .iter()
73
17
                .filter_map(clap::ValueEnum::to_possible_value),
74
17
        ))
75
17
    }
76
}
77
78
#[cfg(feature = "cli")]
79
/// Custom value parser for typo tolerance
80
///
81
/// - `"smart"` → `Typos::Smart` (adaptive: `pattern_length` / 4)
82
/// - `"disabled"` → `Typos::Disabled`
83
/// - `"N"` (N >= 0) → `Typos::Fixed(N)`
84
465
fn parse_typos(s: &str) -> Result<Typos, String> {
85
465
    if s.eq_ignore_ascii_case("smart") {
  Branch (85:8): [True: 4, False: 450]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/options.rs
Line
Count
Source
1
//! Configuration options for skim.
2
//!
3
//! This module provides the `SkimOptions` struct and builder for configuring
4
//! all aspects of skim's behavior, including search, display, layout, and interaction settings.
5
6
use std::cell::RefCell;
7
use std::rc::Rc;
8
9
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10
use derive_builder::Builder;
11
#[cfg(feature = "image")]
12
use ratatui_image::picker::Picker;
13
use regex::Regex;
14
15
use crate::binds::KeyMap;
16
use crate::item::RankCriteria;
17
use crate::prelude::SkimItemReader;
18
use crate::reader::CommandCollector;
19
use crate::tui::actions::Action;
20
use crate::tui::options::{PreviewLayout, TuiLayout};
21
use crate::tui::statusline::{Info, InfoDisplay};
22
use crate::tui::{BorderType, PreviewCallback};
23
use crate::util::read_file_lines;
24
use crate::{CaseMatching, FuzzyAlgorithm, Selector, Typos};
25
26
const MIN_HEIGHT_PARSE_ERROR: &str = "min-height needs to be a non-negative integer";
27
28
482
pub(crate) fn parse_min_height(s: &str) -> Result<u16, String> {
29
482
    s.parse().map_err(|_| 
MIN_HEIGHT_PARSE_ERROR5
.
to_string5
())
30
482
}
31
32
#[cfg(feature = "cli")]
33
468
fn parse_min_height_value(s: &str) -> Result<String, String> {
34
468
    parse_min_height(s).map(|_| 
s466
.
to_string466
())
35
468
}
36
37
#[cfg(feature = "cli")]
38
/// Custom value parser for delimiter that handles escape sequences
39
467
fn parse_delimiter_value(s: &str) -> Result<Regex, String> {
40
467
    let unescaped = crate::util::unescape_delimiter(s);
41
467
    Regex::new(&unescaped).map_err(|e| 
format!0
("Invalid regex delimiter: {e}"))
42
467
}
43
44
/// Custom value parser for border
45
///
46
/// Any undefined value falls back to [`BorderType::Plain`] (see the `FromStr` impl in `tui`)
47
/// instead of producing a parse error like the default `ValueEnum` parser would, while still
48
/// advertising the known variants in `--help` and shell completions by delegating
49
/// [`possible_values`](clap::builder::TypedValueParser::possible_values) to
50
/// [`BorderType`]'s [`ValueEnum`](clap::ValueEnum) members.
51
#[cfg(feature = "cli")]
52
#[derive(Clone)]
53
struct BorderValueParser;
54
55
#[cfg(feature = "cli")]
56
impl clap::builder::TypedValueParser for BorderValueParser {
57
    type Value = BorderType;
58
59
465
    fn parse_ref(
60
465
        &self,
61
465
        _cmd: &clap::Command,
62
465
        _arg: Option<&clap::Arg>,
63
465
        value: &std::ffi::OsStr,
64
465
    ) -> Result<Self::Value, clap::Error> {
65
        // `FromStr for BorderType` is infallible: unknown values map to `BorderType::Plain`.
66
465
        Ok(value.to_string_lossy().parse().unwrap_or(BorderType::Plain))
67
465
    }
68
69
17
    fn possible_values(&self) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
70
17
        Some(Box::new(
71
17
            <BorderType as clap::ValueEnum>::value_variants()
72
17
                .iter()
73
17
                .filter_map(clap::ValueEnum::to_possible_value),
74
17
        ))
75
17
    }
76
}
77
78
#[cfg(feature = "cli")]
79
/// Custom value parser for typo tolerance
80
///
81
/// - `"smart"` → `Typos::Smart` (adaptive: `pattern_length` / 4)
82
/// - `"disabled"` → `Typos::Disabled`
83
/// - `"N"` (N >= 0) → `Typos::Fixed(N)`
84
465
fn parse_typos(s: &str) -> Result<Typos, String> {
85
465
    if s.eq_ignore_ascii_case("smart") {
  Branch (85:8): [True: 3, False: 451]
 
  Branch (85:8): [True: 0, False: 11]
-
86
4
        Ok(Typos::Smart)
87
461
    } else if s.eq_ignore_ascii_case("disabled") {
  Branch (87:15): [True: 450, False: 0]
+
86
3
        Ok(Typos::Smart)
87
462
    } else if s.eq_ignore_ascii_case("disabled") {
  Branch (87:15): [True: 451, False: 0]
 
  Branch (87:15): [True: 11, False: 0]
-
88
461
        Ok(Typos::Disabled)
89
    } else {
90
0
        s.parse::<usize>()
91
0
            .map(Typos::from)
92
0
            .map_err(|_| format!("Invalid typos value '{s}': expected 'smart', 'disabled' or a non-negative integer"))
93
    }
94
465
}
95
96
/// The options for `--scheme`
97
#[derive(Debug, Clone, Default, PartialEq, Eq)]
98
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
99
pub enum MatchScheme {
100
    /// Default scheme, no modifications to the options
101
    #[default]
102
    Default,
103
    /// Path scheme: will find the furthest match in the item and set `pathname` as the main
104
    /// tiebreak
105
    Path,
106
    /// History scheme: will force `index` as the first tiebreak
107
    History,
108
}
109
110
/// Image rendering protocols
111
#[cfg(feature = "image")]
112
#[derive(Default, Debug, Clone, PartialEq, Eq)]
113
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
114
pub enum ImageProtocol {
115
    /// Default: automatically detect the available backend at startup
116
    #[default]
117
    Detect,
118
    /// Force halfblocks if you want blurry previews but a faster startup or if the detection fails
119
    Halfblocks,
120
}
121
122
/// sk - fuzzy finder in Rust
123
///
124
/// sk is a general purpose command-line fuzzy finder.
125
#[allow(missing_docs, clippy::struct_excessive_bools)] // derive_builder seems to have issues with doc comments ?
126
#[derive(Builder)]
127
#[builder(build_fn(name = "final_build"), setter(into, strip_option))]
128
#[builder(default)]
129
#[cfg_attr(feature = "cli", derive(clap::Parser))]
130
#[cfg_attr(
131
    feature = "cli",
132
    command(name = "sk", args_override_self = true, verbatim_doc_comment, version, about)
133
)]
134
#[derive(derive_more::Debug)]
135
pub struct SkimOptions {
136
    //  --- Search ---
137
    /// Show results in reverse order
138
    ///
139
    /// Often used in combination with --no-sort
140
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
141
    pub tac: bool,
142
143
    /// Minimum query length to start showing results
144
    ///
145
    /// Only show results when the query is at least this many characters long
146
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
147
    pub min_query_length: Option<usize>,
148
149
    /// Do not sort the results
150
    ///
151
    /// Often used in combination with --tac
152
    /// Example: `history | sk --tac --no-sort`
153
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
154
    pub no_sort: bool,
155
156
    /// Comma-separated list of sort criteria to apply when the scores are tied.
157
    ///
158
    /// * **score**: Score of the fuzzy match algorithm
159
    ///
160
    ///     - Each criterion could be negated, e.g. (-index)
161
    ///     - Each criterion should appear only once in the list
162
    #[cfg_attr(
163
        feature = "cli",
164
        arg(
165
            short,
166
            long,
167
            default_value = "score,begin,end",
168
            value_enum,
169
            value_delimiter = ',',
170
            help_heading = "Search",
171
            allow_hyphen_values = true,
172
            verbatim_doc_comment
173
        )
174
    )]
175
    pub tiebreak: Vec<RankCriteria>,
176
177
    /// Fields to be matched
178
    ///
179
    /// A field index expression can be a non-zero integer or a range expression (`[BEGIN]..[END]`).
180
    /// `--nth` and `--with-nth` take a comma-separated list of field index expressions.
181
    ///
182
    /// **Examples:**
183
    ///     1      The 1st field
184
    ///     2      The 2nd field
185
    ///     -1     The last field
186
    ///     -2     The 2nd to last field
187
    ///     3..5   From the 3rd field to the 5th field
188
    ///     2..    From the 2nd field to the last field
189
    ///     ..-3   From the 1st field to the 3rd to the last field
190
    ///     ..     All the fields
191
    #[cfg_attr(
192
        feature = "cli",
193
        arg(
194
            short,
195
            long,
196
            default_value = "",
197
            help_heading = "Search",
198
            verbatim_doc_comment,
199
            value_delimiter = ',',
200
            allow_hyphen_values = true,
201
        )
202
    )]
203
    pub nth: Vec<String>,
204
205
    /// Fields to be transformed
206
    ///
207
    /// See **nth** for the details
208
    #[cfg_attr(
209
        feature = "cli",
210
        arg(
211
            long,
212
            default_value = "",
213
            help_heading = "Search",
214
            value_delimiter = ',',
215
            allow_hyphen_values = true,
216
        )
217
    )]
218
    pub with_nth: Vec<String>,
219
220
    /// Fields to hide from display while keeping them searchable
221
    ///
222
    /// Takes the same comma-separated field index expressions as **nth**. The listed
223
    /// fields are removed from the displayed line but remain part of the text used for
224
    /// matching, so a query can still match them. Characters in the hidden fields are
225
    /// ignored for match highlighting and horizontal scrolling.
226
    ///
227
    /// See **nth** for the field index expression syntax.
228
    #[cfg_attr(
229
        feature = "cli",
230
        arg(
231
            long,
232
            default_value = "",
233
            help_heading = "Search",
234
            verbatim_doc_comment,
235
            value_delimiter = ',',
236
            allow_hyphen_values = true,
237
        )
238
    )]
239
    pub hide_nth: Vec<String>,
240
241
    /// Delimiter between fields
242
    ///
243
    /// In regex format, defaults to AWK-style. Escape sequences like \x00, \t, \n are supported.
244
    #[cfg_attr(
245
        feature = "cli",
246
        arg(short, long, default_value = r"[\t\n ]+", value_parser = parse_delimiter_value, help_heading = "Search")
247
    )]
248
    pub delimiter: Regex,
249
250
    /// Run in exact mode
251
    #[cfg_attr(feature = "cli", arg(short, long, help_heading = "Search"))]
252
    pub exact: bool,
253
254
    /// Start in regex mode instead of fuzzy-match
255
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
256
    pub regex: bool,
257
258
    /// Fuzzy matching algorithm
259
    ///
260
    /// - arinae (ari) Latest algorithm
261
    ///
262
    /// - `skim_v2` Legacy skim algorithm
263
    ///
264
    /// - clangd  Used in clangd for keyword completion
265
    ///
266
    /// - fzy     Algorithm from fzy (<https://github.com/jhawthorn/fzy>)
267
    ///
268
    /// - frizbee Algorithm used in the blink.cmp neovim plugin
269
    #[cfg_attr(
270
        feature = "cli",
271
        arg(
272
            long = "algo",
273
            value_enum,
274
            default_value = "arinae",
275
            help_heading = "Search",
276
            verbatim_doc_comment
277
        )
278
    )]
279
    pub algorithm: FuzzyAlgorithm,
280
281
    /// Case sensitivity
282
    ///
283
    /// Determines whether or not to ignore case while matching
284
    /// Note: this is not used for the Frizbee matcher, which uses a penalty system to favor
285
    /// case-sensitivity without enforcing it
286
    #[cfg_attr(
287
        feature = "cli",
288
        arg(long, default_value = "smart", value_enum, help_heading = "Search")
289
    )]
290
    pub case: CaseMatching,
291
292
    /// Enable typo-tolerant matching
293
    ///
294
    /// When passed without a value (`--typos`), uses adaptive formula (`pattern_length` / 4).
295
    /// When passed with a value (e.g. `--typos=2`), uses that exact number as the
296
    /// maximum allowed typos. `--typos=0` explicitly disables typo tolerance.
297
    /// Applies to both fzy and frizbee matchers.
298
    #[cfg_attr(
299
        feature = "cli",
300
        arg(long, default_value = "disabled", default_missing_value = "smart", num_args = 0..=1, value_parser = parse_typos, overrides_with = "no_typos", help_heading = "Search")
301
    )]
302
    pub typos: Typos,
303
304
    /// Disable typo-tolerant matching
305
    #[cfg_attr(feature = "cli", arg(long, overrides_with = "typos", help_heading = "Search"))]
306
    pub no_typos: bool,
307
308
    /// Normalize unicode characters
309
    ///
310
    /// When set, normalize accents and other unicode diacritics/others
311
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
312
    pub normalize: bool,
313
314
    /// Enable split matching and set delimiter
315
    ///
316
    /// Split matching runs the matcher in splits: `foo:bar` will match all items matching `foo`, then
317
    /// `:`, then `bar` if the delimiter is present, or match normally if not.
318
    #[cfg_attr(
319
        feature = "cli",
320
        arg(
321
            long,
322
            default_missing_value = ":",
323
            help_heading = "Search",
324
            num_args=0..
325
        )
326
    )]
327
    pub split_match: Option<char>,
328
329
    /// Highlight the last match found, not the first one
330
    /// This makes tiebreak more pertinent on path items where we want to prioritize a match on the
331
    /// last parts
332
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
333
    pub last_match: bool,
334
335
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search", default_value = "default"))]
336
    scheme: Option<MatchScheme>,
337
338
    //  --- Interface ---
339
    /// Comma-separated key, event, and action bindings
340
    ///
341
    /// `--bind` takes comma-separated `<trigger>:<action>` expressions. A trigger can be a key, the
342
    /// `double-click` mouse binding, a finder event (`change`, `start`, `load`, `result`, `focus`, `zero`, or
343
    /// `one`), or an action name. Use the
344
    /// `act-` prefix for action triggers; it is recommended to avoid ambiguity and required when the action
345
    /// name is also a key, for example `act-up:last`. See the [KEYBINDS] section for details and its
346
    /// [Default key bindings] subsection for the defaults.
347
    ///
348
    /// **Example**: `sk --bind=ctrl-j:accept,load:last,act-up:down`
349
    ///
350
    /// ## Multiple actions can be chained using + separator.
351
    ///
352
    /// **Example**: `sk --bind 'ctrl-a:select-all+accept'`
353
    ///
354
    /// # Special behaviors
355
    ///
356
    /// With `execute(...)` and `reload(...)` action, you can execute arbitrary commands without leaving sk.
357
    /// For example, you can turn sk into a simple file browser by binding enter key to less command like follows:
358
    ///
359
    /// ```bash
360
    /// sk --bind "enter:execute(less {})"
361
    /// ```
362
    ///
363
    /// Note: if no argument is supplied to reload, the default command is run.
364
    ///
365
    /// You can use the same placeholder expressions as in --preview.
366
    ///
367
    /// `sk` switches to the alternate screen when executing a command. However, if the command is
368
    /// expected to complete quickly, and you are not interested in its output, you might want to use
369
    /// execute-silent instead, which silently executes the command without the  switching.  Note  that  sk
370
    /// will  not  be  responsive  until the command is complete. For asynchronous execution, start your
371
    /// command as a background process (i.e. appending `&`).
372
    ///
373
    /// With the `if-query-empty` and `if-query-not-empty` actions, you could specify the action to execute
374
    /// depending on the query condition. For example:
375
    ///
376
    /// `sk --bind 'ctrl-d:if-query-empty(abort)+delete-char'`
377
    ///
378
    /// If  the query is empty, skim will execute abort action, otherwise execute delete-char action. It
379
    /// is equal to 'delete-char/eof'.
380
    #[cfg_attr(
381
        feature = "cli",
382
        arg(short, long, help_heading = "Interface", verbatim_doc_comment, default_value = "", num_args=0..)
383
    )]
384
    pub bind: Vec<String>,
385
386
    /// Enable multiple selection
387
    ///
388
    /// Uses Tab and S-Tab by default for selection
389
    #[cfg_attr(
390
        feature = "cli",
391
        arg(short, long, overrides_with = "no_multi", help_heading = "Interface")
392
    )]
393
    pub multi: bool,
394
395
    /// Disable multiple selection
396
    #[cfg_attr(feature = "cli", arg(long, overrides_with = "multi", help_heading = "Interface"))]
397
    pub no_multi: bool,
398
399
    /// Disable mouse
400
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
401
    pub no_mouse: bool,
402
403
    /// Command to invoke dynamically in interactive mode
404
    ///
405
    /// Will be invoked using `sh -c` on Unix-like systems and `cmd /c` on Windows
406
    #[cfg_attr(feature = "cli", arg(short, long, help_heading = "Interface"))]
407
    pub cmd: Option<String>,
408
409
    /// Start skim in interactive mode
410
    ///
411
    /// In interactive mode, sk will run the command specified by `--cmd` option and display the
412
    /// results.
413
    #[cfg_attr(feature = "cli", arg(short, long, help_heading = "Interface"))]
414
    pub interactive: bool,
415
416
    /// Replace replstr with the selected item in commands
417
    #[cfg_attr(feature = "cli", arg(short = 'I', default_value = "{}", help_heading = "Interface"))]
418
    pub replstr: String,
419
420
    /// Set color theme
421
    ///
422
    /// Format: [BASE][,COLOR:ANSI[:ATTR1:ATTR2:..]]
423
    /// See [THEME] section for details
424
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface", verbatim_doc_comment))]
425
    pub color: Option<String>,
426
427
    /// Highlight the entire current line, not just the text
428
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface", verbatim_doc_comment))]
429
    pub highlight_line: bool,
430
431
    /// Disable horizontal scroll
432
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
433
    pub no_hscroll: bool,
434
435
    /// Keep the right end of the line visible on overflow
436
    ///
437
    /// Effective only when the query string is empty
438
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
439
    pub keep_right: bool,
440
441
    /// Show the matched pattern at the line start
442
    ///
443
    /// Line  will  start  with  the  start of the matched pattern. Effective only when the query
444
    /// string is empty. Was designed to skip showing starts of paths of rg/grep results.
445
    ///
446
    /// e.g. sk -i -c "rg {q} --color=always" --skip-to-pattern '[^/]*:' --ansi
447
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface", verbatim_doc_comment))]
448
    pub skip_to_pattern: Option<String>,
449
450
    /// Do not clear previous line if the command returns an empty result
451
    ///
452
    /// Do not clear previous items if new command returns empty result. This might be useful  to
453
    /// reduce flickering when typing new commands and the half-complete commands are not valid.
454
    ///
455
    /// This is not the default behavior because similar use cases for `grep` and `rg` have already been
456
    /// optimized where empty query results actually mean "empty" and previous results should be
457
    /// cleared.
458
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface", verbatim_doc_comment))]
459
    pub no_clear_if_empty: bool,
460
461
    /// Do not clear items on start
462
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
463
    pub no_clear_start: bool,
464
465
    /// Do not clear screen on exit
466
    ///
467
    /// Do not clear finder interface on exit. If skim was started in full screen mode, it will not switch back to the
468
    /// original  screen, so you'll have to manually run tput rmcup to return. This option can be used to avoid
469
    /// flickering of the screen when your application needs to start skim multiple times in order.
470
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
471
    pub no_clear: bool,
472
473
    /// Show error message if command fails
474
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
475
    pub show_cmd_error: bool,
476
477
    /// Cycle the results by wrapping around when scrolling
478
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
479
    pub cycle: bool,
480
481
    /// Disable matching entirely
482
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
483
    pub disabled: bool,
484
485
    /// Disable items based on this regex pattern
486
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
487
    pub disable_pattern: Option<Regex>,
488
489
    //  --- Layout ---
490
    /// Set layout
491
    ///
492
    #[cfg_attr(
493
        feature = "cli",
494
        arg(long, help_heading = "Layout", verbatim_doc_comment, default_value = "default")
495
    )]
496
    pub layout: TuiLayout,
497
498
    /// Shorthand for reverse layout
499
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Layout", overrides_with = "layout"))]
500
    pub reverse: bool,
501
502
    /// Height of skim's window
503
    ///
504
    /// Can either be a row count or a percentage
505
    /// A negative row count will use `term height` - `value` as height
506
    #[cfg_attr(
507
        feature = "cli",
508
        arg(long, default_value = "100%", help_heading = "Layout", allow_hyphen_values = true)
509
    )]
510
    pub height: String,
511
512
    /// Disable height (force full screen)
513
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Layout"))]
514
    pub no_height: bool,
515
516
    /// Minimum height of skim's window as a non-negative row count
517
    ///
518
    /// Must be a non-negative row count, not a percentage.
519
    /// Useful when the height is set as a percentage.
520
    /// Ignored when --height is not specified.
521
    #[cfg_attr(
522
        feature = "cli",
523
        arg(
524
            long,
525
            default_value = "10",
526
            help_heading = "Layout",
527
            allow_hyphen_values = true,
528
            value_parser = parse_min_height_value,
529
            verbatim_doc_comment
530
        )
531
    )]
532
    pub min_height: String,
533
534
    /// Screen margin
535
    ///
536
    /// For each side, can be either a row count or a percentage of the terminal size
537
    ///
538
    /// Format can be one of:
539
    ///     - TRBL
540
    ///     - TB,RL
541
    ///     - T,RL,B
542
    ///     - T,R,B,L
543
    /// Example: 1,10%
544
    #[cfg_attr(
545
        feature = "cli",
546
        arg(long, default_value = "0", help_heading = "Layout", verbatim_doc_comment)
547
    )]
548
    pub margin: String,
549
550
    /// Set prompt
551
    #[cfg_attr(feature = "cli", arg(long, short, default_value = "> ", help_heading = "Layout"))]
552
    pub prompt: String,
553
554
    /// Set prompt in command mode
555
    #[cfg_attr(feature = "cli", arg(long, default_value = "c> ", help_heading = "Layout"))]
556
    pub cmd_prompt: String,
557
558
    /// Set selected item icon
559
    #[cfg_attr(
560
        feature = "cli",
561
        arg(long = "selector", alias = "pointer", default_value = ">", help_heading = "Layout")
562
    )]
563
    pub selector_icon: String,
564
565
    /// Set multi-selected item icon
566
    #[cfg_attr(
567
        feature = "cli",
568
        arg(
569
            long = "multi-selector",
570
            alias = "marker",
571
            default_value = ">",
572
            help_heading = "Layout"
573
        )
574
    )]
575
    pub multi_select_icon: String,
576
577
    //  --- Display ---
578
    /// Parse ANSI color codes in input strings
579
    ///
580
    /// When using skim as a library, this has no effect and ansi parsing should
581
    /// be enabled by manually injecting a `cmd_collector` like so:
582
    /// ```rust
583
    /// use skim::prelude::*;
584
    ///
585
    /// let _options = SkimOptionsBuilder::default()
586
    ///   .cmd("ls --color")
587
    ///   .cmd_collector(Rc::new(RefCell::new(SkimItemReader::new(
588
    ///     SkimItemReaderOption::default().ansi(true),
589
    ///     ))) as Rc<RefCell<dyn CommandCollector>>)
590
    ///   .build()
591
    ///   .unwrap();
592
    /// ```
593
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
594
    pub ansi: bool,
595
596
    /// Number of spaces that make up a tab
597
    #[cfg_attr(feature = "cli", arg(long, default_value = "8", help_heading = "Display"))]
598
    pub tabstop: usize,
599
600
    /// The characters used to display truncated lines
601
    #[cfg_attr(
602
        feature = "cli",
603
        arg(long, hide = true, allow_hyphen_values = true, default_value = "...")
604
    )]
605
    pub ellipsis: String,
606
607
    /// Set matching result count display position
608
    ///
609
    ///   - hidden  do not display info
610
    ///   - inline[:SEP]  display info in the same row as the input with an optional non-default
611
    ///     separator
612
    ///   - default  display info in a dedicated row above the input
613
    ///   - left  display all info left-aligned in a dedicated row above the input
614
    ///   - right  display all info right-aligned in a dedicated row above the input
615
    ///   - inline-right[:SEP]  display info right-aligned in the same row as the input with an optional
616
    ///     non-default separator
617
    #[cfg_attr(
618
        feature = "cli",
619
        arg(long, help_heading = "Display", default_value = "default", verbatim_doc_comment)
620
    )]
621
    pub info: Info,
622
623
    /// Alias for --info=hidden
624
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
625
    pub no_info: bool,
626
627
    /// Alias for --info=inline
628
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
629
    pub inline_info: bool,
630
631
    /// Set header, displayed next to the info
632
    ///
633
    /// The  given  string  will  be printed as the sticky header. The lines are displayed in the
634
    /// given order from top to bottom regardless of --layout option, and  are  not  affected  by
635
    /// --with-nth. ANSI color codes are processed even when --ansi is not set.
636
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
637
    pub header: Option<String>,
638
639
    /// Number of lines of the input treated as header
640
    ///
641
    /// The  first N lines of the input are treated as the sticky header. When `--with-nth` is set,
642
    /// the lines are transformed just like the other lines that follow.
643
    #[cfg_attr(feature = "cli", arg(long, default_value = "0", help_heading = "Display"))]
644
    pub header_lines: usize,
645
646
    /// Draw borders around the UI components
647
    ///
648
    #[cfg_attr(
649
        feature = "cli",
650
        arg(long, default_missing_value = "plain", help_heading = "Display", default_value = "none", num_args=0.., value_parser = BorderValueParser)
651
    )]
652
    pub border: BorderType,
653
654
    /// Do not collapse adjacent borders into a shared row or column
655
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
656
    pub border_no_collapse: bool,
657
658
    /// Disables all borders, including in tmux/zellij popups
659
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display", overrides_with = "border"))]
660
    pub no_border: bool,
661
662
    /// Wrap items in the item list
663
    #[cfg_attr(feature = "cli", arg(long = "wrap", help_heading = "Display"))]
664
    pub wrap_items: bool,
665
666
    /// Split item text into multiple display lines at the given separator character
667
    /// defaults to `\n` if `read0` is set, and `\\n` if not (matching literal `\n` in text)
668
    ///
669
    /// Each item's text will be split on the separator and each part will be
670
    /// displayed as a separate line within that item's row.
671
    #[cfg_attr(
672
        feature = "cli",
673
        arg(
674
            long = "multiline",
675
            help_heading = "Display",
676
            num_args = 0..=1
677
        )
678
    )]
679
    pub multiline: Option<Option<String>>,
680
681
    /// Set scrollbar style for the item list
682
    ///
683
    /// The optional value is used as the indicator
684
    #[cfg_attr(
685
        feature = "cli",
686
        arg(
687
            long,
688
            help_heading = "Display",
689
            value_name = "THUMB",
690
            overrides_with = "no_scrollbar",
691
            default_value = "▐",
692
            verbatim_doc_comment
693
        )
694
    )]
695
    pub scrollbar: String,
696
    /// Disable the scrollbar in the item list
697
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
698
    pub no_scrollbar: bool,
699
700
    //  --- History ---
701
    /// History file
702
    ///
703
    /// Load search history from the specified file and update the file on completion.
704
    ///
705
    /// When enabled, CTRL-N and CTRL-P are automatically remapped
706
    /// to next-history and previous-history.
707
    #[cfg_attr(feature = "cli", arg(long = "history", help_heading = "History"))]
708
    pub history_file: Option<String>,
709
710
    /// Maximum number of query history entries to keep
711
    #[cfg_attr(feature = "cli", arg(long, default_value = "1000", help_heading = "History"))]
712
    pub history_size: usize,
713
714
    /// Command history file
715
    ///
716
    /// Load command query history from the specified file and update the file on completion.
717
    ///
718
    /// When enabled, CTRL-N and CTRL-P are automatically remapped
719
    /// to next-history and previous-history.
720
    #[cfg_attr(feature = "cli", arg(long = "cmd-history", help_heading = "History"))]
721
    pub cmd_history_file: Option<String>,
722
723
    /// Maximum number of query history entries to keep
724
    #[cfg_attr(feature = "cli", arg(long, default_value = "1000", help_heading = "History"))]
725
    pub cmd_history_size: usize,
726
727
    //  --- Preview ---
728
    /// Preview command
729
    ///
730
    /// Execute the given command with `sh -c` on linux and `cmd /c` on windows for the current line and display the result on the preview window.
731
    /// `{}` in the command is the placeholder that is replaced to the single-quoted string of the current line.
732
    /// To transform the replacement string, specify field index expressions between the braces (See FIELD INDEX EXPRESSION for the details).
733
    ///
734
    /// **Examples**:
735
    ///
736
    /// ```bash
737
    /// sk --preview='head -$LINES {}'
738
    /// ls -l | sk --preview="echo user={3} when={-4..-2}; cat {-1}" --header-lines=1
739
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Preview", verbatim_doc_comment))]
740
    pub preview: Option<String>,
741
742
    /// Preview window layout
743
    ///
744
    /// Format: [up|down|left|right][:SIZE][:hidden][:[no]wrap][:[no]pty][:+SCROLL[-OFFSET]]
745
    ///
746
    /// Determine  the  layout of the preview window. If the argument ends with: hidden, the preview window will be hidden by
747
    /// default until toggle-preview action is triggered. Long lines are truncated by default.
748
    /// Line wrap can be enabled with `:wrap` flag.
749
    /// For more interactive commands or previews that draw complex interfaces, the preview can use a PTY with the `:pty` flag.
750
    ///
751
    /// Note: the preview will run in a PTY (interactive session) on Linux and when `wrap` is unset
752
    ///
753
    /// SIZE can be either:
754
    ///     - `0`, which will hide the preview window
755
    ///     - A positive size (eg `20`)
756
    ///     - A percentage of the total size (eg `50%`)
757
    ///     - A negative size, which will set the size of everything but the preview to that value
758
    ///
759
    /// +SCROLL[-OFFSET] determines the initial scroll offset of the preview window. SCROLL can be either a numeric integer
760
    /// or a single-field index expression that refers to a numeric integer. The optional -OFFSET part is for adjusting the
761
    /// base offset so that you can see the text above it. It should be given as a numeric integer (-INTEGER), or as a
762
    /// denominator form (-/INTEGER) for specifying a fraction of the preview window height.
763
    ///
764
    /// **Examples**:
765
    /// ```bash
766
    /// # Non-default scroll window positions and sizes
767
    /// sk --preview="head {}" --preview-window=up:30%
768
    /// sk --preview="file {}" --preview-window=down:2
769
    ///
770
    /// # Initial scroll offset is set to the line number of each line of
771
    /// # git grep output *minus* 5 lines (-5)
772
    /// git grep --line-number '' |
773
    ///   sk --delimiter:  --preview 'nl {1}' --preview-window +{2}-5
774
    ///
775
    ///             # Preview with bat, matching line in the middle of the window (-/2)
776
    ///             git grep --line-number '' |
777
    ///               sk --delimiter : \
778
    ///                   --preview 'bat --style=numbers --color=always --highlight-line {2} {1}' \
779
    ///                   --preview-window +{2}-/2
780
    /// ```
781
    #[cfg_attr(
782
        feature = "cli",
783
        arg(
784
            long,
785
            default_value = "right:50%",
786
            help_heading = "Preview",
787
            allow_hyphen_values = true
788
        )
789
    )]
790
    pub preview_window: PreviewLayout,
791
792
    /// Enable image preview
793
    ///
794
    /// This will render the preview argument as an image instead of running it as a command.
795
    ///
796
    /// If set to `detect` or if no value is passed, it will try to detect the available image backends at startup, which will add a small
797
    /// delay before the first render.
798
    /// If set to `halfblocks`, it will always use the `halfblocks` rendering method
799
    ///
800
    /// Note: the backend detection **will not** work when piping data into skim, use
801
    /// `SKIM_DEFAULT_COMMAND="find . -type f" sk --image` instead of `find . -type f | sk --image`
802
    #[cfg(feature = "image")]
803
    #[cfg_attr(
804
        feature = "cli",
805
        arg(long, help_heading = "Preview", value_enum, default_missing_value = "detect", num_args=0..)
806
    )]
807
    pub image: Option<ImageProtocol>,
808
809
    /// Terminal image protocol picker, queried after entering the alternate screen.
810
    /// Built from `options.image` and an stdio detection if needed
811
    #[cfg(feature = "image")]
812
    #[cfg_attr(feature = "cli", clap(skip))]
813
    #[builder(setter(skip))]
814
    #[debug(skip)]
815
    pub image_picker: Option<Picker>,
816
817
    //  --- Scripting ---
818
    /// Initial query
819
    #[cfg_attr(feature = "cli", arg(long, short, help_heading = "Scripting"))]
820
    pub query: Option<String>,
821
822
    /// Initial query in interactive mode
823
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
824
    pub cmd_query: Option<String>,
825
826
    /// Read input delimited by ASCII NUL(\\0) characters
827
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
828
    pub read0: bool,
829
830
    /// Print output delimited by ASCII NUL(\\0) characters
831
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
832
    pub print0: bool,
833
834
    /// Print the query as the first line
835
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
836
    pub print_query: bool,
837
838
    /// Print the command as the first line (after print-query)
839
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
840
    pub print_cmd: bool,
841
842
    /// Print the score after each item
843
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
844
    pub print_score: bool,
845
846
    /// Print the header as the first line (after print-score)
847
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
848
    pub print_header: bool,
849
850
    /// Print the current (highlighted) item as the first line (after print-header)
851
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
852
    pub print_current: bool,
853
854
    /// Set the output format
855
    /// If set, overrides all `print_` options
856
    /// Will be expanded the same way as preview or commands
857
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
858
    pub output_format: Option<String>,
859
860
    /// Print the ANSI codes, making the output exactly match the input even when `--ansi` is on
861
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", requires = "ansi"))]
862
    pub no_strip_ansi: bool,
863
864
    /// Do not enter the TUI if the query passed in `-q` matches only one item and return it
865
    #[cfg_attr(feature = "cli", arg(long, short = '1', help_heading = "Scripting"))]
866
    pub select_1: bool,
867
868
    /// Do not enter the TUI if the query passed in `-q` does not match any item
869
    #[cfg_attr(feature = "cli", arg(long, short = '0', help_heading = "Scripting"))]
870
    pub exit_0: bool,
871
872
    /// Synchronous search for multi-staged filtering
873
    ///
874
    /// Synchronous search for multi-staged filtering. If specified,
875
    /// `skim` will launch the TUI finder only after the input stream is complete.
876
    /// e.g. `sk --multi | sk --sync`
877
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
878
    pub sync: bool,
879
880
    /// Pre-select the first n items in multi-selection mode
881
    #[cfg_attr(feature = "cli", arg(long, default_value = "0", help_heading = "Scripting"))]
882
    pub pre_select_n: usize,
883
884
    /// Pre-select the matched items in multi-selection mode
885
    ///
886
    /// Check the doc for the detailed syntax:
887
    /// <https://docs.rs/regex/1.4.1/regex>/
888
    #[cfg_attr(feature = "cli", arg(long, default_value = "", help_heading = "Scripting"))]
889
    pub pre_select_pat: String,
890
891
    /// Pre-select the items separated by newline character
892
    ///
893
    /// Example: 'item1\nitem2'
894
    #[cfg_attr(feature = "cli", arg(long, default_value = "", help_heading = "Scripting"))]
895
    pub pre_select_items: String,
896
897
    /// Pre-select the items read from this file
898
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
899
    pub pre_select_file: Option<String>,
900
901
    /// Query for filter mode
902
    #[cfg_attr(feature = "cli", arg(long, short, help_heading = "Scripting"))]
903
    pub filter: Option<String>,
904
905
    /// Generate shell completion script
906
    ///
907
    /// Generate completion script for the specified shell: bash, zsh, fish, etc.
908
    /// The output can be directly sourced or saved to a file for automatic loading.
909
    /// Examples: `source <(sk --shell bash)` (immediate use)
910
    ///          `sk --shell bash >> ~/.bash_completion` (persistent use)
911
    ///
912
    /// Supported shells: bash, zsh, fish, powershell, elvish
913
    #[cfg(feature = "cli")]
914
    #[cfg_attr(
915
        feature = "cli",
916
        arg(long, value_name = "SHELL", help_heading = "Scripting", value_enum)
917
    )]
918
    pub shell: Option<crate::shell::Shell>,
919
920
    /// Generate shell key bindings - only for bash, zsh and fish
921
    ///
922
    /// Generate key bindings script after the shell completions
923
    /// See the `shell` option for more details
924
    #[cfg(feature = "cli")]
925
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", requires = "shell"))]
926
    pub shell_bindings: bool,
927
928
    /// Generate man page and output it to stdout
929
    #[cfg(feature = "cli")]
930
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
931
    pub man: bool,
932
933
    /// Run an IPC socket with optional name (defaults to `sk`)
934
    ///
935
    /// The socket expects Actions in Ron format (similar to Rust code), see `./src/tui/event.rs` for all possible Actions
936
    /// To write to it, see the `--remote` option or the man page
937
    #[cfg(feature = "listen")]
938
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", default_missing_value = "sk", num_args=0..))]
939
    pub listen: Option<String>,
940
941
    /// Send commands to an IPC socket with optional name (defaults to `sk`)
942
    ///
943
    /// The commands are read from stdin, one per line, in the same format as the actions in the
944
    /// bind flag. They can also be chained using `+` as a separator.
945
    /// All other arguments will be ignored
946
    #[cfg(feature = "listen")]
947
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", default_missing_value = "sk", num_args=0..))]
948
    pub remote: Option<String>,
949
950
    /// Run in a tmux or zellij popup
951
    ///
952
    /// Format: `sk --popup <center|top|bottom|left|right>[,SIZE[%]][,SIZE[%]]`
953
    /// Note: this will try to detect a Zellij session, then a Tmux session
954
    /// This means that in nested sessions, `skim` will prioritize Zellij over Tmux
955
    #[cfg_attr(feature = "cli", arg(long, verbatim_doc_comment, help_heading = "Display", default_missing_value = "center,50%", num_args=0.., alias = "tmux"))]
956
    pub popup: Option<String>,
957
958
    /// Set the log level
959
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
960
    pub log_level: Option<log::LevelFilter>,
961
962
    /// Pipe log output to a file
963
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
964
    pub log_file: Option<String>,
965
966
    /// Feature flags
967
    #[cfg_attr(feature = "cli", arg(long, hide = true, help_heading = "Scripting"))]
968
    pub flags: Vec<FeatureFlag>,
969
970
    // FZF compatibility args
971
    #[cfg_attr(feature = "cli", arg(short = 'x', long, hide = true))]
972
    #[builder(setter(skip))]
973
    extended: bool,
974
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
975
    #[builder(setter(skip))]
976
    literal: bool,
977
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = "10"))]
978
    #[builder(setter(skip))]
979
    hscroll_off: usize,
980
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
981
    #[builder(setter(skip))]
982
    filepath_word: bool,
983
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = ""))]
984
    #[builder(setter(skip))]
985
    jump_labels: String,
986
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
987
    #[builder(setter(skip))]
988
    no_bold: bool,
989
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
990
    #[builder(setter(skip))]
991
    phony: bool,
992
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
993
    #[builder(setter(skip))]
994
    tail: Option<usize>,
995
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
996
    #[builder(setter(skip))]
997
    style: Option<String>,
998
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
999
    #[builder(setter(skip))]
1000
    no_color: bool,
1001
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1002
    #[builder(setter(skip))]
1003
    padding: Option<String>,
1004
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1005
    #[builder(setter(skip))]
1006
    border_label: Option<String>,
1007
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1008
    #[builder(setter(skip))]
1009
    border_label_pos: Option<String>,
1010
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1011
    #[builder(setter(skip))]
1012
    wrap_sign: Option<String>,
1013
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1014
    #[builder(setter(skip))]
1015
    no_multi_line: bool,
1016
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1017
    #[builder(setter(skip))]
1018
    raw: bool,
1019
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1020
    #[builder(setter(skip))]
1021
    track: bool,
1022
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1023
    #[builder(setter(skip))]
1024
    gap: Option<usize>,
1025
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1026
    #[builder(setter(skip))]
1027
    gap_line: Option<String>,
1028
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = "0"))]
1029
    #[builder(setter(skip))]
1030
    freeze_left: usize,
1031
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = "0"))]
1032
    #[builder(setter(skip))]
1033
    freeze_right: usize,
1034
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = "0"))]
1035
    #[builder(setter(skip))]
1036
    scroll_off: usize,
1037
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1038
    #[builder(setter(skip))]
1039
    gutter: Option<String>,
1040
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1041
    #[builder(setter(skip))]
1042
    gutter_raw: Option<String>,
1043
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1044
    #[builder(setter(skip))]
1045
    marker_multi_line: Option<String>,
1046
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1047
    #[builder(setter(skip))]
1048
    list_border: Option<String>,
1049
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1050
    #[builder(setter(skip))]
1051
    list_label: Option<String>,
1052
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1053
    #[builder(setter(skip))]
1054
    list_label_pos: Option<String>,
1055
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1056
    #[builder(setter(skip))]
1057
    no_input: bool,
1058
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1059
    #[builder(setter(skip))]
1060
    info_command: Option<String>,
1061
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1062
    #[builder(setter(skip))]
1063
    separator: Option<String>,
1064
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1065
    #[builder(setter(skip))]
1066
    no_separator: bool,
1067
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1068
    #[builder(setter(skip))]
1069
    ghost: Option<String>,
1070
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1071
    #[builder(setter(skip))]
1072
    input_border: Option<String>,
1073
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1074
    #[builder(setter(skip))]
1075
    input_label: Option<String>,
1076
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1077
    #[builder(setter(skip))]
1078
    input_label_pos: Option<String>,
1079
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1080
    #[builder(setter(skip))]
1081
    preview_label: Option<String>,
1082
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1083
    #[builder(setter(skip))]
1084
    preview_label_pos: Option<String>,
1085
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1086
    #[builder(setter(skip))]
1087
    header_first: bool,
1088
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1089
    #[builder(setter(skip))]
1090
    header_border: Option<String>,
1091
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1092
    #[builder(setter(skip))]
1093
    header_lines_border: Option<String>,
1094
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1095
    #[builder(setter(skip))]
1096
    footer: Option<String>,
1097
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1098
    #[builder(setter(skip))]
1099
    footer_border: Option<String>,
1100
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1101
    #[builder(setter(skip))]
1102
    footer_label: Option<String>,
1103
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1104
    #[builder(setter(skip))]
1105
    footer_label_pos: Option<String>,
1106
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1107
    #[builder(setter(skip))]
1108
    with_shell: Option<String>,
1109
1110
    /// Deprecated, kept for compatibility purposes. See `accept()` bind instead.
1111
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Deprecated", default_value = ""))]
1112
    expect: String,
1113
1114
    /// Command collector for reading items from commands
1115
    #[cfg_attr(feature = "cli", clap(skip = Rc::new(RefCell::new(SkimItemReader::default())) as Rc<RefCell<dyn CommandCollector>>))]
1116
    #[builder(setter(into = false))]
1117
    #[debug(skip)]
1118
    pub cmd_collector: Rc<RefCell<dyn CommandCollector>>,
1119
    /// Query history entries loaded from history file
1120
    #[cfg_attr(feature = "cli", clap(skip))]
1121
    pub query_history: Vec<String>,
1122
    /// Command history entries loaded from cmd history file
1123
    #[cfg_attr(feature = "cli", clap(skip))]
1124
    pub cmd_history: Vec<String>,
1125
    /// Selector for pre-selecting items
1126
    #[cfg_attr(feature = "cli", clap(skip))]
1127
    #[builder(setter(into = false))]
1128
    #[debug(skip)]
1129
    pub selector: Option<Rc<dyn Selector>>,
1130
    /// Preview Callback
1131
    ///
1132
    /// Used to define a function or closure for the preview window, instead of a shell command.
1133
    ///
1134
    /// The function will take a `Vec<Arc<dyn SkimItem>>>` containing the currently selected items
1135
    /// and return a Vec<String> with the lines to display in UTF-8
1136
    #[cfg_attr(feature = "cli", clap(skip))]
1137
    #[debug(skip)]
1138
    pub preview_fn: Option<PreviewCallback>,
1139
1140
    /// The internal (parsed) keymap
1141
    #[cfg_attr(feature = "cli", clap(skip))]
1142
    pub keymap: KeyMap,
1143
1144
    /// Follow-up action bindings, keyed by the canonical action name.
1145
    ///
1146
    /// Populated from `--bind` entries whose "key" is an action name rather than
1147
    /// a real key (e.g. `reload:first`). After an action runs, the chain bound to
1148
    /// its name is queued.
1149
    #[cfg_attr(feature = "cli", clap(skip))]
1150
    pub action_binds: std::collections::HashMap<String, Vec<Action>>,
1151
}
1152
1153
impl Default for SkimOptions {
1154
    #[allow(clippy::too_many_lines)]
1155
395
    fn default() -> Self {
1156
395
        Self {
1157
395
            split_match: None,
1158
395
            no_strip_ansi: false,
1159
395
            wrap_items: false,
1160
395
            multiline: None,
1161
395
            #[cfg(feature = "listen")]
1162
395
            listen: None,
1163
395
            #[cfg(feature = "listen")]
1164
395
            remote: None,
1165
395
            print_header: false,
1166
395
            print_current: false,
1167
395
            disabled: false,
1168
395
            disable_pattern: None,
1169
395
            tac: Default::default(),
1170
395
            min_query_length: Default::default(),
1171
395
            no_sort: Default::default(),
1172
395
            tiebreak: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End],
1173
395
            nth: Default::default(),
1174
395
            with_nth: Default::default(),
1175
395
            hide_nth: Default::default(),
1176
395
            delimiter: Regex::new(r"[\t\n ]+").unwrap(),
1177
395
            exact: Default::default(),
1178
395
            regex: Default::default(),
1179
395
            algorithm: Default::default(),
1180
395
            case: Default::default(),
1181
395
            typos: Typos::Disabled,
1182
395
            no_typos: false,
1183
395
            normalize: false,
1184
395
            last_match: false,
1185
395
            bind: Default::default(),
1186
395
            multi: Default::default(),
1187
395
            no_multi: Default::default(),
1188
395
            no_mouse: Default::default(),
1189
395
            cmd: Default::default(),
1190
395
            interactive: Default::default(),
1191
395
            replstr: String::from("{}"),
1192
395
            color: Default::default(),
1193
395
            no_hscroll: Default::default(),
1194
395
            keep_right: Default::default(),
1195
395
            skip_to_pattern: Default::default(),
1196
395
            no_clear_if_empty: Default::default(),
1197
395
            no_clear_start: Default::default(),
1198
395
            no_clear: Default::default(),
1199
395
            show_cmd_error: Default::default(),
1200
395
            layout: TuiLayout::default(),
1201
395
            reverse: Default::default(),
1202
395
            height: String::from("100%"),
1203
395
            no_height: Default::default(),
1204
395
            min_height: String::from("10"),
1205
395
            margin: Default::default(),
1206
395
            prompt: String::from("> "),
1207
395
            cmd_prompt: String::from("c> "),
1208
395
            selector_icon: String::from(">"),
1209
395
            multi_select_icon: String::from(">"),
1210
395
            ansi: Default::default(),
1211
395
            tabstop: 8,
1212
395
            info: Default::default(),
1213
395
            no_info: Default::default(),
1214
395
            inline_info: Default::default(),
1215
395
            header: Default::default(),
1216
395
            header_lines: Default::default(),
1217
395
            history_file: Default::default(),
1218
395
            history_size: 1000,
1219
395
            cmd_history_file: Default::default(),
1220
395
            cmd_history_size: 1000,
1221
395
            preview: Default::default(),
1222
395
            preview_window: PreviewLayout::default(),
1223
395
            #[cfg(feature = "image")]
1224
395
            image: None,
1225
395
            #[cfg(feature = "image")]
1226
395
            image_picker: None,
1227
395
            query: Default::default(),
1228
395
            cmd_query: Default::default(),
1229
395
            read0: Default::default(),
1230
395
            print0: Default::default(),
1231
395
            print_query: Default::default(),
1232
395
            print_cmd: Default::default(),
1233
395
            print_score: Default::default(),
1234
395
            output_format: Default::default(),
1235
395
            select_1: Default::default(),
1236
395
            exit_0: Default::default(),
1237
395
            sync: Default::default(),
1238
395
            pre_select_n: Default::default(),
1239
395
            pre_select_pat: Default::default(),
1240
395
            pre_select_items: Default::default(),
1241
395
            pre_select_file: Default::default(),
1242
395
            filter: Default::default(),
1243
395
            popup: Default::default(),
1244
395
            log_file: Default::default(),
1245
395
            extended: Default::default(),
1246
395
            literal: Default::default(),
1247
395
            cycle: Default::default(),
1248
395
            hscroll_off: 10,
1249
395
            filepath_word: Default::default(),
1250
395
            jump_labels: String::from("abcdefghijklmnopqrstuvwxyz"),
1251
395
            border: Default::default(),
1252
395
            border_no_collapse: Default::default(),
1253
395
            no_bold: Default::default(),
1254
395
            phony: Default::default(),
1255
395
            scheme: Default::default(),
1256
395
            tail: Default::default(),
1257
395
            style: Default::default(),
1258
395
            no_color: Default::default(),
1259
395
            padding: Default::default(),
1260
395
            border_label: Default::default(),
1261
395
            border_label_pos: Default::default(),
1262
395
            highlight_line: Default::default(),
1263
395
            wrap_sign: Default::default(),
1264
395
            no_multi_line: Default::default(),
1265
395
            raw: Default::default(),
1266
395
            track: Default::default(),
1267
395
            gap: Default::default(),
1268
395
            gap_line: Default::default(),
1269
395
            freeze_left: Default::default(),
1270
395
            freeze_right: Default::default(),
1271
395
            scroll_off: Default::default(),
1272
395
            gutter: Default::default(),
1273
395
            gutter_raw: Default::default(),
1274
395
            marker_multi_line: Default::default(),
1275
395
            ellipsis: Default::default(),
1276
395
            scrollbar: Default::default(),
1277
395
            no_scrollbar: Default::default(),
1278
395
            list_border: Default::default(),
1279
395
            list_label: Default::default(),
1280
395
            list_label_pos: Default::default(),
1281
395
            no_input: Default::default(),
1282
395
            info_command: Default::default(),
1283
395
            separator: Default::default(),
1284
395
            no_separator: Default::default(),
1285
395
            ghost: Default::default(),
1286
395
            input_border: Default::default(),
1287
395
            input_label: Default::default(),
1288
395
            input_label_pos: Default::default(),
1289
395
            preview_label: Default::default(),
1290
395
            preview_label_pos: Default::default(),
1291
395
            header_first: Default::default(),
1292
395
            header_border: Default::default(),
1293
395
            header_lines_border: Default::default(),
1294
395
            footer: Default::default(),
1295
395
            footer_border: Default::default(),
1296
395
            footer_label: Default::default(),
1297
395
            footer_label_pos: Default::default(),
1298
395
            with_shell: Default::default(),
1299
395
            expect: Default::default(),
1300
395
            cmd_collector: Rc::new(RefCell::new(SkimItemReader::default())) as Rc<RefCell<dyn CommandCollector>>,
1301
395
            query_history: Default::default(),
1302
395
            cmd_history: Default::default(),
1303
395
            selector: Default::default(),
1304
395
            preview_fn: Default::default(),
1305
395
            keymap: Default::default(),
1306
395
            action_binds: Default::default(),
1307
395
            #[cfg(feature = "cli")]
1308
395
            shell: Default::default(),
1309
395
            #[cfg(feature = "cli")]
1310
395
            man: false,
1311
395
            #[cfg(feature = "cli")]
1312
395
            shell_bindings: false,
1313
395
            flags: Default::default(),
1314
395
            log_level: Default::default(),
1315
395
            no_border: false,
1316
395
        }
1317
395
    }
1318
}
1319
1320
impl SkimOptionsBuilder {
1321
    /// Builds the `SkimOptions` from the builder
1322
    ///
1323
    /// # Errors
1324
    ///
1325
    /// Returns an error if any required fields are missing.
1326
66
    pub fn build(&mut self) -> Result<SkimOptions, SkimOptionsBuilderError> {
1327
66
        self.final_build().map(SkimOptions::build)
1328
66
    }
1329
}
1330
1331
impl SkimOptions {
1332
    /// Finalizes the options by applying defaults and initializing components
1333
    #[must_use]
1334
551
    pub fn build(mut self) -> Self {
1335
551
        if self.no_height {
  Branch (1335:12): [True: 0, False: 455]
+
88
462
        Ok(Typos::Disabled)
89
    } else {
90
0
        s.parse::<usize>()
91
0
            .map(Typos::from)
92
0
            .map_err(|_| format!("Invalid typos value '{s}': expected 'smart', 'disabled' or a non-negative integer"))
93
    }
94
465
}
95
96
/// The options for `--scheme`
97
#[derive(Debug, Clone, Default, PartialEq, Eq)]
98
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
99
pub enum MatchScheme {
100
    /// Default scheme, no modifications to the options
101
    #[default]
102
    Default,
103
    /// Path scheme: will find the furthest match in the item and set `pathname` as the main
104
    /// tiebreak
105
    Path,
106
    /// History scheme: will force `index` as the first tiebreak
107
    History,
108
}
109
110
/// Image rendering protocols
111
#[cfg(feature = "image")]
112
#[derive(Default, Debug, Clone, PartialEq, Eq)]
113
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
114
pub enum ImageProtocol {
115
    /// Default: automatically detect the available backend at startup
116
    #[default]
117
    Detect,
118
    /// Force halfblocks if you want blurry previews but a faster startup or if the detection fails
119
    Halfblocks,
120
}
121
122
/// sk - fuzzy finder in Rust
123
///
124
/// sk is a general purpose command-line fuzzy finder.
125
#[allow(missing_docs, clippy::struct_excessive_bools)] // derive_builder seems to have issues with doc comments ?
126
#[derive(Builder)]
127
#[builder(build_fn(name = "final_build"), setter(into, strip_option))]
128
#[builder(default)]
129
#[cfg_attr(feature = "cli", derive(clap::Parser))]
130
#[cfg_attr(
131
    feature = "cli",
132
    command(name = "sk", args_override_self = true, verbatim_doc_comment, version, about)
133
)]
134
#[derive(derive_more::Debug)]
135
pub struct SkimOptions {
136
    //  --- Search ---
137
    /// Show results in reverse order
138
    ///
139
    /// Often used in combination with --no-sort
140
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
141
    pub tac: bool,
142
143
    /// Minimum query length to start showing results
144
    ///
145
    /// Only show results when the query is at least this many characters long
146
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
147
    pub min_query_length: Option<usize>,
148
149
    /// Do not sort the results
150
    ///
151
    /// Often used in combination with --tac
152
    /// Example: `history | sk --tac --no-sort`
153
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
154
    pub no_sort: bool,
155
156
    /// Comma-separated list of sort criteria to apply when the scores are tied.
157
    ///
158
    /// * **score**: Score of the fuzzy match algorithm
159
    ///
160
    ///     - Each criterion could be negated, e.g. (-index)
161
    ///     - Each criterion should appear only once in the list
162
    #[cfg_attr(
163
        feature = "cli",
164
        arg(
165
            short,
166
            long,
167
            default_value = "score,begin,end",
168
            value_enum,
169
            value_delimiter = ',',
170
            help_heading = "Search",
171
            allow_hyphen_values = true,
172
            verbatim_doc_comment
173
        )
174
    )]
175
    pub tiebreak: Vec<RankCriteria>,
176
177
    /// Fields to be matched
178
    ///
179
    /// A field index expression can be a non-zero integer or a range expression (`[BEGIN]..[END]`).
180
    /// `--nth` and `--with-nth` take a comma-separated list of field index expressions.
181
    ///
182
    /// **Examples:**
183
    ///     1      The 1st field
184
    ///     2      The 2nd field
185
    ///     -1     The last field
186
    ///     -2     The 2nd to last field
187
    ///     3..5   From the 3rd field to the 5th field
188
    ///     2..    From the 2nd field to the last field
189
    ///     ..-3   From the 1st field to the 3rd to the last field
190
    ///     ..     All the fields
191
    #[cfg_attr(
192
        feature = "cli",
193
        arg(
194
            short,
195
            long,
196
            default_value = "",
197
            help_heading = "Search",
198
            verbatim_doc_comment,
199
            value_delimiter = ',',
200
            allow_hyphen_values = true,
201
        )
202
    )]
203
    pub nth: Vec<String>,
204
205
    /// Fields to be transformed
206
    ///
207
    /// See **nth** for the details
208
    #[cfg_attr(
209
        feature = "cli",
210
        arg(
211
            long,
212
            default_value = "",
213
            help_heading = "Search",
214
            value_delimiter = ',',
215
            allow_hyphen_values = true,
216
        )
217
    )]
218
    pub with_nth: Vec<String>,
219
220
    /// Fields to hide from display while keeping them searchable
221
    ///
222
    /// Takes the same comma-separated field index expressions as **nth**. The listed
223
    /// fields are removed from the displayed line but remain part of the text used for
224
    /// matching, so a query can still match them. Characters in the hidden fields are
225
    /// ignored for match highlighting and horizontal scrolling.
226
    ///
227
    /// See **nth** for the field index expression syntax.
228
    #[cfg_attr(
229
        feature = "cli",
230
        arg(
231
            long,
232
            default_value = "",
233
            help_heading = "Search",
234
            verbatim_doc_comment,
235
            value_delimiter = ',',
236
            allow_hyphen_values = true,
237
        )
238
    )]
239
    pub hide_nth: Vec<String>,
240
241
    /// Delimiter between fields
242
    ///
243
    /// In regex format, defaults to AWK-style. Escape sequences like \x00, \t, \n are supported.
244
    #[cfg_attr(
245
        feature = "cli",
246
        arg(short, long, default_value = r"[\t\n ]+", value_parser = parse_delimiter_value, help_heading = "Search")
247
    )]
248
    pub delimiter: Regex,
249
250
    /// Run in exact mode
251
    #[cfg_attr(feature = "cli", arg(short, long, help_heading = "Search"))]
252
    pub exact: bool,
253
254
    /// Start in regex mode instead of fuzzy-match
255
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
256
    pub regex: bool,
257
258
    /// Fuzzy matching algorithm
259
    ///
260
    /// - arinae (ari) Latest algorithm
261
    ///
262
    /// - `skim_v2` Legacy skim algorithm
263
    ///
264
    /// - clangd  Used in clangd for keyword completion
265
    ///
266
    /// - fzy     Algorithm from fzy (<https://github.com/jhawthorn/fzy>)
267
    ///
268
    /// - frizbee Algorithm used in the blink.cmp neovim plugin
269
    #[cfg_attr(
270
        feature = "cli",
271
        arg(
272
            long = "algo",
273
            value_enum,
274
            default_value = "arinae",
275
            help_heading = "Search",
276
            verbatim_doc_comment
277
        )
278
    )]
279
    pub algorithm: FuzzyAlgorithm,
280
281
    /// Case sensitivity
282
    ///
283
    /// Determines whether or not to ignore case while matching
284
    /// Note: this is not used for the Frizbee matcher, which uses a penalty system to favor
285
    /// case-sensitivity without enforcing it
286
    #[cfg_attr(
287
        feature = "cli",
288
        arg(long, default_value = "smart", value_enum, help_heading = "Search")
289
    )]
290
    pub case: CaseMatching,
291
292
    /// Enable typo-tolerant matching
293
    ///
294
    /// When passed without a value (`--typos`), uses adaptive formula (`pattern_length` / 4).
295
    /// When passed with a value (e.g. `--typos=2`), uses that exact number as the
296
    /// maximum allowed typos. `--typos=0` explicitly disables typo tolerance.
297
    /// Applies to both fzy and frizbee matchers.
298
    #[cfg_attr(
299
        feature = "cli",
300
        arg(long, default_value = "disabled", default_missing_value = "smart", num_args = 0..=1, value_parser = parse_typos, overrides_with = "no_typos", help_heading = "Search")
301
    )]
302
    pub typos: Typos,
303
304
    /// Disable typo-tolerant matching
305
    #[cfg_attr(feature = "cli", arg(long, overrides_with = "typos", help_heading = "Search"))]
306
    pub no_typos: bool,
307
308
    /// Normalize unicode characters
309
    ///
310
    /// When set, normalize accents and other unicode diacritics/others
311
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
312
    pub normalize: bool,
313
314
    /// Enable split matching and set delimiter
315
    ///
316
    /// Split matching runs the matcher in splits: `foo:bar` will match all items matching `foo`, then
317
    /// `:`, then `bar` if the delimiter is present, or match normally if not.
318
    #[cfg_attr(
319
        feature = "cli",
320
        arg(
321
            long,
322
            default_missing_value = ":",
323
            help_heading = "Search",
324
            num_args=0..
325
        )
326
    )]
327
    pub split_match: Option<char>,
328
329
    /// Highlight the last match found, not the first one
330
    /// This makes tiebreak more pertinent on path items where we want to prioritize a match on the
331
    /// last parts
332
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
333
    pub last_match: bool,
334
335
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search", default_value = "default"))]
336
    scheme: Option<MatchScheme>,
337
338
    //  --- Interface ---
339
    /// Comma-separated key, event, and action bindings
340
    ///
341
    /// `--bind` takes comma-separated `<trigger>:<action>` expressions. A trigger can be a key, the
342
    /// `double-click` mouse binding, a finder event (`change`, `start`, `load`, `result`, `focus`, `zero`, or
343
    /// `one`), or an action name. Use the
344
    /// `act-` prefix for action triggers; it is recommended to avoid ambiguity and required when the action
345
    /// name is also a key, for example `act-up:last`. See the [KEYBINDS] section for details and its
346
    /// [Default key bindings] subsection for the defaults.
347
    ///
348
    /// **Example**: `sk --bind=ctrl-j:accept,load:last,act-up:down`
349
    ///
350
    /// ## Multiple actions can be chained using + separator.
351
    ///
352
    /// **Example**: `sk --bind 'ctrl-a:select-all+accept'`
353
    ///
354
    /// # Special behaviors
355
    ///
356
    /// With `execute(...)` and `reload(...)` action, you can execute arbitrary commands without leaving sk.
357
    /// For example, you can turn sk into a simple file browser by binding enter key to less command like follows:
358
    ///
359
    /// ```bash
360
    /// sk --bind "enter:execute(less {})"
361
    /// ```
362
    ///
363
    /// Note: if no argument is supplied to reload, the default command is run.
364
    ///
365
    /// You can use the same placeholder expressions as in --preview.
366
    ///
367
    /// `sk` switches to the alternate screen when executing a command. However, if the command is
368
    /// expected to complete quickly, and you are not interested in its output, you might want to use
369
    /// execute-silent instead, which silently executes the command without the  switching.  Note  that  sk
370
    /// will  not  be  responsive  until the command is complete. For asynchronous execution, start your
371
    /// command as a background process (i.e. appending `&`).
372
    ///
373
    /// With the `if-query-empty` and `if-query-not-empty` actions, you could specify the action to execute
374
    /// depending on the query condition. For example:
375
    ///
376
    /// `sk --bind 'ctrl-d:if-query-empty(abort)+delete-char'`
377
    ///
378
    /// If  the query is empty, skim will execute abort action, otherwise execute delete-char action. It
379
    /// is equal to 'delete-char/eof'.
380
    #[cfg_attr(
381
        feature = "cli",
382
        arg(short, long, help_heading = "Interface", verbatim_doc_comment, default_value = "", num_args=0..)
383
    )]
384
    pub bind: Vec<String>,
385
386
    /// Enable multiple selection
387
    ///
388
    /// Uses Tab and S-Tab by default for selection
389
    #[cfg_attr(
390
        feature = "cli",
391
        arg(short, long, overrides_with = "no_multi", help_heading = "Interface")
392
    )]
393
    pub multi: bool,
394
395
    /// Disable multiple selection
396
    #[cfg_attr(feature = "cli", arg(long, overrides_with = "multi", help_heading = "Interface"))]
397
    pub no_multi: bool,
398
399
    /// Disable mouse
400
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
401
    pub no_mouse: bool,
402
403
    /// Command to invoke dynamically in interactive mode
404
    ///
405
    /// Will be invoked using `sh -c` on Unix-like systems and `cmd /c` on Windows
406
    #[cfg_attr(feature = "cli", arg(short, long, help_heading = "Interface"))]
407
    pub cmd: Option<String>,
408
409
    /// Start skim in interactive mode
410
    ///
411
    /// In interactive mode, sk will run the command specified by `--cmd` option and display the
412
    /// results.
413
    #[cfg_attr(feature = "cli", arg(short, long, help_heading = "Interface"))]
414
    pub interactive: bool,
415
416
    /// Replace replstr with the selected item in commands
417
    #[cfg_attr(feature = "cli", arg(short = 'I', default_value = "{}", help_heading = "Interface"))]
418
    pub replstr: String,
419
420
    /// Set color theme
421
    ///
422
    /// Format: [BASE][,COLOR:ANSI[:ATTR1:ATTR2:..]]
423
    /// See [THEME] section for details
424
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface", verbatim_doc_comment))]
425
    pub color: Option<String>,
426
427
    /// Highlight the entire current line, not just the text
428
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface", verbatim_doc_comment))]
429
    pub highlight_line: bool,
430
431
    /// Disable horizontal scroll
432
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
433
    pub no_hscroll: bool,
434
435
    /// Keep the right end of the line visible on overflow
436
    ///
437
    /// Effective only when the query string is empty
438
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
439
    pub keep_right: bool,
440
441
    /// Show the matched pattern at the line start
442
    ///
443
    /// Line  will  start  with  the  start of the matched pattern. Effective only when the query
444
    /// string is empty. Was designed to skip showing starts of paths of rg/grep results.
445
    ///
446
    /// e.g. sk -i -c "rg {q} --color=always" --skip-to-pattern '[^/]*:' --ansi
447
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface", verbatim_doc_comment))]
448
    pub skip_to_pattern: Option<String>,
449
450
    /// Do not clear previous line if the command returns an empty result
451
    ///
452
    /// Do not clear previous items if new command returns empty result. This might be useful  to
453
    /// reduce flickering when typing new commands and the half-complete commands are not valid.
454
    ///
455
    /// This is not the default behavior because similar use cases for `grep` and `rg` have already been
456
    /// optimized where empty query results actually mean "empty" and previous results should be
457
    /// cleared.
458
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface", verbatim_doc_comment))]
459
    pub no_clear_if_empty: bool,
460
461
    /// Do not clear items on start
462
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
463
    pub no_clear_start: bool,
464
465
    /// Do not clear screen on exit
466
    ///
467
    /// Do not clear finder interface on exit. If skim was started in full screen mode, it will not switch back to the
468
    /// original  screen, so you'll have to manually run tput rmcup to return. This option can be used to avoid
469
    /// flickering of the screen when your application needs to start skim multiple times in order.
470
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
471
    pub no_clear: bool,
472
473
    /// Show error message if command fails
474
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
475
    pub show_cmd_error: bool,
476
477
    /// Cycle the results by wrapping around when scrolling
478
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
479
    pub cycle: bool,
480
481
    /// Disable matching entirely
482
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
483
    pub disabled: bool,
484
485
    /// Disable items based on this regex pattern
486
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
487
    pub disable_pattern: Option<Regex>,
488
489
    //  --- Layout ---
490
    /// Set layout
491
    ///
492
    #[cfg_attr(
493
        feature = "cli",
494
        arg(long, help_heading = "Layout", verbatim_doc_comment, default_value = "default")
495
    )]
496
    pub layout: TuiLayout,
497
498
    /// Shorthand for reverse layout
499
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Layout", overrides_with = "layout"))]
500
    pub reverse: bool,
501
502
    /// Height of skim's window
503
    ///
504
    /// Can either be a row count or a percentage
505
    /// A negative row count will use `term height` - `value` as height
506
    #[cfg_attr(
507
        feature = "cli",
508
        arg(long, default_value = "100%", help_heading = "Layout", allow_hyphen_values = true)
509
    )]
510
    pub height: String,
511
512
    /// Disable height (force full screen)
513
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Layout"))]
514
    pub no_height: bool,
515
516
    /// Minimum height of skim's window as a non-negative row count
517
    ///
518
    /// Must be a non-negative row count, not a percentage.
519
    /// Useful when the height is set as a percentage.
520
    /// Ignored when --height is not specified.
521
    #[cfg_attr(
522
        feature = "cli",
523
        arg(
524
            long,
525
            default_value = "10",
526
            help_heading = "Layout",
527
            allow_hyphen_values = true,
528
            value_parser = parse_min_height_value,
529
            verbatim_doc_comment
530
        )
531
    )]
532
    pub min_height: String,
533
534
    /// Screen margin
535
    ///
536
    /// For each side, can be either a row count or a percentage of the terminal size
537
    ///
538
    /// Format can be one of:
539
    ///     - TRBL
540
    ///     - TB,RL
541
    ///     - T,RL,B
542
    ///     - T,R,B,L
543
    /// Example: 1,10%
544
    #[cfg_attr(
545
        feature = "cli",
546
        arg(long, default_value = "0", help_heading = "Layout", verbatim_doc_comment)
547
    )]
548
    pub margin: String,
549
550
    /// Set prompt
551
    #[cfg_attr(feature = "cli", arg(long, short, default_value = "> ", help_heading = "Layout"))]
552
    pub prompt: String,
553
554
    /// Set prompt in command mode
555
    #[cfg_attr(feature = "cli", arg(long, default_value = "c> ", help_heading = "Layout"))]
556
    pub cmd_prompt: String,
557
558
    /// Set selected item icon
559
    #[cfg_attr(
560
        feature = "cli",
561
        arg(long = "selector", alias = "pointer", default_value = ">", help_heading = "Layout")
562
    )]
563
    pub selector_icon: String,
564
565
    /// Set multi-selected item icon
566
    #[cfg_attr(
567
        feature = "cli",
568
        arg(
569
            long = "multi-selector",
570
            alias = "marker",
571
            default_value = ">",
572
            help_heading = "Layout"
573
        )
574
    )]
575
    pub multi_select_icon: String,
576
577
    //  --- Display ---
578
    /// Parse ANSI color codes in input strings
579
    ///
580
    /// When using skim as a library, this has no effect and ansi parsing should
581
    /// be enabled by manually injecting a `cmd_collector` like so:
582
    /// ```rust
583
    /// use skim::prelude::*;
584
    ///
585
    /// let _options = SkimOptionsBuilder::default()
586
    ///   .cmd("ls --color")
587
    ///   .cmd_collector(Rc::new(RefCell::new(SkimItemReader::new(
588
    ///     SkimItemReaderOption::default().ansi(true),
589
    ///     ))) as Rc<RefCell<dyn CommandCollector>>)
590
    ///   .build()
591
    ///   .unwrap();
592
    /// ```
593
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
594
    pub ansi: bool,
595
596
    /// Number of spaces that make up a tab
597
    #[cfg_attr(feature = "cli", arg(long, default_value = "8", help_heading = "Display"))]
598
    pub tabstop: usize,
599
600
    /// The characters used to display truncated lines
601
    #[cfg_attr(
602
        feature = "cli",
603
        arg(long, hide = true, allow_hyphen_values = true, default_value = "...")
604
    )]
605
    pub ellipsis: String,
606
607
    /// Set matching result count display position
608
    ///
609
    ///   - hidden  do not display info
610
    ///   - inline[:SEP]  display info in the same row as the input with an optional non-default
611
    ///     separator
612
    ///   - default  display info in a dedicated row above the input
613
    ///   - left  display all info left-aligned in a dedicated row above the input
614
    ///   - right  display all info right-aligned in a dedicated row above the input
615
    ///   - inline-right[:SEP]  display info right-aligned in the same row as the input with an optional
616
    ///     non-default separator
617
    #[cfg_attr(
618
        feature = "cli",
619
        arg(long, help_heading = "Display", default_value = "default", verbatim_doc_comment)
620
    )]
621
    pub info: Info,
622
623
    /// Alias for --info=hidden
624
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
625
    pub no_info: bool,
626
627
    /// Alias for --info=inline
628
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
629
    pub inline_info: bool,
630
631
    /// Set header, displayed next to the info
632
    ///
633
    /// The  given  string  will  be printed as the sticky header. The lines are displayed in the
634
    /// given order from top to bottom regardless of --layout option, and  are  not  affected  by
635
    /// --with-nth. ANSI color codes are processed even when --ansi is not set.
636
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
637
    pub header: Option<String>,
638
639
    /// Number of lines of the input treated as header
640
    ///
641
    /// The  first N lines of the input are treated as the sticky header. When `--with-nth` is set,
642
    /// the lines are transformed just like the other lines that follow.
643
    #[cfg_attr(feature = "cli", arg(long, default_value = "0", help_heading = "Display"))]
644
    pub header_lines: usize,
645
646
    /// Draw borders around the UI components
647
    ///
648
    #[cfg_attr(
649
        feature = "cli",
650
        arg(long, default_missing_value = "plain", help_heading = "Display", default_value = "none", num_args=0.., value_parser = BorderValueParser)
651
    )]
652
    pub border: BorderType,
653
654
    /// Do not collapse adjacent borders into a shared row or column
655
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
656
    pub border_no_collapse: bool,
657
658
    /// Disables all borders, including in tmux/zellij popups
659
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display", overrides_with = "border"))]
660
    pub no_border: bool,
661
662
    /// Wrap items in the item list
663
    #[cfg_attr(feature = "cli", arg(long = "wrap", help_heading = "Display"))]
664
    pub wrap_items: bool,
665
666
    /// Split item text into multiple display lines at the given separator character
667
    /// defaults to `\n` if `read0` is set, and `\\n` if not (matching literal `\n` in text)
668
    ///
669
    /// Each item's text will be split on the separator and each part will be
670
    /// displayed as a separate line within that item's row.
671
    #[cfg_attr(
672
        feature = "cli",
673
        arg(
674
            long = "multiline",
675
            help_heading = "Display",
676
            num_args = 0..=1
677
        )
678
    )]
679
    pub multiline: Option<Option<String>>,
680
681
    /// Set scrollbar style for the item list
682
    ///
683
    /// The optional value is used as the indicator
684
    #[cfg_attr(
685
        feature = "cli",
686
        arg(
687
            long,
688
            help_heading = "Display",
689
            value_name = "THUMB",
690
            overrides_with = "no_scrollbar",
691
            default_value = "▐",
692
            verbatim_doc_comment
693
        )
694
    )]
695
    pub scrollbar: String,
696
    /// Disable the scrollbar in the item list
697
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
698
    pub no_scrollbar: bool,
699
700
    //  --- History ---
701
    /// History file
702
    ///
703
    /// Load search history from the specified file and update the file on completion.
704
    ///
705
    /// When enabled, CTRL-N and CTRL-P are automatically remapped
706
    /// to next-history and previous-history.
707
    #[cfg_attr(feature = "cli", arg(long = "history", help_heading = "History"))]
708
    pub history_file: Option<String>,
709
710
    /// Maximum number of query history entries to keep
711
    #[cfg_attr(feature = "cli", arg(long, default_value = "1000", help_heading = "History"))]
712
    pub history_size: usize,
713
714
    /// Command history file
715
    ///
716
    /// Load command query history from the specified file and update the file on completion.
717
    ///
718
    /// When enabled, CTRL-N and CTRL-P are automatically remapped
719
    /// to next-history and previous-history.
720
    #[cfg_attr(feature = "cli", arg(long = "cmd-history", help_heading = "History"))]
721
    pub cmd_history_file: Option<String>,
722
723
    /// Maximum number of query history entries to keep
724
    #[cfg_attr(feature = "cli", arg(long, default_value = "1000", help_heading = "History"))]
725
    pub cmd_history_size: usize,
726
727
    //  --- Preview ---
728
    /// Preview command
729
    ///
730
    /// Execute the given command with `sh -c` on linux and `cmd /c` on windows for the current line and display the result on the preview window.
731
    /// `{}` in the command is the placeholder that is replaced to the single-quoted string of the current line.
732
    /// To transform the replacement string, specify field index expressions between the braces (See FIELD INDEX EXPRESSION for the details).
733
    ///
734
    /// **Examples**:
735
    ///
736
    /// ```bash
737
    /// sk --preview='head -$LINES {}'
738
    /// ls -l | sk --preview="echo user={3} when={-4..-2}; cat {-1}" --header-lines=1
739
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Preview", verbatim_doc_comment))]
740
    pub preview: Option<String>,
741
742
    /// Preview window layout
743
    ///
744
    /// Format: [up|down|left|right][:SIZE][:hidden][:[no]wrap][:[no]pty][:+SCROLL[-OFFSET]]
745
    ///
746
    /// Determine  the  layout of the preview window. If the argument ends with: hidden, the preview window will be hidden by
747
    /// default until toggle-preview action is triggered. Long lines are truncated by default.
748
    /// Line wrap can be enabled with `:wrap` flag.
749
    /// For more interactive commands or previews that draw complex interfaces, the preview can use a PTY with the `:pty` flag.
750
    ///
751
    /// Note: the preview will run in a PTY (interactive session) on Linux and when `wrap` is unset
752
    ///
753
    /// SIZE can be either:
754
    ///     - `0`, which will hide the preview window
755
    ///     - A positive size (eg `20`)
756
    ///     - A percentage of the total size (eg `50%`)
757
    ///     - A negative size, which will set the size of everything but the preview to that value
758
    ///
759
    /// +SCROLL[-OFFSET] determines the initial scroll offset of the preview window. SCROLL can be either a numeric integer
760
    /// or a single-field index expression that refers to a numeric integer. The optional -OFFSET part is for adjusting the
761
    /// base offset so that you can see the text above it. It should be given as a numeric integer (-INTEGER), or as a
762
    /// denominator form (-/INTEGER) for specifying a fraction of the preview window height.
763
    ///
764
    /// **Examples**:
765
    /// ```bash
766
    /// # Non-default scroll window positions and sizes
767
    /// sk --preview="head {}" --preview-window=up:30%
768
    /// sk --preview="file {}" --preview-window=down:2
769
    ///
770
    /// # Initial scroll offset is set to the line number of each line of
771
    /// # git grep output *minus* 5 lines (-5)
772
    /// git grep --line-number '' |
773
    ///   sk --delimiter:  --preview 'nl {1}' --preview-window +{2}-5
774
    ///
775
    ///             # Preview with bat, matching line in the middle of the window (-/2)
776
    ///             git grep --line-number '' |
777
    ///               sk --delimiter : \
778
    ///                   --preview 'bat --style=numbers --color=always --highlight-line {2} {1}' \
779
    ///                   --preview-window +{2}-/2
780
    /// ```
781
    #[cfg_attr(
782
        feature = "cli",
783
        arg(
784
            long,
785
            default_value = "right:50%",
786
            help_heading = "Preview",
787
            allow_hyphen_values = true
788
        )
789
    )]
790
    pub preview_window: PreviewLayout,
791
792
    /// Enable image preview
793
    ///
794
    /// This will render the preview argument as an image instead of running it as a command.
795
    ///
796
    /// If set to `detect` or if no value is passed, it will try to detect the available image backends at startup, which will add a small
797
    /// delay before the first render.
798
    /// If set to `halfblocks`, it will always use the `halfblocks` rendering method
799
    ///
800
    /// Note: the backend detection **will not** work when piping data into skim, use
801
    /// `SKIM_DEFAULT_COMMAND="find . -type f" sk --image` instead of `find . -type f | sk --image`
802
    #[cfg(feature = "image")]
803
    #[cfg_attr(
804
        feature = "cli",
805
        arg(long, help_heading = "Preview", value_enum, default_missing_value = "detect", num_args=0..)
806
    )]
807
    pub image: Option<ImageProtocol>,
808
809
    /// Terminal image protocol picker, queried after entering the alternate screen.
810
    /// Built from `options.image` and an stdio detection if needed
811
    #[cfg(feature = "image")]
812
    #[cfg_attr(feature = "cli", clap(skip))]
813
    #[builder(setter(skip))]
814
    #[debug(skip)]
815
    pub image_picker: Option<Picker>,
816
817
    //  --- Scripting ---
818
    /// Initial query
819
    #[cfg_attr(feature = "cli", arg(long, short, help_heading = "Scripting"))]
820
    pub query: Option<String>,
821
822
    /// Initial query in interactive mode
823
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
824
    pub cmd_query: Option<String>,
825
826
    /// Read input delimited by ASCII NUL(\\0) characters
827
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
828
    pub read0: bool,
829
830
    /// Print output delimited by ASCII NUL(\\0) characters
831
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
832
    pub print0: bool,
833
834
    /// Print the query as the first line
835
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
836
    pub print_query: bool,
837
838
    /// Print the command as the first line (after print-query)
839
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
840
    pub print_cmd: bool,
841
842
    /// Print the score after each item
843
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
844
    pub print_score: bool,
845
846
    /// Print the header as the first line (after print-score)
847
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
848
    pub print_header: bool,
849
850
    /// Print the current (highlighted) item as the first line (after print-header)
851
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
852
    pub print_current: bool,
853
854
    /// Set the output format
855
    /// If set, overrides all `print_` options
856
    /// Will be expanded the same way as preview or commands
857
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
858
    pub output_format: Option<String>,
859
860
    /// Print the ANSI codes, making the output exactly match the input even when `--ansi` is on
861
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", requires = "ansi"))]
862
    pub no_strip_ansi: bool,
863
864
    /// Do not enter the TUI if the query passed in `-q` matches only one item and return it
865
    #[cfg_attr(feature = "cli", arg(long, short = '1', help_heading = "Scripting"))]
866
    pub select_1: bool,
867
868
    /// Do not enter the TUI if the query passed in `-q` does not match any item
869
    #[cfg_attr(feature = "cli", arg(long, short = '0', help_heading = "Scripting"))]
870
    pub exit_0: bool,
871
872
    /// Synchronous search for multi-staged filtering
873
    ///
874
    /// Synchronous search for multi-staged filtering. If specified,
875
    /// `skim` will launch the TUI finder only after the input stream is complete.
876
    /// e.g. `sk --multi | sk --sync`
877
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
878
    pub sync: bool,
879
880
    /// Pre-select the first n items in multi-selection mode
881
    #[cfg_attr(feature = "cli", arg(long, default_value = "0", help_heading = "Scripting"))]
882
    pub pre_select_n: usize,
883
884
    /// Pre-select the matched items in multi-selection mode
885
    ///
886
    /// Check the doc for the detailed syntax:
887
    /// <https://docs.rs/regex/1.4.1/regex>/
888
    #[cfg_attr(feature = "cli", arg(long, default_value = "", help_heading = "Scripting"))]
889
    pub pre_select_pat: String,
890
891
    /// Pre-select the items separated by newline character
892
    ///
893
    /// Example: 'item1\nitem2'
894
    #[cfg_attr(feature = "cli", arg(long, default_value = "", help_heading = "Scripting"))]
895
    pub pre_select_items: String,
896
897
    /// Pre-select the items read from this file
898
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
899
    pub pre_select_file: Option<String>,
900
901
    /// Query for filter mode
902
    #[cfg_attr(feature = "cli", arg(long, short, help_heading = "Scripting"))]
903
    pub filter: Option<String>,
904
905
    /// Generate shell completion script
906
    ///
907
    /// Generate completion script for the specified shell: bash, zsh, fish, etc.
908
    /// The output can be directly sourced or saved to a file for automatic loading.
909
    /// Examples: `source <(sk --shell bash)` (immediate use)
910
    ///          `sk --shell bash >> ~/.bash_completion` (persistent use)
911
    ///
912
    /// Supported shells: bash, zsh, fish, powershell, elvish
913
    #[cfg(feature = "cli")]
914
    #[cfg_attr(
915
        feature = "cli",
916
        arg(long, value_name = "SHELL", help_heading = "Scripting", value_enum)
917
    )]
918
    pub shell: Option<crate::shell::Shell>,
919
920
    /// Generate shell key bindings - only for bash, zsh and fish
921
    ///
922
    /// Generate key bindings script after the shell completions
923
    /// See the `shell` option for more details
924
    #[cfg(feature = "cli")]
925
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", requires = "shell"))]
926
    pub shell_bindings: bool,
927
928
    /// Generate man page and output it to stdout
929
    #[cfg(feature = "cli")]
930
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
931
    pub man: bool,
932
933
    /// Run an IPC socket with optional name (defaults to `sk`)
934
    ///
935
    /// The socket expects Actions in Ron format (similar to Rust code), see `./src/tui/event.rs` for all possible Actions
936
    /// To write to it, see the `--remote` option or the man page
937
    #[cfg(feature = "listen")]
938
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", default_missing_value = "sk", num_args=0..))]
939
    pub listen: Option<String>,
940
941
    /// Send commands to an IPC socket with optional name (defaults to `sk`)
942
    ///
943
    /// The commands are read from stdin, one per line, in the same format as the actions in the
944
    /// bind flag. They can also be chained using `+` as a separator.
945
    /// All other arguments will be ignored
946
    #[cfg(feature = "listen")]
947
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", default_missing_value = "sk", num_args=0..))]
948
    pub remote: Option<String>,
949
950
    /// Run in a tmux or zellij popup
951
    ///
952
    /// Format: `sk --popup <center|top|bottom|left|right>[,SIZE[%]][,SIZE[%]]`
953
    /// Note: this will try to detect a Zellij session, then a Tmux session
954
    /// This means that in nested sessions, `skim` will prioritize Zellij over Tmux
955
    #[cfg_attr(feature = "cli", arg(long, verbatim_doc_comment, help_heading = "Display", default_missing_value = "center,50%", num_args=0.., alias = "tmux"))]
956
    pub popup: Option<String>,
957
958
    /// Set the log level
959
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
960
    pub log_level: Option<log::LevelFilter>,
961
962
    /// Pipe log output to a file
963
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
964
    pub log_file: Option<String>,
965
966
    /// Feature flags
967
    #[cfg_attr(feature = "cli", arg(long, hide = true, help_heading = "Scripting"))]
968
    pub flags: Vec<FeatureFlag>,
969
970
    // FZF compatibility args
971
    #[cfg_attr(feature = "cli", arg(short = 'x', long, hide = true))]
972
    #[builder(setter(skip))]
973
    extended: bool,
974
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
975
    #[builder(setter(skip))]
976
    literal: bool,
977
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = "10"))]
978
    #[builder(setter(skip))]
979
    hscroll_off: usize,
980
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
981
    #[builder(setter(skip))]
982
    filepath_word: bool,
983
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = ""))]
984
    #[builder(setter(skip))]
985
    jump_labels: String,
986
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
987
    #[builder(setter(skip))]
988
    no_bold: bool,
989
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
990
    #[builder(setter(skip))]
991
    phony: bool,
992
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
993
    #[builder(setter(skip))]
994
    tail: Option<usize>,
995
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
996
    #[builder(setter(skip))]
997
    style: Option<String>,
998
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
999
    #[builder(setter(skip))]
1000
    no_color: bool,
1001
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1002
    #[builder(setter(skip))]
1003
    padding: Option<String>,
1004
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1005
    #[builder(setter(skip))]
1006
    border_label: Option<String>,
1007
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1008
    #[builder(setter(skip))]
1009
    border_label_pos: Option<String>,
1010
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1011
    #[builder(setter(skip))]
1012
    wrap_sign: Option<String>,
1013
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1014
    #[builder(setter(skip))]
1015
    no_multi_line: bool,
1016
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1017
    #[builder(setter(skip))]
1018
    raw: bool,
1019
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1020
    #[builder(setter(skip))]
1021
    track: bool,
1022
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1023
    #[builder(setter(skip))]
1024
    gap: Option<usize>,
1025
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1026
    #[builder(setter(skip))]
1027
    gap_line: Option<String>,
1028
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = "0"))]
1029
    #[builder(setter(skip))]
1030
    freeze_left: usize,
1031
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = "0"))]
1032
    #[builder(setter(skip))]
1033
    freeze_right: usize,
1034
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = "0"))]
1035
    #[builder(setter(skip))]
1036
    scroll_off: usize,
1037
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1038
    #[builder(setter(skip))]
1039
    gutter: Option<String>,
1040
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1041
    #[builder(setter(skip))]
1042
    gutter_raw: Option<String>,
1043
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1044
    #[builder(setter(skip))]
1045
    marker_multi_line: Option<String>,
1046
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1047
    #[builder(setter(skip))]
1048
    list_border: Option<String>,
1049
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1050
    #[builder(setter(skip))]
1051
    list_label: Option<String>,
1052
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1053
    #[builder(setter(skip))]
1054
    list_label_pos: Option<String>,
1055
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1056
    #[builder(setter(skip))]
1057
    no_input: bool,
1058
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1059
    #[builder(setter(skip))]
1060
    info_command: Option<String>,
1061
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1062
    #[builder(setter(skip))]
1063
    separator: Option<String>,
1064
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1065
    #[builder(setter(skip))]
1066
    no_separator: bool,
1067
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1068
    #[builder(setter(skip))]
1069
    ghost: Option<String>,
1070
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1071
    #[builder(setter(skip))]
1072
    input_border: Option<String>,
1073
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1074
    #[builder(setter(skip))]
1075
    input_label: Option<String>,
1076
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1077
    #[builder(setter(skip))]
1078
    input_label_pos: Option<String>,
1079
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1080
    #[builder(setter(skip))]
1081
    preview_label: Option<String>,
1082
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1083
    #[builder(setter(skip))]
1084
    preview_label_pos: Option<String>,
1085
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1086
    #[builder(setter(skip))]
1087
    header_first: bool,
1088
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1089
    #[builder(setter(skip))]
1090
    header_border: Option<String>,
1091
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1092
    #[builder(setter(skip))]
1093
    header_lines_border: Option<String>,
1094
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1095
    #[builder(setter(skip))]
1096
    footer: Option<String>,
1097
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1098
    #[builder(setter(skip))]
1099
    footer_border: Option<String>,
1100
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1101
    #[builder(setter(skip))]
1102
    footer_label: Option<String>,
1103
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1104
    #[builder(setter(skip))]
1105
    footer_label_pos: Option<String>,
1106
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1107
    #[builder(setter(skip))]
1108
    with_shell: Option<String>,
1109
1110
    /// Deprecated, kept for compatibility purposes. See `accept()` bind instead.
1111
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Deprecated", default_value = ""))]
1112
    expect: String,
1113
1114
    /// Command collector for reading items from commands
1115
    #[cfg_attr(feature = "cli", clap(skip = Rc::new(RefCell::new(SkimItemReader::default())) as Rc<RefCell<dyn CommandCollector>>))]
1116
    #[builder(setter(into = false))]
1117
    #[debug(skip)]
1118
    pub cmd_collector: Rc<RefCell<dyn CommandCollector>>,
1119
    /// Query history entries loaded from history file
1120
    #[cfg_attr(feature = "cli", clap(skip))]
1121
    pub query_history: Vec<String>,
1122
    /// Command history entries loaded from cmd history file
1123
    #[cfg_attr(feature = "cli", clap(skip))]
1124
    pub cmd_history: Vec<String>,
1125
    /// Selector for pre-selecting items
1126
    #[cfg_attr(feature = "cli", clap(skip))]
1127
    #[builder(setter(into = false))]
1128
    #[debug(skip)]
1129
    pub selector: Option<Rc<dyn Selector>>,
1130
    /// Preview Callback
1131
    ///
1132
    /// Used to define a function or closure for the preview window, instead of a shell command.
1133
    ///
1134
    /// The function will take a `Vec<Arc<dyn SkimItem>>>` containing the currently selected items
1135
    /// and return a Vec<String> with the lines to display in UTF-8
1136
    #[cfg_attr(feature = "cli", clap(skip))]
1137
    #[debug(skip)]
1138
    pub preview_fn: Option<PreviewCallback>,
1139
1140
    /// The internal (parsed) keymap
1141
    #[cfg_attr(feature = "cli", clap(skip))]
1142
    pub keymap: KeyMap,
1143
1144
    /// Follow-up action bindings, keyed by the canonical action name.
1145
    ///
1146
    /// Populated from `--bind` entries whose "key" is an action name rather than
1147
    /// a real key (e.g. `reload:first`). After an action runs, the chain bound to
1148
    /// its name is queued.
1149
    #[cfg_attr(feature = "cli", clap(skip))]
1150
    pub action_binds: std::collections::HashMap<String, Vec<Action>>,
1151
}
1152
1153
impl Default for SkimOptions {
1154
    #[allow(clippy::too_many_lines)]
1155
395
    fn default() -> Self {
1156
395
        Self {
1157
395
            split_match: None,
1158
395
            no_strip_ansi: false,
1159
395
            wrap_items: false,
1160
395
            multiline: None,
1161
395
            #[cfg(feature = "listen")]
1162
395
            listen: None,
1163
395
            #[cfg(feature = "listen")]
1164
395
            remote: None,
1165
395
            print_header: false,
1166
395
            print_current: false,
1167
395
            disabled: false,
1168
395
            disable_pattern: None,
1169
395
            tac: Default::default(),
1170
395
            min_query_length: Default::default(),
1171
395
            no_sort: Default::default(),
1172
395
            tiebreak: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End],
1173
395
            nth: Default::default(),
1174
395
            with_nth: Default::default(),
1175
395
            hide_nth: Default::default(),
1176
395
            delimiter: Regex::new(r"[\t\n ]+").unwrap(),
1177
395
            exact: Default::default(),
1178
395
            regex: Default::default(),
1179
395
            algorithm: Default::default(),
1180
395
            case: Default::default(),
1181
395
            typos: Typos::Disabled,
1182
395
            no_typos: false,
1183
395
            normalize: false,
1184
395
            last_match: false,
1185
395
            bind: Default::default(),
1186
395
            multi: Default::default(),
1187
395
            no_multi: Default::default(),
1188
395
            no_mouse: Default::default(),
1189
395
            cmd: Default::default(),
1190
395
            interactive: Default::default(),
1191
395
            replstr: String::from("{}"),
1192
395
            color: Default::default(),
1193
395
            no_hscroll: Default::default(),
1194
395
            keep_right: Default::default(),
1195
395
            skip_to_pattern: Default::default(),
1196
395
            no_clear_if_empty: Default::default(),
1197
395
            no_clear_start: Default::default(),
1198
395
            no_clear: Default::default(),
1199
395
            show_cmd_error: Default::default(),
1200
395
            layout: TuiLayout::default(),
1201
395
            reverse: Default::default(),
1202
395
            height: String::from("100%"),
1203
395
            no_height: Default::default(),
1204
395
            min_height: String::from("10"),
1205
395
            margin: Default::default(),
1206
395
            prompt: String::from("> "),
1207
395
            cmd_prompt: String::from("c> "),
1208
395
            selector_icon: String::from(">"),
1209
395
            multi_select_icon: String::from(">"),
1210
395
            ansi: Default::default(),
1211
395
            tabstop: 8,
1212
395
            info: Default::default(),
1213
395
            no_info: Default::default(),
1214
395
            inline_info: Default::default(),
1215
395
            header: Default::default(),
1216
395
            header_lines: Default::default(),
1217
395
            history_file: Default::default(),
1218
395
            history_size: 1000,
1219
395
            cmd_history_file: Default::default(),
1220
395
            cmd_history_size: 1000,
1221
395
            preview: Default::default(),
1222
395
            preview_window: PreviewLayout::default(),
1223
395
            #[cfg(feature = "image")]
1224
395
            image: None,
1225
395
            #[cfg(feature = "image")]
1226
395
            image_picker: None,
1227
395
            query: Default::default(),
1228
395
            cmd_query: Default::default(),
1229
395
            read0: Default::default(),
1230
395
            print0: Default::default(),
1231
395
            print_query: Default::default(),
1232
395
            print_cmd: Default::default(),
1233
395
            print_score: Default::default(),
1234
395
            output_format: Default::default(),
1235
395
            select_1: Default::default(),
1236
395
            exit_0: Default::default(),
1237
395
            sync: Default::default(),
1238
395
            pre_select_n: Default::default(),
1239
395
            pre_select_pat: Default::default(),
1240
395
            pre_select_items: Default::default(),
1241
395
            pre_select_file: Default::default(),
1242
395
            filter: Default::default(),
1243
395
            popup: Default::default(),
1244
395
            log_file: Default::default(),
1245
395
            extended: Default::default(),
1246
395
            literal: Default::default(),
1247
395
            cycle: Default::default(),
1248
395
            hscroll_off: 10,
1249
395
            filepath_word: Default::default(),
1250
395
            jump_labels: String::from("abcdefghijklmnopqrstuvwxyz"),
1251
395
            border: Default::default(),
1252
395
            border_no_collapse: Default::default(),
1253
395
            no_bold: Default::default(),
1254
395
            phony: Default::default(),
1255
395
            scheme: Default::default(),
1256
395
            tail: Default::default(),
1257
395
            style: Default::default(),
1258
395
            no_color: Default::default(),
1259
395
            padding: Default::default(),
1260
395
            border_label: Default::default(),
1261
395
            border_label_pos: Default::default(),
1262
395
            highlight_line: Default::default(),
1263
395
            wrap_sign: Default::default(),
1264
395
            no_multi_line: Default::default(),
1265
395
            raw: Default::default(),
1266
395
            track: Default::default(),
1267
395
            gap: Default::default(),
1268
395
            gap_line: Default::default(),
1269
395
            freeze_left: Default::default(),
1270
395
            freeze_right: Default::default(),
1271
395
            scroll_off: Default::default(),
1272
395
            gutter: Default::default(),
1273
395
            gutter_raw: Default::default(),
1274
395
            marker_multi_line: Default::default(),
1275
395
            ellipsis: Default::default(),
1276
395
            scrollbar: Default::default(),
1277
395
            no_scrollbar: Default::default(),
1278
395
            list_border: Default::default(),
1279
395
            list_label: Default::default(),
1280
395
            list_label_pos: Default::default(),
1281
395
            no_input: Default::default(),
1282
395
            info_command: Default::default(),
1283
395
            separator: Default::default(),
1284
395
            no_separator: Default::default(),
1285
395
            ghost: Default::default(),
1286
395
            input_border: Default::default(),
1287
395
            input_label: Default::default(),
1288
395
            input_label_pos: Default::default(),
1289
395
            preview_label: Default::default(),
1290
395
            preview_label_pos: Default::default(),
1291
395
            header_first: Default::default(),
1292
395
            header_border: Default::default(),
1293
395
            header_lines_border: Default::default(),
1294
395
            footer: Default::default(),
1295
395
            footer_border: Default::default(),
1296
395
            footer_label: Default::default(),
1297
395
            footer_label_pos: Default::default(),
1298
395
            with_shell: Default::default(),
1299
395
            expect: Default::default(),
1300
395
            cmd_collector: Rc::new(RefCell::new(SkimItemReader::default())) as Rc<RefCell<dyn CommandCollector>>,
1301
395
            query_history: Default::default(),
1302
395
            cmd_history: Default::default(),
1303
395
            selector: Default::default(),
1304
395
            preview_fn: Default::default(),
1305
395
            keymap: Default::default(),
1306
395
            action_binds: Default::default(),
1307
395
            #[cfg(feature = "cli")]
1308
395
            shell: Default::default(),
1309
395
            #[cfg(feature = "cli")]
1310
395
            man: false,
1311
395
            #[cfg(feature = "cli")]
1312
395
            shell_bindings: false,
1313
395
            flags: Default::default(),
1314
395
            log_level: Default::default(),
1315
395
            no_border: false,
1316
395
        }
1317
395
    }
1318
}
1319
1320
impl SkimOptionsBuilder {
1321
    /// Builds the `SkimOptions` from the builder
1322
    ///
1323
    /// # Errors
1324
    ///
1325
    /// Returns an error if any required fields are missing.
1326
66
    pub fn build(&mut self) -> Result<SkimOptions, SkimOptionsBuilderError> {
1327
66
        self.final_build().map(SkimOptions::build)
1328
66
    }
1329
}
1330
1331
impl SkimOptions {
1332
    /// Finalizes the options by applying defaults and initializing components
1333
    #[must_use]
1334
551
    pub fn build(mut self) -> Self {
1335
551
        if self.no_height {
  Branch (1335:12): [True: 0, False: 455]
 
  Branch (1335:12): [True: 1, False: 95]
-
1336
1
            self.height = String::from("100%");
1337
550
        }
1338
1339
38
        if let Some(None) = self.multiline {
  Branch (1339:16): [True: 33, False: 422]
+
1336
1
            self.height = String::from("100%");
1337
550
        }
1338
1339
40
        if let Some(None) = self.multiline {
  Branch (1339:16): [True: 34, False: 421]
 
  Branch (1339:16): [True: 2, False: 94]
-
1340
35
            if self.read0 {
  Branch (1340:16): [True: 2, False: 31]
+
1340
36
            if self.read0 {
  Branch (1340:16): [True: 2, False: 32]
 
  Branch (1340:16): [True: 1, False: 1]
-
1341
3
                self.multiline = Some(Some(String::from("\n")));
1342
32
            } else {
1343
32
                self.multiline = Some(Some(String::from("\\n")));
1344
32
            }
1345
516
        }
1346
1347
551
        self.keymap = self.bind.iter().fold(KeyMap::default(), |mut res, part| 
{459
1348
459
            res.add_keymaps_str(part);
1349
459
            res
1350
459
        });
1351
1352
        // Bindings whose "key" is an action name (e.g. `reload:first`) become
1353
        // follow-up actions that run right after that action.
1354
551
        self.action_binds = self
1355
551
            .bind
1356
551
            .iter()
1357
551
            .flat_map(|part| 
crate::binds::parse_action_binds459
(
crate::binds::split_top_level459
(
part459
, ',').
into_iter459
()))
1358
551
            .collect();
1359
1360
551
        if self.reverse {
  Branch (1360:12): [True: 6, False: 449]
+
1341
3
                self.multiline = Some(Some(String::from("\n")));
1342
33
            } else {
1343
33
                self.multiline = Some(Some(String::from("\\n")));
1344
33
            }
1345
515
        }
1346
1347
551
        self.keymap = self.bind.iter().fold(KeyMap::default(), |mut res, part| 
{459
1348
459
            res.add_keymaps_str(part);
1349
459
            res
1350
459
        });
1351
1352
        // Bindings whose "key" is an action name (e.g. `reload:first`) become
1353
        // follow-up actions that run right after that action.
1354
551
        self.action_binds = self
1355
551
            .bind
1356
551
            .iter()
1357
551
            .flat_map(|part| 
crate::binds::parse_action_binds459
(
crate::binds::split_top_level459
(
part459
, ',').
into_iter459
()))
1358
551
            .collect();
1359
1360
551
        if self.reverse {
  Branch (1360:12): [True: 6, False: 449]
 
  Branch (1360:12): [True: 1, False: 95]
 
1361
7
            self.layout = TuiLayout::Reverse;
1362
544
        }
1363
551
        if self.history_file.is_some() || 
self.cmd_history_file549
.
is_some549
() {
  Branch (1363:12): [True: 1, False: 454]
   Branch (1363:43): [True: 0, False: 454]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/output.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/output.rs.html
index b73ef29e..7df131e4 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/output.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/output.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/output.rs
Line
Count
Source
1
use std::io::{self, Write};
2
3
use derive_builder::Builder;
4
5
use crate::item::MatchedItem;
6
use crate::options::SkimOptions;
7
use crate::tui::Event;
8
use crate::tui::actions::Action;
9
10
/// Output from running skim, containing the final selection and state
11
#[derive(Debug)]
12
pub struct SkimOutput {
13
    /// The final event that makes skim accept/quit.
14
    /// Was designed to determine if skim quit or accept.
15
    /// Typically there are only two options: `Event::EvActAbort` | `Event::EvActAccept`
16
    pub final_event: Event,
17
18
    /// quick pass for judging if skim aborts.
19
    pub is_abort: bool,
20
21
    /// The final key that makes skim accept/quit.
22
    /// Note that it might be `Key::Null` if it is triggered by skim.
23
    pub final_key: crossterm::event::KeyEvent,
24
25
    /// The query
26
    pub query: String,
27
28
    /// The command query
29
    pub cmd: String,
30
31
    /// The selected items.
32
    pub selected_items: Vec<MatchedItem>,
33
34
    /// The current item
35
    pub current: Option<MatchedItem>,
36
37
    /// The header
38
    pub header: String,
39
}
40
41
impl SkimOutput {
42
    /// Serialize this output to `out` according to the CLI output options.
43
    ///
44
    /// This is the formatting half of skim's output and is intentionally
45
    /// independent of stdout so it can be exercised by unit tests: the binary
46
    /// passes a locked, buffered stdout, while tests pass a `Vec<u8>`.
47
    ///
48
    /// # Errors
49
    ///
50
    /// Returns any [`io::Error`] produced while writing to `out`.
51
41
    pub fn write_output<W: Write>(&self, out: &mut W, opts: &BinOptions) -> io::Result<()> {
52
41
        if let Some(
ref output_format7
) = opts.output_format {
  Branch (52:16): [Folded - Ignored]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/output.rs
Line
Count
Source
1
use std::io::{self, Write};
2
3
use derive_builder::Builder;
4
5
use crate::item::MatchedItem;
6
use crate::options::SkimOptions;
7
use crate::tui::Event;
8
use crate::tui::actions::Action;
9
10
/// Output from running skim, containing the final selection and state
11
#[derive(Debug)]
12
pub struct SkimOutput {
13
    /// The final event that makes skim accept/quit.
14
    /// Was designed to determine if skim quit or accept.
15
    /// Typically there are only two options: `Event::EvActAbort` | `Event::EvActAccept`
16
    pub final_event: Event,
17
18
    /// quick pass for judging if skim aborts.
19
    pub is_abort: bool,
20
21
    /// The final key that makes skim accept/quit.
22
    /// Note that it might be `Key::Null` if it is triggered by skim.
23
    pub final_key: crossterm::event::KeyEvent,
24
25
    /// The query
26
    pub query: String,
27
28
    /// The command query
29
    pub cmd: String,
30
31
    /// The selected items.
32
    pub selected_items: Vec<MatchedItem>,
33
34
    /// The current item
35
    pub current: Option<MatchedItem>,
36
37
    /// The header
38
    pub header: String,
39
}
40
41
impl SkimOutput {
42
    /// Serialize this output to `out` according to the CLI output options.
43
    ///
44
    /// This is the formatting half of skim's output and is intentionally
45
    /// independent of stdout so it can be exercised by unit tests: the binary
46
    /// passes a locked, buffered stdout, while tests pass a `Vec<u8>`.
47
    ///
48
    /// # Errors
49
    ///
50
    /// Returns any [`io::Error`] produced while writing to `out`.
51
41
    pub fn write_output<W: Write>(&self, out: &mut W, opts: &BinOptions) -> io::Result<()> {
52
41
        if let Some(
ref output_format7
) = opts.output_format {
  Branch (52:16): [Folded - Ignored]
 
  Branch (52:16): [True: 6, False: 23]
 
  Branch (52:16): [True: 1, False: 11]
 
53
7
            write!(
54
7
                out,
55
                "{}{}",
56
7
                crate::printf(
57
7
                    output_format,
58
7
                    &opts.delimiter,
59
7
                    &opts.replstr,
60
7
                    &self.selected_items.iter(),
61
7
                    &self.current,
62
7
                    &self.query,
63
7
                    &self.cmd,
64
                    false
65
                ),
66
                opts.output_ending
67
0
            )?;
68
7
            return Ok(());
69
34
        }
70
71
34
        if opts.print_query {
  Branch (71:12): [Folded - Ignored]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/popup/mod.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/popup/mod.rs.html
index 7138ced9..47bbfd31 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/popup/mod.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/popup/mod.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/popup/mod.rs
Line
Count
Source
1
//! Tmux & Zellij integration utilities.
2
//!
3
//! This module provides functionality for running skim within tmux/zellij panes,
4
//! allowing skim to be used as a tmux popup or split pane.
5
6
mod tmux;
7
mod zellij;
8
9
use std::borrow::Cow;
10
use std::fmt::Write as FmtWrite;
11
use std::io::{BufRead as _, BufReader, BufWriter, IsTerminal as _, Write as _};
12
use std::process::ExitStatus;
13
use std::sync::Arc;
14
use std::sync::atomic::{AtomicBool, Ordering};
15
use std::thread;
16
17
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
18
use nix::sys::stat::Mode;
19
use nix::unistd::mkfifo;
20
21
use crate::item::{MatchedItem, RankBuilder};
22
use crate::tui::Event;
23
use crate::tui::actions::Action;
24
use crate::{Rank, SkimItem, SkimOptions, SkimOutput};
25
26
use tmux::TmuxPopup;
27
use zellij::ZellijPopup;
28
29
#[derive(Debug, PartialEq, Eq)]
30
enum PopupWindowDir {
31
    Center,
32
    Top,
33
    Bottom,
34
    Left,
35
    Right,
36
}
37
38
impl From<&str> for PopupWindowDir {
39
37
    fn from(value: &str) -> Self {
40
        use PopupWindowDir::{Bottom, Center, Left, Right, Top};
41
37
        match value {
42
37
            "top" => 
Top4
,
43
33
            "bottom" => 
Bottom3
,
44
30
            "left" => 
Left3
,
45
27
            "right" => 
Right3
,
46
24
            _ => Center, // includes "center" and all unknown values
47
        }
48
37
    }
49
}
50
51
trait SkimPopup {
52
    fn from_options(options: &SkimOptions) -> Box<dyn SkimPopup>
53
    where
54
        Self: Sized;
55
    fn add_env(&mut self, key: &str, value: &str);
56
    fn run_and_wait(&mut self, command: &str) -> std::io::Result<ExitStatus>;
57
}
58
59
struct SkimPopupOutput {
60
    line: String,
61
}
62
63
impl SkimItem for SkimPopupOutput {
64
1
    fn text(&self) -> Cow<'_, str> {
65
1
        Cow::from(&self.line)
66
1
    }
67
}
68
69
/// Returns true if a compatible multiplexer is running and we are not already in a popup
70
/// (`$_SKIM_POPUP`)
71
#[must_use]
72
7
pub fn check_env() -> bool {
73
7
    std::env::var("_SKIM_POPUP").is_err() && (
tmux::is_available6
() ||
zellij::is_available1
())
  Branch (73:5): [True: 5, False: 0]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/popup/mod.rs
Line
Count
Source
1
//! Tmux & Zellij integration utilities.
2
//!
3
//! This module provides functionality for running skim within tmux/zellij panes,
4
//! allowing skim to be used as a tmux popup or split pane.
5
6
mod tmux;
7
mod zellij;
8
9
use std::borrow::Cow;
10
use std::fmt::Write as FmtWrite;
11
use std::io::{BufRead as _, BufReader, BufWriter, IsTerminal as _, Write as _};
12
use std::process::ExitStatus;
13
use std::sync::Arc;
14
use std::sync::atomic::{AtomicBool, Ordering};
15
use std::thread;
16
17
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
18
use nix::sys::stat::Mode;
19
use nix::unistd::mkfifo;
20
21
use crate::item::{MatchedItem, RankBuilder};
22
use crate::tui::Event;
23
use crate::tui::actions::Action;
24
use crate::{Rank, SkimItem, SkimOptions, SkimOutput};
25
26
use tmux::TmuxPopup;
27
use zellij::ZellijPopup;
28
29
#[derive(Debug, PartialEq, Eq)]
30
enum PopupWindowDir {
31
    Center,
32
    Top,
33
    Bottom,
34
    Left,
35
    Right,
36
}
37
38
impl From<&str> for PopupWindowDir {
39
37
    fn from(value: &str) -> Self {
40
        use PopupWindowDir::{Bottom, Center, Left, Right, Top};
41
37
        match value {
42
37
            "top" => 
Top4
,
43
33
            "bottom" => 
Bottom3
,
44
30
            "left" => 
Left3
,
45
27
            "right" => 
Right3
,
46
24
            _ => Center, // includes "center" and all unknown values
47
        }
48
37
    }
49
}
50
51
trait SkimPopup {
52
    fn from_options(options: &SkimOptions) -> Box<dyn SkimPopup>
53
    where
54
        Self: Sized;
55
    fn add_env(&mut self, key: &str, value: &str);
56
    fn run_and_wait(&mut self, command: &str) -> std::io::Result<ExitStatus>;
57
}
58
59
struct SkimPopupOutput {
60
    line: String,
61
}
62
63
impl SkimItem for SkimPopupOutput {
64
1
    fn text(&self) -> Cow<'_, str> {
65
1
        Cow::from(&self.line)
66
1
    }
67
}
68
69
/// Returns true if a compatible multiplexer is running and we are not already in a popup
70
/// (`$_SKIM_POPUP`)
71
#[must_use]
72
7
pub fn check_env() -> bool {
73
7
    std::env::var("_SKIM_POPUP").is_err() && (
tmux::is_available6
() ||
zellij::is_available1
())
  Branch (73:5): [True: 5, False: 0]
   Branch (73:47): [True: 5, False: 0]
 
  Branch (73:5): [True: 1, False: 1]
   Branch (73:47): [True: 0, False: 1]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/popup/tmux.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/popup/tmux.rs.html
index 4ce1340a..a1113b3c 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/popup/tmux.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/popup/tmux.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/popup/tmux.rs
Line
Count
Source
1
use crate::SkimOptions;
2
3
use super::{PopupWindowDir, SkimPopup};
4
use std::process::{Command, ExitStatus, Stdio};
5
6
12
pub fn is_available() -> bool {
7
12
    cfg!(unix) && std::env::var("TMUX").is_ok() && 
which::which("tmux")10
.
is_ok10
()
  Branch (7:19): [True: 10, False: 0]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/popup/tmux.rs
Line
Count
Source
1
use crate::SkimOptions;
2
3
use super::{PopupWindowDir, SkimPopup};
4
use std::process::{Command, ExitStatus, Stdio};
5
6
12
pub fn is_available() -> bool {
7
12
    cfg!(unix) && std::env::var("TMUX").is_ok() && 
which::which("tmux")10
.
is_ok10
()
  Branch (7:19): [True: 10, False: 0]
 
  Branch (7:19): [True: 0, False: 2]
 
8
12
}
9
10
pub(super) struct TmuxPopup {
11
    cmd: Command,
12
}
13
14
impl TmuxPopup {
15
19
    fn build(options: &SkimOptions) -> Self {
16
19
        let arg = options.popup.as_ref().expect("this arg should be present to get here");
17
        // `is_available` already guarantees tmux is on PATH before we reach here
18
        // in production; fall back to the bare name so arg-building (and tests)
19
        // work even when the binary cannot be resolved.
20
19
        let mut cmd = Command::new(which::which("tmux").unwrap_or_else(|_| 
"tmux"0
.
into0
()));
21
19
        cmd.arg("display-popup").arg("-E").args([
22
            "-d",
23
19
            &std::env::current_dir()
24
19
                .ok()
25
19
                .map_or(".".to_string(), |d| d.to_string_lossy().to_string()),
26
        ]);
27
28
19
        let border = {
29
            use crate::tui::BorderType::{ForceOff, None, Plain, Rounded, Thick};
30
19
            match options.border {
31
0
                ForceOff => "none",
32
16
                None | Plain => "single",
33
1
                Rounded => "rounded",
34
1
                Thick => "heavy",
35
1
                _ => "double",
36
            }
37
        };
38
39
19
        let (raw_dir, size) = arg.split_once(',').unwrap_or((arg, "50%"));
40
19
        let dir = PopupWindowDir::from(raw_dir);
41
19
        let (height, width) = if let Some((
lhs2
,
rhs2
)) = size.split_once(',') {
  Branch (41:38): [True: 0, False: 5]
 
  Branch (41:38): [True: 2, False: 12]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/popup/zellij.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/popup/zellij.rs.html
index 1397cb0e..2e55b32e 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/popup/zellij.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/popup/zellij.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/popup/zellij.rs
Line
Count
Source
1
use super::{PopupWindowDir, SkimPopup};
2
use crate::SkimOptions;
3
use crate::tui::Size;
4
5
use std::fmt::Write as _;
6
use std::process::{Command, ExitStatus, Stdio};
7
8
7
pub fn is_available() -> bool {
9
7
    std::env::var("ZELLIJ").is_ok() && 
which::which("zellij")0
.
is_ok0
()
  Branch (9:5): [True: 0, False: 5]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/popup/zellij.rs
Line
Count
Source
1
use super::{PopupWindowDir, SkimPopup};
2
use crate::SkimOptions;
3
use crate::tui::Size;
4
5
use std::fmt::Write as _;
6
use std::process::{Command, ExitStatus, Stdio};
7
8
7
pub fn is_available() -> bool {
9
7
    std::env::var("ZELLIJ").is_ok() && 
which::which("zellij")0
.
is_ok0
()
  Branch (9:5): [True: 0, False: 5]
 
  Branch (9:5): [True: 0, False: 2]
 
10
7
}
11
12
pub(super) struct ZellijPopup {
13
    cmd: Command,
14
    env: String,
15
}
16
17
20
fn middle_coord(size: Size, var: &str) -> Size {
18
20
    match size {
19
17
        Size::Percent(p) => Size::Percent(100u16.saturating_sub(p) / 2),
20
2
        Size::Fixed(cells) => Size::Fixed(
21
2
            std::env::var(var)
22
2
                .map_or(80u16, |s| 
s.parse()1
.
unwrap_or1
(80))
23
2
                .saturating_sub(cells)
24
                / 2,
25
        ),
26
1
        Size::Neg(cells) => Size::Fixed(cells / 2),
27
    }
28
20
}
29
30
5
fn align_end_coord(size: Size, var: &str) -> Size {
31
5
    match size {
32
3
        Size::Percent(p) => Size::Percent(100 - p),
33
1
        Size::Fixed(cols) => Size::Fixed(
34
1
            std::env::var(var)
35
1
                .map_or(80u16, |s| s.parse().unwrap_or(80))
36
1
                .saturating_sub(cols),
37
        ),
38
1
        Size::Neg(cells) => Size::Fixed(cells),
39
    }
40
5
}
41
42
impl ZellijPopup {
43
10
    fn build(options: &SkimOptions) -> Self {
44
        // `is_available` already guarantees zellij is on PATH before we reach
45
        // here in production; fall back to the bare name so arg-building (and
46
        // tests) work even when the binary cannot be resolved.
47
10
        let mut cmd = Command::new(which::which("zellij").unwrap_or_else(|_| 
"zellij"0
.
into0
()));
48
10
        cmd.arg("run")
49
10
            .arg("--floating")
50
10
            .arg("--block-until-exit")
51
10
            .arg("--close-on-exit")
52
10
            .args(["--pinned", "true"])
53
10
            .args(["--name", "skim"])
54
10
            .args([
55
                "--cwd",
56
10
                &std::env::current_dir()
57
10
                    .ok()
58
10
                    .map_or(".".to_string(), |d| d.to_string_lossy().to_string()),
59
            ]);
60
61
10
        if options.border == crate::tui::BorderType::ForceOff {
  Branch (61:12): [True: 0, False: 0]
 
  Branch (61:12): [True: 1, False: 9]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/reader.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/reader.rs.html
index a3127bc4..8e7088d3 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/reader.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/reader.rs.html
@@ -1,13 +1,13 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/reader.rs
Line
Count
Source
1
//! Reader is used for reading items from datasource (e.g. stdin or command output)
2
//!
3
//! After reading in a line, reader will save an item into the pool(items)
4
use crate::item::ItemPool;
5
use crate::options::SkimOptions;
6
use crate::prelude::{Sender, SkimItemReader};
7
use crate::spinlock::SpinLock;
8
use crate::thread_pool::ThreadPool;
9
use crate::{SkimItem, SkimItemReceiver};
10
use std::cell::RefCell;
11
use std::rc::Rc;
12
use std::sync::Arc;
13
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
14
15
/// Trait for collecting items from command output
16
pub trait CommandCollector {
17
    /// execute the `cmd` and produce a
18
    /// - skim item producer
19
    /// - a channel sender, any message send would mean to terminate the `cmd` process (for now).
20
    ///
21
    /// Internally, the command collector may start several threads(components), the collector
22
    /// should add `1` on every thread creation and sub `1` on thread termination. reader would use
23
    /// this information to determine whether the collector had stopped or not.
24
    fn invoke(
25
        &mut self,
26
        cmd: &str,
27
        components_to_stop: Arc<AtomicUsize>,
28
    ) -> (SkimItemReceiver, crate::prelude::Sender<i32>);
29
30
    /// Provides a shared thread pool so that chunk-processing work submitted
31
    /// by this collector competes for the same threads as the matcher rather
32
    /// than spawning additional OS threads.  The default implementation is a
33
    /// no-op; collectors that support pool-based I/O should override it.
34
0
    fn set_thread_pool(&mut self, _pool: Arc<ThreadPool>) {}
35
}
36
37
/// Handle for controlling a running reader
38
pub struct ReaderControl {
39
    tx_interrupt: Sender<i32>,
40
    tx_interrupt_cmd: Option<Sender<i32>>,
41
    components_to_stop: Arc<AtomicUsize>,
42
    items: Arc<SpinLock<Vec<Arc<dyn SkimItem>>>>,
43
}
44
45
impl ReaderControl {
46
    /// Kills the reader and waits for all components to stop
47
522
    pub fn kill(&mut self) {
48
522
        debug!(
49
            "kill reader, components before: {}",
50
2
            self.components_to_stop.load(Ordering::SeqCst)
51
        );
52
53
522
        let _ = self.tx_interrupt_cmd.clone().map(|tx| 
tx114
.
send114
(1));
54
522
        let _ = self.tx_interrupt.send(1);
55
101k
        while self.components_to_stop.load(Ordering::SeqCst) != 0 
{}101k
  Branch (55:15): [True: 0, False: 492]
-
  Branch (55:15): [True: 101k, False: 30]
-
56
522
    }
57
58
    /// Takes all items collected so far
59
    #[must_use]
60
1
    pub fn take(&self) -> Vec<Arc<dyn SkimItem>> {
61
1
        let mut items = self.items.lock();
62
1
        let mut ret = Vec::with_capacity(items.len());
63
1
        ret.append(&mut items);
64
1
        ret
65
1
    }
66
67
    /// Returns true if the reader has finished and no items remain
68
    #[must_use]
69
10.4k
    pub fn is_done(&self) -> bool {
70
10.4k
        let items = self.items.lock();
71
10.4k
        self.components_to_stop.load(Ordering::SeqCst) == 0 && 
items.is_empty()10.3k
  Branch (71:9): [True: 10.3k, False: 139]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/reader.rs
Line
Count
Source
1
//! Reader is used for reading items from datasource (e.g. stdin or command output)
2
//!
3
//! After reading in a line, reader will save an item into the pool(items)
4
use crate::item::ItemPool;
5
use crate::options::SkimOptions;
6
use crate::prelude::{Sender, SkimItemReader};
7
use crate::spinlock::SpinLock;
8
use crate::thread_pool::ThreadPool;
9
use crate::{SkimItem, SkimItemReceiver};
10
use std::cell::RefCell;
11
use std::rc::Rc;
12
use std::sync::Arc;
13
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
14
15
/// Trait for collecting items from command output
16
pub trait CommandCollector {
17
    /// execute the `cmd` and produce a
18
    /// - skim item producer
19
    /// - a channel sender, any message send would mean to terminate the `cmd` process (for now).
20
    ///
21
    /// Internally, the command collector may start several threads(components), the collector
22
    /// should add `1` on every thread creation and sub `1` on thread termination. reader would use
23
    /// this information to determine whether the collector had stopped or not.
24
    fn invoke(
25
        &mut self,
26
        cmd: &str,
27
        components_to_stop: Arc<AtomicUsize>,
28
    ) -> (SkimItemReceiver, crate::prelude::Sender<i32>);
29
30
    /// Provides a shared thread pool so that chunk-processing work submitted
31
    /// by this collector competes for the same threads as the matcher rather
32
    /// than spawning additional OS threads.  The default implementation is a
33
    /// no-op; collectors that support pool-based I/O should override it.
34
0
    fn set_thread_pool(&mut self, _pool: Arc<ThreadPool>) {}
35
}
36
37
/// Handle for controlling a running reader
38
pub struct ReaderControl {
39
    tx_interrupt: Sender<i32>,
40
    tx_interrupt_cmd: Option<Sender<i32>>,
41
    components_to_stop: Arc<AtomicUsize>,
42
    items: Arc<SpinLock<Vec<Arc<dyn SkimItem>>>>,
43
}
44
45
impl ReaderControl {
46
    /// Kills the reader and waits for all components to stop
47
522
    pub fn kill(&mut self) {
48
522
        debug!(
49
            "kill reader, components before: {}",
50
2
            self.components_to_stop.load(Ordering::SeqCst)
51
        );
52
53
522
        let _ = self.tx_interrupt_cmd.clone().map(|tx| 
tx113
.
send113
(1));
54
522
        let _ = self.tx_interrupt.send(1);
55
1.06M
        while self.components_to_stop.load(Ordering::SeqCst) != 0 
{}1.06M
  Branch (55:15): [True: 0, False: 492]
+
  Branch (55:15): [True: 1.06M, False: 30]
+
56
522
    }
57
58
    /// Takes all items collected so far
59
    #[must_use]
60
1
    pub fn take(&self) -> Vec<Arc<dyn SkimItem>> {
61
1
        let mut items = self.items.lock();
62
1
        let mut ret = Vec::with_capacity(items.len());
63
1
        ret.append(&mut items);
64
1
        ret
65
1
    }
66
67
    /// Returns true if the reader has finished and no items remain
68
    #[must_use]
69
10.4k
    pub fn is_done(&self) -> bool {
70
10.4k
        let items = self.items.lock();
71
10.4k
        self.components_to_stop.load(Ordering::SeqCst) == 0 && 
items.is_empty()10.2k
  Branch (71:9): [True: 10.2k, False: 131]
 
  Branch (71:9): [True: 24, False: 4]
-
72
10.4k
    }
73
}
74
75
impl Drop for ReaderControl {
76
440
    fn drop(&mut self) {
77
440
        self.kill();
78
440
    }
79
}
80
81
/// Reader for streaming items from commands or other sources
82
pub struct Reader {
83
    cmd_collector: Rc<RefCell<dyn CommandCollector>>,
84
    rx_item: Option<SkimItemReceiver>,
85
}
86
87
impl Reader {
88
    /// Creates a new reader from skim options
89
    #[must_use]
90
390
    pub fn from_options(options: &SkimOptions) -> Self {
91
390
        Self {
92
390
            cmd_collector: options.cmd_collector.clone(),
93
390
            rx_item: None,
94
390
        }
95
390
    }
96
97
    /// Sets the item source (if None, will use command collector)
98
    #[must_use]
99
395
    pub fn source(mut self, rx_item: Option<SkimItemReceiver>) -> Self {
100
395
        self.rx_item = rx_item;
101
395
        self
102
395
    }
103
104
    /// Forwards a shared thread pool to the underlying [`CommandCollector`] so
105
    /// that I/O work shares the matcher's thread budget instead of spawning
106
    /// separate OS threads.
107
390
    pub fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
108
390
        self.cmd_collector.borrow_mut().set_thread_pool(pool);
109
390
    }
110
111
    /// Starts the reader and returns a control handle
112
2
    pub fn run(&mut self, app_tx: Sender<Vec<Arc<dyn SkimItem>>>, cmd: &str) -> ReaderControl {
113
2
        let components_to_stop: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
114
2
        let items = Arc::new(SpinLock::new(Vec::new()));
115
116
2
        let (rx_item, tx_interrupt_cmd) = self.rx_item.take().map_or_else(
117
1
            || {
118
1
                let components_to_stop_clone = components_to_stop.clone();
119
1
                let (rx_item, tx_interrupt_cmd) = self.cmd_collector.borrow_mut().invoke(cmd, components_to_stop_clone);
120
1
                (rx_item, Some(tx_interrupt_cmd))
121
1
            },
122
1
            |rx| (rx, None),
123
        );
124
125
2
        let components_to_stop_clone = components_to_stop.clone();
126
2
        let tx_interrupt = collect_items(components_to_stop_clone, rx_item, move |items| _ = app_tx.send(items));
127
128
2
        ReaderControl {
129
2
            tx_interrupt,
130
2
            tx_interrupt_cmd,
131
2
            components_to_stop,
132
2
            items,
133
2
        }
134
2
    }
135
136
    /// Starts collecting items and sending them to the pool directly
137
    /// Returns a control handle
138
438
    pub fn collect(&mut self, item_pool: Arc<ItemPool>, cmd: &str) -> ReaderControl {
139
438
        let components_to_stop: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
140
438
        let items = Arc::new(SpinLock::new(Vec::new()));
141
142
438
        let (rx_item, tx_interrupt_cmd) = self.rx_item.take().map_or_else(
143
70
            || {
144
70
                let components_to_stop_clone = components_to_stop.clone();
145
70
                let (rx_item, tx_interrupt_cmd) = self.cmd_collector.borrow_mut().invoke(cmd, components_to_stop_clone);
146
70
                (rx_item, Some(tx_interrupt_cmd))
147
70
            },
148
368
            |rx| (rx, None),
149
        );
150
151
438
        let components_to_stop_clone = components_to_stop.clone();
152
438
        let tx_interrupt = collect_items(components_to_stop_clone, rx_item, move |items| 
{414
153
414
            item_pool.append(items);
154
414
        });
155
438
        debug!("collect: started ({components_to_stop:?} components)");
156
157
438
        ReaderControl {
158
438
            tx_interrupt,
159
438
            tx_interrupt_cmd,
160
438
            components_to_stop,
161
438
            items,
162
438
        }
163
438
    }
164
}
165
166
impl Default for Reader {
167
7
    fn default() -> Self {
168
7
        Self {
169
7
            cmd_collector: Rc::new(RefCell::new(SkimItemReader::default())) as Rc<RefCell<dyn CommandCollector>>,
170
7
            rx_item: Default::default(),
171
7
        }
172
7
    }
173
}
174
175
440
fn collect_items<F>(components_to_stop: Arc<AtomicUsize>, rx_item: SkimItemReceiver, callback: F) -> Sender<i32>
176
440
where
177
440
    F: Fn(Vec<Arc<dyn SkimItem>>) + Send + 'static,
178
{
179
440
    let (tx_interrupt, rx_interrupt) = crate::prelude::bounded(8);
180
181
440
    let started = Arc::new(AtomicBool::new(false));
182
440
    let started_clone = started.clone();
183
440
    std::thread::spawn(move || {
184
440
        debug!("collect_item start");
185
440
        components_to_stop.fetch_add(1, Ordering::SeqCst);
186
440
        started_clone.store(true, Ordering::SeqCst); // notify parent that it is started
187
188
        loop {
189
879
            if let Ok(Some(
msg1
)) = rx_interrupt.try_recv() {
  Branch (189:20): [True: 0, False: 0]
-  Branch (189:20): [True: 0, False: 833]
+
72
10.4k
    }
73
}
74
75
impl Drop for ReaderControl {
76
440
    fn drop(&mut self) {
77
440
        self.kill();
78
440
    }
79
}
80
81
/// Reader for streaming items from commands or other sources
82
pub struct Reader {
83
    cmd_collector: Rc<RefCell<dyn CommandCollector>>,
84
    rx_item: Option<SkimItemReceiver>,
85
}
86
87
impl Reader {
88
    /// Creates a new reader from skim options
89
    #[must_use]
90
390
    pub fn from_options(options: &SkimOptions) -> Self {
91
390
        Self {
92
390
            cmd_collector: options.cmd_collector.clone(),
93
390
            rx_item: None,
94
390
        }
95
390
    }
96
97
    /// Sets the item source (if None, will use command collector)
98
    #[must_use]
99
395
    pub fn source(mut self, rx_item: Option<SkimItemReceiver>) -> Self {
100
395
        self.rx_item = rx_item;
101
395
        self
102
395
    }
103
104
    /// Forwards a shared thread pool to the underlying [`CommandCollector`] so
105
    /// that I/O work shares the matcher's thread budget instead of spawning
106
    /// separate OS threads.
107
390
    pub fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
108
390
        self.cmd_collector.borrow_mut().set_thread_pool(pool);
109
390
    }
110
111
    /// Starts the reader and returns a control handle
112
2
    pub fn run(&mut self, app_tx: Sender<Vec<Arc<dyn SkimItem>>>, cmd: &str) -> ReaderControl {
113
2
        let components_to_stop: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
114
2
        let items = Arc::new(SpinLock::new(Vec::new()));
115
116
2
        let (rx_item, tx_interrupt_cmd) = self.rx_item.take().map_or_else(
117
1
            || {
118
1
                let components_to_stop_clone = components_to_stop.clone();
119
1
                let (rx_item, tx_interrupt_cmd) = self.cmd_collector.borrow_mut().invoke(cmd, components_to_stop_clone);
120
1
                (rx_item, Some(tx_interrupt_cmd))
121
1
            },
122
1
            |rx| (rx, None),
123
        );
124
125
2
        let components_to_stop_clone = components_to_stop.clone();
126
2
        let tx_interrupt = collect_items(components_to_stop_clone, rx_item, move |items| _ = app_tx.send(items));
127
128
2
        ReaderControl {
129
2
            tx_interrupt,
130
2
            tx_interrupt_cmd,
131
2
            components_to_stop,
132
2
            items,
133
2
        }
134
2
    }
135
136
    /// Starts collecting items and sending them to the pool directly
137
    /// Returns a control handle
138
438
    pub fn collect(&mut self, item_pool: Arc<ItemPool>, cmd: &str) -> ReaderControl {
139
438
        let components_to_stop: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
140
438
        let items = Arc::new(SpinLock::new(Vec::new()));
141
142
438
        let (rx_item, tx_interrupt_cmd) = self.rx_item.take().map_or_else(
143
69
            || {
144
69
                let components_to_stop_clone = components_to_stop.clone();
145
69
                let (rx_item, tx_interrupt_cmd) = self.cmd_collector.borrow_mut().invoke(cmd, components_to_stop_clone);
146
69
                (rx_item, Some(tx_interrupt_cmd))
147
69
            },
148
369
            |rx| (rx, None),
149
        );
150
151
438
        let components_to_stop_clone = components_to_stop.clone();
152
438
        let tx_interrupt = collect_items(components_to_stop_clone, rx_item, move |items| 
{414
153
414
            item_pool.append(items);
154
414
        });
155
438
        debug!("collect: started ({components_to_stop:?} components)");
156
157
438
        ReaderControl {
158
438
            tx_interrupt,
159
438
            tx_interrupt_cmd,
160
438
            components_to_stop,
161
438
            items,
162
438
        }
163
438
    }
164
}
165
166
impl Default for Reader {
167
7
    fn default() -> Self {
168
7
        Self {
169
7
            cmd_collector: Rc::new(RefCell::new(SkimItemReader::default())) as Rc<RefCell<dyn CommandCollector>>,
170
7
            rx_item: Default::default(),
171
7
        }
172
7
    }
173
}
174
175
440
fn collect_items<F>(components_to_stop: Arc<AtomicUsize>, rx_item: SkimItemReceiver, callback: F) -> Sender<i32>
176
440
where
177
440
    F: Fn(Vec<Arc<dyn SkimItem>>) + Send + 'static,
178
{
179
440
    let (tx_interrupt, rx_interrupt) = crate::prelude::bounded(8);
180
181
440
    let started = Arc::new(AtomicBool::new(false));
182
440
    let started_clone = started.clone();
183
440
    std::thread::spawn(move || {
184
440
        debug!("collect_item start");
185
440
        components_to_stop.fetch_add(1, Ordering::SeqCst);
186
440
        started_clone.store(true, Ordering::SeqCst); // notify parent that it is started
187
188
        loop {
189
879
            if let Ok(Some(
msg1
)) = rx_interrupt.try_recv() {
  Branch (189:20): [True: 0, False: 0]
+  Branch (189:20): [True: 0, False: 831]
 
  Branch (189:20): [True: 0, False: 4]
-  Branch (189:20): [True: 1, False: 41]
-
190
1
                debug!("interrupt: {msg}");
191
1
                break;
192
878
            }
193
878
            match rx_item.recv_timeout(std::time::Duration::from_millis(1)) {
194
416
                Ok(items) => {
195
416
                    trace!("collect_item: got {} items", 
items2
.
len2
());
196
416
                    callback(items);
197
                }
198
23
                Err(kanal::ReceiveErrorTimeout::Timeout) => {
199
23
                    // No items within the timeout — loop back to check the
200
23
                    // interrupt channel before blocking again.
201
23
                }
202
                Err(kanal::ReceiveErrorTimeout::Closed | kanal::ReceiveErrorTimeout::SendClosed) => {
203
439
                    break;
204
                }
205
            }
206
        }
207
208
440
        components_to_stop.fetch_sub(1, Ordering::SeqCst);
209
440
        debug!("collect_item stop");
210
440
    });
211
212
22.3M
    while !started.load(Ordering::SeqCst) {
  Branch (212:11): [True: 0, False: 0]
-  Branch (212:11): [True: 20.2M, False: 417]
-
  Branch (212:11): [True: 632k, False: 2]
-  Branch (212:11): [True: 1.50M, False: 21]
-
213
22.3M
        // busy waiting for the thread to start. (components_to_stop is added)
214
22.3M
    }
215
216
440
    tx_interrupt
217
440
}
218
219
#[cfg(test)]
220
#[cfg_attr(coverage, coverage(off))]
221
mod tests {
222
    use super::*;
223
    use std::io::Cursor;
224
    use std::time::{Duration, Instant};
225
226
    fn source(text: &str) -> SkimItemReceiver {
227
        SkimItemReader::default().of_bufread(Cursor::new(text.to_owned().into_bytes()))
228
    }
229
230
    /// Spin until `cond` holds or a short timeout elapses.
231
    fn wait_until(mut cond: impl FnMut() -> bool) {
232
        let start = Instant::now();
233
        while !cond() && start.elapsed() < Duration::from_secs(5) {
234
            std::thread::sleep(Duration::from_millis(2));
235
        }
236
    }
237
238
    #[test]
239
    fn collect_streams_items_into_pool() {
240
        let pool = Arc::new(ItemPool::new());
241
        let mut reader = Reader::default().source(Some(source("a\nb\nc\n")));
242
        let control = reader.collect(pool.clone(), "");
243
        wait_until(|| pool.len() == 3);
244
        assert_eq!(pool.len(), 3);
245
        drop(control);
246
    }
247
248
    #[test]
249
    fn run_sends_items_to_channel() {
250
        let (tx, rx) = kanal::unbounded::<Vec<Arc<dyn SkimItem>>>();
251
        let mut reader = Reader::default().source(Some(source("x\ny\n")));
252
        let control = reader.run(tx, "");
253
        wait_until(|| control.is_done());
254
255
        let mut count = 0;
256
        while let Ok(Some(batch)) = rx.try_recv() {
257
            count += batch.len();
258
        }
259
        assert_eq!(count, 2);
260
        drop(control);
261
    }
262
263
    #[test]
264
    fn is_done_true_after_completion() {
265
        let pool = Arc::new(ItemPool::new());
266
        let mut reader = Reader::default().source(Some(source("only\n")));
267
        let control = reader.collect(pool.clone(), "");
268
        wait_until(|| control.is_done());
269
        assert!(control.is_done());
270
    }
271
272
    #[test]
273
    fn kill_stops_all_components() {
274
        let pool = Arc::new(ItemPool::new());
275
        let mut reader = Reader::default().source(Some(source("a\nb\n")));
276
        let mut control = reader.collect(pool, "");
277
        control.kill();
278
        // After kill, no components remain running.
279
        assert!(control.is_done());
280
    }
281
282
    #[test]
283
    fn run_without_source_invokes_command() {
284
        // With no preset source, `run` falls back to invoking the command via
285
        // the command collector.
286
        #[cfg(unix)]
287
        let cmd = "printf 'a\\nb\\n'";
288
        #[cfg(windows)]
289
        let cmd = "echo a & echo b";
290
291
        let (tx, rx) = kanal::unbounded::<Vec<Arc<dyn SkimItem>>>();
292
        let mut reader = Reader::default();
293
        let control = reader.run(tx, cmd);
294
        wait_until(|| control.is_done());
295
296
        let mut count = 0;
297
        while let Ok(Some(batch)) = rx.try_recv() {
298
            count += batch.len();
299
        }
300
        assert_eq!(count, 2);
301
        drop(control);
302
    }
303
304
    #[test]
305
    fn collect_without_source_invokes_command() {
306
        // Same command-invoking fallback for the pool-collecting path.
307
        #[cfg(unix)]
308
        let cmd = "printf 'x\\ny\\nz\\n'";
309
        #[cfg(windows)]
310
        let cmd = "echo x & echo y & echo z";
311
312
        let pool = Arc::new(ItemPool::new());
313
        let mut reader = Reader::default();
314
        let control = reader.collect(pool.clone(), cmd);
315
        wait_until(|| pool.len() == 3);
316
        assert_eq!(pool.len(), 3);
317
        drop(control);
318
    }
319
320
    #[test]
321
    fn take_returns_empty_for_pool_collection() {
322
        // `collect` routes items to the pool, not the control's own buffer.
323
        let pool = Arc::new(ItemPool::new());
324
        let mut reader = Reader::default().source(Some(source("a\n")));
325
        let control = reader.collect(pool, "");
326
        wait_until(|| control.is_done());
327
        assert!(control.take().is_empty());
328
    }
329
}
\ No newline at end of file + Branch (189:20): [True: 1, False: 43] +
190
1
                debug!("interrupt: {msg}");
191
1
                break;
192
878
            }
193
878
            match rx_item.recv_timeout(std::time::Duration::from_millis(1)) {
194
416
                Ok(items) => {
195
416
                    trace!("collect_item: got {} items", 
items2
.
len2
());
196
416
                    callback(items);
197
                }
198
23
                Err(kanal::ReceiveErrorTimeout::Timeout) => {
199
23
                    // No items within the timeout — loop back to check the
200
23
                    // interrupt channel before blocking again.
201
23
                }
202
                Err(kanal::ReceiveErrorTimeout::Closed | kanal::ReceiveErrorTimeout::SendClosed) => {
203
439
                    break;
204
                }
205
            }
206
        }
207
208
440
        components_to_stop.fetch_sub(1, Ordering::SeqCst);
209
440
        debug!("collect_item stop");
210
440
    });
211
212
65.2M
    while !started.load(Ordering::SeqCst) {
  Branch (212:11): [True: 0, False: 0]
+  Branch (212:11): [True: 60.2M, False: 417]
+
  Branch (212:11): [True: 409k, False: 2]
+  Branch (212:11): [True: 4.55M, False: 21]
+
213
65.2M
        // busy waiting for the thread to start. (components_to_stop is added)
214
65.2M
    }
215
216
440
    tx_interrupt
217
440
}
218
219
#[cfg(test)]
220
#[cfg_attr(coverage, coverage(off))]
221
mod tests {
222
    use super::*;
223
    use std::io::Cursor;
224
    use std::time::{Duration, Instant};
225
226
    fn source(text: &str) -> SkimItemReceiver {
227
        SkimItemReader::default().of_bufread(Cursor::new(text.to_owned().into_bytes()))
228
    }
229
230
    /// Spin until `cond` holds or a short timeout elapses.
231
    fn wait_until(mut cond: impl FnMut() -> bool) {
232
        let start = Instant::now();
233
        while !cond() && start.elapsed() < Duration::from_secs(5) {
234
            std::thread::sleep(Duration::from_millis(2));
235
        }
236
    }
237
238
    #[test]
239
    fn collect_streams_items_into_pool() {
240
        let pool = Arc::new(ItemPool::new());
241
        let mut reader = Reader::default().source(Some(source("a\nb\nc\n")));
242
        let control = reader.collect(pool.clone(), "");
243
        wait_until(|| pool.len() == 3);
244
        assert_eq!(pool.len(), 3);
245
        drop(control);
246
    }
247
248
    #[test]
249
    fn run_sends_items_to_channel() {
250
        let (tx, rx) = kanal::unbounded::<Vec<Arc<dyn SkimItem>>>();
251
        let mut reader = Reader::default().source(Some(source("x\ny\n")));
252
        let control = reader.run(tx, "");
253
        wait_until(|| control.is_done());
254
255
        let mut count = 0;
256
        while let Ok(Some(batch)) = rx.try_recv() {
257
            count += batch.len();
258
        }
259
        assert_eq!(count, 2);
260
        drop(control);
261
    }
262
263
    #[test]
264
    fn is_done_true_after_completion() {
265
        let pool = Arc::new(ItemPool::new());
266
        let mut reader = Reader::default().source(Some(source("only\n")));
267
        let control = reader.collect(pool.clone(), "");
268
        wait_until(|| control.is_done());
269
        assert!(control.is_done());
270
    }
271
272
    #[test]
273
    fn kill_stops_all_components() {
274
        let pool = Arc::new(ItemPool::new());
275
        let mut reader = Reader::default().source(Some(source("a\nb\n")));
276
        let mut control = reader.collect(pool, "");
277
        control.kill();
278
        // After kill, no components remain running.
279
        assert!(control.is_done());
280
    }
281
282
    #[test]
283
    fn run_without_source_invokes_command() {
284
        // With no preset source, `run` falls back to invoking the command via
285
        // the command collector.
286
        #[cfg(unix)]
287
        let cmd = "printf 'a\\nb\\n'";
288
        #[cfg(windows)]
289
        let cmd = "echo a & echo b";
290
291
        let (tx, rx) = kanal::unbounded::<Vec<Arc<dyn SkimItem>>>();
292
        let mut reader = Reader::default();
293
        let control = reader.run(tx, cmd);
294
        wait_until(|| control.is_done());
295
296
        let mut count = 0;
297
        while let Ok(Some(batch)) = rx.try_recv() {
298
            count += batch.len();
299
        }
300
        assert_eq!(count, 2);
301
        drop(control);
302
    }
303
304
    #[test]
305
    fn collect_without_source_invokes_command() {
306
        // Same command-invoking fallback for the pool-collecting path.
307
        #[cfg(unix)]
308
        let cmd = "printf 'x\\ny\\nz\\n'";
309
        #[cfg(windows)]
310
        let cmd = "echo x & echo y & echo z";
311
312
        let pool = Arc::new(ItemPool::new());
313
        let mut reader = Reader::default();
314
        let control = reader.collect(pool.clone(), cmd);
315
        wait_until(|| pool.len() == 3);
316
        assert_eq!(pool.len(), 3);
317
        drop(control);
318
    }
319
320
    #[test]
321
    fn take_returns_empty_for_pool_collection() {
322
        // `collect` routes items to the pool, not the control's own buffer.
323
        let pool = Arc::new(ItemPool::new());
324
        let mut reader = Reader::default().source(Some(source("a\n")));
325
        let control = reader.collect(pool, "");
326
        wait_until(|| control.is_done());
327
        assert!(control.take().is_empty());
328
    }
329
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/shell.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/shell.rs.html index 969df5ef..9b3f95e9 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/shell.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/shell.rs.html @@ -1,4 +1,4 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/shell.rs
Line
Count
Source
1
//! Provides helpers to easily generate shell completions
2
use std::io::Write;
3
4
use clap::CommandFactory;
5
6
use crate::SkimOptions;
7
8
/// Available shells for completion generation
9
#[derive(Clone, clap::ValueEnum, PartialEq, Debug)]
10
pub enum Shell {
11
    /// Bourne Again `SHell`
12
    Bash,
13
    /// Elvish shell
14
    Elvish,
15
    /// Friendly Interactive `SHell`
16
    Fish,
17
    /// Nushell (nu)
18
    Nushell,
19
    /// `PowerShell`
20
    PowerShell,
21
    /// Zsh
22
    Zsh,
23
}
24
25
/// Generate the completion and write it to stdout
26
8
pub fn generate_completions(sh: &Shell, output: &mut impl Write) {
27
    use Shell::{Bash, Elvish, Fish, Nushell, PowerShell, Zsh};
28
8
    let cmd = &mut SkimOptions::command();
29
8
    let bin_name = "sk";
30
31
8
    if *sh == Nushell {
  Branch (31:8): [Folded - Ignored]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/shell.rs
Line
Count
Source
1
//! Provides helpers to easily generate shell completions
2
use std::io::Write;
3
4
use clap::CommandFactory;
5
6
use crate::SkimOptions;
7
8
/// Available shells for completion generation
9
#[derive(Clone, clap::ValueEnum, PartialEq, Debug)]
10
pub enum Shell {
11
    /// Bourne Again `SHell`
12
    Bash,
13
    /// Elvish shell
14
    Elvish,
15
    /// Friendly Interactive `SHell`
16
    Fish,
17
    /// Nushell (nu)
18
    Nushell,
19
    /// `PowerShell`
20
    PowerShell,
21
    /// Zsh
22
    Zsh,
23
}
24
25
/// Generate the completion and write it to stdout
26
8
pub fn generate_completions(sh: &Shell, output: &mut impl Write) {
27
    use Shell::{Bash, Elvish, Fish, Nushell, PowerShell, Zsh};
28
8
    let cmd = &mut SkimOptions::command();
29
8
    let bin_name = "sk";
30
31
8
    if *sh == Nushell {
  Branch (31:8): [Folded - Ignored]
 
  Branch (31:8): [True: 0, False: 2]
 
  Branch (31:8): [True: 1, False: 5]
 
32
1
        clap_complete::generate(clap_complete_nushell::Nushell, cmd, bin_name, output);
33
1
    } else {
34
7
        let clap_shell: clap_complete::Shell = match sh {
35
2
            Bash => clap_complete::Shell::Bash,
36
1
            Elvish => clap_complete::Shell::Elvish,
37
1
            Fish => clap_complete::Shell::Fish,
38
1
            PowerShell => clap_complete::Shell::PowerShell,
39
2
            Zsh => clap_complete::Shell::Zsh,
40
0
            Nushell => unreachable!(),
41
        };
42
7
        clap_complete::generate(clap_shell, cmd, bin_name, output);
43
    }
44
8
}
45
46
/// Generate the key-bindings script and write it to the given writer
47
/// # Errors
48
/// This errors if it fails to write the bytes to the output
49
7
pub fn generate_key_bindings(sh: &Shell, output: &mut impl Write) -> std::io::Result<()> {
50
    use Shell::{Bash, Fish, Zsh};
51
7
    let binds_script = match sh {
52
1
        Bash => include_str!("../shell/key-bindings.bash"),
53
2
        Zsh => include_str!("../shell/key-bindings.zsh"),
54
1
        Fish => include_str!("../shell/key-bindings.fish"),
55
3
        _ => "",
56
    };
57
7
    if !binds_script.is_empty() {
  Branch (57:8): [Folded - Ignored]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/skim.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/skim.rs.html
index eeac3b8a..d081ad91 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/skim.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/skim.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/skim.rs
Line
Count
Source
1
//! Module containing skim's entry point
2
use std::io::{BufWriter, Stderr};
3
use std::sync::Arc;
4
use std::time::Duration;
5
6
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
7
use eyre::{self, OptionExt, Result};
8
#[cfg(feature = "image")]
9
use ratatui_image::picker::Picker;
10
use tokio::runtime::Handle;
11
use tokio::select;
12
use tokio::task::block_in_place;
13
14
use crate::binds::SkimEvent;
15
use crate::reader::{Reader, ReaderControl};
16
use crate::tui::actions::Action;
17
#[cfg(feature = "image")]
18
use crate::tui::util::detect_image_picker;
19
use crate::tui::{App, Event, Size, TICK_RATE, Tui};
20
use crate::{SkimItem, SkimItemReceiver, SkimOptions, SkimOutput};
21
22
/// Stream type yielded by the IPC listener. With the `listen` feature disabled the
23
/// listener branch can never fire, so its payload type is uninhabited.
24
#[cfg(feature = "listen")]
25
type RemoteStream = interprocess::local_socket::tokio::Stream;
26
#[cfg(not(feature = "listen"))]
27
type RemoteStream = std::convert::Infallible;
28
29
/// Main entry point for running skim
30
pub struct Skim<Backend = ratatui::backend::CrosstermBackend<BufWriter<Stderr>>>
31
where
32
    Backend: ratatui::backend::Backend,
33
    Backend::Error: Send + Sync + 'static,
34
{
35
    app: App,
36
    tui: Option<Tui<Backend>>,
37
    height: Size,
38
    reader: Reader,
39
    reader_done: bool,
40
    initial_cmd: String,
41
    reader_control: Option<ReaderControl>,
42
    matcher_interval: Option<tokio::time::Interval>,
43
    #[cfg(feature = "listen")]
44
    listener: Option<interprocess::local_socket::tokio::Listener>,
45
    final_event: Event,
46
    final_key: KeyEvent,
47
    /// Whether the `start` event has already been fired (fired exactly once).
48
    start_fired: bool,
49
}
50
51
impl Skim {
52
    /// Run skim, collecting items from the source and using options
53
    ///
54
    /// # Params
55
    ///
56
    /// - options: the "complex" options that control how skim behaves
57
    /// - source: a stream of items to be passed to skim for filtering.
58
    ///   If None is given, skim will invoke the command given to fetch the items.
59
    ///
60
    /// # Returns
61
    ///
62
    /// - None: on internal errors.
63
    /// - `SkimOutput`: the collected key, event, query, selected items, etc.
64
    ///
65
    /// # Errors
66
    ///
67
    /// Returns an error if skim initialization or the TUI loop fails.
68
    ///
69
    /// # Panics
70
    ///
71
    /// Panics if the tui fails to initialize
72
32
    pub fn run_with(options: SkimOptions, source: Option<SkimItemReceiver>) -> Result<SkimOutput> {
73
32
        trace!("running skim");
74
32
        let mut skim = Self::init(options, source)
?0
;
75
76
32
        skim.start();
77
78
32
        if skim.should_enter() {
  Branch (78:12): [True: 10, False: 22]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/skim.rs
Line
Count
Source
1
//! Module containing skim's entry point
2
use std::io::{BufWriter, Stderr};
3
use std::sync::Arc;
4
use std::time::Duration;
5
6
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
7
use eyre::{self, OptionExt, Result};
8
#[cfg(feature = "image")]
9
use ratatui_image::picker::Picker;
10
use tokio::runtime::Handle;
11
use tokio::select;
12
use tokio::task::block_in_place;
13
14
use crate::binds::SkimEvent;
15
use crate::reader::{Reader, ReaderControl};
16
use crate::tui::actions::Action;
17
#[cfg(feature = "image")]
18
use crate::tui::util::detect_image_picker;
19
use crate::tui::{App, Event, Size, TICK_RATE, Tui};
20
use crate::{SkimItem, SkimItemReceiver, SkimOptions, SkimOutput};
21
22
/// Stream type yielded by the IPC listener. With the `listen` feature disabled the
23
/// listener branch can never fire, so its payload type is uninhabited.
24
#[cfg(feature = "listen")]
25
type RemoteStream = interprocess::local_socket::tokio::Stream;
26
#[cfg(not(feature = "listen"))]
27
type RemoteStream = std::convert::Infallible;
28
29
/// Main entry point for running skim
30
pub struct Skim<Backend = ratatui::backend::CrosstermBackend<BufWriter<Stderr>>>
31
where
32
    Backend: ratatui::backend::Backend,
33
    Backend::Error: Send + Sync + 'static,
34
{
35
    app: App,
36
    tui: Option<Tui<Backend>>,
37
    height: Size,
38
    reader: Reader,
39
    reader_done: bool,
40
    initial_cmd: String,
41
    reader_control: Option<ReaderControl>,
42
    matcher_interval: Option<tokio::time::Interval>,
43
    #[cfg(feature = "listen")]
44
    listener: Option<interprocess::local_socket::tokio::Listener>,
45
    final_event: Event,
46
    final_key: KeyEvent,
47
    /// Whether the `start` event has already been fired (fired exactly once).
48
    start_fired: bool,
49
}
50
51
impl Skim {
52
    /// Run skim, collecting items from the source and using options
53
    ///
54
    /// # Params
55
    ///
56
    /// - options: the "complex" options that control how skim behaves
57
    /// - source: a stream of items to be passed to skim for filtering.
58
    ///   If None is given, skim will invoke the command given to fetch the items.
59
    ///
60
    /// # Returns
61
    ///
62
    /// - None: on internal errors.
63
    /// - `SkimOutput`: the collected key, event, query, selected items, etc.
64
    ///
65
    /// # Errors
66
    ///
67
    /// Returns an error if skim initialization or the TUI loop fails.
68
    ///
69
    /// # Panics
70
    ///
71
    /// Panics if the tui fails to initialize
72
32
    pub fn run_with(options: SkimOptions, source: Option<SkimItemReceiver>) -> Result<SkimOutput> {
73
32
        trace!("running skim");
74
32
        let mut skim = Self::init(options, source)
?0
;
75
76
32
        skim.start();
77
78
32
        if skim.should_enter() {
  Branch (78:12): [True: 10, False: 22]
 
  Branch (78:12): [Folded - Ignored]
 
79
10
            skim.init_tui()
?0
;
80
10
            let task = async {
81
10
                skim.enter().await
?0
;
82
10
                skim.run().await
?0
;
83
10
                eyre::Ok(())
84
10
            };
85
86
10
            if let Ok(
handle0
) = Handle::try_current() {
  Branch (86:20): [True: 0, False: 10]
 
  Branch (86:20): [Folded - Ignored]
@@ -6,9 +6,9 @@
 
  Branch (123:16): [Folded - Ignored]
 
124
0
                tx.send(batch)?;
125
0
                batch = Vec::with_capacity(BATCH_SIZE);
126
0
            }
127
0
            batch.push(Arc::new(item) as Arc<dyn SkimItem>);
128
        }
129
0
        tx.send(batch)?;
130
0
        Self::run_with(options, Some(rx))
131
0
    }
132
133
    /// Initialize the TUI with the default crossterm backend, but do not enter it yet
134
    ///
135
    /// # Errors
136
    ///
137
    /// Returns an error if the TUI backend cannot be initialized.
138
10
    pub fn init_tui(&mut self) -> Result<()> {
139
10
        let mut tui = Tui::new_with_height(self.height)
?0
;
140
10
        if self.app.options.no_mouse {
  Branch (140:12): [True: 0, False: 10]
 
  Branch (140:12): [Folded - Ignored]
-
141
0
            tui.disable_mouse();
142
10
        }
143
10
        let min_height = crate::options::parse_min_height(&self.app.options.min_height).map_err(eyre::Report::msg)
?0
;
144
10
        tui.min_height(min_height)
?0
;
145
10
        self.tui = Some(tui);
146
10
        Ok(())
147
10
    }
148
}
149
150
impl<Backend: ratatui::backend::Backend + 'static> Skim<Backend>
151
where
152
    Backend::Error: Send + Sync + 'static,
153
{
154
    /// Initialize skim, without starting anything yet
155
    ///
156
    /// # Errors
157
    ///
158
    /// Returns an error if parsing the height or other options fails.
159
390
    pub fn init(options: SkimOptions, source: Option<SkimItemReceiver>) -> Result<Self> {
160
390
        let height = Size::try_from(options.height.as_str())
?0
;
161
162
        // application state
163
        // Initialize theme from options
164
390
        let theme = Arc::new(crate::theme::ColorTheme::init_from_options(&options));
165
390
        let mut reader = Reader::from_options(&options).source(source);
166
390
        let cmd = options.cmd.clone().unwrap_or_default();
167
168
390
        let app = App::from_options(options, theme.clone(), cmd.clone());
169
170
        // Give the reader its own dedicated pool (⌈N/3⌉ threads) so it never
171
        // competes with the matcher's pool (⌊2N/3⌋ threads) for the same
172
        // worker threads.
173
390
        reader.set_thread_pool(Arc::clone(&app.reader_pool));
174
175
        //------------------------------------------------------------------------------
176
        // reader
177
        // In interactive mode, expand all placeholders ({}, {q}, etc) with initial query (empty or from --query)
178
390
        let initial_cmd = if app.options.interactive && 
app.options.cmd32
.
is_some32
() {
  Branch (178:30): [True: 0, False: 32]
+
141
0
            tui.disable_mouse();
142
10
        }
143
10
        let min_height = crate::options::parse_min_height(&self.app.options.min_height).map_err(eyre::Report::msg)
?0
;
144
10
        tui.min_height(min_height)
?0
;
145
10
        self.tui = Some(tui);
146
10
        Ok(())
147
10
    }
148
}
149
150
impl<Backend: ratatui::backend::Backend + 'static> Skim<Backend>
151
where
152
    Backend::Error: Send + Sync + 'static,
153
{
154
    /// Initialize skim, without starting anything yet
155
    ///
156
    /// # Errors
157
    ///
158
    /// Returns an error if parsing the height or other options fails.
159
390
    pub fn init(options: SkimOptions, source: Option<SkimItemReceiver>) -> Result<Self> {
160
390
        let height = Size::try_from(options.height.as_str())
?0
;
161
162
        // application state
163
        // Initialize theme from options
164
390
        let theme = Arc::new(crate::theme::ColorTheme::init_from_options(&options));
165
390
        let mut reader = Reader::from_options(&options).source(source);
166
390
        let cmd = options.cmd.clone().unwrap_or_default();
167
168
390
        let app = App::from_options(options, theme.clone(), cmd.clone());
169
170
        // Give the reader its own dedicated pool (⌈N/3⌉ threads) so it never
171
        // competes with the matcher's pool (⌊2N/3⌋ threads) for the same
172
        // worker threads.
173
390
        reader.set_thread_pool(Arc::clone(&app.reader_pool));
174
175
        //------------------------------------------------------------------------------
176
        // reader
177
        // In interactive mode, expand all placeholders ({}, {q}, etc) with initial query (empty or from --query)
178
390
        let initial_cmd = if app.options.interactive && 
app.options.cmd31
.
is_some31
() {
  Branch (178:30): [True: 0, False: 32]
   Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 0, False: 32]
+
  Branch (178:30): [True: 0, False: 34]
   Branch (178:57): [True: 0, False: 0]
 
  Branch (178:30): [True: 2, False: 24]
   Branch (178:57): [True: 2, False: 0]
@@ -16,17 +16,17 @@
   Branch (178:57): [True: 0, False: 0]
 
  Branch (178:30): [True: 0, False: 2]
   Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 0, False: 10]
+
  Branch (178:30): [True: 0, False: 9]
   Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 0, False: 8]
+
  Branch (178:30): [True: 0, False: 7]
   Branch (178:57): [True: 0, False: 0]
 
  Branch (178:30): [True: 0, False: 34]
   Branch (178:57): [True: 0, False: 0]
 
  Branch (178:30): [True: 0, False: 1]
   Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 0, False: 9]
+
  Branch (178:30): [True: 0, False: 10]
   Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 1, False: 3]
+
  Branch (178:30): [True: 1, False: 4]
   Branch (178:57): [True: 1, False: 0]
 
  Branch (178:30): [True: 22, False: 0]
   Branch (178:57): [True: 17, False: 5]
@@ -36,55 +36,55 @@
   Branch (178:57): [True: 0, False: 0]
 
  Branch (178:30): [True: 0, False: 5]
   Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 6, False: 116]
-  Branch (178:57): [True: 6, False: 0]
-
  Branch (178:30): [True: 0, False: 10]
+
  Branch (178:30): [True: 5, False: 116]
+  Branch (178:57): [True: 5, False: 0]
+
  Branch (178:30): [True: 0, False: 9]
   Branch (178:57): [True: 0, False: 0]
 
  Branch (178:30): [True: 0, False: 22]
   Branch (178:57): [True: 0, False: 0]
 
  Branch (178:30): [True: 0, False: 12]
   Branch (178:57): [True: 0, False: 0]
-
179
26
            let expanded = app.expand_cmd(&cmd, true);
180
26
            log::debug!("Interactive mode: initial_cmd = {expanded:?} (from template {cmd:?})");
181
26
            expanded
182
        } else {
183
364
            cmd.clone()
184
        };
185
390
        Ok(Self {
186
390
            app,
187
390
            height,
188
390
            reader,
189
390
            reader_done: false,
190
390
            initial_cmd,
191
390
            tui: None,
192
390
            reader_control: None,
193
390
            matcher_interval: None,
194
390
            #[cfg(feature = "listen")]
195
390
            listener: None,
196
390
            final_event: Event::Quit,
197
390
            final_key: KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
198
390
            start_fired: false,
199
390
        })
200
390
    }
201
202
    /// Start the reader and matcher, but do not enter the TUI yet
203
390
    pub fn start(&mut self) {
204
390
        debug!("Starting reader with initial_cmd: {:?}", self.initial_cmd);
205
390
        self.reader_control = Some(self.reader.collect(self.app.item_pool.clone(), &self.initial_cmd));
206
390
        self.app.restart_matcher(true);
207
        // If the TUI is already available (e.g. test harnesses that build the
208
        // TUI before starting), fire the `start` event now. In the normal
209
        // binary flow the TUI is created after `start()`, so `enter()` fires it.
210
390
        self.fire_start_event();
211
390
    }
212
213
    /// Fire the `start` event exactly once, as soon as the TUI event channel is
214
    /// available. The event is routed through the keymap like any other key, so
215
    /// a `--bind start:<action>` binding runs when skim comes up. In sync mode,
216
    /// render the completed matcher output first so the action sees every item.
217
554
    fn fire_start_event(&mut self) {
218
554
        if self.start_fired {
  Branch (218:12): [True: 148, False: 42]
-
  Branch (218:12): [True: 0, False: 32]
+
179
25
            let expanded = app.expand_cmd(&cmd, true);
180
25
            log::debug!("Interactive mode: initial_cmd = {expanded:?} (from template {cmd:?})");
181
25
            expanded
182
        } else {
183
365
            cmd.clone()
184
        };
185
390
        Ok(Self {
186
390
            app,
187
390
            height,
188
390
            reader,
189
390
            reader_done: false,
190
390
            initial_cmd,
191
390
            tui: None,
192
390
            reader_control: None,
193
390
            matcher_interval: None,
194
390
            #[cfg(feature = "listen")]
195
390
            listener: None,
196
390
            final_event: Event::Quit,
197
390
            final_key: KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
198
390
            start_fired: false,
199
390
        })
200
390
    }
201
202
    /// Start the reader and matcher, but do not enter the TUI yet
203
390
    pub fn start(&mut self) {
204
390
        debug!("Starting reader with initial_cmd: {:?}", self.initial_cmd);
205
390
        self.reader_control = Some(self.reader.collect(self.app.item_pool.clone(), &self.initial_cmd));
206
390
        self.app.restart_matcher(true);
207
        // If the TUI is already available (e.g. test harnesses that build the
208
        // TUI before starting), fire the `start` event now. In the normal
209
        // binary flow the TUI is created after `start()`, so `enter()` fires it.
210
390
        self.fire_start_event();
211
390
    }
212
213
    /// Fire the `start` event exactly once, as soon as the TUI event channel is
214
    /// available. The event is routed through the keymap like any other key, so
215
    /// a `--bind start:<action>` binding runs when skim comes up. In sync mode,
216
    /// render the completed matcher output first so the action sees every item.
217
547
    fn fire_start_event(&mut self) {
218
547
        if self.start_fired {
  Branch (218:12): [True: 141, False: 42]
+
  Branch (218:12): [True: 0, False: 34]
 
  Branch (218:12): [True: 0, False: 26]
 
  Branch (218:12): [True: 0, False: 12]
 
  Branch (218:12): [True: 0, False: 2]
-
  Branch (218:12): [True: 0, False: 10]
-
  Branch (218:12): [True: 0, False: 8]
+
  Branch (218:12): [True: 0, False: 9]
+
  Branch (218:12): [True: 0, False: 7]
 
  Branch (218:12): [True: 0, False: 34]
 
  Branch (218:12): [True: 0, False: 1]
-
  Branch (218:12): [True: 0, False: 9]
-
  Branch (218:12): [True: 0, False: 4]
+
  Branch (218:12): [True: 0, False: 10]
+
  Branch (218:12): [True: 0, False: 5]
 
  Branch (218:12): [True: 0, False: 22]
 
  Branch (218:12): [True: 6, False: 16]
 
  Branch (218:12): [True: 0, False: 11]
 
  Branch (218:12): [True: 0, False: 5]
-
  Branch (218:12): [True: 0, False: 122]
-
  Branch (218:12): [True: 0, False: 10]
+
  Branch (218:12): [True: 0, False: 121]
+
  Branch (218:12): [True: 0, False: 9]
 
  Branch (218:12): [True: 0, False: 22]
 
  Branch (218:12): [True: 0, False: 12]
-
219
154
            return;
220
400
        }
221
400
        if let Some(
tui368
) = self.tui.as_ref() {
  Branch (221:16): [True: 10, False: 32]
-
  Branch (221:16): [True: 32, False: 0]
+
219
147
            return;
220
400
        }
221
400
        if let Some(
tui368
) = self.tui.as_ref() {
  Branch (221:16): [True: 10, False: 32]
+
  Branch (221:16): [True: 34, False: 0]
 
  Branch (221:16): [True: 26, False: 0]
 
  Branch (221:16): [True: 12, False: 0]
 
  Branch (221:16): [True: 2, False: 0]
-
  Branch (221:16): [True: 10, False: 0]
-
  Branch (221:16): [True: 8, False: 0]
+
  Branch (221:16): [True: 9, False: 0]
+
  Branch (221:16): [True: 7, False: 0]
 
  Branch (221:16): [True: 34, False: 0]
 
  Branch (221:16): [True: 1, False: 0]
-
  Branch (221:16): [True: 9, False: 0]
-
  Branch (221:16): [True: 4, False: 0]
+
  Branch (221:16): [True: 10, False: 0]
+
  Branch (221:16): [True: 5, False: 0]
 
  Branch (221:16): [True: 22, False: 0]
 
  Branch (221:16): [True: 16, False: 0]
 
  Branch (221:16): [True: 11, False: 0]
 
  Branch (221:16): [True: 5, False: 0]
-
  Branch (221:16): [True: 122, False: 0]
-
  Branch (221:16): [True: 10, False: 0]
+
  Branch (221:16): [True: 121, False: 0]
+
  Branch (221:16): [True: 9, False: 0]
 
  Branch (221:16): [True: 22, False: 0]
 
  Branch (221:16): [True: 12, False: 0]
 
222
368
            if self.app.options.sync && 
tui.event_tx2
.try_send(Event::Render).
is_err2
() {
  Branch (222:16): [True: 0, False: 10]
   Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 32]
+
  Branch (222:16): [True: 0, False: 34]
   Branch (222:41): [True: 0, False: 0]
 
  Branch (222:16): [True: 1, False: 25]
   Branch (222:41): [True: 0, False: 1]
@@ -92,17 +92,17 @@
   Branch (222:41): [True: 0, False: 0]
 
  Branch (222:16): [True: 0, False: 2]
   Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 10]
+
  Branch (222:16): [True: 0, False: 9]
   Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 8]
+
  Branch (222:16): [True: 0, False: 7]
   Branch (222:41): [True: 0, False: 0]
 
  Branch (222:16): [True: 0, False: 34]
   Branch (222:41): [True: 0, False: 0]
 
  Branch (222:16): [True: 0, False: 1]
   Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 9]
+
  Branch (222:16): [True: 0, False: 10]
   Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 4]
+
  Branch (222:16): [True: 0, False: 5]
   Branch (222:41): [True: 0, False: 0]
 
  Branch (222:16): [True: 0, False: 22]
   Branch (222:41): [True: 0, False: 0]
@@ -112,34 +112,34 @@
   Branch (222:41): [True: 0, False: 0]
 
  Branch (222:16): [True: 0, False: 5]
   Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 122]
+
  Branch (222:16): [True: 0, False: 121]
   Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 10]
+
  Branch (222:16): [True: 0, False: 9]
   Branch (222:41): [True: 0, False: 0]
 
  Branch (222:16): [True: 0, False: 22]
   Branch (222:41): [True: 0, False: 0]
 
  Branch (222:16): [True: 0, False: 12]
   Branch (222:41): [True: 0, False: 0]
 
223
0
                return;
224
368
            }
225
368
            if tui.event_tx.try_send(Event::Key(SkimEvent::Start.into())).is_ok() {
  Branch (225:16): [True: 10, False: 0]
-
  Branch (225:16): [True: 32, False: 0]
+
  Branch (225:16): [True: 34, False: 0]
 
  Branch (225:16): [True: 26, False: 0]
 
  Branch (225:16): [True: 12, False: 0]
 
  Branch (225:16): [True: 2, False: 0]
-
  Branch (225:16): [True: 10, False: 0]
-
  Branch (225:16): [True: 8, False: 0]
+
  Branch (225:16): [True: 9, False: 0]
+
  Branch (225:16): [True: 7, False: 0]
 
  Branch (225:16): [True: 34, False: 0]
 
  Branch (225:16): [True: 1, False: 0]
-
  Branch (225:16): [True: 9, False: 0]
-
  Branch (225:16): [True: 4, False: 0]
+
  Branch (225:16): [True: 10, False: 0]
+
  Branch (225:16): [True: 5, False: 0]
 
  Branch (225:16): [True: 22, False: 0]
 
  Branch (225:16): [True: 16, False: 0]
 
  Branch (225:16): [True: 11, False: 0]
 
  Branch (225:16): [True: 5, False: 0]
-
  Branch (225:16): [True: 122, False: 0]
-
  Branch (225:16): [True: 10, False: 0]
+
  Branch (225:16): [True: 121, False: 0]
+
  Branch (225:16): [True: 9, False: 0]
 
  Branch (225:16): [True: 22, False: 0]
 
  Branch (225:16): [True: 12, False: 0]
-
226
368
                self.start_fired = true;
227
368
            
}0
228
32
        }
229
554
    }
230
231
    /// Handle a reload event by killing the current reader, clearing items, and starting a new reader.
232
    ///
233
    /// This encapsulates the reload logic from the main event loop so it can
234
    /// be reused by test harnesses without reimplementing it.
235
43
    pub fn handle_reload(&mut self, new_cmd: &str) {
236
43
        debug!("reloading with cmd {new_cmd}");
237
        // Kill the current reader
238
43
        if let Some(rc) = self.reader_control.as_mut() {
  Branch (238:16): [True: 0, False: 0]
+
226
368
                self.start_fired = true;
227
368
            
}0
228
32
        }
229
547
    }
230
231
    /// Handle a reload event by killing the current reader, clearing items, and starting a new reader.
232
    ///
233
    /// This encapsulates the reload logic from the main event loop so it can
234
    /// be reused by test harnesses without reimplementing it.
235
43
    pub fn handle_reload(&mut self, new_cmd: &str) {
236
43
        debug!("reloading with cmd {new_cmd}");
237
        // Kill the current reader
238
43
        if let Some(rc) = self.reader_control.as_mut() {
  Branch (238:16): [True: 0, False: 0]
 
  Branch (238:16): [True: 0, False: 0]
 
  Branch (238:16): [True: 0, False: 0]
 
  Branch (238:16): [True: 0, False: 0]
@@ -177,102 +177,102 @@
 
  Branch (244:12): [True: 0, False: 0]
 
  Branch (244:12): [True: 0, False: 0]
 
  Branch (244:12): [True: 0, False: 0]
-
245
42
            self.app.item_list.clear();
246
42
        
}1
247
43
        self.app.restart_matcher(true);
248
        // Start a new reader with the new command
249
43
        self.reader_control = Some(self.reader.collect(self.app.item_pool.clone(), new_cmd));
250
43
        self.reader_done = false;
251
        // A new read is in flight: arm the `load` event to fire again once the
252
        // new item set has been read and rendered.
253
43
        self.app.reader_done = false;
254
43
        self.app.load_event_fired = false;
255
43
    }
256
257
    /// Check if the reader has finished and restart the matcher if needed.
258
    ///
259
    /// This encapsulates the reader-status check from the main event loop
260
    /// so it can be reused by test harnesses.
261
    ///
262
    /// Returns `true` if the reader has completed.
263
9.45k
    pub fn check_reader(&mut self) -> bool {
264
9.45k
        if self
  Branch (264:12): [True: 127, False: 3]
-
  Branch (264:12): [True: 527, False: 3]
-
  Branch (264:12): [True: 735, False: 0]
-
  Branch (264:12): [True: 865, False: 2]
+
245
42
            self.app.item_list.clear();
246
42
        
}1
247
43
        self.app.restart_matcher(true);
248
        // Start a new reader with the new command
249
43
        self.reader_control = Some(self.reader.collect(self.app.item_pool.clone(), new_cmd));
250
43
        self.reader_done = false;
251
        // A new read is in flight: arm the `load` event to fire again once the
252
        // new item set has been read and rendered.
253
43
        self.app.reader_done = false;
254
43
        self.app.load_event_fired = false;
255
43
    }
256
257
    /// Check if the reader has finished and restart the matcher if needed.
258
    ///
259
    /// This encapsulates the reader-status check from the main event loop
260
    /// so it can be reused by test harnesses.
261
    ///
262
    /// Returns `true` if the reader has completed.
263
9.45k
    pub fn check_reader(&mut self) -> bool {
264
9.45k
        if self
  Branch (264:12): [True: 127, False: 0]
+
  Branch (264:12): [True: 547, False: 4]
+
  Branch (264:12): [True: 735, False: 1]
+
  Branch (264:12): [True: 865, False: 1]
 
  Branch (264:12): [True: 75, False: 0]
-
  Branch (264:12): [True: 109, False: 3]
-
  Branch (264:12): [True: 296, False: 1]
-
  Branch (264:12): [True: 694, False: 4]
-
  Branch (264:12): [True: 26, False: 0]
-
  Branch (264:12): [True: 99, False: 2]
-
  Branch (264:12): [True: 172, False: 6]
-
  Branch (264:12): [True: 749, False: 60]
+
  Branch (264:12): [True: 99, False: 0]
+
  Branch (264:12): [True: 274, False: 1]
+
  Branch (264:12): [True: 694, False: 6]
+
  Branch (264:12): [True: 25, False: 1]
+
  Branch (264:12): [True: 109, False: 1]
+
  Branch (264:12): [True: 181, False: 13]
+
  Branch (264:12): [True: 751, False: 70]
 
  Branch (264:12): [True: 10, False: 0]
 
  Branch (264:12): [True: 548, False: 0]
 
  Branch (264:12): [True: 112, False: 0]
-
  Branch (264:12): [True: 2.41k, False: 25]
-
  Branch (264:12): [True: 594, False: 0]
-
  Branch (264:12): [True: 788, False: 2]
-
  Branch (264:12): [True: 394, False: 2]
-
265
9.45k
            .reader_control
266
9.45k
            .as_ref()
267
9.45k
            .is_some_and(super::reader::ReaderControl::is_done)
268
9.33k
            && !self.reader_done
  Branch (268:16): [True: 10, False: 117]
-
  Branch (268:16): [True: 32, False: 495]
+
  Branch (264:12): [True: 2.41k, False: 24]
+
  Branch (264:12): [True: 584, False: 0]
+
  Branch (264:12): [True: 788, False: 1]
+
  Branch (264:12): [True: 394, False: 0]
+
265
9.45k
            .reader_control
266
9.45k
            .as_ref()
267
9.45k
            .is_some_and(super::reader::ReaderControl::is_done)
268
9.32k
            && !self.reader_done
  Branch (268:16): [True: 10, False: 117]
+
  Branch (268:16): [True: 34, False: 513]
 
  Branch (268:16): [True: 26, False: 709]
 
  Branch (268:16): [True: 12, False: 853]
 
  Branch (268:16): [True: 2, False: 73]
-
  Branch (268:16): [True: 10, False: 99]
-
  Branch (268:16): [True: 8, False: 288]
-
  Branch (268:16): [True: 34, False: 660]
-
  Branch (268:16): [True: 1, False: 25]
 
  Branch (268:16): [True: 9, False: 90]
-
  Branch (268:16): [True: 10, False: 162]
-
  Branch (268:16): [True: 55, False: 694]
+
  Branch (268:16): [True: 7, False: 267]
+
  Branch (268:16): [True: 34, False: 660]
+
  Branch (268:16): [True: 1, False: 24]
+
  Branch (268:16): [True: 10, False: 99]
+
  Branch (268:16): [True: 11, False: 170]
+
  Branch (268:16): [True: 55, False: 696]
 
  Branch (268:16): [True: 7, False: 3]
 
  Branch (268:16): [True: 11, False: 537]
 
  Branch (268:16): [True: 5, False: 107]
-
  Branch (268:16): [True: 126, False: 2.29k]
-
  Branch (268:16): [True: 10, False: 584]
+
  Branch (268:16): [True: 125, False: 2.28k]
+
  Branch (268:16): [True: 9, False: 575]
 
  Branch (268:16): [True: 22, False: 766]
 
  Branch (268:16): [True: 12, False: 382]
-
269
        {
270
402
            self.reader_done = true;
271
            // Signal that reading is complete. The `load` event is fired later
272
            // from `App::poll_completion_events` (the heartbeat handler) once the
273
            // reader is done, the matcher has stopped, and every item has been
274
            // consumed, so a `load` binding sees a fully-populated, stable list.
275
402
            self.app.reader_done = true;
276
402
            self.app.restart_matcher(false);
277
            // If the matcher already consumed everything, stop the periodic
278
            // interval immediately rather than waiting for the next tick.
279
402
            if self.app.matcher_control.stopped() && 
self.app.item_pool.num_not_taken() == 0288
{
  Branch (279:16): [True: 7, False: 3]
-  Branch (279:54): [True: 7, False: 0]
-
  Branch (279:16): [True: 24, False: 8]
-  Branch (279:54): [True: 24, False: 0]
-
  Branch (279:16): [True: 24, False: 2]
-  Branch (279:54): [True: 24, False: 0]
-
  Branch (279:16): [True: 6, False: 6]
-  Branch (279:54): [True: 6, False: 0]
+
269
        {
270
402
            self.reader_done = true;
271
            // Signal that reading is complete. The `load` event is fired later
272
            // from `App::poll_completion_events` (the heartbeat handler) once the
273
            // reader is done, the matcher has stopped, and every item has been
274
            // consumed, so a `load` binding sees a fully-populated, stable list.
275
402
            self.app.reader_done = true;
276
402
            self.app.restart_matcher(false);
277
            // If the matcher already consumed everything, stop the periodic
278
            // interval immediately rather than waiting for the next tick.
279
402
            if self.app.matcher_control.stopped() && 
self.app.item_pool.num_not_taken() == 0308
{
  Branch (279:16): [True: 9, False: 1]
+  Branch (279:54): [True: 9, False: 0]
+
  Branch (279:16): [True: 22, False: 12]
+  Branch (279:54): [True: 22, False: 0]
+
  Branch (279:16): [True: 26, False: 0]
+  Branch (279:54): [True: 26, False: 0]
+
  Branch (279:16): [True: 9, False: 3]
+  Branch (279:54): [True: 9, False: 0]
 
  Branch (279:16): [True: 2, False: 0]
   Branch (279:54): [True: 2, False: 0]
-
  Branch (279:16): [True: 3, False: 7]
-  Branch (279:54): [True: 3, False: 0]
-
  Branch (279:16): [True: 6, False: 2]
+
  Branch (279:16): [True: 4, False: 5]
+  Branch (279:54): [True: 4, False: 0]
+
  Branch (279:16): [True: 6, False: 1]
   Branch (279:54): [True: 6, False: 0]
 
  Branch (279:16): [True: 23, False: 11]
   Branch (279:54): [True: 23, False: 0]
-
  Branch (279:16): [True: 1, False: 0]
-  Branch (279:54): [True: 1, False: 0]
-
  Branch (279:16): [True: 7, False: 2]
+
  Branch (279:16): [True: 0, False: 1]
+  Branch (279:54): [True: 0, False: 0]
+
  Branch (279:16): [True: 7, False: 3]
   Branch (279:54): [True: 7, False: 0]
-
  Branch (279:16): [True: 3, False: 7]
-  Branch (279:54): [True: 3, False: 0]
+
  Branch (279:16): [True: 4, False: 7]
+  Branch (279:54): [True: 4, False: 0]
 
  Branch (279:16): [True: 53, False: 2]
   Branch (279:54): [True: 53, False: 0]
-
  Branch (279:16): [True: 1, False: 6]
-  Branch (279:54): [True: 1, False: 0]
+
  Branch (279:16): [True: 2, False: 5]
+  Branch (279:54): [True: 2, False: 0]
 
  Branch (279:16): [True: 11, False: 0]
   Branch (279:54): [True: 11, False: 0]
 
  Branch (279:16): [True: 5, False: 0]
   Branch (279:54): [True: 5, False: 0]
-
  Branch (279:16): [True: 81, False: 45]
-  Branch (279:54): [True: 77, False: 4]
-
  Branch (279:16): [True: 6, False: 4]
-  Branch (279:54): [True: 6, False: 0]
-
  Branch (279:16): [True: 16, False: 6]
-  Branch (279:54): [True: 16, False: 0]
-
  Branch (279:16): [True: 9, False: 3]
-  Branch (279:54): [True: 9, False: 0]
-
280
284
                trace!("matcher interval stopped in check_reader: all items consumed");
281
284
                self.matcher_interval = None;
282
118
            }
283
402
            true
284
        } else {
285
9.05k
            false
286
        }
287
9.45k
    }
288
289
    /// Returns `true` if the reader is done (has finished producing items).
290
1.29k
    pub fn reader_done(&self) -> bool {
291
1.29k
        self.reader_done
  Branch (291:9): [Folded - Ignored]
-  Branch (291:9): [True: 49, False: 35]
-
  Branch (291:9): [True: 67, False: 26]
-
  Branch (291:9): [True: 88, False: 14]
+
  Branch (279:16): [True: 89, False: 36]
+  Branch (279:54): [True: 85, False: 4]
+
  Branch (279:16): [True: 8, False: 1]
+  Branch (279:54): [True: 8, False: 0]
+
  Branch (279:16): [True: 18, False: 4]
+  Branch (279:54): [True: 18, False: 0]
+
  Branch (279:16): [True: 10, False: 2]
+  Branch (279:54): [True: 10, False: 0]
+
280
304
                trace!("matcher interval stopped in check_reader: all items consumed");
281
304
                self.matcher_interval = None;
282
98
            }
283
402
            true
284
        } else {
285
9.05k
            false
286
        }
287
9.45k
    }
288
289
    /// Returns `true` if the reader is done (has finished producing items).
290
1.30k
    pub fn reader_done(&self) -> bool {
291
1.30k
        self.reader_done
  Branch (291:9): [Folded - Ignored]
+  Branch (291:9): [True: 51, False: 38]
+
  Branch (291:9): [True: 67, False: 27]
+
  Branch (291:9): [True: 88, False: 13]
 
  Branch (291:9): [True: 7, False: 2]
-
  Branch (291:9): [True: 10, False: 13]
-
  Branch (291:9): [True: 27, False: 9]
-
  Branch (291:9): [True: 45, False: 38]
-
  Branch (291:9): [True: 1, False: 1]
-
  Branch (291:9): [True: 9, False: 11]
-
  Branch (291:9): [True: 15, False: 7]
-
  Branch (291:9): [True: 81, False: 78]
+
  Branch (291:9): [True: 9, False: 9]
+
  Branch (291:9): [True: 25, False: 8]
+
  Branch (291:9): [True: 45, False: 40]
+
  Branch (291:9): [True: 1, False: 2]
+
  Branch (291:9): [True: 10, False: 11]
+
  Branch (291:9): [True: 16, False: 14]
+
  Branch (291:9): [True: 81, False: 90]
 
  Branch (291:9): [True: 1, False: 0]
 
  Branch (291:9): [True: 50, False: 11]
 
  Branch (291:9): [True: 10, False: 5]
-
  Branch (291:9): [True: 216, False: 145]
-
  Branch (291:9): [True: 59, False: 10]
-
  Branch (291:9): [True: 82, False: 24]
-
  Branch (291:9): [True: 32, False: 14]
-
292
849
            && self
293
849
                .reader_control
294
849
                .as_ref()
295
849
                .is_none_or(super::reader::ReaderControl::is_done)
296
1.29k
    }
297
298
    /// Returns `true` if the matcher is stopped
299
7
    pub fn matcher_stopped(&self) -> bool {
300
7
        self.app.matcher_control.stopped()
301
7
    }
302
303
    /// Initialize the TUI with a caller-provided instance.
304
    ///
305
    /// Use this instead of [`init_tui()`](Skim::init_tui) when you need a
306
    /// non-default backend (e.g. `TestBackend` for snapshot tests).
307
358
    pub fn init_tui_with(&mut self, tui: Tui<Backend>) {
308
358
        self.tui = Some(tui);
309
358
    }
310
311
    /// Returns a shared reference to the application state.
312
13.8k
    pub fn app(&self) -> &App {
313
13.8k
        &self.app
314
13.8k
    }
315
316
    /// Returns a mutable reference to the application state.
317
5
    pub fn app_mut(&mut self) -> &mut App {
318
5
        &mut self.app
319
5
    }
320
321
    /// Returns a shared reference to the TUI.
322
    ///
323
    /// # Panics
324
    ///
325
    /// Panics if the TUI has not been initialized yet.
326
598
    pub fn tui_ref(&self) -> &Tui<Backend> {
327
598
        self.tui.as_ref().expect("TUI needs to be initialized before access")
328
598
    }
329
330
    /// Returns a mutable reference to the TUI.
331
    ///
332
    /// # Panics
333
    ///
334
    /// Panics if the TUI has not been initialized yet.
335
20.6k
    pub fn tui_mut(&mut self) -> &mut Tui<Backend> {
336
20.6k
        self.tui.as_mut().expect("TUI needs to be initialized before access")
337
20.6k
    }
338
339
    /// Returns mutable references to both the app and the TUI simultaneously.
340
    ///
341
    /// This is useful when you need to call `app.handle_event(tui, ...)` or
342
    /// `tui.draw(|frame| frame.render_widget(app, ...))`, which require
343
    /// disjoint mutable borrows of both fields.
344
    ///
345
    /// # Panics
346
    ///
347
    /// Panics if the TUI has not been initialized yet.
348
8.48k
    pub fn app_and_tui(&mut self) -> (&mut App, &mut Tui<Backend>) {
349
8.48k
        (
350
8.48k
            &mut self.app,
351
8.48k
            self.tui.as_mut().expect("TUI needs to be initialized before access"),
352
8.48k
        )
353
8.48k
    }
354
355
    /// Returns a shared reference to the final event that caused skim to quit.
356
3
    pub fn final_event(&self) -> &Event {
357
3
        &self.final_event
358
3
    }
359
360
    /// Returns a clone of the TUI event sender.
361
    ///
362
    /// Use this to send events (e.g. [`Event::Render`], [`Event::Action`])
363
    /// to the running skim instance from outside the event loop. The sender
364
    /// is cheap to clone and can be moved into async blocks or other tasks.
365
    ///
366
    /// Must be called after [`init_tui()`](Skim::init_tui).
367
    ///
368
    /// # Panics
369
    ///
370
    /// Panics if `init_tui` has not been called before this method.
371
1
    pub fn event_sender(&self) -> tokio::sync::mpsc::Sender<Event> {
372
1
        self.tui
373
1
            .as_ref()
374
1
            .expect("TUI needs to be initialized using Skim::init_tui before getting the event sender")
375
1
            .event_tx
376
1
            .clone()
377
1
    }
378
379
    /// Enter the TUI
380
    ///
381
    /// # Errors
382
    ///
383
    /// Returns an error if the TUI cannot be entered or the input listener fails.
384
    ///
385
    /// # Panics
386
    ///
387
    /// Panics if `init_tui` has not been called before this method.
388
    ///
389
    /// # Async
390
    ///
391
    /// This will call `init_listener`, which requires a tokio runtime
392
    /// Though it is not technically async code, this is a good hint.
393
    #[allow(clippy::unused_async, unknown_lints, clippy::unused_async_trait_impl)]
394
10
    pub async fn enter(&mut self) -> Result<()> {
395
10
        debug!("Entering TUI");
396
10
        let tui = self
397
10
            .tui
398
10
            .as_mut()
399
10
            .expect("TUI needs to be initialized using Skim::init_tui before entering");
400
401
10
        tui.enter_terminal()
?0
;
402
        #[cfg(feature = "image")]
403
10
        if self.app.options.image == Some(crate::options::ImageProtocol::Detect) {
  Branch (403:12): [True: 0, False: 10]
+
  Branch (291:9): [True: 215, False: 145]
+
  Branch (291:9): [True: 58, False: 9]
+
  Branch (291:9): [True: 82, False: 23]
+
  Branch (291:9): [True: 32, False: 12]
+
292
848
            && self
293
848
                .reader_control
294
848
                .as_ref()
295
848
                .is_none_or(super::reader::ReaderControl::is_done)
296
1.30k
    }
297
298
    /// Returns `true` if the matcher is stopped
299
7
    pub fn matcher_stopped(&self) -> bool {
300
7
        self.app.matcher_control.stopped()
301
7
    }
302
303
    /// Initialize the TUI with a caller-provided instance.
304
    ///
305
    /// Use this instead of [`init_tui()`](Skim::init_tui) when you need a
306
    /// non-default backend (e.g. `TestBackend` for snapshot tests).
307
358
    pub fn init_tui_with(&mut self, tui: Tui<Backend>) {
308
358
        self.tui = Some(tui);
309
358
    }
310
311
    /// Returns a shared reference to the application state.
312
13.8k
    pub fn app(&self) -> &App {
313
13.8k
        &self.app
314
13.8k
    }
315
316
    /// Returns a mutable reference to the application state.
317
5
    pub fn app_mut(&mut self) -> &mut App {
318
5
        &mut self.app
319
5
    }
320
321
    /// Returns a shared reference to the TUI.
322
    ///
323
    /// # Panics
324
    ///
325
    /// Panics if the TUI has not been initialized yet.
326
598
    pub fn tui_ref(&self) -> &Tui<Backend> {
327
598
        self.tui.as_ref().expect("TUI needs to be initialized before access")
328
598
    }
329
330
    /// Returns a mutable reference to the TUI.
331
    ///
332
    /// # Panics
333
    ///
334
    /// Panics if the TUI has not been initialized yet.
335
20.5k
    pub fn tui_mut(&mut self) -> &mut Tui<Backend> {
336
20.5k
        self.tui.as_mut().expect("TUI needs to be initialized before access")
337
20.5k
    }
338
339
    /// Returns mutable references to both the app and the TUI simultaneously.
340
    ///
341
    /// This is useful when you need to call `app.handle_event(tui, ...)` or
342
    /// `tui.draw(|frame| frame.render_widget(app, ...))`, which require
343
    /// disjoint mutable borrows of both fields.
344
    ///
345
    /// # Panics
346
    ///
347
    /// Panics if the TUI has not been initialized yet.
348
8.47k
    pub fn app_and_tui(&mut self) -> (&mut App, &mut Tui<Backend>) {
349
8.47k
        (
350
8.47k
            &mut self.app,
351
8.47k
            self.tui.as_mut().expect("TUI needs to be initialized before access"),
352
8.47k
        )
353
8.47k
    }
354
355
    /// Returns a shared reference to the final event that caused skim to quit.
356
3
    pub fn final_event(&self) -> &Event {
357
3
        &self.final_event
358
3
    }
359
360
    /// Returns a clone of the TUI event sender.
361
    ///
362
    /// Use this to send events (e.g. [`Event::Render`], [`Event::Action`])
363
    /// to the running skim instance from outside the event loop. The sender
364
    /// is cheap to clone and can be moved into async blocks or other tasks.
365
    ///
366
    /// Must be called after [`init_tui()`](Skim::init_tui).
367
    ///
368
    /// # Panics
369
    ///
370
    /// Panics if `init_tui` has not been called before this method.
371
1
    pub fn event_sender(&self) -> tokio::sync::mpsc::Sender<Event> {
372
1
        self.tui
373
1
            .as_ref()
374
1
            .expect("TUI needs to be initialized using Skim::init_tui before getting the event sender")
375
1
            .event_tx
376
1
            .clone()
377
1
    }
378
379
    /// Enter the TUI
380
    ///
381
    /// # Errors
382
    ///
383
    /// Returns an error if the TUI cannot be entered or the input listener fails.
384
    ///
385
    /// # Panics
386
    ///
387
    /// Panics if `init_tui` has not been called before this method.
388
    ///
389
    /// # Async
390
    ///
391
    /// This will call `init_listener`, which requires a tokio runtime
392
    /// Though it is not technically async code, this is a good hint.
393
    #[allow(clippy::unused_async, unknown_lints, clippy::unused_async_trait_impl)]
394
10
    pub async fn enter(&mut self) -> Result<()> {
395
10
        debug!("Entering TUI");
396
10
        let tui = self
397
10
            .tui
398
10
            .as_mut()
399
10
            .expect("TUI needs to be initialized using Skim::init_tui before entering");
400
401
10
        tui.enter_terminal()
?0
;
402
        #[cfg(feature = "image")]
403
10
        if self.app.options.image == Some(crate::options::ImageProtocol::Detect) {
  Branch (403:12): [True: 0, False: 10]
 
  Branch (403:12): [Folded - Ignored]
 
404
0
            if !tui.is_fullscreen {
  Branch (404:16): [True: 0, False: 0]
 
  Branch (404:16): [Folded - Ignored]
@@ -282,17 +282,17 @@
 
  Branch (416:19): [Folded - Ignored]
 
417
0
            let picker = Picker::halfblocks();
418
0
            self.app.options.image_picker = Some(picker.clone());
419
0
            self.app.preview.set_image_picker(Some(picker));
420
10
        }
421
422
10
        self.init_listener()
?0
;
423
10
        self.tui
424
10
            .as_mut()
425
10
            .expect("TUI needs to be initialized using Skim::init_tui before starting")
426
10
            .start();
427
        // In the normal binary flow the TUI is created after `start()`, so this
428
        // is the first point at which the `start` event can be queued.
429
10
        self.fire_start_event();
430
10
        Ok(())
431
10
    }
432
433
    /// Checks read-0 select-1, filter, and sync to wait and returns whether or not we should enter
434
    ///
435
    /// # Panics
436
    ///
437
    /// Panics if `start` has not been called before this method.
438
38
    pub fn should_enter(&mut self) -> bool {
439
38
        let reader_control = self
440
38
            .reader_control
441
38
            .as_ref()
442
38
            .expect("reader_control needs to be initialized using Skim::start");
443
38
        let app = &mut self.app;
444
445
        // Filter mode: wait for all items to be read and matched, then return without entering TUI
446
38
        if app.options.filter.is_some() {
  Branch (446:12): [True: 15, False: 17]
 
  Branch (446:12): [True: 3, False: 3]
-
447
18
            trace!("filter mode: waiting for all items to be processed");
448
            loop {
449
                // `--min-query-length` short-circuits `restart_matcher`, so the pool would
450
                // never be drained and this loop would spin forever. There is nothing to
451
                // match in that case: stop as soon as the reader is done.
452
155
                if app.query_below_min_length() {
  Branch (452:20): [True: 0, False: 149]
+
447
18
            trace!("filter mode: waiting for all items to be processed");
448
            loop {
449
                // `--min-query-length` short-circuits `restart_matcher`, so the pool would
450
                // never be drained and this loop would spin forever. There is nothing to
451
                // match in that case: stop as soon as the reader is done.
452
85
                if app.query_below_min_length() {
  Branch (452:20): [True: 0, False: 79]
 
  Branch (452:20): [True: 2, False: 4]
 
453
2
                    if reader_control.is_done() {
  Branch (453:24): [True: 0, False: 0]
 
  Branch (453:24): [True: 1, False: 1]
-
454
1
                        debug!("filter mode: query shorter than --min-query-length, no results");
455
1
                        app.item_list.items.clear();
456
1
                        return false;
457
1
                    }
458
1
                    std::thread::sleep(Duration::from_millis(1));
459
1
                    continue;
460
153
                }
461
153
                let matcher_stopped = app.matcher_control.stopped();
462
153
                let reader_done = reader_control.is_done();
463
153
                if matcher_stopped && 
reader_done26
&&
app.item_pool.num_not_taken() == 017
{
  Branch (463:20): [True: 24, False: 125]
-  Branch (463:39): [True: 15, False: 9]
+
454
1
                        debug!("filter mode: query shorter than --min-query-length, no results");
455
1
                        app.item_list.items.clear();
456
1
                        return false;
457
1
                    }
458
1
                    std::thread::sleep(Duration::from_millis(1));
459
1
                    continue;
460
83
                }
461
83
                let matcher_stopped = app.matcher_control.stopped();
462
83
                let reader_done = reader_control.is_done();
463
83
                if matcher_stopped && 
reader_done19
&&
app.item_pool.num_not_taken() == 017
{
  Branch (463:20): [True: 17, False: 62]
+  Branch (463:39): [True: 15, False: 2]
   Branch (463:54): [True: 15, False: 0]
 
  Branch (463:20): [True: 2, False: 2]
   Branch (463:39): [True: 2, False: 0]
   Branch (463:54): [True: 2, False: 0]
-
464
17
                    break;
465
136
                }
466
136
                std::thread::sleep(Duration::from_millis(1));
467
136
                app.restart_matcher(false);
468
            }
469
17
            app.item_list.items = app
470
17
                .item_list
471
17
                .processed_items
472
17
                .lock()
473
17
                .take()
474
17
                .unwrap_or_default()
475
17
                .items
476
17
                .into_iter()
477
50.0k
                .
filter17
(|i| !i.item.disabled())
478
17
                .collect();
479
17
            debug!("filter mode: matched {} items", 
app.item_list.items0
.
len0
());
480
17
            return false;
481
20
        }
482
483
        // Deal with read-0 / select-1
484
20
        let min_items_before_enter = if app.options.exit_0 {
  Branch (484:41): [True: 0, False: 17]
+
464
17
                    break;
465
66
                }
466
66
                std::thread::sleep(Duration::from_millis(1));
467
66
                app.restart_matcher(false);
468
            }
469
17
            app.item_list.items = app
470
17
                .item_list
471
17
                .processed_items
472
17
                .lock()
473
17
                .take()
474
17
                .unwrap_or_default()
475
17
                .items
476
17
                .into_iter()
477
50.0k
                .
filter17
(|i| !i.item.disabled())
478
17
                .collect();
479
17
            debug!("filter mode: matched {} items", 
app.item_list.items0
.
len0
());
480
17
            return false;
481
20
        }
482
483
        // Deal with read-0 / select-1
484
20
        let min_items_before_enter = if app.options.exit_0 {
  Branch (484:41): [True: 0, False: 17]
 
  Branch (484:41): [True: 1, False: 2]
 
485
1
            1
486
19
        } else if app.options.select_1 {
  Branch (486:19): [True: 7, False: 10]
 
  Branch (486:19): [True: 1, False: 1]
@@ -302,13 +302,13 @@
   Branch (493:42): [True: 0, False: 10]
 
  Branch (493:12): [True: 3, False: 0]
   Branch (493:42): [True: 0, False: 0]
-
494
10
            trace!(
495
                "checking matcher, stopped: {}, processed: {}, matched: {}/{}, pool: {}, query: {}, reader_control_done: {}",
496
1
                app.matcher_control.stopped(),
497
1
                app.matcher_control.get_num_processed(),
498
1
                app.matcher_control.get_num_matched(),
499
                min_items_before_enter,
500
1
                app.item_pool.num_not_taken(),
501
                app.input.value,
502
1
                reader_control.is_done()
503
            );
504
24
            while app.matcher_control.get_num_matched() < min_items_before_enter
  Branch (504:19): [True: 17, False: 0]
-
  Branch (504:19): [True: 6, False: 1]
-
505
23
                && (!app.matcher_control.stopped() || 
!reader_control.is_done()9
)
  Branch (505:21): [True: 10, False: 7]
+
494
10
            trace!(
495
                "checking matcher, stopped: {}, processed: {}, matched: {}/{}, pool: {}, query: {}, reader_control_done: {}",
496
1
                app.matcher_control.stopped(),
497
1
                app.matcher_control.get_num_processed(),
498
1
                app.matcher_control.get_num_matched(),
499
                min_items_before_enter,
500
1
                app.item_pool.num_not_taken(),
501
                app.input.value,
502
1
                reader_control.is_done()
503
            );
504
22
            while app.matcher_control.get_num_matched() < min_items_before_enter
  Branch (504:19): [True: 16, False: 0]
+
  Branch (504:19): [True: 5, False: 1]
+
505
21
                && (!app.matcher_control.stopped() || 
!reader_control.is_done()9
)
  Branch (505:21): [True: 9, False: 7]
   Branch (505:55): [True: 0, False: 7]
-
  Branch (505:21): [True: 4, False: 2]
+
  Branch (505:21): [True: 3, False: 2]
   Branch (505:55): [True: 0, False: 2]
-
506
            {
507
14
                trace!("still waiting");
508
14
                std::thread::sleep(Duration::from_millis(1));
509
14
                app.restart_matcher(false);
510
            }
511
10
            trace!(
512
                "checked matcher, stopped: {}, processed: {}, pool: {}, query: {}, reader_control_done: {}",
513
1
                app.matcher_control.stopped(),
514
1
                app.matcher_control.get_num_processed(),
515
1
                app.item_pool.num_not_taken(),
516
                app.input.value,
517
1
                reader_control.is_done()
518
            );
519
10
            trace!(
520
                "checking for matched item count before entering: {}/{min_items_before_enter}",
521
1
                app.matcher_control.get_num_matched()
522
            );
523
10
            if app.matcher_control.get_num_matched() == min_items_before_enter - 1 {
  Branch (523:16): [True: 7, False: 0]
+
506
            {
507
12
                trace!("still waiting");
508
12
                std::thread::sleep(Duration::from_millis(1));
509
12
                app.restart_matcher(false);
510
            }
511
10
            trace!(
512
                "checked matcher, stopped: {}, processed: {}, pool: {}, query: {}, reader_control_done: {}",
513
1
                app.matcher_control.stopped(),
514
1
                app.matcher_control.get_num_processed(),
515
1
                app.item_pool.num_not_taken(),
516
                app.input.value,
517
1
                reader_control.is_done()
518
            );
519
10
            trace!(
520
                "checking for matched item count before entering: {}/{min_items_before_enter}",
521
1
                app.matcher_control.get_num_matched()
522
            );
523
10
            if app.matcher_control.get_num_matched() == min_items_before_enter - 1 {
  Branch (523:16): [True: 7, False: 0]
 
  Branch (523:16): [True: 1, False: 2]
 
524
8
                app.item_list.items = app.item_list.processed_items.lock().take().unwrap_or_default().items;
525
8
                debug!("early exit, result: {:?}", 
app1
.
results1
());
526
8
                return false;
527
2
            }
528
10
        }
529
12
        true
530
38
    }
531
532
    /// Initialize the IPC socket listener
533
    /// This needs to be called from an async context despite being sync
534
    #[cfg_attr(not(feature = "listen"), allow(clippy::unnecessary_wraps, clippy::unused_self))]
535
10
    fn init_listener(&mut self) -> Result<()> {
536
        #[cfg(feature = "listen")]
537
10
        if let Some(
socket_name3
) = &self.app.options.listen {
  Branch (537:16): [True: 3, False: 7]
 
  Branch (537:16): [Folded - Ignored]
@@ -318,34 +318,34 @@
 
  Branch (565:22): [True: 1, False: 5]
 
566
1
            self.app.input.to_string()
567
37
        } else if self.app.options.cmd_query.is_some() {
  Branch (567:19): [True: 0, False: 32]
 
  Branch (567:19): [True: 1, False: 4]
-
568
1
            self.app.options.cmd_query.clone().unwrap()
569
        } else {
570
36
            self.initial_cmd.clone()
571
        };
572
38
        let query = self.app.input.to_string();
573
38
        let current = self.app.item_list.selected();
574
38
        let header = self.app.header.header.clone();
575
38
        let final_event = self.final_event.clone();
576
38
        let final_key = self.final_key;
577
578
38
        drop(self);
579
580
38
        SkimOutput {
581
38
            final_event,
582
38
            is_abort,
583
38
            final_key,
584
38
            query,
585
38
            cmd,
586
38
            selected_items,
587
38
            current,
588
38
            header,
589
38
        }
590
38
    }
591
592
    /// Returns true if skim has finished (the user accepted or aborted)
593
1
    pub fn should_quit(&self) -> bool {
594
1
        self.app.should_quit
595
1
    }
596
597
    /// If `needs_render` has been set (e.g. by the matcher thread), immediately
598
    /// send a `Render` event to the TUI so the screen updates without waiting
599
    /// for the next heartbeat tick.  Respects the 30 FPS frame-rate cap.
600
18
    fn try_flush_render(&mut self) {
601
        use std::sync::atomic::Ordering;
602
18
        if self.app.needs_render.load(Ordering::Relaxed)
  Branch (602:12): [True: 11, False: 4]
+
568
1
            self.app.options.cmd_query.clone().unwrap()
569
        } else {
570
36
            self.initial_cmd.clone()
571
        };
572
38
        let query = self.app.input.to_string();
573
38
        let current = self.app.item_list.selected();
574
38
        let header = self.app.header.header.clone();
575
38
        let final_event = self.final_event.clone();
576
38
        let final_key = self.final_key;
577
578
38
        drop(self);
579
580
38
        SkimOutput {
581
38
            final_event,
582
38
            is_abort,
583
38
            final_key,
584
38
            query,
585
38
            cmd,
586
38
            selected_items,
587
38
            current,
588
38
            header,
589
38
        }
590
38
    }
591
592
    /// Returns true if skim has finished (the user accepted or aborted)
593
1
    pub fn should_quit(&self) -> bool {
594
1
        self.app.should_quit
595
1
    }
596
597
    /// If `needs_render` has been set (e.g. by the matcher thread), immediately
598
    /// send a `Render` event to the TUI so the screen updates without waiting
599
    /// for the next heartbeat tick.  Respects the 30 FPS frame-rate cap.
600
14
    fn try_flush_render(&mut self) {
601
        use std::sync::atomic::Ordering;
602
14
        if self.app.needs_render.load(Ordering::Relaxed)
  Branch (602:12): [True: 11, False: 0]
 
  Branch (602:12): [True: 2, False: 1]
 
603
13
            && self.app.last_render_timer.elapsed().as_millis() > 1000 / u128::from(TICK_RATE)
  Branch (603:16): [True: 10, False: 1]
 
  Branch (603:16): [True: 2, False: 0]
 
604
        {
605
12
            self.app.needs_render.store(false, Ordering::Relaxed);
606
12
            self.app.last_render_timer = std::time::Instant::now();
607
12
            if let Some(tui) = self.tui.as_ref() {
  Branch (607:20): [True: 10, False: 0]
 
  Branch (607:20): [True: 2, False: 0]
-
608
12
                let _ = tui.event_tx.try_send(Event::Render);
609
12
            
}0
610
6
        }
611
18
    }
612
613
    /// Process a single event loop iteration.
614
    ///
615
    /// This awaits the next event from the TUI, matcher, or IPC listener,
616
    /// processes it, and returns. Use this in your own event loop when you
617
    /// need fine-grained control over the application lifecycle.
618
    ///
619
    /// Returns `Ok(true)` if skim should quit, `Ok(false)` to continue.
620
    ///
621
    /// # Errors
622
    ///
623
    /// Returns an error if the TUI cannot produce the next event or if event handling fails.
624
    ///
625
    /// # Panics
626
    ///
627
    /// Panics if `init_tui` has not been called before this method.
628
    ///
629
    /// # Example
630
    ///
631
    /// ```ignore
632
    /// while !skim.tick().await? {
633
    ///     // do your own work between ticks
634
    /// }
635
    /// ```
636
154
    pub async fn tick(&mut self) -> Result<bool> {
637
        // Retry the one-shot `start` event until the (bounded) event channel
638
        // accepts it. `start()`/`enter()` fire it eagerly, but if the channel was
639
        // momentarily full there, this guarantees it is not lost. Idempotent: the
640
        // `start_fired` guard makes every call after the first a no-op.
641
154
        self.fire_start_event();
642
154
        let matcher_interval = &mut self.matcher_interval;
643
154
        let items_available = self.app.item_pool.items_available.clone();
644
154
        select! {
645
154
            
event135
= self.tui.as_mut().expect("TUI should be initialized before the event loop can start").next() => {
646
135
                let evt = event.ok_or_eyre("Could not acquire next event")
?0
;
647
648
135
                if let Event::Key(
k50
) = &evt {
  Branch (648:24): [True: 48, False: 82]
+
608
12
                let _ = tui.event_tx.try_send(Event::Render);
609
12
            
}0
610
2
        }
611
14
    }
612
613
    /// Process a single event loop iteration.
614
    ///
615
    /// This awaits the next event from the TUI, matcher, or IPC listener,
616
    /// processes it, and returns. Use this in your own event loop when you
617
    /// need fine-grained control over the application lifecycle.
618
    ///
619
    /// Returns `Ok(true)` if skim should quit, `Ok(false)` to continue.
620
    ///
621
    /// # Errors
622
    ///
623
    /// Returns an error if the TUI cannot produce the next event or if event handling fails.
624
    ///
625
    /// # Panics
626
    ///
627
    /// Panics if `init_tui` has not been called before this method.
628
    ///
629
    /// # Example
630
    ///
631
    /// ```ignore
632
    /// while !skim.tick().await? {
633
    ///     // do your own work between ticks
634
    /// }
635
    /// ```
636
147
    pub async fn tick(&mut self) -> Result<bool> {
637
        // Retry the one-shot `start` event until the (bounded) event channel
638
        // accepts it. `start()`/`enter()` fire it eagerly, but if the channel was
639
        // momentarily full there, this guarantees it is not lost. Idempotent: the
640
        // `start_fired` guard makes every call after the first a no-op.
641
147
        self.fire_start_event();
642
147
        let matcher_interval = &mut self.matcher_interval;
643
147
        let items_available = self.app.item_pool.items_available.clone();
644
147
        select! {
645
147
            
event132
= self.tui.as_mut().expect("TUI should be initialized before the event loop can start").next() => {
646
132
                let evt = event.ok_or_eyre("Could not acquire next event")
?0
;
647
648
132
                if let Event::Key(
k50
) = &evt {
  Branch (648:24): [True: 48, False: 79]
 
  Branch (648:24): [True: 2, False: 3]
-
649
50
                  self.final_key.clone_from(k);
650
85
                } else {
651
85
                  self.final_event = evt.clone();
652
85
                }
653
654
655
                // Handle reload event separately
656
135
                if let Event::Reload(
new_cmd0
) = &evt {
  Branch (656:24): [True: 0, False: 130]
+
649
50
                  self.final_key.clone_from(k);
650
82
                } else {
651
82
                  self.final_event = evt.clone();
652
82
                }
653
654
655
                // Handle reload event separately
656
132
                if let Event::Reload(
new_cmd0
) = &evt {
  Branch (656:24): [True: 0, False: 127]
 
  Branch (656:24): [True: 0, False: 5]
-
657
0
                    self.handle_reload(&new_cmd.clone());
658
0
                } else {
659
135
                    self.app.handle_event(self.tui.as_mut().expect("TUI should be initialized before handling events"), &evt)
?0
;
660
                }
661
662
135
                if let Some(
action12
) = self.app.final_action.take() {
  Branch (662:24): [True: 10, False: 120]
+
657
0
                    self.handle_reload(&new_cmd.clone());
658
0
                } else {
659
132
                    self.app.handle_event(self.tui.as_mut().expect("TUI should be initialized before handling events"), &evt)
?0
;
660
                }
661
662
132
                if let Some(
action12
) = self.app.final_action.take() {
  Branch (662:24): [True: 10, False: 117]
 
  Branch (662:24): [True: 2, False: 3]
-
663
12
                    self.final_event = Event::Action(action);
664
123
                }
665
666
                // Check reader status and update
667
135
                self.check_reader();
668
            }
669
88
            () = async {
670
88
                match matcher_interval {
671
19
                    Some(interval) => { interval.tick().await; },
672
69
                    None => std::future::pending::<()>().await,
673
                }
674
5
            } => {
675
              // Check for a pending debounced restart (e.g. the user typed
676
              // while a match-all was running).  Checking here (every 10ms)
677
              // rather than only on the heartbeat (83ms) eliminates most of
678
              // the latency between query change and matcher restart.
679
5
              if self.app.pending_matcher_restart {
  Branch (679:18): [True: 0, False: 5]
+
663
12
                    self.final_event = Event::Action(action);
664
120
                }
665
666
                // Check reader status and update
667
132
                self.check_reader();
668
            }
669
82
            () = async {
670
82
                match matcher_interval {
671
9
                    Some(interval) => { interval.tick().await; },
672
73
                    None => std::future::pending::<()>().await,
673
                }
674
1
            } => {
675
              // Check for a pending debounced restart (e.g. the user typed
676
              // while a match-all was running).  Checking here (every 10ms)
677
              // rather than only on the heartbeat (83ms) eliminates most of
678
              // the latency between query change and matcher restart.
679
1
              if self.app.pending_matcher_restart {
  Branch (679:18): [True: 0, False: 1]
 
  Branch (679:18): [True: 0, False: 0]
-
680
0
                  self.app.restart_matcher(true);
681
5
              } else {
682
5
                  self.app.restart_matcher(false);
683
5
              }
684
              // Check if the matcher (or reader) has set the render flag and
685
              // flush a render event immediately instead of waiting for the
686
              // next heartbeat tick.  This can shave up to ~80ms of latency
687
              // when the heartbeat runs at 12 Hz.
688
5
              self.try_flush_render();
689
              // Once the reader has finished and the matcher has consumed all
690
              // items, stop the periodic interval — it can only produce empty
691
              // ticks from this point.  The `items_available` Notify branch
692
              // still handles the (rare) case of late-arriving items.
693
5
              if self.reader_done
  Branch (693:18): [True: 5, False: 0]
+
680
0
                  self.app.restart_matcher(true);
681
1
              } else {
682
1
                  self.app.restart_matcher(false);
683
1
              }
684
              // Check if the matcher (or reader) has set the render flag and
685
              // flush a render event immediately instead of waiting for the
686
              // next heartbeat tick.  This can shave up to ~80ms of latency
687
              // when the heartbeat runs at 12 Hz.
688
1
              self.try_flush_render();
689
              // Once the reader has finished and the matcher has consumed all
690
              // items, stop the periodic interval — it can only produce empty
691
              // ticks from this point.  The `items_available` Notify branch
692
              // still handles the (rare) case of late-arriving items.
693
1
              if self.reader_done
  Branch (693:18): [True: 1, False: 0]
 
  Branch (693:18): [True: 0, False: 0]
-
694
5
                  && self.app.matcher_control.stopped()
  Branch (694:22): [True: 3, False: 2]
+
694
1
                  && self.app.matcher_control.stopped()
  Branch (694:22): [True: 1, False: 0]
 
  Branch (694:22): [True: 0, False: 0]
-
695
3
                  && self.app.item_pool.num_not_taken() == 0
  Branch (695:22): [True: 3, False: 0]
+
695
1
                  && self.app.item_pool.num_not_taken() == 0
  Branch (695:22): [True: 1, False: 0]
 
  Branch (695:22): [True: 0, False: 0]
-
696
              {
697
3
                  trace!("matcher interval stopped: reader done, matcher idle, no pending items");
698
3
                  self.matcher_interval = None;
699
2
              }
700
            }
701
            // Wake immediately when new items arrive in the pool so the matcher
702
            // can pick them up without waiting for the next periodic interval.
703
154
            () = items_available.notified() => {
704
11
                self.app.restart_matcher(false);
705
11
                self.try_flush_render();
706
11
            }
707
            // IPC listener branch. `tokio::select!` does not allow `#[cfg]` on a
708
            // branch, so the branch stays but its body is gated: with the `listen`
709
            // feature off the future is a never-resolving `pending()` and the handler
710
            // is unreachable (`RemoteStream` is uninhabited).
711
114
            Ok(
stream3
) = async {
712
                #[cfg(feature = "listen")]
713
114
                if let Some(
l33
) = self.listener.as_ref() {
  Branch (713:24): [True: 33, False: 77]
-
  Branch (713:24): [True: 0, False: 4]
-
714
33
                    return interprocess::local_socket::traits::tokio::Listener::accept(l).await;
715
81
                }
716
81
                std::future::pending::<std::io::Result<RemoteStream>>().await
717
3
            } => {
718
                #[cfg(feature = "listen")]
719
                {
720
3
                    debug!("Listener accepted a connection");
721
3
                    let event_tx_clone_ipc = self.tui.as_ref().expect("TUI should be initialized before listening").event_tx.clone();
722
3
                    tokio::spawn(async move {
723
                        use tokio::io::AsyncBufReadExt;
724
3
                        let reader = tokio::io::BufReader::new(stream);
725
3
                        let mut lines = reader.lines();
726
6
                        while let Ok(Some(
line3
)) = lines.next_line().await {
  Branch (726:35): [True: 3, False: 0]
+
696
              {
697
1
                  trace!("matcher interval stopped: reader done, matcher idle, no pending items");
698
1
                  self.matcher_interval = None;
699
0
              }
700
            }
701
            // Wake immediately when new items arrive in the pool so the matcher
702
            // can pick them up without waiting for the next periodic interval.
703
147
            () = items_available.notified() => {
704
11
                self.app.restart_matcher(false);
705
11
                self.try_flush_render();
706
11
            }
707
            // IPC listener branch. `tokio::select!` does not allow `#[cfg]` on a
708
            // branch, so the branch stays but its body is gated: with the `listen`
709
            // feature off the future is a never-resolving `pending()` and the handler
710
            // is unreachable (`RemoteStream` is uninhabited).
711
113
            Ok(
stream3
) = async {
712
                #[cfg(feature = "listen")]
713
113
                if let Some(
l35
) = self.listener.as_ref() {
  Branch (713:24): [True: 35, False: 73]
+
  Branch (713:24): [True: 0, False: 5]
+
714
35
                    return interprocess::local_socket::traits::tokio::Listener::accept(l).await;
715
78
                }
716
78
                std::future::pending::<std::io::Result<RemoteStream>>().await
717
3
            } => {
718
                #[cfg(feature = "listen")]
719
                {
720
3
                    debug!("Listener accepted a connection");
721
3
                    let event_tx_clone_ipc = self.tui.as_ref().expect("TUI should be initialized before listening").event_tx.clone();
722
3
                    tokio::spawn(async move {
723
                        use tokio::io::AsyncBufReadExt;
724
3
                        let reader = tokio::io::BufReader::new(stream);
725
3
                        let mut lines = reader.lines();
726
6
                        while let Ok(Some(
line3
)) = lines.next_line().await {
  Branch (726:35): [True: 3, False: 0]
 
  Branch (726:35): [True: 0, False: 0]
 
727
3
                            debug!("listener: got {line}");
728
3
                            if let Ok(act) = ron::from_str::<Action>(&line) {
  Branch (728:36): [True: 3, False: 0]
 
  Branch (728:36): [True: 0, False: 0]
 
729
3
                                debug!("listener: parsed into action {act:?}");
730
3
                                if let Err(
e0
) = event_tx_clone_ipc.try_send(Event::Action(act)) {
  Branch (730:40): [True: 0, False: 3]
 
  Branch (730:40): [True: 0, False: 0]
-
731
0
                                    warn!("listener: failed to send action to backend: {e:?}");
732
3
                                }
733
0
                            }
734
                        }
735
0
                    });
736
                }
737
                #[cfg(not(feature = "listen"))]
738
                match stream {}
739
            }
740
        }
741
742
154
        Ok(self.app.should_quit)
743
154
    }
744
745
    /// Run the event loop on the current task until skim quits.
746
    ///
747
    /// This is a convenience wrapper around [`tick()`](Self::tick) that loops
748
    /// until the user accepts or aborts. Use `tick()` directly if you need
749
    /// to interleave your own logic between iterations.
750
    ///
751
    /// # Errors
752
    ///
753
    /// Returns an error if any tick in the event loop fails.
754
12
    pub async fn run(&mut self) -> Result<()> {
755
12
        self.matcher_interval = Some(tokio::time::interval(Duration::from_millis(10)));
756
12
        trace!("Starting event loop");
757
        loop {
758
154
            if self.tick().await
?0
{
  Branch (758:16): [True: 10, False: 138]
+
731
0
                                    warn!("listener: failed to send action to backend: {e:?}");
732
3
                                }
733
0
                            }
734
                        }
735
0
                    });
736
                }
737
                #[cfg(not(feature = "listen"))]
738
                match stream {}
739
            }
740
        }
741
742
147
        Ok(self.app.should_quit)
743
147
    }
744
745
    /// Run the event loop on the current task until skim quits.
746
    ///
747
    /// This is a convenience wrapper around [`tick()`](Self::tick) that loops
748
    /// until the user accepts or aborts. Use `tick()` directly if you need
749
    /// to interleave your own logic between iterations.
750
    ///
751
    /// # Errors
752
    ///
753
    /// Returns an error if any tick in the event loop fails.
754
12
    pub async fn run(&mut self) -> Result<()> {
755
12
        self.matcher_interval = Some(tokio::time::interval(Duration::from_millis(10)));
756
12
        trace!("Starting event loop");
757
        loop {
758
147
            if self.tick().await
?0
{
  Branch (758:16): [True: 10, False: 131]
 
  Branch (758:16): [True: 2, False: 4]
-
759
12
                break Ok(());
760
142
            }
761
        }
762
12
    }
763
764
    /// Spawn the event loop and run a user-provided future concurrently.
765
    ///
766
    /// This consumes `self`, spawns the event loop as a local task, and runs
767
    /// `user_task` alongside it. When the user accepts or aborts in the TUI,
768
    /// the event loop completes and the [`SkimOutput`] is returned — regardless
769
    /// of whether `user_task` has finished.
770
    ///
771
    /// Use this when you need to send items or do other work concurrently
772
    /// while the TUI is running.
773
    ///
774
    /// # Errors
775
    ///
776
    /// Returns an error if the event loop or the task join fails.
777
    ///
778
    /// # Example
779
    ///
780
    /// ```ignore
781
    /// let output = skim.run_until(async {
782
    ///     for i in 1..=10 {
783
    ///         tx.send(vec![Arc::new(format!("item {i}"))]);
784
    ///         tokio::time::sleep(Duration::from_millis(100)).await;
785
    ///     }
786
    /// }).await?;
787
    /// ```
788
0
    pub async fn run_until<F: Future + 'static>(mut self, user_task: F) -> Result<SkimOutput> {
789
0
        let local = tokio::task::LocalSet::new();
790
0
        local
791
0
            .run_until(async {
792
0
                let handle = tokio::task::spawn_local(async move {
793
0
                    self.run().await?;
794
0
                    Ok(self.output())
795
0
                });
796
0
                tokio::task::spawn_local(user_task);
797
0
                handle.await?
798
0
            })
799
0
            .await
800
0
    }
801
}
802
803
#[cfg(test)]
804
#[path = "skim_tests.rs"]
805
mod tests;
\ No newline at end of file +
759
12
                break Ok(());
760
135
            }
761
        }
762
12
    }
763
764
    /// Spawn the event loop and run a user-provided future concurrently.
765
    ///
766
    /// This consumes `self`, spawns the event loop as a local task, and runs
767
    /// `user_task` alongside it. When the user accepts or aborts in the TUI,
768
    /// the event loop completes and the [`SkimOutput`] is returned — regardless
769
    /// of whether `user_task` has finished.
770
    ///
771
    /// Use this when you need to send items or do other work concurrently
772
    /// while the TUI is running.
773
    ///
774
    /// # Errors
775
    ///
776
    /// Returns an error if the event loop or the task join fails.
777
    ///
778
    /// # Example
779
    ///
780
    /// ```ignore
781
    /// let output = skim.run_until(async {
782
    ///     for i in 1..=10 {
783
    ///         tx.send(vec![Arc::new(format!("item {i}"))]);
784
    ///         tokio::time::sleep(Duration::from_millis(100)).await;
785
    ///     }
786
    /// }).await?;
787
    /// ```
788
0
    pub async fn run_until<F: Future + 'static>(mut self, user_task: F) -> Result<SkimOutput> {
789
0
        let local = tokio::task::LocalSet::new();
790
0
        local
791
0
            .run_until(async {
792
0
                let handle = tokio::task::spawn_local(async move {
793
0
                    self.run().await?;
794
0
                    Ok(self.output())
795
0
                });
796
0
                tokio::task::spawn_local(user_task);
797
0
                handle.await?
798
0
            })
799
0
            .await
800
0
    }
801
}
802
803
#[cfg(test)]
804
#[path = "skim_tests.rs"]
805
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/skim_item.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/skim_item.rs.html index 9b361dee..c1b22c5e 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/skim_item.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/skim_item.rs.html @@ -1 +1 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/skim_item.rs
Line
Count
Source
1
use ratatui::text::Line;
2
use std::borrow::Cow;
3
use std::fmt::{Debug, Display};
4
5
use crate::{AsAny, DisplayContext, ItemPreview, PreviewContext};
6
7
/// A `SkimItem` defines what's been processed(fetched, matched, previewed and returned) by skim
8
///
9
/// # Downcast Example
10
/// Skim will return the item back, but in `Arc<dyn SkimItem>` form. We might want a reference
11
/// to the concrete type instead of trait object. Skim provide a somehow "complicated" way to
12
/// `downcast` it back to the reference of the original concrete type.
13
///
14
/// ```rust
15
/// use skim::prelude::*;
16
///
17
/// struct MyItem {}
18
/// impl SkimItem for MyItem {
19
///     fn text(&self) -> Cow<str> {
20
///         unimplemented!()
21
///     }
22
/// }
23
///
24
/// impl MyItem {
25
///     pub fn mutable(&mut self) -> i32 {
26
///         1
27
///     }
28
///
29
///     pub fn immutable(&self) -> i32 {
30
///         0
31
///     }
32
/// }
33
///
34
/// let mut ret: Arc<dyn SkimItem> = Arc::new(MyItem{});
35
/// let mutable: &mut MyItem = Arc::get_mut(&mut ret)
36
///     .expect("item is referenced by others")
37
///     .as_any_mut() // cast to Any
38
///     .downcast_mut::<MyItem>() // downcast to (mut) concrete type
39
///     .expect("something wrong with downcast");
40
/// assert_eq!(mutable.mutable(), 1);
41
///
42
/// let immutable: &MyItem = (*ret).as_any() // cast to Any
43
///     .downcast_ref::<MyItem>() // downcast to concrete type
44
///     .expect("something wrong with downcast");
45
/// assert_eq!(immutable.immutable(), 0)
46
/// ```
47
pub trait SkimItem: AsAny + Send + Sync + 'static {
48
    /// The string to be used for matching (without color)
49
    fn text(&self) -> Cow<'_, str>;
50
51
    /// The content to be displayed on the item list, could contain ANSI properties
52
109
    fn display(&self, context: DisplayContext) -> Line<'_> {
53
109
        context.to_line(self.text())
54
109
    }
55
56
    /// Custom preview content, default to `ItemPreview::Global` which will use global preview
57
    /// setting(i.e. the command set by `preview` option)
58
68
    fn preview(&self, _context: PreviewContext) -> ItemPreview {
59
68
        ItemPreview::Global
60
68
    }
61
62
    /// Get output text(after accept), default to `text()`
63
    ///
64
    /// Note that this function is intended to be used by the caller of skim and will not be used by
65
    /// skim. And since skim will return the item back in `SkimOutput`, if string is not what you
66
    /// want, you could still use `downcast` to retain the pointer to the original struct.
67
51
    fn output(&self) -> Cow<'_, str> {
68
51
        self.text()
69
51
    }
70
71
    /// Limit the matching ranges of the `get_text` of the item.
72
    /// providing (`start_byte`, `end_byte`) of the range
73
394
    fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> {
74
394
        None
75
394
    }
76
77
    /// Byte ranges of `text()` that are hidden from display (via `--hide-nth`).
78
    ///
79
    /// Characters inside these ranges are removed from the rendered line and ignored
80
    /// for match highlighting and horizontal scrolling, but stay part of `text()` so
81
    /// they remain searchable. Ranges are expressed as (`start_byte`, `end_byte`) and
82
    /// are expected to be sorted and non-overlapping. Returns `None` when nothing is
83
    /// hidden.
84
110
    fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
85
110
        None
86
110
    }
87
88
    /// Returns true if the item should be disabled
89
    /// Disabled items cannot be selected
90
338
    fn disabled(&self) -> bool {
91
338
        false
92
338
    }
93
}
94
95
//------------------------------------------------------------------------------
96
// Implement SkimItem for raw strings
97
98
impl<T: AsRef<str> + Send + Sync + 'static> SkimItem for T {
99
585
    fn text(&self) -> Cow<'_, str> {
100
585
        Cow::Borrowed(self.as_ref())
101
585
    }
102
}
103
104
impl Display for dyn SkimItem {
105
1
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106
1
        f.write_str(&self.text())
107
1
    }
108
}
109
impl Debug for dyn SkimItem {
110
1
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111
1
        f.write_fmt(format_args!("SkimItem {{ text: {} }}", self.text()))
112
1
    }
113
}
114
115
#[cfg(test)]
116
#[cfg_attr(coverage, coverage(off))]
117
mod tests {
118
    use super::*;
119
    use std::sync::Arc;
120
121
    #[test]
122
    fn blanket_impl_default_methods() {
123
        let item = "hello".to_string();
124
        assert_eq!(item.text(), "hello");
125
        // `output` defaults to `text`.
126
        assert_eq!(item.output(), "hello");
127
        assert!(item.get_matching_ranges().is_none());
128
        assert!(!item.disabled());
129
    }
130
131
    #[test]
132
    fn display_and_debug_for_trait_object() {
133
        let item: Arc<dyn SkimItem> = Arc::new("world".to_string());
134
        let as_dyn: &dyn SkimItem = &*item;
135
        assert_eq!(format!("{as_dyn}"), "world");
136
        assert!(format!("{as_dyn:?}").contains("world"));
137
    }
138
}
\ No newline at end of file +

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/skim_item.rs
Line
Count
Source
1
use ratatui::text::Line;
2
use std::borrow::Cow;
3
use std::fmt::{Debug, Display};
4
5
use crate::{AsAny, DisplayContext, ItemPreview, PreviewContext};
6
7
/// A `SkimItem` defines what's been processed(fetched, matched, previewed and returned) by skim
8
///
9
/// # Downcast Example
10
/// Skim will return the item back, but in `Arc<dyn SkimItem>` form. We might want a reference
11
/// to the concrete type instead of trait object. Skim provide a somehow "complicated" way to
12
/// `downcast` it back to the reference of the original concrete type.
13
///
14
/// ```rust
15
/// use skim::prelude::*;
16
///
17
/// struct MyItem {}
18
/// impl SkimItem for MyItem {
19
///     fn text(&self) -> Cow<str> {
20
///         unimplemented!()
21
///     }
22
/// }
23
///
24
/// impl MyItem {
25
///     pub fn mutable(&mut self) -> i32 {
26
///         1
27
///     }
28
///
29
///     pub fn immutable(&self) -> i32 {
30
///         0
31
///     }
32
/// }
33
///
34
/// let mut ret: Arc<dyn SkimItem> = Arc::new(MyItem{});
35
/// let mutable: &mut MyItem = Arc::get_mut(&mut ret)
36
///     .expect("item is referenced by others")
37
///     .as_any_mut() // cast to Any
38
///     .downcast_mut::<MyItem>() // downcast to (mut) concrete type
39
///     .expect("something wrong with downcast");
40
/// assert_eq!(mutable.mutable(), 1);
41
///
42
/// let immutable: &MyItem = (*ret).as_any() // cast to Any
43
///     .downcast_ref::<MyItem>() // downcast to concrete type
44
///     .expect("something wrong with downcast");
45
/// assert_eq!(immutable.immutable(), 0)
46
/// ```
47
pub trait SkimItem: AsAny + Send + Sync + 'static {
48
    /// The string to be used for matching (without color)
49
    fn text(&self) -> Cow<'_, str>;
50
51
    /// The content to be displayed on the item list, could contain ANSI properties
52
110
    fn display(&self, context: DisplayContext) -> Line<'_> {
53
110
        context.to_line(self.text())
54
110
    }
55
56
    /// Custom preview content, default to `ItemPreview::Global` which will use global preview
57
    /// setting(i.e. the command set by `preview` option)
58
68
    fn preview(&self, _context: PreviewContext) -> ItemPreview {
59
68
        ItemPreview::Global
60
68
    }
61
62
    /// Get output text(after accept), default to `text()`
63
    ///
64
    /// Note that this function is intended to be used by the caller of skim and will not be used by
65
    /// skim. And since skim will return the item back in `SkimOutput`, if string is not what you
66
    /// want, you could still use `downcast` to retain the pointer to the original struct.
67
51
    fn output(&self) -> Cow<'_, str> {
68
51
        self.text()
69
51
    }
70
71
    /// Limit the matching ranges of the `get_text` of the item.
72
    /// providing (`start_byte`, `end_byte`) of the range
73
394
    fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> {
74
394
        None
75
394
    }
76
77
    /// Byte ranges of `text()` that are hidden from display (via `--hide-nth`).
78
    ///
79
    /// Characters inside these ranges are removed from the rendered line and ignored
80
    /// for match highlighting and horizontal scrolling, but stay part of `text()` so
81
    /// they remain searchable. Ranges are expressed as (`start_byte`, `end_byte`) and
82
    /// are expected to be sorted and non-overlapping. Returns `None` when nothing is
83
    /// hidden.
84
111
    fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
85
111
        None
86
111
    }
87
88
    /// Returns true if the item should be disabled
89
    /// Disabled items cannot be selected
90
345
    fn disabled(&self) -> bool {
91
345
        false
92
345
    }
93
}
94
95
//------------------------------------------------------------------------------
96
// Implement SkimItem for raw strings
97
98
impl<T: AsRef<str> + Send + Sync + 'static> SkimItem for T {
99
589
    fn text(&self) -> Cow<'_, str> {
100
589
        Cow::Borrowed(self.as_ref())
101
589
    }
102
}
103
104
impl Display for dyn SkimItem {
105
1
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106
1
        f.write_str(&self.text())
107
1
    }
108
}
109
impl Debug for dyn SkimItem {
110
1
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111
1
        f.write_fmt(format_args!("SkimItem {{ text: {} }}", self.text()))
112
1
    }
113
}
114
115
#[cfg(test)]
116
#[cfg_attr(coverage, coverage(off))]
117
mod tests {
118
    use super::*;
119
    use std::sync::Arc;
120
121
    #[test]
122
    fn blanket_impl_default_methods() {
123
        let item = "hello".to_string();
124
        assert_eq!(item.text(), "hello");
125
        // `output` defaults to `text`.
126
        assert_eq!(item.output(), "hello");
127
        assert!(item.get_matching_ranges().is_none());
128
        assert!(!item.disabled());
129
    }
130
131
    #[test]
132
    fn display_and_debug_for_trait_object() {
133
        let item: Arc<dyn SkimItem> = Arc::new("world".to_string());
134
        let as_dyn: &dyn SkimItem = &*item;
135
        assert_eq!(format!("{as_dyn}"), "world");
136
        assert!(format!("{as_dyn:?}").contains("world"));
137
    }
138
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/spinlock.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/spinlock.rs.html index 6eeae19c..aa12d373 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/spinlock.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/spinlock.rs.html @@ -1,8 +1,8 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/spinlock.rs
Line
Count
Source
1
//! `SpinLock` implemented using `AtomicBool`
2
//! Just like Mutex except:
3
//!
4
//! 1. It uses CAS for locking, more efficient in low contention
5
//! 2. Use `.lock()` instead of `.lock().unwrap()` to retrieve the guard.
6
//! 3. It doesn't handle poison so data is still available on thread panic.
7
use std::cell::UnsafeCell;
8
use std::ops::{Deref, DerefMut};
9
use std::sync::atomic::{AtomicBool, Ordering};
10
11
/// A spin lock that uses busy-waiting instead of OS blocking
12
#[derive(Default)]
13
pub struct SpinLock<T: ?Sized> {
14
    locked: AtomicBool,
15
    data: UnsafeCell<T>,
16
}
17
18
unsafe impl<T: ?Sized + Send> Send for SpinLock<T> {}
19
unsafe impl<T: ?Sized + Send> Sync for SpinLock<T> {}
20
21
/// RAII guard for a spin lock, automatically releases the lock when dropped
22
pub struct SpinLockGuard<'a, T: ?Sized + 'a> {
23
    // funny underscores due to how Deref/DerefMut currently work (they
24
    // disregard field privacy).
25
    __lock: &'a SpinLock<T>,
26
}
27
28
impl<'a, T: ?Sized + 'a> SpinLockGuard<'a, T> {
29
    /// Creates a new guard for the given lock
30
25.2k
    pub fn new(pool: &'a SpinLock<T>) -> SpinLockGuard<'a, T> {
31
25.2k
        Self { __lock: pool }
32
25.2k
    }
33
}
34
35
unsafe impl<T: ?Sized + Sync> Sync for SpinLockGuard<'_, T> {}
36
37
impl<T> SpinLock<T> {
38
    /// Creates a new unlocked spin lock containing the given value
39
2.07k
    pub fn new(t: T) -> SpinLock<T> {
40
2.07k
        Self {
41
2.07k
            locked: AtomicBool::new(false),
42
2.07k
            data: UnsafeCell::new(t),
43
2.07k
        }
44
2.07k
    }
45
}
46
47
impl<T: ?Sized> SpinLock<T> {
48
    /// Acquires the lock, blocking the current thread until it succeeds
49
25.2k
    pub fn lock(&self) -> SpinLockGuard<'_, T> {
50
44.0k
        while self
  Branch (50:15): [True: 15.3k, False: 15.5k]
-  Branch (50:15): [True: 0, False: 3.48k]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/spinlock.rs
Line
Count
Source
1
//! `SpinLock` implemented using `AtomicBool`
2
//! Just like Mutex except:
3
//!
4
//! 1. It uses CAS for locking, more efficient in low contention
5
//! 2. Use `.lock()` instead of `.lock().unwrap()` to retrieve the guard.
6
//! 3. It doesn't handle poison so data is still available on thread panic.
7
use std::cell::UnsafeCell;
8
use std::ops::{Deref, DerefMut};
9
use std::sync::atomic::{AtomicBool, Ordering};
10
11
/// A spin lock that uses busy-waiting instead of OS blocking
12
#[derive(Default)]
13
pub struct SpinLock<T: ?Sized> {
14
    locked: AtomicBool,
15
    data: UnsafeCell<T>,
16
}
17
18
unsafe impl<T: ?Sized + Send> Send for SpinLock<T> {}
19
unsafe impl<T: ?Sized + Send> Sync for SpinLock<T> {}
20
21
/// RAII guard for a spin lock, automatically releases the lock when dropped
22
pub struct SpinLockGuard<'a, T: ?Sized + 'a> {
23
    // funny underscores due to how Deref/DerefMut currently work (they
24
    // disregard field privacy).
25
    __lock: &'a SpinLock<T>,
26
}
27
28
impl<'a, T: ?Sized + 'a> SpinLockGuard<'a, T> {
29
    /// Creates a new guard for the given lock
30
25.1k
    pub fn new(pool: &'a SpinLock<T>) -> SpinLockGuard<'a, T> {
31
25.1k
        Self { __lock: pool }
32
25.1k
    }
33
}
34
35
unsafe impl<T: ?Sized + Sync> Sync for SpinLockGuard<'_, T> {}
36
37
impl<T> SpinLock<T> {
38
    /// Creates a new unlocked spin lock containing the given value
39
2.07k
    pub fn new(t: T) -> SpinLock<T> {
40
2.07k
        Self {
41
2.07k
            locked: AtomicBool::new(false),
42
2.07k
            data: UnsafeCell::new(t),
43
2.07k
        }
44
2.07k
    }
45
}
46
47
impl<T: ?Sized> SpinLock<T> {
48
    /// Acquires the lock, blocking the current thread until it succeeds
49
25.1k
    pub fn lock(&self) -> SpinLockGuard<'_, T> {
50
40.4k
        while self
  Branch (50:15): [True: 15.2k, False: 15.4k]
+  Branch (50:15): [True: 0, False: 3.46k]
   Branch (50:15): [True: 0, False: 2]
-  Branch (50:15): [True: 1, False: 182]
+  Branch (50:15): [True: 17, False: 182]
   Branch (50:15): [True: 0, False: 66]
-  Branch (50:15): [True: 3.41k, False: 6.00k]
+  Branch (50:15): [True: 0, False: 6.00k]
   Branch (50:15): [True: 0, False: 2]
-
51
44.0k
            .locked
52
44.0k
            .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed)
53
44.0k
            .is_err()
54
18.7k
        {
55
18.7k
            core::hint::spin_loop();
56
18.7k
        }
57
25.2k
        SpinLockGuard::new(self)
58
25.2k
    }
59
}
60
61
impl<T: ?Sized> Deref for SpinLockGuard<'_, T> {
62
    type Target = T;
63
64
15.9k
    fn deref(&self) -> &T {
65
15.9k
        unsafe { &*self.__lock.data.get() }
66
15.9k
    }
67
}
68
69
impl<T: ?Sized> DerefMut for SpinLockGuard<'_, T> {
70
10.1k
    fn deref_mut(&mut self) -> &mut T {
71
10.1k
        unsafe { &mut *self.__lock.data.get() }
72
10.1k
    }
73
}
74
75
impl<T: ?Sized> Drop for SpinLockGuard<'_, T> {
76
    #[inline]
77
25.2k
    fn drop(&mut self) {
78
25.2k
        self.__lock.locked.store(false, Ordering::Release);
79
25.2k
    }
80
}
81
82
#[cfg(test)]
83
#[cfg_attr(coverage, coverage(off))]
84
mod tests {
85
    use super::*;
86
    use std::sync::Arc;
87
    use std::sync::mpsc::channel;
88
    use std::thread;
89
90
    #[test]
91
    fn smoke() {
92
        let m = SpinLock::new(());
93
        drop(m.lock());
94
        drop(m.lock());
95
    }
96
97
    #[test]
98
    fn lots_and_lots() {
99
        const J: u32 = 1000;
100
        const K: u32 = 3;
101
102
        fn inc(m: &SpinLock<u32>) {
103
            for _ in 0..J {
104
                *m.lock() += 1;
105
            }
106
        }
107
108
        let m = Arc::new(SpinLock::new(0));
109
        let (tx, rx) = channel();
110
        for _ in 0..K {
111
            let tx2 = tx.clone();
112
            let m2 = m.clone();
113
            thread::spawn(move || {
114
                inc(&m2);
115
                tx2.send(()).unwrap();
116
            });
117
            let tx2 = tx.clone();
118
            let m2 = m.clone();
119
            thread::spawn(move || {
120
                inc(&m2);
121
                tx2.send(()).unwrap();
122
            });
123
        }
124
125
        drop(tx);
126
        for _ in 0..2 * K {
127
            rx.recv().unwrap();
128
        }
129
        assert_eq!(*m.lock(), J * K * 2);
130
    }
131
132
    #[test]
133
    fn test_mutex_unsized() {
134
        let mutex = SpinLock::new([1, 2, 3]);
135
        {
136
            let b = &mut *mutex.lock();
137
            b[0] = 4;
138
            b[2] = 5;
139
        }
140
        let comp: &[i32] = &[4, 2, 5];
141
        assert_eq!(&*mutex.lock(), comp);
142
    }
143
}
\ No newline at end of file +
51
40.4k
            .locked
52
40.4k
            .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed)
53
40.4k
            .is_err()
54
15.2k
        {
55
15.2k
            core::hint::spin_loop();
56
15.2k
        }
57
25.1k
        SpinLockGuard::new(self)
58
25.1k
    }
59
}
60
61
impl<T: ?Sized> Deref for SpinLockGuard<'_, T> {
62
    type Target = T;
63
64
15.8k
    fn deref(&self) -> &T {
65
15.8k
        unsafe { &*self.__lock.data.get() }
66
15.8k
    }
67
}
68
69
impl<T: ?Sized> DerefMut for SpinLockGuard<'_, T> {
70
10.0k
    fn deref_mut(&mut self) -> &mut T {
71
10.0k
        unsafe { &mut *self.__lock.data.get() }
72
10.0k
    }
73
}
74
75
impl<T: ?Sized> Drop for SpinLockGuard<'_, T> {
76
    #[inline]
77
25.1k
    fn drop(&mut self) {
78
25.1k
        self.__lock.locked.store(false, Ordering::Release);
79
25.1k
    }
80
}
81
82
#[cfg(test)]
83
#[cfg_attr(coverage, coverage(off))]
84
mod tests {
85
    use super::*;
86
    use std::sync::Arc;
87
    use std::sync::mpsc::channel;
88
    use std::thread;
89
90
    #[test]
91
    fn smoke() {
92
        let m = SpinLock::new(());
93
        drop(m.lock());
94
        drop(m.lock());
95
    }
96
97
    #[test]
98
    fn lots_and_lots() {
99
        const J: u32 = 1000;
100
        const K: u32 = 3;
101
102
        fn inc(m: &SpinLock<u32>) {
103
            for _ in 0..J {
104
                *m.lock() += 1;
105
            }
106
        }
107
108
        let m = Arc::new(SpinLock::new(0));
109
        let (tx, rx) = channel();
110
        for _ in 0..K {
111
            let tx2 = tx.clone();
112
            let m2 = m.clone();
113
            thread::spawn(move || {
114
                inc(&m2);
115
                tx2.send(()).unwrap();
116
            });
117
            let tx2 = tx.clone();
118
            let m2 = m.clone();
119
            thread::spawn(move || {
120
                inc(&m2);
121
                tx2.send(()).unwrap();
122
            });
123
        }
124
125
        drop(tx);
126
        for _ in 0..2 * K {
127
            rx.recv().unwrap();
128
        }
129
        assert_eq!(*m.lock(), J * K * 2);
130
    }
131
132
    #[test]
133
    fn test_mutex_unsized() {
134
        let mutex = SpinLock::new([1, 2, 3]);
135
        {
136
            let b = &mut *mutex.lock();
137
            b[0] = 4;
138
            b[2] = 5;
139
        }
140
        let comp: &[i32] = &[4, 2, 5];
141
        assert_eq!(&*mutex.lock(), comp);
142
    }
143
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/theme.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/theme.rs.html index d5adb42a..bdddb619 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/theme.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/theme.rs.html @@ -1,16 +1,16 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/theme.rs
Line
Count
Source
1
//! Handle the color theme
2
use ratatui::style::{Color, Modifier, Style};
3
4
use crate::options::SkimOptions;
5
6
/// The color scheme of skim's UI
7
///
8
/// <pre>
9
/// +----------------+
10
/// | >selected line |  --> selected & normal(fg/bg) & matched
11
/// |> current line  |  --> cursor & current & current_match
12
/// |  normal line   |
13
/// |\ 8/10          |  --> spinner & info
14
/// |> query         |  --> prompt & query
15
/// +----------------+
16
/// </pre>
17
#[derive(Copy, Clone, Debug)]
18
pub struct ColorTheme {
19
    /// Non-selected lines and general text
20
    pub normal: Style,
21
    /// Matched text on non-current lines
22
    pub matched: Style,
23
    /// Current line, non-matched text
24
    pub current: Style,
25
    /// Current line, matched text
26
    pub current_match: Style,
27
    /// Query text/input
28
    pub query: Style,
29
    /// Spinner
30
    pub spinner: Style,
31
    /// Info (outside of spinner)
32
    pub info: Style,
33
    /// Prompt prefix
34
    pub prompt: Style,
35
    /// Cursor/Selector/pointer (prefix of current item)
36
    pub cursor: Style,
37
    /// Multi-selector/marker (prefix of selected items)
38
    pub selected: Style,
39
    /// Header lines
40
    pub header: Style,
41
    /// Border
42
    pub border: Style,
43
    /// Scrollbar thumb on the item list
44
    pub scrollbar: Style,
45
}
46
47
impl Default for ColorTheme {
48
    /// Theme defaults to Dark256
49
293
    fn default() -> Self {
50
293
        ColorTheme::dark256()
51
293
    }
52
}
53
54
#[allow(dead_code)]
55
impl ColorTheme {
56
    /// Setup the theme from the skim options
57
    #[must_use]
58
393
    pub fn init_from_options(options: &SkimOptions) -> ColorTheme {
59
        // register
60
393
        if let Some(
color7
) = options.color.clone() {
  Branch (60:16): [True: 6, False: 368]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/theme.rs
Line
Count
Source
1
//! Handle the color theme
2
use ratatui::style::{Color, Modifier, Style};
3
4
use crate::options::SkimOptions;
5
6
/// The color scheme of skim's UI
7
///
8
/// <pre>
9
/// +----------------+
10
/// | >selected line |  --> selected & normal(fg/bg) & matched
11
/// |> current line  |  --> cursor & current & current_match
12
/// |  normal line   |
13
/// |\ 8/10          |  --> spinner & info
14
/// |> query         |  --> prompt & query
15
/// +----------------+
16
/// </pre>
17
#[derive(Copy, Clone, Debug)]
18
pub struct ColorTheme {
19
    /// Non-selected lines and general text
20
    pub normal: Style,
21
    /// Matched text on non-current lines
22
    pub matched: Style,
23
    /// Current line, non-matched text
24
    pub current: Style,
25
    /// Current line, matched text
26
    pub current_match: Style,
27
    /// Query text/input
28
    pub query: Style,
29
    /// Spinner
30
    pub spinner: Style,
31
    /// Info (outside of spinner)
32
    pub info: Style,
33
    /// Prompt prefix
34
    pub prompt: Style,
35
    /// Cursor/Selector/pointer (prefix of current item)
36
    pub cursor: Style,
37
    /// Multi-selector/marker (prefix of selected items)
38
    pub selected: Style,
39
    /// Header lines
40
    pub header: Style,
41
    /// Border
42
    pub border: Style,
43
    /// Scrollbar thumb on the item list
44
    pub scrollbar: Style,
45
}
46
47
impl Default for ColorTheme {
48
    /// Theme defaults to Dark256
49
293
    fn default() -> Self {
50
293
        ColorTheme::dark256()
51
293
    }
52
}
53
54
#[allow(dead_code)]
55
impl ColorTheme {
56
    /// Setup the theme from the skim options
57
    #[must_use]
58
393
    pub fn init_from_options(options: &SkimOptions) -> ColorTheme {
59
        // register
60
393
        if let Some(
color6
) = options.color.clone() {
  Branch (60:16): [True: 5, False: 369]
 
  Branch (60:16): [True: 1, False: 18]
-
61
7
            ColorTheme::from_options(&color)
62
        } else {
63
            // Check for NO_COLOR environment variable
64
386
            match std::env::var_os("NO_COLOR") {
65
2
                Some(
no_color1
) if !no_color.is_empty(
)1
=>
ColorTheme::none1
(),
  Branch (65:35): [True: 0, False: 0]
+
61
6
            ColorTheme::from_options(&color)
62
        } else {
63
            // Check for NO_COLOR environment variable
64
387
            match std::env::var_os("NO_COLOR") {
65
2
                Some(
no_color1
) if !no_color.is_empty(
)1
=>
ColorTheme::none1
(),
  Branch (65:35): [True: 0, False: 0]
 
  Branch (65:35): [True: 1, False: 1]
-
66
385
                _ => ColorTheme::dark256(),
67
            }
68
        }
69
393
    }
70
71
800
    fn none() -> Self {
72
800
        let def = Style::default();
73
800
        Self {
74
800
            spinner: Style::default().bold(),
75
800
            normal: def,
76
800
            matched: def,
77
800
            current: def,
78
800
            current_match: def,
79
800
            query: def,
80
800
            info: def,
81
800
            prompt: def,
82
800
            cursor: def,
83
800
            selected: def,
84
800
            header: def,
85
800
            border: def,
86
800
            scrollbar: def,
87
800
        }
88
800
    }
89
90
2
    fn bw() -> Self {
91
2
        let base = ColorTheme::none();
92
2
        ColorTheme {
93
2
            matched: base.matched.underlined(),
94
2
            current: base.current.reversed(),
95
2
            current_match: base.current_match.reversed().underlined(),
96
2
            ..base
97
2
        }
98
2
    }
99
100
4
    fn default16() -> Self {
101
4
        let base = ColorTheme::none();
102
4
        ColorTheme {
103
4
            matched: base.matched.fg(Color::Green),
104
4
            current: base.current.fg(Color::Yellow),
105
4
            current_match: base.current_match.fg(Color::Green),
106
4
            spinner: base.spinner.fg(Color::Green),
107
4
            info: base.info.fg(Color::White),
108
4
            prompt: base.prompt.fg(Color::Blue),
109
4
            cursor: base.cursor.fg(Color::Red),
110
4
            selected: base.selected.fg(Color::Magenta),
111
4
            header: base.header.fg(Color::Cyan),
112
4
            border: base.border.fg(Color::Black),
113
4
            scrollbar: base.scrollbar.fg(Color::Black),
114
4
            ..base
115
4
        }
116
4
    }
117
118
773
    fn dark256() -> Self {
119
773
        let base = ColorTheme::none();
120
773
        ColorTheme {
121
773
            matched: base.matched.fg(Color::Indexed(108)).bg(Color::Indexed(0)),
122
773
            current: base.current.bg(Color::Indexed(236)),
123
773
            current_match: base.current_match.fg(Color::Indexed(151)).bg(Color::Indexed(236)),
124
773
            spinner: base.spinner.fg(Color::Indexed(148)),
125
773
            info: base.info.fg(Color::Indexed(144)),
126
773
            prompt: base.prompt.fg(Color::Indexed(110)),
127
773
            cursor: base.cursor.fg(Color::Indexed(161)),
128
773
            selected: base.selected.fg(Color::Indexed(168)),
129
773
            header: base.header.fg(Color::Indexed(109)),
130
773
            border: base.border.fg(Color::Indexed(59)),
131
773
            scrollbar: base.scrollbar.fg(Color::Indexed(59)),
132
773
            ..base
133
773
        }
134
773
    }
135
136
2
    fn molokai256() -> Self {
137
2
        let base = ColorTheme::none();
138
2
        ColorTheme {
139
2
            matched: base.matched.fg(Color::Indexed(234)).bg(Color::Indexed(186)),
140
2
            current: base.current.bg(Color::Indexed(236)),
141
2
            current_match: base.current_match.fg(Color::Indexed(234)).bg(Color::Indexed(186)),
142
2
            spinner: base.spinner.fg(Color::Indexed(148)),
143
2
            info: base.info.fg(Color::Indexed(144)),
144
2
            prompt: base.prompt.fg(Color::Indexed(110)),
145
2
            cursor: base.cursor.fg(Color::Indexed(161)),
146
2
            selected: base.selected.fg(Color::Indexed(168)),
147
2
            header: base.header.fg(Color::Indexed(109)),
148
2
            border: base.border.fg(Color::Indexed(59)),
149
2
            scrollbar: base.scrollbar.fg(Color::Indexed(59)),
150
2
            ..base
151
2
        }
152
2
    }
153
154
2
    fn light256() -> Self {
155
2
        let base = ColorTheme::none();
156
2
        ColorTheme {
157
2
            matched: base.matched.fg(Color::Indexed(0)).bg(Color::Indexed(220)),
158
2
            current: base.current.bg(Color::Indexed(251)),
159
2
            current_match: base.current_match.fg(Color::Indexed(66)).bg(Color::Indexed(251)),
160
2
            spinner: base.spinner.fg(Color::Indexed(65)),
161
2
            info: base.info.fg(Color::Indexed(101)),
162
2
            prompt: base.prompt.fg(Color::Indexed(25)),
163
2
            cursor: base.cursor.fg(Color::Indexed(161)),
164
2
            selected: base.selected.fg(Color::Indexed(168)),
165
2
            header: base.header.fg(Color::Indexed(31)),
166
2
            border: base.border.fg(Color::Indexed(145)),
167
2
            scrollbar: base.scrollbar.fg(Color::Indexed(145)),
168
2
            ..base
169
2
        }
170
2
    }
171
172
    #[allow(unused_variables)]
173
3
    fn catppuccin_mocha() -> Self {
174
3
        let base = ColorTheme::none();
175
3
        let text = Color::Rgb(205, 214, 244);
176
3
        let subtext0 = Color::Rgb(166, 173, 200);
177
3
        let subtext1 = Color::Rgb(186, 194, 222);
178
3
        let overlay0 = Color::Rgb(108, 112, 134);
179
3
        let surface0 = Color::Rgb(49, 50, 68);
180
3
        let blue = Color::Rgb(137, 180, 250);
181
3
        let red = Color::Rgb(243, 139, 168);
182
3
        let lavender = Color::Rgb(180, 190, 254);
183
3
        let sapphire = Color::Rgb(116, 199, 236);
184
3
        Self {
185
3
            normal: base.normal.fg(text),
186
3
            matched: base.matched.fg(blue).underlined(),
187
3
            current: base.current.bg(surface0),
188
3
            current_match: base.current_match.fg(red).underlined(),
189
3
            query: base.query.fg(text),
190
3
            spinner: base.spinner.fg(subtext1).bold(),
191
3
            info: base.info.fg(subtext1),
192
3
            prompt: base.prompt.fg(lavender),
193
3
            cursor: base.cursor.fg(red),
194
3
            selected: base.selected.fg(red),
195
3
            header: base.header.fg(subtext1),
196
3
            border: base.header.fg(lavender),
197
3
            scrollbar: base.scrollbar.fg(overlay0),
198
3
        }
199
3
    }
200
    #[allow(unused_variables)]
201
3
    fn catppuccin_macchiato() -> Self {
202
3
        let base = ColorTheme::none();
203
3
        let text = Color::Rgb(202, 211, 245);
204
3
        let subtext0 = Color::Rgb(165, 173, 203);
205
3
        let subtext1 = Color::Rgb(184, 192, 224);
206
3
        let overlay0 = Color::Rgb(110, 115, 141);
207
3
        let surface0 = Color::Rgb(54, 58, 79);
208
3
        let blue = Color::Rgb(138, 173, 244);
209
3
        let red = Color::Rgb(237, 135, 150);
210
3
        let lavender = Color::Rgb(183, 189, 248);
211
3
        let sapphire = Color::Rgb(125, 196, 228);
212
3
        Self {
213
3
            normal: base.normal.fg(text),
214
3
            matched: base.matched.fg(blue).underlined(),
215
3
            current: base.current.bg(surface0),
216
3
            current_match: base.current_match.fg(red).underlined(),
217
3
            query: base.query.fg(text),
218
3
            spinner: base.spinner.fg(subtext1).bold(),
219
3
            info: base.info.fg(subtext1),
220
3
            prompt: base.prompt.fg(lavender),
221
3
            cursor: base.cursor.fg(red),
222
3
            selected: base.selected.fg(red),
223
3
            header: base.header.fg(subtext1),
224
3
            border: base.header.fg(lavender),
225
3
            scrollbar: base.scrollbar.fg(overlay0),
226
3
        }
227
3
    }
228
    #[allow(unused_variables)]
229
3
    fn catppuccin_latte() -> Self {
230
3
        let base = ColorTheme::none();
231
3
        let text = Color::Rgb(76, 79, 105);
232
3
        let subtext0 = Color::Rgb(108, 111, 133);
233
3
        let subtext1 = Color::Rgb(92, 95, 119);
234
3
        let overlay0 = Color::Rgb(156, 160, 176);
235
3
        let surface0 = Color::Rgb(204, 208, 218);
236
3
        let blue = Color::Rgb(30, 102, 245);
237
3
        let red = Color::Rgb(210, 15, 57);
238
3
        let lavender = Color::Rgb(114, 135, 253);
239
3
        let sapphire = Color::Rgb(32, 159, 181);
240
3
        Self {
241
3
            normal: base.normal.fg(text),
242
3
            matched: base.matched.fg(blue).underlined(),
243
3
            current: base.current.bg(surface0),
244
3
            current_match: base.current_match.fg(red).underlined(),
245
3
            query: base.query.fg(text),
246
3
            spinner: base.spinner.fg(subtext1).bold(),
247
3
            info: base.info.fg(subtext1),
248
3
            prompt: base.prompt.fg(lavender),
249
3
            cursor: base.cursor.fg(red),
250
3
            selected: base.selected.fg(red),
251
3
            header: base.header.fg(subtext1),
252
3
            border: base.header.fg(lavender),
253
3
            scrollbar: base.scrollbar.fg(overlay0),
254
3
        }
255
3
    }
256
    #[allow(unused_variables)]
257
3
    fn catppuccin_frappe() -> Self {
258
3
        let base = ColorTheme::none();
259
3
        let text = Color::Rgb(198, 208, 245);
260
3
        let subtext0 = Color::Rgb(165, 173, 206);
261
3
        let subtext1 = Color::Rgb(181, 191, 226);
262
3
        let overlay0 = Color::Rgb(115, 121, 148);
263
3
        let surface0 = Color::Rgb(65, 69, 89);
264
3
        let blue = Color::Rgb(140, 170, 238);
265
3
        let red = Color::Rgb(231, 130, 132);
266
3
        let lavender = Color::Rgb(186, 187, 241);
267
3
        let sapphire = Color::Rgb(133, 193, 220);
268
3
        Self {
269
3
            normal: base.normal.fg(text),
270
3
            matched: base.matched.fg(blue).underlined(),
271
3
            current: base.current.bg(surface0),
272
3
            current_match: base.current_match.fg(red).underlined(),
273
3
            query: base.query.fg(text),
274
3
            spinner: base.spinner.fg(subtext1).bold(),
275
3
            info: base.info.fg(subtext1),
276
3
            prompt: base.prompt.fg(lavender),
277
3
            cursor: base.cursor.fg(red),
278
3
            selected: base.selected.fg(red),
279
3
            header: base.header.fg(subtext1),
280
3
            border: base.header.fg(lavender),
281
3
            scrollbar: base.scrollbar.fg(overlay0),
282
3
        }
283
3
    }
284
285
79
    fn set_color(&mut self, name: &str, spec: &str) {
286
79
        let spec_parts: Vec<_> = spec.split(&['+', ':']).collect();
287
288
        // Compute modifiers
289
79
        let mut modifier = Modifier::empty();
290
79
        for 
part23
in spec_parts.iter().skip(1) {
291
23
            if 
matches!2
(*part, "x" |
"regular"22
) {
292
2
                modifier = Modifier::empty();
293
2
            } else {
294
21
                modifier |= match *part {
295
21
                    "b" | 
"bold"20
=>
Modifier::BOLD7
,
296
14
                    "u" | 
"underlined"13
=>
Modifier::UNDERLINED7
,
297
7
                    "c" | 
"crossed-out"6
=>
Modifier::CROSSED_OUT1
,
298
6
                    "d" | 
"dim"5
=>
Modifier::DIM1
,
299
5
                    "i" | 
"italic"4
=>
Modifier::ITALIC2
,
300
3
                    "r" | 
"reverse"2
=>
Modifier::REVERSED2
,
301
1
                    m => {
302
1
                        debug!("Unknown modifier '{m}'");
303
1
                        Modifier::empty()
304
                    }
305
                };
306
            }
307
        }
308
        // Apply - check for layer suffixes (_fg, -fg, _bg, -bg, _u, -u, etc.)
309
79
        let (component_name, layer) = if name.ends_with("_fg") || 
name78
.
ends_with78
("-fg") {
  Branch (309:42): [True: 0, False: 12]
-  Branch (309:67): [True: 0, False: 12]
+
66
386
                _ => ColorTheme::dark256(),
67
            }
68
        }
69
393
    }
70
71
800
    fn none() -> Self {
72
800
        let def = Style::default();
73
800
        Self {
74
800
            spinner: Style::default().bold(),
75
800
            normal: def,
76
800
            matched: def,
77
800
            current: def,
78
800
            current_match: def,
79
800
            query: def,
80
800
            info: def,
81
800
            prompt: def,
82
800
            cursor: def,
83
800
            selected: def,
84
800
            header: def,
85
800
            border: def,
86
800
            scrollbar: def,
87
800
        }
88
800
    }
89
90
2
    fn bw() -> Self {
91
2
        let base = ColorTheme::none();
92
2
        ColorTheme {
93
2
            matched: base.matched.underlined(),
94
2
            current: base.current.reversed(),
95
2
            current_match: base.current_match.reversed().underlined(),
96
2
            ..base
97
2
        }
98
2
    }
99
100
4
    fn default16() -> Self {
101
4
        let base = ColorTheme::none();
102
4
        ColorTheme {
103
4
            matched: base.matched.fg(Color::Green),
104
4
            current: base.current.fg(Color::Yellow),
105
4
            current_match: base.current_match.fg(Color::Green),
106
4
            spinner: base.spinner.fg(Color::Green),
107
4
            info: base.info.fg(Color::White),
108
4
            prompt: base.prompt.fg(Color::Blue),
109
4
            cursor: base.cursor.fg(Color::Red),
110
4
            selected: base.selected.fg(Color::Magenta),
111
4
            header: base.header.fg(Color::Cyan),
112
4
            border: base.border.fg(Color::Black),
113
4
            scrollbar: base.scrollbar.fg(Color::Black),
114
4
            ..base
115
4
        }
116
4
    }
117
118
773
    fn dark256() -> Self {
119
773
        let base = ColorTheme::none();
120
773
        ColorTheme {
121
773
            matched: base.matched.fg(Color::Indexed(108)).bg(Color::Indexed(0)),
122
773
            current: base.current.bg(Color::Indexed(236)),
123
773
            current_match: base.current_match.fg(Color::Indexed(151)).bg(Color::Indexed(236)),
124
773
            spinner: base.spinner.fg(Color::Indexed(148)),
125
773
            info: base.info.fg(Color::Indexed(144)),
126
773
            prompt: base.prompt.fg(Color::Indexed(110)),
127
773
            cursor: base.cursor.fg(Color::Indexed(161)),
128
773
            selected: base.selected.fg(Color::Indexed(168)),
129
773
            header: base.header.fg(Color::Indexed(109)),
130
773
            border: base.border.fg(Color::Indexed(59)),
131
773
            scrollbar: base.scrollbar.fg(Color::Indexed(59)),
132
773
            ..base
133
773
        }
134
773
    }
135
136
2
    fn molokai256() -> Self {
137
2
        let base = ColorTheme::none();
138
2
        ColorTheme {
139
2
            matched: base.matched.fg(Color::Indexed(234)).bg(Color::Indexed(186)),
140
2
            current: base.current.bg(Color::Indexed(236)),
141
2
            current_match: base.current_match.fg(Color::Indexed(234)).bg(Color::Indexed(186)),
142
2
            spinner: base.spinner.fg(Color::Indexed(148)),
143
2
            info: base.info.fg(Color::Indexed(144)),
144
2
            prompt: base.prompt.fg(Color::Indexed(110)),
145
2
            cursor: base.cursor.fg(Color::Indexed(161)),
146
2
            selected: base.selected.fg(Color::Indexed(168)),
147
2
            header: base.header.fg(Color::Indexed(109)),
148
2
            border: base.border.fg(Color::Indexed(59)),
149
2
            scrollbar: base.scrollbar.fg(Color::Indexed(59)),
150
2
            ..base
151
2
        }
152
2
    }
153
154
2
    fn light256() -> Self {
155
2
        let base = ColorTheme::none();
156
2
        ColorTheme {
157
2
            matched: base.matched.fg(Color::Indexed(0)).bg(Color::Indexed(220)),
158
2
            current: base.current.bg(Color::Indexed(251)),
159
2
            current_match: base.current_match.fg(Color::Indexed(66)).bg(Color::Indexed(251)),
160
2
            spinner: base.spinner.fg(Color::Indexed(65)),
161
2
            info: base.info.fg(Color::Indexed(101)),
162
2
            prompt: base.prompt.fg(Color::Indexed(25)),
163
2
            cursor: base.cursor.fg(Color::Indexed(161)),
164
2
            selected: base.selected.fg(Color::Indexed(168)),
165
2
            header: base.header.fg(Color::Indexed(31)),
166
2
            border: base.border.fg(Color::Indexed(145)),
167
2
            scrollbar: base.scrollbar.fg(Color::Indexed(145)),
168
2
            ..base
169
2
        }
170
2
    }
171
172
    #[allow(unused_variables)]
173
3
    fn catppuccin_mocha() -> Self {
174
3
        let base = ColorTheme::none();
175
3
        let text = Color::Rgb(205, 214, 244);
176
3
        let subtext0 = Color::Rgb(166, 173, 200);
177
3
        let subtext1 = Color::Rgb(186, 194, 222);
178
3
        let overlay0 = Color::Rgb(108, 112, 134);
179
3
        let surface0 = Color::Rgb(49, 50, 68);
180
3
        let blue = Color::Rgb(137, 180, 250);
181
3
        let red = Color::Rgb(243, 139, 168);
182
3
        let lavender = Color::Rgb(180, 190, 254);
183
3
        let sapphire = Color::Rgb(116, 199, 236);
184
3
        Self {
185
3
            normal: base.normal.fg(text),
186
3
            matched: base.matched.fg(blue).underlined(),
187
3
            current: base.current.bg(surface0),
188
3
            current_match: base.current_match.fg(red).underlined(),
189
3
            query: base.query.fg(text),
190
3
            spinner: base.spinner.fg(subtext1).bold(),
191
3
            info: base.info.fg(subtext1),
192
3
            prompt: base.prompt.fg(lavender),
193
3
            cursor: base.cursor.fg(red),
194
3
            selected: base.selected.fg(red),
195
3
            header: base.header.fg(subtext1),
196
3
            border: base.header.fg(lavender),
197
3
            scrollbar: base.scrollbar.fg(overlay0),
198
3
        }
199
3
    }
200
    #[allow(unused_variables)]
201
3
    fn catppuccin_macchiato() -> Self {
202
3
        let base = ColorTheme::none();
203
3
        let text = Color::Rgb(202, 211, 245);
204
3
        let subtext0 = Color::Rgb(165, 173, 203);
205
3
        let subtext1 = Color::Rgb(184, 192, 224);
206
3
        let overlay0 = Color::Rgb(110, 115, 141);
207
3
        let surface0 = Color::Rgb(54, 58, 79);
208
3
        let blue = Color::Rgb(138, 173, 244);
209
3
        let red = Color::Rgb(237, 135, 150);
210
3
        let lavender = Color::Rgb(183, 189, 248);
211
3
        let sapphire = Color::Rgb(125, 196, 228);
212
3
        Self {
213
3
            normal: base.normal.fg(text),
214
3
            matched: base.matched.fg(blue).underlined(),
215
3
            current: base.current.bg(surface0),
216
3
            current_match: base.current_match.fg(red).underlined(),
217
3
            query: base.query.fg(text),
218
3
            spinner: base.spinner.fg(subtext1).bold(),
219
3
            info: base.info.fg(subtext1),
220
3
            prompt: base.prompt.fg(lavender),
221
3
            cursor: base.cursor.fg(red),
222
3
            selected: base.selected.fg(red),
223
3
            header: base.header.fg(subtext1),
224
3
            border: base.header.fg(lavender),
225
3
            scrollbar: base.scrollbar.fg(overlay0),
226
3
        }
227
3
    }
228
    #[allow(unused_variables)]
229
3
    fn catppuccin_latte() -> Self {
230
3
        let base = ColorTheme::none();
231
3
        let text = Color::Rgb(76, 79, 105);
232
3
        let subtext0 = Color::Rgb(108, 111, 133);
233
3
        let subtext1 = Color::Rgb(92, 95, 119);
234
3
        let overlay0 = Color::Rgb(156, 160, 176);
235
3
        let surface0 = Color::Rgb(204, 208, 218);
236
3
        let blue = Color::Rgb(30, 102, 245);
237
3
        let red = Color::Rgb(210, 15, 57);
238
3
        let lavender = Color::Rgb(114, 135, 253);
239
3
        let sapphire = Color::Rgb(32, 159, 181);
240
3
        Self {
241
3
            normal: base.normal.fg(text),
242
3
            matched: base.matched.fg(blue).underlined(),
243
3
            current: base.current.bg(surface0),
244
3
            current_match: base.current_match.fg(red).underlined(),
245
3
            query: base.query.fg(text),
246
3
            spinner: base.spinner.fg(subtext1).bold(),
247
3
            info: base.info.fg(subtext1),
248
3
            prompt: base.prompt.fg(lavender),
249
3
            cursor: base.cursor.fg(red),
250
3
            selected: base.selected.fg(red),
251
3
            header: base.header.fg(subtext1),
252
3
            border: base.header.fg(lavender),
253
3
            scrollbar: base.scrollbar.fg(overlay0),
254
3
        }
255
3
    }
256
    #[allow(unused_variables)]
257
3
    fn catppuccin_frappe() -> Self {
258
3
        let base = ColorTheme::none();
259
3
        let text = Color::Rgb(198, 208, 245);
260
3
        let subtext0 = Color::Rgb(165, 173, 206);
261
3
        let subtext1 = Color::Rgb(181, 191, 226);
262
3
        let overlay0 = Color::Rgb(115, 121, 148);
263
3
        let surface0 = Color::Rgb(65, 69, 89);
264
3
        let blue = Color::Rgb(140, 170, 238);
265
3
        let red = Color::Rgb(231, 130, 132);
266
3
        let lavender = Color::Rgb(186, 187, 241);
267
3
        let sapphire = Color::Rgb(133, 193, 220);
268
3
        Self {
269
3
            normal: base.normal.fg(text),
270
3
            matched: base.matched.fg(blue).underlined(),
271
3
            current: base.current.bg(surface0),
272
3
            current_match: base.current_match.fg(red).underlined(),
273
3
            query: base.query.fg(text),
274
3
            spinner: base.spinner.fg(subtext1).bold(),
275
3
            info: base.info.fg(subtext1),
276
3
            prompt: base.prompt.fg(lavender),
277
3
            cursor: base.cursor.fg(red),
278
3
            selected: base.selected.fg(red),
279
3
            header: base.header.fg(subtext1),
280
3
            border: base.header.fg(lavender),
281
3
            scrollbar: base.scrollbar.fg(overlay0),
282
3
        }
283
3
    }
284
285
77
    fn set_color(&mut self, name: &str, spec: &str) {
286
77
        let spec_parts: Vec<_> = spec.split(&['+', ':']).collect();
287
288
        // Compute modifiers
289
77
        let mut modifier = Modifier::empty();
290
77
        for 
part23
in spec_parts.iter().skip(1) {
291
23
            if 
matches!2
(*part, "x" |
"regular"22
) {
292
2
                modifier = Modifier::empty();
293
2
            } else {
294
21
                modifier |= match *part {
295
21
                    "b" | 
"bold"20
=>
Modifier::BOLD7
,
296
14
                    "u" | 
"underlined"13
=>
Modifier::UNDERLINED7
,
297
7
                    "c" | 
"crossed-out"6
=>
Modifier::CROSSED_OUT1
,
298
6
                    "d" | 
"dim"5
=>
Modifier::DIM1
,
299
5
                    "i" | 
"italic"4
=>
Modifier::ITALIC2
,
300
3
                    "r" | 
"reverse"2
=>
Modifier::REVERSED2
,
301
1
                    m => {
302
1
                        debug!("Unknown modifier '{m}'");
303
1
                        Modifier::empty()
304
                    }
305
                };
306
            }
307
        }
308
        // Apply - check for layer suffixes (_fg, -fg, _bg, -bg, _u, -u, etc.)
309
77
        let (component_name, layer) = if name.ends_with("_fg") || 
name76
.
ends_with76
("-fg") {
  Branch (309:42): [True: 0, False: 10]
+  Branch (309:67): [True: 0, False: 10]
 
  Branch (309:42): [True: 1, False: 66]
   Branch (309:67): [True: 1, False: 65]
-
310
2
            (&name[..name.len() - 3], "fg")
311
77
        } else if name.ends_with("_bg") || 
name69
.
ends_with69
("-bg") {
  Branch (311:19): [True: 7, False: 5]
+
310
2
            (&name[..name.len() - 3], "fg")
311
75
        } else if name.ends_with("_bg") || 
name69
.
ends_with69
("-bg") {
  Branch (311:19): [True: 5, False: 5]
   Branch (311:44): [True: 0, False: 5]
 
  Branch (311:19): [True: 1, False: 64]
   Branch (311:44): [True: 2, False: 62]
-
312
10
            (&name[..name.len() - 3], "bg")
313
67
        } else if name.ends_with("_u") || 
name66
.
ends_with66
("-u") {
  Branch (313:19): [True: 0, False: 5]
+
312
8
            (&name[..name.len() - 3], "bg")
313
67
        } else if name.ends_with("_u") || 
name66
.
ends_with66
("-u") {
  Branch (313:19): [True: 0, False: 5]
   Branch (313:43): [True: 0, False: 5]
 
  Branch (313:19): [True: 1, False: 61]
   Branch (313:43): [True: 1, False: 60]
@@ -20,18 +20,18 @@
   Branch (315:51): [True: 1, False: 58]
 
316
2
            (&name[..name.len() - 10], "underline")
317
63
        } else if name == "bg" {
  Branch (317:19): [True: 0, False: 5]
 
  Branch (317:19): [True: 1, False: 57]
-
318
1
            ("", "bg")
319
        } else {
320
62
            (name, "fg")
321
        };
322
323
79
        let 
target_style78
= match component_name {
324
79
            "" | 
"normal"78
=>
&mut self.normal2
,
325
77
            "matched" | 
"hl"45
=>
&mut self.matched35
,
326
42
            "current" | 
"fg+"36
|
"bg+"35
=>
&mut self.current10
,
327
32
            "current_match" | 
"hl+"26
=>
&mut self.current_match8
,
328
24
            "query" => 
&mut self.query1
,
329
23
            "spinner" => 
&mut self.spinner1
,
330
22
            "info" => 
&mut self.info2
,
331
20
            "prompt" => 
&mut self.prompt10
,
332
10
            "cursor" | 
"pointer"9
=>
&mut self.cursor2
,
333
8
            "selected" | 
"marker"7
=>
&mut self.selected2
,
334
6
            "header" => 
&mut self.header1
,
335
5
            "border" => 
&mut self.border1
,
336
4
            "scrollbar" => 
&mut self.scrollbar3
,
337
1
            _ => return,
338
        };
339
340
        // Handle color reset with `-1`
341
78
        let raw_color = spec_parts[0];
342
        // Compute color
343
78
        let new_color = if raw_color.len() == 7 && 
raw_color10
.
starts_with10
('#') {
  Branch (343:28): [True: 0, False: 12]
+
318
1
            ("", "bg")
319
        } else {
320
62
            (name, "fg")
321
        };
322
323
77
        let 
target_style76
= match component_name {
324
77
            "" | 
"normal"76
=>
&mut self.normal2
,
325
75
            "matched" | 
"hl"43
=>
&mut self.matched35
,
326
40
            "current" | 
"fg+"35
|
"bg+"34
=>
&mut self.current9
,
327
31
            "current_match" | 
"hl+"26
=>
&mut self.current_match7
,
328
24
            "query" => 
&mut self.query1
,
329
23
            "spinner" => 
&mut self.spinner1
,
330
22
            "info" => 
&mut self.info2
,
331
20
            "prompt" => 
&mut self.prompt10
,
332
10
            "cursor" | 
"pointer"9
=>
&mut self.cursor2
,
333
8
            "selected" | 
"marker"7
=>
&mut self.selected2
,
334
6
            "header" => 
&mut self.header1
,
335
5
            "border" => 
&mut self.border1
,
336
4
            "scrollbar" => 
&mut self.scrollbar3
,
337
1
            _ => return,
338
        };
339
340
        // Handle color reset with `-1`
341
76
        let raw_color = spec_parts[0];
342
        // Compute color
343
76
        let new_color = if raw_color.len() == 7 && 
raw_color10
.
starts_with10
('#') {
  Branch (343:28): [True: 0, False: 10]
   Branch (343:52): [True: 0, False: 0]
 
  Branch (343:28): [True: 10, False: 56]
   Branch (343:52): [True: 9, False: 1]
-
344
            // RGB Hex color
345
9
            let r = u8::from_str_radix(&raw_color[1..3], 16).unwrap_or(255);
346
9
            let g = u8::from_str_radix(&raw_color[3..5], 16).unwrap_or(255);
347
9
            let b = u8::from_str_radix(&raw_color[5..7], 16).unwrap_or(255);
348
9
            Some(Color::Rgb(r, g, b))
349
69
        } else if raw_color == "-1" {
  Branch (349:19): [True: 0, False: 12]
+
344
            // RGB Hex color
345
9
            let r = u8::from_str_radix(&raw_color[1..3], 16).unwrap_or(255);
346
9
            let g = u8::from_str_radix(&raw_color[3..5], 16).unwrap_or(255);
347
9
            let b = u8::from_str_radix(&raw_color[5..7], 16).unwrap_or(255);
348
9
            Some(Color::Rgb(r, g, b))
349
67
        } else if raw_color == "-1" {
  Branch (349:19): [True: 0, False: 10]
 
  Branch (349:19): [True: 6, False: 51]
-
350
6
            Some(Color::Reset)
351
        } else {
352
63
            raw_color.parse::<u8>().ok().map(Color::Indexed).or_else(|| 
{1
353
1
                if !raw_color.is_empty() {
  Branch (353:20): [True: 0, False: 0]
+
350
6
            Some(Color::Reset)
351
        } else {
352
61
            raw_color.parse::<u8>().ok().map(Color::Indexed).or_else(|| 
{1
353
1
                if !raw_color.is_empty() {
  Branch (353:20): [True: 0, False: 0]
 
  Branch (353:20): [True: 1, False: 0]
-
354
1
                    debug!("Unknown color '{}'", 
spec_parts[0]0
);
355
0
                }
356
1
                None
357
1
            })
358
        };
359
360
78
        let layer_override = if component_name == "bg+" { 
"bg"3
} else {
layer75
};
  Branch (360:33): [True: 0, False: 12]
+
354
1
                    debug!("Unknown color '{}'", 
spec_parts[0]0
);
355
0
                }
356
1
                None
357
1
            })
358
        };
359
360
76
        let layer_override = if component_name == "bg+" { 
"bg"3
} else {
layer73
};
  Branch (360:33): [True: 0, False: 10]
 
  Branch (360:33): [True: 3, False: 63]
-
361
78
        set_style(target_style, layer_override, new_color, modifier);
362
79
    }
363
364
82
    fn from_options(color: &str) -> Self {
365
82
        let mut theme = ColorTheme::dark256();
366
103
        for pair in 
color82
.
split82
(',') {
367
103
            if let Some((
name79
,
spec79
)) = pair.split_once(':') {
  Branch (367:20): [True: 12, False: 0]
+
361
76
        set_style(target_style, layer_override, new_color, modifier);
362
77
    }
363
364
81
    fn from_options(color: &str) -> Self {
365
81
        let mut theme = ColorTheme::dark256();
366
101
        for pair in 
color81
.
split81
(',') {
367
101
            if let Some((
name77
,
spec77
)) = pair.split_once(':') {
  Branch (367:20): [True: 10, False: 0]
 
  Branch (367:20): [True: 67, False: 24]
-
368
79
                theme.set_color(name, spec);
369
79
            } else {
370
24
                theme = match pair {
371
24
                    "molokai" => 
ColorTheme::molokai2561
(),
372
23
                    "light" => 
ColorTheme::light2561
(),
373
22
                    "16" => 
ColorTheme::default163
(),
374
19
                    "bw" => 
ColorTheme::bw1
(),
375
18
                    "none" | 
"empty"17
=>
ColorTheme::none2
(),
376
16
                    "dark" | 
"default"11
=>
ColorTheme::dark2567
(),
377
9
                    "catppuccin_mocha" | 
"catppuccin-mocha"8
=>
ColorTheme::catppuccin_mocha2
(),
378
7
                    "catppuccin_macchiato" | 
"catppuccin-macchiato"6
=>
ColorTheme::catppuccin_macchiato2
(),
379
5
                    "catppuccin_latte" | 
"catppuccin-latte"4
=>
ColorTheme::catppuccin_latte2
(),
380
3
                    "catppuccin_frappe" | 
"catppuccin-frappe"2
=>
ColorTheme::catppuccin_frappe2
(),
381
1
                    t => {
382
1
                        debug!("Unknown color theme '{t}'");
383
1
                        ColorTheme::dark256()
384
                    }
385
                };
386
            }
387
        }
388
82
        theme
389
82
    }
390
}
391
392
78
fn set_style(s: &mut Style, layer: &str, color: Option<Color>, modifier: Modifier) {
393
78
    if let Some(
c77
) = color {
  Branch (393:12): [True: 12, False: 0]
+
368
77
                theme.set_color(name, spec);
369
77
            } else {
370
24
                theme = match pair {
371
24
                    "molokai" => 
ColorTheme::molokai2561
(),
372
23
                    "light" => 
ColorTheme::light2561
(),
373
22
                    "16" => 
ColorTheme::default163
(),
374
19
                    "bw" => 
ColorTheme::bw1
(),
375
18
                    "none" | 
"empty"17
=>
ColorTheme::none2
(),
376
16
                    "dark" | 
"default"11
=>
ColorTheme::dark2567
(),
377
9
                    "catppuccin_mocha" | 
"catppuccin-mocha"8
=>
ColorTheme::catppuccin_mocha2
(),
378
7
                    "catppuccin_macchiato" | 
"catppuccin-macchiato"6
=>
ColorTheme::catppuccin_macchiato2
(),
379
5
                    "catppuccin_latte" | 
"catppuccin-latte"4
=>
ColorTheme::catppuccin_latte2
(),
380
3
                    "catppuccin_frappe" | 
"catppuccin-frappe"2
=>
ColorTheme::catppuccin_frappe2
(),
381
1
                    t => {
382
1
                        debug!("Unknown color theme '{t}'");
383
1
                        ColorTheme::dark256()
384
                    }
385
                };
386
            }
387
        }
388
81
        theme
389
81
    }
390
}
391
392
76
fn set_style(s: &mut Style, layer: &str, color: Option<Color>, modifier: Modifier) {
393
76
    if let Some(
c75
) = color {
  Branch (393:12): [True: 10, False: 0]
 
  Branch (393:12): [True: 65, False: 1]
-
394
77
        *s = match layer {
395
77
            "fg" => 
s59
.
fg59
(
c59
),
396
18
            "bg" => 
s14
.
bg14
(
c14
),
397
4
            "u" | 
"underline"2
=> s.underline_color(c),
398
0
            _ => *s,
399
        }
400
1
    }
401
78
    *s = s.add_modifier(modifier);
402
78
}
403
404
#[cfg(test)]
405
#[path = "theme_tests.rs"]
406
mod tests;
\ No newline at end of file +
394
75
        *s = match layer {
395
75
            "fg" => 
s59
.
fg59
(
c59
),
396
16
            "bg" => 
s12
.
bg12
(
c12
),
397
4
            "u" | 
"underline"2
=> s.underline_color(c),
398
0
            _ => *s,
399
        }
400
1
    }
401
76
    *s = s.add_modifier(modifier);
402
76
}
403
404
#[cfg(test)]
405
#[path = "theme_tests.rs"]
406
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/thread_pool.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/thread_pool.rs.html index 98c7353c..855682dc 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/thread_pool.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/thread_pool.rs.html @@ -1,10 +1,10 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/thread_pool.rs
Line
Count
Source
1
//! A lightweight thread pool with a shared work queue.
2
//!
3
//! Worker threads pick up the next available job as soon as they finish their
4
//! current one, giving natural load balancing without work-stealing.
5
6
use std::cell::UnsafeCell;
7
use std::collections::VecDeque;
8
use std::sync::atomic::{AtomicUsize, Ordering};
9
use std::sync::{Arc, Condvar, Mutex};
10
use std::thread;
11
12
// ---------------------------------------------------------------------------
13
// Thread-count partitioning
14
// ---------------------------------------------------------------------------
15
16
/// Splits `n` logical threads between the reader pipeline and the matcher.
17
///
18
/// Returns `(reader, matcher)` where:
19
/// - `reader`  = ⌈n / 3⌉, minimum 1
20
/// - `matcher` = ⌊2n / 3⌋, minimum 1
21
///
22
/// On single-core machines (`n = 1`) both values are 1 so neither subsystem
23
/// starves; the OS time-slices between the two small pools as usual.
24
#[must_use]
25
1.83k
pub fn partition_threads(n: usize) -> (usize, usize) {
26
1.83k
    let reader = n.div_ceil(3); // ⌈n/3⌉
27
1.83k
    let matcher = (2 * n) / 3; // ⌊2n/3⌋
28
1.83k
    (reader.max(1), matcher.max(1))
29
1.83k
}
30
31
// ---------------------------------------------------------------------------
32
// Job type
33
// ---------------------------------------------------------------------------
34
35
type Job = Box<dyn FnOnce() + Send + 'static>;
36
37
// ---------------------------------------------------------------------------
38
// Shared state between the pool handle and workers
39
// ---------------------------------------------------------------------------
40
41
struct SharedState {
42
    queue: Mutex<QueueInner>,
43
    /// Wakes workers when a new job is enqueued or shutdown is requested.
44
    job_available: Condvar,
45
}
46
47
struct QueueInner {
48
    jobs: VecDeque<Job>,
49
    shutdown: bool,
50
}
51
52
// ---------------------------------------------------------------------------
53
// ThreadPool
54
// ---------------------------------------------------------------------------
55
56
/// A simple thread pool backed by a FIFO work queue.
57
///
58
/// Each worker thread blocks on a shared condvar and picks up the next
59
/// available job as soon as it becomes idle.  This means that when a thread
60
/// finishes a job (including any reduce/merge work that was part of that job)
61
/// it immediately checks for the next queued job without any extra
62
/// coordination.
63
pub struct ThreadPool {
64
    shared: Arc<SharedState>,
65
    workers: Vec<thread::JoinHandle<()>>,
66
    num_threads: usize,
67
}
68
69
impl ThreadPool {
70
    /// Creates a new pool with `num_threads` worker threads.
71
    ///
72
    /// # Panics
73
    ///
74
    /// Panics if `num_threads` is 0.
75
    #[must_use]
76
2.30k
    pub fn new(num_threads: usize) -> Self {
77
2.30k
        assert!(num_threads > 0, "ThreadPool requires at least 1 thread");
78
79
2.30k
        let shared = Arc::new(SharedState {
80
2.30k
            queue: Mutex::new(QueueInner {
81
2.30k
                jobs: VecDeque::new(),
82
2.30k
                shutdown: false,
83
2.30k
            }),
84
2.30k
            job_available: Condvar::new(),
85
2.30k
        });
86
87
2.30k
        let mut workers = Vec::with_capacity(num_threads);
88
89
2.30k
        for _ in 0..num_threads {
90
4.61k
            let worker_shared = Arc::clone(&shared);
91
4.61k
            workers.push(thread::spawn(move || worker_loop(&worker_shared)));
92
        }
93
94
2.30k
        Self {
95
2.30k
            shared,
96
2.30k
            workers,
97
2.30k
            num_threads,
98
2.30k
        }
99
2.30k
    }
100
101
    /// Returns the number of worker threads in the pool.
102
    #[inline]
103
    #[must_use]
104
1.27k
    pub fn num_threads(&self) -> usize {
105
1.27k
        self.num_threads
106
1.27k
    }
107
108
    /// Submits a closure to be executed by the next available worker.
109
    ///
110
    /// The lock is dropped *before* notifying the condvar so that the woken
111
    /// worker can acquire it immediately instead of blocking on the notifier.
112
    ///
113
    /// # Panics
114
    ///
115
    /// Panics if the internal job-queue mutex is poisoned.
116
413
    pub fn spawn<F>(&self, f: F)
117
413
    where
118
413
        F: FnOnce() + Send + 'static,
119
    {
120
413
        {
121
413
            let mut queue = self.shared.queue.lock().unwrap();
122
413
            queue.jobs.push_back(Box::new(f));
123
413
        } // lock dropped before notify
124
413
        self.shared.job_available.notify_one();
125
413
    }
126
127
    /// Submits multiple closures in a single lock acquisition, then wakes
128
    /// exactly as many workers as there are new jobs (capped at the pool size).
129
    /// This avoids unnecessary wakes when fewer jobs than workers are
130
    /// submitted.
131
    ///
132
    /// # Panics
133
    ///
134
    /// Panics if the internal job-queue mutex is poisoned.
135
709
    pub fn spawn_batch<I>(&self, jobs: I)
136
709
    where
137
709
        I: IntoIterator<Item = Box<dyn FnOnce() + Send + 'static>>,
138
    {
139
709
        let count = {
140
709
            let mut queue = self.shared.queue.lock().unwrap();
141
709
            let before = queue.jobs.len();
142
1.43k
            for job in 
jobs709
{
143
1.43k
                queue.jobs.push_back(job);
144
1.43k
            }
145
709
            queue.jobs.len() - before
146
        }; // lock dropped before notify
147
709
        if count >= self.num_threads {
  Branch (147:12): [True: 686, False: 0]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/thread_pool.rs
Line
Count
Source
1
//! A lightweight thread pool with a shared work queue.
2
//!
3
//! Worker threads pick up the next available job as soon as they finish their
4
//! current one, giving natural load balancing without work-stealing.
5
6
use std::cell::UnsafeCell;
7
use std::collections::VecDeque;
8
use std::sync::atomic::{AtomicUsize, Ordering};
9
use std::sync::{Arc, Condvar, Mutex};
10
use std::thread;
11
12
// ---------------------------------------------------------------------------
13
// Thread-count partitioning
14
// ---------------------------------------------------------------------------
15
16
/// Splits `n` logical threads between the reader pipeline and the matcher.
17
///
18
/// Returns `(reader, matcher)` where:
19
/// - `reader`  = ⌈n / 3⌉, minimum 1
20
/// - `matcher` = ⌊2n / 3⌋, minimum 1
21
///
22
/// On single-core machines (`n = 1`) both values are 1 so neither subsystem
23
/// starves; the OS time-slices between the two small pools as usual.
24
#[must_use]
25
1.83k
pub fn partition_threads(n: usize) -> (usize, usize) {
26
1.83k
    let reader = n.div_ceil(3); // ⌈n/3⌉
27
1.83k
    let matcher = (2 * n) / 3; // ⌊2n/3⌋
28
1.83k
    (reader.max(1), matcher.max(1))
29
1.83k
}
30
31
// ---------------------------------------------------------------------------
32
// Job type
33
// ---------------------------------------------------------------------------
34
35
type Job = Box<dyn FnOnce() + Send + 'static>;
36
37
// ---------------------------------------------------------------------------
38
// Shared state between the pool handle and workers
39
// ---------------------------------------------------------------------------
40
41
struct SharedState {
42
    queue: Mutex<QueueInner>,
43
    /// Wakes workers when a new job is enqueued or shutdown is requested.
44
    job_available: Condvar,
45
}
46
47
struct QueueInner {
48
    jobs: VecDeque<Job>,
49
    shutdown: bool,
50
}
51
52
// ---------------------------------------------------------------------------
53
// ThreadPool
54
// ---------------------------------------------------------------------------
55
56
/// A simple thread pool backed by a FIFO work queue.
57
///
58
/// Each worker thread blocks on a shared condvar and picks up the next
59
/// available job as soon as it becomes idle.  This means that when a thread
60
/// finishes a job (including any reduce/merge work that was part of that job)
61
/// it immediately checks for the next queued job without any extra
62
/// coordination.
63
pub struct ThreadPool {
64
    shared: Arc<SharedState>,
65
    workers: Vec<thread::JoinHandle<()>>,
66
    num_threads: usize,
67
}
68
69
impl ThreadPool {
70
    /// Creates a new pool with `num_threads` worker threads.
71
    ///
72
    /// # Panics
73
    ///
74
    /// Panics if `num_threads` is 0.
75
    #[must_use]
76
2.30k
    pub fn new(num_threads: usize) -> Self {
77
2.30k
        assert!(num_threads > 0, "ThreadPool requires at least 1 thread");
78
79
2.30k
        let shared = Arc::new(SharedState {
80
2.30k
            queue: Mutex::new(QueueInner {
81
2.30k
                jobs: VecDeque::new(),
82
2.30k
                shutdown: false,
83
2.30k
            }),
84
2.30k
            job_available: Condvar::new(),
85
2.30k
        });
86
87
2.30k
        let mut workers = Vec::with_capacity(num_threads);
88
89
2.30k
        for _ in 0..num_threads {
90
4.61k
            let worker_shared = Arc::clone(&shared);
91
4.61k
            workers.push(thread::spawn(move || worker_loop(&worker_shared)));
92
        }
93
94
2.30k
        Self {
95
2.30k
            shared,
96
2.30k
            workers,
97
2.30k
            num_threads,
98
2.30k
        }
99
2.30k
    }
100
101
    /// Returns the number of worker threads in the pool.
102
    #[inline]
103
    #[must_use]
104
1.25k
    pub fn num_threads(&self) -> usize {
105
1.25k
        self.num_threads
106
1.25k
    }
107
108
    /// Submits a closure to be executed by the next available worker.
109
    ///
110
    /// The lock is dropped *before* notifying the condvar so that the woken
111
    /// worker can acquire it immediately instead of blocking on the notifier.
112
    ///
113
    /// # Panics
114
    ///
115
    /// Panics if the internal job-queue mutex is poisoned.
116
413
    pub fn spawn<F>(&self, f: F)
117
413
    where
118
413
        F: FnOnce() + Send + 'static,
119
    {
120
413
        {
121
413
            let mut queue = self.shared.queue.lock().unwrap();
122
413
            queue.jobs.push_back(Box::new(f));
123
413
        } // lock dropped before notify
124
413
        self.shared.job_available.notify_one();
125
413
    }
126
127
    /// Submits multiple closures in a single lock acquisition, then wakes
128
    /// exactly as many workers as there are new jobs (capped at the pool size).
129
    /// This avoids unnecessary wakes when fewer jobs than workers are
130
    /// submitted.
131
    ///
132
    /// # Panics
133
    ///
134
    /// Panics if the internal job-queue mutex is poisoned.
135
709
    pub fn spawn_batch<I>(&self, jobs: I)
136
709
    where
137
709
        I: IntoIterator<Item = Box<dyn FnOnce() + Send + 'static>>,
138
    {
139
709
        let count = {
140
709
            let mut queue = self.shared.queue.lock().unwrap();
141
709
            let before = queue.jobs.len();
142
1.43k
            for job in 
jobs709
{
143
1.43k
                queue.jobs.push_back(job);
144
1.43k
            }
145
709
            queue.jobs.len() - before
146
        }; // lock dropped before notify
147
709
        if count >= self.num_threads {
  Branch (147:12): [True: 686, False: 0]
 
  Branch (147:12): [True: 23, False: 0]
-
148
709
            self.shared.job_available.notify_all();
149
709
        } else {
150
0
            for _ in 0..count {
151
0
                self.shared.job_available.notify_one();
152
0
            }
153
        }
154
709
    }
155
}
156
157
impl Drop for ThreadPool {
158
2.30k
    fn drop(&mut self) {
159
        // Signal shutdown.
160
2.30k
        {
161
2.30k
            let mut queue = self.shared.queue.lock().unwrap();
162
2.30k
            queue.shutdown = true;
163
2.30k
        }
164
2.30k
        self.shared.job_available.notify_all();
165
166
        // Join all workers (ignore panics from individual threads).
167
4.61k
        for handle in 
self.workers2.30k
.
drain2.30k
(
..2.30k
) {
168
4.61k
            let _ = handle.join();
169
4.61k
        }
170
2.30k
    }
171
}
172
173
// ---------------------------------------------------------------------------
174
// Worker loop
175
// ---------------------------------------------------------------------------
176
177
4.61k
fn worker_loop(shared: &SharedState) {
178
    loop {
179
6.45k
        let next_job = {
180
6.45k
            let mut queue = shared.queue.lock().unwrap();
181
            loop {
182
12.1k
                if let Some(
ready1.84k
) = queue.jobs.pop_front() {
  Branch (182:24): [True: 1.76k, False: 8.10k]
-
  Branch (182:24): [True: 82, False: 2.24k]
-
183
1.84k
                    break Some(ready);
184
10.3k
                }
185
10.3k
                if queue.shutdown {
  Branch (185:20): [True: 3.27k, False: 4.82k]
-
  Branch (185:20): [True: 1.33k, False: 913]
-
186
4.61k
                    break None;
187
5.73k
                }
188
5.73k
                queue = shared.job_available.wait(queue).unwrap();
189
            }
190
        };
191
192
6.45k
        match next_job {
193
1.84k
            Some(runnable) => runnable(),
194
4.61k
            None => return, // shutdown
195
        }
196
    }
197
4.61k
}
198
199
// ---------------------------------------------------------------------------
200
// Parallel work-queue helpers used by the matcher
201
// ---------------------------------------------------------------------------
202
// Cache-line–aligned result slot
203
// ---------------------------------------------------------------------------
204
205
/// A single worker's result slot, padded to a full cache line so that
206
/// concurrent writes to adjacent slots by different cores don't cause
207
/// false sharing.
208
///
209
/// Alignment is platform-dependant, this is taken from <https://docs.rs/crossbeam-utils/0.8.21/src/crossbeam_utils/cache_padded.rs.html>
210
///
211
// Starting from Intel's Sandy Bridge, spatial prefetcher is now pulling pairs of 64-byte cache
212
// lines at a time, so we have to align to 128 bytes rather than 64.
213
#[cfg_attr(
214
    any(
215
        target_arch = "x86_64",
216
        target_arch = "aarch64",
217
        target_arch = "arm64ec",
218
        target_arch = "powerpc64",
219
    ),
220
    repr(align(128))
221
)]
222
// arm, mips, mips64, sparc, and hexagon have 32-byte cache line size.
223
#[cfg_attr(
224
    any(
225
        target_arch = "arm",
226
        target_arch = "mips",
227
        target_arch = "mips32r6",
228
        target_arch = "mips64",
229
        target_arch = "mips64r6",
230
        target_arch = "sparc",
231
        target_arch = "hexagon",
232
    ),
233
    repr(align(32))
234
)]
235
// m68k has 16-byte cache line size.
236
#[cfg_attr(target_arch = "m68k", repr(align(16)))]
237
// s390x has 256-byte cache line size.
238
#[cfg_attr(target_arch = "s390x", repr(align(256)))]
239
// x86, wasm, riscv, and sparc64 have 64-byte cache line size.
240
// All others are assumed to have 64-byte cache line size.
241
#[cfg_attr(
242
    not(any(
243
        target_arch = "x86_64",
244
        target_arch = "aarch64",
245
        target_arch = "arm64ec",
246
        target_arch = "powerpc64",
247
        target_arch = "arm",
248
        target_arch = "mips",
249
        target_arch = "mips32r6",
250
        target_arch = "mips64",
251
        target_arch = "mips64r6",
252
        target_arch = "sparc",
253
        target_arch = "hexagon",
254
        target_arch = "m68k",
255
        target_arch = "s390x",
256
    )),
257
    repr(align(64))
258
)]
259
struct Slot<R> {
260
    value: UnsafeCell<Option<R>>,
261
}
262
263
// SAFETY: each slot is written by exactly one worker (unique `worker_id`)
264
// and read by the coordinator only after the barrier guarantees all writes
265
// are visible.  No two threads ever access the same slot concurrently.
266
unsafe impl<R: Send> Send for Slot<R> {}
267
unsafe impl<R: Send> Sync for Slot<R> {}
268
269
impl<R> Slot<R> {
270
1.42k
    fn new() -> Self {
271
1.42k
        Self {
272
1.42k
            value: UnsafeCell::new(None),
273
1.42k
        }
274
1.42k
    }
275
}
276
277
// ---------------------------------------------------------------------------
278
279
/// Processes `items` in parallel across `num_workers` threads from the given
280
/// pool, then hands the results to `merge`.
281
///
282
/// The work is split into chunks of `chunk_size`.  Each worker thread
283
/// repeatedly grabs the next available chunk (via an atomic counter), runs
284
/// `process_chunk` on it, and folds the partial result into a *local*
285
/// accumulator using `reduce`.  When all chunks are consumed, each worker
286
/// calls `prepare` on its local accumulator (e.g. to sort it) — this step
287
/// runs **in parallel** across all workers — and then writes the prepared
288
/// result into its slot. The coordinator collects every worker's result and
289
/// passes them all to `merge` in a single call. When `preserve_chunk_order` is
290
/// set, reduction and preparation are skipped and each chunk result is stored
291
/// directly in its chunk-indexed slot.
292
///
293
/// Because each worker picks up the *next* chunk as soon as it finishes the
294
/// previous one, faster threads naturally do more work without any explicit
295
/// work-stealing.
296
///
297
/// # Parameters
298
///
299
/// * `pool`           – thread pool whose workers will execute the chunks.
300
/// * `num_workers`    – how many workers to dispatch (capped to pool size internally by caller).
301
/// * `items`          – the data to process; shared read-only across workers via `Arc`.
302
/// * `chunk_size`     – number of items per chunk.
303
/// * `preserve_chunk_order` – store results by chunk index and skip reduction/preparation.
304
/// * `identity`       – the identity/seed value for per-worker local accumulators (called once per worker).
305
/// * `process_chunk`  – `(chunk_start_index, &[T]) -> R` – processes one chunk.
306
/// * `reduce`         – folds a per-chunk result into a worker-local accumulator (`&mut acc, partial`).
307
/// * `prepare`        – called on each worker's finished accumulator **on the worker thread** (runs in parallel).  Use this for expensive per-worker work like sorting.
308
/// * `merge`          – called once on the coordinator with all per-worker results.
309
#[allow(clippy::too_many_arguments)]
310
844
pub fn parallel_work_queue<S, T, R, P, M, I, W, G>(
311
844
    pool: &ThreadPool,
312
844
    num_workers: usize,
313
844
    items: &Arc<S>,
314
844
    chunk_size: usize,
315
844
    preserve_chunk_order: bool,
316
844
    identity: I,
317
844
    process_chunk: P,
318
844
    reduce: M,
319
844
    prepare: W,
320
844
    merge: G,
321
844
) where
322
844
    S: AsRef<[T]> + Send + Sync + ?Sized + 'static,
323
844
    T: Send + Sync + 'static,
324
844
    R: Send + 'static,
325
844
    P: Fn(usize, &[T]) -> R + Send + Sync + 'static,
326
844
    M: Fn(&mut R, R) + Send + Sync + 'static,
327
844
    I: Fn() -> R + Send + Sync + 'static,
328
844
    W: Fn(&mut R) + Send + Sync + 'static,
329
844
    G: FnOnce(Vec<R>),
330
{
331
844
    let items_slice: &[T] = AsRef::<[T]>::as_ref(&**items);
332
844
    let total = items_slice.len();
333
844
    if total == 0 {
  Branch (333:8): [True: 128, False: 686]
+
148
709
            self.shared.job_available.notify_all();
149
709
        } else {
150
0
            for _ in 0..count {
151
0
                self.shared.job_available.notify_one();
152
0
            }
153
        }
154
709
    }
155
}
156
157
impl Drop for ThreadPool {
158
2.30k
    fn drop(&mut self) {
159
        // Signal shutdown.
160
2.30k
        {
161
2.30k
            let mut queue = self.shared.queue.lock().unwrap();
162
2.30k
            queue.shutdown = true;
163
2.30k
        }
164
2.30k
        self.shared.job_available.notify_all();
165
166
        // Join all workers (ignore panics from individual threads).
167
4.61k
        for handle in 
self.workers2.30k
.
drain2.30k
(
..2.30k
) {
168
4.61k
            let _ = handle.join();
169
4.61k
        }
170
2.30k
    }
171
}
172
173
// ---------------------------------------------------------------------------
174
// Worker loop
175
// ---------------------------------------------------------------------------
176
177
4.61k
fn worker_loop(shared: &SharedState) {
178
    loop {
179
6.46k
        let next_job = {
180
6.46k
            let mut queue = shared.queue.lock().unwrap();
181
            loop {
182
12.1k
                if let Some(
ready1.84k
) = queue.jobs.pop_front() {
  Branch (182:24): [True: 1.76k, False: 8.13k]
+
  Branch (182:24): [True: 82, False: 2.21k]
+
183
1.84k
                    break Some(ready);
184
10.3k
                }
185
10.3k
                if queue.shutdown {
  Branch (185:20): [True: 3.28k, False: 4.85k]
+
  Branch (185:20): [True: 1.33k, False: 882]
+
186
4.61k
                    break None;
187
5.73k
                }
188
5.73k
                queue = shared.job_available.wait(queue).unwrap();
189
            }
190
        };
191
192
6.46k
        match next_job {
193
1.84k
            Some(runnable) => runnable(),
194
4.61k
            None => return, // shutdown
195
        }
196
    }
197
4.61k
}
198
199
// ---------------------------------------------------------------------------
200
// Parallel work-queue helpers used by the matcher
201
// ---------------------------------------------------------------------------
202
// Cache-line–aligned result slot
203
// ---------------------------------------------------------------------------
204
205
/// A single worker's result slot, padded to a full cache line so that
206
/// concurrent writes to adjacent slots by different cores don't cause
207
/// false sharing.
208
///
209
/// Alignment is platform-dependant, this is taken from <https://docs.rs/crossbeam-utils/0.8.21/src/crossbeam_utils/cache_padded.rs.html>
210
///
211
// Starting from Intel's Sandy Bridge, spatial prefetcher is now pulling pairs of 64-byte cache
212
// lines at a time, so we have to align to 128 bytes rather than 64.
213
#[cfg_attr(
214
    any(
215
        target_arch = "x86_64",
216
        target_arch = "aarch64",
217
        target_arch = "arm64ec",
218
        target_arch = "powerpc64",
219
    ),
220
    repr(align(128))
221
)]
222
// arm, mips, mips64, sparc, and hexagon have 32-byte cache line size.
223
#[cfg_attr(
224
    any(
225
        target_arch = "arm",
226
        target_arch = "mips",
227
        target_arch = "mips32r6",
228
        target_arch = "mips64",
229
        target_arch = "mips64r6",
230
        target_arch = "sparc",
231
        target_arch = "hexagon",
232
    ),
233
    repr(align(32))
234
)]
235
// m68k has 16-byte cache line size.
236
#[cfg_attr(target_arch = "m68k", repr(align(16)))]
237
// s390x has 256-byte cache line size.
238
#[cfg_attr(target_arch = "s390x", repr(align(256)))]
239
// x86, wasm, riscv, and sparc64 have 64-byte cache line size.
240
// All others are assumed to have 64-byte cache line size.
241
#[cfg_attr(
242
    not(any(
243
        target_arch = "x86_64",
244
        target_arch = "aarch64",
245
        target_arch = "arm64ec",
246
        target_arch = "powerpc64",
247
        target_arch = "arm",
248
        target_arch = "mips",
249
        target_arch = "mips32r6",
250
        target_arch = "mips64",
251
        target_arch = "mips64r6",
252
        target_arch = "sparc",
253
        target_arch = "hexagon",
254
        target_arch = "m68k",
255
        target_arch = "s390x",
256
    )),
257
    repr(align(64))
258
)]
259
struct Slot<R> {
260
    value: UnsafeCell<Option<R>>,
261
}
262
263
// SAFETY: each slot is written by exactly one worker (unique `worker_id`)
264
// and read by the coordinator only after the barrier guarantees all writes
265
// are visible.  No two threads ever access the same slot concurrently.
266
unsafe impl<R: Send> Send for Slot<R> {}
267
unsafe impl<R: Send> Sync for Slot<R> {}
268
269
impl<R> Slot<R> {
270
1.42k
    fn new() -> Self {
271
1.42k
        Self {
272
1.42k
            value: UnsafeCell::new(None),
273
1.42k
        }
274
1.42k
    }
275
}
276
277
// ---------------------------------------------------------------------------
278
279
/// Processes `items` in parallel across `num_workers` threads from the given
280
/// pool, then hands the results to `merge`.
281
///
282
/// The work is split into chunks of `chunk_size`.  Each worker thread
283
/// repeatedly grabs the next available chunk (via an atomic counter), runs
284
/// `process_chunk` on it, and folds the partial result into a *local*
285
/// accumulator using `reduce`.  When all chunks are consumed, each worker
286
/// calls `prepare` on its local accumulator (e.g. to sort it) — this step
287
/// runs **in parallel** across all workers — and then writes the prepared
288
/// result into its slot. The coordinator collects every worker's result and
289
/// passes them all to `merge` in a single call. When `preserve_chunk_order` is
290
/// set, reduction and preparation are skipped and each chunk result is stored
291
/// directly in its chunk-indexed slot.
292
///
293
/// Because each worker picks up the *next* chunk as soon as it finishes the
294
/// previous one, faster threads naturally do more work without any explicit
295
/// work-stealing.
296
///
297
/// # Parameters
298
///
299
/// * `pool`           – thread pool whose workers will execute the chunks.
300
/// * `num_workers`    – how many workers to dispatch (capped to pool size internally by caller).
301
/// * `items`          – the data to process; shared read-only across workers via `Arc`.
302
/// * `chunk_size`     – number of items per chunk.
303
/// * `preserve_chunk_order` – store results by chunk index and skip reduction/preparation.
304
/// * `identity`       – the identity/seed value for per-worker local accumulators (called once per worker).
305
/// * `process_chunk`  – `(chunk_start_index, &[T]) -> R` – processes one chunk.
306
/// * `reduce`         – folds a per-chunk result into a worker-local accumulator (`&mut acc, partial`).
307
/// * `prepare`        – called on each worker's finished accumulator **on the worker thread** (runs in parallel).  Use this for expensive per-worker work like sorting.
308
/// * `merge`          – called once on the coordinator with all per-worker results.
309
#[allow(clippy::too_many_arguments)]
310
828
pub fn parallel_work_queue<S, T, R, P, M, I, W, G>(
311
828
    pool: &ThreadPool,
312
828
    num_workers: usize,
313
828
    items: &Arc<S>,
314
828
    chunk_size: usize,
315
828
    preserve_chunk_order: bool,
316
828
    identity: I,
317
828
    process_chunk: P,
318
828
    reduce: M,
319
828
    prepare: W,
320
828
    merge: G,
321
828
) where
322
828
    S: AsRef<[T]> + Send + Sync + ?Sized + 'static,
323
828
    T: Send + Sync + 'static,
324
828
    R: Send + 'static,
325
828
    P: Fn(usize, &[T]) -> R + Send + Sync + 'static,
326
828
    M: Fn(&mut R, R) + Send + Sync + 'static,
327
828
    I: Fn() -> R + Send + Sync + 'static,
328
828
    W: Fn(&mut R) + Send + Sync + 'static,
329
828
    G: FnOnce(Vec<R>),
330
{
331
828
    let items_slice: &[T] = AsRef::<[T]>::as_ref(&**items);
332
828
    let total = items_slice.len();
333
828
    if total == 0 {
  Branch (333:8): [True: 112, False: 686]
 
  Branch (333:8): [True: 7, False: 17]
 
  Branch (333:8): [True: 0, False: 1]
 
  Branch (333:8): [True: 0, False: 1]
@@ -12,7 +12,7 @@
 
  Branch (333:8): [True: 0, False: 1]
 
  Branch (333:8): [True: 0, False: 1]
 
  Branch (333:8): [True: 0, False: 1]
-
334
136
        merge(Vec::new());
335
136
        return;
336
708
    }
337
338
708
    let num_chunks = total.div_ceil(chunk_size);
339
340
    // Shared atomic counter – workers fetch-add to grab the next chunk index.
341
708
    let next_chunk = Arc::new(AtomicUsize::new(0));
342
343
    // Ordered mode uses one slot per chunk; otherwise each worker writes its
344
    // reduced result to its own slot. The coordinator reads only after the barrier.
345
708
    let num_slots = if preserve_chunk_order { 
num_chunks7
} else {
num_workers701
};
  Branch (345:24): [True: 6, False: 680]
+
334
120
        merge(Vec::new());
335
120
        return;
336
708
    }
337
338
708
    let num_chunks = total.div_ceil(chunk_size);
339
340
    // Shared atomic counter – workers fetch-add to grab the next chunk index.
341
708
    let next_chunk = Arc::new(AtomicUsize::new(0));
342
343
    // Ordered mode uses one slot per chunk; otherwise each worker writes its
344
    // reduced result to its own slot. The coordinator reads only after the barrier.
345
708
    let num_slots = if preserve_chunk_order { 
num_chunks7
} else {
num_workers701
};
  Branch (345:24): [True: 6, False: 680]
 
  Branch (345:24): [True: 0, False: 17]
 
  Branch (345:24): [True: 1, False: 0]
 
  Branch (345:24): [True: 0, False: 1]
@@ -60,10 +60,10 @@
 
  Branch (442:12): [True: 1, False: 0]
 
  Branch (442:12): [True: 1, False: 0]
 
  Branch (442:12): [True: 1, False: 0]
-
443
1.42k
        let 
results708
:
Vec<R>708
=
slots708
.
into_iter708
().
filter_map708
(|slot| slot.value.into_inner()).
collect708
();
444
445
708
        merge(results);
446
    } else {
447
0
        log::error!("More than one ref to the slots remaining after workers exit. This SHOULD NOT happen.");
448
    }
449
844
}
450
451
// ---------------------------------------------------------------------------
452
// AtomicCounter with parking (avoids spinning in the coordinator)
453
// ---------------------------------------------------------------------------
454
455
struct AtomicCounter {
456
    count: AtomicUsize,
457
    /// The thread that called `wait_for_zero`.  Workers unpark it when the
458
    /// count reaches zero.  Set once by `wait_for_zero` before any worker can
459
    /// finish, so plain `Relaxed` loads inside `dec_and_notify` are fine
460
    /// (the `fetch_sub` with `AcqRel` provides the necessary ordering).
461
    waiter: UnsafeCell<Option<thread::Thread>>,
462
}
463
464
// SAFETY: `waiter` is written exactly once (by the coordinator in
465
// `set_waiter`, before any worker can observe it via `dec_and_notify`)
466
// and read by workers only after that write is visible (guaranteed by the
467
// `AcqRel` ordering on the atomic counter operations).
468
unsafe impl Send for AtomicCounter {}
469
unsafe impl Sync for AtomicCounter {}
470
471
impl AtomicCounter {
472
708
    fn new(n: usize) -> Self {
473
708
        Self {
474
708
            count: AtomicUsize::new(n),
475
708
            waiter: UnsafeCell::new(None),
476
708
        }
477
708
    }
478
479
    /// Register the current thread as the waiter.
480
    ///
481
    /// Must be called exactly once, before any worker calls `dec_and_notify`.
482
708
    fn set_waiter(&self) {
483
        // SAFETY: called once by the coordinator before workers start.
484
708
        unsafe { *self.waiter.get() = Some(thread::current()) };
485
708
    }
486
487
    /// Decrements the counter by one and unparks the waiter if it reaches zero.
488
    ///
489
    /// # Panics (debug only)
490
    ///
491
    /// Debug-asserts that the counter has not already reached zero, which
492
    /// would indicate a double-decrement bug.
493
1.42k
    fn dec_and_notify(&self) {
494
1.42k
        let prev = self.count.fetch_sub(1, Ordering::AcqRel);
495
1.42k
        debug_assert!(
prev > 00
, "AtomicCounter decremented below zero — double-decrement bug?");
496
1.42k
        if prev == 1 {
  Branch (496:12): [True: 686, False: 686]
+
443
1.42k
        let 
results708
:
Vec<R>708
=
slots708
.
into_iter708
().
filter_map708
(|slot| slot.value.into_inner()).
collect708
();
444
445
708
        merge(results);
446
    } else {
447
0
        log::error!("More than one ref to the slots remaining after workers exit. This SHOULD NOT happen.");
448
    }
449
828
}
450
451
// ---------------------------------------------------------------------------
452
// AtomicCounter with parking (avoids spinning in the coordinator)
453
// ---------------------------------------------------------------------------
454
455
struct AtomicCounter {
456
    count: AtomicUsize,
457
    /// The thread that called `wait_for_zero`.  Workers unpark it when the
458
    /// count reaches zero.  Set once by `wait_for_zero` before any worker can
459
    /// finish, so plain `Relaxed` loads inside `dec_and_notify` are fine
460
    /// (the `fetch_sub` with `AcqRel` provides the necessary ordering).
461
    waiter: UnsafeCell<Option<thread::Thread>>,
462
}
463
464
// SAFETY: `waiter` is written exactly once (by the coordinator in
465
// `set_waiter`, before any worker can observe it via `dec_and_notify`)
466
// and read by workers only after that write is visible (guaranteed by the
467
// `AcqRel` ordering on the atomic counter operations).
468
unsafe impl Send for AtomicCounter {}
469
unsafe impl Sync for AtomicCounter {}
470
471
impl AtomicCounter {
472
708
    fn new(n: usize) -> Self {
473
708
        Self {
474
708
            count: AtomicUsize::new(n),
475
708
            waiter: UnsafeCell::new(None),
476
708
        }
477
708
    }
478
479
    /// Register the current thread as the waiter.
480
    ///
481
    /// Must be called exactly once, before any worker calls `dec_and_notify`.
482
708
    fn set_waiter(&self) {
483
        // SAFETY: called once by the coordinator before workers start.
484
708
        unsafe { *self.waiter.get() = Some(thread::current()) };
485
708
    }
486
487
    /// Decrements the counter by one and unparks the waiter if it reaches zero.
488
    ///
489
    /// # Panics (debug only)
490
    ///
491
    /// Debug-asserts that the counter has not already reached zero, which
492
    /// would indicate a double-decrement bug.
493
1.42k
    fn dec_and_notify(&self) {
494
1.42k
        let prev = self.count.fetch_sub(1, Ordering::AcqRel);
495
1.42k
        debug_assert!(
prev > 00
, "AtomicCounter decremented below zero — double-decrement bug?");
496
1.42k
        if prev == 1 {
  Branch (496:12): [True: 686, False: 686]
 
  Branch (496:12): [True: 22, False: 30]
 
497
            // We just decremented from 1 → 0.
498
            // SAFETY: waiter was set before workers were dispatched.
499
708
            if let Some(t) = unsafe { &*self.waiter.get() } {
  Branch (499:20): [True: 686, False: 0]
 
  Branch (499:20): [True: 22, False: 0]
-
500
708
                t.unpark();
501
708
            
}0
502
716
        }
503
1.42k
    }
504
505
    /// Blocks until the counter reaches zero.
506
708
    fn wait_for_zero(&self) {
507
1.32k
        while self.count.load(Ordering::Acquire) > 0 {
  Branch (507:15): [True: 597, False: 686]
-
  Branch (507:15): [True: 18, False: 22]
-
508
615
            thread::park();
509
615
        }
510
708
    }
511
}
512
513
#[cfg(test)]
514
#[path = "thread_pool_tests.rs"]
515
mod tests;
\ No newline at end of file +
500
708
                t.unpark();
501
708
            
}0
502
716
        }
503
1.42k
    }
504
505
    /// Blocks until the counter reaches zero.
506
708
    fn wait_for_zero(&self) {
507
1.30k
        while self.count.load(Ordering::Acquire) > 0 {
  Branch (507:15): [True: 577, False: 686]
+
  Branch (507:15): [True: 16, False: 22]
+
508
593
            thread::park();
509
593
        }
510
708
    }
511
}
512
513
#[cfg(test)]
514
#[path = "thread_pool_tests.rs"]
515
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/actions.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/actions.rs.html index e68d087c..8d7320f8 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/tui/actions.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/actions.rs.html @@ -1,3 +1,3 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/actions.rs
Line
Count
Source
1
//! Action definitions, the action catalog, and action parsing.
2
//!
3
//! The [`Action`] enum, its canonical names and its parser are all generated
4
//! from a single source of truth: the [`define_action_catalog`] invocation at
5
//! the bottom of this module. Each entry declares the variant (with its doc
6
//! comment and payload types), the kebab-case name accepted by `--bind`, and
7
//! the expression that builds the variant from an optional argument.
8
9
use std::future::Future;
10
use std::pin::Pin;
11
use std::sync::{Arc, Mutex};
12
13
use derive_more::{Debug, Eq, PartialEq};
14
15
use super::event::Event;
16
17
type BoxError = Box<dyn std::error::Error + Sync + Send>;
18
type BoxFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<Event>, BoxError>> + Send + 'a>>;
19
20
/// Trait object stored inside [`ActionCallback`].
21
///
22
/// Having an explicit trait (rather than a bare `dyn Fn` type alias) allows
23
/// Rust to correctly resolve the higher-ranked lifetime in the return type.
24
trait AsyncCallbackFn: Send {
25
    fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a>;
26
}
27
28
/// Adapter that stores a concrete async closure and implements [`AsyncCallbackFn`].
29
struct AsyncFnWrapper<F>(F);
30
31
impl<F, Fut> AsyncCallbackFn for AsyncFnWrapper<F>
32
where
33
    F: for<'a> Fn(&'a mut crate::tui::App) -> Fut + Send,
34
    Fut: Future<Output = Result<Vec<Event>, BoxError>> + Send + 'static,
35
{
36
1
    fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a> {
37
1
        Box::pin((self.0)(app))
38
1
    }
39
}
40
41
/// Adapter that stores a plain synchronous closure and implements [`AsyncCallbackFn`].
42
struct SyncFnWrapper<F>(F);
43
44
impl<F> AsyncCallbackFn for SyncFnWrapper<F>
45
where
46
    F: Fn(&mut crate::tui::App) -> Result<Vec<Event>, BoxError> + Send,
47
{
48
1
    fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a> {
49
1
        Box::pin(std::future::ready((self.0)(app)))
50
1
    }
51
}
52
53
/// A custom action callback that receives a mutable reference to the App.
54
///
55
/// The closure will be called with a mutable reference to App and should return
56
/// a vec of events that will be processed after the callback completes.
57
///
58
/// Both sync and async closures are supported:
59
/// - Use [`ActionCallback::new`] to wrap an **async** closure or block.
60
/// - Use [`ActionCallback::new_sync`] to wrap a plain synchronous closure.
61
#[derive(Clone)]
62
pub struct ActionCallback(Arc<Mutex<dyn AsyncCallbackFn>>);
63
64
impl std::fmt::Debug for ActionCallback {
65
2
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66
2
        f.debug_struct("ActionCallback").finish()
67
2
    }
68
}
69
70
impl ActionCallback {
71
    /// Create a new action callback from an **async** closure or block.
72
    ///
73
    /// ```rust,ignore
74
    /// ActionCallback::new(|app| async move {
75
    ///     // async work here …
76
    ///     Ok(vec![])
77
    /// });
78
    /// ```
79
2
    pub fn new<F, Fut>(f: F) -> Self
80
2
    where
81
2
        F: for<'a> Fn(&'a mut crate::tui::App) -> Fut + Send + 'static,
82
2
        Fut: Future<Output = Result<Vec<Event>, BoxError>> + Send + 'static,
83
    {
84
2
        Self(Arc::new(Mutex::new(AsyncFnWrapper(f))))
85
2
    }
86
87
    /// Create a new action callback from a plain **synchronous** closure.
88
    ///
89
    /// This is a convenience wrapper; the closure is lifted into an immediately-
90
    /// resolving future so it integrates with the same async call site.
91
    ///
92
    /// ```rust,ignore
93
    /// ActionCallback::new_sync(|app| {
94
    ///     Ok(vec![Event::Action(Action::SelectAll)])
95
    /// });
96
    /// ```
97
2
    pub fn new_sync<F>(f: F) -> Self
98
2
    where
99
2
        F: Fn(&mut crate::tui::App) -> Result<Vec<Event>, BoxError> + Send + 'static,
100
    {
101
2
        Self(Arc::new(Mutex::new(SyncFnWrapper(f))))
102
2
    }
103
104
    /// Call the callback with an App reference, driving the returned future to completion.
105
    ///
106
    /// Must be called from within a Tokio multi-thread runtime context.
107
2
    pub(crate) fn call(&self, app: &mut crate::tui::App) -> Result<Vec<Event>, BoxError> {
108
2
        let callback = self.0.lock().unwrap();
109
2
        let fut = callback.call(app);
110
        // We are inside a synchronous call stack that originates from an async
111
        // tokio context.  `block_in_place` moves the current thread out of the
112
        // async worker pool temporarily so we can block on the future without
113
        // starving the runtime.
114
2
        tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
115
2
    }
116
}
117
118
70
fn parse_conditional(arg: Option<String>, constructor: fn(String, Option<String>) -> Action) -> Option<Action> {
119
70
    let 
arg40
= arg
?30
;
120
40
    let (then, otherwise) = match arg.split_once('+') {
121
3
        Some((
then1
, "")) =>
(then, None)1
,
122
2
        Some((then, otherwise)) => (then, Some(otherwise.to_string())),
123
37
        None => (arg.as_str(), None),
124
    };
125
40
    Some(constructor(then.to_string(), otherwise))
126
70
}
127
128
/// Documentation for a single entry of the action catalog.
129
///
130
/// Produced by `define_action_catalog!` and exposed through [`ACTION_CATALOG`];
131
/// used to generate the actions list of the manpage.
132
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133
pub struct ActionDoc {
134
    /// Canonical kebab-case name, as accepted by [`parse_action`].
135
    pub name: &'static str,
136
    /// Whether the action carries an argument (`name(...)` / `name:...`).
137
    pub takes_arg: bool,
138
    /// The action's rustdoc, one line per doc comment line.
139
    pub doc: &'static str,
140
}
141
142
impl ActionDoc {
143
    /// The action as it is spelled in a binding, with `(...)` for actions taking an argument.
144
    #[must_use]
145
649
    pub fn display_name(&self) -> String {
146
649
        if self.takes_arg {
  Branch (146:12): [True: 29, False: 43]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/actions.rs
Line
Count
Source
1
//! Action definitions, the action catalog, and action parsing.
2
//!
3
//! The [`Action`] enum, its canonical names and its parser are all generated
4
//! from a single source of truth: the [`define_action_catalog`] invocation at
5
//! the bottom of this module. Each entry declares the variant (with its doc
6
//! comment and payload types), the kebab-case name accepted by `--bind`, and
7
//! the expression that builds the variant from an optional argument.
8
9
use std::future::Future;
10
use std::pin::Pin;
11
use std::sync::{Arc, Mutex};
12
13
use derive_more::{Debug, Eq, PartialEq};
14
15
use super::event::Event;
16
17
type BoxError = Box<dyn std::error::Error + Sync + Send>;
18
type BoxFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<Event>, BoxError>> + Send + 'a>>;
19
20
/// Trait object stored inside [`ActionCallback`].
21
///
22
/// Having an explicit trait (rather than a bare `dyn Fn` type alias) allows
23
/// Rust to correctly resolve the higher-ranked lifetime in the return type.
24
trait AsyncCallbackFn: Send {
25
    fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a>;
26
}
27
28
/// Adapter that stores a concrete async closure and implements [`AsyncCallbackFn`].
29
struct AsyncFnWrapper<F>(F);
30
31
impl<F, Fut> AsyncCallbackFn for AsyncFnWrapper<F>
32
where
33
    F: for<'a> Fn(&'a mut crate::tui::App) -> Fut + Send,
34
    Fut: Future<Output = Result<Vec<Event>, BoxError>> + Send + 'static,
35
{
36
1
    fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a> {
37
1
        Box::pin((self.0)(app))
38
1
    }
39
}
40
41
/// Adapter that stores a plain synchronous closure and implements [`AsyncCallbackFn`].
42
struct SyncFnWrapper<F>(F);
43
44
impl<F> AsyncCallbackFn for SyncFnWrapper<F>
45
where
46
    F: Fn(&mut crate::tui::App) -> Result<Vec<Event>, BoxError> + Send,
47
{
48
1
    fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a> {
49
1
        Box::pin(std::future::ready((self.0)(app)))
50
1
    }
51
}
52
53
/// A custom action callback that receives a mutable reference to the App.
54
///
55
/// The closure will be called with a mutable reference to App and should return
56
/// a vec of events that will be processed after the callback completes.
57
///
58
/// Both sync and async closures are supported:
59
/// - Use [`ActionCallback::new`] to wrap an **async** closure or block.
60
/// - Use [`ActionCallback::new_sync`] to wrap a plain synchronous closure.
61
#[derive(Clone)]
62
pub struct ActionCallback(Arc<Mutex<dyn AsyncCallbackFn>>);
63
64
impl std::fmt::Debug for ActionCallback {
65
2
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66
2
        f.debug_struct("ActionCallback").finish()
67
2
    }
68
}
69
70
impl ActionCallback {
71
    /// Create a new action callback from an **async** closure or block.
72
    ///
73
    /// ```rust,ignore
74
    /// ActionCallback::new(|app| async move {
75
    ///     // async work here …
76
    ///     Ok(vec![])
77
    /// });
78
    /// ```
79
2
    pub fn new<F, Fut>(f: F) -> Self
80
2
    where
81
2
        F: for<'a> Fn(&'a mut crate::tui::App) -> Fut + Send + 'static,
82
2
        Fut: Future<Output = Result<Vec<Event>, BoxError>> + Send + 'static,
83
    {
84
2
        Self(Arc::new(Mutex::new(AsyncFnWrapper(f))))
85
2
    }
86
87
    /// Create a new action callback from a plain **synchronous** closure.
88
    ///
89
    /// This is a convenience wrapper; the closure is lifted into an immediately-
90
    /// resolving future so it integrates with the same async call site.
91
    ///
92
    /// ```rust,ignore
93
    /// ActionCallback::new_sync(|app| {
94
    ///     Ok(vec![Event::Action(Action::SelectAll)])
95
    /// });
96
    /// ```
97
2
    pub fn new_sync<F>(f: F) -> Self
98
2
    where
99
2
        F: Fn(&mut crate::tui::App) -> Result<Vec<Event>, BoxError> + Send + 'static,
100
    {
101
2
        Self(Arc::new(Mutex::new(SyncFnWrapper(f))))
102
2
    }
103
104
    /// Call the callback with an App reference, driving the returned future to completion.
105
    ///
106
    /// Must be called from within a Tokio multi-thread runtime context.
107
2
    pub(crate) fn call(&self, app: &mut crate::tui::App) -> Result<Vec<Event>, BoxError> {
108
2
        let callback = self.0.lock().unwrap();
109
2
        let fut = callback.call(app);
110
        // We are inside a synchronous call stack that originates from an async
111
        // tokio context.  `block_in_place` moves the current thread out of the
112
        // async worker pool temporarily so we can block on the future without
113
        // starving the runtime.
114
2
        tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
115
2
    }
116
}
117
118
70
fn parse_conditional(arg: Option<String>, constructor: fn(String, Option<String>) -> Action) -> Option<Action> {
119
70
    let 
arg40
= arg
?30
;
120
40
    let (then, otherwise) = match arg.split_once('+') {
121
3
        Some((
then1
, "")) =>
(then, None)1
,
122
2
        Some((then, otherwise)) => (then, Some(otherwise.to_string())),
123
37
        None => (arg.as_str(), None),
124
    };
125
40
    Some(constructor(then.to_string(), otherwise))
126
70
}
127
128
/// Documentation for a single entry of the action catalog.
129
///
130
/// Produced by `define_action_catalog!` and exposed through [`ACTION_CATALOG`];
131
/// used to generate the actions list of the manpage.
132
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133
pub struct ActionDoc {
134
    /// Canonical kebab-case name, as accepted by [`parse_action`].
135
    pub name: &'static str,
136
    /// Whether the action carries an argument (`name(...)` / `name:...`).
137
    pub takes_arg: bool,
138
    /// The action's rustdoc, one line per doc comment line.
139
    pub doc: &'static str,
140
}
141
142
impl ActionDoc {
143
    /// The action as it is spelled in a binding, with `(...)` for actions taking an argument.
144
    #[must_use]
145
649
    pub fn display_name(&self) -> String {
146
649
        if self.takes_arg {
  Branch (146:12): [True: 29, False: 43]
   Branch (146:12): [True: 233, False: 344]
-
147
262
            format!("{}(...)", self.name)
148
        } else {
149
387
            self.name.to_string()
150
        }
151
649
    }
152
153
    /// The action's documentation collapsed into a single line.
154
    #[must_use]
155
651
    pub fn summary(&self) -> String {
156
651
        self.doc
157
651
            .lines()
158
651
            .map(str::trim)
159
848
            .
filter651
(|line| !line.is_empty())
160
651
            .collect::<Vec<_>>()
161
651
            .join(" ")
162
651
    }
163
164
    /// Whether this action can be named in a binding.
165
    ///
166
    /// [`Action::Custom`] exists only for library users building actions in
167
    /// Rust, so it has no spelling [`parse_action`] accepts.
168
    #[must_use]
169
658
    pub fn is_bindable(&self) -> bool {
170
        // Actions that require an argument only parse with one, so retry with
171
        // the parser's empty placeholder before giving up.
172
658
        parse_action(self.name)
173
658
            .or_else(|| 
parse_action109
(
&format!("{}()", self.name)109
))
174
658
            .is_some()
175
658
    }
176
}
177
178
/// Expands to the argument marker used in the manpage, ignoring the payload types it is handed.
179
macro_rules! action_arg_marker {
180
    ($($payload:tt)*) => {
181
        true
182
    };
183
}
184
185
/// Declares the whole action catalog in one place.
186
///
187
/// Every entry has the shape
188
///
189
/// ```text
190
/// /// doc comment
191
/// Variant(payload types…) => "canonical-name" => constructor expression
192
/// ```
193
///
194
/// Doc comments are captured (so they can be re-emitted on the variant *and*
195
/// rendered into the manpage), which means any other attribute has to be passed
196
/// through the optional `@attrs[…]` group instead — a bare `#[…]` would be
197
/// ambiguous with the doc comments:
198
///
199
/// ```text
200
/// /// doc comment
201
/// @attrs[debug("custom")]
202
/// Variant(payload) => "canonical-name" => constructor expression
203
/// ```
204
///
205
/// and the macro generates, from that single list:
206
/// - the [`Action`] enum (with the doc comments and payloads as written),
207
/// - [`Action::name`], mapping each variant to its canonical name,
208
/// - `parse_named_action`, mapping a name plus optional argument back to a variant,
209
/// - [`ACTION_CATALOG`], the name/argument/documentation list the manpage is generated from.
210
///
211
/// The identifier before the `;` is the name bound to the optional argument
212
/// (`Option<String>`) inside the constructor expressions.
213
macro_rules! define_action_catalog {
214
    (
215
        $arg:ident;
216
        $(
217
            $(#[doc = $doc:literal])*
218
            $(@attrs[$($attr:meta),+ $(,)?])?
219
            $variant:ident $(($($payload:ty),+ $(,)?))? => $name:literal => $parsed:expr
220
        ),+ $(,)?
221
    ) => {
222
        /// Actions that can be performed in skim
223
        #[derive(Debug, Clone, PartialEq, Eq)]
224
        #[cfg_attr(feature = "listen", derive(serde::Serialize, serde::Deserialize))]
225
        pub enum Action {
226
            $(
227
                $(#[doc = $doc])*
228
                $($(#[$attr])+)?
229
                $variant $(($($payload),+))?,
230
            )+
231
        }
232
233
        /// Every action, in declaration order, with its name, argument marker and documentation.
234
        ///
235
        /// This is the source the manpage's action list is generated from, so a
236
        /// new entry in the catalog is documented automatically.
237
        pub const ACTION_CATALOG: &[ActionDoc] = &[
238
            $(ActionDoc {
239
                name: $name,
240
                takes_arg: false $(|| action_arg_marker!($($payload)+))?,
241
                doc: concat!($($doc, "\n"),*),
242
            }),+
243
        ];
244
245
        impl Action {
246
            /// Returns the canonical kebab-case name of this action — the same spelling
247
            /// [`parse_action`] accepts.
248
            ///
249
            /// This lets an action be bound as if it were an event (e.g. `reload:first`):
250
            /// after the action runs, any follow-up chain keyed by this name is queued.
251
            /// The name ignores the action's arguments, so `down` matches `Down(1)` and
252
            /// `Down(5)` alike.
253
            #[must_use]
254
1.19k
            pub fn name(&self) -> &'static str {
255
1.19k
                match self {
256
                    $(Self::$variant { .. } => $name),+
257
                }
258
1.19k
            }
259
        }
260
261
1.16k
        fn parse_named_action(action: &str, $arg: Option<String>) -> Option<Action> {
262
            #[allow(clippy::enum_glob_use)]
263
            use Action::*;
264
1.16k
            match action {
265
                $($name => $parsed),+,
266
13
                _ => None,
267
            }
268
1.16k
        }
269
    };
270
}
271
272
define_action_catalog! {
273
    arg;
274
    /// Abort and exit with error
275
    Abort => "abort" => Some(Abort),
276
    /// Accept selection and exit with optional key.
277
    ///
278
    /// The argument is printed when the binding is triggered.
279
    Accept(Option<String>) => "accept" => Some(Accept(arg)),
280
    /// Add a character to the query
281
18
    AddChar(char) => "add-char" => arg.map(|s| AddChar(s.chars().next().unwrap_or_default())),
282
    /// Append to selection and select
283
    AppendAndSelect => "append-and-select" => Some(AppendAndSelect),
284
    /// Move cursor backward one character
285
    BackwardChar => "backward-char" => Some(BackwardChar),
286
    /// Delete character before cursor
287
    BackwardDeleteChar => "backward-delete-char" => Some(BackwardDeleteChar),
288
    /// Delete character before cursor or exit if the query is empty
289
    BackwardDeleteCharEof => "backward-delete-char/eof" => Some(BackwardDeleteCharEof),
290
    /// Delete word before cursor
291
    BackwardKillWord => "backward-kill-word" => Some(BackwardKillWord),
292
    /// Move cursor backward one word
293
    BackwardWord => "backward-word" => Some(BackwardWord),
294
    /// Move cursor to beginning of line
295
    BeginningOfLine => "beginning-of-line" => Some(BeginningOfLine),
296
    /// Bind one or more keys to action chains.
297
    ///
298
    /// The argument is a comma-separated list of `trigger:action[+action]` bindings to add,
299
    /// using the same syntax as `--bind`, including action triggers such as `act-up:last`.
300
    Bind(String) => "bind" => arg.map(Bind),
301
    /// Cancel current operation
302
    Cancel => "cancel" => Some(Cancel),
303
    /// Clear the screen
304
    ClearScreen => "clear-screen" => Some(ClearScreen),
305
    /// Delete character under cursor
306
    DeleteChar => "delete-char" => Some(DeleteChar),
307
    /// Delete character or exit if empty
308
    DeleteCharEof => "delete-char/eof" => Some(DeleteCharEof),
309
    /// Deselect all items
310
    DeselectAll => "deselect-all" => Some(DeselectAll),
311
    /// Move selection down by N items
312
4
    Down(u16) => "down" => Some(Down(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
313
    /// Move cursor to end of line
314
    EndOfLine => "end-of-line" => Some(EndOfLine),
315
    /// Execute a command.
316
    ///
317
    /// The argument is a command, see COMMAND EXPANSION for details.
318
    Execute(String) => "execute" => arg.map(Execute),
319
    /// Execute a command silently.
320
    ///
321
    /// The argument is a command, see COMMAND EXPANSION for details.
322
    ExecuteSilent(String) => "execute-silent" => arg.map(ExecuteSilent),
323
    /// Jump to first item in list
324
    First => "first" => Some(First),
325
    /// Move cursor forward one character
326
    ForwardChar => "forward-char" => Some(ForwardChar),
327
    /// Move cursor forward one word
328
    ForwardWord => "forward-word" => Some(ForwardWord),
329
    /// Execute action if query is empty
330
    IfQueryEmpty(String, Option<String>) => "if-query-empty" => parse_conditional(arg, IfQueryEmpty),
331
    /// Execute action if query is not empty
332
    IfQueryNotEmpty(String, Option<String>) => "if-query-not-empty" => parse_conditional(arg, IfQueryNotEmpty),
333
    /// Execute action if no items match
334
    IfNonMatched(String, Option<String>) => "if-non-matched" => parse_conditional(arg, IfNonMatched),
335
    /// Ignore the action
336
    Ignore => "ignore" => Some(Ignore),
337
    /// Delete from cursor to end of line
338
    KillLine => "kill-line" => Some(KillLine),
339
    /// Delete word after cursor
340
    KillWord => "kill-word" => Some(KillWord),
341
    /// Jump to last item in list
342
    Last => "last" => Some(Last),
343
    /// Move to next history entry (requires `--history` or `--cmd-history`)
344
    NextHistory => "next-history" => Some(NextHistory),
345
    /// Scroll down by half a page
346
1
    HalfPageDown(i32) => "half-page-down" => Some(HalfPageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
347
    /// Scroll up by half a page
348
0
    HalfPageUp(i32) => "half-page-up" => Some(HalfPageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
349
    /// Scroll down by a page
350
0
    PageDown(i32) => "page-down" => Some(PageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
351
    /// Scroll up by a page
352
1
    PageUp(i32) => "page-up" => Some(PageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
353
    /// Scroll preview up
354
1
    PreviewUp(i32) => "preview-up" => Some(PreviewUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
355
    /// Scroll preview down
356
0
    PreviewDown(i32) => "preview-down" => Some(PreviewDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
357
    /// Scroll preview left
358
0
    PreviewLeft(i32) => "preview-left" => Some(PreviewLeft(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
359
    /// Scroll preview right
360
0
    PreviewRight(i32) => "preview-right" => Some(PreviewRight(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
361
    /// Scroll preview up by a page
362
0
    PreviewPageUp(i32) => "preview-page-up" => Some(PreviewPageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
363
    /// Scroll preview down by a page
364
0
    PreviewPageDown(i32) => "preview-page-down" => Some(PreviewPageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
365
    /// Move to previous history entry (requires `--history` or `--cmd-history`)
366
    PreviousHistory => "previous-history" => Some(PreviousHistory),
367
    /// Redraw the screen
368
    Redraw => "redraw" => Some(Redraw),
369
    /// Refresh the command
370
    RefreshCmd => "refresh-cmd" => Some(RefreshCmd),
371
    /// Refresh the preview
372
    RefreshPreview => "refresh-preview" => Some(RefreshPreview),
373
    /// Restart the matcher
374
    RestartMatcher => "restart-matcher" => Some(RestartMatcher),
375
    /// Reload with optional new command
376
    Reload(Option<String>) => "reload" => Some(Reload(arg)),
377
    /// Rotate through matching modes
378
    RotateMode => "rotate-mode" => Some(RotateMode),
379
    /// Scroll item list left
380
0
    ScrollLeft(i32) => "scroll-left" => Some(ScrollLeft(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
381
    /// Scroll item list right
382
1
    ScrollRight(i32) => "scroll-right" => Some(ScrollRight(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
383
    /// Select all items
384
    SelectAll => "select-all" => Some(SelectAll),
385
    /// Select a specific row
386
3
    SelectRow(usize) => "select-row" => Some(SelectRow(arg.and_then(|s| s.parse().ok()).unwrap_or_default())),
387
    /// Select current item
388
    Select => "select" => Some(Select),
389
    /// Suppress the default behaviour of the action this is bound to.
390
    ///
391
    /// Only meaningful as a follow-up bound to an action (e.g. `act-up:suppress`):
392
    /// it cancels that action's own effect, so the remaining follow-up chain
393
    /// runs in its place. On its own it is a no-op (equivalent to `ignore`).
394
    Suppress => "suppress" => Some(Suppress),
395
    /// Set the interactive-mode command and rerun it.
396
    ///
397
    /// The argument is an expanded expression, see COMMAND EXPANSION for details.
398
    SetCmd(String) => "set-cmd" => arg.map(SetCmd),
399
    /// Set the header (or disable it on an empty value)
400
    SetHeader(Option<String>) => "set-header" => Some(SetHeader(arg)),
401
    /// Set the preview cmd and rerun preview.
402
    ///
403
    /// The argument is an expanded expression, see COMMAND EXPANSION for details.
404
    SetPreviewCmd(String) => "set-preview-cmd" => arg.map(SetPreviewCmd),
405
    /// Set the query to the expanded value.
406
    ///
407
    /// The argument is an expanded expression, see COMMAND EXPANSION for details.
408
    SetQuery(String) => "set-query" => arg.map(SetQuery),
409
    /// Toggle selection of current item
410
    Toggle => "toggle" => Some(Toggle),
411
    /// Toggle selection of all items
412
    ToggleAll => "toggle-all" => Some(ToggleAll),
413
    /// Toggle and move in
414
    ToggleIn => "toggle-in" => Some(ToggleIn),
415
    /// Toggle interactive mode
416
    ToggleInteractive => "toggle-interactive" => Some(ToggleInteractive),
417
    /// Toggle and move out
418
    ToggleOut => "toggle-out" => Some(ToggleOut),
419
    /// Toggle preview visibility
420
    TogglePreview => "toggle-preview" => Some(TogglePreview),
421
    /// Toggle preview line wrapping
422
    TogglePreviewWrap => "toggle-preview-wrap" => Some(TogglePreviewWrap),
423
    /// Toggle sorting
424
    ToggleSort => "toggle-sort" => Some(ToggleSort),
425
    /// Jump to first item in list (alias for First)
426
    Top => "top" => Some(Top),
427
    /// Unbind one or more keys.
428
    ///
429
    /// The argument is a comma-separated list of keys or action triggers (e.g. `act-up`) to unbind.
430
    Unbind(String) => "unbind" => arg.map(Unbind),
431
    /// Discard line (unix-style)
432
    UnixLineDiscard => "unix-line-discard" => Some(UnixLineDiscard),
433
    /// Delete word backward (unix-style)
434
    UnixWordRubout => "unix-word-rubout" => Some(UnixWordRubout),
435
    /// Move selection up by N items
436
2
    Up(u16) => "up" => Some(Up(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
437
    /// Yank (paste)
438
    Yank => "yank" => Some(Yank),
439
    /// Custom action from lib
440
    @attrs[
441
        debug("custom"),
442
        eq(skip),
443
        partial_eq(skip),
444
        cfg_attr(feature = "listen", serde(skip)),
445
    ]
446
    Custom(ActionCallback) => "custom" => None,
447
}
448
449
/// Parses an action string into an Action enum
450
///
451
/// Returns `None` if the action is unrecognized, or if it is specified without
452
/// an argument it requires (the `if-*` actions, `execute`, `set-query`, … — see
453
/// the `arg.map(…)` arms of the catalog).
454
#[must_use]
455
1.16k
pub fn parse_action(raw_action: &str) -> Option<Action> {
456
1.16k
    let parts = raw_action.split_once([':', '(', ')']);
457
    let action;
458
1.16k
    let mut arg = None;
459
1.16k
    match parts {
460
935
        None => action = raw_action,
461
227
        Some((
act16
, "")) =>
action = act16
,
462
211
        Some((act, a)) => {
463
211
            action = act;
464
211
            arg = Some(a.trim_end_matches(')').to_string());
465
211
        }
466
    }
467
1.16k
    debug!("parse_action: action={action}, arg={arg:?}");
468
469
1.16k
    parse_named_action(action, arg)
470
1.16k
}
471
472
#[cfg(test)]
473
#[path = "actions_tests.rs"]
474
mod tests;
\ No newline at end of file +
147
262
            format!("{}(...)", self.name)
148
        } else {
149
387
            self.name.to_string()
150
        }
151
649
    }
152
153
    /// The action's documentation collapsed into a single line.
154
    #[must_use]
155
651
    pub fn summary(&self) -> String {
156
651
        self.doc
157
651
            .lines()
158
651
            .map(str::trim)
159
848
            .
filter651
(|line| !line.is_empty())
160
651
            .collect::<Vec<_>>()
161
651
            .join(" ")
162
651
    }
163
164
    /// Whether this action can be named in a binding.
165
    ///
166
    /// [`Action::Custom`] exists only for library users building actions in
167
    /// Rust, so it has no spelling [`parse_action`] accepts.
168
    #[must_use]
169
658
    pub fn is_bindable(&self) -> bool {
170
        // Actions that require an argument only parse with one, so retry with
171
        // the parser's empty placeholder before giving up.
172
658
        parse_action(self.name)
173
658
            .or_else(|| 
parse_action109
(
&format!("{}()", self.name)109
))
174
658
            .is_some()
175
658
    }
176
}
177
178
/// Expands to the argument marker used in the manpage, ignoring the payload types it is handed.
179
macro_rules! action_arg_marker {
180
    ($($payload:tt)*) => {
181
        true
182
    };
183
}
184
185
/// Declares the whole action catalog in one place.
186
///
187
/// Every entry has the shape
188
///
189
/// ```text
190
/// /// doc comment
191
/// Variant(payload types…) => "canonical-name" => constructor expression
192
/// ```
193
///
194
/// Doc comments are captured (so they can be re-emitted on the variant *and*
195
/// rendered into the manpage), which means any other attribute has to be passed
196
/// through the optional `@attrs[…]` group instead — a bare `#[…]` would be
197
/// ambiguous with the doc comments:
198
///
199
/// ```text
200
/// /// doc comment
201
/// @attrs[debug("custom")]
202
/// Variant(payload) => "canonical-name" => constructor expression
203
/// ```
204
///
205
/// and the macro generates, from that single list:
206
/// - the [`Action`] enum (with the doc comments and payloads as written),
207
/// - [`Action::name`], mapping each variant to its canonical name,
208
/// - `parse_named_action`, mapping a name plus optional argument back to a variant,
209
/// - [`ACTION_CATALOG`], the name/argument/documentation list the manpage is generated from.
210
///
211
/// The identifier before the `;` is the name bound to the optional argument
212
/// (`Option<String>`) inside the constructor expressions.
213
macro_rules! define_action_catalog {
214
    (
215
        $arg:ident;
216
        $(
217
            $(#[doc = $doc:literal])*
218
            $(@attrs[$($attr:meta),+ $(,)?])?
219
            $variant:ident $(($($payload:ty),+ $(,)?))? => $name:literal => $parsed:expr
220
        ),+ $(,)?
221
    ) => {
222
        /// Actions that can be performed in skim
223
        #[derive(Debug, Clone, PartialEq, Eq)]
224
        #[cfg_attr(feature = "listen", derive(serde::Serialize, serde::Deserialize))]
225
        pub enum Action {
226
            $(
227
                $(#[doc = $doc])*
228
                $($(#[$attr])+)?
229
                $variant $(($($payload),+))?,
230
            )+
231
        }
232
233
        /// Every action, in declaration order, with its name, argument marker and documentation.
234
        ///
235
        /// This is the source the manpage's action list is generated from, so a
236
        /// new entry in the catalog is documented automatically.
237
        pub const ACTION_CATALOG: &[ActionDoc] = &[
238
            $(ActionDoc {
239
                name: $name,
240
                takes_arg: false $(|| action_arg_marker!($($payload)+))?,
241
                doc: concat!($($doc, "\n"),*),
242
            }),+
243
        ];
244
245
        impl Action {
246
            /// Returns the canonical kebab-case name of this action — the same spelling
247
            /// [`parse_action`] accepts.
248
            ///
249
            /// This lets an action be bound as if it were an event (e.g. `reload:first`):
250
            /// after the action runs, any follow-up chain keyed by this name is queued.
251
            /// The name ignores the action's arguments, so `down` matches `Down(1)` and
252
            /// `Down(5)` alike.
253
            #[must_use]
254
1.18k
            pub fn name(&self) -> &'static str {
255
1.18k
                match self {
256
                    $(Self::$variant { .. } => $name),+
257
                }
258
1.18k
            }
259
        }
260
261
1.16k
        fn parse_named_action(action: &str, $arg: Option<String>) -> Option<Action> {
262
            #[allow(clippy::enum_glob_use)]
263
            use Action::*;
264
1.16k
            match action {
265
                $($name => $parsed),+,
266
13
                _ => None,
267
            }
268
1.16k
        }
269
    };
270
}
271
272
define_action_catalog! {
273
    arg;
274
    /// Abort and exit with error
275
    Abort => "abort" => Some(Abort),
276
    /// Accept selection and exit with optional key.
277
    ///
278
    /// The argument is printed when the binding is triggered.
279
    Accept(Option<String>) => "accept" => Some(Accept(arg)),
280
    /// Add a character to the query
281
18
    AddChar(char) => "add-char" => arg.map(|s| AddChar(s.chars().next().unwrap_or_default())),
282
    /// Append to selection and select
283
    AppendAndSelect => "append-and-select" => Some(AppendAndSelect),
284
    /// Move cursor backward one character
285
    BackwardChar => "backward-char" => Some(BackwardChar),
286
    /// Delete character before cursor
287
    BackwardDeleteChar => "backward-delete-char" => Some(BackwardDeleteChar),
288
    /// Delete character before cursor or exit if the query is empty
289
    BackwardDeleteCharEof => "backward-delete-char/eof" => Some(BackwardDeleteCharEof),
290
    /// Delete word before cursor
291
    BackwardKillWord => "backward-kill-word" => Some(BackwardKillWord),
292
    /// Move cursor backward one word
293
    BackwardWord => "backward-word" => Some(BackwardWord),
294
    /// Move cursor to beginning of line
295
    BeginningOfLine => "beginning-of-line" => Some(BeginningOfLine),
296
    /// Bind one or more keys to action chains.
297
    ///
298
    /// The argument is a comma-separated list of `trigger:action[+action]` bindings to add,
299
    /// using the same syntax as `--bind`, including action triggers such as `act-up:last`.
300
    Bind(String) => "bind" => arg.map(Bind),
301
    /// Cancel current operation
302
    Cancel => "cancel" => Some(Cancel),
303
    /// Clear the screen
304
    ClearScreen => "clear-screen" => Some(ClearScreen),
305
    /// Delete character under cursor
306
    DeleteChar => "delete-char" => Some(DeleteChar),
307
    /// Delete character or exit if empty
308
    DeleteCharEof => "delete-char/eof" => Some(DeleteCharEof),
309
    /// Deselect all items
310
    DeselectAll => "deselect-all" => Some(DeselectAll),
311
    /// Move selection down by N items
312
4
    Down(u16) => "down" => Some(Down(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
313
    /// Move cursor to end of line
314
    EndOfLine => "end-of-line" => Some(EndOfLine),
315
    /// Execute a command.
316
    ///
317
    /// The argument is a command, see COMMAND EXPANSION for details.
318
    Execute(String) => "execute" => arg.map(Execute),
319
    /// Execute a command silently.
320
    ///
321
    /// The argument is a command, see COMMAND EXPANSION for details.
322
    ExecuteSilent(String) => "execute-silent" => arg.map(ExecuteSilent),
323
    /// Jump to first item in list
324
    First => "first" => Some(First),
325
    /// Move cursor forward one character
326
    ForwardChar => "forward-char" => Some(ForwardChar),
327
    /// Move cursor forward one word
328
    ForwardWord => "forward-word" => Some(ForwardWord),
329
    /// Execute action if query is empty
330
    IfQueryEmpty(String, Option<String>) => "if-query-empty" => parse_conditional(arg, IfQueryEmpty),
331
    /// Execute action if query is not empty
332
    IfQueryNotEmpty(String, Option<String>) => "if-query-not-empty" => parse_conditional(arg, IfQueryNotEmpty),
333
    /// Execute action if no items match
334
    IfNonMatched(String, Option<String>) => "if-non-matched" => parse_conditional(arg, IfNonMatched),
335
    /// Ignore the action
336
    Ignore => "ignore" => Some(Ignore),
337
    /// Delete from cursor to end of line
338
    KillLine => "kill-line" => Some(KillLine),
339
    /// Delete word after cursor
340
    KillWord => "kill-word" => Some(KillWord),
341
    /// Jump to last item in list
342
    Last => "last" => Some(Last),
343
    /// Move to next history entry (requires `--history` or `--cmd-history`)
344
    NextHistory => "next-history" => Some(NextHistory),
345
    /// Scroll down by half a page
346
1
    HalfPageDown(i32) => "half-page-down" => Some(HalfPageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
347
    /// Scroll up by half a page
348
0
    HalfPageUp(i32) => "half-page-up" => Some(HalfPageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
349
    /// Scroll down by a page
350
0
    PageDown(i32) => "page-down" => Some(PageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
351
    /// Scroll up by a page
352
1
    PageUp(i32) => "page-up" => Some(PageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
353
    /// Scroll preview up
354
1
    PreviewUp(i32) => "preview-up" => Some(PreviewUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
355
    /// Scroll preview down
356
0
    PreviewDown(i32) => "preview-down" => Some(PreviewDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
357
    /// Scroll preview left
358
0
    PreviewLeft(i32) => "preview-left" => Some(PreviewLeft(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
359
    /// Scroll preview right
360
0
    PreviewRight(i32) => "preview-right" => Some(PreviewRight(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
361
    /// Scroll preview up by a page
362
0
    PreviewPageUp(i32) => "preview-page-up" => Some(PreviewPageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
363
    /// Scroll preview down by a page
364
0
    PreviewPageDown(i32) => "preview-page-down" => Some(PreviewPageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
365
    /// Move to previous history entry (requires `--history` or `--cmd-history`)
366
    PreviousHistory => "previous-history" => Some(PreviousHistory),
367
    /// Redraw the screen
368
    Redraw => "redraw" => Some(Redraw),
369
    /// Refresh the command
370
    RefreshCmd => "refresh-cmd" => Some(RefreshCmd),
371
    /// Refresh the preview
372
    RefreshPreview => "refresh-preview" => Some(RefreshPreview),
373
    /// Restart the matcher
374
    RestartMatcher => "restart-matcher" => Some(RestartMatcher),
375
    /// Reload with optional new command
376
    Reload(Option<String>) => "reload" => Some(Reload(arg)),
377
    /// Rotate through matching modes
378
    RotateMode => "rotate-mode" => Some(RotateMode),
379
    /// Scroll item list left
380
0
    ScrollLeft(i32) => "scroll-left" => Some(ScrollLeft(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
381
    /// Scroll item list right
382
1
    ScrollRight(i32) => "scroll-right" => Some(ScrollRight(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
383
    /// Select all items
384
    SelectAll => "select-all" => Some(SelectAll),
385
    /// Select a specific row
386
3
    SelectRow(usize) => "select-row" => Some(SelectRow(arg.and_then(|s| s.parse().ok()).unwrap_or_default())),
387
    /// Select current item
388
    Select => "select" => Some(Select),
389
    /// Suppress the default behaviour of the action this is bound to.
390
    ///
391
    /// Only meaningful as a follow-up bound to an action (e.g. `act-up:suppress`):
392
    /// it cancels that action's own effect, so the remaining follow-up chain
393
    /// runs in its place. On its own it is a no-op (equivalent to `ignore`).
394
    Suppress => "suppress" => Some(Suppress),
395
    /// Set the interactive-mode command and rerun it.
396
    ///
397
    /// The argument is an expanded expression, see COMMAND EXPANSION for details.
398
    SetCmd(String) => "set-cmd" => arg.map(SetCmd),
399
    /// Set the header (or disable it on an empty value)
400
    SetHeader(Option<String>) => "set-header" => Some(SetHeader(arg)),
401
    /// Set the preview cmd and rerun preview.
402
    ///
403
    /// The argument is an expanded expression, see COMMAND EXPANSION for details.
404
    SetPreviewCmd(String) => "set-preview-cmd" => arg.map(SetPreviewCmd),
405
    /// Set the query to the expanded value.
406
    ///
407
    /// The argument is an expanded expression, see COMMAND EXPANSION for details.
408
    SetQuery(String) => "set-query" => arg.map(SetQuery),
409
    /// Toggle selection of current item
410
    Toggle => "toggle" => Some(Toggle),
411
    /// Toggle selection of all items
412
    ToggleAll => "toggle-all" => Some(ToggleAll),
413
    /// Toggle and move in
414
    ToggleIn => "toggle-in" => Some(ToggleIn),
415
    /// Toggle interactive mode
416
    ToggleInteractive => "toggle-interactive" => Some(ToggleInteractive),
417
    /// Toggle and move out
418
    ToggleOut => "toggle-out" => Some(ToggleOut),
419
    /// Toggle preview visibility
420
    TogglePreview => "toggle-preview" => Some(TogglePreview),
421
    /// Toggle preview line wrapping
422
    TogglePreviewWrap => "toggle-preview-wrap" => Some(TogglePreviewWrap),
423
    /// Toggle sorting
424
    ToggleSort => "toggle-sort" => Some(ToggleSort),
425
    /// Jump to first item in list (alias for First)
426
    Top => "top" => Some(Top),
427
    /// Unbind one or more keys.
428
    ///
429
    /// The argument is a comma-separated list of keys or action triggers (e.g. `act-up`) to unbind.
430
    Unbind(String) => "unbind" => arg.map(Unbind),
431
    /// Discard line (unix-style)
432
    UnixLineDiscard => "unix-line-discard" => Some(UnixLineDiscard),
433
    /// Delete word backward (unix-style)
434
    UnixWordRubout => "unix-word-rubout" => Some(UnixWordRubout),
435
    /// Move selection up by N items
436
2
    Up(u16) => "up" => Some(Up(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
437
    /// Yank (paste)
438
    Yank => "yank" => Some(Yank),
439
    /// Custom action from lib
440
    @attrs[
441
        debug("custom"),
442
        eq(skip),
443
        partial_eq(skip),
444
        cfg_attr(feature = "listen", serde(skip)),
445
    ]
446
    Custom(ActionCallback) => "custom" => None,
447
}
448
449
/// Parses an action string into an Action enum
450
///
451
/// Returns `None` if the action is unrecognized, or if it is specified without
452
/// an argument it requires (the `if-*` actions, `execute`, `set-query`, … — see
453
/// the `arg.map(…)` arms of the catalog).
454
#[must_use]
455
1.16k
pub fn parse_action(raw_action: &str) -> Option<Action> {
456
1.16k
    let parts = raw_action.split_once([':', '(', ')']);
457
    let action;
458
1.16k
    let mut arg = None;
459
1.16k
    match parts {
460
935
        None => action = raw_action,
461
227
        Some((
act16
, "")) =>
action = act16
,
462
211
        Some((act, a)) => {
463
211
            action = act;
464
211
            arg = Some(a.trim_end_matches(')').to_string());
465
211
        }
466
    }
467
1.16k
    debug!("parse_action: action={action}, arg={arg:?}");
468
469
1.16k
    parse_named_action(action, arg)
470
1.16k
}
471
472
#[cfg(test)]
473
#[path = "actions_tests.rs"]
474
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/app.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/app.rs.html index 0934b995..eef90a5e 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/tui/app.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/app.rs.html @@ -1,18 +1,18 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/app.rs
Line
Count
Source
1
use std::process::Stdio;
2
use std::rc::Rc;
3
use std::sync::Arc;
4
use std::sync::atomic::{AtomicBool, Ordering};
5
6
use crate::item::{ItemPool, MatchedItem};
7
use crate::matcher::{Matcher, MatcherControl};
8
use crate::prelude::ExactOrFuzzyEngineFactory;
9
use crate::tui::input::StatusInfo;
10
use crate::tui::layout::{AppLayout, LayoutTemplate};
11
use crate::tui::options::TuiLayout;
12
use crate::tui::statusline::InfoDisplay;
13
use crate::tui::widget::SkimWidget;
14
use crate::tui::{SkimRender, TICK_RATE};
15
use crate::{ItemPreview, PreviewContext, Rank, SkimItem, SkimOptions, util};
16
17
#[cfg(test)]
18
#[path = "app_tests.rs"]
19
mod tests;
20
21
use super::actions::Action;
22
use super::header::Header;
23
use super::item_list::ItemList;
24
use super::{Event, Tui, input, preview};
25
use crate::binds::SkimEvent;
26
use crate::thread_pool::{self, ThreadPool};
27
use crossterm::event::{KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
28
use eyre::{Result, bail};
29
use input::Input;
30
use preview::Preview;
31
use ratatui::buffer::Buffer;
32
use ratatui::crossterm::event::KeyCode::Char;
33
use ratatui::layout::Rect;
34
use ratatui::prelude::Backend;
35
use ratatui::widgets::Widget;
36
use std::sync::LazyLock;
37
38
519
static NUM_THREADS: LazyLock<usize> = LazyLock::new(|| {
39
519
    std::thread::available_parallelism()
40
519
        .ok()
41
519
        .map_or_else(|| 0, std::num::NonZero::get)
42
519
});
43
44
const MATCHER_DEBOUNCE_MS: u128 = 200;
45
const HIDE_GRACE_MS: u128 = 500;
46
const DOUBLE_CLICK_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
47
48
/// Application state for skim's TUI
49
#[allow(clippy::struct_excessive_bools)]
50
pub struct App {
51
    /// Pool of items to be filtered
52
    pub item_pool: Arc<ItemPool>,
53
    /// Thread pool used by the matcher (⌊2N/3⌋ threads).
54
    pub matcher_pool: Arc<ThreadPool>,
55
    /// Thread pool used by the reader pipeline (⌈N/3⌉ threads).
56
    pub reader_pool: Arc<ThreadPool>,
57
    /// Whether the application should quit
58
    pub should_quit: bool,
59
    /// The terminating action, including one dispatched inside a follow-up or conditional chain.
60
    pub(crate) final_action: Option<Action>,
61
62
    /// Current cursor position (x, y)
63
    pub cursor_pos: (u16, u16),
64
    /// Control handle for the matcher thread
65
    pub matcher_control: MatcherControl,
66
    /// The matcher for filtering items
67
    pub matcher: Matcher,
68
    /// Register for yank/paste operations
69
    pub yank_register: String,
70
    /// Last time the matcher was restarted
71
    pub last_matcher_restart: std::time::Instant,
72
    /// Whether a matcher restart is pending
73
    pub pending_matcher_restart: bool,
74
    /// Whether or not we need a render on the next heartbeat
75
    pub needs_render: Arc<AtomicBool>,
76
    /// Time of the last render
77
    pub last_render_timer: std::time::Instant,
78
79
    /// Input field widget
80
    pub input: Input,
81
    /// Preview pane widget
82
    pub preview: Preview,
83
    /// Header widget
84
    pub header: Header,
85
    /// Item list widget
86
    pub item_list: ItemList,
87
    /// Color theme
88
    pub theme: Arc<crate::theme::ColorTheme>,
89
90
    /// Timer for tracking matcher activity
91
    pub matcher_timer: std::time::Instant,
92
93
    /// Last time spinner visibility changed
94
    pub spinner_last_change: std::time::Instant,
95
    /// Whether to show the spinner (controlled with debouncing)
96
    pub show_spinner: bool,
97
    /// Start time for spinner animation (set once at app creation, never reset)
98
    pub spinner_start: std::time::Instant,
99
100
    /// Query history navigation
101
    pub query_history: Vec<String>,
102
    /// Current position in query history
103
    pub history_index: Option<usize>,
104
    /// Saved input when navigating history
105
    pub saved_input: String,
106
107
    /// Command history navigation (for interactive mode)
108
    pub cmd_history: Vec<String>,
109
    /// Current position in command history
110
    pub cmd_history_index: Option<usize>,
111
    /// Saved command input when navigating history
112
    pub saved_cmd_input: String,
113
114
    /// Skim configuration options
115
    pub options: SkimOptions,
116
    /// The command being executed
117
    pub cmd: String,
118
    /// Pre-computed layout template built from options; rebuilt when options change.
119
    pub layout_template: LayoutTemplate,
120
    /// The header height used when `layout_template` was last built.
121
    /// Used to detect when multiline header items arrive and the template needs
122
    /// to be rebuilt to allocate the correct number of rows.
123
    last_header_height: u16,
124
    /// Concrete widget areas for the last rendered frame; updated in `render()`.
125
    pub layout: AppLayout,
126
    /// Last time preview was spawned (for debouncing)
127
    pub last_preview_spawn: std::time::Instant,
128
    /// Whether a preview run was debounced and needs to be retried
129
    pub pending_preview_run: bool,
130
    reader_timer: std::time::Instant,
131
    items_just_updated: bool,
132
    /// Records if we are scrolling (mouse down on the scrollbar and no mouse up yet)
133
    currently_scrolling: bool,
134
    /// Time of the previous left click, used to recognize `double-click` bindings.
135
    last_left_click: std::time::Instant,
136
    /// Set by [`Skim::check_reader`] once the reader has finished producing
137
    /// items. Reset on `reload`. Drives the one-shot `load` event.
138
    pub(crate) reader_done: bool,
139
    /// Whether the `load` event has been fired for the current read. Reset on
140
    /// `reload` so a new read fires `load` again.
141
    pub(crate) load_event_fired: bool,
142
    /// Set whenever a matcher run is (re)started; edge-triggers the one-shot
143
    /// `result` (and `zero`/`one`) events once that run completes, polled from
144
    /// the heartbeat.
145
    pub(crate) result_pending: bool,
146
    /// The last item that had focus, tracked so the `focus` event fires only
147
    /// when the focused item actually changes on cursor movement.
148
    last_focused: Option<Arc<dyn SkimItem>>,
149
}
150
151
impl Widget for &mut App {
152
2.68k
    fn render(self, area: Rect, buf: &mut Buffer) {
153
2.68k
        let mut res = SkimRender::default();
154
2.68k
        let has_border = self.options.border.is_some();
155
156
        // Update header with reserved items (from --header-lines).  When
157
        // --multiline is active a header-line item may occupy more rows than
158
        // the initial estimate (which was based purely on item count).  Detect
159
        // that change and rebuild the layout template so the header area gets
160
        // the right height before the frame is drawn.
161
2.68k
        self.header.set_header_lines(self.item_pool.reserved());
162
2.68k
        let current_header_height = self.header.height();
163
2.68k
        if current_header_height != self.last_header_height {
  Branch (163:12): [True: 7, False: 2.64k]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/app.rs
Line
Count
Source
1
use std::process::Stdio;
2
use std::rc::Rc;
3
use std::sync::Arc;
4
use std::sync::atomic::{AtomicBool, Ordering};
5
6
use crate::item::{ItemPool, MatchedItem};
7
use crate::matcher::{Matcher, MatcherControl};
8
use crate::prelude::ExactOrFuzzyEngineFactory;
9
use crate::tui::input::StatusInfo;
10
use crate::tui::layout::{AppLayout, LayoutTemplate};
11
use crate::tui::options::TuiLayout;
12
use crate::tui::statusline::InfoDisplay;
13
use crate::tui::widget::SkimWidget;
14
use crate::tui::{SkimRender, TICK_RATE};
15
use crate::{ItemPreview, PreviewContext, Rank, SkimItem, SkimOptions, util};
16
17
#[cfg(test)]
18
#[path = "app_tests.rs"]
19
mod tests;
20
21
use super::actions::Action;
22
use super::header::Header;
23
use super::item_list::ItemList;
24
use super::{Event, Tui, input, preview};
25
use crate::binds::SkimEvent;
26
use crate::thread_pool::{self, ThreadPool};
27
use crossterm::event::{KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
28
use eyre::{Result, bail};
29
use input::Input;
30
use preview::Preview;
31
use ratatui::buffer::Buffer;
32
use ratatui::crossterm::event::KeyCode::Char;
33
use ratatui::layout::Rect;
34
use ratatui::prelude::Backend;
35
use ratatui::widgets::Widget;
36
use std::sync::LazyLock;
37
38
519
static NUM_THREADS: LazyLock<usize> = LazyLock::new(|| {
39
519
    std::thread::available_parallelism()
40
519
        .ok()
41
519
        .map_or_else(|| 0, std::num::NonZero::get)
42
519
});
43
44
const MATCHER_DEBOUNCE_MS: u128 = 200;
45
const HIDE_GRACE_MS: u128 = 500;
46
const DOUBLE_CLICK_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
47
48
/// Application state for skim's TUI
49
#[allow(clippy::struct_excessive_bools)]
50
pub struct App {
51
    /// Pool of items to be filtered
52
    pub item_pool: Arc<ItemPool>,
53
    /// Thread pool used by the matcher (⌊2N/3⌋ threads).
54
    pub matcher_pool: Arc<ThreadPool>,
55
    /// Thread pool used by the reader pipeline (⌈N/3⌉ threads).
56
    pub reader_pool: Arc<ThreadPool>,
57
    /// Whether the application should quit
58
    pub should_quit: bool,
59
    /// The terminating action, including one dispatched inside a follow-up or conditional chain.
60
    pub(crate) final_action: Option<Action>,
61
62
    /// Current cursor position (x, y)
63
    pub cursor_pos: (u16, u16),
64
    /// Control handle for the matcher thread
65
    pub matcher_control: MatcherControl,
66
    /// The matcher for filtering items
67
    pub matcher: Matcher,
68
    /// Register for yank/paste operations
69
    pub yank_register: String,
70
    /// Last time the matcher was restarted
71
    pub last_matcher_restart: std::time::Instant,
72
    /// Whether a matcher restart is pending
73
    pub pending_matcher_restart: bool,
74
    /// Whether or not we need a render on the next heartbeat
75
    pub needs_render: Arc<AtomicBool>,
76
    /// Time of the last render
77
    pub last_render_timer: std::time::Instant,
78
79
    /// Input field widget
80
    pub input: Input,
81
    /// Preview pane widget
82
    pub preview: Preview,
83
    /// Header widget
84
    pub header: Header,
85
    /// Item list widget
86
    pub item_list: ItemList,
87
    /// Color theme
88
    pub theme: Arc<crate::theme::ColorTheme>,
89
90
    /// Timer for tracking matcher activity
91
    pub matcher_timer: std::time::Instant,
92
93
    /// Last time spinner visibility changed
94
    pub spinner_last_change: std::time::Instant,
95
    /// Whether to show the spinner (controlled with debouncing)
96
    pub show_spinner: bool,
97
    /// Start time for spinner animation (set once at app creation, never reset)
98
    pub spinner_start: std::time::Instant,
99
100
    /// Query history navigation
101
    pub query_history: Vec<String>,
102
    /// Current position in query history
103
    pub history_index: Option<usize>,
104
    /// Saved input when navigating history
105
    pub saved_input: String,
106
107
    /// Command history navigation (for interactive mode)
108
    pub cmd_history: Vec<String>,
109
    /// Current position in command history
110
    pub cmd_history_index: Option<usize>,
111
    /// Saved command input when navigating history
112
    pub saved_cmd_input: String,
113
114
    /// Skim configuration options
115
    pub options: SkimOptions,
116
    /// The command being executed
117
    pub cmd: String,
118
    /// Pre-computed layout template built from options; rebuilt when options change.
119
    pub layout_template: LayoutTemplate,
120
    /// The header height used when `layout_template` was last built.
121
    /// Used to detect when multiline header items arrive and the template needs
122
    /// to be rebuilt to allocate the correct number of rows.
123
    last_header_height: u16,
124
    /// Concrete widget areas for the last rendered frame; updated in `render()`.
125
    pub layout: AppLayout,
126
    /// Last time preview was spawned (for debouncing)
127
    pub last_preview_spawn: std::time::Instant,
128
    /// Whether a preview run was debounced and needs to be retried
129
    pub pending_preview_run: bool,
130
    reader_timer: std::time::Instant,
131
    items_just_updated: bool,
132
    /// Records if we are scrolling (mouse down on the scrollbar and no mouse up yet)
133
    currently_scrolling: bool,
134
    /// Time of the previous left click, used to recognize `double-click` bindings.
135
    last_left_click: std::time::Instant,
136
    /// Set by [`Skim::check_reader`] once the reader has finished producing
137
    /// items. Reset on `reload`. Drives the one-shot `load` event.
138
    pub(crate) reader_done: bool,
139
    /// Whether the `load` event has been fired for the current read. Reset on
140
    /// `reload` so a new read fires `load` again.
141
    pub(crate) load_event_fired: bool,
142
    /// Set whenever a matcher run is (re)started; edge-triggers the one-shot
143
    /// `result` (and `zero`/`one`) events once that run completes, polled from
144
    /// the heartbeat.
145
    pub(crate) result_pending: bool,
146
    /// The last item that had focus, tracked so the `focus` event fires only
147
    /// when the focused item actually changes on cursor movement.
148
    last_focused: Option<Arc<dyn SkimItem>>,
149
}
150
151
impl Widget for &mut App {
152
2.67k
    fn render(self, area: Rect, buf: &mut Buffer) {
153
2.67k
        let mut res = SkimRender::default();
154
2.67k
        let has_border = self.options.border.is_some();
155
156
        // Update header with reserved items (from --header-lines).  When
157
        // --multiline is active a header-line item may occupy more rows than
158
        // the initial estimate (which was based purely on item count).  Detect
159
        // that change and rebuild the layout template so the header area gets
160
        // the right height before the frame is drawn.
161
2.67k
        self.header.set_header_lines(self.item_pool.reserved());
162
2.67k
        let current_header_height = self.header.height();
163
2.67k
        if current_header_height != self.last_header_height {
  Branch (163:12): [True: 7, False: 2.63k]
 
  Branch (163:12): [True: 0, False: 28]
-
164
7
            self.last_header_height = current_header_height;
165
7
            self.layout_template = LayoutTemplate::from_options(&self.options, current_header_height);
166
2.67k
        }
167
2.68k
        self.layout = self.layout_template.apply(area);
168
169
2.68k
        if let Some(
header_area96
) = self.layout.header_area {
  Branch (169:16): [True: 96, False: 2.55k]
+
164
7
            self.last_header_height = current_header_height;
165
7
            self.layout_template = LayoutTemplate::from_options(&self.options, current_header_height);
166
2.66k
        }
167
2.67k
        self.layout = self.layout_template.apply(area);
168
169
2.67k
        if let Some(
header_area99
) = self.layout.header_area {
  Branch (169:16): [True: 99, False: 2.54k]
 
  Branch (169:16): [True: 0, False: 28]
-
170
96
            res |= self.header.render(header_area, buf);
171
2.58k
        }
172
173
2.68k
        if let Some(
preview_area223
) = self.layout.preview_area {
  Branch (173:16): [True: 221, False: 2.43k]
+
170
99
            res |= self.header.render(header_area, buf);
171
2.57k
        }
172
173
2.67k
        if let Some(
preview_area223
) = self.layout.preview_area {
  Branch (173:16): [True: 221, False: 2.42k]
 
  Branch (173:16): [True: 2, False: 26]
-
174
223
            res |= self.preview.render(preview_area, buf);
175
2.45k
        }
176
177
2.68k
        res |= self.item_list.render(self.layout.list_area, buf);
178
179
        // Render the input after the item list so that the status shows correct information.
180
2.68k
        self.input.status_info = if self.options.info.display == InfoDisplay::Hidden {
  Branch (180:37): [True: 59, False: 2.59k]
+
174
223
            res |= self.preview.render(preview_area, buf);
175
2.45k
        }
176
177
2.67k
        res |= self.item_list.render(self.layout.list_area, buf);
178
179
        // Render the input after the item list so that the status shows correct information.
180
2.67k
        self.input.status_info = if self.options.info.display == InfoDisplay::Hidden {
  Branch (180:37): [True: 59, False: 2.58k]
 
  Branch (180:37): [True: 1, False: 27]
-
181
60
            None
182
        } else {
183
            Some(StatusInfo {
184
2.62k
                total: self.item_pool.len(),
185
2.62k
                matched: self.item_list.count(),
186
2.62k
                processed: self.matcher_control.get_num_processed(),
187
2.62k
                show_spinner: self.show_spinner,
188
2.62k
                matcher_mode: if self.options.regex {
  Branch (188:34): [True: 3, False: 2.59k]
+
181
60
            None
182
        } else {
183
            Some(StatusInfo {
184
2.61k
                total: self.item_pool.len(),
185
2.61k
                matched: self.item_list.count(),
186
2.61k
                processed: self.matcher_control.get_num_processed(),
187
2.61k
                show_spinner: self.show_spinner,
188
2.61k
                matcher_mode: if self.options.regex {
  Branch (188:34): [True: 3, False: 2.58k]
 
  Branch (188:34): [True: 1, False: 26]
-
189
4
                    "RE".to_string()
190
                } else {
191
2.61k
                    String::new()
192
                },
193
2.62k
                multi_selection: self.options.multi,
194
2.62k
                selected: self.item_list.selection.len(),
195
2.62k
                current_item_idx: self.item_list.current,
196
2.62k
                hscroll_offset: i64::from(self.item_list.manual_hscroll),
197
2.62k
                start: Some(self.spinner_start),
198
2.62k
                inline_separator: self
199
2.62k
                    .options
200
2.62k
                    .info
201
2.62k
                    .separator()
202
2.62k
                    .unwrap_or(super::statusline::DEFAULT_SEPARATOR)
203
2.62k
                    .to_string(),
204
            })
205
        };
206
2.68k
        res |= self.input.render(self.layout.input_area, buf);
207
208
        // Cursor position needs to account for input border and title.
209
        self.cursor_pos = (
210
2.68k
            self.layout.input_area.x + self.input.cursor_pos() + u16::from(has_border),
211
2.68k
            self.layout.input_area.y
212
2.68k
                + u16::from(!(self.options.layout == TuiLayout::Reverse && 
self.options.border49
.
is_none49
())),
  Branch (212:31): [True: 48, False: 2.60k]
+
189
4
                    "RE".to_string()
190
                } else {
191
2.61k
                    String::new()
192
                },
193
2.61k
                multi_selection: self.options.multi,
194
2.61k
                selected: self.item_list.selection.len(),
195
2.61k
                current_item_idx: self.item_list.current,
196
2.61k
                hscroll_offset: i64::from(self.item_list.manual_hscroll),
197
2.61k
                start: Some(self.spinner_start),
198
2.61k
                inline_separator: self
199
2.61k
                    .options
200
2.61k
                    .info
201
2.61k
                    .separator()
202
2.61k
                    .unwrap_or(super::statusline::DEFAULT_SEPARATOR)
203
2.61k
                    .to_string(),
204
            })
205
        };
206
2.67k
        res |= self.input.render(self.layout.input_area, buf);
207
208
        // Cursor position needs to account for input border and title.
209
        self.cursor_pos = (
210
2.67k
            self.layout.input_area.x + self.input.cursor_pos() + u16::from(has_border),
211
2.67k
            self.layout.input_area.y
212
2.67k
                + u16::from(!(self.options.layout == TuiLayout::Reverse && 
self.options.border48
.
is_none48
())),
  Branch (212:31): [True: 47, False: 2.59k]
 
  Branch (212:31): [True: 1, False: 27]
-
213
        );
214
2.68k
        if res.run_preview {
  Branch (214:12): [True: 374, False: 2.27k]
-
  Branch (214:12): [True: 0, False: 28]
-
215
374
            self.pending_preview_run = true;
216
2.30k
        }
217
2.68k
    }
218
}
219
220
impl Default for App {
221
137
    fn default() -> Self {
222
137
        let theme = Arc::new(crate::theme::ColorTheme::default());
223
137
        let opts = SkimOptions::default();
224
137
        let header = Header::from_options(&opts, theme.clone());
225
137
        let initial_header_height = header.height();
226
137
        let layout_template = LayoutTemplate::from_options(&opts, initial_header_height);
227
137
        let layout = layout_template.apply(Rect::default());
228
137
        let (reader_threads, matcher_threads) = thread_pool::partition_threads(*NUM_THREADS);
229
137
        Self {
230
137
            input: Input::from_options(&opts, theme.clone()),
231
137
            preview: Preview::from_options(&opts, theme.clone()),
232
137
            header,
233
137
            item_list: ItemList::from_options(&opts, theme.clone()),
234
137
            matcher_pool: Arc::new(ThreadPool::new(matcher_threads)),
235
137
            reader_pool: Arc::new(ThreadPool::new(reader_threads)),
236
137
            item_pool: Arc::default(),
237
137
            theme,
238
137
            should_quit: false,
239
137
            final_action: None,
240
137
            cursor_pos: (0, 0),
241
137
            matcher: Matcher::builder(Rc::new(ExactOrFuzzyEngineFactory::builder().build()))
242
137
                .case(crate::CaseMatching::default())
243
137
                .build(),
244
137
            yank_register: String::new(),
245
137
            matcher_control: MatcherControl::default(),
246
137
            matcher_timer: std::time::Instant::now(),
247
137
            last_matcher_restart: std::time::Instant::now(),
248
137
            pending_matcher_restart: false,
249
137
            needs_render: Arc::new(AtomicBool::new(true)),
250
137
            last_render_timer: std::time::Instant::now()
251
137
                .checked_sub(std::time::Duration::from_secs(1))
252
137
                .unwrap(),
253
137
            // spinner initial state
254
137
            spinner_last_change: std::time::Instant::now(),
255
137
            show_spinner: false,
256
137
            spinner_start: std::time::Instant::now(),
257
137
            query_history: Vec::new(),
258
137
            history_index: None,
259
137
            saved_input: String::new(),
260
137
            cmd_history: Vec::new(),
261
137
            cmd_history_index: None,
262
137
            saved_cmd_input: String::new(),
263
137
            options: opts,
264
137
            cmd: String::new(),
265
137
            last_header_height: initial_header_height,
266
137
            layout_template,
267
137
            layout,
268
137
            last_preview_spawn: std::time::Instant::now(),
269
137
            pending_preview_run: false,
270
137
            reader_timer: std::time::Instant::now(),
271
137
            items_just_updated: false,
272
137
            currently_scrolling: false,
273
137
            last_left_click: std::time::Instant::now()
274
137
                .checked_sub(DOUBLE_CLICK_INTERVAL * 2)
275
137
                .unwrap(),
276
137
            reader_done: false,
277
137
            load_event_fired: false,
278
137
            result_pending: false,
279
137
            last_focused: None,
280
137
        }
281
137
    }
282
}
283
284
impl App {
285
    /// Creates a new App from skim options.
286
    ///
287
    /// # Panics
288
    ///
289
    /// Panics if the thread pool cannot be built (system resource exhaustion).
290
    #[must_use]
291
391
    pub fn from_options(options: SkimOptions, theme: Arc<crate::theme::ColorTheme>, cmd: String) -> Self {
292
391
        let header = Header::from_options(&options, theme.clone());
293
391
        let initial_header_height = header.height();
294
391
        let layout_template = LayoutTemplate::from_options(&options, initial_header_height);
295
391
        let layout = layout_template.apply(Rect::default());
296
391
        let (mut reader_threads, mut matcher_threads) = thread_pool::partition_threads(*NUM_THREADS);
297
391
        if options.flags.contains(&crate::options::FeatureFlag::SingleReader) {
  Branch (297:12): [True: 0, False: 374]
+
213
        );
214
2.67k
        if res.run_preview {
  Branch (214:12): [True: 373, False: 2.27k]
+
  Branch (214:12): [True: 1, False: 27]
+
215
374
            self.pending_preview_run = true;
216
2.30k
        }
217
2.67k
    }
218
}
219
220
impl Default for App {
221
137
    fn default() -> Self {
222
137
        let theme = Arc::new(crate::theme::ColorTheme::default());
223
137
        let opts = SkimOptions::default();
224
137
        let header = Header::from_options(&opts, theme.clone());
225
137
        let initial_header_height = header.height();
226
137
        let layout_template = LayoutTemplate::from_options(&opts, initial_header_height);
227
137
        let layout = layout_template.apply(Rect::default());
228
137
        let (reader_threads, matcher_threads) = thread_pool::partition_threads(*NUM_THREADS);
229
137
        Self {
230
137
            input: Input::from_options(&opts, theme.clone()),
231
137
            preview: Preview::from_options(&opts, theme.clone()),
232
137
            header,
233
137
            item_list: ItemList::from_options(&opts, theme.clone()),
234
137
            matcher_pool: Arc::new(ThreadPool::new(matcher_threads)),
235
137
            reader_pool: Arc::new(ThreadPool::new(reader_threads)),
236
137
            item_pool: Arc::default(),
237
137
            theme,
238
137
            should_quit: false,
239
137
            final_action: None,
240
137
            cursor_pos: (0, 0),
241
137
            matcher: Matcher::builder(Rc::new(ExactOrFuzzyEngineFactory::builder().build()))
242
137
                .case(crate::CaseMatching::default())
243
137
                .build(),
244
137
            yank_register: String::new(),
245
137
            matcher_control: MatcherControl::default(),
246
137
            matcher_timer: std::time::Instant::now(),
247
137
            last_matcher_restart: std::time::Instant::now(),
248
137
            pending_matcher_restart: false,
249
137
            needs_render: Arc::new(AtomicBool::new(true)),
250
137
            last_render_timer: std::time::Instant::now()
251
137
                .checked_sub(std::time::Duration::from_secs(1))
252
137
                .unwrap(),
253
137
            // spinner initial state
254
137
            spinner_last_change: std::time::Instant::now(),
255
137
            show_spinner: false,
256
137
            spinner_start: std::time::Instant::now(),
257
137
            query_history: Vec::new(),
258
137
            history_index: None,
259
137
            saved_input: String::new(),
260
137
            cmd_history: Vec::new(),
261
137
            cmd_history_index: None,
262
137
            saved_cmd_input: String::new(),
263
137
            options: opts,
264
137
            cmd: String::new(),
265
137
            last_header_height: initial_header_height,
266
137
            layout_template,
267
137
            layout,
268
137
            last_preview_spawn: std::time::Instant::now(),
269
137
            pending_preview_run: false,
270
137
            reader_timer: std::time::Instant::now(),
271
137
            items_just_updated: false,
272
137
            currently_scrolling: false,
273
137
            last_left_click: std::time::Instant::now()
274
137
                .checked_sub(DOUBLE_CLICK_INTERVAL * 2)
275
137
                .unwrap(),
276
137
            reader_done: false,
277
137
            load_event_fired: false,
278
137
            result_pending: false,
279
137
            last_focused: None,
280
137
        }
281
137
    }
282
}
283
284
impl App {
285
    /// Creates a new App from skim options.
286
    ///
287
    /// # Panics
288
    ///
289
    /// Panics if the thread pool cannot be built (system resource exhaustion).
290
    #[must_use]
291
391
    pub fn from_options(options: SkimOptions, theme: Arc<crate::theme::ColorTheme>, cmd: String) -> Self {
292
391
        let header = Header::from_options(&options, theme.clone());
293
391
        let initial_header_height = header.height();
294
391
        let layout_template = LayoutTemplate::from_options(&options, initial_header_height);
295
391
        let layout = layout_template.apply(Rect::default());
296
391
        let (mut reader_threads, mut matcher_threads) = thread_pool::partition_threads(*NUM_THREADS);
297
391
        if options.flags.contains(&crate::options::FeatureFlag::SingleReader) {
  Branch (297:12): [True: 0, False: 374]
 
  Branch (297:12): [True: 1, False: 16]
 
298
1
            reader_threads = 1;
299
390
        }
300
391
        if options.flags.contains(&crate::options::FeatureFlag::SingleMatcher) {
  Branch (300:12): [True: 0, False: 374]
 
  Branch (300:12): [True: 1, False: 16]
@@ -20,51 +20,51 @@
 
  Branch (376:16): [True: 2, False: 2]
 
377
11
            let 
left_val4
=
left4
.
trim_matches4
(|x: char| !x.is_numeric()).
parse4
::<u16>().
unwrap_or4
(0);
378
4
            let right_val = right
379
4
                .trim_matches(|x: char| !x.is_numeric())
380
4
                .parse::<u16>()
381
4
                .unwrap_or(0);
382
4
            left_val.saturating_sub(right_val)
383
6
        } else if let Some((
left1
,
right1
)) = substituted.split_once('+') {
  Branch (383:23): [True: 0, False: 4]
 
  Branch (383:23): [True: 1, False: 1]
-
384
1
            let left_val = left.trim_matches(|x: char| !x.is_numeric()).parse::<u16>().unwrap_or(0);
385
1
            let right_val = right
386
1
                .trim_matches(|x: char| !x.is_numeric())
387
1
                .parse::<u16>()
388
1
                .unwrap_or(0);
389
1
            left_val.saturating_add(right_val)
390
        } else {
391
5
            substituted
392
14
                .
trim_matches5
(|x: char| !x.is_numeric())
393
5
                .parse::<u16>()
394
5
                .unwrap_or(0)
395
        }
396
10
    }
397
398
93
    fn needs_render(&mut self) {
399
93
        self.needs_render.store(true, Ordering::Relaxed);
400
93
    }
401
402
    /// Call after items are added or filtered (e.g., `Event::NewItem`, matcher completes)
403
4
    fn on_items_updated(&mut self) {
404
4
        self.pending_matcher_restart = true;
405
4
        trace!("Got new items, len {}", 
self.item_pool.len()0
);
406
        // mark reader activity and reset reader timer
407
4
        self.reader_timer = std::time::Instant::now();
408
4
        self.items_just_updated = true;
409
4
    }
410
411
    /// Call after selection changes (e.g., selection actions, `Event::Key`).
412
    ///
413
    /// Emits the `focus` event when the focused item actually changed, so a
414
    /// `focus:<action>` binding runs on cursor movement.
415
154
    fn on_selection_changed(&mut self) -> Vec<Event> {
416
154
        let mut events = vec![Event::RunPreview];
417
154
        events.extend(self.take_focus_event());
418
154
        events
419
154
    }
420
421
    /// Returns a `focus` event if the focused item changed since the last call,
422
    /// updating the tracked item. Used by [`Self::on_selection_changed`].
423
2.81k
    fn take_focus_event(&mut self) -> Option<Event> {
424
2.81k
        let focused = self.item_list.selected().map(|m| m.item);
425
2.81k
        let changed = match (&self.last_focused, &focused) {
426
1.87k
            (Some(prev), Some(curr)) => !Arc::ptr_eq(prev, curr),
427
562
            (None, None) => false,
428
376
            _ => true,
429
        };
430
2.81k
        if changed {
  Branch (430:12): [True: 451, False: 2.30k]
-
  Branch (430:12): [True: 44, False: 15]
-
431
495
            self.last_focused = focused;
432
495
            Some(Event::Key(SkimEvent::Focus.into()))
433
        } else {
434
2.31k
            None
435
        }
436
2.81k
    }
437
438
    /// Polls the async reader/matcher state and returns any newly-due
439
    /// completion events (`load`, `result`, `zero`, `one`).
440
    ///
441
    /// Each is edge-triggered by a flag so it fires once per read / search:
442
    /// `load` when the reader finishes, `result` (plus `zero`/`one` from the
443
    /// matcher's authoritative count) when a search completes. Called from the
444
    /// heartbeat.
445
1.74k
    fn poll_completion_events(&mut self) -> Vec<Event> {
446
1.74k
        let mut events = Vec::new();
447
448
1.74k
        if self.reader_done
  Branch (448:12): [True: 1.74k, False: 1]
+
384
1
            let left_val = left.trim_matches(|x: char| !x.is_numeric()).parse::<u16>().unwrap_or(0);
385
1
            let right_val = right
386
1
                .trim_matches(|x: char| !x.is_numeric())
387
1
                .parse::<u16>()
388
1
                .unwrap_or(0);
389
1
            left_val.saturating_add(right_val)
390
        } else {
391
5
            substituted
392
14
                .
trim_matches5
(|x: char| !x.is_numeric())
393
5
                .parse::<u16>()
394
5
                .unwrap_or(0)
395
        }
396
10
    }
397
398
93
    fn needs_render(&mut self) {
399
93
        self.needs_render.store(true, Ordering::Relaxed);
400
93
    }
401
402
    /// Call after items are added or filtered (e.g., `Event::NewItem`, matcher completes)
403
4
    fn on_items_updated(&mut self) {
404
4
        self.pending_matcher_restart = true;
405
4
        trace!("Got new items, len {}", 
self.item_pool.len()0
);
406
        // mark reader activity and reset reader timer
407
4
        self.reader_timer = std::time::Instant::now();
408
4
        self.items_just_updated = true;
409
4
    }
410
411
    /// Call after selection changes (e.g., selection actions, `Event::Key`).
412
    ///
413
    /// Emits the `focus` event when the focused item actually changed, so a
414
    /// `focus:<action>` binding runs on cursor movement.
415
154
    fn on_selection_changed(&mut self) -> Vec<Event> {
416
154
        let mut events = vec![Event::RunPreview];
417
154
        events.extend(self.take_focus_event());
418
154
        events
419
154
    }
420
421
    /// Returns a `focus` event if the focused item changed since the last call,
422
    /// updating the tracked item. Used by [`Self::on_selection_changed`].
423
2.80k
    fn take_focus_event(&mut self) -> Option<Event> {
424
2.80k
        let focused = self.item_list.selected().map(|m| m.item);
425
2.80k
        let changed = match (&self.last_focused, &focused) {
426
1.87k
            (Some(prev), Some(curr)) => !Arc::ptr_eq(prev, curr),
427
558
            (None, None) => false,
428
376
            _ => true,
429
        };
430
2.80k
        if changed {
  Branch (430:12): [True: 450, False: 2.29k]
+
  Branch (430:12): [True: 45, False: 14]
+
431
495
            self.last_focused = focused;
432
495
            Some(Event::Key(SkimEvent::Focus.into()))
433
        } else {
434
2.30k
            None
435
        }
436
2.80k
    }
437
438
    /// Polls the async reader/matcher state and returns any newly-due
439
    /// completion events (`load`, `result`, `zero`, `one`).
440
    ///
441
    /// Each is edge-triggered by a flag so it fires once per read / search:
442
    /// `load` when the reader finishes, `result` (plus `zero`/`one` from the
443
    /// matcher's authoritative count) when a search completes. Called from the
444
    /// heartbeat.
445
1.74k
    fn poll_completion_events(&mut self) -> Vec<Event> {
446
1.74k
        let mut events = Vec::new();
447
448
1.74k
        if self.reader_done
  Branch (448:12): [True: 1.74k, False: 0]
 
  Branch (448:12): [True: 4, False: 2]
-
449
1.74k
            && !self.load_event_fired
  Branch (449:16): [True: 407, False: 1.33k]
+
449
1.74k
            && !self.load_event_fired
  Branch (449:16): [True: 406, False: 1.33k]
 
  Branch (449:16): [True: 3, False: 1]
-
450
410
            && self.matcher_control.stopped()
  Branch (450:16): [True: 406, False: 1]
+
450
409
            && self.matcher_control.stopped()
  Branch (450:16): [True: 406, False: 0]
 
  Branch (450:16): [True: 3, False: 0]
-
451
409
            && self.item_pool.num_not_taken() == 0
  Branch (451:16): [True: 389, False: 17]
+
451
409
            && self.item_pool.num_not_taken() == 0
  Branch (451:16): [True: 388, False: 18]
 
  Branch (451:16): [True: 2, False: 1]
-
452
391
        {
453
391
            self.load_event_fired = true;
454
391
            events.push(Event::Key(SkimEvent::Load.into()));
455
1.35k
        }
456
457
1.74k
        if self.result_pending && 
self.matcher_control729
.
stopped729
() {
  Branch (457:12): [True: 726, False: 1.01k]
-  Branch (457:35): [True: 724, False: 2]
+
452
390
        {
453
390
            self.load_event_fired = true;
454
390
            events.push(Event::Key(SkimEvent::Load.into()));
455
1.35k
        }
456
457
1.74k
        if self.result_pending && 
self.matcher_control727
.
stopped727
() {
  Branch (457:12): [True: 724, False: 1.01k]
+  Branch (457:35): [True: 723, False: 1]
 
  Branch (457:12): [True: 3, False: 3]
   Branch (457:35): [True: 2, False: 1]
-
458
726
            self.result_pending = false;
459
726
            events.push(Event::Key(SkimEvent::Result.into()));
460
726
            if self.reader_done {
  Branch (460:16): [True: 723, False: 1]
+
458
725
            self.result_pending = false;
459
725
            events.push(Event::Key(SkimEvent::Result.into()));
460
725
            if self.reader_done {
  Branch (460:16): [True: 723, False: 0]
 
  Branch (460:16): [True: 1, False: 1]
-
461
724
                match self.matcher_control.get_num_matched() {
462
156
                    0 => events.push(Event::Key(SkimEvent::Zero.into())),
463
222
                    1 => events.push(Event::Key(SkimEvent::One.into())),
464
346
                    _ => {}
465
                }
466
2
            }
467
1.02k
        }
468
469
1.74k
        events
470
1.74k
    }
471
472
    /// Call when query changes (e.g., `AddChar`, `BackwardDeleteChar`, etc.)
473
395
    fn on_query_changed(&mut self) -> Vec<Event> {
474
        // In interactive mode with --cmd, execute the command with {} substitution
475
395
        if self.options.interactive && 
self.options.cmd46
.
is_some46
() {
  Branch (475:12): [True: 45, False: 330]
+
461
724
                match self.matcher_control.get_num_matched() {
462
156
                    0 => events.push(Event::Key(SkimEvent::Zero.into())),
463
221
                    1 => events.push(Event::Key(SkimEvent::One.into())),
464
347
                    _ => {}
465
                }
466
1
            }
467
1.02k
        }
468
469
1.74k
        events
470
1.74k
    }
471
472
    /// Call when query changes (e.g., `AddChar`, `BackwardDeleteChar`, etc.)
473
394
    fn on_query_changed(&mut self) -> Vec<Event> {
474
        // In interactive mode with --cmd, execute the command with {} substitution
475
394
        if self.options.interactive && 
self.options.cmd46
.
is_some46
() {
  Branch (475:12): [True: 45, False: 329]
   Branch (475:40): [True: 43, False: 2]
 
  Branch (475:12): [True: 1, False: 19]
   Branch (475:40): [True: 0, False: 1]
-
476
43
            let expanded_cmd = self.expand_cmd(&self.cmd, true);
477
43
            return vec![Event::Reload(expanded_cmd)];
478
352
        }
479
352
        self.restart_matcher_debounced();
480
352
        vec![
481
352
            Event::Key(crate::binds::SkimEvent::Change.into()), // fire the `change` event binding
482
352
            Event::RunPreview,
483
        ]
484
395
    }
485
486
1.74k
    fn update_spinner(&mut self) {
487
1.74k
        let matcher_running = !self.matcher_control.stopped();
488
1.74k
        let time_since_match = self.matcher_timer.elapsed();
489
1.74k
        let reading = self.item_pool.num_not_taken() != 0;
490
491
1.74k
        let should_show_spinner = reading || (
matcher_running1.72k
&&
time_since_match.as_millis() > MATCHER_DEBOUNCE_MS1
);
  Branch (491:35): [True: 17, False: 1.72k]
-  Branch (491:47): [True: 1, False: 1.72k]
+
476
43
            let expanded_cmd = self.expand_cmd(&self.cmd, true);
477
43
            return vec![Event::Reload(expanded_cmd)];
478
351
        }
479
351
        self.restart_matcher_debounced();
480
351
        vec![
481
351
            Event::Key(crate::binds::SkimEvent::Change.into()), // fire the `change` event binding
482
351
            Event::RunPreview,
483
        ]
484
394
    }
485
486
1.74k
    fn update_spinner(&mut self) {
487
1.74k
        let matcher_running = !self.matcher_control.stopped();
488
1.74k
        let time_since_match = self.matcher_timer.elapsed();
489
1.74k
        let reading = self.item_pool.num_not_taken() != 0;
490
491
1.74k
        let should_show_spinner = reading || (
matcher_running1.72k
&&
time_since_match.as_millis() > MATCHER_DEBOUNCE_MS0
);
  Branch (491:35): [True: 18, False: 1.72k]
+  Branch (491:47): [True: 0, False: 1.72k]
 
  Branch (491:35): [True: 2, False: 1]
   Branch (491:47): [True: 0, False: 1]
-
492
493
1.74k
        if should_show_spinner && 
!self.show_spinner19
{
  Branch (493:12): [True: 17, False: 1.72k]
-  Branch (493:35): [True: 6, False: 11]
+
492
493
1.74k
        if should_show_spinner && 
!self.show_spinner20
{
  Branch (493:12): [True: 18, False: 1.72k]
+  Branch (493:35): [True: 6, False: 12]
 
  Branch (493:12): [True: 2, False: 1]
   Branch (493:35): [True: 2, False: 0]
-
494
8
            self.toggle_spinner();
495
1.73k
        } else if !should_show_spinner && 
self.show_spinner1.72k
{
  Branch (495:19): [True: 1.72k, False: 11]
-  Branch (495:43): [True: 6, False: 1.72k]
+
494
8
            self.toggle_spinner();
495
1.73k
        } else if !should_show_spinner && 
self.show_spinner1.72k
{
  Branch (495:19): [True: 1.72k, False: 12]
+  Branch (495:43): [True: 4, False: 1.72k]
 
  Branch (495:19): [True: 1, False: 0]
   Branch (495:43): [True: 1, False: 0]
-
496
            // Hide spinner only after grace period to avoid flickering
497
7
            if self.spinner_last_change.elapsed().as_millis() >= HIDE_GRACE_MS {
  Branch (497:16): [True: 0, False: 6]
+
496
            // Hide spinner only after grace period to avoid flickering
497
5
            if self.spinner_last_change.elapsed().as_millis() >= HIDE_GRACE_MS {
  Branch (497:16): [True: 0, False: 4]
 
  Branch (497:16): [True: 1, False: 0]
-
498
1
                self.toggle_spinner();
499
6
            }
500
1.73k
        }
501
1.74k
        if self.show_spinner {
  Branch (501:12): [True: 23, False: 1.72k]
+
498
1
                self.toggle_spinner();
499
4
            }
500
1.73k
        }
501
1.74k
        if self.show_spinner {
  Branch (501:12): [True: 22, False: 1.72k]
 
  Branch (501:12): [True: 2, False: 1]
-
502
25
            self.needs_render.store(true, Ordering::Relaxed);
503
1.72k
        }
504
1.74k
    }
505
506
    #[allow(clippy::too_many_lines)]
507
1.38k
    fn run_preview<B: Backend>(&mut self, tui: &mut Tui<B>) -> Result<()>
508
1.38k
    where
509
1.38k
        B::Error: Send + Sync + 'static,
510
    {
511
        // Debounce preview spawning to prevent overwhelming the system during rapid scrolling
512
        const DEBOUNCE_MS: u64 = 50;
513
1.38k
        let now = std::time::Instant::now();
514
1.38k
        let elapsed = now.duration_since(self.last_preview_spawn);
515
516
1.38k
        if elapsed.as_millis() < u128::from(DEBOUNCE_MS) {
  Branch (516:12): [True: 0, False: 10]
+
502
24
            self.needs_render.store(true, Ordering::Relaxed);
503
1.72k
        }
504
1.74k
    }
505
506
    #[allow(clippy::too_many_lines)]
507
1.37k
    fn run_preview<B: Backend>(&mut self, tui: &mut Tui<B>) -> Result<()>
508
1.37k
    where
509
1.37k
        B::Error: Send + Sync + 'static,
510
    {
511
        // Debounce preview spawning to prevent overwhelming the system during rapid scrolling
512
        const DEBOUNCE_MS: u64 = 50;
513
1.37k
        let now = std::time::Instant::now();
514
1.37k
        let elapsed = now.duration_since(self.last_preview_spawn);
515
516
1.37k
        if elapsed.as_millis() < u128::from(DEBOUNCE_MS) {
  Branch (516:12): [True: 0, False: 10]
 
  Branch (516:12): [True: 29, False: 50]
 
  Branch (516:12): [True: 51, False: 61]
 
  Branch (516:12): [True: 81, False: 89]
 
  Branch (516:12): [True: 0, False: 5]
 
  Branch (516:12): [True: 0, False: 9]
-
  Branch (516:12): [True: 0, False: 24]
+
  Branch (516:12): [True: 0, False: 22]
 
  Branch (516:12): [True: 67, False: 56]
-
  Branch (516:12): [True: 4, False: 2]
+
  Branch (516:12): [True: 3, False: 2]
 
  Branch (516:12): [True: 0, False: 9]
 
  Branch (516:12): [True: 10, False: 12]
 
  Branch (516:12): [True: 6, False: 10]
@@ -75,13 +75,13 @@
 
  Branch (516:12): [True: 49, False: 58]
 
  Branch (516:12): [True: 19, False: 48]
 
  Branch (516:12): [True: 65, False: 41]
-
517
            // Mark that we have a pending preview to run after the debounce period
518
604
            self.pending_preview_run = true;
519
604
            return Ok(());
520
776
        }
521
522
776
        self.pending_preview_run = false;
523
776
        self.last_preview_spawn = now;
524
525
776
        if let Some(
preview_opt74
) = &self.options.preview
  Branch (525:16): [True: 0, False: 10]
+
517
            // Mark that we have a pending preview to run after the debounce period
518
603
            self.pending_preview_run = true;
519
603
            return Ok(());
520
774
        }
521
522
774
        self.pending_preview_run = false;
523
774
        self.last_preview_spawn = now;
524
525
774
        if let Some(
preview_opt74
) = &self.options.preview
  Branch (525:16): [True: 0, False: 10]
 
  Branch (525:16): [True: 0, False: 50]
 
  Branch (525:16): [True: 5, False: 56]
 
  Branch (525:16): [True: 0, False: 89]
 
  Branch (525:16): [True: 0, False: 5]
 
  Branch (525:16): [True: 0, False: 9]
-
  Branch (525:16): [True: 0, False: 24]
+
  Branch (525:16): [True: 0, False: 22]
 
  Branch (525:16): [True: 56, False: 0]
 
  Branch (525:16): [True: 2, False: 0]
 
  Branch (525:16): [True: 0, False: 9]
@@ -151,13 +151,13 @@
 
  Branch (609:16): [True: 0, False: 0]
 
  Branch (609:16): [True: 0, False: 0]
 
  Branch (609:16): [True: 0, False: 0]
-
610
3
                let _ = tui.event_tx.try_send(Event::PreviewReady);
611
71
            }
612
702
        } else if let Some(
cb3
) = &self.options.preview_fn {
  Branch (612:23): [True: 0, False: 10]
+
610
3
                let _ = tui.event_tx.try_send(Event::PreviewReady);
611
71
            }
612
700
        } else if let Some(
cb3
) = &self.options.preview_fn {
  Branch (612:23): [True: 0, False: 10]
 
  Branch (612:23): [True: 0, False: 50]
 
  Branch (612:23): [True: 0, False: 56]
 
  Branch (612:23): [True: 0, False: 89]
 
  Branch (612:23): [True: 0, False: 5]
 
  Branch (612:23): [True: 0, False: 9]
-
  Branch (612:23): [True: 0, False: 24]
+
  Branch (612:23): [True: 0, False: 22]
 
  Branch (612:23): [True: 0, False: 0]
 
  Branch (612:23): [True: 0, False: 0]
 
  Branch (612:23): [True: 0, False: 9]
@@ -208,148 +208,148 @@
 
  Branch (616:27): [True: 0, False: 0]
 
  Branch (616:27): [True: 0, False: 0]
 
  Branch (616:27): [True: 0, False: 0]
-
617
1
                selection = vec![sel.item];
618
1
            } else {
619
1
                selection = Vec::new();
620
1
            }
621
3
            self.preview.content(&cb(selection).join("\n").into_bytes())
?0
;
622
699
        }
623
776
        Ok(())
624
1.38k
    }
625
626
    /// Handles a TUI event and updates application state
627
    ///
628
    /// # Errors
629
    ///
630
    /// Returns an error if drawing, sending events, or spawning a preview fails.
631
    #[allow(clippy::too_many_lines)]
632
8.65k
    pub fn handle_event<B: Backend>(&mut self, tui: &mut Tui<B>, event: &Event) -> Result<()>
633
8.65k
    where
634
8.65k
        B::Error: Send + Sync + 'static,
635
    {
636
8.65k
        trace!("handling event {event:?}");
637
8.65k
        match event {
638
            Event::Render => {
639
                // Always render to avoid freezing, but the render function itself can optimize
640
2.65k
                tui.get_frame();
641
2.65k
                tui.draw(|f| {
642
2.65k
                    f.render_widget(&mut *self, f.area());
643
2.65k
                    f.set_cursor_position(self.cursor_pos);
644
2.65k
                })
?0
;
645
                // Matcher output is merged into the item list during rendering,
646
                // so this is where result-driven focus changes become observable.
647
2.65k
                if let Some(
event382
) = self.take_focus_event() {
  Branch (647:24): [True: 10, False: 15]
-
  Branch (647:24): [True: 33, False: 107]
+
617
1
                selection = vec![sel.item];
618
1
            } else {
619
1
                selection = Vec::new();
620
1
            }
621
3
            self.preview.content(&cb(selection).join("\n").into_bytes())
?0
;
622
697
        }
623
774
        Ok(())
624
1.37k
    }
625
626
    /// Handles a TUI event and updates application state
627
    ///
628
    /// # Errors
629
    ///
630
    /// Returns an error if drawing, sending events, or spawning a preview fails.
631
    #[allow(clippy::too_many_lines)]
632
8.63k
    pub fn handle_event<B: Backend>(&mut self, tui: &mut Tui<B>, event: &Event) -> Result<()>
633
8.63k
    where
634
8.63k
        B::Error: Send + Sync + 'static,
635
    {
636
8.63k
        trace!("handling event {event:?}");
637
8.63k
        match event {
638
            Event::Render => {
639
                // Always render to avoid freezing, but the render function itself can optimize
640
2.65k
                tui.get_frame();
641
2.65k
                tui.draw(|f| {
642
2.65k
                    f.render_widget(&mut *self, f.area());
643
2.65k
                    f.set_cursor_position(self.cursor_pos);
644
2.65k
                })
?0
;
645
                // Matcher output is merged into the item list during rendering,
646
                // so this is where result-driven focus changes become observable.
647
2.65k
                if let Some(
event382
) = self.take_focus_event() {
  Branch (647:24): [True: 10, False: 11]
+
  Branch (647:24): [True: 33, False: 113]
 
  Branch (647:24): [True: 28, False: 181]
 
  Branch (647:24): [True: 18, False: 261]
 
  Branch (647:24): [True: 3, False: 20]
-
  Branch (647:24): [True: 9, False: 21]
-
  Branch (647:24): [True: 13, False: 76]
+
  Branch (647:24): [True: 9, False: 18]
+
  Branch (647:24): [True: 12, False: 71]
 
  Branch (647:24): [True: 34, False: 150]
 
  Branch (647:24): [True: 1, False: 6]
-
  Branch (647:24): [True: 9, False: 18]
-
  Branch (647:24): [True: 15, False: 31]
+
  Branch (647:24): [True: 9, False: 21]
+
  Branch (647:24): [True: 15, False: 34]
 
  Branch (647:24): [True: 5, False: 220]
-
  Branch (647:24): [True: 2, False: 2]
+
  Branch (647:24): [True: 3, False: 1]
 
  Branch (647:24): [True: 16, False: 146]
 
  Branch (647:24): [True: 4, False: 28]
-
  Branch (647:24): [True: 137, False: 513]
-
  Branch (647:24): [True: 10, False: 176]
+
  Branch (647:24): [True: 137, False: 510]
+
  Branch (647:24): [True: 10, False: 173]
 
  Branch (647:24): [True: 10, False: 221]
 
  Branch (647:24): [True: 25, False: 83]
-
648
382
                    tui.event_tx.try_send(event)
?0
;
649
2.27k
                }
650
            }
651
            Event::Heartbeat | Event::Tick => {
652
                // Heartbeat is used for periodic UI updates
653
1.74k
                self.update_spinner();
654
1.74k
                if self.preview.is_loading() {
  Branch (654:20): [True: 0, False: 47]
-
  Branch (654:20): [True: 0, False: 108]
+
648
382
                    tui.event_tx.try_send(event)
?0
;
649
2.26k
                }
650
            }
651
            Event::Heartbeat | Event::Tick => {
652
                // Heartbeat is used for periodic UI updates
653
1.74k
                self.update_spinner();
654
1.74k
                if self.preview.is_loading() {
  Branch (654:20): [True: 0, False: 48]
+
  Branch (654:20): [True: 0, False: 112]
 
  Branch (654:20): [True: 0, False: 145]
 
  Branch (654:20): [True: 0, False: 120]
 
  Branch (654:20): [True: 0, False: 11]
-
  Branch (654:20): [True: 0, False: 20]
-
  Branch (654:20): [True: 0, False: 43]
-
  Branch (654:20): [True: 0, False: 142]
-
  Branch (654:20): [True: 0, False: 7]
 
  Branch (654:20): [True: 0, False: 18]
-
  Branch (654:20): [True: 0, False: 34]
+
  Branch (654:20): [True: 0, False: 40]
+
  Branch (654:20): [True: 0, False: 142]
+
  Branch (654:20): [True: 0, False: 6]
+
  Branch (654:20): [True: 0, False: 20]
+
  Branch (654:20): [True: 0, False: 36]
 
  Branch (654:20): [True: 0, False: 133]
 
  Branch (654:20): [True: 0, False: 1]
 
  Branch (654:20): [True: 0, False: 78]
 
  Branch (654:20): [True: 0, False: 17]
-
  Branch (654:20): [True: 0, False: 495]
-
  Branch (654:20): [True: 0, False: 78]
+
  Branch (654:20): [True: 0, False: 493]
+
  Branch (654:20): [True: 0, False: 76]
 
  Branch (654:20): [True: 0, False: 146]
 
  Branch (654:20): [True: 0, False: 101]
-
655
0
                    self.needs_render.store(true, Ordering::Relaxed);
656
1.74k
                }
657
658
1.74k
                if self.pending_matcher_restart {
  Branch (658:20): [True: 0, False: 47]
-
  Branch (658:20): [True: 0, False: 108]
+
655
0
                    self.needs_render.store(true, Ordering::Relaxed);
656
1.74k
                }
657
658
1.74k
                if self.pending_matcher_restart {
  Branch (658:20): [True: 0, False: 48]
+
  Branch (658:20): [True: 0, False: 112]
 
  Branch (658:20): [True: 1, False: 144]
 
  Branch (658:20): [True: 0, False: 120]
 
  Branch (658:20): [True: 0, False: 11]
-
  Branch (658:20): [True: 0, False: 20]
-
  Branch (658:20): [True: 0, False: 43]
-
  Branch (658:20): [True: 0, False: 142]
-
  Branch (658:20): [True: 0, False: 7]
 
  Branch (658:20): [True: 0, False: 18]
-
  Branch (658:20): [True: 0, False: 34]
+
  Branch (658:20): [True: 0, False: 40]
+
  Branch (658:20): [True: 0, False: 142]
+
  Branch (658:20): [True: 0, False: 6]
+
  Branch (658:20): [True: 0, False: 20]
+
  Branch (658:20): [True: 0, False: 36]
 
  Branch (658:20): [True: 0, False: 133]
 
  Branch (658:20): [True: 1, False: 0]
 
  Branch (658:20): [True: 0, False: 78]
 
  Branch (658:20): [True: 0, False: 17]
-
  Branch (658:20): [True: 0, False: 495]
-
  Branch (658:20): [True: 0, False: 78]
+
  Branch (658:20): [True: 0, False: 493]
+
  Branch (658:20): [True: 0, False: 76]
 
  Branch (658:20): [True: 0, False: 146]
 
  Branch (658:20): [True: 0, False: 101]
-
659
2
                    self.restart_matcher(true);
660
1.74k
                }
661
1.74k
                if self.needs_render.load(Ordering::Relaxed)
  Branch (661:20): [True: 8, False: 39]
-
  Branch (661:20): [True: 36, False: 72]
+
659
2
                    self.restart_matcher(true);
660
1.74k
                }
661
1.74k
                if self.needs_render.load(Ordering::Relaxed)
  Branch (661:20): [True: 2, False: 46]
+
  Branch (661:20): [True: 40, False: 72]
 
  Branch (661:20): [True: 55, False: 90]
 
  Branch (661:20): [True: 88, False: 32]
 
  Branch (661:20): [True: 7, False: 4]
-
  Branch (661:20): [True: 11, False: 9]
-
  Branch (661:20): [True: 28, False: 15]
-
  Branch (661:20): [True: 90, False: 52]
-
  Branch (661:20): [True: 4, False: 3]
 
  Branch (661:20): [True: 9, False: 9]
-
  Branch (661:20): [True: 13, False: 21]
+
  Branch (661:20): [True: 25, False: 15]
+
  Branch (661:20): [True: 90, False: 52]
+
  Branch (661:20): [True: 4, False: 2]
+
  Branch (661:20): [True: 11, False: 9]
+
  Branch (661:20): [True: 15, False: 21]
 
  Branch (661:20): [True: 57, False: 76]
 
  Branch (661:20): [True: 1, False: 0]
 
  Branch (661:20): [True: 50, False: 28]
 
  Branch (661:20): [True: 10, False: 7]
-
  Branch (661:20): [True: 192, False: 303]
-
  Branch (661:20): [True: 60, False: 18]
+
  Branch (661:20): [True: 190, False: 303]
+
  Branch (661:20): [True: 58, False: 18]
 
  Branch (661:20): [True: 58, False: 88]
 
  Branch (661:20): [True: 32, False: 69]
-
662
809
                    && self.last_render_timer.elapsed().as_millis() > 1000 / u128::from(TICK_RATE)
  Branch (662:24): [True: 4, False: 4]
-
  Branch (662:24): [True: 36, False: 0]
+
662
802
                    && self.last_render_timer.elapsed().as_millis() > 1000 / u128::from(TICK_RATE)
  Branch (662:24): [True: 1, False: 1]
+
  Branch (662:24): [True: 38, False: 2]
 
  Branch (662:24): [True: 54, False: 1]
 
  Branch (662:24): [True: 88, False: 0]
 
  Branch (662:24): [True: 7, False: 0]
-
  Branch (662:24): [True: 10, False: 1]
-
  Branch (662:24): [True: 27, False: 1]
+
  Branch (662:24): [True: 9, False: 0]
+
  Branch (662:24): [True: 25, False: 0]
 
  Branch (662:24): [True: 90, False: 0]
 
  Branch (662:24): [True: 4, False: 0]
-
  Branch (662:24): [True: 9, False: 0]
-
  Branch (662:24): [True: 13, False: 0]
+
  Branch (662:24): [True: 10, False: 1]
+
  Branch (662:24): [True: 14, False: 1]
 
  Branch (662:24): [True: 57, False: 0]
 
  Branch (662:24): [True: 1, False: 0]
 
  Branch (662:24): [True: 50, False: 0]
 
  Branch (662:24): [True: 10, False: 0]
-
  Branch (662:24): [True: 185, False: 7]
-
  Branch (662:24): [True: 59, False: 1]
+
  Branch (662:24): [True: 184, False: 6]
+
  Branch (662:24): [True: 58, False: 0]
 
  Branch (662:24): [True: 58, False: 0]
 
  Branch (662:24): [True: 32, False: 0]
-
663
                {
664
794
                    debug!("Triggering render");
665
794
                    self.needs_render.store(false, Ordering::Relaxed);
666
794
                    self.last_render_timer = std::time::Instant::now();
667
794
                    tui.event_tx.try_send(Event::Render)
?0
;
668
950
                }
669
670
                // Fire the reader/matcher-completion events (`load`, `result`,
671
                // `zero`, `one`). These track async state that has no synchronous
672
                // callback, so they are polled here on the heartbeat rather than
673
                // in the render path. A `Render` is queued first so a binding
674
                // that inspects the list (e.g. `load:first`) sees the final one.
675
1.74k
                let completion_events = self.poll_completion_events();
676
1.74k
                if !completion_events.is_empty() {
  Branch (676:20): [True: 11, False: 36]
-
  Branch (676:20): [True: 36, False: 72]
+
663
                {
664
790
                    debug!("Triggering render");
665
790
                    self.needs_render.store(false, Ordering::Relaxed);
666
790
                    self.last_render_timer = std::time::Instant::now();
667
790
                    tui.event_tx.try_send(Event::Render)
?0
;
668
953
                }
669
670
                // Fire the reader/matcher-completion events (`load`, `result`,
671
                // `zero`, `one`). These track async state that has no synchronous
672
                // callback, so they are polled here on the heartbeat rather than
673
                // in the render path. A `Render` is queued first so a binding
674
                // that inspects the list (e.g. `load:first`) sees the final one.
675
1.74k
                let completion_events = self.poll_completion_events();
676
1.74k
                if !completion_events.is_empty() {
  Branch (676:20): [True: 10, False: 38]
+
  Branch (676:20): [True: 38, False: 74]
 
  Branch (676:20): [True: 49, False: 96]
 
  Branch (676:20): [True: 88, False: 32]
 
  Branch (676:20): [True: 7, False: 4]
-
  Branch (676:20): [True: 10, False: 10]
-
  Branch (676:20): [True: 27, False: 16]
-
  Branch (676:20): [True: 34, False: 108]
-
  Branch (676:20): [True: 1, False: 6]
 
  Branch (676:20): [True: 9, False: 9]
-
  Branch (676:20): [True: 13, False: 21]
+
  Branch (676:20): [True: 25, False: 15]
+
  Branch (676:20): [True: 34, False: 108]
+
  Branch (676:20): [True: 1, False: 5]
+
  Branch (676:20): [True: 10, False: 10]
+
  Branch (676:20): [True: 14, False: 22]
 
  Branch (676:20): [True: 57, False: 76]
 
  Branch (676:20): [True: 0, False: 1]
 
  Branch (676:20): [True: 50, False: 28]
 
  Branch (676:20): [True: 10, False: 7]
-
  Branch (676:20): [True: 174, False: 321]
-
  Branch (676:20): [True: 59, False: 19]
+
  Branch (676:20): [True: 173, False: 320]
+
  Branch (676:20): [True: 58, False: 18]
 
  Branch (676:20): [True: 58, False: 88]
 
  Branch (676:20): [True: 32, False: 69]
-
677
725
                    tui.event_tx.try_send(Event::Render)
?0
;
678
1.49k
                    for evt in 
completion_events725
{
679
1.49k
                        tui.event_tx.try_send(evt)
?0
;
680
                    }
681
1.01k
                }
682
683
                // Check if a debounced preview run needs to be executed
684
1.74k
                if self.pending_preview_run
  Branch (684:20): [True: 10, False: 37]
-
  Branch (684:20): [True: 60, False: 48]
+
677
723
                    tui.event_tx.try_send(Event::Render)
?0
;
678
1.48k
                    for evt in 
completion_events723
{
679
1.48k
                        tui.event_tx.try_send(evt)
?0
;
680
                    }
681
1.02k
                }
682
683
                // Check if a debounced preview run needs to be executed
684
1.74k
                if self.pending_preview_run
  Branch (684:20): [True: 10, False: 38]
+
  Branch (684:20): [True: 60, False: 52]
 
  Branch (684:20): [True: 67, False: 78]
 
  Branch (684:20): [True: 94, False: 26]
 
  Branch (684:20): [True: 0, False: 11]
-
  Branch (684:20): [True: 9, False: 11]
-
  Branch (684:20): [True: 5, False: 38]
-
  Branch (684:20): [True: 63, False: 79]
-
  Branch (684:20): [True: 4, False: 3]
 
  Branch (684:20): [True: 9, False: 9]
-
  Branch (684:20): [True: 19, False: 15]
+
  Branch (684:20): [True: 4, False: 36]
+
  Branch (684:20): [True: 63, False: 79]
+
  Branch (684:20): [True: 3, False: 3]
+
  Branch (684:20): [True: 9, False: 11]
+
  Branch (684:20): [True: 19, False: 17]
 
  Branch (684:20): [True: 10, False: 123]
 
  Branch (684:20): [True: 0, False: 1]
 
  Branch (684:20): [True: 56, False: 22]
 
  Branch (684:20): [True: 8, False: 9]
-
  Branch (684:20): [True: 299, False: 196]
-
  Branch (684:20): [True: 58, False: 20]
+
  Branch (684:20): [True: 299, False: 194]
+
  Branch (684:20): [True: 58, False: 18]
 
  Branch (684:20): [True: 25, False: 121]
 
  Branch (684:20): [True: 86, False: 15]
-
685
882
                    && let Err(
e0
) = self.run_preview(tui)
  Branch (685:28): [True: 0, False: 10]
+
685
880
                    && let Err(
e0
) = self.run_preview(tui)
  Branch (685:28): [True: 0, False: 10]
 
  Branch (685:28): [True: 0, False: 60]
 
  Branch (685:28): [True: 0, False: 67]
 
  Branch (685:28): [True: 0, False: 94]
 
  Branch (685:28): [True: 0, False: 0]
 
  Branch (685:28): [True: 0, False: 9]
-
  Branch (685:28): [True: 0, False: 5]
-
  Branch (685:28): [True: 0, False: 63]
 
  Branch (685:28): [True: 0, False: 4]
+
  Branch (685:28): [True: 0, False: 63]
+
  Branch (685:28): [True: 0, False: 3]
 
  Branch (685:28): [True: 0, False: 9]
 
  Branch (685:28): [True: 0, False: 19]
 
  Branch (685:28): [True: 0, False: 10]
@@ -360,13 +360,13 @@
 
  Branch (685:28): [True: 0, False: 58]
 
  Branch (685:28): [True: 0, False: 25]
 
  Branch (685:28): [True: 0, False: 86]
-
686
                {
687
0
                    warn!("Heartbeat RunPreview: error {e:?}");
688
1.74k
                }
689
            }
690
            Event::RunPreview => {
691
486
                if let Err(
e0
) = self.run_preview(tui) {
  Branch (691:24): [True: 0, False: 0]
+
686
                {
687
0
                    warn!("Heartbeat RunPreview: error {e:?}");
688
1.74k
                }
689
            }
690
            Event::RunPreview => {
691
485
                if let Err(
e0
) = self.run_preview(tui) {
  Branch (691:24): [True: 0, False: 0]
 
  Branch (691:24): [True: 0, False: 19]
 
  Branch (691:24): [True: 0, False: 45]
 
  Branch (691:24): [True: 0, False: 76]
 
  Branch (691:24): [True: 0, False: 5]
 
  Branch (691:24): [True: 0, False: 0]
-
  Branch (691:24): [True: 0, False: 19]
+
  Branch (691:24): [True: 0, False: 18]
 
  Branch (691:24): [True: 0, False: 60]
 
  Branch (691:24): [True: 0, False: 2]
 
  Branch (691:24): [True: 0, False: 0]
@@ -379,7 +379,7 @@
 
  Branch (691:24): [True: 0, False: 49]
 
  Branch (691:24): [True: 0, False: 42]
 
  Branch (691:24): [True: 0, False: 20]
-
692
0
                    warn!("RunPreview: error {e:?}");
693
486
                }
694
            }
695
1
            Event::RunExecute(cmd) => {
696
1
                tui.run_execute(cmd)
?0
;
697
1
                self.handle_event(tui, &Event::Redraw)
?0
;
698
            }
699
            Event::Clear => {
700
1
                tui.clear()
?0
;
701
            }
702
            Event::Redraw => {
703
                // Avoid `Event::Redraw` (which calls `tui.clear()`): ratatui's
704
                // `Terminal::clear` first queries the cursor position, and
705
                // crossterm writes that query (`ESC [ 6 n`) to *stdout*. skim
706
                // renders to stderr and its stdout is frequently redirected
707
                // (`sk > file`, `find | sk | …`); there the query reaches no
708
                // terminal, no reply ever comes, and the UI stalls for seconds
709
                // before erroring out. Resetting both of ratatui's diff buffers
710
                // instead makes the next draw repaint every cell — no cursor
711
                // query, and it works for both fullscreen and inline viewports.
712
2
                tui.force_full_redraw();
713
2
                self.handle_event(tui, &Event::Render)
?0
;
714
            }
715
            Event::Quit | Event::Close => {
716
0
                tui.exit()?;
717
0
                self.should_quit = true;
718
            }
719
            Event::PreviewReady => {
720
69
                self.preview.mark_ready();
721
                // Apply preview offset if configured
722
69
                if let Some(
offset_expr6
) = &self.options.preview_window.offset {
  Branch (722:24): [True: 0, False: 0]
+
692
0
                    warn!("RunPreview: error {e:?}");
693
485
                }
694
            }
695
1
            Event::RunExecute(cmd) => {
696
1
                tui.run_execute(cmd)
?0
;
697
1
                self.handle_event(tui, &Event::Redraw)
?0
;
698
            }
699
            Event::Clear => {
700
1
                tui.clear()
?0
;
701
            }
702
            Event::Redraw => {
703
                // Avoid `Event::Redraw` (which calls `tui.clear()`): ratatui's
704
                // `Terminal::clear` first queries the cursor position, and
705
                // crossterm writes that query (`ESC [ 6 n`) to *stdout*. skim
706
                // renders to stderr and its stdout is frequently redirected
707
                // (`sk > file`, `find | sk | …`); there the query reaches no
708
                // terminal, no reply ever comes, and the UI stalls for seconds
709
                // before erroring out. Resetting both of ratatui's diff buffers
710
                // instead makes the next draw repaint every cell — no cursor
711
                // query, and it works for both fullscreen and inline viewports.
712
2
                tui.force_full_redraw();
713
2
                self.handle_event(tui, &Event::Render)
?0
;
714
            }
715
            Event::Quit | Event::Close => {
716
0
                tui.exit()?;
717
0
                self.should_quit = true;
718
            }
719
            Event::PreviewReady => {
720
69
                self.preview.mark_ready();
721
                // Apply preview offset if configured
722
69
                if let Some(
offset_expr6
) = &self.options.preview_window.offset {
  Branch (722:24): [True: 0, False: 0]
 
  Branch (722:24): [True: 0, False: 0]
 
  Branch (722:24): [True: 0, False: 5]
 
  Branch (722:24): [True: 0, False: 0]
@@ -398,7 +398,7 @@
 
  Branch (722:24): [True: 0, False: 0]
 
  Branch (722:24): [True: 0, False: 0]
 
  Branch (722:24): [True: 0, False: 0]
-
723
6
                    let offset = self.calculate_preview_offset(offset_expr);
724
6
                    self.preview.set_offset(offset);
725
63
                }
726
69
                self.needs_render();
727
            }
728
1
            Event::Error(msg) => {
729
1
                tui.exit()
?0
;
730
1
                bail!(msg.to_owned());
731
            }
732
540
            Event::Action(act) => {
733
540
                let events = self.handle_action(act)
?0
;
734
879
                for evt in 
events540
{
735
879
                    tui.event_tx.try_send(evt)
?0
;
736
                }
737
540
                tui.event_tx.try_send(Event::Render)
?0
;
738
            }
739
3.13k
            Event::Key(key) => {
740
3.13k
                let events = self.handle_key(key);
741
3.13k
                for 
evt526
in events {
742
526
                    tui.event_tx.try_send(evt)
?0
;
743
                }
744
            }
745
2
            Event::Paste(text) => {
746
                // Strip newlines/carriage returns from pasted text so they don't
747
                // trigger Accept or get inserted as invisible characters.
748
10
                let 
cleaned2
:
String2
=
text.chars()2
.
filter2
(|c| *c != '\n' &&
*c != '\r'8
).
collect2
();
  Branch (748:63): [True: 0, False: 0]
+
723
6
                    let offset = self.calculate_preview_offset(offset_expr);
724
6
                    self.preview.set_offset(offset);
725
63
                }
726
69
                self.needs_render();
727
            }
728
1
            Event::Error(msg) => {
729
1
                tui.exit()
?0
;
730
1
                bail!(msg.to_owned());
731
            }
732
539
            Event::Action(act) => {
733
539
                let events = self.handle_action(act)
?0
;
734
877
                for evt in 
events539
{
735
877
                    tui.event_tx.try_send(evt)
?0
;
736
                }
737
539
                tui.event_tx.try_send(Event::Render)
?0
;
738
            }
739
3.12k
            Event::Key(key) => {
740
3.12k
                let events = self.handle_key(key);
741
3.12k
                for 
evt525
in events {
742
525
                    tui.event_tx.try_send(evt)
?0
;
743
                }
744
            }
745
2
            Event::Paste(text) => {
746
                // Strip newlines/carriage returns from pasted text so they don't
747
                // trigger Accept or get inserted as invisible characters.
748
10
                let 
cleaned2
:
String2
=
text.chars()2
.
filter2
(|c| *c != '\n' &&
*c != '\r'8
).
collect2
();
  Branch (748:63): [True: 0, False: 0]
 
  Branch (748:63): [True: 0, False: 0]
 
  Branch (748:63): [True: 0, False: 0]
 
  Branch (748:63): [True: 0, False: 0]
@@ -474,21 +474,21 @@
 
  Branch (769:24): [True: 0, False: 0]
 
  Branch (769:24): [True: 0, False: 0]
 
  Branch (769:24): [True: 0, False: 0]
-
770
0
                    warn!("error while rerunnig preview after resize: {e}");
771
2
                }
772
            }
773
13
            Event::Mouse(mouse_event) => {
774
13
                let events = self.handle_mouse(*mouse_event)
?0
;
775
16
                for evt in 
events13
{
776
16
                    tui.event_tx.try_send(evt)
?0
;
777
                }
778
            }
779
            Event::InvalidInput => {
780
1
                warn!("Received invalid input");
781
            }
782
1
            Event::ClearItems => {
783
1
                self.item_pool.clear();
784
1
                self.restart_matcher(true);
785
1
            }
786
1
            Event::AppendItems(items) => {
787
1
                self.item_pool.append(items.to_owned());
788
1
                self.restart_matcher(false);
789
1
            }
790
            Event::Reload(_) => {
791
0
                unreachable!("Reload is handled by the TUI event loop in lib.rs")
792
            }
793
        }
794
795
8.65k
        Ok(())
796
8.65k
    }
797
    /// Handles new items received from the reader
798
4
    pub fn handle_items(&mut self, items: Vec<Arc<dyn SkimItem>>) {
799
4
        self.item_pool.append(items);
800
4
        trace!("Got new items, len {}", 
self.item_pool.len()0
);
801
4
        self.on_items_updated();
802
4
    }
803
3.13k
    fn handle_key(&mut self, key: &KeyEvent) -> Vec<Event> {
804
3.13k
        let normalized_key = KeyEvent::new(key.code, key.modifiers);
805
3.13k
        debug!("key event: {key:?}, normalized: {normalized_key:?}");
806
807
3.13k
        if let Some(
act183
) = &self.options.keymap.get(&normalized_key) {
  Branch (807:16): [True: 179, False: 2.94k]
+
770
0
                    warn!("error while rerunnig preview after resize: {e}");
771
2
                }
772
            }
773
13
            Event::Mouse(mouse_event) => {
774
13
                let events = self.handle_mouse(*mouse_event)
?0
;
775
16
                for evt in 
events13
{
776
16
                    tui.event_tx.try_send(evt)
?0
;
777
                }
778
            }
779
            Event::InvalidInput => {
780
1
                warn!("Received invalid input");
781
            }
782
1
            Event::ClearItems => {
783
1
                self.item_pool.clear();
784
1
                self.restart_matcher(true);
785
1
            }
786
1
            Event::AppendItems(items) => {
787
1
                self.item_pool.append(items.to_owned());
788
1
                self.restart_matcher(false);
789
1
            }
790
            Event::Reload(_) => {
791
0
                unreachable!("Reload is handled by the TUI event loop in lib.rs")
792
            }
793
        }
794
795
8.63k
        Ok(())
796
8.63k
    }
797
    /// Handles new items received from the reader
798
4
    pub fn handle_items(&mut self, items: Vec<Arc<dyn SkimItem>>) {
799
4
        self.item_pool.append(items);
800
4
        trace!("Got new items, len {}", 
self.item_pool.len()0
);
801
4
        self.on_items_updated();
802
4
    }
803
3.13k
    fn handle_key(&mut self, key: &KeyEvent) -> Vec<Event> {
804
3.13k
        let normalized_key = KeyEvent::new(key.code, key.modifiers);
805
3.13k
        debug!("key event: {key:?}, normalized: {normalized_key:?}");
806
807
3.13k
        if let Some(
act183
) = &self.options.keymap.get(&normalized_key) {
  Branch (807:16): [True: 179, False: 2.94k]
 
  Branch (807:16): [True: 4, False: 8]
 
808
183
            debug!("{act:?}");
809
195
            return 
act.iter()183
.
map183
(|a| Event::Action(a.clone())).
collect183
();
810
2.95k
        }
811
2.95k
        match key.modifiers {
812
            KeyModifiers::CONTROL => {
813
3
                if let Char('c') = key.code {
  Branch (813:24): [True: 0, False: 0]
 
  Branch (813:24): [True: 1, False: 2]
-
814
1
                    return vec![Event::Quit];
815
2
                }
816
            }
817
            KeyModifiers::NONE => {
818
2.95k
                if let Char(
c334
) = key.code {
  Branch (818:24): [True: 332, False: 2.61k]
+
814
1
                    return vec![Event::Quit];
815
2
                }
816
            }
817
            KeyModifiers::NONE => {
818
2.94k
                if let Char(
c333
) = key.code {
  Branch (818:24): [True: 331, False: 2.61k]
 
  Branch (818:24): [True: 2, False: 0]
-
819
334
                    return vec![Event::Action(Action::AddChar(c))];
820
2.61k
                }
821
            }
822
            KeyModifiers::SHIFT => {
823
2
                if let Char(
c1
) = key.code {
  Branch (823:24): [True: 0, False: 0]
+
819
333
                    return vec![Event::Action(Action::AddChar(c))];
820
2.61k
                }
821
            }
822
            KeyModifiers::SHIFT => {
823
2
                if let Char(
c1
) = key.code {
  Branch (823:24): [True: 0, False: 0]
 
  Branch (823:24): [True: 1, False: 1]
-
824
1
                    return vec![Event::Action(Action::AddChar(c.to_uppercase().next().unwrap()))];
825
1
                }
826
            }
827
1
            _ => (),
828
        }
829
2.62k
        vec![]
830
3.13k
    }
831
832
    /// Runs an action, then directly dispatches any follow-up actions bound to it.
833
    ///
834
    /// Follow-ups use non-recursive (`noremap`) semantics: an action in the
835
    /// follow-up chain does not trigger its own follow-up binding. If the chain
836
    /// contains [`Action::Suppress`], the triggering action is skipped.
837
679
    fn handle_action(&mut self, act: &Action) -> Result<Vec<Event>> {
838
679
        let follow = self.options.action_binds.get(act.name()).cloned();
839
679
        let suppress_default = follow
840
679
            .as_ref()
841
679
            .is_some_and(|chain| 
chain.iter()5
.
any5
(|a| matches!(
a5
, Action::Suppress)));
842
843
679
        let mut events = if suppress_default {
  Branch (843:29): [True: 1, False: 536]
+
824
1
                    return vec![Event::Action(Action::AddChar(c.to_uppercase().next().unwrap()))];
825
1
                }
826
            }
827
1
            _ => (),
828
        }
829
2.61k
        vec![]
830
3.13k
    }
831
832
    /// Runs an action, then directly dispatches any follow-up actions bound to it.
833
    ///
834
    /// Follow-ups use non-recursive (`noremap`) semantics: an action in the
835
    /// follow-up chain does not trigger its own follow-up binding. If the chain
836
    /// contains [`Action::Suppress`], the triggering action is skipped.
837
678
    fn handle_action(&mut self, act: &Action) -> Result<Vec<Event>> {
838
678
        let follow = self.options.action_binds.get(act.name()).cloned();
839
678
        let suppress_default = follow
840
678
            .as_ref()
841
678
            .is_some_and(|chain| 
chain.iter()5
.
any5
(|a| matches!(
a5
, Action::Suppress)));
842
843
678
        let mut events = if suppress_default {
  Branch (843:29): [True: 1, False: 535]
 
  Branch (843:29): [True: 1, False: 141]
-
844
2
            Vec::new()
845
        } else {
846
677
            self.dispatch_action(act)
?0
847
        };
848
679
        if let Some(
chain5
) = follow {
  Branch (848:16): [True: 3, False: 534]
+
844
2
            Vec::new()
845
        } else {
846
676
            self.dispatch_action(act)
?0
847
        };
848
678
        if let Some(
chain5
) = follow {
  Branch (848:16): [True: 3, False: 533]
 
  Branch (848:16): [True: 2, False: 140]
-
849
8
            for 
action6
in
chain.iter()5
.
filter5
(|a| !
matches!6
(a, Action::Suppress)) {
850
6
                events.extend(self.dispatch_action(action)
?0
);
851
            }
852
674
        }
853
679
        Ok(events)
854
679
    }
855
856
14
    fn dispatch_conditional(&mut self, condition: bool, then: &str, otherwise: Option<&str>) -> Result<Vec<Event>> {
857
14
        let Some(
chain10
) = condition.then_some(then).or(otherwise) else {
  Branch (857:13): [True: 1, False: 1]
+
849
8
            for 
action6
in
chain.iter()5
.
filter5
(|a| !
matches!6
(a, Action::Suppress)) {
850
6
                events.extend(self.dispatch_action(action)
?0
);
851
            }
852
673
        }
853
678
        Ok(events)
854
678
    }
855
856
14
    fn dispatch_conditional(&mut self, condition: bool, then: &str, otherwise: Option<&str>) -> Result<Vec<Event>> {
857
14
        let Some(
chain10
) = condition.then_some(then).or(otherwise) else {
  Branch (857:13): [True: 1, False: 1]
 
  Branch (857:13): [True: 9, False: 3]
-
858
4
            return Ok(Vec::new());
859
        };
860
        // `if-*` branch chains are stored unparsed (see `parse_action`), so an
861
        // invalid action name only surfaces here, at dispatch time. Log and
862
        // skip the chain instead of erroring out of the event loop, matching
863
        // the invalid-chain handling of `parse_action_binds`.
864
10
        let 
actions9
= match crate::binds::parse_action_chain(chain) {
865
9
            Ok(actions) => actions,
866
1
            Err(err) => {
867
1
                warn!("Ignoring conditional action chain `{chain}`: {err}");
868
1
                return Ok(Vec::new());
869
            }
870
        };
871
9
        let mut events = Vec::new();
872
9
        for action in actions {
873
9
            events.extend(self.dispatch_action(&action)
?0
);
874
        }
875
9
        Ok(events)
876
14
    }
877
878
    #[allow(clippy::too_many_lines)]
879
692
    fn dispatch_action(&mut self, act: &Action) -> Result<Vec<Event>> {
880
        #[allow(clippy::enum_glob_use)]
881
        use Action::*;
882
        use ratatui::widgets::ListDirection::{BottomToTop, TopToBottom};
883
2
        match act {
884
25
            Abort | Accept(_) => {
885
25
                self.should_quit = true;
886
25
                self.final_action = Some(act.clone());
887
25
            }
888
336
            AddChar(c) => {
889
336
                self.input.insert(*c);
890
336
                return Ok(self.on_query_changed());
891
            }
892
            AppendAndSelect => {
893
2
                let value = self.input.value.clone();
894
2
                let item: Arc<dyn SkimItem> = Arc::new(value);
895
2
                let rank = Rank {
896
2
                    index: i32::try_from(self.item_pool.len()).unwrap_or(i32::MAX),
897
2
                    ..Default::default()
898
2
                };
899
2
                self.item_pool.append(vec![item.clone()]);
900
2
                self.item_list.append(&mut vec![MatchedItem::new(
901
2
                    item,
902
2
                    rank,
903
2
                    None,
904
2
                    &self.matcher.rank_builder,
905
2
                )]);
906
2
                self.item_list.select_row(self.item_list.items.len() - 1);
907
2
                self.restart_matcher_debounced();
908
2
                return Ok(self.on_selection_changed());
909
            }
910
6
            BackwardChar => {
911
6
                self.input.move_cursor(-1);
912
6
            }
913
            BackwardDeleteChar => {
914
7
                if self.input.delete(-1).is_some() {
  Branch (914:20): [True: 6, False: 0]
+
858
4
            return Ok(Vec::new());
859
        };
860
        // `if-*` branch chains are stored unparsed (see `parse_action`), so an
861
        // invalid action name only surfaces here, at dispatch time. Log and
862
        // skip the chain instead of erroring out of the event loop, matching
863
        // the invalid-chain handling of `parse_action_binds`.
864
10
        let 
actions9
= match crate::binds::parse_action_chain(chain) {
865
9
            Ok(actions) => actions,
866
1
            Err(err) => {
867
1
                warn!("Ignoring conditional action chain `{chain}`: {err}");
868
1
                return Ok(Vec::new());
869
            }
870
        };
871
9
        let mut events = Vec::new();
872
9
        for action in actions {
873
9
            events.extend(self.dispatch_action(&action)
?0
);
874
        }
875
9
        Ok(events)
876
14
    }
877
878
    #[allow(clippy::too_many_lines)]
879
691
    fn dispatch_action(&mut self, act: &Action) -> Result<Vec<Event>> {
880
        #[allow(clippy::enum_glob_use)]
881
        use Action::*;
882
        use ratatui::widgets::ListDirection::{BottomToTop, TopToBottom};
883
2
        match act {
884
25
            Abort | Accept(_) => {
885
25
                self.should_quit = true;
886
25
                self.final_action = Some(act.clone());
887
25
            }
888
335
            AddChar(c) => {
889
335
                self.input.insert(*c);
890
335
                return Ok(self.on_query_changed());
891
            }
892
            AppendAndSelect => {
893
2
                let value = self.input.value.clone();
894
2
                let item: Arc<dyn SkimItem> = Arc::new(value);
895
2
                let rank = Rank {
896
2
                    index: i32::try_from(self.item_pool.len()).unwrap_or(i32::MAX),
897
2
                    ..Default::default()
898
2
                };
899
2
                self.item_pool.append(vec![item.clone()]);
900
2
                self.item_list.append(&mut vec![MatchedItem::new(
901
2
                    item,
902
2
                    rank,
903
2
                    None,
904
2
                    &self.matcher.rank_builder,
905
2
                )]);
906
2
                self.item_list.select_row(self.item_list.items.len() - 1);
907
2
                self.restart_matcher_debounced();
908
2
                return Ok(self.on_selection_changed());
909
            }
910
6
            BackwardChar => {
911
6
                self.input.move_cursor(-1);
912
6
            }
913
            BackwardDeleteChar => {
914
7
                if self.input.delete(-1).is_some() {
  Branch (914:20): [True: 6, False: 0]
 
  Branch (914:20): [True: 1, False: 0]
 
915
7
                    return Ok(self.on_query_changed());
916
0
                }
917
            }
918
            BackwardDeleteCharEof => {
919
2
                if self.input.is_empty() {
  Branch (919:20): [True: 0, False: 0]
 
  Branch (919:20): [True: 1, False: 1]
@@ -532,43 +532,43 @@
 
  Branch (1319:20): [True: 1, False: 1]
 
1320
4
                    return Ok(self.on_query_changed());
1321
1
                }
1322
            }
1323
            UnixWordRubout => {
1324
21
                if !self.input.delete_backward_to_whitespace().is_empty() {
  Branch (1324:20): [True: 19, False: 0]
 
  Branch (1324:20): [True: 1, False: 1]
-
1325
20
                    return Ok(self.on_query_changed());
1326
1
                }
1327
            }
1328
64
            Up(n) => {
1329
64
                match self.item_list.direction {
1330
1
                    TopToBottom => self.item_list.scroll_by(-i32::from(*n)),
1331
63
                    BottomToTop => self.item_list.scroll_by(i32::from(*n)),
1332
                }
1333
64
                return Ok(self.on_selection_changed());
1334
            }
1335
            Yank => {
1336
                // Insert from yank register at cursor position
1337
3
                self.input.insert_str(&self.yank_register);
1338
3
                return Ok(self.on_query_changed());
1339
            }
1340
2
            Custom(cb) => {
1341
2
                return cb.call(self).map_err(|e| 
eyre::eyre!0
("{}", e));
1342
            }
1343
        }
1344
119
        Ok(Vec::default())
1345
692
    }
1346
1347
    /// Returns the selected items as results
1348
42
    pub fn results(&mut self) -> Vec<MatchedItem> {
1349
42
        if self.options.filter.is_some() {
  Branch (1349:12): [True: 15, False: 18]
+
1325
20
                    return Ok(self.on_query_changed());
1326
1
                }
1327
            }
1328
64
            Up(n) => {
1329
64
                match self.item_list.direction {
1330
1
                    TopToBottom => self.item_list.scroll_by(-i32::from(*n)),
1331
63
                    BottomToTop => self.item_list.scroll_by(i32::from(*n)),
1332
                }
1333
64
                return Ok(self.on_selection_changed());
1334
            }
1335
            Yank => {
1336
                // Insert from yank register at cursor position
1337
3
                self.input.insert_str(&self.yank_register);
1338
3
                return Ok(self.on_query_changed());
1339
            }
1340
2
            Custom(cb) => {
1341
2
                return cb.call(self).map_err(|e| 
eyre::eyre!0
("{}", e));
1342
            }
1343
        }
1344
119
        Ok(Vec::default())
1345
691
    }
1346
1347
    /// Returns the selected items as results
1348
42
    pub fn results(&mut self) -> Vec<MatchedItem> {
1349
42
        if self.options.filter.is_some() {
  Branch (1349:12): [True: 15, False: 18]
 
  Branch (1349:12): [True: 1, False: 8]
 
1350
            // In filter mode, drain items to avoid cloning
1351
16
            std::mem::take(&mut self.item_list.items)
1352
26
        } else if self.options.multi && 
!self.item_list.selection.is_empty()2
{
  Branch (1352:19): [True: 0, False: 18]
   Branch (1352:41): [True: 0, False: 0]
 
  Branch (1352:19): [True: 2, False: 6]
   Branch (1352:41): [True: 2, False: 0]
-
1353
2
            self.item_list.selection.clone().into_iter().collect()
1354
24
        } else if let Some(
sel19
) = self.item_list.selected() {
  Branch (1354:23): [True: 18, False: 0]
-
  Branch (1354:23): [True: 1, False: 5]
-
1355
19
            vec![sel]
1356
        } else {
1357
5
            vec![]
1358
        }
1359
42
    }
1360
1361
    /// Whether the current query is shorter than `--min-query-length`, meaning no
1362
    /// results should be produced yet.
1363
    ///
1364
    /// Always false when `--min-query-length` is unset, or under `--disabled`, where
1365
    /// the input is not used as a query.
1366
    #[must_use]
1367
1.50k
    pub fn query_below_min_length(&self) -> bool {
1368
1.50k
        self.options
1369
1.50k
            .min_query_length
1370
1.50k
            .is_some_and(|min| 
!self.options.disabled21
&&
self.input.value.chars()21
.count() < min)
  Branch (1370:32): [True: 13, False: 0]
+
1353
2
            self.item_list.selection.clone().into_iter().collect()
1354
24
        } else if let Some(
sel20
) = self.item_list.selected() {
  Branch (1354:23): [True: 18, False: 0]
+
  Branch (1354:23): [True: 2, False: 4]
+
1355
20
            vec![sel]
1356
        } else {
1357
4
            vec![]
1358
        }
1359
42
    }
1360
1361
    /// Whether the current query is shorter than `--min-query-length`, meaning no
1362
    /// results should be produced yet.
1363
    ///
1364
    /// Always false when `--min-query-length` is unset, or under `--disabled`, where
1365
    /// the input is not used as a query.
1366
    #[must_use]
1367
1.35k
    pub fn query_below_min_length(&self) -> bool {
1368
1.35k
        self.options
1369
1.35k
            .min_query_length
1370
1.35k
            .is_some_and(|min| 
!self.options.disabled21
&&
self.input.value.chars()21
.count() < min)
  Branch (1370:32): [True: 13, False: 0]
 
  Branch (1370:32): [True: 8, False: 0]
-
1371
1.50k
    }
1372
1373
    /// Restart the matcher to process items in the item pool.
1374
    ///
1375
    /// If `force` is true, the matcher will be restarted even if it's currently running.
1376
    /// If `force` is false, the matcher will only be restarted if there are new items
1377
    /// to process or if the previous matcher has completed.
1378
1.34k
    pub fn restart_matcher(&mut self, force: bool) {
1379
        use crate::tui::item_list::MergeStrategy;
1380
        // Check if query meets minimum length requirement
1381
1.34k
        if self.query_below_min_length() {
  Branch (1381:12): [True: 10, False: 1.29k]
-
  Branch (1381:12): [True: 2, False: 39]
-
1382
            // Query is too short, clear items and don't run matcher
1383
12
            self.matcher_control.kill();
1384
12
            self.item_list.items.clear();
1385
12
            self.item_list.current = 0;
1386
12
            self.item_list.offset = 0;
1387
12
            return;
1388
1.33k
        }
1389
1390
1.33k
        let matcher_stopped = self.matcher_control.stopped();
1391
1.33k
        if force || (
matcher_stopped566
&&
self.item_pool.num_not_taken() > 0396
) {
  Branch (1391:12): [True: 747, False: 550]
-  Branch (1391:22): [True: 388, False: 162]
-  Branch (1391:41): [True: 67, False: 321]
-
  Branch (1391:12): [True: 23, False: 16]
-  Branch (1391:22): [True: 8, False: 8]
-  Branch (1391:41): [True: 1, False: 7]
-
1392
838
            trace!("restarting matcher, force={force}");
1393
            // Reset debounce timer on any restart to prevent interference
1394
838
            self.last_matcher_restart = std::time::Instant::now();
1395
838
            self.pending_matcher_restart = false;
1396
838
            self.matcher_control.kill();
1397
            // record matcher start time for statusline spinner/progress
1398
838
            self.matcher_timer = std::time::Instant::now();
1399
            // In interactive mode, use empty query so all items are shown
1400
            // The input contains the command to execute, not a filter query
1401
838
            let query = if self.options.disabled {
  Branch (1401:28): [True: 2, False: 812]
+
1371
1.35k
    }
1372
1373
    /// Restart the matcher to process items in the item pool.
1374
    ///
1375
    /// If `force` is true, the matcher will be restarted even if it's currently running.
1376
    /// If `force` is false, the matcher will only be restarted if there are new items
1377
    /// to process or if the previous matcher has completed.
1378
1.27k
    pub fn restart_matcher(&mut self, force: bool) {
1379
        use crate::tui::item_list::MergeStrategy;
1380
        // Check if query meets minimum length requirement
1381
1.27k
        if self.query_below_min_length() {
  Branch (1381:12): [True: 10, False: 1.22k]
+
  Branch (1381:12): [True: 2, False: 38]
+
1382
            // Query is too short, clear items and don't run matcher
1383
12
            self.matcher_control.kill();
1384
12
            self.item_list.items.clear();
1385
12
            self.item_list.current = 0;
1386
12
            self.item_list.offset = 0;
1387
12
            return;
1388
1.25k
        }
1389
1390
1.25k
        let matcher_stopped = self.matcher_control.stopped();
1391
1.25k
        if force || (
matcher_stopped490
&&
self.item_pool.num_not_taken() > 0398
) {
  Branch (1391:12): [True: 746, False: 475]
+  Branch (1391:22): [True: 388, False: 87]
+  Branch (1391:41): [True: 52, False: 336]
+
  Branch (1391:12): [True: 23, False: 15]
+  Branch (1391:22): [True: 10, False: 5]
+  Branch (1391:41): [True: 1, False: 9]
+
1392
822
            trace!("restarting matcher, force={force}");
1393
            // Reset debounce timer on any restart to prevent interference
1394
822
            self.last_matcher_restart = std::time::Instant::now();
1395
822
            self.pending_matcher_restart = false;
1396
822
            self.matcher_control.kill();
1397
            // record matcher start time for statusline spinner/progress
1398
822
            self.matcher_timer = std::time::Instant::now();
1399
            // In interactive mode, use empty query so all items are shown
1400
            // The input contains the command to execute, not a filter query
1401
822
            let query = if self.options.disabled {
  Branch (1401:28): [True: 1, False: 797]
 
  Branch (1401:28): [True: 0, False: 24]
-
1402
2
                ""
1403
836
            } else if self.options.interactive {
  Branch (1403:23): [True: 83, False: 729]
+
1402
1
                ""
1403
821
            } else if self.options.interactive {
  Branch (1403:23): [True: 83, False: 714]
 
  Branch (1403:23): [True: 2, False: 22]
-
1404
85
                &input::Input::default()
1405
            } else {
1406
751
                &self.input
1407
            };
1408
838
            let item_pool = self.item_pool.clone();
1409
838
            let thread_pool = &self.matcher_pool;
1410
838
            let no_sort = self.options.no_sort;
1411
1412
838
            if force {
  Branch (1412:16): [True: 747, False: 67]
+
1404
85
                &input::Input::default()
1405
            } else {
1406
736
                &self.input
1407
            };
1408
822
            let item_pool = self.item_pool.clone();
1409
822
            let thread_pool = &self.matcher_pool;
1410
822
            let no_sort = self.options.no_sort;
1411
1412
822
            if force {
  Branch (1412:16): [True: 746, False: 52]
 
  Branch (1412:16): [True: 23, False: 1]
-
1413
770
                self.item_pool.reset();
1414
770
            
}68
1415
1416
838
            let merge_strategy = if force {
  Branch (1416:37): [True: 747, False: 67]
+
1413
769
                self.item_pool.reset();
1414
769
            
}53
1415
1416
822
            let merge_strategy = if force {
  Branch (1416:37): [True: 746, False: 52]
 
  Branch (1416:37): [True: 23, False: 1]
-
1417
770
                MergeStrategy::Replace
1418
68
            } else if no_sort && 
self.options.tac2
{
  Branch (1418:23): [True: 2, False: 65]
+
1417
769
                MergeStrategy::Replace
1418
53
            } else if no_sort && 
self.options.tac2
{
  Branch (1418:23): [True: 2, False: 50]
   Branch (1418:34): [True: 0, False: 2]
 
  Branch (1418:23): [True: 0, False: 1]
   Branch (1418:34): [True: 0, False: 0]
-
1419
0
                MergeStrategy::Prepend
1420
68
            } else if no_sort {
  Branch (1420:23): [True: 2, False: 65]
+
1419
0
                MergeStrategy::Prepend
1420
53
            } else if no_sort {
  Branch (1420:23): [True: 2, False: 50]
 
  Branch (1420:23): [True: 0, False: 1]
-
1421
2
                MergeStrategy::Append
1422
            } else {
1423
66
                MergeStrategy::SortedMerge
1424
            };
1425
1426
838
            self.matcher_control = self.matcher.run(
1427
838
                query,
1428
838
                &item_pool,
1429
838
                thread_pool,
1430
838
                self.item_list.processed_items.clone(),
1431
838
                merge_strategy,
1432
838
                no_sort,
1433
838
                self.options.tac,
1434
838
                self.needs_render.clone(),
1435
            );
1436
            // A new search is in flight; arm the `result`/`zero`/`one` events to
1437
            // fire once it completes and its results are rendered.
1438
838
            self.result_pending = true;
1439
498
        }
1440
1.34k
    }
1441
1442
8
    fn yank(&mut self, contents: String) {
1443
8
        self.yank_register = contents;
1444
8
    }
1445
1446
    /// Expand placeholders in a command string with current app state.
1447
    /// Replaces {}, {q}, {cq}, {n}, {+}, {+n}, and field patterns.
1448
    ///
1449
    /// Note: in command mode, the replstr is replaced by the current query
1450
    #[must_use]
1451
165
    pub fn expand_cmd(&self, cmd: &str, quote_args: bool) -> String {
1452
165
        util::printf(
1453
165
            cmd,
1454
165
            &self.options.delimiter,
1455
165
            &self.options.replstr,
1456
165
            &self.item_list.selection.iter(),
1457
165
            &self.item_list.selected(),
1458
165
            &self.input.value,
1459
165
            &self.input.value,
1460
165
            quote_args,
1461
        )
1462
165
    }
1463
1464
    /// Restart matcher with debouncing to avoid excessive restarts during rapid typing
1465
354
    fn restart_matcher_debounced(&mut self) {
1466
        const DEBOUNCE_MS: u64 = 10;
1467
1468
354
        if self.options.disabled {
  Branch (1468:12): [True: 1, False: 332]
+
1421
2
                MergeStrategy::Append
1422
            } else {
1423
51
                MergeStrategy::SortedMerge
1424
            };
1425
1426
822
            self.matcher_control = self.matcher.run(
1427
822
                query,
1428
822
                &item_pool,
1429
822
                thread_pool,
1430
822
                self.item_list.processed_items.clone(),
1431
822
                merge_strategy,
1432
822
                no_sort,
1433
822
                self.options.tac,
1434
822
                self.needs_render.clone(),
1435
            );
1436
            // A new search is in flight; arm the `result`/`zero`/`one` events to
1437
            // fire once it completes and its results are rendered.
1438
822
            self.result_pending = true;
1439
437
        }
1440
1.27k
    }
1441
1442
8
    fn yank(&mut self, contents: String) {
1443
8
        self.yank_register = contents;
1444
8
    }
1445
1446
    /// Expand placeholders in a command string with current app state.
1447
    /// Replaces {}, {q}, {cq}, {n}, {+}, {+n}, and field patterns.
1448
    ///
1449
    /// Note: in command mode, the replstr is replaced by the current query
1450
    #[must_use]
1451
164
    pub fn expand_cmd(&self, cmd: &str, quote_args: bool) -> String {
1452
164
        util::printf(
1453
164
            cmd,
1454
164
            &self.options.delimiter,
1455
164
            &self.options.replstr,
1456
164
            &self.item_list.selection.iter(),
1457
164
            &self.item_list.selected(),
1458
164
            &self.input.value,
1459
164
            &self.input.value,
1460
164
            quote_args,
1461
        )
1462
164
    }
1463
1464
    /// Restart matcher with debouncing to avoid excessive restarts during rapid typing
1465
353
    fn restart_matcher_debounced(&mut self) {
1466
        const DEBOUNCE_MS: u64 = 10;
1467
1468
353
        if self.options.disabled {
  Branch (1468:12): [True: 1, False: 331]
 
  Branch (1468:12): [True: 0, False: 21]
-
1469
1
            return;
1470
353
        }
1471
1472
        // If enough time has passed since last restart, restart immediately
1473
353
        if self.last_matcher_restart.elapsed().as_millis() > u128::from(DEBOUNCE_MS) {
  Branch (1473:12): [True: 331, False: 1]
+
1469
1
            return;
1470
352
        }
1471
1472
        // If enough time has passed since last restart, restart immediately
1473
352
        if self.last_matcher_restart.elapsed().as_millis() > u128::from(DEBOUNCE_MS) {
  Branch (1473:12): [True: 330, False: 1]
 
  Branch (1473:12): [True: 0, False: 21]
-
1474
331
            debug!("restart_matcher_debounced: true");
1475
331
            self.restart_matcher(true);
1476
        } else {
1477
22
            debug!("restart_matcher_debounced: false");
1478
22
            self.pending_matcher_restart = true;
1479
        }
1480
354
    }
1481
1482
    /// Returns the border-adjusted inner rect of the list area.
1483
36
    fn list_inner_area(&self) -> ratatui::layout::Rect {
1484
36
        let list_area = self.layout.list_area;
1485
36
        if self.options.border.is_some() {
  Branch (1485:12): [True: 0, False: 2]
+
1474
330
            debug!("restart_matcher_debounced: true");
1475
330
            self.restart_matcher(true);
1476
        } else {
1477
22
            debug!("restart_matcher_debounced: false");
1478
22
            self.pending_matcher_restart = true;
1479
        }
1480
353
    }
1481
1482
    /// Returns the border-adjusted inner rect of the list area.
1483
36
    fn list_inner_area(&self) -> ratatui::layout::Rect {
1484
36
        let list_area = self.layout.list_area;
1485
36
        if self.options.border.is_some() {
  Branch (1485:12): [True: 0, False: 2]
 
  Branch (1485:12): [True: 12, False: 22]
 
1486
12
            ratatui::layout::Rect {
1487
12
                x: list_area.x + 1,
1488
12
                y: list_area.y + 1,
1489
12
                width: list_area.width.saturating_sub(2),
1490
12
                height: list_area.height.saturating_sub(2),
1491
12
            }
1492
        } else {
1493
24
            list_area
1494
        }
1495
36
    }
1496
1497
    /// Returns the inner rect of the list area and the x column of the scrollbar, but only
1498
    /// when the scrollbar is actually rendered (config enabled and items overflow the area).
1499
14
    fn scrollbar_column(&self) -> Option<(ratatui::layout::Rect, u16)> {
1500
14
        let inner = self.list_inner_area();
1501
14
        let available_rows = inner.height as usize;
1502
14
        if inner.width == 0 || self.item_list.scrollbar_thumb.is_empty() || 
self.item_list.items7
.len() <= available_rows
  Branch (1502:12): [True: 0, False: 1]
   Branch (1502:32): [True: 0, False: 1]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/backend.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/backend.rs.html
index 3b2b40a9..7a31bc28 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/tui/backend.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/backend.rs.html
@@ -1,20 +1,20 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/backend.rs
Line
Count
Source
1
use std::io::{BufWriter, stderr};
2
use std::ops::{Deref, DerefMut};
3
use std::process::Stdio;
4
use std::sync::Once;
5
6
use crossterm::event::{
7
    DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, KeyEventKind,
8
    KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
9
};
10
use crossterm::terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen};
11
use crossterm::{self, cursor};
12
use eyre::Result;
13
use futures::{FutureExt as _, StreamExt as _};
14
use ratatui::layout::Rect;
15
use ratatui::prelude::{Backend, CrosstermBackend};
16
use ratatui::{TerminalOptions, Viewport};
17
use tokio::sync::mpsc::{Receiver, Sender, channel};
18
use tokio::task::JoinHandle;
19
use tokio_util::sync::CancellationToken;
20
21
use super::util::cursor_pos_from_tty;
22
use super::{Event, Size, TICK_RATE};
23
24
static PANIC_HOOK_SET: Once = Once::new();
25
26
/// Terminal user interface handler for skim
27
pub struct Tui<B: Backend = ratatui::backend::CrosstermBackend<BufWriter<std::io::Stderr>>>
28
where
29
    B::Error: Send + Sync + 'static,
30
{
31
    /// The ratatui terminal instance
32
    pub terminal: ratatui::Terminal<B>,
33
    /// Background task handle for event polling
34
    pub task: Option<JoinHandle<()>>,
35
    /// Receiver for TUI events
36
    pub event_rx: Receiver<Event>,
37
    /// Sender for TUI events
38
    pub event_tx: Sender<Event>,
39
    /// Tick rate for updates (ticks per second)
40
    pub tick_rate: f64,
41
    /// Token for cancelling background tasks
42
    pub cancellation_token: CancellationToken,
43
    /// Whether running in fullscreen mode
44
    pub is_fullscreen: bool,
45
    /// The terminal's rect (drawing) area, set if the layout is inline
46
    rect: Option<Rect>,
47
    enable_mouse: bool,
48
}
49
50
impl Tui {
51
    /// Creates a TUI with the default backend (buffered stderr) and the specified height
52
    ///
53
    /// # Errors
54
    ///
55
    /// Returns an error if the TUI backend cannot be initialized.
56
10
    pub fn new_with_height(height: Size) -> Result<Self> {
57
10
        let backend = CrosstermBackend::new(std::io::BufWriter::new(stderr()));
58
10
        Self::new_with_height_and_backend(backend, height)
59
10
    }
60
    /// Disable mouse handling.
61
    /// Needs to be called before enter.
62
0
    pub fn disable_mouse(&mut self) -> &mut Self {
63
0
        self.enable_mouse = false;
64
0
        self
65
0
    }
66
}
67
68
impl<B: Backend> Tui<B>
69
where
70
    B::Error: Send + Sync + 'static,
71
{
72
    /// Creates a new TUI with the specified backend and height
73
    ///
74
    /// # Errors
75
    ///
76
    /// Returns an error if the terminal size cannot be determined or setup fails.
77
    ///
78
    /// # Panics
79
    ///
80
    /// Panics if the terminal size cannot be read from the backend.
81
407
    pub fn new_with_height_and_backend(backend: B, height: Size) -> Result<Self> {
82
407
        let event_channel = channel(1024 * 1024);
83
84
407
        let term_height = backend.size().expect("Failed to get terminal height").height;
85
407
        let lines = match height {
86
400
            Size::Percent(100) => None,
87
0
            Size::Fixed(lines) => Some(lines),
88
7
            Size::Percent(p) => Some(term_height * p / 100),
89
0
            Size::Neg(lines) => Some(term_height.saturating_sub(lines)),
90
        };
91
92
        let rect: Option<Rect>;
93
407
        let viewport = if let Some(
mut height7
) = lines {
  Branch (93:31): [True: 7, False: 3]
-
  Branch (93:31): [True: 0, False: 32]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/backend.rs
Line
Count
Source
1
use std::io::{BufWriter, stderr};
2
use std::ops::{Deref, DerefMut};
3
use std::process::Stdio;
4
use std::sync::Once;
5
6
use crossterm::event::{
7
    DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, KeyEventKind,
8
    KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
9
};
10
use crossterm::terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen};
11
use crossterm::{self, cursor};
12
use eyre::Result;
13
use futures::{FutureExt as _, StreamExt as _};
14
use ratatui::layout::Rect;
15
use ratatui::prelude::{Backend, CrosstermBackend};
16
use ratatui::{TerminalOptions, Viewport};
17
use tokio::sync::mpsc::{Receiver, Sender, channel};
18
use tokio::task::JoinHandle;
19
use tokio_util::sync::CancellationToken;
20
21
use super::util::cursor_pos_from_tty;
22
use super::{Event, Size, TICK_RATE};
23
24
static PANIC_HOOK_SET: Once = Once::new();
25
26
/// Terminal user interface handler for skim
27
pub struct Tui<B: Backend = ratatui::backend::CrosstermBackend<BufWriter<std::io::Stderr>>>
28
where
29
    B::Error: Send + Sync + 'static,
30
{
31
    /// The ratatui terminal instance
32
    pub terminal: ratatui::Terminal<B>,
33
    /// Background task handle for event polling
34
    pub task: Option<JoinHandle<()>>,
35
    /// Receiver for TUI events
36
    pub event_rx: Receiver<Event>,
37
    /// Sender for TUI events
38
    pub event_tx: Sender<Event>,
39
    /// Tick rate for updates (ticks per second)
40
    pub tick_rate: f64,
41
    /// Token for cancelling background tasks
42
    pub cancellation_token: CancellationToken,
43
    /// Whether running in fullscreen mode
44
    pub is_fullscreen: bool,
45
    /// The terminal's rect (drawing) area, set if the layout is inline
46
    rect: Option<Rect>,
47
    enable_mouse: bool,
48
}
49
50
impl Tui {
51
    /// Creates a TUI with the default backend (buffered stderr) and the specified height
52
    ///
53
    /// # Errors
54
    ///
55
    /// Returns an error if the TUI backend cannot be initialized.
56
10
    pub fn new_with_height(height: Size) -> Result<Self> {
57
10
        let backend = CrosstermBackend::new(std::io::BufWriter::new(stderr()));
58
10
        Self::new_with_height_and_backend(backend, height)
59
10
    }
60
    /// Disable mouse handling.
61
    /// Needs to be called before enter.
62
0
    pub fn disable_mouse(&mut self) -> &mut Self {
63
0
        self.enable_mouse = false;
64
0
        self
65
0
    }
66
}
67
68
impl<B: Backend> Tui<B>
69
where
70
    B::Error: Send + Sync + 'static,
71
{
72
    /// Creates a new TUI with the specified backend and height
73
    ///
74
    /// # Errors
75
    ///
76
    /// Returns an error if the terminal size cannot be determined or setup fails.
77
    ///
78
    /// # Panics
79
    ///
80
    /// Panics if the terminal size cannot be read from the backend.
81
407
    pub fn new_with_height_and_backend(backend: B, height: Size) -> Result<Self> {
82
407
        let event_channel = channel(1024 * 1024);
83
84
407
        let term_height = backend.size().expect("Failed to get terminal height").height;
85
407
        let lines = match height {
86
400
            Size::Percent(100) => None,
87
0
            Size::Fixed(lines) => Some(lines),
88
7
            Size::Percent(p) => Some(term_height * p / 100),
89
0
            Size::Neg(lines) => Some(term_height.saturating_sub(lines)),
90
        };
91
92
        let rect: Option<Rect>;
93
407
        let viewport = if let Some(
mut height7
) = lines {
  Branch (93:31): [True: 7, False: 3]
+
  Branch (93:31): [True: 0, False: 34]
 
  Branch (93:31): [True: 0, False: 26]
 
  Branch (93:31): [True: 0, False: 12]
 
  Branch (93:31): [True: 0, False: 2]
-
  Branch (93:31): [True: 0, False: 10]
-
  Branch (93:31): [True: 0, False: 8]
+
  Branch (93:31): [True: 0, False: 9]
+
  Branch (93:31): [True: 0, False: 7]
 
  Branch (93:31): [True: 0, False: 34]
 
  Branch (93:31): [True: 0, False: 1]
-
  Branch (93:31): [True: 0, False: 9]
-
  Branch (93:31): [True: 0, False: 4]
+
  Branch (93:31): [True: 0, False: 10]
+
  Branch (93:31): [True: 0, False: 5]
 
  Branch (93:31): [True: 0, False: 22]
 
  Branch (93:31): [True: 0, False: 55]
 
  Branch (93:31): [True: 0, False: 11]
 
  Branch (93:31): [True: 0, False: 5]
-
  Branch (93:31): [True: 0, False: 122]
-
  Branch (93:31): [True: 0, False: 10]
+
  Branch (93:31): [True: 0, False: 121]
+
  Branch (93:31): [True: 0, False: 9]
 
  Branch (93:31): [True: 0, False: 22]
 
  Branch (93:31): [True: 0, False: 12]
 
94
            // Until https://github.com/crossterm-rs/crossterm/issues/919 is fixed, we need to do it ourselves
95
7
            let cursor_pos = cursor_pos_from_tty()
?0
;
96
7
            let mut y = cursor_pos.1 - 1;
97
7
            height = height.min(term_height);
98
7
            if term_height - cursor_pos.1 < height {
  Branch (98:16): [True: 2, False: 5]
@@ -37,22 +37,22 @@
 
  Branch (98:16): [True: 0, False: 0]
 
  Branch (98:16): [True: 0, False: 0]
 
99
2
                let to_scroll = height - (term_height - cursor_pos.1) - 1;
100
2
                crossterm::execute!(stderr(), crossterm::terminal::ScrollUp(to_scroll))
?0
;
101
2
                y = y.saturating_sub(to_scroll);
102
5
            }
103
7
            rect = Some(Rect::new(
104
7
                0,
105
7
                y,
106
7
                backend.size().expect("Failed to get terminal width").width - 1,
107
7
                height,
108
7
            ));
109
7
            Viewport::Fixed(rect.unwrap())
110
        } else {
111
400
            rect = None;
112
400
            Viewport::Fullscreen
113
        };
114
115
407
        set_panic_hook();
116
        Ok(Self {
117
407
            terminal: ratatui::Terminal::with_options(backend, TerminalOptions { viewport })
?0
,
118
407
            task: None,
119
407
            rect,
120
407
            event_rx: event_channel.1,
121
407
            event_tx: event_channel.0,
122
407
            tick_rate: f64::from(TICK_RATE),
123
407
            cancellation_token: CancellationToken::default(),
124
407
            is_fullscreen: lines.is_none(),
125
            enable_mouse: true,
126
        })
127
407
    }
128
129
    /// Enters the TUI by enabling raw mode and starting event handling
130
    ///
131
    /// # Errors
132
    ///
133
    /// Returns an error if enabling raw mode or mouse capture fails.
134
0
    pub fn enter(&mut self) -> Result<()> {
135
0
        self.enter_terminal()?;
136
0
        self.start();
137
0
        Ok(())
138
0
    }
139
140
    /// Enables terminal modes and enters the alternate screen without starting event polling.
141
    ///
142
    /// This lets callers run terminal queries after alternate-screen entry but
143
    /// before the event stream starts reading terminal input.
144
    ///
145
    /// # Errors
146
    ///
147
    /// Returns an error if enabling raw mode or terminal features fails.
148
10
    pub fn enter_terminal(&mut self) -> Result<()> {
149
10
        crossterm::terminal::enable_raw_mode()
?0
;
150
        // On Windows, install a console ctrl handler so that CTRL_C_EVENT
151
        // performs terminal cleanup instead of killing the process abruptly.
152
        #[cfg(windows)]
153
        super::windows::install_ctrl_c_handler()?;
154
155
10
        self.execute_enter()
?0
;
156
10
        Ok(())
157
10
    }
158
159
    /// Exits the TUI by stopping event handling and disabling raw mode
160
    ///
161
    /// # Errors
162
    ///
163
    /// Returns an error if disabling raw mode or mouse capture fails.
164
408
    pub fn exit(&mut self) -> Result<()> {
165
408
        self.stop();
166
408
        cleanup_terminal()
?0
;
167
        // Remove our console ctrl handler now that raw mode is off.
168
        #[cfg(windows)]
169
        super::windows::uninstall_ctrl_c_handler();
170
        // When using the inline layout, we want to remove all previous output
171
        //  -> reset cursor at the top of the drawing area
172
408
        if !self.is_fullscreen {
  Branch (172:12): [True: 7, False: 3]
-
  Branch (172:12): [True: 0, False: 32]
+
  Branch (172:12): [True: 0, False: 34]
 
  Branch (172:12): [True: 0, False: 26]
 
  Branch (172:12): [True: 0, False: 12]
 
  Branch (172:12): [True: 0, False: 2]
-
  Branch (172:12): [True: 0, False: 10]
-
  Branch (172:12): [True: 0, False: 8]
+
  Branch (172:12): [True: 0, False: 9]
+
  Branch (172:12): [True: 0, False: 7]
 
  Branch (172:12): [True: 0, False: 34]
 
  Branch (172:12): [True: 0, False: 1]
-
  Branch (172:12): [True: 0, False: 9]
-
  Branch (172:12): [True: 0, False: 4]
+
  Branch (172:12): [True: 0, False: 10]
+
  Branch (172:12): [True: 0, False: 5]
 
  Branch (172:12): [True: 0, False: 22]
 
  Branch (172:12): [True: 2, False: 54]
 
  Branch (172:12): [True: 0, False: 11]
 
  Branch (172:12): [True: 0, False: 5]
-
  Branch (172:12): [True: 0, False: 122]
-
  Branch (172:12): [True: 0, False: 10]
+
  Branch (172:12): [True: 0, False: 121]
+
  Branch (172:12): [True: 0, False: 9]
 
  Branch (172:12): [True: 0, False: 22]
 
  Branch (172:12): [True: 0, False: 12]
 
173
9
            let area = self.get_frame().area();
174
9
            let orig = ratatui::layout::Position { x: area.x, y: area.y };
175
9
            crossterm::execute!(
176
9
                stderr(),
177
                cursor::MoveTo(orig.x, orig.y),
178
                Clear(ClearType::FromCursorDown)
179
0
            )?;
180
9
            self.set_cursor_position(orig)
?0
;
181
399
        }
182
408
        Ok(())
183
408
    }
184
    /// Stops the TUI event loop
185
    /// Equivalent to `self.cancel()`
186
409
    pub fn stop(&self) {
187
409
        self.cancel();
188
409
    }
189
    /// Forces the next [`draw`](ratatui::Terminal::draw) to repaint every cell.
190
    ///
191
    /// ratatui only writes cells that differ from the previously drawn buffer.
192
    /// After the display has been disturbed out from under it — e.g. an
193
    /// `execute` action that ran a child program and re-entered the alternate
194
    /// screen — that cached buffer is stale and a normal draw would leave the
195
    /// screen partially blank. Resetting *both* double buffers makes the next
196
    /// draw diff against an empty buffer and thus repaint everything.
197
    ///
198
    /// Unlike [`ratatui::Terminal::clear`], this performs no cursor-position
199
    /// query (which crossterm writes to stdout and which stalls when stdout is
200
    /// redirected), and it is viewport-agnostic (works for fullscreen and
201
    /// inline layouts alike).
202
2
    pub fn force_full_redraw(&mut self) {
203
2
        self.terminal.swap_buffers();
204
2
        self.terminal.swap_buffers();
205
2
    }
206
    /// Stops the input reader and waits for it to release the terminal.
207
    ///
208
    /// Unlike [`stop`](Self::stop), this blocks until the background task has
209
    /// observed the cancellation and dropped its `EventStream`, so crossterm's
210
    /// internal reader thread has stopped reading the terminal before this
211
    /// returns. Call this before handing the terminal to a foreground child
212
    /// process (e.g. an `execute` action): otherwise skim's reader competes
213
    /// with the child for keystrokes and interactive TUIs appear to freeze.
214
    ///
215
    /// Restart the reader afterwards with [`start`](Self::start).
216
    ///
217
    /// # Panics
218
    ///
219
    /// Panics if called from outside a multi-threaded Tokio runtime, since it
220
    /// uses `block_in_place` to await the reader task from synchronous code.
221
1
    pub fn stop_and_join(&mut self) {
222
1
        self.cancel();
223
1
        if let Some(
task0
) = self.task.take() {
  Branch (223:16): [True: 0, False: 0]
@@ -93,7 +93,7 @@
 
  Branch (239:12): [True: 0, False: 0]
 
  Branch (239:12): [True: 0, False: 0]
 
  Branch (239:12): [True: 0, False: 0]
-
240
0
            self.cancel();
241
11
        }
242
        // Install a fresh cancellation token: a `CancellationToken` stays
243
        // cancelled once cancelled, so reusing the old one (after `stop`,
244
        // `stop_and_join`, or a prior `start`) would make the new task observe
245
        // the cancellation immediately and exit without reading any input.
246
        // This is what lets the reader resume after an `execute` action.
247
11
        self.cancellation_token = CancellationToken::new();
248
11
        let cancellation_token_clone = self.cancellation_token.clone();
249
11
        self.task = Some(tokio::spawn(async move {
250
11
            let mut reader = crossterm::event::EventStream::new();
251
11
            let mut tick_interval = tokio::time::interval(tick_delay);
252
            loop {
253
64
                let tick_delay = tick_interval.tick();
254
64
                let crossterm_event = reader.next().fuse();
255
64
                tokio::select! {
256
64
                    () = cancellation_token_clone.cancelled() => {
257
1
                        break;
258
                    }
259
64
                    
maybe_event6
= crossterm_event => {
260
7
                      match maybe_event {
261
7
                        Some(Ok(crossterm::event::Event::Key(key))) => {
262
7
                          if key.kind == KeyEventKind::Press {
  Branch (262:30): [True: 7, False: 0]
+
240
0
            self.cancel();
241
11
        }
242
        // Install a fresh cancellation token: a `CancellationToken` stays
243
        // cancelled once cancelled, so reusing the old one (after `stop`,
244
        // `stop_and_join`, or a prior `start`) would make the new task observe
245
        // the cancellation immediately and exit without reading any input.
246
        // This is what lets the reader resume after an `execute` action.
247
11
        self.cancellation_token = CancellationToken::new();
248
11
        let cancellation_token_clone = self.cancellation_token.clone();
249
11
        self.task = Some(tokio::spawn(async move {
250
11
            let mut reader = crossterm::event::EventStream::new();
251
11
            let mut tick_interval = tokio::time::interval(tick_delay);
252
            loop {
253
65
                let tick_delay = tick_interval.tick();
254
65
                let crossterm_event = reader.next().fuse();
255
65
                tokio::select! {
256
65
                    () = cancellation_token_clone.cancelled() => {
257
1
                        break;
258
                    }
259
65
                    
maybe_event6
= crossterm_event => {
260
7
                      match maybe_event {
261
7
                        Some(Ok(crossterm::event::Event::Key(key))) => {
262
7
                          if key.kind == KeyEventKind::Press {
  Branch (262:30): [True: 7, False: 0]
 
  Branch (262:30): [True: 0, False: 0]
 
  Branch (262:30): [True: 0, False: 0]
 
  Branch (262:30): [True: 0, False: 0]
@@ -112,7 +112,7 @@
 
  Branch (262:30): [True: 0, False: 0]
 
  Branch (262:30): [True: 0, False: 0]
 
  Branch (262:30): [True: 0, False: 0]
-
263
7
                            _ = event_tx_clone.try_send(Event::Key(key));
264
7
                          
}0
265
                        }
266
0
                        Some(Ok(crossterm::event::Event::Paste(text))) => {
267
0
                          _ = event_tx_clone.try_send(Event::Paste(text));
268
0
                        }
269
0
                        Some(Ok(crossterm::event::Event::Mouse(mouse))) => {
270
0
                          _ = event_tx_clone.try_send(Event::Mouse(mouse));
271
0
                        }
272
0
                        Some(Ok(crossterm::event::Event::Resize(cols, rows))) => {
273
0
                          _ = event_tx_clone.try_send(Event::Resize(cols, rows));
274
0
                          _ = event_tx_clone.try_send(Event::Render);
275
0
                        }
276
0
                        Some(Err(e)) => {
277
0
                          _ = event_tx_clone.try_send(Event::Error(e.to_string()));
278
0
                        }
279
18.4E
                        None | Some(Ok(_)) => {},
280
                      }
281
                    },
282
64
                    _ = tick_delay => {
283
47
                        _ = event_tx_clone.try_send(Event::Heartbeat);
284
47
                    },
285
                }
286
            }
287
1
        }));
288
11
    }
289
290
    /// Gets the next event from the event queue
291
154
    pub async fn next(&mut self) -> Option<Event> 
{144
292
144
        self.event_rx.recv().await
293
135
    }
294
295
10
    fn execute_enter(&self) -> Result<()> {
296
10
        crossterm::execute!(stderr(), EnableBracketedPaste)
?0
;
297
10
        if self.enable_mouse {
  Branch (297:12): [True: 10, False: 0]
+
263
7
                            _ = event_tx_clone.try_send(Event::Key(key));
264
7
                          
}0
265
                        }
266
0
                        Some(Ok(crossterm::event::Event::Paste(text))) => {
267
0
                          _ = event_tx_clone.try_send(Event::Paste(text));
268
0
                        }
269
0
                        Some(Ok(crossterm::event::Event::Mouse(mouse))) => {
270
0
                          _ = event_tx_clone.try_send(Event::Mouse(mouse));
271
0
                        }
272
0
                        Some(Ok(crossterm::event::Event::Resize(cols, rows))) => {
273
0
                          _ = event_tx_clone.try_send(Event::Resize(cols, rows));
274
0
                          _ = event_tx_clone.try_send(Event::Render);
275
0
                        }
276
0
                        Some(Err(e)) => {
277
0
                          _ = event_tx_clone.try_send(Event::Error(e.to_string()));
278
0
                        }
279
18.4E
                        None | Some(Ok(_)) => {},
280
                      }
281
                    },
282
65
                    _ = tick_delay => {
283
48
                        _ = event_tx_clone.try_send(Event::Heartbeat);
284
48
                    },
285
                }
286
            }
287
1
        }));
288
11
    }
289
290
    /// Gets the next event from the event queue
291
147
    pub async fn next(&mut self) -> Option<Event> 
{139
292
139
        self.event_rx.recv().await
293
132
    }
294
295
10
    fn execute_enter(&self) -> Result<()> {
296
10
        crossterm::execute!(stderr(), EnableBracketedPaste)
?0
;
297
10
        if self.enable_mouse {
  Branch (297:12): [True: 10, False: 0]
 
  Branch (297:12): [True: 0, False: 0]
 
  Branch (297:12): [True: 0, False: 0]
 
  Branch (297:12): [True: 0, False: 0]
@@ -310,23 +310,23 @@
 
  Branch (410:12): [True: 0, False: 1]
 
411
4
            return Ok(());
412
4
        }
413
414
4
        if to_scroll > 0 {
  Branch (414:12): [True: 2, False: 1]
 
  Branch (414:12): [True: 1, False: 0]
-
415
3
            crossterm::execute!(stderr(), crossterm::terminal::ScrollUp(to_scroll))
?0
;
416
1
        }
417
4
        debug!("min_height: resizing TUI to {rect:?}");
418
4
        self.resize(rect)
?0
;
419
4
        self.rect = Some(rect);
420
4
        Ok(())
421
11
    }
422
}
423
424
12
fn rect_with_min_height(mut rect: Rect, min_height: u16, terminal_height: u16) -> (Rect, u16) {
425
12
    rect.height = rect.height.max(min_height).min(terminal_height);
426
12
    let lowest_origin = terminal_height.saturating_sub(rect.height);
427
12
    let to_scroll = rect.y.saturating_sub(lowest_origin);
428
12
    rect.y = rect.y.saturating_sub(to_scroll);
429
12
    (rect, to_scroll)
430
12
}
431
432
impl<B: Backend> Deref for Tui<B>
433
where
434
    B::Error: Send + Sync + 'static,
435
{
436
    type Target = ratatui::Terminal<B>;
437
438
606
    fn deref(&self) -> &Self::Target {
439
606
        &self.terminal
440
606
    }
441
}
442
443
impl<B: Backend> DerefMut for Tui<B>
444
where
445
    B::Error: Send + Sync + 'static,
446
{
447
5.34k
    fn deref_mut(&mut self) -> &mut Self::Target {
448
5.34k
        &mut self.terminal
449
5.34k
    }
450
}
451
452
impl<B: Backend> Drop for Tui<B>
453
where
454
    B::Error: Send + Sync + 'static,
455
{
456
407
    fn drop(&mut self) {
457
407
        if let Some(
t11
) = self.task.take() {
  Branch (457:16): [True: 10, False: 0]
-
  Branch (457:16): [True: 0, False: 32]
+
415
3
            crossterm::execute!(stderr(), crossterm::terminal::ScrollUp(to_scroll))
?0
;
416
1
        }
417
4
        debug!("min_height: resizing TUI to {rect:?}");
418
4
        self.resize(rect)
?0
;
419
4
        self.rect = Some(rect);
420
4
        Ok(())
421
11
    }
422
}
423
424
12
fn rect_with_min_height(mut rect: Rect, min_height: u16, terminal_height: u16) -> (Rect, u16) {
425
12
    rect.height = rect.height.max(min_height).min(terminal_height);
426
12
    let lowest_origin = terminal_height.saturating_sub(rect.height);
427
12
    let to_scroll = rect.y.saturating_sub(lowest_origin);
428
12
    rect.y = rect.y.saturating_sub(to_scroll);
429
12
    (rect, to_scroll)
430
12
}
431
432
impl<B: Backend> Deref for Tui<B>
433
where
434
    B::Error: Send + Sync + 'static,
435
{
436
    type Target = ratatui::Terminal<B>;
437
438
606
    fn deref(&self) -> &Self::Target {
439
606
        &self.terminal
440
606
    }
441
}
442
443
impl<B: Backend> DerefMut for Tui<B>
444
where
445
    B::Error: Send + Sync + 'static,
446
{
447
5.32k
    fn deref_mut(&mut self) -> &mut Self::Target {
448
5.32k
        &mut self.terminal
449
5.32k
    }
450
}
451
452
impl<B: Backend> Drop for Tui<B>
453
where
454
    B::Error: Send + Sync + 'static,
455
{
456
407
    fn drop(&mut self) {
457
407
        if let Some(
t11
) = self.task.take() {
  Branch (457:16): [True: 10, False: 0]
+
  Branch (457:16): [True: 0, False: 34]
 
  Branch (457:16): [True: 0, False: 26]
 
  Branch (457:16): [True: 0, False: 12]
 
  Branch (457:16): [True: 0, False: 2]
-
  Branch (457:16): [True: 0, False: 10]
-
  Branch (457:16): [True: 0, False: 8]
+
  Branch (457:16): [True: 0, False: 9]
+
  Branch (457:16): [True: 0, False: 7]
 
  Branch (457:16): [True: 0, False: 34]
 
  Branch (457:16): [True: 0, False: 1]
-
  Branch (457:16): [True: 0, False: 9]
-
  Branch (457:16): [True: 0, False: 4]
+
  Branch (457:16): [True: 0, False: 10]
+
  Branch (457:16): [True: 0, False: 5]
 
  Branch (457:16): [True: 0, False: 22]
 
  Branch (457:16): [True: 1, False: 54]
 
  Branch (457:16): [True: 0, False: 11]
 
  Branch (457:16): [True: 0, False: 5]
-
  Branch (457:16): [True: 0, False: 122]
-
  Branch (457:16): [True: 0, False: 10]
+
  Branch (457:16): [True: 0, False: 121]
+
  Branch (457:16): [True: 0, False: 9]
 
  Branch (457:16): [True: 0, False: 22]
 
  Branch (457:16): [True: 0, False: 12]
 
458
11
            t.abort();
459
396
        }
460
407
        let _ = self.exit();
461
407
    }
462
}
463
464
407
fn set_panic_hook() {
465
407
    PANIC_HOOK_SET.call_once(|| 
{406
466
406
        let hook = std::panic::take_hook();
467
406
        std::panic::set_hook(Box::new(move |panic_info| 
{5
468
5
            let _ = cleanup_terminal();
469
            #[cfg(windows)]
470
            super::windows::uninstall_ctrl_c_handler();
471
5
            hook(panic_info);
472
5
        }));
473
406
    });
474
407
}
475
476
/// Perform terminal cleanup: disable mouse capture, bracketed paste,
477
/// leave alternate screen, show cursor, and disable raw mode.
478
///
479
/// This is safe to call from any thread since:
480
/// - Escape sequences are written atomically to stderr
481
/// - `SetConsoleMode` (used by `disable_raw_mode`) is thread-safe on Windows
482
413
pub(crate) fn cleanup_terminal() -> std::io::Result<()> {
483
413
    if let Err(
e0
) = crossterm::execute!(stderr(), PopKeyboardEnhancementFlags) {
  Branch (483:12): [True: 0, False: 356]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/header.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/header.rs.html
index 0dad879f..e64b9378 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/tui/header.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/header.rs.html
@@ -1,29 +1,29 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/header.rs
Line
Count
Source
1
//! Header display widget for skim's TUI.
2
//!
3
//! This module provides the header widget that displays static text above the item list.
4
use crate::theme::ColorTheme;
5
use crate::tui::BorderType;
6
use crate::tui::options::TuiLayout;
7
use crate::tui::util::{char_display_width, clip_line_to_chars, style_line, style_text};
8
use crate::tui::widget::{SkimRender, SkimWidget};
9
use crate::{DisplayContext, SkimItem, SkimOptions};
10
11
use ansi_to_tui::IntoText;
12
use ratatui::buffer::Buffer;
13
use ratatui::layout::Rect;
14
use ratatui::text::{Line, Span, Text};
15
use ratatui::widgets::{Block, Borders, Paragraph, Widget};
16
use std::cmp::max;
17
use std::sync::Arc;
18
19
/// Header widget for displaying static text above the item list
20
// The field named `header` in `Header` is intentional — it holds the static
21
// header string that this widget displays. Renaming it would reduce clarity.
22
#[allow(clippy::struct_field_names)]
23
#[derive(Clone)]
24
pub struct Header {
25
    /// The static header string (from --header option), with expanded tabstop
26
    pub header: String,
27
    /// Dynamic header lines from input (from --header-lines option)
28
    pub header_lines: Vec<Arc<dyn SkimItem>>,
29
    /// Fixed number of rows reserved for dynamic header lines (`--header-lines`).
30
    /// Used as the row estimate before items arrive; once items are available
31
    /// `height()` counts the actual sub-lines produced by multiline splitting.
32
    header_lines_count: u16,
33
    /// When `--multiline` is active, the separator string used to split each
34
    /// header-line item into multiple display rows.
35
    multiline: Option<String>,
36
    /// The number of spaces to show before the header
37
    indent_size: u16,
38
    theme: Arc<ColorTheme>,
39
    /// Border type
40
    pub border: BorderType,
41
    /// Whether to reverse the order of `header_lines` (for default/bottom-to-top layout)
42
    reverse_lines: bool,
43
    /// Reverse layout
44
    reverse: bool,
45
}
46
47
impl Default for Header {
48
3
    fn default() -> Self {
49
3
        Self::_default()
50
3
    }
51
}
52
53
impl Header {
54
    /// Sets the color theme for the header
55
    #[must_use]
56
1
    pub fn theme(mut self, theme: Arc<ColorTheme>) -> Self {
57
1
        self.theme = theme;
58
1
        self
59
1
    }
60
    /// Returns the total height (in rows) reserved for this header widget.
61
    ///
62
    /// This value is stable at construction time: it is derived purely from
63
    /// `options.header` (static text) and `options.header_lines` (reserved-item
64
    /// count), so the layout does not shift as items arrive at runtime.
65
    #[must_use]
66
3.23k
    pub fn height(&self) -> u16 {
67
3.23k
        let static_lines = if self.header.is_empty() {
  Branch (67:31): [True: 2.94k, False: 92]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/header.rs
Line
Count
Source
1
//! Header display widget for skim's TUI.
2
//!
3
//! This module provides the header widget that displays static text above the item list.
4
use crate::theme::ColorTheme;
5
use crate::tui::BorderType;
6
use crate::tui::options::TuiLayout;
7
use crate::tui::util::{char_display_width, clip_line_to_chars, style_line, style_text};
8
use crate::tui::widget::{SkimRender, SkimWidget};
9
use crate::{DisplayContext, SkimItem, SkimOptions};
10
11
use ansi_to_tui::IntoText;
12
use ratatui::buffer::Buffer;
13
use ratatui::layout::Rect;
14
use ratatui::text::{Line, Span, Text};
15
use ratatui::widgets::{Block, Borders, Paragraph, Widget};
16
use std::cmp::max;
17
use std::sync::Arc;
18
19
/// Header widget for displaying static text above the item list
20
// The field named `header` in `Header` is intentional — it holds the static
21
// header string that this widget displays. Renaming it would reduce clarity.
22
#[allow(clippy::struct_field_names)]
23
#[derive(Clone)]
24
pub struct Header {
25
    /// The static header string (from --header option), with expanded tabstop
26
    pub header: String,
27
    /// Dynamic header lines from input (from --header-lines option)
28
    pub header_lines: Vec<Arc<dyn SkimItem>>,
29
    /// Fixed number of rows reserved for dynamic header lines (`--header-lines`).
30
    /// Used as the row estimate before items arrive; once items are available
31
    /// `height()` counts the actual sub-lines produced by multiline splitting.
32
    header_lines_count: u16,
33
    /// When `--multiline` is active, the separator string used to split each
34
    /// header-line item into multiple display rows.
35
    multiline: Option<String>,
36
    /// The number of spaces to show before the header
37
    indent_size: u16,
38
    theme: Arc<ColorTheme>,
39
    /// Border type
40
    pub border: BorderType,
41
    /// Whether to reverse the order of `header_lines` (for default/bottom-to-top layout)
42
    reverse_lines: bool,
43
    /// Reverse layout
44
    reverse: bool,
45
}
46
47
impl Default for Header {
48
3
    fn default() -> Self {
49
3
        Self::_default()
50
3
    }
51
}
52
53
impl Header {
54
    /// Sets the color theme for the header
55
    #[must_use]
56
1
    pub fn theme(mut self, theme: Arc<ColorTheme>) -> Self {
57
1
        self.theme = theme;
58
1
        self
59
1
    }
60
    /// Returns the total height (in rows) reserved for this header widget.
61
    ///
62
    /// This value is stable at construction time: it is derived purely from
63
    /// `options.header` (static text) and `options.header_lines` (reserved-item
64
    /// count), so the layout does not shift as items arrive at runtime.
65
    #[must_use]
66
3.22k
    pub fn height(&self) -> u16 {
67
3.22k
        let static_lines = if self.header.is_empty() {
  Branch (67:31): [True: 2.93k, False: 96]
 
  Branch (67:31): [True: 198, False: 2]
-
68
3.14k
            0
69
        } else {
70
94
            u16::try_from(self.header.lines().count()).unwrap_or(u16::MAX)
71
        };
72
        // Once items have arrived, count actual terminal rows (accounting for
73
        // multiline splitting).  Before items arrive fall back to the
74
        // compile-time estimate so the layout is stable on the first frame.
75
3.23k
        let dynamic_lines = if self.header_lines.is_empty() {
  Branch (75:32): [True: 2.97k, False: 55]
+
68
3.12k
            0
69
        } else {
70
98
            u16::try_from(self.header.lines().count()).unwrap_or(u16::MAX)
71
        };
72
        // Once items have arrived, count actual terminal rows (accounting for
73
        // multiline splitting).  Before items arrive fall back to the
74
        // compile-time estimate so the layout is stable on the first frame.
75
3.22k
        let dynamic_lines = if self.header_lines.is_empty() {
  Branch (75:32): [True: 2.96k, False: 58]
 
  Branch (75:32): [True: 199, False: 1]
-
76
3.17k
            self.header_lines_count
77
56
        } else if let Some(
sep3
) = self.multiline.as_deref() {
  Branch (77:23): [True: 3, False: 52]
+
76
3.16k
            self.header_lines_count
77
59
        } else if let Some(
sep3
) = self.multiline.as_deref() {
  Branch (77:23): [True: 3, False: 55]
 
  Branch (77:23): [True: 0, False: 1]
-
78
3
            self.header_lines
79
3
                .iter()
80
3
                .map(|item| u16::try_from(item.text().split(sep).count().max(1)).unwrap_or(1))
81
3
                .sum()
82
        } else {
83
53
            u16::try_from(self.header_lines.len()).unwrap_or(u16::MAX)
84
        };
85
3.23k
        static_lines + dynamic_lines
86
3.23k
    }
87
88
    /// Sets the dynamic header lines from input (--header-lines)
89
2.68k
    pub fn set_header_lines(&mut self, items: Vec<Arc<dyn SkimItem>>) {
90
2.68k
        self.header_lines = items;
91
2.68k
        if self.reverse_lines {
  Branch (91:12): [True: 2.58k, False: 64]
+
78
3
            self.header_lines
79
3
                .iter()
80
3
                .map(|item| u16::try_from(item.text().split(sep).count().max(1)).unwrap_or(1))
81
3
                .sum()
82
        } else {
83
56
            u16::try_from(self.header_lines.len()).unwrap_or(u16::MAX)
84
        };
85
3.22k
        static_lines + dynamic_lines
86
3.22k
    }
87
88
    /// Sets the dynamic header lines from input (--header-lines)
89
2.67k
    pub fn set_header_lines(&mut self, items: Vec<Arc<dyn SkimItem>>) {
90
2.67k
        self.header_lines = items;
91
2.67k
        if self.reverse_lines {
  Branch (91:12): [True: 2.58k, False: 66]
 
  Branch (91:12): [True: 29, False: 0]
-
92
2.61k
            self.header_lines.reverse();
93
2.61k
        
}64
94
2.68k
    }
95
138
    fn header_text<'a>(&self) -> Text<'a> {
96
138
        let mut res = self.header.into_text().unwrap();
97
138
        style_text(&mut res, self.theme.header);
98
138
        res
99
138
    }
100
}
101
102
/// Expands tab characters to spaces based on tabstop width and current position
103
547
fn apply_tabstop(text: &str, tabstop: usize) -> String {
104
547
    let mut result = String::new();
105
547
    let mut current_width = 0;
106
107
547
    for 
ch217
in text.chars() {
108
217
        if ch == '\t' {
  Branch (108:12): [True: 0, False: 170]
+
92
2.60k
            self.header_lines.reverse();
93
2.60k
        
}66
94
2.67k
    }
95
144
    fn header_text<'a>(&self) -> Text<'a> {
96
144
        let mut res = self.header.into_text().unwrap();
97
144
        style_text(&mut res, self.theme.header);
98
144
        res
99
144
    }
100
}
101
102
/// Expands tab characters to spaces based on tabstop width and current position
103
547
fn apply_tabstop(text: &str, tabstop: usize) -> String {
104
547
    let mut result = String::new();
105
547
    let mut current_width = 0;
106
107
547
    for 
ch223
in text.chars() {
108
223
        if ch == '\t' {
  Branch (108:12): [True: 0, False: 176]
 
  Branch (108:12): [True: 3, False: 44]
-
109
3
            let tab_width = tabstop - (current_width % tabstop);
110
3
            result.push_str(&" ".repeat(tab_width));
111
3
            current_width += tab_width;
112
214
        } else {
113
214
            result.push(ch);
114
214
            current_width += char_display_width(ch);
115
214
        }
116
    }
117
118
547
    result
119
547
}
120
121
impl SkimWidget for Header {
122
543
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
123
543
        let tabstop = max(1, options.tabstop);
124
543
        let header = options.header.clone().unwrap_or_default();
125
126
        // Expand tabs once during initialization
127
543
        let expanded_header = apply_tabstop(&header, tabstop);
128
129
        // In default layout (bottom-to-top), header_lines should be reversed
130
        // to match the visual flow of the item list
131
543
        let reverse_lines = options.layout == TuiLayout::Default;
132
133
543
        Self {
134
543
            header: expanded_header,
135
543
            header_lines: Vec::new(),
136
543
            header_lines_count: options
137
543
                .header_lines
138
543
                .try_into()
139
543
                .expect("header_lines count overflows u16"),
140
543
            multiline: options.multiline.as_ref().and_then(std::clone::Clone::clone),
141
543
            indent_size: (options.selector_icon.chars().count() + options.multi_select_icon.chars().count())
142
543
                .try_into()
143
543
                .expect("Failed to fit selector lens into an u16"),
144
543
            theme,
145
543
            border: options.border,
146
543
            reverse_lines,
147
543
            reverse: options.layout == TuiLayout::Reverse,
148
543
        }
149
543
    }
150
151
98
    fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender {
152
98
        let block = if let Some(
border_type18
) = self.border.into_ratatui() {
  Branch (152:28): [True: 18, False: 78]
+
109
3
            let tab_width = tabstop - (current_width % tabstop);
110
3
            result.push_str(&" ".repeat(tab_width));
111
3
            current_width += tab_width;
112
220
        } else {
113
220
            result.push(ch);
114
220
            current_width += char_display_width(ch);
115
220
        }
116
    }
117
118
547
    result
119
547
}
120
121
impl SkimWidget for Header {
122
543
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
123
543
        let tabstop = max(1, options.tabstop);
124
543
        let header = options.header.clone().unwrap_or_default();
125
126
        // Expand tabs once during initialization
127
543
        let expanded_header = apply_tabstop(&header, tabstop);
128
129
        // In default layout (bottom-to-top), header_lines should be reversed
130
        // to match the visual flow of the item list
131
543
        let reverse_lines = options.layout == TuiLayout::Default;
132
133
543
        Self {
134
543
            header: expanded_header,
135
543
            header_lines: Vec::new(),
136
543
            header_lines_count: options
137
543
                .header_lines
138
543
                .try_into()
139
543
                .expect("header_lines count overflows u16"),
140
543
            multiline: options.multiline.as_ref().and_then(std::clone::Clone::clone),
141
543
            indent_size: (options.selector_icon.chars().count() + options.multi_select_icon.chars().count())
142
543
                .try_into()
143
543
                .expect("Failed to fit selector lens into an u16"),
144
543
            theme,
145
543
            border: options.border,
146
543
            reverse_lines,
147
543
            reverse: options.layout == TuiLayout::Reverse,
148
543
        }
149
543
    }
150
151
101
    fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender {
152
101
        let block = if let Some(
border_type21
) = self.border.into_ratatui() {
  Branch (152:28): [True: 21, False: 78]
 
  Branch (152:28): [True: 0, False: 2]
-
153
18
            Block::default()
154
18
                .borders(Borders::ALL)
155
18
                .border_type(border_type)
156
18
                .border_style(self.theme.border)
157
        } else {
158
80
            Block::default()
159
        }
160
98
        .padding(ratatui::widgets::Padding::left(self.indent_size));
161
162
98
        let content_height = if self.header.is_empty() {
  Branch (162:33): [True: 28, False: 68]
+
153
21
            Block::default()
154
21
                .borders(Borders::ALL)
155
21
                .border_type(border_type)
156
21
                .border_style(self.theme.border)
157
        } else {
158
80
            Block::default()
159
        }
160
101
        .padding(ratatui::widgets::Padding::left(self.indent_size));
161
162
101
        let content_height = if self.header.is_empty() {
  Branch (162:33): [True: 28, False: 71]
 
  Branch (162:33): [True: 1, False: 1]
-
163
29
            0
164
        } else {
165
69
            self.header_text().lines.len()
166
98
        } + if let Some(
sep9
) = self.multiline.as_deref() {
  Branch (166:20): [True: 9, False: 87]
+
163
29
            0
164
        } else {
165
72
            self.header_text().lines.len()
166
101
        } + if let Some(
sep9
) = self.multiline.as_deref() {
  Branch (166:20): [True: 9, False: 90]
 
  Branch (166:20): [True: 0, False: 2]
-
167
9
            self.header_lines
168
9
                .iter()
169
9
                .map(|item| 
item.text()3
.split(sep).
count3
().
max3
(1))
170
9
                .sum()
171
        } else {
172
89
            self.header_lines.len()
173
        };
174
175
98
        let container_height = if self.border.is_some() {
  Branch (175:35): [True: 18, False: 78]
+
167
9
            self.header_lines
168
9
                .iter()
169
9
                .map(|item| 
item.text()3
.split(sep).
count3
().
max3
(1))
170
9
                .sum()
171
        } else {
172
92
            self.header_lines.len()
173
        };
174
175
101
        let container_height = if self.border.is_some() {
  Branch (175:35): [True: 21, False: 78]
 
  Branch (175:35): [True: 0, False: 2]
-
176
18
            area.height - 2
177
        } else {
178
80
            area.height
179
        };
180
181
        // Combine static header with dynamic header_lines
182
98
        let mut combined_header = if self.reverse && 
!self.header.is_empty()21
{
  Branch (182:38): [True: 21, False: 75]
+
176
21
            area.height - 2
177
        } else {
178
80
            area.height
179
        };
180
181
        // Combine static header with dynamic header_lines
182
101
        let mut combined_header = if self.reverse && 
!self.header.is_empty()21
{
  Branch (182:38): [True: 21, False: 78]
   Branch (182:54): [True: 15, False: 6]
 
  Branch (182:38): [True: 0, False: 2]
   Branch (182:54): [True: 0, False: 0]
-
183
15
            self.header_text()
184
        } else {
185
83
            let mut r = Text::default();
186
83
            for _ in 0..(container_height.saturating_sub(content_height.try_into().unwrap())) {
187
5
                r.push_line("");
188
5
            }
189
83
            r
190
        };
191
192
98
        let display_context = DisplayContext {
193
98
            base_style: self.theme.header,
194
98
            ..Default::default()
195
98
        };
196
197
98
        for 
item91
in &self.header_lines {
198
91
            if let Some(
sep3
) = self.multiline.as_deref() {
  Branch (198:20): [True: 3, False: 88]
+
183
15
            self.header_text()
184
        } else {
185
86
            let mut r = Text::default();
186
86
            for _ in 0..(container_height.saturating_sub(content_height.try_into().unwrap())) {
187
5
                r.push_line("");
188
5
            }
189
86
            r
190
        };
191
192
101
        let display_context = DisplayContext {
193
101
            base_style: self.theme.header,
194
101
            ..Default::default()
195
101
        };
196
197
101
        for 
item97
in &self.header_lines {
198
97
            if let Some(
sep3
) = self.multiline.as_deref() {
  Branch (198:20): [True: 3, False: 94]
 
  Branch (198:20): [True: 0, False: 0]
-
199
3
                let item_text = item.text();
200
3
                let sub_lines: Vec<&str> = item_text.split(sep).collect();
201
202
                // First sub-line: call display() so that ANSI styling is applied,
203
                // then clip the result to the character count of that sub-part.
204
3
                let first_char_len = sub_lines.first().map_or(0, |s| s.chars().count());
205
3
                let full_display = item.display(display_context.clone());
206
3
                let mut first_line = clip_line_to_chars(full_display, first_char_len);
207
3
                style_line(&mut first_line, self.theme.header);
208
3
                combined_header.push_line(first_line);
209
210
                // Remaining sub-lines: plain styled text (no ANSI re-processing needed).
211
3
                for sub_text in sub_lines.iter().skip(1) {
212
3
                    let mut line = Line::from(vec![Span::styled(sub_text.to_string(), display_context.base_style)]);
213
3
                    style_line(&mut line, self.theme.header);
214
3
                    combined_header.push_line(line);
215
3
                }
216
88
            } else {
217
88
                let mut line = item.display(display_context.clone());
218
88
                style_line(&mut line, self.theme.header);
219
88
                combined_header.push_line(line);
220
88
            }
221
        }
222
223
        // Add static header (from --header)
224
98
        if !self.reverse && 
!self.header.is_empty()77
{
  Branch (224:12): [True: 75, False: 21]
-  Branch (224:29): [True: 53, False: 22]
+
199
3
                let item_text = item.text();
200
3
                let sub_lines: Vec<&str> = item_text.split(sep).collect();
201
202
                // First sub-line: call display() so that ANSI styling is applied,
203
                // then clip the result to the character count of that sub-part.
204
3
                let first_char_len = sub_lines.first().map_or(0, |s| s.chars().count());
205
3
                let full_display = item.display(display_context.clone());
206
3
                let mut first_line = clip_line_to_chars(full_display, first_char_len);
207
3
                style_line(&mut first_line, self.theme.header);
208
3
                combined_header.push_line(first_line);
209
210
                // Remaining sub-lines: plain styled text (no ANSI re-processing needed).
211
3
                for sub_text in sub_lines.iter().skip(1) {
212
3
                    let mut line = Line::from(vec![Span::styled(sub_text.to_string(), display_context.base_style)]);
213
3
                    style_line(&mut line, self.theme.header);
214
3
                    combined_header.push_line(line);
215
3
                }
216
94
            } else {
217
94
                let mut line = item.display(display_context.clone());
218
94
                style_line(&mut line, self.theme.header);
219
94
                combined_header.push_line(line);
220
94
            }
221
        }
222
223
        // Add static header (from --header)
224
101
        if !self.reverse && 
!self.header.is_empty()80
{
  Branch (224:12): [True: 78, False: 21]
+  Branch (224:29): [True: 56, False: 22]
 
  Branch (224:12): [True: 2, False: 0]
   Branch (224:29): [True: 1, False: 1]
-
225
54
            combined_header += self.header_text();
226
54
        
}44
227
228
98
        Paragraph::new(combined_header)
229
98
            .style(self.theme.header)
230
98
            .block(block)
231
98
            .render(area, buf);
232
233
98
        SkimRender::default()
234
98
    }
235
}
236
237
#[cfg(test)]
238
#[cfg_attr(coverage, coverage(off))]
239
mod tests {
240
    use super::*;
241
    use crate::options::SkimOptionsBuilder;
242
    use ratatui::buffer::Buffer;
243
    use ratatui::layout::Rect;
244
245
    fn header_with(options: &SkimOptions) -> Header {
246
        Header::from_options(options, Arc::new(ColorTheme::default()))
247
    }
248
249
    fn buffer_text(buf: &Buffer) -> String {
250
        let area = buf.area;
251
        let mut out = String::new();
252
        for y in 0..area.height {
253
            for x in 0..area.width {
254
                out.push_str(buf[(x, y)].symbol());
255
            }
256
            out.push('\n');
257
        }
258
        out
259
    }
260
261
    #[test]
262
    fn apply_tabstop_expands_to_column() {
263
        // A tab advances to the next multiple of the tabstop width.
264
        assert_eq!(apply_tabstop("a\tb", 4), "a   b");
265
        assert_eq!(apply_tabstop("\t", 4), "    ");
266
        assert_eq!(apply_tabstop("ab\tc", 4), "ab  c");
267
        assert_eq!(apply_tabstop("noTabs", 8), "noTabs");
268
    }
269
270
    #[test]
271
    fn default_header_is_empty_with_zero_height() {
272
        let header = Header::default();
273
        assert_eq!(header.height(), 0);
274
    }
275
276
    #[test]
277
    fn theme_setter_is_chainable() {
278
        let header = Header::default().theme(Arc::new(ColorTheme::default()));
279
        assert_eq!(header.height(), 0);
280
    }
281
282
    #[test]
283
    fn height_counts_static_header_lines() {
284
        let options = SkimOptionsBuilder::default()
285
            .header("line one\nline two")
286
            .build()
287
            .unwrap();
288
        let header = header_with(&options);
289
        assert_eq!(header.height(), 2);
290
    }
291
292
    #[test]
293
    fn height_includes_reserved_header_lines_count() {
294
        let options = SkimOptionsBuilder::default().header_lines(3usize).build().unwrap();
295
        let header = header_with(&options);
296
        // No items yet → falls back to the reserved count.
297
        assert_eq!(header.height(), 3);
298
    }
299
300
    #[test]
301
    fn set_header_lines_counts_dynamic_items() {
302
        let options = SkimOptionsBuilder::default().header_lines(2usize).build().unwrap();
303
        let mut header = header_with(&options);
304
        let items: Vec<Arc<dyn SkimItem>> = vec![Arc::new("a".to_string()), Arc::new("b".to_string())];
305
        header.set_header_lines(items);
306
        assert_eq!(header.height(), 2);
307
    }
308
309
    #[test]
310
    fn render_writes_static_header_text() {
311
        let options = SkimOptionsBuilder::default().header("MYHEADER").build().unwrap();
312
        let mut header = header_with(&options);
313
        let area = Rect::new(0, 0, 20, 3);
314
        let mut buf = Buffer::empty(area);
315
        header.render(area, &mut buf);
316
        assert!(buffer_text(&buf).contains("MYHEADER"));
317
    }
318
319
    #[test]
320
    fn render_empty_header_does_not_panic() {
321
        let mut header = Header::default();
322
        let area = Rect::new(0, 0, 20, 3);
323
        let mut buf = Buffer::empty(area);
324
        // Exercises the blank-line padding branch for an empty header.
325
        let _ = header.render(area, &mut buf);
326
    }
327
}
\ No newline at end of file +
225
57
            combined_header += self.header_text();
226
57
        
}44
227
228
101
        Paragraph::new(combined_header)
229
101
            .style(self.theme.header)
230
101
            .block(block)
231
101
            .render(area, buf);
232
233
101
        SkimRender::default()
234
101
    }
235
}
236
237
#[cfg(test)]
238
#[cfg_attr(coverage, coverage(off))]
239
mod tests {
240
    use super::*;
241
    use crate::options::SkimOptionsBuilder;
242
    use ratatui::buffer::Buffer;
243
    use ratatui::layout::Rect;
244
245
    fn header_with(options: &SkimOptions) -> Header {
246
        Header::from_options(options, Arc::new(ColorTheme::default()))
247
    }
248
249
    fn buffer_text(buf: &Buffer) -> String {
250
        let area = buf.area;
251
        let mut out = String::new();
252
        for y in 0..area.height {
253
            for x in 0..area.width {
254
                out.push_str(buf[(x, y)].symbol());
255
            }
256
            out.push('\n');
257
        }
258
        out
259
    }
260
261
    #[test]
262
    fn apply_tabstop_expands_to_column() {
263
        // A tab advances to the next multiple of the tabstop width.
264
        assert_eq!(apply_tabstop("a\tb", 4), "a   b");
265
        assert_eq!(apply_tabstop("\t", 4), "    ");
266
        assert_eq!(apply_tabstop("ab\tc", 4), "ab  c");
267
        assert_eq!(apply_tabstop("noTabs", 8), "noTabs");
268
    }
269
270
    #[test]
271
    fn default_header_is_empty_with_zero_height() {
272
        let header = Header::default();
273
        assert_eq!(header.height(), 0);
274
    }
275
276
    #[test]
277
    fn theme_setter_is_chainable() {
278
        let header = Header::default().theme(Arc::new(ColorTheme::default()));
279
        assert_eq!(header.height(), 0);
280
    }
281
282
    #[test]
283
    fn height_counts_static_header_lines() {
284
        let options = SkimOptionsBuilder::default()
285
            .header("line one\nline two")
286
            .build()
287
            .unwrap();
288
        let header = header_with(&options);
289
        assert_eq!(header.height(), 2);
290
    }
291
292
    #[test]
293
    fn height_includes_reserved_header_lines_count() {
294
        let options = SkimOptionsBuilder::default().header_lines(3usize).build().unwrap();
295
        let header = header_with(&options);
296
        // No items yet → falls back to the reserved count.
297
        assert_eq!(header.height(), 3);
298
    }
299
300
    #[test]
301
    fn set_header_lines_counts_dynamic_items() {
302
        let options = SkimOptionsBuilder::default().header_lines(2usize).build().unwrap();
303
        let mut header = header_with(&options);
304
        let items: Vec<Arc<dyn SkimItem>> = vec![Arc::new("a".to_string()), Arc::new("b".to_string())];
305
        header.set_header_lines(items);
306
        assert_eq!(header.height(), 2);
307
    }
308
309
    #[test]
310
    fn render_writes_static_header_text() {
311
        let options = SkimOptionsBuilder::default().header("MYHEADER").build().unwrap();
312
        let mut header = header_with(&options);
313
        let area = Rect::new(0, 0, 20, 3);
314
        let mut buf = Buffer::empty(area);
315
        header.render(area, &mut buf);
316
        assert!(buffer_text(&buf).contains("MYHEADER"));
317
    }
318
319
    #[test]
320
    fn render_empty_header_does_not_panic() {
321
        let mut header = Header::default();
322
        let area = Rect::new(0, 0, 20, 3);
323
        let mut buf = Buffer::empty(area);
324
        // Exercises the blank-line padding branch for an empty header.
325
        let _ = header.render(area, &mut buf);
326
    }
327
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/input.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/input.rs.html index 88864ac8..72657ac8 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/tui/input.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/input.rs.html @@ -1,17 +1,17 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/input.rs
Line
Count
Source
1
use std::fmt::Write as _;
2
use std::ops::{Deref, DerefMut};
3
use std::sync::Arc;
4
use std::time::Instant;
5
6
use ansi_to_tui::IntoText;
7
use ratatui::prelude::*;
8
use ratatui::widgets::Widget;
9
use unicode_display_width::width as display_width;
10
11
use crate::SkimOptions;
12
use crate::helper::item::strip_ansi;
13
use crate::theme::ColorTheme;
14
use crate::tui::BorderType;
15
use crate::tui::options::TuiLayout;
16
use crate::tui::statusline::{Info, InfoDisplay, spinner_char};
17
use crate::tui::util::style_line;
18
use crate::tui::widget::{SkimRender, SkimWidget};
19
20
/// Status information to display in the input widget's title
21
#[derive(Clone, Default)]
22
pub struct StatusInfo {
23
    /// Total number of items
24
    pub total: usize,
25
    /// Number of matched items
26
    pub matched: usize,
27
    /// Number of processed items
28
    pub processed: usize,
29
    /// Whether the spinner should be shown (controlled by App with debouncing)
30
    pub show_spinner: bool,
31
    /// Current matcher mode (e.g., "RE" for regex)
32
    pub matcher_mode: String,
33
    /// Whether multi-selection mode is enabled
34
    pub multi_selection: bool,
35
    /// Number of selected items
36
    pub selected: usize,
37
    /// Index of the current item
38
    pub current_item_idx: usize,
39
    /// Horizontal scroll offset
40
    pub hscroll_offset: i64,
41
    /// Start time for calculating spinner animation
42
    pub start: Option<Instant>,
43
    /// Inline prefix/separator (when the spinner is hidden)
44
    pub inline_separator: String,
45
}
46
47
impl StatusInfo {
48
    /// Build the left-aligned title string (spinner, matched/total, mode, progress, selection)
49
    /// Used for Default info display mode (separate line)
50
2.57k
    pub fn left_title(&self) -> String {
51
2.57k
        let mut parts = String::new();
52
53
        // Spinner
54
2.57k
        if self.show_spinner
  Branch (54:12): [True: 15, False: 2.53k]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/input.rs
Line
Count
Source
1
use std::fmt::Write as _;
2
use std::ops::{Deref, DerefMut};
3
use std::sync::Arc;
4
use std::time::Instant;
5
6
use ansi_to_tui::IntoText;
7
use ratatui::prelude::*;
8
use ratatui::widgets::Widget;
9
use unicode_display_width::width as display_width;
10
11
use crate::SkimOptions;
12
use crate::helper::item::strip_ansi;
13
use crate::theme::ColorTheme;
14
use crate::tui::BorderType;
15
use crate::tui::options::TuiLayout;
16
use crate::tui::statusline::{Info, InfoDisplay, spinner_char};
17
use crate::tui::util::style_line;
18
use crate::tui::widget::{SkimRender, SkimWidget};
19
20
/// Status information to display in the input widget's title
21
#[derive(Clone, Default)]
22
pub struct StatusInfo {
23
    /// Total number of items
24
    pub total: usize,
25
    /// Number of matched items
26
    pub matched: usize,
27
    /// Number of processed items
28
    pub processed: usize,
29
    /// Whether the spinner should be shown (controlled by App with debouncing)
30
    pub show_spinner: bool,
31
    /// Current matcher mode (e.g., "RE" for regex)
32
    pub matcher_mode: String,
33
    /// Whether multi-selection mode is enabled
34
    pub multi_selection: bool,
35
    /// Number of selected items
36
    pub selected: usize,
37
    /// Index of the current item
38
    pub current_item_idx: usize,
39
    /// Horizontal scroll offset
40
    pub hscroll_offset: i64,
41
    /// Start time for calculating spinner animation
42
    pub start: Option<Instant>,
43
    /// Inline prefix/separator (when the spinner is hidden)
44
    pub inline_separator: String,
45
}
46
47
impl StatusInfo {
48
    /// Build the left-aligned title string (spinner, matched/total, mode, progress, selection)
49
    /// Used for Default info display mode (separate line)
50
2.57k
    pub fn left_title(&self) -> String {
51
2.57k
        let mut parts = String::new();
52
53
        // Spinner
54
2.57k
        if self.show_spinner
  Branch (54:12): [True: 12, False: 2.52k]
 
  Branch (54:12): [True: 1, False: 29]
-
55
16
            && let Some(start) = self.start
  Branch (55:20): [True: 15, False: 0]
+
55
13
            && let Some(start) = self.start
  Branch (55:20): [True: 12, False: 0]
 
  Branch (55:20): [True: 1, False: 0]
-
56
16
        {
57
16
            parts.push(spinner_char(start));
58
16
            parts.push(' ');
59
2.56k
        } else {
60
2.56k
            parts.push_str("  ");
61
2.56k
        }
62
63
        // Matched/total
64
2.57k
        let _ = write!(parts, "{}/{}", self.matched, self.total);
65
66
        // Matcher mode
67
2.57k
        if !self.matcher_mode.is_empty() {
  Branch (67:12): [True: 3, False: 2.54k]
+
56
13
        {
57
13
            parts.push(spinner_char(start));
58
13
            parts.push(' ');
59
2.55k
        } else {
60
2.55k
            parts.push_str("  ");
61
2.55k
        }
62
63
        // Matched/total
64
2.57k
        let _ = write!(parts, "{}/{}", self.matched, self.total);
65
66
        // Matcher mode
67
2.57k
        if !self.matcher_mode.is_empty() {
  Branch (67:12): [True: 3, False: 2.53k]
 
  Branch (67:12): [True: 2, False: 28]
-
68
5
            let _ = write!(parts, "/{}", self.matcher_mode);
69
2.57k
        }
70
71
        // Progress percentage
72
2.57k
        if self.show_spinner && 
self.total > 016
&&
self.processed != self.total16
{
  Branch (72:12): [True: 15, False: 2.53k]
-  Branch (72:33): [True: 15, False: 0]
-  Branch (72:51): [True: 12, False: 3]
+
68
5
            let _ = write!(parts, "/{}", self.matcher_mode);
69
2.56k
        }
70
71
        // Progress percentage
72
2.57k
        if self.show_spinner && 
self.total > 013
&&
self.processed != self.total13
{
  Branch (72:12): [True: 12, False: 2.52k]
+  Branch (72:33): [True: 12, False: 0]
+  Branch (72:51): [True: 12, False: 0]
 
  Branch (72:12): [True: 1, False: 29]
   Branch (72:33): [True: 1, False: 0]
   Branch (72:51): [True: 1, False: 0]
-
73
13
            let pct = self.processed.saturating_mul(100) / self.total;
74
13
            let _ = write!(parts, " ({pct}%)");
75
2.56k
        }
76
77
        // Selection count
78
2.57k
        if self.multi_selection && 
self.selected > 0142
{
  Branch (78:12): [True: 141, False: 2.40k]
-  Branch (78:36): [True: 87, False: 54]
+
73
13
            let pct = self.processed.saturating_mul(100) / self.total;
74
13
            let _ = write!(parts, " ({pct}%)");
75
2.55k
        }
76
77
        // Selection count
78
2.57k
        if self.multi_selection && 
self.selected > 0145
{
  Branch (78:12): [True: 144, False: 2.39k]
+  Branch (78:36): [True: 87, False: 57]
 
  Branch (78:12): [True: 1, False: 29]
   Branch (78:36): [True: 1, False: 0]
 
79
88
            let _ = write!(parts, " [{}]", self.selected);
80
2.48k
        }
81
82
2.57k
        parts
83
2.57k
    }
84
85
    /// Get the inline separator character: spinner when active, '<' otherwise
86
    /// Used for Inline info display mode
87
49
    pub fn inline_separator_or_spinner(&self) -> String {
88
49
        if self.show_spinner
  Branch (88:12): [True: 0, False: 47]
@@ -28,7 +28,7 @@
   Branch (121:36): [True: 0, False: 0]
 
  Branch (121:12): [True: 1, False: 0]
   Branch (121:36): [True: 1, False: 0]
-
122
1
            let _ = write!(parts, " [{}]", self.selected);
123
47
        }
124
125
48
        parts
126
48
    }
127
128
    /// Build the right-aligned title string (current index / hscroll)
129
2.62k
    pub fn right_title(&self) -> String {
130
2.62k
        format!("{}/{}", self.current_item_idx, self.hscroll_offset)
131
2.62k
    }
132
}
133
134
pub struct Input {
135
    pub prompt: String,
136
    /// see `alternate_value`
137
    alternate_prompt: String,
138
    pub value: String,
139
    /// cmd query when in normal mode, query when in interactive mode
140
    alternate_value: String,
141
    pub cursor_pos: u16,
142
    pub alternate_cursor_pos: u16,
143
    pub theme: Arc<ColorTheme>,
144
    /// Border type
145
    pub border: BorderType,
146
    /// Status information to display as the input's title
147
    pub status_info: Option<StatusInfo>,
148
    /// How to display the info/status (default, inline, or hidden)
149
    pub info: Info,
150
    /// Whether layout is reversed (status goes below input instead of above)
151
    pub reverse: bool,
152
}
153
154
impl Default for Input {
155
103
    fn default() -> Self {
156
103
        Self::_default()
157
103
    }
158
}
159
160
impl Input {
161
338
    pub fn insert(&mut self, c: char) {
162
338
        self.value.insert(self.cursor_pos.into(), c);
163
        // unwrap: len_utf8 < 4
164
338
        self.move_cursor(c.len_utf8().try_into().unwrap());
165
338
    }
166
22
    pub fn insert_str(&mut self, s: &str) {
167
22
        self.value.insert_str(self.cursor_pos as usize, s);
168
        // `cursor_pos` is a byte offset (see `move_cursor_to`), so advance by the
169
        // inserted byte length, not the char count.
170
22
        self.move_cursor(s.len().try_into().expect("Failed to fit inserted str len into an i32"));
171
22
    }
172
74
    fn nchars(&self) -> usize {
173
74
        self.value.chars().count()
174
74
    }
175
13
    pub fn delete(&mut self, offset: i32) -> Option<char> {
176
13
        if self.value.is_empty() {
  Branch (176:12): [True: 0, False: 6]
+
122
1
            let _ = write!(parts, " [{}]", self.selected);
123
47
        }
124
125
48
        parts
126
48
    }
127
128
    /// Build the right-aligned title string (current index / hscroll)
129
2.61k
    pub fn right_title(&self) -> String {
130
2.61k
        format!("{}/{}", self.current_item_idx, self.hscroll_offset)
131
2.61k
    }
132
}
133
134
pub struct Input {
135
    pub prompt: String,
136
    /// see `alternate_value`
137
    alternate_prompt: String,
138
    pub value: String,
139
    /// cmd query when in normal mode, query when in interactive mode
140
    alternate_value: String,
141
    pub cursor_pos: u16,
142
    pub alternate_cursor_pos: u16,
143
    pub theme: Arc<ColorTheme>,
144
    /// Border type
145
    pub border: BorderType,
146
    /// Status information to display as the input's title
147
    pub status_info: Option<StatusInfo>,
148
    /// How to display the info/status (default, inline, or hidden)
149
    pub info: Info,
150
    /// Whether layout is reversed (status goes below input instead of above)
151
    pub reverse: bool,
152
}
153
154
impl Default for Input {
155
103
    fn default() -> Self {
156
103
        Self::_default()
157
103
    }
158
}
159
160
impl Input {
161
337
    pub fn insert(&mut self, c: char) {
162
337
        self.value.insert(self.cursor_pos.into(), c);
163
        // unwrap: len_utf8 < 4
164
337
        self.move_cursor(c.len_utf8().try_into().unwrap());
165
337
    }
166
22
    pub fn insert_str(&mut self, s: &str) {
167
22
        self.value.insert_str(self.cursor_pos as usize, s);
168
        // `cursor_pos` is a byte offset (see `move_cursor_to`), so advance by the
169
        // inserted byte length, not the char count.
170
22
        self.move_cursor(s.len().try_into().expect("Failed to fit inserted str len into an i32"));
171
22
    }
172
74
    fn nchars(&self) -> usize {
173
74
        self.value.chars().count()
174
74
    }
175
13
    pub fn delete(&mut self, offset: i32) -> Option<char> {
176
13
        if self.value.is_empty() {
  Branch (176:12): [True: 0, False: 6]
 
  Branch (176:12): [True: 2, False: 5]
 
177
2
            return None;
178
11
        }
179
11
        let new_pos = i32::from(self.cursor_pos) + offset;
180
11
        if new_pos < 0 || usize::try_from(new_pos).map_or(true, |p| p >= self.value.len()) {
  Branch (180:12): [True: 0, False: 6]
   Branch (180:27): [True: 0, False: 6]
@@ -36,13 +36,13 @@
   Branch (180:27): [True: 1, False: 4]
 
181
1
            return None;
182
10
        }
183
10
        let pos = self.value.floor_char_boundary(new_pos.unsigned_abs() as usize);
184
10
        let ch = self.value.remove(pos);
185
        // Only move cursor if deleting backwards
186
10
        if offset < 0 {
  Branch (186:12): [True: 6, False: 0]
 
  Branch (186:12): [True: 2, False: 2]
-
187
8
            self.move_cursor(-1);
188
8
        
}2
189
10
        Some(ch)
190
13
    }
191
383
    pub fn move_cursor(&mut self, offset: i32) {
192
383
        if offset == 0 {
  Branch (192:12): [True: 0, False: 353]
+
187
8
            self.move_cursor(-1);
188
8
        
}2
189
10
        Some(ch)
190
13
    }
191
382
    pub fn move_cursor(&mut self, offset: i32) {
192
382
        if offset == 0 {
  Branch (192:12): [True: 0, False: 352]
 
  Branch (192:12): [True: 1, False: 29]
-
193
1
            return;
194
382
        }
195
382
        if offset < 0 {
  Branch (195:12): [True: 11, False: 342]
+
193
1
            return;
194
381
        }
195
381
        if offset < 0 {
  Branch (195:12): [True: 11, False: 341]
 
  Branch (195:12): [True: 4, False: 25]
-
196
15
            let new_pos = (i32::from(self.cursor_pos) + offset).max(0).unsigned_abs() as usize;
197
15
            self.move_cursor_to(u16::try_from(self.value.floor_char_boundary(new_pos)).unwrap_or(u16::MAX));
198
367
        } else {
199
367
            let new_pos = (i32::from(self.cursor_pos) + offset).unsigned_abs() as usize;
200
367
            self.move_cursor_to(u16::try_from(self.value.ceil_char_boundary(new_pos)).unwrap_or(u16::MAX));
201
367
        }
202
383
    }
203
428
    pub fn move_cursor_to(&mut self, pos: u16) {
204
428
        if self.value.is_char_boundary(pos as usize) {
  Branch (204:12): [True: 370, False: 0]
+
196
15
            let new_pos = (i32::from(self.cursor_pos) + offset).max(0).unsigned_abs() as usize;
197
15
            self.move_cursor_to(u16::try_from(self.value.floor_char_boundary(new_pos)).unwrap_or(u16::MAX));
198
366
        } else {
199
366
            let new_pos = (i32::from(self.cursor_pos) + offset).unsigned_abs() as usize;
200
366
            self.move_cursor_to(u16::try_from(self.value.ceil_char_boundary(new_pos)).unwrap_or(u16::MAX));
201
366
        }
202
382
    }
203
427
    pub fn move_cursor_to(&mut self, pos: u16) {
204
427
        if self.value.is_char_boundary(pos as usize) {
  Branch (204:12): [True: 369, False: 0]
 
  Branch (204:12): [True: 58, False: 0]
-
205
428
            self.cursor_pos = u16::clamp(pos, 0, u16::try_from(self.value.len()).unwrap_or(u16::MAX));
206
428
        } else {
207
0
            warn!("Invalid cursor pos");
208
        }
209
428
    }
210
26
    pub fn move_to_end(&mut self) {
211
26
        self.move_cursor_to(
212
26
            self.value
213
26
                .len()
214
26
                .try_into()
215
26
                .expect("Failed to fit input len into an u16"),
216
        );
217
26
    }
218
219
    /// Check if a character is a word character (alphanumeric only)
220
118
    fn is_word_char(ch: char) -> bool {
221
118
        ch.is_alphanumeric()
222
118
    }
223
224
    /// Find the position of the end of the next word (alphanumeric boundaries for deletion)
225
4
    fn find_next_word_end(&self, start_pos: usize) -> usize {
226
4
        let mut pos = start_pos;
227
228
        // Skip any non-word characters
229
7
        while pos < self.nchars() {
  Branch (229:15): [True: 2, False: 0]
+
205
427
            self.cursor_pos = u16::clamp(pos, 0, u16::try_from(self.value.len()).unwrap_or(u16::MAX));
206
427
        } else {
207
0
            warn!("Invalid cursor pos");
208
        }
209
427
    }
210
26
    pub fn move_to_end(&mut self) {
211
26
        self.move_cursor_to(
212
26
            self.value
213
26
                .len()
214
26
                .try_into()
215
26
                .expect("Failed to fit input len into an u16"),
216
        );
217
26
    }
218
219
    /// Check if a character is a word character (alphanumeric only)
220
118
    fn is_word_char(ch: char) -> bool {
221
118
        ch.is_alphanumeric()
222
118
    }
223
224
    /// Find the position of the end of the next word (alphanumeric boundaries for deletion)
225
4
    fn find_next_word_end(&self, start_pos: usize) -> usize {
226
4
        let mut pos = start_pos;
227
228
        // Skip any non-word characters
229
7
        while pos < self.nchars() {
  Branch (229:15): [True: 2, False: 0]
 
  Branch (229:15): [True: 5, False: 0]
 
230
7
            let ch = self.value.chars().nth(pos).unwrap();
231
7
            if Self::is_word_char(ch) {
  Branch (231:16): [True: 2, False: 0]
 
  Branch (231:16): [True: 2, False: 3]
@@ -92,22 +92,22 @@
   Branch (356:26): [True: 8, False: 2]
 
357
62
            pos -= 1;
358
62
        }
359
360
21
        let deleted = self.value[pos..self.cursor_pos as usize].to_string();
361
21
        self.value = format!("{}{}", &self.value[..pos], &self.value[self.cursor_pos as usize..]);
362
21
        self.cursor_pos = u16::try_from(pos).unwrap_or(u16::MAX);
363
21
        deleted
364
23
    }
365
366
5
    pub fn delete_forward_word(&mut self) -> String {
367
5
        if self.cursor_pos as usize >= self.value.len() {
  Branch (367:12): [True: 0, False: 2]
 
  Branch (367:12): [True: 1, False: 2]
-
368
1
            return String::new();
369
4
        }
370
4
        let end_pos = self.find_next_word_end(self.cursor_pos as usize);
371
4
        let deleted = self.value[self.cursor_pos as usize..end_pos].to_string();
372
4
        self.value = format!("{}{}", &self.value[..self.cursor_pos as usize], &self.value[end_pos..]);
373
4
        deleted
374
5
    }
375
7
    pub fn move_cursor_forward_word(&mut self) {
376
7
        let new_pos = self.find_compound_word_end(self.cursor_pos as usize);
377
7
        self.cursor_pos = u16::try_from(new_pos).unwrap_or(u16::MAX);
378
7
    }
379
380
13
    pub fn move_cursor_backward_word(&mut self) {
381
13
        let new_pos = self.find_prev_word_start(self.cursor_pos as usize);
382
13
        self.cursor_pos = u16::try_from(new_pos).unwrap_or(u16::MAX);
383
13
    }
384
6
    pub fn delete_to_beginning(&mut self) -> String {
385
6
        let deleted = self.value[..self.cursor_pos as usize].to_string();
386
6
        self.value = self.value[self.cursor_pos as usize..].to_string();
387
6
        self.cursor_pos = 0;
388
6
        deleted
389
6
    }
390
2.68k
    pub fn cursor_pos(&self) -> u16 {
391
2.68k
        (display_width(&self.value[..(self.cursor_pos as usize)]) + display_width(&strip_ansi(&self.prompt).0))
392
2.68k
            .try_into()
393
2.68k
            .expect("Failed to fit cursor char into an u16")
394
2.68k
    }
395
7
    pub fn switch_mode(&mut self) {
396
7
        std::mem::swap(&mut self.prompt, &mut self.alternate_prompt);
397
7
        std::mem::swap(&mut self.value, &mut self.alternate_value);
398
7
        std::mem::swap(&mut self.cursor_pos, &mut self.alternate_cursor_pos);
399
7
    }
400
}
401
402
impl SkimWidget for Input {
403
631
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
404
631
        let mut res = Self {
405
631
            theme,
406
631
            border: options.border,
407
631
            info: options.info.clone(),
408
631
            reverse: options.layout == TuiLayout::Reverse,
409
631
            prompt: String::new(),
410
631
            alternate_prompt: String::new(),
411
631
            value: String::new(),
412
631
            alternate_value: String::new(),
413
631
            cursor_pos: 0,
414
631
            alternate_cursor_pos: 0,
415
631
            status_info: None,
416
631
        };
417
631
        if options.interactive {
  Branch (417:12): [True: 31, False: 426]
+
368
1
            return String::new();
369
4
        }
370
4
        let end_pos = self.find_next_word_end(self.cursor_pos as usize);
371
4
        let deleted = self.value[self.cursor_pos as usize..end_pos].to_string();
372
4
        self.value = format!("{}{}", &self.value[..self.cursor_pos as usize], &self.value[end_pos..]);
373
4
        deleted
374
5
    }
375
7
    pub fn move_cursor_forward_word(&mut self) {
376
7
        let new_pos = self.find_compound_word_end(self.cursor_pos as usize);
377
7
        self.cursor_pos = u16::try_from(new_pos).unwrap_or(u16::MAX);
378
7
    }
379
380
13
    pub fn move_cursor_backward_word(&mut self) {
381
13
        let new_pos = self.find_prev_word_start(self.cursor_pos as usize);
382
13
        self.cursor_pos = u16::try_from(new_pos).unwrap_or(u16::MAX);
383
13
    }
384
6
    pub fn delete_to_beginning(&mut self) -> String {
385
6
        let deleted = self.value[..self.cursor_pos as usize].to_string();
386
6
        self.value = self.value[self.cursor_pos as usize..].to_string();
387
6
        self.cursor_pos = 0;
388
6
        deleted
389
6
    }
390
2.67k
    pub fn cursor_pos(&self) -> u16 {
391
2.67k
        (display_width(&self.value[..(self.cursor_pos as usize)]) + display_width(&strip_ansi(&self.prompt).0))
392
2.67k
            .try_into()
393
2.67k
            .expect("Failed to fit cursor char into an u16")
394
2.67k
    }
395
7
    pub fn switch_mode(&mut self) {
396
7
        std::mem::swap(&mut self.prompt, &mut self.alternate_prompt);
397
7
        std::mem::swap(&mut self.value, &mut self.alternate_value);
398
7
        std::mem::swap(&mut self.cursor_pos, &mut self.alternate_cursor_pos);
399
7
    }
400
}
401
402
impl SkimWidget for Input {
403
631
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
404
631
        let mut res = Self {
405
631
            theme,
406
631
            border: options.border,
407
631
            info: options.info.clone(),
408
631
            reverse: options.layout == TuiLayout::Reverse,
409
631
            prompt: String::new(),
410
631
            alternate_prompt: String::new(),
411
631
            value: String::new(),
412
631
            alternate_value: String::new(),
413
631
            cursor_pos: 0,
414
631
            alternate_cursor_pos: 0,
415
631
            status_info: None,
416
631
        };
417
631
        if options.interactive {
  Branch (417:12): [True: 30, False: 427]
 
  Branch (417:12): [True: 1, False: 173]
-
418
32
            res.prompt.clone_from(&options.cmd_prompt);
419
32
            res.alternate_prompt.clone_from(&options.prompt);
420
32
            res.value = options.cmd_query.clone().unwrap_or_default();
421
32
            res.alternate_value = options.query.clone().unwrap_or_default();
422
599
        } else {
423
599
            res.prompt.clone_from(&options.prompt);
424
599
            res.alternate_prompt.clone_from(&options.cmd_prompt);
425
599
            res.value = options.query.clone().unwrap_or_default();
426
599
            res.alternate_value = options.cmd_query.clone().unwrap_or_default();
427
599
        }
428
631
        res.cursor_pos = u16::try_from(res.value.len()).unwrap_or(u16::MAX);
429
631
        res.alternate_cursor_pos = u16::try_from(res.alternate_value.len()).unwrap_or(u16::MAX);
430
631
        res
431
631
    }
432
433
2.68k
    fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender {
434
        use ratatui::layout::Alignment;
435
        use ratatui::text::{Line, Span};
436
        use ratatui::widgets::{Block, Borders, Paragraph};
437
438
2.68k
        let mut line = self.prompt.into_text().map_or_else(
439
0
            |_| Line::from(self.prompt.as_str()),
440
2.68k
            |t| t.lines.into_iter().next().unwrap_or_default(),
441
        );
442
2.68k
        style_line(&mut line, self.theme.prompt);
443
2.68k
        line.push_span(Span::styled(&self.value, self.theme.query));
444
445
2.68k
        let mut block = Block::default();
446
447
        // Add borders if enabled
448
2.68k
        if let Some(
border_type63
) = self.border.into_ratatui() {
  Branch (448:16): [True: 63, False: 2.59k]
+
418
31
            res.prompt.clone_from(&options.cmd_prompt);
419
31
            res.alternate_prompt.clone_from(&options.prompt);
420
31
            res.value = options.cmd_query.clone().unwrap_or_default();
421
31
            res.alternate_value = options.query.clone().unwrap_or_default();
422
600
        } else {
423
600
            res.prompt.clone_from(&options.prompt);
424
600
            res.alternate_prompt.clone_from(&options.cmd_prompt);
425
600
            res.value = options.query.clone().unwrap_or_default();
426
600
            res.alternate_value = options.cmd_query.clone().unwrap_or_default();
427
600
        }
428
631
        res.cursor_pos = u16::try_from(res.value.len()).unwrap_or(u16::MAX);
429
631
        res.alternate_cursor_pos = u16::try_from(res.alternate_value.len()).unwrap_or(u16::MAX);
430
631
        res
431
631
    }
432
433
2.67k
    fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender {
434
        use ratatui::layout::Alignment;
435
        use ratatui::text::{Line, Span};
436
        use ratatui::widgets::{Block, Borders, Paragraph};
437
438
2.67k
        let mut line = self.prompt.into_text().map_or_else(
439
0
            |_| Line::from(self.prompt.as_str()),
440
2.67k
            |t| t.lines.into_iter().next().unwrap_or_default(),
441
        );
442
2.67k
        style_line(&mut line, self.theme.prompt);
443
2.67k
        line.push_span(Span::styled(&self.value, self.theme.query));
444
445
2.67k
        let mut block = Block::default();
446
447
        // Add borders if enabled
448
2.67k
        if let Some(
border_type66
) = self.border.into_ratatui() {
  Branch (448:16): [True: 66, False: 2.58k]
 
  Branch (448:16): [True: 0, False: 29]
-
449
63
            block = block
450
63
                .borders(Borders::ALL)
451
63
                .border_type(border_type)
452
63
                .border_style(self.theme.border);
453
2.61k
        }
454
455
        // Handle different info display modes
456
2.68k
        match self.info.display {
457
            InfoDisplay::Inline | InfoDisplay::InlineRight => {
458
                // Inline mode: render status on the same line as input
459
                // Format: prompt + value + " " + separator_char + " " + status + padding + right_status
460
                // separator_char is spinner when active, '<' otherwise
461
47
                if let Some(ref status) = self.status_info {
  Branch (461:24): [True: 47, False: 0]
+
449
66
            block = block
450
66
                .borders(Borders::ALL)
451
66
                .border_type(border_type)
452
66
                .border_style(self.theme.border);
453
2.60k
        }
454
455
        // Handle different info display modes
456
2.67k
        match self.info.display {
457
            InfoDisplay::Inline | InfoDisplay::InlineRight => {
458
                // Inline mode: render status on the same line as input
459
                // Format: prompt + value + " " + separator_char + " " + status + padding + right_status
460
                // separator_char is spinner when active, '<' otherwise
461
47
                if let Some(ref status) = self.status_info {
  Branch (461:24): [True: 47, False: 0]
 
  Branch (461:24): [True: 0, False: 0]
 
462
47
                    let separator = status.inline_separator_or_spinner();
463
47
                    let inline_status = status.inline_status();
464
47
                    let right_status = status.right_title();
465
466
                    // Calculate available width for padding
467
                    // Format: " X " where X is separator (3 chars total)
468
47
                    let prompt_width = display_width(&self.prompt);
469
47
                    let value_width = display_width(&self.value);
470
47
                    let separator_width = display_width(&separator); // "  X " (2xspace + separator + space)
471
47
                    let inline_status_width = display_width(&inline_status);
472
47
                    let right_status_width = display_width(&right_status);
473
474
47
                    let used_width =
475
47
                        prompt_width + value_width + separator_width + inline_status_width + right_status_width;
476
47
                    let available_width = u64::from(area.width);
477
47
                    let padding_width = available_width.saturating_sub(used_width);
478
479
47
                    if self.info.display == InfoDisplay::InlineRight {
  Branch (479:24): [True: 14, False: 33]
 
  Branch (479:24): [True: 0, False: 0]
 
480
14
                        line.push_span(Span::raw(" ".repeat(usize::try_from(padding_width).unwrap() - 2)));
481
33
                    }
482
47
                    line.push_span(Span::styled(separator, self.theme.info));
483
47
                    line.push_span(Span::styled(inline_status, self.theme.info));
484
47
                    if self.info.display == InfoDisplay::Inline {
  Branch (484:24): [True: 33, False: 14]
 
  Branch (484:24): [True: 0, False: 0]
-
485
33
                        line.push_span(Span::raw(" ".repeat(padding_width.try_into().unwrap())));
486
33
                    } else {
487
14
                        line.push_span(Span::raw(" ".repeat(2)));
488
14
                    }
489
47
                    line.push_span(Span::styled(right_status, self.theme.info));
490
491
47
                    Paragraph::new(line)
492
47
                        .block(block)
493
47
                        .style(self.theme.normal)
494
47
                        .render(area, buf);
495
0
                } else {
496
0
                    // No status info, just render input
497
0
                    Paragraph::new(line)
498
0
                        .block(block)
499
0
                        .style(self.theme.normal)
500
0
                        .render(area, buf);
501
0
                }
502
            }
503
            InfoDisplay::Default | InfoDisplay::Left | InfoDisplay::Right => {
504
                // Default mode: render status as block title (separate line)
505
                // In normal layout: status above input (title_top)
506
                // In reverse layout: status below input (title_bottom)
507
                //
508
                // Left and Right modes pack both titles together at the corresponding edge.
509
2.57k
                if let Some(
ref status2.57k
) = self.status_info {
  Branch (509:24): [True: 2.54k, False: 0]
+
485
33
                        line.push_span(Span::raw(" ".repeat(padding_width.try_into().unwrap())));
486
33
                    } else {
487
14
                        line.push_span(Span::raw(" ".repeat(2)));
488
14
                    }
489
47
                    line.push_span(Span::styled(right_status, self.theme.info));
490
491
47
                    Paragraph::new(line)
492
47
                        .block(block)
493
47
                        .style(self.theme.normal)
494
47
                        .render(area, buf);
495
0
                } else {
496
0
                    // No status info, just render input
497
0
                    Paragraph::new(line)
498
0
                        .block(block)
499
0
                        .style(self.theme.normal)
500
0
                        .render(area, buf);
501
0
                }
502
            }
503
            InfoDisplay::Default | InfoDisplay::Left | InfoDisplay::Right => {
504
                // Default mode: render status as block title (separate line)
505
                // In normal layout: status above input (title_top)
506
                // In reverse layout: status below input (title_bottom)
507
                //
508
                // Left and Right modes pack both titles together at the corresponding edge.
509
2.56k
                if let Some(
ref status2.56k
) = self.status_info {
  Branch (509:24): [True: 2.54k, False: 0]
 
  Branch (509:24): [True: 28, False: 1]
-
510
2.57k
                    let left_title = status.left_title();
511
2.57k
                    let right_title = status.right_title();
512
513
2.57k
                    if 
matches!2.56k
(self.info.display, InfoDisplay::Left | InfoDisplay::Right) {
514
14
                        let alignment = if self.info.display == InfoDisplay::Left {
  Branch (514:44): [True: 7, False: 7]
+
510
2.56k
                    let left_title = status.left_title();
511
2.56k
                    let right_title = status.right_title();
512
513
2.56k
                    if 
matches!2.55k
(self.info.display, InfoDisplay::Left | InfoDisplay::Right) {
514
14
                        let alignment = if self.info.display == InfoDisplay::Left {
  Branch (514:44): [True: 7, False: 7]
 
  Branch (514:44): [True: 0, False: 0]
 
515
7
                            Alignment::Left
516
                        } else {
517
7
                            Alignment::Right
518
                        };
519
14
                        let title = Line::from(format!("{left_title}  {right_title}"))
520
14
                            .style(self.theme.info)
521
14
                            .alignment(alignment);
522
14
                        block = if self.reverse {
  Branch (522:36): [True: 0, False: 14]
 
  Branch (522:36): [True: 0, False: 0]
-
523
0
                            block.title_bottom(title)
524
                        } else {
525
14
                            block.title_top(title)
526
                        };
527
                    } else {
528
2.56k
                        let info_line = Line::from(left_title).style(self.theme.info).alignment(Alignment::Left);
529
2.56k
                        let index_line = Line::from(right_title)
530
2.56k
                            .style(self.theme.info)
531
2.56k
                            .alignment(Alignment::Right);
532
2.56k
                        block = if self.reverse {
  Branch (532:36): [True: 39, False: 2.49k]
+
523
0
                            block.title_bottom(title)
524
                        } else {
525
14
                            block.title_top(title)
526
                        };
527
                    } else {
528
2.55k
                        let info_line = Line::from(left_title).style(self.theme.info).alignment(Alignment::Left);
529
2.55k
                        let index_line = Line::from(right_title)
530
2.55k
                            .style(self.theme.info)
531
2.55k
                            .alignment(Alignment::Right);
532
2.55k
                        block = if self.reverse {
  Branch (532:36): [True: 38, False: 2.48k]
 
  Branch (532:36): [True: 0, False: 28]
-
533
39
                            block.title_bottom(info_line).title_bottom(index_line)
534
                        } else {
535
2.52k
                            block.title_top(info_line).title_top(index_line)
536
                        };
537
                    }
538
1
                }
539
540
2.57k
                Paragraph::new(line)
541
2.57k
                    .block(block)
542
2.57k
                    .style(self.theme.normal)
543
2.57k
                    .render(area, buf);
544
            }
545
59
            InfoDisplay::Hidden => {
546
59
                // Hidden mode: no status displayed
547
59
                Paragraph::new(line)
548
59
                    .block(block)
549
59
                    .style(self.theme.normal)
550
59
                    .render(area, buf);
551
59
            }
552
        }
553
554
2.68k
        SkimRender::default()
555
2.68k
    }
556
}
557
558
impl Deref for Input {
559
    type Target = String;
560
561
888
    fn deref(&self) -> &Self::Target {
562
888
        &self.value
563
888
    }
564
}
565
566
impl DerefMut for Input {
567
1
    fn deref_mut(&mut self) -> &mut Self::Target {
568
1
        &mut self.value
569
1
    }
570
}
571
572
#[cfg(test)]
573
#[path = "input_tests.rs"]
574
mod tests;
\ No newline at end of file +
533
38
                            block.title_bottom(info_line).title_bottom(index_line)
534
                        } else {
535
2.51k
                            block.title_top(info_line).title_top(index_line)
536
                        };
537
                    }
538
1
                }
539
540
2.56k
                Paragraph::new(line)
541
2.56k
                    .block(block)
542
2.56k
                    .style(self.theme.normal)
543
2.56k
                    .render(area, buf);
544
            }
545
59
            InfoDisplay::Hidden => {
546
59
                // Hidden mode: no status displayed
547
59
                Paragraph::new(line)
548
59
                    .block(block)
549
59
                    .style(self.theme.normal)
550
59
                    .render(area, buf);
551
59
            }
552
        }
553
554
2.67k
        SkimRender::default()
555
2.67k
    }
556
}
557
558
impl Deref for Input {
559
    type Target = String;
560
561
873
    fn deref(&self) -> &Self::Target {
562
873
        &self.value
563
873
    }
564
}
565
566
impl DerefMut for Input {
567
1
    fn deref_mut(&mut self) -> &mut Self::Target {
568
1
        &mut self.value
569
1
    }
570
}
571
572
#[cfg(test)]
573
#[path = "input_tests.rs"]
574
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/item_list.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/item_list.rs.html index cd725e48..db5aa6fc 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/tui/item_list.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/item_list.rs.html @@ -1,8 +1,8 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/item_list.rs
Line
Count
Source
1
use std::rc::Rc;
2
use std::sync::Arc;
3
4
use indexmap::IndexSet;
5
use ratatui::widgets::{
6
    Block, Borders, Clear, List, ListDirection, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState,
7
    StatefulWidget, Widget,
8
};
9
use regex::Regex;
10
11
use crate::item::MatchedItem;
12
use crate::options::feature_flag;
13
use crate::spinlock::SpinLock;
14
use crate::theme::ColorTheme;
15
use crate::tui::BorderType;
16
use crate::tui::item_renderer::ItemRenderer;
17
use crate::tui::options::TuiLayout;
18
use crate::tui::widget::{SkimRender, SkimWidget};
19
use crate::{Selector, SkimOptions};
20
21
/// How to apply processed items to the display list
22
#[derive(Default, Clone, Copy)]
23
pub(crate) enum MergeStrategy {
24
    /// Replace the entire item list (full re-match or first result)
25
    #[default]
26
    Replace,
27
    /// Merge into existing list using sorted merge by rank
28
    SortedMerge,
29
    /// Append to existing list without sorting (for --no-sort)
30
    Append,
31
    /// Prepend to existing list without sorting (for --tac --no-sort)
32
    Prepend,
33
}
34
35
/// Processed items ready for rendering
36
pub(crate) struct ProcessedItems {
37
    pub(crate) items: Vec<MatchedItem>,
38
    pub(crate) merge: MergeStrategy,
39
}
40
41
impl Default for ProcessedItems {
42
1
    fn default() -> Self {
43
1
        Self {
44
1
            items: Vec::new(),
45
1
            merge: MergeStrategy::Replace,
46
1
        }
47
1
    }
48
}
49
50
/// Widget for displaying and managing the list of filtered items
51
#[allow(clippy::struct_excessive_bools)]
52
pub struct ItemList {
53
    pub(crate) items: Vec<MatchedItem>,
54
    pub(crate) selection: IndexSet<MatchedItem>,
55
    pub(crate) processed_items: Arc<SpinLock<Option<ProcessedItems>>>,
56
    pub(crate) direction: ListDirection,
57
    pub(crate) offset: usize,
58
    /// How many leading sub-lines of items[offset] have been scrolled off the top.
59
    /// Only meaningful when multiline is active; always 0 otherwise.
60
    sub_offset: usize,
61
    pub(crate) current: usize,
62
    pub(crate) height: u16,
63
    pub(crate) theme: std::sync::Arc<crate::theme::ColorTheme>,
64
    pub(crate) multi_select: bool,
65
    reserved: usize,
66
    pub(crate) no_hscroll: bool,
67
    pub(crate) ellipsis: String,
68
    pub(crate) keep_right: bool,
69
    pub(crate) skip_to_pattern: Option<Regex>,
70
    pub(crate) tabstop: usize,
71
    selector: Option<Rc<dyn Selector>>,
72
    pre_select_target: usize, // How many items we want to pre-select
73
    no_clear_if_empty: bool,
74
    interactive: bool,              // Whether we're in interactive mode
75
    showing_stale_items: bool,      // True when displaying old items due to no_clear_if_empty
76
    pub(crate) manual_hscroll: i32, // Manual horizontal scroll offset for ScrollLeft/ScrollRight
77
    pub(crate) selector_icon: String,
78
    pub(crate) multi_select_icon: String,
79
    cycle: bool,
80
    pub(crate) wrap: bool,
81
    /// When Some, split item text on this separator and show each part on its own line
82
    pub(crate) multiline: Option<String>,
83
    /// Border type
84
    pub border: BorderType,
85
    /// When true, prepend each item's match score to its display text
86
    pub(crate) show_score: bool,
87
    pub(crate) show_index: bool,
88
    /// When true, highlight the entire current line (not just the matched text)
89
    pub(crate) highlight_line: bool,
90
    /// Scrollbar display configuration
91
    pub(crate) scrollbar_thumb: String,
92
}
93
94
impl Default for ItemList {
95
24
    fn default() -> Self {
96
24
        Self::_default()
97
24
    }
98
}
99
100
impl ItemList {
101
9.56k
    fn cursor(&self) -> usize {
102
9.56k
        self.current
103
9.56k
    }
104
105
    /// Returns the count of items for status display.
106
    ///
107
    /// This may differ from `items.len()` when `no_clear_if_empty` is active and showing stale items
108
    #[must_use]
109
2.62k
    pub fn count(&self) -> usize {
110
2.62k
        if self.showing_stale_items { 
05
} else {
self.items2.61k
.
len2.61k
() }
  Branch (110:12): [True: 4, False: 2.59k]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/item_list.rs
Line
Count
Source
1
use std::rc::Rc;
2
use std::sync::Arc;
3
4
use indexmap::IndexSet;
5
use ratatui::widgets::{
6
    Block, Borders, Clear, List, ListDirection, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState,
7
    StatefulWidget, Widget,
8
};
9
use regex::Regex;
10
11
use crate::item::MatchedItem;
12
use crate::options::feature_flag;
13
use crate::spinlock::SpinLock;
14
use crate::theme::ColorTheme;
15
use crate::tui::BorderType;
16
use crate::tui::item_renderer::ItemRenderer;
17
use crate::tui::options::TuiLayout;
18
use crate::tui::widget::{SkimRender, SkimWidget};
19
use crate::{Selector, SkimOptions};
20
21
/// How to apply processed items to the display list
22
#[derive(Default, Clone, Copy)]
23
pub(crate) enum MergeStrategy {
24
    /// Replace the entire item list (full re-match or first result)
25
    #[default]
26
    Replace,
27
    /// Merge into existing list using sorted merge by rank
28
    SortedMerge,
29
    /// Append to existing list without sorting (for --no-sort)
30
    Append,
31
    /// Prepend to existing list without sorting (for --tac --no-sort)
32
    Prepend,
33
}
34
35
/// Processed items ready for rendering
36
pub(crate) struct ProcessedItems {
37
    pub(crate) items: Vec<MatchedItem>,
38
    pub(crate) merge: MergeStrategy,
39
}
40
41
impl Default for ProcessedItems {
42
1
    fn default() -> Self {
43
1
        Self {
44
1
            items: Vec::new(),
45
1
            merge: MergeStrategy::Replace,
46
1
        }
47
1
    }
48
}
49
50
/// Widget for displaying and managing the list of filtered items
51
#[allow(clippy::struct_excessive_bools)]
52
pub struct ItemList {
53
    pub(crate) items: Vec<MatchedItem>,
54
    pub(crate) selection: IndexSet<MatchedItem>,
55
    pub(crate) processed_items: Arc<SpinLock<Option<ProcessedItems>>>,
56
    pub(crate) direction: ListDirection,
57
    pub(crate) offset: usize,
58
    /// How many leading sub-lines of items[offset] have been scrolled off the top.
59
    /// Only meaningful when multiline is active; always 0 otherwise.
60
    sub_offset: usize,
61
    pub(crate) current: usize,
62
    pub(crate) height: u16,
63
    pub(crate) theme: std::sync::Arc<crate::theme::ColorTheme>,
64
    pub(crate) multi_select: bool,
65
    reserved: usize,
66
    pub(crate) no_hscroll: bool,
67
    pub(crate) ellipsis: String,
68
    pub(crate) keep_right: bool,
69
    pub(crate) skip_to_pattern: Option<Regex>,
70
    pub(crate) tabstop: usize,
71
    selector: Option<Rc<dyn Selector>>,
72
    pre_select_target: usize, // How many items we want to pre-select
73
    no_clear_if_empty: bool,
74
    interactive: bool,              // Whether we're in interactive mode
75
    showing_stale_items: bool,      // True when displaying old items due to no_clear_if_empty
76
    pub(crate) manual_hscroll: i32, // Manual horizontal scroll offset for ScrollLeft/ScrollRight
77
    pub(crate) selector_icon: String,
78
    pub(crate) multi_select_icon: String,
79
    cycle: bool,
80
    pub(crate) wrap: bool,
81
    /// When Some, split item text on this separator and show each part on its own line
82
    pub(crate) multiline: Option<String>,
83
    /// Border type
84
    pub border: BorderType,
85
    /// When true, prepend each item's match score to its display text
86
    pub(crate) show_score: bool,
87
    pub(crate) show_index: bool,
88
    /// When true, highlight the entire current line (not just the matched text)
89
    pub(crate) highlight_line: bool,
90
    /// Scrollbar display configuration
91
    pub(crate) scrollbar_thumb: String,
92
}
93
94
impl Default for ItemList {
95
24
    fn default() -> Self {
96
24
        Self::_default()
97
24
    }
98
}
99
100
impl ItemList {
101
9.54k
    fn cursor(&self) -> usize {
102
9.54k
        self.current
103
9.54k
    }
104
105
    /// Returns the count of items for status display.
106
    ///
107
    /// This may differ from `items.len()` when `no_clear_if_empty` is active and showing stale items
108
    #[must_use]
109
2.61k
    pub fn count(&self) -> usize {
110
2.61k
        if self.showing_stale_items { 
05
} else {
self.items2.61k
.
len2.61k
() }
  Branch (110:12): [True: 4, False: 2.58k]
 
  Branch (110:12): [True: 1, False: 28]
-
111
2.62k
    }
112
113
    /// Returns the currently selected item, if any
114
    #[must_use]
115
9.54k
    pub fn selected(&self) -> Option<MatchedItem> {
116
9.54k
        let item = self.items.get(self.cursor());
117
9.54k
        if item.is_some_and(|i| !
i.item.disabled()6.77k
) {
  Branch (117:12): [True: 6.62k, False: 2.73k]
-
  Branch (117:12): [True: 147, False: 35]
-
118
6.77k
            item.cloned()
119
        } else {
120
2.76k
            None
121
        }
122
9.54k
    }
123
124
    /// Appends new matched items to the list
125
83
    pub fn append(&mut self, items: &mut Vec<MatchedItem>) {
126
83
        self.items.append(items);
127
83
        self.showing_stale_items = false;
128
83
    }
129
130
    /// Prepends a batch while preserving either the head-following behavior or
131
    /// the item currently focused by a user who has moved away from the head.
132
2
    fn prepend(&mut self, mut items: Vec<MatchedItem>) {
133
2
        if items.is_empty() {
  Branch (133:12): [True: 0, False: 0]
+
111
2.61k
    }
112
113
    /// Returns the currently selected item, if any
114
    #[must_use]
115
9.51k
    pub fn selected(&self) -> Option<MatchedItem> {
116
9.51k
        let item = self.items.get(self.cursor());
117
9.51k
        if item.is_some_and(|i| !
i.item.disabled()6.76k
) {
  Branch (117:12): [True: 6.61k, False: 2.72k]
+
  Branch (117:12): [True: 153, False: 29]
+
118
6.76k
            item.cloned()
119
        } else {
120
2.74k
            None
121
        }
122
9.51k
    }
123
124
    /// Appends new matched items to the list
125
83
    pub fn append(&mut self, items: &mut Vec<MatchedItem>) {
126
83
        self.items.append(items);
127
83
        self.showing_stale_items = false;
128
83
    }
129
130
    /// Prepends a batch while preserving either the head-following behavior or
131
    /// the item currently focused by a user who has moved away from the head.
132
2
    fn prepend(&mut self, mut items: Vec<MatchedItem>) {
133
2
        if items.is_empty() {
  Branch (133:12): [True: 0, False: 0]
 
  Branch (133:12): [True: 0, False: 2]
 
134
0
            return;
135
2
        }
136
137
2
        let added = items.len();
138
2
        let follows_head = self.current == 0;
139
2
        items.append(&mut self.items);
140
2
        self.items = items;
141
142
2
        if follows_head {
  Branch (142:12): [True: 0, False: 0]
 
  Branch (142:12): [True: 1, False: 1]
@@ -31,8 +31,8 @@
 
242
0
            return;
243
90
        }
244
90
        let reserved = i32::try_from(self.reserved).unwrap_or(0);
245
90
        let total = i32::try_from(self.items.len()).unwrap_or(i32::MAX);
246
90
        let mut new = i32::try_from(self.current).unwrap_or(0) + offset;
247
90
        if self.cycle {
  Branch (247:12): [True: 6, False: 62]
 
  Branch (247:12): [True: 2, False: 20]
 
248
8
            let n = total - reserved;
249
8
            new = reserved + (new + n - reserved) % n;
250
82
        } else {
251
82
            new = new.min(total - 1).max(reserved);
252
82
        }
253
90
        self.current = new.max(0).unsigned_abs() as usize;
254
90
        debug!("Scrolled to {}", self.current);
255
90
        debug!("Selection: {:?}", self.selection);
256
90
    }
257
    /// Selects the previous item in the list
258
4
    pub fn select_previous(&mut self) {
259
4
        self.scroll_by(-1);
260
4
    }
261
    /// Selects the next item in the list
262
4
    pub fn select_next(&mut self) {
263
4
        self.scroll_by(1);
264
4
    }
265
    /// Jump to the first selectable item (respecting reserved header lines)
266
10
    pub fn jump_to_first(&mut self) {
267
10
        if self.items.len() > self.reserved {
  Branch (267:12): [True: 5, False: 0]
-
  Branch (267:12): [True: 4, False: 1]
-
268
9
            self.current = self.reserved;
269
9
            self.offset = self.reserved;
270
9
            self.sub_offset = 0;
271
9
        
}1
272
10
    }
273
    /// Given a terminal row within the list's rendered inner area (0 = topmost row),
274
    /// return the index of the item that occupies that row, or `None` if the row is
275
    /// empty (e.g. fewer items than the available height).
276
    ///
277
    /// Respects `direction`: in `BottomToTop` layouts item `offset` occupies the
278
    /// bottom row, so row 0 (top) maps to the highest-indexed visible item.
279
    #[must_use]
280
13
    pub fn item_at_visual_row(&self, row: usize) -> Option<usize> {
281
13
        let available_rows = self.height as usize;
282
13
        if row >= available_rows || 
self.items11
.
is_empty11
() {
  Branch (282:12): [True: 0, False: 1]
+
  Branch (267:12): [True: 5, False: 0]
+
268
10
            self.current = self.reserved;
269
10
            self.offset = self.reserved;
270
10
            self.sub_offset = 0;
271
10
        
}0
272
10
    }
273
    /// Given a terminal row within the list's rendered inner area (0 = topmost row),
274
    /// return the index of the item that occupies that row, or `None` if the row is
275
    /// empty (e.g. fewer items than the available height).
276
    ///
277
    /// Respects `direction`: in `BottomToTop` layouts item `offset` occupies the
278
    /// bottom row, so row 0 (top) maps to the highest-indexed visible item.
279
    #[must_use]
280
13
    pub fn item_at_visual_row(&self, row: usize) -> Option<usize> {
281
13
        let available_rows = self.height as usize;
282
13
        if row >= available_rows || 
self.items11
.
is_empty11
() {
  Branch (282:12): [True: 0, False: 1]
   Branch (282:37): [True: 0, False: 1]
 
  Branch (282:12): [True: 2, False: 10]
   Branch (282:37): [True: 0, False: 10]
@@ -44,83 +44,83 @@
 
  Branch (303:16): [True: 0, False: 12]
 
304
0
                break;
305
13
            }
306
        }
307
0
        None
308
13
    }
309
310
    /// Jump to the last item in the list
311
11
    pub fn jump_to_last(&mut self) {
312
11
        if !self.items.is_empty() {
  Branch (312:12): [True: 8, False: 0]
 
  Branch (312:12): [True: 3, False: 0]
-
313
11
            self.current = self.items.len().saturating_sub(1);
314
11
            self.sub_offset = 0;
315
11
        
}0
316
11
    }
317
318
    /// Number of terminal rows item at `index` occupies.
319
    ///
320
    /// When `--multiline` is active this is the number of sub-lines produced by
321
    /// splitting on the separator; otherwise every item is exactly 1 row.
322
4.09k
    fn item_row_count(&self, index: usize) -> usize {
323
4.09k
        if let Some(
sep686
) = self.multiline.as_deref()
  Branch (323:16): [True: 686, False: 3.06k]
+
313
11
            self.current = self.items.len().saturating_sub(1);
314
11
            self.sub_offset = 0;
315
11
        
}0
316
11
    }
317
318
    /// Number of terminal rows item at `index` occupies.
319
    ///
320
    /// When `--multiline` is active this is the number of sub-lines produced by
321
    /// splitting on the separator; otherwise every item is exactly 1 row.
322
4.08k
    fn item_row_count(&self, index: usize) -> usize {
323
4.08k
        if let Some(
sep692
) = self.multiline.as_deref()
  Branch (323:16): [True: 692, False: 3.05k]
 
  Branch (323:16): [True: 0, False: 342]
-
324
686
            && let Some(
item648
) = self.items.get(index)
  Branch (324:20): [True: 648, False: 38]
+
324
692
            && let Some(
item648
) = self.items.get(index)
  Branch (324:20): [True: 648, False: 44]
 
  Branch (324:20): [True: 0, False: 0]
-
325
        {
326
648
            item.item.text().split(sep).count().max(1)
327
        } else {
328
3.44k
            1
329
        }
330
4.09k
    }
331
332
    /// How many terminal rows are consumed by items `[from, from + count)`.
333
202
    fn rows_for_range(&self, from: usize, count: usize) -> usize {
334
1.26k
        (
from202
..from + count).
map202
(|i| self.item_row_count(i)).
sum202
()
335
202
    }
336
337
    /// How many rows are consumed by items `[offset..=current]`, with `sub_offset`
338
    /// leading sub-lines of `items[offset]` already scrolled off.
339
2.73k
    fn rows_visible(&self, offset: usize, sub_offset: usize, current: usize) -> usize {
340
2.73k
        if offset > current {
  Branch (340:12): [True: 0, False: 2.68k]
+
325
        {
326
648
            item.item.text().split(sep).count().max(1)
327
        } else {
328
3.43k
            1
329
        }
330
4.08k
    }
331
332
    /// How many terminal rows are consumed by items `[from, from + count)`.
333
202
    fn rows_for_range(&self, from: usize, count: usize) -> usize {
334
1.26k
        (
from202
..from + count).
map202
(|i| self.item_row_count(i)).
sum202
()
335
202
    }
336
337
    /// How many rows are consumed by items `[offset..=current]`, with `sub_offset`
338
    /// leading sub-lines of `items[offset]` already scrolled off.
339
2.72k
    fn rows_visible(&self, offset: usize, sub_offset: usize, current: usize) -> usize {
340
2.72k
        if offset > current {
  Branch (340:12): [True: 0, False: 2.67k]
 
  Branch (340:12): [True: 0, False: 51]
-
341
0
            return 0;
342
2.73k
        }
343
2.73k
        let top_rows = self.item_row_count(offset).saturating_sub(sub_offset);
344
2.73k
        if offset == current {
  Branch (344:12): [True: 2.49k, False: 185]
+
341
0
            return 0;
342
2.72k
        }
343
2.72k
        let top_rows = self.item_row_count(offset).saturating_sub(sub_offset);
344
2.72k
        if offset == current {
  Branch (344:12): [True: 2.49k, False: 185]
 
  Branch (344:12): [True: 34, False: 17]
-
345
2.53k
            return top_rows;
346
202
        }
347
202
        top_rows + self.rows_for_range(offset + 1, current - offset)
348
2.73k
    }
349
350
    /// Advance `(offset, sub_offset)` one row at a time until `current` fits
351
    /// within `available_rows`.  Returns the new `(offset, sub_offset)`.
352
7
    fn advance_to_fit(&self, current: usize, available_rows: usize) -> (usize, usize) {
353
7
        let mut offset = self.offset;
354
7
        let mut sub_offset = self.sub_offset;
355
45
        while offset < current && self.rows_visible(offset, sub_offset, current) > available_rows {
  Branch (355:15): [True: 29, False: 0]
+
345
2.52k
            return top_rows;
346
202
        }
347
202
        top_rows + self.rows_for_range(offset + 1, current - offset)
348
2.72k
    }
349
350
    /// Advance `(offset, sub_offset)` one row at a time until `current` fits
351
    /// within `available_rows`.  Returns the new `(offset, sub_offset)`.
352
7
    fn advance_to_fit(&self, current: usize, available_rows: usize) -> (usize, usize) {
353
7
        let mut offset = self.offset;
354
7
        let mut sub_offset = self.sub_offset;
355
45
        while offset < current && self.rows_visible(offset, sub_offset, current) > available_rows {
  Branch (355:15): [True: 29, False: 0]
   Branch (355:35): [True: 23, False: 6]
 
  Branch (355:15): [True: 16, False: 0]
   Branch (355:35): [True: 15, False: 1]
 
356
38
            let top_rows = self.item_row_count(offset);
357
38
            if sub_offset + 1 < top_rows {
  Branch (357:16): [True: 8, False: 15]
 
  Branch (357:16): [True: 0, False: 15]
-
358
8
                // Still more sub-lines in the top item: scroll one sub-line off.
359
8
                sub_offset += 1;
360
30
            } else {
361
30
                // Entire top item scrolled off: move to next item.
362
30
                offset += 1;
363
30
                sub_offset = 0;
364
30
            }
365
        }
366
7
        (offset, sub_offset)
367
7
    }
368
}
369
370
impl SkimWidget for ItemList {
371
552
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
372
        use crate::helper::selector::DefaultSkimSelector;
373
        use crate::util::read_file_lines;
374
375
552
        let skip_to_pattern = options
376
552
            .skip_to_pattern
377
552
            .as_ref()
378
552
            .and_then(|pattern| 
Regex::new1
(
pattern1
).
ok1
());
379
380
        // Build the selector from options and calculate pre-select target
381
552
        let (selector, pre_select_target) = if options.pre_select_n > 0
  Branch (381:48): [True: 11, False: 363]
+
358
8
                // Still more sub-lines in the top item: scroll one sub-line off.
359
8
                sub_offset += 1;
360
30
            } else {
361
30
                // Entire top item scrolled off: move to next item.
362
30
                offset += 1;
363
30
                sub_offset = 0;
364
30
            }
365
        }
366
7
        (offset, sub_offset)
367
7
    }
368
}
369
370
impl SkimWidget for ItemList {
371
552
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
372
        use crate::helper::selector::DefaultSkimSelector;
373
        use crate::util::read_file_lines;
374
375
552
        let skip_to_pattern = options
376
552
            .skip_to_pattern
377
552
            .as_ref()
378
552
            .and_then(|pattern| 
Regex::new1
(
pattern1
).
ok1
());
379
380
        // Build the selector from options and calculate pre-select target
381
552
        let (selector, pre_select_target) = if options.pre_select_n > 0
  Branch (381:48): [True: 12, False: 362]
 
  Branch (381:48): [True: 0, False: 178]
-
382
541
            || !options.pre_select_pat.is_empty()
  Branch (382:16): [True: 2, False: 361]
+
382
540
            || !options.pre_select_pat.is_empty()
  Branch (382:16): [True: 2, False: 360]
 
  Branch (382:16): [True: 0, False: 178]
-
383
539
            || !options.pre_select_items.is_empty()
  Branch (383:16): [True: 1, False: 360]
+
383
538
            || !options.pre_select_items.is_empty()
  Branch (383:16): [True: 1, False: 359]
 
  Branch (383:16): [True: 0, False: 178]
-
384
538
            || options.pre_select_file.is_some()
  Branch (384:16): [True: 1, False: 359]
+
384
537
            || options.pre_select_file.is_some()
  Branch (384:16): [True: 1, False: 358]
 
  Branch (384:16): [True: 0, False: 178]
-
385
537
            || options.selector.is_some()
  Branch (385:16): [True: 0, False: 359]
+
385
536
            || options.selector.is_some()
  Branch (385:16): [True: 0, False: 358]
 
  Branch (385:16): [True: 0, False: 178]
-
386
        {
387
15
            if let Some(
s0
) = options.selector.clone() {
  Branch (387:20): [True: 0, False: 15]
+
386
        {
387
16
            if let Some(
s0
) = options.selector.clone() {
  Branch (387:20): [True: 0, False: 16]
 
  Branch (387:20): [True: 0, False: 0]
-
388
                // For custom selectors, use a very large target (pre-select all matching)
389
0
                (Some(s), usize::MAX)
390
            } else {
391
15
                let mut preset_items: Vec<String> = options
392
15
                    .pre_select_items
393
15
                    .split('\n')
394
15
                    .filter(|s| !s.is_empty())
395
15
                    .map(std::string::ToString::to_string)
396
15
                    .collect();
397
398
15
                if let Some(
ref pre_select_file1
) = options.pre_select_file
  Branch (398:24): [True: 1, False: 14]
+
388
                // For custom selectors, use a very large target (pre-select all matching)
389
0
                (Some(s), usize::MAX)
390
            } else {
391
16
                let mut preset_items: Vec<String> = options
392
16
                    .pre_select_items
393
16
                    .split('\n')
394
16
                    .filter(|s| !s.is_empty())
395
16
                    .map(std::string::ToString::to_string)
396
16
                    .collect();
397
398
16
                if let Some(
ref pre_select_file1
) = options.pre_select_file
  Branch (398:24): [True: 1, False: 15]
 
  Branch (398:24): [True: 0, False: 0]
 
399
1
                    && let Ok(file_items) = read_file_lines(pre_select_file)
  Branch (399:28): [True: 1, False: 0]
 
  Branch (399:28): [True: 0, False: 0]
-
400
1
                {
401
1
                    preset_items.extend(file_items);
402
14
                }
403
404
15
                let selector = DefaultSkimSelector::default()
405
15
                    .first_n(options.pre_select_n)
406
15
                    .regex(&options.pre_select_pat)
407
15
                    .preset(preset_items.clone());
408
409
                // Only use a target for --pre-select-n
410
                // For pattern/items, the selector always returns the same matches regardless of timing
411
15
                let target = if options.pre_select_n > 0 {
  Branch (411:33): [True: 11, False: 4]
+
400
1
                {
401
1
                    preset_items.extend(file_items);
402
15
                }
403
404
16
                let selector = DefaultSkimSelector::default()
405
16
                    .first_n(options.pre_select_n)
406
16
                    .regex(&options.pre_select_pat)
407
16
                    .preset(preset_items.clone());
408
409
                // Only use a target for --pre-select-n
410
                // For pattern/items, the selector always returns the same matches regardless of timing
411
16
                let target = if options.pre_select_n > 0 {
  Branch (411:33): [True: 12, False: 4]
 
  Branch (411:33): [True: 0, False: 0]
-
412
11
                    options.pre_select_n
413
                } else {
414
4
                    usize::MAX // No target - keep selecting matching items
415
                };
416
417
15
                (Some(Rc::new(selector) as Rc<dyn Selector>), target)
418
            }
419
        } else {
420
537
            (None, 0)
421
        };
422
423
552
        let processed_items = Arc::new(SpinLock::new(None));
424
425
552
        let interactive = options.interactive;
426
552
        let no_clear_if_empty = options.no_clear_if_empty;
427
552
        let multi_select = options.multi;
428
429
        // Spawn background processing thread with the appropriate configuration
430
        Self {
431
552
            processed_items,
432
            reserved: 0, // header_lines are now displayed in the Header widget, not ItemList
433
552
            direction: match options.layout {
434
533
                TuiLayout::Default => ratatui::widgets::ListDirection::BottomToTop,
435
19
                TuiLayout::Reverse | TuiLayout::ReverseList => ratatui::widgets::ListDirection::TopToBottom,
436
            },
437
            current: 0,
438
552
            theme,
439
552
            multi_select,
440
552
            no_hscroll: options.no_hscroll,
441
552
            ellipsis: options.ellipsis.clone(),
442
552
            keep_right: options.keep_right,
443
552
            skip_to_pattern,
444
552
            tabstop: options.tabstop.max(1),
445
552
            selector,
446
552
            pre_select_target,
447
552
            no_clear_if_empty,
448
552
            interactive,
449
            showing_stale_items: false,
450
            manual_hscroll: 0,
451
552
            items: Default::default(),
452
552
            selection: Default::default(),
453
552
            offset: Default::default(),
454
            sub_offset: 0,
455
552
            height: Default::default(),
456
552
            selector_icon: options.selector_icon.clone(),
457
552
            multi_select_icon: options.multi_select_icon.clone(),
458
552
            cycle: options.cycle,
459
552
            wrap: options.wrap_items,
460
552
            multiline: options
461
552
                .multiline
462
552
                .clone()
463
552
                .map(|opt_m| 
opt_m36
.
unwrap_or36
(
String::from36
("\\n"))),
464
552
            border: options.border,
465
552
            show_score: feature_flag!(options, ShowScore),
466
552
            show_index: feature_flag!(options, ShowIndex),
467
552
            highlight_line: options.highlight_line,
468
552
            scrollbar_thumb: options.scrollbar.clone(),
469
        }
470
552
    }
471
472
    // The render function handles the full item list rendering pipeline; splitting
473
    // it into smaller helpers would require passing many parameters between them.
474
    #[allow(clippy::too_many_lines)]
475
2.68k
    fn render(&mut self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) -> SkimRender {
476
2.68k
        let this = &mut *self;
477
478
        // Calculate inner area if borders are enabled
479
2.68k
        let inner_area = if this.border.is_some() {
  Branch (479:29): [True: 63, False: 2.59k]
+
412
12
                    options.pre_select_n
413
                } else {
414
4
                    usize::MAX // No target - keep selecting matching items
415
                };
416
417
16
                (Some(Rc::new(selector) as Rc<dyn Selector>), target)
418
            }
419
        } else {
420
536
            (None, 0)
421
        };
422
423
552
        let processed_items = Arc::new(SpinLock::new(None));
424
425
552
        let interactive = options.interactive;
426
552
        let no_clear_if_empty = options.no_clear_if_empty;
427
552
        let multi_select = options.multi;
428
429
        // Spawn background processing thread with the appropriate configuration
430
        Self {
431
552
            processed_items,
432
            reserved: 0, // header_lines are now displayed in the Header widget, not ItemList
433
552
            direction: match options.layout {
434
532
                TuiLayout::Default => ratatui::widgets::ListDirection::BottomToTop,
435
20
                TuiLayout::Reverse | TuiLayout::ReverseList => ratatui::widgets::ListDirection::TopToBottom,
436
            },
437
            current: 0,
438
552
            theme,
439
552
            multi_select,
440
552
            no_hscroll: options.no_hscroll,
441
552
            ellipsis: options.ellipsis.clone(),
442
552
            keep_right: options.keep_right,
443
552
            skip_to_pattern,
444
552
            tabstop: options.tabstop.max(1),
445
552
            selector,
446
552
            pre_select_target,
447
552
            no_clear_if_empty,
448
552
            interactive,
449
            showing_stale_items: false,
450
            manual_hscroll: 0,
451
552
            items: Default::default(),
452
552
            selection: Default::default(),
453
552
            offset: Default::default(),
454
            sub_offset: 0,
455
552
            height: Default::default(),
456
552
            selector_icon: options.selector_icon.clone(),
457
552
            multi_select_icon: options.multi_select_icon.clone(),
458
552
            cycle: options.cycle,
459
552
            wrap: options.wrap_items,
460
552
            multiline: options
461
552
                .multiline
462
552
                .clone()
463
552
                .map(|opt_m| 
opt_m38
.
unwrap_or38
(
String::from38
("\\n"))),
464
552
            border: options.border,
465
552
            show_score: feature_flag!(options, ShowScore),
466
552
            show_index: feature_flag!(options, ShowIndex),
467
552
            highlight_line: options.highlight_line,
468
552
            scrollbar_thumb: options.scrollbar.clone(),
469
        }
470
552
    }
471
472
    // The render function handles the full item list rendering pipeline; splitting
473
    // it into smaller helpers would require passing many parameters between them.
474
    #[allow(clippy::too_many_lines)]
475
2.68k
    fn render(&mut self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) -> SkimRender {
476
2.68k
        let this = &mut *self;
477
478
        // Calculate inner area if borders are enabled
479
2.68k
        let inner_area = if this.border.is_some() {
  Branch (479:29): [True: 66, False: 2.58k]
 
  Branch (479:29): [True: 0, False: 35]
-
480
63
            ratatui::layout::Rect {
481
63
                x: area.x + 1,
482
63
                y: area.y + 1,
483
63
                width: area.width.saturating_sub(2),
484
63
                height: area.height.saturating_sub(2),
485
63
            }
486
        } else {
487
2.62k
            area
488
        };
489
490
2.68k
        this.height = inner_area.height;
491
2.68k
        let available_rows = inner_area.height as usize;
492
493
        // Clamp current to valid range after any item list replacement.
494
2.68k
        if this.items.is_empty() {
  Branch (494:12): [True: 882, False: 1.77k]
+
480
66
            ratatui::layout::Rect {
481
66
                x: area.x + 1,
482
66
                y: area.y + 1,
483
66
                width: area.width.saturating_sub(2),
484
66
                height: area.height.saturating_sub(2),
485
66
            }
486
        } else {
487
2.61k
            area
488
        };
489
490
2.68k
        this.height = inner_area.height;
491
2.68k
        let available_rows = inner_area.height as usize;
492
493
        // Clamp current to valid range after any item list replacement.
494
2.68k
        if this.items.is_empty() {
  Branch (494:12): [True: 879, False: 1.76k]
 
  Branch (494:12): [True: 3, False: 32]
-
495
885
            this.current = 0;
496
885
            this.offset = 0;
497
1.80k
        } else {
498
1.80k
            this.current = this.current.min(this.items.len() - 1).max(this.reserved);
499
1.80k
        }
500
501
2.68k
        if this.current < this.offset {
  Branch (501:12): [True: 0, False: 2.65k]
+
495
882
            this.current = 0;
496
882
            this.offset = 0;
497
1.79k
        } else {
498
1.79k
            this.current = this.current.min(this.items.len() - 1).max(this.reserved);
499
1.79k
        }
500
501
2.68k
        if this.current < this.offset {
  Branch (501:12): [True: 0, False: 2.64k]
 
  Branch (501:12): [True: 0, False: 35]
 
502
0
            // Cursor moved above the top item: snap to it with no sub-line offset.
503
0
            this.offset = this.current;
504
0
            this.sub_offset = 0;
505
2.68k
        } else if this.rows_visible(this.offset, this.sub_offset, this.current) > available_rows {
  Branch (505:19): [True: 6, False: 2.64k]
 
  Branch (505:19): [True: 1, False: 34]
-
506
7
            // Current item is below the visible window: advance one row at a time.
507
7
            (this.offset, this.sub_offset) = this.advance_to_fit(this.current, available_rows);
508
2.68k
        }
509
2.68k
        let initial_current = this.selected();
510
511
        // Check for pre-processed items from background thread (non-blocking).
512
        // Bind the result separately so the lock guard is dropped before a merge
513
        // mutates the item list.
514
2.68k
        let processed = this.processed_items.lock().take();
515
2.68k
        let items_updated = if let Some(
processed739
) = processed {
  Branch (515:36): [True: 734, False: 1.91k]
-
  Branch (515:36): [True: 5, False: 30]
-
516
739
            debug!("Render: Got {} processed items", 
processed.items0
.
len0
());
517
518
            // Check if items are empty or blank for no_clear_if_empty handling
519
739
            let items_are_empty_or_blank =
520
739
                processed.items.is_empty() || 
processed.items.iter()575
.
all575
(|item|
item.item.text().trim()575
.
is_empty575
());
  Branch (520:17): [True: 164, False: 570]
-
  Branch (520:17): [True: 0, False: 5]
-
521
522
739
            if this.interactive && 
this.no_clear_if_empty85
&&
items_are_empty_or_blank2
&&
!this.items.is_empty()1
{
  Branch (522:16): [True: 85, False: 649]
-  Branch (522:36): [True: 2, False: 83]
+
506
7
            // Current item is below the visible window: advance one row at a time.
507
7
            (this.offset, this.sub_offset) = this.advance_to_fit(this.current, available_rows);
508
2.67k
        }
509
2.68k
        let initial_current = this.selected();
510
511
        // Check for pre-processed items from background thread (non-blocking).
512
        // Bind the result separately so the lock guard is dropped before a merge
513
        // mutates the item list.
514
2.68k
        let processed = this.processed_items.lock().take();
515
2.68k
        let items_updated = if let Some(
processed738
) = processed {
  Branch (515:36): [True: 732, False: 1.91k]
+
  Branch (515:36): [True: 6, False: 29]
+
516
738
            debug!("Render: Got {} processed items", 
processed.items0
.
len0
());
517
518
            // Check if items are empty or blank for no_clear_if_empty handling
519
738
            let items_are_empty_or_blank =
520
738
                processed.items.is_empty() || 
processed.items.iter()576
.
all576
(|item|
item.item.text().trim()576
.
is_empty576
());
  Branch (520:17): [True: 162, False: 570]
+
  Branch (520:17): [True: 0, False: 6]
+
521
522
738
            if this.interactive && 
this.no_clear_if_empty84
&&
items_are_empty_or_blank2
&&
!this.items.is_empty()1
{
  Branch (522:16): [True: 84, False: 648]
+  Branch (522:36): [True: 2, False: 82]
   Branch (522:62): [True: 1, False: 1]
   Branch (522:90): [True: 1, False: 0]
-
  Branch (522:16): [True: 0, False: 5]
+
  Branch (522:16): [True: 0, False: 6]
   Branch (522:36): [True: 0, False: 0]
   Branch (522:62): [True: 0, False: 0]
   Branch (522:90): [True: 0, False: 0]
-
523
1
                debug!(
524
                    "no_clear_if_empty: keeping {} old items for display (new items are empty/blank)",
525
0
                    this.items.len()
526
                );
527
1
                this.showing_stale_items = true;
528
            } else {
529
738
                match processed.merge {
530
725
                    MergeStrategy::Replace => {
531
725
                        this.items = processed.items;
532
725
                        this.sub_offset = 0;
533
725
                    }
534
10
                    MergeStrategy::SortedMerge => {
535
10
                        let existing = std::mem::take(&mut this.items);
536
10
                        this.items = MatchedItem::sorted_merge(existing, processed.items);
537
10
                        this.sub_offset = 0;
538
10
                    }
539
1
                    MergeStrategy::Append => {
540
1
                        this.items.extend(processed.items);
541
1
                    }
542
2
                    MergeStrategy::Prepend => {
543
2
                        this.prepend(processed.items);
544
2
                    }
545
                }
546
738
                this.showing_stale_items = false;
547
548
                // Apply pre-selection only when new items arrive and only if we haven't reached target
549
                // This runs once per item batch, not on every render
550
738
                if this.multi_select
  Branch (550:20): [True: 27, False: 706]
-
  Branch (550:20): [True: 0, False: 5]
-
551
27
                    && let Some(
selector15
) = &this.selector
  Branch (551:28): [True: 15, False: 12]
+
523
1
                debug!(
524
                    "no_clear_if_empty: keeping {} old items for display (new items are empty/blank)",
525
0
                    this.items.len()
526
                );
527
1
                this.showing_stale_items = true;
528
            } else {
529
737
                match processed.merge {
530
725
                    MergeStrategy::Replace => {
531
725
                        this.items = processed.items;
532
725
                        this.sub_offset = 0;
533
725
                    }
534
9
                    MergeStrategy::SortedMerge => {
535
9
                        let existing = std::mem::take(&mut this.items);
536
9
                        this.items = MatchedItem::sorted_merge(existing, processed.items);
537
9
                        this.sub_offset = 0;
538
9
                    }
539
1
                    MergeStrategy::Append => {
540
1
                        this.items.extend(processed.items);
541
1
                    }
542
2
                    MergeStrategy::Prepend => {
543
2
                        this.prepend(processed.items);
544
2
                    }
545
                }
546
737
                this.showing_stale_items = false;
547
548
                // Apply pre-selection only when new items arrive and only if we haven't reached target
549
                // This runs once per item batch, not on every render
550
737
                if this.multi_select
  Branch (550:20): [True: 28, False: 703]
+
  Branch (550:20): [True: 0, False: 6]
+
551
28
                    && let Some(
selector16
) = &this.selector
  Branch (551:28): [True: 16, False: 12]
 
  Branch (551:28): [True: 0, False: 0]
-
552
15
                    && this.selection.len() < this.pre_select_target
  Branch (552:24): [True: 15, False: 0]
+
552
16
                    && this.selection.len() < this.pre_select_target
  Branch (552:24): [True: 16, False: 0]
 
  Branch (552:24): [True: 0, False: 0]
-
553
                {
554
15
                    debug!(
555
                        "Applying pre-selection to {} items (currently {} selected, target {})",
556
0
                        this.items.len(),
557
0
                        this.selection.len(),
558
                        this.pre_select_target
559
                    );
560
45
                    for (index, item) in 
this.items.iter()15
.
enumerate15
() {
561
45
                        if this.selection.len() >= this.pre_select_target {
  Branch (561:28): [True: 11, False: 34]
+
553
                {
554
16
                    debug!(
555
                        "Applying pre-selection to {} items (currently {} selected, target {})",
556
0
                        this.items.len(),
557
0
                        this.selection.len(),
558
                        this.pre_select_target
559
                    );
560
45
                    for (index, item) in 
this.items.iter()16
.
enumerate16
() {
561
45
                        if this.selection.len() >= this.pre_select_target {
  Branch (561:28): [True: 11, False: 34]
 
  Branch (561:28): [True: 0, False: 0]
 
562
11
                            break;
563
34
                        }
564
34
                        let should_select = selector.should_select(index, item.item.as_ref());
565
34
                        if should_select {
  Branch (565:28): [True: 27, False: 7]
 
  Branch (565:28): [True: 0, False: 0]
-
566
27
                            debug!("Pre-selecting item[{}]: '{}'", index, 
item.item.text()0
);
567
27
                            this.selection.insert(item.clone());
568
7
                        }
569
                    }
570
15
                    debug!("Pre-selected {} items total", 
this.selection0
.
len0
());
571
723
                }
572
            }
573
574
739
            true
575
        } else {
576
1.94k
            false
577
        };
578
579
2.68k
        let icon_width = this.selector_icon.chars().count() + this.multi_select_icon.chars().count();
580
2.68k
        let container_width = (inner_area.width as usize).saturating_sub(icon_width);
581
2.68k
        let sub_offset = this.sub_offset;
582
583
2.68k
        let renderer = ItemRenderer::new_for(this, container_width);
584
585
2.68k
        let mut flat_rows: Vec<ListItem<'static>> = Vec::with_capacity(available_rows + 1);
586
2.68k
        let mut rows_used = 0usize;
587
588
6.55k
        for (idx, item) in 
this.items.iter()2.68k
.
enumerate2.68k
().
skip2.68k
(
this.offset2.68k
) {
589
6.55k
            if rows_used >= available_rows {
  Branch (589:16): [True: 78, False: 6.36k]
-
  Branch (589:16): [True: 15, False: 105]
-
590
93
                break;
591
6.46k
            }
592
6.46k
            let is_current = idx == this.current;
593
6.46k
            let is_selected = this.selection.contains(item);
594
6.46k
            let skip_subs = if idx == this.offset { 
sub_offset2.09k
} else {
04.37k
};
  Branch (594:32): [True: 2.06k, False: 4.29k]
-
  Branch (594:32): [True: 32, False: 73]
-
595
6.46k
            rows_used += renderer.render_item(
596
6.46k
                item,
597
6.46k
                is_current,
598
6.46k
                is_selected,
599
6.46k
                skip_subs,
600
6.46k
                available_rows,
601
6.46k
                rows_used,
602
6.46k
                &mut flat_rows,
603
6.46k
            );
604
        }
605
606
2.68k
        let list = List::new(flat_rows).direction(this.direction).style(this.theme.normal);
607
608
2.68k
        Widget::render(Clear, area, buf);
609
610
        // Render border if enabled
611
2.68k
        if let Some(
border_type63
) = this.border.into_ratatui() {
  Branch (611:16): [True: 63, False: 2.59k]
+
566
27
                            debug!("Pre-selecting item[{}]: '{}'", index, 
item.item.text()0
);
567
27
                            this.selection.insert(item.clone());
568
7
                        }
569
                    }
570
16
                    debug!("Pre-selected {} items total", 
this.selection0
.
len0
());
571
721
                }
572
            }
573
574
738
            true
575
        } else {
576
1.94k
            false
577
        };
578
579
2.68k
        let icon_width = this.selector_icon.chars().count() + this.multi_select_icon.chars().count();
580
2.68k
        let container_width = (inner_area.width as usize).saturating_sub(icon_width);
581
2.68k
        let sub_offset = this.sub_offset;
582
583
2.68k
        let renderer = ItemRenderer::new_for(this, container_width);
584
585
2.68k
        let mut flat_rows: Vec<ListItem<'static>> = Vec::with_capacity(available_rows + 1);
586
2.68k
        let mut rows_used = 0usize;
587
588
6.53k
        for (idx, item) in 
this.items.iter()2.68k
.
enumerate2.68k
().
skip2.68k
(
this.offset2.68k
) {
589
6.53k
            if rows_used >= available_rows {
  Branch (589:16): [True: 76, False: 6.33k]
+
  Branch (589:16): [True: 15, False: 106]
+
590
91
                break;
591
6.44k
            }
592
6.44k
            let is_current = idx == this.current;
593
6.44k
            let is_selected = this.selection.contains(item);
594
6.44k
            let skip_subs = if idx == this.offset { 
sub_offset2.09k
} else {
04.35k
};
  Branch (594:32): [True: 2.05k, False: 4.28k]
+
  Branch (594:32): [True: 33, False: 73]
+
595
6.44k
            rows_used += renderer.render_item(
596
6.44k
                item,
597
6.44k
                is_current,
598
6.44k
                is_selected,
599
6.44k
                skip_subs,
600
6.44k
                available_rows,
601
6.44k
                rows_used,
602
6.44k
                &mut flat_rows,
603
6.44k
            );
604
        }
605
606
2.68k
        let list = List::new(flat_rows).direction(this.direction).style(this.theme.normal);
607
608
2.68k
        Widget::render(Clear, area, buf);
609
610
        // Render border if enabled
611
2.68k
        if let Some(
border_type66
) = this.border.into_ratatui() {
  Branch (611:16): [True: 66, False: 2.58k]
 
  Branch (611:16): [True: 0, False: 35]
-
612
63
            let block = Block::default()
613
63
                .borders(Borders::ALL)
614
63
                .border_type(border_type)
615
63
                .border_style(this.theme.border);
616
63
            Widget::render(block, area, buf);
617
2.62k
        }
618
619
        // We manage offset and selection styling ourselves, so render as a plain
620
        // Widget — this bypasses ratatui's get_items_bounds which can index out of
621
        // bounds on our pre-sliced flat_rows when the selected row is near the edge.
622
2.68k
        Widget::render(list, inner_area, buf);
623
624
        // Render the scrollbar on top of the rightmost column of inner_area, but only
625
        // when there are more items than fit on screen (nothing to scroll → no bar).
626
2.68k
        if !this.scrollbar_thumb.is_empty() && 
this.items2.65k
.len() > available_rows {
  Branch (626:12): [True: 2.64k, False: 8]
-  Branch (626:48): [True: 52, False: 2.59k]
+
612
66
            let block = Block::default()
613
66
                .borders(Borders::ALL)
614
66
                .border_type(border_type)
615
66
                .border_style(this.theme.border);
616
66
            Widget::render(block, area, buf);
617
2.61k
        }
618
619
        // We manage offset and selection styling ourselves, so render as a plain
620
        // Widget — this bypasses ratatui's get_items_bounds which can index out of
621
        // bounds on our pre-sliced flat_rows when the selected row is near the edge.
622
2.68k
        Widget::render(list, inner_area, buf);
623
624
        // Render the scrollbar on top of the rightmost column of inner_area, but only
625
        // when there are more items than fit on screen (nothing to scroll → no bar).
626
2.68k
        if !this.scrollbar_thumb.is_empty() && 
this.items2.64k
.len() > available_rows {
  Branch (626:12): [True: 2.63k, False: 8]
+  Branch (626:48): [True: 50, False: 2.58k]
 
  Branch (626:12): [True: 5, False: 30]
   Branch (626:48): [True: 5, False: 0]
-
627
57
            // Use offset directly as the scroll position for all layout directions:
628
57
            // offset == 0           → thumb at top    (beginning of content)
629
57
            // offset == max_offset  → thumb at bottom (end of content)
630
57
            // This matches conventional scrollbar behaviour regardless of list direction.
631
57
            let mut scrollbar_state = ScrollbarState::new(this.items.len()).position(this.current);
632
57
            // .viewport_content_length(available_rows);
633
57
634
57
            // Both Default and Custom use a thumb-only style (no track/begin/end arrows).
635
57
            // Default uses ▐ (right half-block), which gives a clean minimal look.
636
57
            // Without an explicit thumb_style the thumb inherits whatever fg/bg the
637
57
            // row painted underneath it, so it picks up the current-line highlight as
638
57
            // the cursor scrolls past; the themed `scrollbar` color keeps it uniform.
639
57
            let scrollbar: Scrollbar<'_> = Scrollbar::new(ScrollbarOrientation::VerticalRight)
640
57
                .thumb_symbol(&self.scrollbar_thumb)
641
57
                .thumb_style(self.theme.scrollbar)
642
57
                .track_symbol(None)
643
57
                .begin_symbol(None)
644
57
                .end_symbol(None);
645
57
            StatefulWidget::render(scrollbar, inner_area, buf, &mut scrollbar_state);
646
2.63k
        }
647
648
2.68k
        let run_preview = if let Some(
curr2.09k
) = self.selected()
  Branch (648:34): [True: 2.06k, False: 590]
-
  Branch (648:34): [True: 32, False: 3]
-
649
2.09k
            && let Some(
prev1.77k
) = initial_current
  Branch (649:20): [True: 1.74k, False: 318]
-
  Branch (649:20): [True: 32, False: 0]
-
650
        {
651
1.77k
            curr.text() != prev.text()
652
        } else {
653
911
            self.selected().is_some() != initial_current.is_some()
654
        };
655
2.68k
        SkimRender {
656
2.68k
            items_updated,
657
2.68k
            run_preview,
658
2.68k
        }
659
2.68k
    }
660
}
661
662
40
fn toggle_item(sel: &mut IndexSet<MatchedItem>, item: &MatchedItem) {
663
40
    if sel.contains(item) {
  Branch (663:8): [True: 0, False: 12]
+
627
55
            // Use offset directly as the scroll position for all layout directions:
628
55
            // offset == 0           → thumb at top    (beginning of content)
629
55
            // offset == max_offset  → thumb at bottom (end of content)
630
55
            // This matches conventional scrollbar behaviour regardless of list direction.
631
55
            let mut scrollbar_state = ScrollbarState::new(this.items.len()).position(this.current);
632
55
            // .viewport_content_length(available_rows);
633
55
634
55
            // Both Default and Custom use a thumb-only style (no track/begin/end arrows).
635
55
            // Default uses ▐ (right half-block), which gives a clean minimal look.
636
55
            // Without an explicit thumb_style the thumb inherits whatever fg/bg the
637
55
            // row painted underneath it, so it picks up the current-line highlight as
638
55
            // the cursor scrolls past; the themed `scrollbar` color keeps it uniform.
639
55
            let scrollbar: Scrollbar<'_> = Scrollbar::new(ScrollbarOrientation::VerticalRight)
640
55
                .thumb_symbol(&self.scrollbar_thumb)
641
55
                .thumb_style(self.theme.scrollbar)
642
55
                .track_symbol(None)
643
55
                .begin_symbol(None)
644
55
                .end_symbol(None);
645
55
            StatefulWidget::render(scrollbar, inner_area, buf, &mut scrollbar_state);
646
2.62k
        }
647
648
2.68k
        let run_preview = if let Some(
curr2.09k
) = self.selected()
  Branch (648:34): [True: 2.05k, False: 588]
+
  Branch (648:34): [True: 33, False: 2]
+
649
2.09k
            && let Some(
prev1.77k
) = initial_current
  Branch (649:20): [True: 1.74k, False: 317]
+
  Branch (649:20): [True: 32, False: 1]
+
650
        {
651
1.77k
            curr.text() != prev.text()
652
        } else {
653
908
            self.selected().is_some() != initial_current.is_some()
654
        };
655
2.68k
        SkimRender {
656
2.68k
            items_updated,
657
2.68k
            run_preview,
658
2.68k
        }
659
2.68k
    }
660
}
661
662
40
fn toggle_item(sel: &mut IndexSet<MatchedItem>, item: &MatchedItem) {
663
40
    if sel.contains(item) {
  Branch (663:8): [True: 0, False: 12]
 
  Branch (663:8): [True: 8, False: 20]
 
664
8
        sel.shift_remove(item);
665
32
    } else if !item.disabled() {
  Branch (665:15): [True: 12, False: 0]
 
  Branch (665:15): [True: 19, False: 1]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/item_renderer.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/item_renderer.rs.html
index a619188a..4dbc9438 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/tui/item_renderer.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/item_renderer.rs.html
@@ -1,127 +1,127 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/item_renderer.rs
Line
Count
Source
1
use ratatui::style::Color;
2
use ratatui::text::{Line, Span};
3
use ratatui::widgets::{ListDirection, ListItem};
4
use unicode_display_width::width as display_width;
5
6
use crate::item::MatchedItem;
7
use crate::theme::ColorTheme;
8
use crate::tui::item_list::ItemList;
9
use crate::tui::util::{char_display_width, clip_line_to_chars, wrap_text};
10
use crate::{DisplayContext, MatchRange};
11
12
#[allow(clippy::struct_excessive_bools)]
13
struct SubLineState {
14
    /// Current item
15
    is_current: bool,
16
    /// Selected (in multi-select)
17
    is_selected: bool,
18
    /// First item on the screen
19
    is_first: bool,
20
    /// First line of the item
21
    is_first_sub_line: bool,
22
    needs_ellipsis: bool,
23
}
24
25
/// Rendering parameters that are constant across all items in one render pass.
26
#[allow(clippy::struct_excessive_bools)]
27
pub(crate) struct ItemRenderer<'a> {
28
    pub theme: &'a ColorTheme,
29
    pub selector_icon: &'a str,
30
    pub multi_select_icon: &'a str,
31
    pub ellipsis: &'a str,
32
    pub container_width: usize,
33
    pub wrap: bool,
34
    pub multiline: Option<&'a str>,
35
    pub show_score: bool,
36
    pub show_index: bool,
37
    pub multi_select: bool,
38
    pub tabstop: usize,
39
    pub no_hscroll: bool,
40
    pub keep_right: bool,
41
    pub manual_hscroll: i32,
42
    pub skip_to_pattern: Option<&'a regex::Regex>,
43
    /// When true, reverse the order of sub-lines within each multiline item
44
    /// before appending to the output. Required for `BottomToTop` list direction
45
    /// so that sub-line 0 appears visually above sub-line 1.
46
    pub reverse_sub_lines: bool,
47
    /// When true, fill the rest of the current line with the `current` background color
48
    pub highlight_line: bool,
49
}
50
51
impl<'a> ItemRenderer<'a> {
52
    /// Build a renderer from an [`ItemList`] and the pre-computed `container_width`
53
    /// (inner area width minus the icon prefix columns).
54
2.68k
    pub fn new_for(list: &'a ItemList, container_width: usize) -> Self {
55
2.68k
        Self {
56
2.68k
            theme: &list.theme,
57
2.68k
            selector_icon: &list.selector_icon,
58
2.68k
            multi_select_icon: &list.multi_select_icon,
59
2.68k
            ellipsis: &list.ellipsis,
60
2.68k
            container_width,
61
2.68k
            wrap: list.wrap,
62
2.68k
            multiline: list.multiline.as_deref(),
63
2.68k
            show_score: list.show_score,
64
2.68k
            show_index: list.show_index,
65
2.68k
            multi_select: list.multi_select,
66
2.68k
            tabstop: list.tabstop,
67
2.68k
            no_hscroll: list.no_hscroll,
68
2.68k
            keep_right: list.keep_right,
69
2.68k
            manual_hscroll: list.manual_hscroll,
70
2.68k
            skip_to_pattern: list.skip_to_pattern.as_ref(),
71
2.68k
            reverse_sub_lines: list.direction == ListDirection::BottomToTop,
72
2.68k
            highlight_line: list.highlight_line,
73
2.68k
        }
74
2.68k
    }
75
76
    /// Render a single `MatchedItem` into one or more flat `ListItem`s (one per
77
    /// visible sub-line), appending them to `out`.
78
    ///
79
    /// `is_current` / `is_selected` control cursor/selection styling.
80
    /// `skip_subs` leading sub-lines are omitted (for partial top-of-screen scroll).
81
    /// `available_rows` is how many rows remain; rendering stops when exhausted.
82
    /// Returns the number of rows appended.
83
    #[allow(clippy::too_many_arguments)]
84
6.46k
    pub fn render_item(
85
6.46k
        &self,
86
6.46k
        item: &MatchedItem,
87
6.46k
        is_current: bool,
88
6.46k
        is_selected: bool,
89
6.46k
        skip_subs: usize,
90
6.46k
        available_rows: usize,
91
6.46k
        rows_used: usize,
92
6.46k
        out: &mut Vec<ListItem<'static>>,
93
6.46k
    ) -> usize {
94
6.46k
        let item_text = item.item.text();
95
        // When fields are hidden (--hide-nth), project the display text and match
96
        // positions into the visible coordinate space so hidden characters are ignored
97
        // for sub-line splitting, highlighting, and horizontal scrolling. `display()`
98
        // performs the same projection, keeping the styled line consistent with these
99
        // positions.
100
6.46k
        let (display_text, match_start_char, match_end_char): (std::borrow::Cow<'_, str>, usize, usize) =
101
6.46k
            match item.item.hidden_ranges() {
102
45
                Some(hidden) if !hidden.is_empty() => {
  Branch (102:33): [True: 45, False: 0]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/item_renderer.rs
Line
Count
Source
1
use ratatui::style::Color;
2
use ratatui::text::{Line, Span};
3
use ratatui::widgets::{ListDirection, ListItem};
4
use unicode_display_width::width as display_width;
5
6
use crate::item::MatchedItem;
7
use crate::theme::ColorTheme;
8
use crate::tui::item_list::ItemList;
9
use crate::tui::util::{char_display_width, clip_line_to_chars, wrap_text};
10
use crate::{DisplayContext, MatchRange};
11
12
#[allow(clippy::struct_excessive_bools)]
13
struct SubLineState {
14
    /// Current item
15
    is_current: bool,
16
    /// Selected (in multi-select)
17
    is_selected: bool,
18
    /// First item on the screen
19
    is_first: bool,
20
    /// First line of the item
21
    is_first_sub_line: bool,
22
    needs_ellipsis: bool,
23
}
24
25
/// Rendering parameters that are constant across all items in one render pass.
26
#[allow(clippy::struct_excessive_bools)]
27
pub(crate) struct ItemRenderer<'a> {
28
    pub theme: &'a ColorTheme,
29
    pub selector_icon: &'a str,
30
    pub multi_select_icon: &'a str,
31
    pub ellipsis: &'a str,
32
    pub container_width: usize,
33
    pub wrap: bool,
34
    pub multiline: Option<&'a str>,
35
    pub show_score: bool,
36
    pub show_index: bool,
37
    pub multi_select: bool,
38
    pub tabstop: usize,
39
    pub no_hscroll: bool,
40
    pub keep_right: bool,
41
    pub manual_hscroll: i32,
42
    pub skip_to_pattern: Option<&'a regex::Regex>,
43
    /// When true, reverse the order of sub-lines within each multiline item
44
    /// before appending to the output. Required for `BottomToTop` list direction
45
    /// so that sub-line 0 appears visually above sub-line 1.
46
    pub reverse_sub_lines: bool,
47
    /// When true, fill the rest of the current line with the `current` background color
48
    pub highlight_line: bool,
49
}
50
51
impl<'a> ItemRenderer<'a> {
52
    /// Build a renderer from an [`ItemList`] and the pre-computed `container_width`
53
    /// (inner area width minus the icon prefix columns).
54
2.68k
    pub fn new_for(list: &'a ItemList, container_width: usize) -> Self {
55
2.68k
        Self {
56
2.68k
            theme: &list.theme,
57
2.68k
            selector_icon: &list.selector_icon,
58
2.68k
            multi_select_icon: &list.multi_select_icon,
59
2.68k
            ellipsis: &list.ellipsis,
60
2.68k
            container_width,
61
2.68k
            wrap: list.wrap,
62
2.68k
            multiline: list.multiline.as_deref(),
63
2.68k
            show_score: list.show_score,
64
2.68k
            show_index: list.show_index,
65
2.68k
            multi_select: list.multi_select,
66
2.68k
            tabstop: list.tabstop,
67
2.68k
            no_hscroll: list.no_hscroll,
68
2.68k
            keep_right: list.keep_right,
69
2.68k
            manual_hscroll: list.manual_hscroll,
70
2.68k
            skip_to_pattern: list.skip_to_pattern.as_ref(),
71
2.68k
            reverse_sub_lines: list.direction == ListDirection::BottomToTop,
72
2.68k
            highlight_line: list.highlight_line,
73
2.68k
        }
74
2.68k
    }
75
76
    /// Render a single `MatchedItem` into one or more flat `ListItem`s (one per
77
    /// visible sub-line), appending them to `out`.
78
    ///
79
    /// `is_current` / `is_selected` control cursor/selection styling.
80
    /// `skip_subs` leading sub-lines are omitted (for partial top-of-screen scroll).
81
    /// `available_rows` is how many rows remain; rendering stops when exhausted.
82
    /// Returns the number of rows appended.
83
    #[allow(clippy::too_many_arguments)]
84
6.44k
    pub fn render_item(
85
6.44k
        &self,
86
6.44k
        item: &MatchedItem,
87
6.44k
        is_current: bool,
88
6.44k
        is_selected: bool,
89
6.44k
        skip_subs: usize,
90
6.44k
        available_rows: usize,
91
6.44k
        rows_used: usize,
92
6.44k
        out: &mut Vec<ListItem<'static>>,
93
6.44k
    ) -> usize {
94
6.44k
        let item_text = item.item.text();
95
        // When fields are hidden (--hide-nth), project the display text and match
96
        // positions into the visible coordinate space so hidden characters are ignored
97
        // for sub-line splitting, highlighting, and horizontal scrolling. `display()`
98
        // performs the same projection, keeping the styled line consistent with these
99
        // positions.
100
6.44k
        let (display_text, match_start_char, match_end_char): (std::borrow::Cow<'_, str>, usize, usize) =
101
6.44k
            match item.item.hidden_ranges() {
102
45
                Some(hidden) if !hidden.is_empty() => {
  Branch (102:33): [True: 45, False: 0]
 
  Branch (102:33): [True: 0, False: 0]
-
103
45
                    let (visible, map) = crate::helper::item::project_visible_text(item_text.as_ref(), hidden);
104
45
                    let matches = Self::display_matches(item.matched_range.as_ref());
105
45
                    let indices = crate::helper::item::project_match_indices(item_text.as_ref(), &matches, &map);
106
45
                    let (start, end) = match (indices.first(), indices.last()) {
107
3
                        (Some(first), Some(last)) => (*first, *last + 1),
108
42
                        _ => (0, 0),
109
                    };
110
45
                    (std::borrow::Cow::Owned(visible), start, end)
111
                }
112
                _ => {
113
6.42k
                    let (start, end) = Self::matched_range(item_text.as_ref(), item.matched_range.as_ref());
114
6.42k
                    (item_text, start, end)
115
                }
116
            };
117
6.46k
        let sub_lines = self.split_sub_lines(display_text.as_ref());
118
119
6.46k
        let mut added = 0usize;
120
        // Collect rows for this item into a temporary buffer so we can reverse
121
        // them when rendering in BottomToTop direction.
122
6.46k
        let mut item_rows: Vec<ListItem<'static>> = Vec::new();
123
124
6.99k
        for (sub_idx, sub_text) in 
sub_lines.iter()6.46k
.
enumerate6.46k
().
skip6.46k
(
skip_subs6.46k
) {
125
6.99k
            if rows_used + added >= available_rows {
  Branch (125:16): [True: 0, False: 6.88k]
-
  Branch (125:16): [True: 0, False: 107]
-
126
0
                break;
127
6.99k
            }
128
129
6.99k
            let is_first = sub_idx == skip_subs;
130
6.99k
            let is_top_cutoff = is_first && 
skip_subs > 06.46k
;
  Branch (130:33): [True: 6.36k, False: 524]
-
  Branch (130:33): [True: 106, False: 1]
-
131
6.99k
            let is_bottom_cutoff = rows_used + added + 1 >= available_rows && 
sub_idx + 1108
< sub_lines.len();
  Branch (131:36): [True: 92, False: 6.79k]
-
  Branch (131:36): [True: 16, False: 91]
-
132
6.99k
            let list_item = self.render_sub_line(
133
6.99k
                item,
134
6.99k
                sub_text,
135
                &SubLineState {
136
6.99k
                    is_current,
137
6.99k
                    is_selected,
138
6.99k
                    is_first,
139
6.99k
                    is_first_sub_line: sub_idx == 0,
140
6.99k
                    needs_ellipsis: is_top_cutoff || 
is_bottom_cutoff6.99k
,
  Branch (140:37): [True: 0, False: 6.88k]
-
  Branch (140:37): [True: 1, False: 106]
-
141
                },
142
6.99k
                match_start_char,
143
6.99k
                match_end_char,
144
            );
145
146
6.99k
            item_rows.push(list_item);
147
6.99k
            added += 1;
148
        }
149
150
6.46k
        if self.reverse_sub_lines {
  Branch (150:12): [True: 6.10k, False: 258]
-
  Branch (150:12): [True: 105, False: 1]
-
151
6.20k
            item_rows.reverse();
152
6.20k
        
}259
153
6.46k
        out.extend(item_rows);
154
155
6.46k
        added
156
6.46k
    }
157
158
6.46k
    fn split_sub_lines<'b>(&self, item_text: &'b str) -> Vec<&'b str> {
159
6.46k
        if let Some(
sep667
) = self.multiline {
  Branch (159:16): [True: 665, False: 5.69k]
-
  Branch (159:16): [True: 2, False: 106]
-
160
667
            item_text.split(sep).collect()
161
        } else {
162
5.80k
            vec![item_text]
163
        }
164
6.46k
    }
165
166
6.42k
    fn matched_range(item_text: &str, matched_range: Option<&MatchRange>) -> (usize, usize) {
167
6.31k
        match matched_range {
168
1.66k
            Some(MatchRange::Chars(indices)) => {
169
1.66k
                if indices.is_empty() {
  Branch (169:20): [True: 6, False: 1.65k]
+
103
45
                    let (visible, map) = crate::helper::item::project_visible_text(item_text.as_ref(), hidden);
104
45
                    let matches = Self::display_matches(item.matched_range.as_ref());
105
45
                    let indices = crate::helper::item::project_match_indices(item_text.as_ref(), &matches, &map);
106
45
                    let (start, end) = match (indices.first(), indices.last()) {
107
3
                        (Some(first), Some(last)) => (*first, *last + 1),
108
42
                        _ => (0, 0),
109
                    };
110
45
                    (std::borrow::Cow::Owned(visible), start, end)
111
                }
112
                _ => {
113
6.40k
                    let (start, end) = Self::matched_range(item_text.as_ref(), item.matched_range.as_ref());
114
6.40k
                    (item_text, start, end)
115
                }
116
            };
117
6.44k
        let sub_lines = self.split_sub_lines(display_text.as_ref());
118
119
6.44k
        let mut added = 0usize;
120
        // Collect rows for this item into a temporary buffer so we can reverse
121
        // them when rendering in BottomToTop direction.
122
6.44k
        let mut item_rows: Vec<ListItem<'static>> = Vec::new();
123
124
6.97k
        for (sub_idx, sub_text) in 
sub_lines.iter()6.44k
.
enumerate6.44k
().
skip6.44k
(
skip_subs6.44k
) {
125
6.97k
            if rows_used + added >= available_rows {
  Branch (125:16): [True: 0, False: 6.86k]
+
  Branch (125:16): [True: 0, False: 108]
+
126
0
                break;
127
6.97k
            }
128
129
6.97k
            let is_first = sub_idx == skip_subs;
130
6.97k
            let is_top_cutoff = is_first && 
skip_subs > 06.44k
;
  Branch (130:33): [True: 6.33k, False: 524]
+
  Branch (130:33): [True: 107, False: 1]
+
131
6.97k
            let is_bottom_cutoff = rows_used + added + 1 >= available_rows && 
sub_idx + 1106
< sub_lines.len();
  Branch (131:36): [True: 90, False: 6.77k]
+
  Branch (131:36): [True: 16, False: 92]
+
132
6.97k
            let list_item = self.render_sub_line(
133
6.97k
                item,
134
6.97k
                sub_text,
135
                &SubLineState {
136
6.97k
                    is_current,
137
6.97k
                    is_selected,
138
6.97k
                    is_first,
139
6.97k
                    is_first_sub_line: sub_idx == 0,
140
6.97k
                    needs_ellipsis: is_top_cutoff || 
is_bottom_cutoff6.96k
,
  Branch (140:37): [True: 0, False: 6.86k]
+
  Branch (140:37): [True: 1, False: 107]
+
141
                },
142
6.97k
                match_start_char,
143
6.97k
                match_end_char,
144
            );
145
146
6.97k
            item_rows.push(list_item);
147
6.97k
            added += 1;
148
        }
149
150
6.44k
        if self.reverse_sub_lines {
  Branch (150:12): [True: 6.08k, False: 258]
+
  Branch (150:12): [True: 106, False: 1]
+
151
6.18k
            item_rows.reverse();
152
6.18k
        
}259
153
6.44k
        out.extend(item_rows);
154
155
6.44k
        added
156
6.44k
    }
157
158
6.44k
    fn split_sub_lines<'b>(&self, item_text: &'b str) -> Vec<&'b str> {
159
6.44k
        if let Some(
sep667
) = self.multiline {
  Branch (159:16): [True: 665, False: 5.67k]
+
  Branch (159:16): [True: 2, False: 107]
+
160
667
            item_text.split(sep).collect()
161
        } else {
162
5.78k
            vec![item_text]
163
        }
164
6.44k
    }
165
166
6.40k
    fn matched_range(item_text: &str, matched_range: Option<&MatchRange>) -> (usize, usize) {
167
6.29k
        match matched_range {
168
1.66k
            Some(MatchRange::Chars(indices)) => {
169
1.66k
                if indices.is_empty() {
  Branch (169:20): [True: 6, False: 1.65k]
 
  Branch (169:20): [True: 1, False: 1]
-
170
7
                    (0, 0)
171
                } else {
172
1.65k
                    (indices[0], indices[indices.len() - 1] + 1)
173
                }
174
            }
175
4.65k
            Some(MatchRange::ByteRange(start, end)) => {
176
4.65k
                let start_char = item_text[..*start].chars().count();
177
4.65k
                let len = item_text[*start..*end].chars().count();
178
4.65k
                (start_char, start_char + len)
179
            }
180
1
            Some(MatchRange::CharRange(start, end)) => (*start, *end),
181
108
            None => (0, 0),
182
        }
183
6.42k
    }
184
185
6.99k
    fn render_sub_line(
186
6.99k
        &self,
187
6.99k
        item: &MatchedItem,
188
6.99k
        sub_text: &str,
189
6.99k
        state: &SubLineState,
190
6.99k
        match_start_char: usize,
191
6.99k
        match_end_char: usize,
192
6.99k
    ) -> ListItem<'static> {
193
6.99k
        let mut all_spans = self.prefix_spans(item, state);
194
6.99k
        let content_line = self.content_line(item, sub_text, state, match_start_char, match_end_char);
195
196
6.99k
        if state.needs_ellipsis {
  Branch (196:12): [True: 0, False: 6.88k]
-
  Branch (196:12): [True: 1, False: 106]
-
197
1
            all_spans.extend(self.trim_with_ellipsis(content_line, state.is_current));
198
6.99k
        } else {
199
6.99k
            all_spans.extend(content_line.spans);
200
6.99k
        }
201
202
6.99k
        if item.item.disabled() {
  Branch (202:12): [True: 0, False: 6.88k]
-
  Branch (202:12): [True: 0, False: 107]
-
203
0
            for span in &mut all_spans {
204
0
                span.style = span.style.dim();
205
0
            }
206
6.99k
        }
207
208
6.99k
        self.list_item_from_spans(all_spans, state.is_current)
209
6.99k
    }
210
211
6.99k
    fn prefix_spans(&self, item: &MatchedItem, state: &SubLineState) -> Vec<Span<'static>> {
212
6.99k
        let mut prefix: Vec<Span<'static>> = Vec::with_capacity(4);
213
        // When highlight_line is active for the current item, the line-level style fills the
214
        // entire row with the current background. Reset the bg on prefix spans so the
215
        // selector/marker columns are not highlighted.
216
6.99k
        let prefix_cursor_style = if self.highlight_line && 
state.is_current208
{
  Branch (216:38): [True: 207, False: 6.67k]
+
170
7
                    (0, 0)
171
                } else {
172
1.65k
                    (indices[0], indices[indices.len() - 1] + 1)
173
                }
174
            }
175
4.63k
            Some(MatchRange::ByteRange(start, end)) => {
176
4.63k
                let start_char = item_text[..*start].chars().count();
177
4.63k
                let len = item_text[*start..*end].chars().count();
178
4.63k
                (start_char, start_char + len)
179
            }
180
1
            Some(MatchRange::CharRange(start, end)) => (*start, *end),
181
108
            None => (0, 0),
182
        }
183
6.40k
    }
184
185
6.97k
    fn render_sub_line(
186
6.97k
        &self,
187
6.97k
        item: &MatchedItem,
188
6.97k
        sub_text: &str,
189
6.97k
        state: &SubLineState,
190
6.97k
        match_start_char: usize,
191
6.97k
        match_end_char: usize,
192
6.97k
    ) -> ListItem<'static> {
193
6.97k
        let mut all_spans = self.prefix_spans(item, state);
194
6.97k
        let content_line = self.content_line(item, sub_text, state, match_start_char, match_end_char);
195
196
6.97k
        if state.needs_ellipsis {
  Branch (196:12): [True: 0, False: 6.86k]
+
  Branch (196:12): [True: 1, False: 107]
+
197
1
            all_spans.extend(self.trim_with_ellipsis(content_line, state.is_current));
198
6.96k
        } else {
199
6.96k
            all_spans.extend(content_line.spans);
200
6.96k
        }
201
202
6.97k
        if item.item.disabled() {
  Branch (202:12): [True: 0, False: 6.86k]
+
  Branch (202:12): [True: 0, False: 108]
+
203
0
            for span in &mut all_spans {
204
0
                span.style = span.style.dim();
205
0
            }
206
6.97k
        }
207
208
6.97k
        self.list_item_from_spans(all_spans, state.is_current)
209
6.97k
    }
210
211
6.97k
    fn prefix_spans(&self, item: &MatchedItem, state: &SubLineState) -> Vec<Span<'static>> {
212
6.97k
        let mut prefix: Vec<Span<'static>> = Vec::with_capacity(4);
213
        // When highlight_line is active for the current item, the line-level style fills the
214
        // entire row with the current background. Reset the bg on prefix spans so the
215
        // selector/marker columns are not highlighted.
216
6.97k
        let prefix_cursor_style = if self.highlight_line && 
state.is_current208
{
  Branch (216:38): [True: 207, False: 6.65k]
   Branch (216:61): [True: 9, False: 198]
-
  Branch (216:38): [True: 1, False: 109]
+
  Branch (216:38): [True: 1, False: 110]
   Branch (216:61): [True: 1, False: 0]
-
217
10
            self.theme.cursor.bg(self.theme.cursor.bg.unwrap_or(Color::Reset))
218
        } else {
219
6.98k
            self.theme.cursor
220
        };
221
6.99k
        let prefix_selected_style = if self.highlight_line && 
state.is_current208
{
  Branch (221:40): [True: 207, False: 6.67k]
+
217
10
            self.theme.cursor.bg(self.theme.cursor.bg.unwrap_or(Color::Reset))
218
        } else {
219
6.96k
            self.theme.cursor
220
        };
221
6.97k
        let prefix_selected_style = if self.highlight_line && 
state.is_current208
{
  Branch (221:40): [True: 207, False: 6.65k]
   Branch (221:63): [True: 9, False: 198]
-
  Branch (221:40): [True: 1, False: 109]
+
  Branch (221:40): [True: 1, False: 110]
   Branch (221:63): [True: 1, False: 0]
-
222
10
            self.theme.selected.bg(self.theme.selected.bg.unwrap_or(Color::Reset))
223
        } else {
224
6.98k
            self.theme.selected
225
        };
226
227
6.99k
        prefix.push(Span::styled(
228
6.99k
            if state.is_first && 
state.is_current6.46k
{
  Branch (228:16): [True: 6.36k, False: 524]
-  Branch (228:34): [True: 2.06k, False: 4.29k]
-
  Branch (228:16): [True: 108, False: 2]
-  Branch (228:34): [True: 34, False: 74]
-
229
2.09k
                self.selector_icon.to_owned()
230
            } else {
231
4.89k
                str::repeat(" ", self.selector_icon.chars().count())
232
            },
233
6.99k
            prefix_cursor_style,
234
        ));
235
6.99k
        prefix.push(Span::styled(
236
6.99k
            if state.is_first && 
self.multi_select6.46k
&&
state.is_selected394
{
  Branch (236:16): [True: 6.36k, False: 524]
-  Branch (236:34): [True: 392, False: 5.96k]
+
222
10
            self.theme.selected.bg(self.theme.selected.bg.unwrap_or(Color::Reset))
223
        } else {
224
6.96k
            self.theme.selected
225
        };
226
227
6.97k
        prefix.push(Span::styled(
228
6.97k
            if state.is_first && 
state.is_current6.44k
{
  Branch (228:16): [True: 6.33k, False: 524]
+  Branch (228:34): [True: 2.05k, False: 4.28k]
+
  Branch (228:16): [True: 109, False: 2]
+  Branch (228:34): [True: 35, False: 74]
+
229
2.09k
                self.selector_icon.to_owned()
230
            } else {
231
4.88k
                str::repeat(" ", self.selector_icon.chars().count())
232
            },
233
6.97k
            prefix_cursor_style,
234
        ));
235
6.97k
        prefix.push(Span::styled(
236
6.97k
            if state.is_first && 
self.multi_select6.44k
&&
state.is_selected394
{
  Branch (236:16): [True: 6.33k, False: 524]
+  Branch (236:34): [True: 392, False: 5.94k]
   Branch (236:55): [True: 150, False: 242]
-
  Branch (236:16): [True: 108, False: 2]
-  Branch (236:34): [True: 2, False: 106]
+
  Branch (236:16): [True: 109, False: 2]
+  Branch (236:34): [True: 2, False: 107]
   Branch (236:55): [True: 2, False: 0]
-
237
152
                self.multi_select_icon.to_owned()
238
            } else {
239
6.84k
                str::repeat(" ", self.multi_select_icon.chars().count())
240
            },
241
6.99k
            prefix_selected_style,
242
        ));
243
6.99k
        if self.show_score {
  Branch (243:12): [True: 0, False: 6.88k]
-
  Branch (243:12): [True: 2, False: 108]
-
244
2
            self.push_first_line_field(&mut prefix, state, item.rank.score);
245
6.99k
        }
246
6.99k
        if self.show_index {
  Branch (246:12): [True: 0, False: 6.88k]
-
  Branch (246:12): [True: 2, False: 108]
-
247
2
            self.push_first_line_field(&mut prefix, state, item.rank.index);
248
6.99k
        }
249
250
6.99k
        prefix
251
6.99k
    }
252
253
4
    fn push_first_line_field(
254
4
        &self,
255
4
        prefix: &mut Vec<Span<'static>>,
256
4
        state: &SubLineState,
257
4
        value: impl std::fmt::Display,
258
4
    ) {
259
4
        let value = format!("[{value}] ");
260
4
        prefix.push(Span::styled(
261
4
            if state.is_first {
  Branch (261:16): [True: 0, False: 0]
+
237
152
                self.multi_select_icon.to_owned()
238
            } else {
239
6.82k
                str::repeat(" ", self.multi_select_icon.chars().count())
240
            },
241
6.97k
            prefix_selected_style,
242
        ));
243
6.97k
        if self.show_score {
  Branch (243:12): [True: 0, False: 6.86k]
+
  Branch (243:12): [True: 2, False: 109]
+
244
2
            self.push_first_line_field(&mut prefix, state, item.rank.score);
245
6.97k
        }
246
6.97k
        if self.show_index {
  Branch (246:12): [True: 0, False: 6.86k]
+
  Branch (246:12): [True: 2, False: 109]
+
247
2
            self.push_first_line_field(&mut prefix, state, item.rank.index);
248
6.97k
        }
249
250
6.97k
        prefix
251
6.97k
    }
252
253
4
    fn push_first_line_field(
254
4
        &self,
255
4
        prefix: &mut Vec<Span<'static>>,
256
4
        state: &SubLineState,
257
4
        value: impl std::fmt::Display,
258
4
    ) {
259
4
        let value = format!("[{value}] ");
260
4
        prefix.push(Span::styled(
261
4
            if state.is_first {
  Branch (261:16): [True: 0, False: 0]
 
  Branch (261:16): [True: 2, False: 2]
-
262
2
                value.clone()
263
            } else {
264
2
                str::repeat(" ", value.chars().count())
265
            },
266
4
            self.base_style(state.is_current),
267
        ));
268
4
    }
269
270
6.99k
    fn content_line(
271
6.99k
        &self,
272
6.99k
        item: &MatchedItem,
273
6.99k
        sub_text: &str,
274
6.99k
        state: &SubLineState,
275
6.99k
        match_start_char: usize,
276
6.99k
        match_end_char: usize,
277
6.99k
    ) -> Line<'static> {
278
6.99k
        if state.is_first && 
state.is_first_sub_line6.46k
{
  Branch (278:12): [True: 6.36k, False: 524]
-  Branch (278:30): [True: 6.36k, False: 0]
-
  Branch (278:12): [True: 106, False: 1]
-  Branch (278:30): [True: 105, False: 1]
-
279
6.46k
            self.first_sub_line_content(item, sub_text, state.is_current, match_start_char, match_end_char)
280
        } else {
281
526
            self.continuation_sub_line_content(sub_text, state.is_current)
282
        }
283
6.99k
    }
284
285
6.46k
    fn first_sub_line_content(
286
6.46k
        &self,
287
6.46k
        item: &MatchedItem,
288
6.46k
        first_sub_line: &str,
289
6.46k
        is_current: bool,
290
6.46k
        match_start_char: usize,
291
6.46k
        match_end_char: usize,
292
6.46k
    ) -> Line<'static> {
293
6.46k
        let first_sub_char_len = first_sub_line.chars().count();
294
6.46k
        let dl = item.item.display(DisplayContext {
295
6.46k
            score: item.rank.score,
296
6.46k
            matches: Self::display_matches(item.matched_range.as_ref()),
297
6.46k
            container_width: self.container_width,
298
6.46k
            base_style: self.base_style(is_current),
299
6.46k
            matched_style: if is_current {
  Branch (299:31): [True: 2.06k, False: 4.29k]
-
  Branch (299:31): [True: 32, False: 74]
-
300
2.09k
                self.theme.current_match
301
            } else {
302
4.37k
                self.theme.matched
303
            },
304
        });
305
306
        // In multiline mode the first rendered line must be clipped to the first text sub-line.
307
        // Without multiline, custom display text may legitimately be longer than item.text().
308
6.46k
        let mut line = if self.multiline.is_some() {
  Branch (308:27): [True: 665, False: 5.69k]
-
  Branch (308:27): [True: 0, False: 106]
-
309
665
            clip_line_to_chars(dl, first_sub_char_len)
310
        } else {
311
5.80k
            dl
312
        };
313
6.46k
        if !self.wrap {
  Branch (313:12): [True: 6.35k, False: 9]
-
  Branch (313:12): [True: 106, False: 0]
-
314
6.45k
            let (shift, full_width, _, _) = if self.multiline.is_some() {
  Branch (314:48): [True: 659, False: 5.69k]
-
  Branch (314:48): [True: 0, False: 106]
-
315
659
                self.calc_hscroll(first_sub_line, match_start_char, match_end_char)
316
            } else {
317
5.79k
                self.calc_line_hscroll(&line, first_sub_line, match_start_char, match_end_char)
318
            };
319
6.45k
            line = self.apply_hscroll(line, shift, full_width);
320
9
        }
321
6.46k
        Self::into_static_line(line)
322
6.46k
    }
323
324
527
    fn continuation_sub_line_content(&self, sub_text: &str, is_current: bool) -> Line<'static> {
325
527
        let sub_str = sub_text.to_string();
326
527
        let raw: Line<'static> = Line::from(vec![Span::styled(sub_str, self.base_style(is_current))]);
327
527
        if self.wrap {
  Branch (327:12): [True: 6, False: 518]
+
262
2
                value.clone()
263
            } else {
264
2
                str::repeat(" ", value.chars().count())
265
            },
266
4
            self.base_style(state.is_current),
267
        ));
268
4
    }
269
270
6.97k
    fn content_line(
271
6.97k
        &self,
272
6.97k
        item: &MatchedItem,
273
6.97k
        sub_text: &str,
274
6.97k
        state: &SubLineState,
275
6.97k
        match_start_char: usize,
276
6.97k
        match_end_char: usize,
277
6.97k
    ) -> Line<'static> {
278
6.97k
        if state.is_first && 
state.is_first_sub_line6.44k
{
  Branch (278:12): [True: 6.33k, False: 524]
+  Branch (278:30): [True: 6.33k, False: 0]
+
  Branch (278:12): [True: 107, False: 1]
+  Branch (278:30): [True: 106, False: 1]
+
279
6.44k
            self.first_sub_line_content(item, sub_text, state.is_current, match_start_char, match_end_char)
280
        } else {
281
526
            self.continuation_sub_line_content(sub_text, state.is_current)
282
        }
283
6.97k
    }
284
285
6.44k
    fn first_sub_line_content(
286
6.44k
        &self,
287
6.44k
        item: &MatchedItem,
288
6.44k
        first_sub_line: &str,
289
6.44k
        is_current: bool,
290
6.44k
        match_start_char: usize,
291
6.44k
        match_end_char: usize,
292
6.44k
    ) -> Line<'static> {
293
6.44k
        let first_sub_char_len = first_sub_line.chars().count();
294
6.44k
        let dl = item.item.display(DisplayContext {
295
6.44k
            score: item.rank.score,
296
6.44k
            matches: Self::display_matches(item.matched_range.as_ref()),
297
6.44k
            container_width: self.container_width,
298
6.44k
            base_style: self.base_style(is_current),
299
6.44k
            matched_style: if is_current {
  Branch (299:31): [True: 2.05k, False: 4.28k]
+
  Branch (299:31): [True: 33, False: 74]
+
300
2.09k
                self.theme.current_match
301
            } else {
302
4.35k
                self.theme.matched
303
            },
304
        });
305
306
        // In multiline mode the first rendered line must be clipped to the first text sub-line.
307
        // Without multiline, custom display text may legitimately be longer than item.text().
308
6.44k
        let mut line = if self.multiline.is_some() {
  Branch (308:27): [True: 665, False: 5.67k]
+
  Branch (308:27): [True: 0, False: 107]
+
309
665
            clip_line_to_chars(dl, first_sub_char_len)
310
        } else {
311
5.78k
            dl
312
        };
313
6.44k
        if !self.wrap {
  Branch (313:12): [True: 6.32k, False: 9]
+
  Branch (313:12): [True: 107, False: 0]
+
314
6.43k
            let (shift, full_width, _, _) = if self.multiline.is_some() {
  Branch (314:48): [True: 659, False: 5.67k]
+
  Branch (314:48): [True: 0, False: 107]
+
315
659
                self.calc_hscroll(first_sub_line, match_start_char, match_end_char)
316
            } else {
317
5.77k
                self.calc_line_hscroll(&line, first_sub_line, match_start_char, match_end_char)
318
            };
319
6.43k
            line = self.apply_hscroll(line, shift, full_width);
320
9
        }
321
6.44k
        Self::into_static_line(line)
322
6.44k
    }
323
324
527
    fn continuation_sub_line_content(&self, sub_text: &str, is_current: bool) -> Line<'static> {
325
527
        let sub_str = sub_text.to_string();
326
527
        let raw: Line<'static> = Line::from(vec![Span::styled(sub_str, self.base_style(is_current))]);
327
527
        if self.wrap {
  Branch (327:12): [True: 6, False: 518]
 
  Branch (327:12): [True: 0, False: 3]
-
328
6
            raw
329
        } else {
330
521
            let (shift, full_width, _, _) = self.calc_hscroll(sub_text, 0, 0);
331
521
            let scrolled = self.apply_hscroll(raw, shift, full_width);
332
521
            Self::into_static_line(scrolled)
333
        }
334
527
    }
335
336
6.51k
    fn display_matches(matched_range: Option<&MatchRange>) -> crate::Matches {
337
6.40k
        match matched_range {
338
4.71k
            Some(MatchRange::ByteRange(start, end)) => crate::Matches::ByteRange(*start, *end),
339
1
            Some(MatchRange::CharRange(start, end)) => crate::Matches::CharRange(*start, *end),
340
1.69k
            Some(MatchRange::Chars(chars)) => crate::Matches::CharIndices(chars.clone()),
341
108
            None => crate::Matches::None,
342
        }
343
6.51k
    }
344
345
2
    fn trim_with_ellipsis(&self, content_line: Line<'static>, is_current: bool) -> Vec<Span<'static>> {
346
2
        let ell_width = usize::try_from(display_width(self.ellipsis)).unwrap();
347
2
        let available = self.container_width.saturating_sub(ell_width);
348
2
        let mut trimmed: Vec<Span<'static>> = Vec::new();
349
2
        let mut used = 0usize;
350
351
3
        'trim: for span in 
content_line.spans2
{
352
3
            let span_chars: Vec<char> = span.content.chars().collect();
353
11
            for (i, ch) in 
span_chars.iter()3
.
enumerate3
() {
354
11
                let w = char_display_width(*ch);
355
11
                if used + w > available {
  Branch (355:20): [True: 0, False: 0]
+
328
6
            raw
329
        } else {
330
521
            let (shift, full_width, _, _) = self.calc_hscroll(sub_text, 0, 0);
331
521
            let scrolled = self.apply_hscroll(raw, shift, full_width);
332
521
            Self::into_static_line(scrolled)
333
        }
334
527
    }
335
336
6.49k
    fn display_matches(matched_range: Option<&MatchRange>) -> crate::Matches {
337
6.38k
        match matched_range {
338
4.69k
            Some(MatchRange::ByteRange(start, end)) => crate::Matches::ByteRange(*start, *end),
339
1
            Some(MatchRange::CharRange(start, end)) => crate::Matches::CharRange(*start, *end),
340
1.69k
            Some(MatchRange::Chars(chars)) => crate::Matches::CharIndices(chars.clone()),
341
108
            None => crate::Matches::None,
342
        }
343
6.49k
    }
344
345
2
    fn trim_with_ellipsis(&self, content_line: Line<'static>, is_current: bool) -> Vec<Span<'static>> {
346
2
        let ell_width = usize::try_from(display_width(self.ellipsis)).unwrap();
347
2
        let available = self.container_width.saturating_sub(ell_width);
348
2
        let mut trimmed: Vec<Span<'static>> = Vec::new();
349
2
        let mut used = 0usize;
350
351
3
        'trim: for span in 
content_line.spans2
{
352
3
            let span_chars: Vec<char> = span.content.chars().collect();
353
11
            for (i, ch) in 
span_chars.iter()3
.
enumerate3
() {
354
11
                let w = char_display_width(*ch);
355
11
                if used + w > available {
  Branch (355:20): [True: 0, False: 0]
 
  Branch (355:20): [True: 1, False: 10]
 
356
1
                    let partial: String = span_chars[..i].iter().collect();
357
1
                    if !partial.is_empty() {
  Branch (357:24): [True: 0, False: 0]
 
  Branch (357:24): [True: 1, False: 0]
-
358
1
                        trimmed.push(Span::styled(partial, span.style));
359
1
                    
}0
360
1
                    break 'trim;
361
10
                }
362
10
                used += w;
363
            }
364
2
            trimmed.push(Span::styled(span.content.into_owned(), span.style));
365
        }
366
2
        trimmed.push(Span::styled(self.ellipsis.to_owned(), self.base_style(is_current)));
367
2
        trimmed
368
2
    }
369
370
6.99k
    fn list_item_from_spans(&self, spans: Vec<Span<'static>>, is_current: bool) -> ListItem<'static> {
371
6.99k
        if self.wrap {
  Branch (371:12): [True: 15, False: 6.86k]
-
  Branch (371:12): [True: 0, False: 107]
-
372
15
            wrap_text(
373
15
                ratatui::text::Text::from(Line::from(spans)),
374
15
                self.container_width + self.selector_icon.chars().count() + self.multi_select_icon.chars().count(),
375
            )
376
15
            .into()
377
        } else {
378
6.97k
            let mut line = Line::from(spans);
379
            // When highlight_line is enabled, set the line's style to the current theme
380
            // so ratatui fills the entire row width with the current background color.
381
6.97k
            if self.highlight_line && 
is_current207
{
  Branch (381:16): [True: 207, False: 6.66k]
+
358
1
                        trimmed.push(Span::styled(partial, span.style));
359
1
                    
}0
360
1
                    break 'trim;
361
10
                }
362
10
                used += w;
363
            }
364
2
            trimmed.push(Span::styled(span.content.into_owned(), span.style));
365
        }
366
2
        trimmed.push(Span::styled(self.ellipsis.to_owned(), self.base_style(is_current)));
367
2
        trimmed
368
2
    }
369
370
6.97k
    fn list_item_from_spans(&self, spans: Vec<Span<'static>>, is_current: bool) -> ListItem<'static> {
371
6.97k
        if self.wrap {
  Branch (371:12): [True: 15, False: 6.84k]
+
  Branch (371:12): [True: 0, False: 108]
+
372
15
            wrap_text(
373
15
                ratatui::text::Text::from(Line::from(spans)),
374
15
                self.container_width + self.selector_icon.chars().count() + self.multi_select_icon.chars().count(),
375
            )
376
15
            .into()
377
        } else {
378
6.95k
            let mut line = Line::from(spans);
379
            // When highlight_line is enabled, set the line's style to the current theme
380
            // so ratatui fills the entire row width with the current background color.
381
6.95k
            if self.highlight_line && 
is_current207
{
  Branch (381:16): [True: 207, False: 6.64k]
   Branch (381:39): [True: 9, False: 198]
-
  Branch (381:16): [True: 0, False: 107]
+
  Branch (381:16): [True: 0, False: 108]
   Branch (381:39): [True: 0, False: 0]
-
382
9
                line = line.style(self.theme.current);
383
6.96k
            }
384
6.97k
            line.into()
385
        }
386
6.99k
    }
387
388
6.99k
    fn base_style(&self, is_current: bool) -> ratatui::style::Style {
389
6.99k
        if is_current {
  Branch (389:12): [True: 2.18k, False: 4.70k]
-
  Branch (389:12): [True: 36, False: 79]
-
390
2.21k
            self.theme.current
391
        } else {
392
4.78k
            self.theme.normal
393
        }
394
6.99k
    }
395
396
6.98k
    fn into_static_line(line: Line<'_>) -> Line<'static> {
397
6.98k
        line.spans
398
6.98k
            .into_iter()
399
11.0k
            .
map6.98k
(|span| Span::styled(span.content.into_owned(), span.style))
400
6.98k
            .collect()
401
6.98k
    }
402
403
    // ── hscroll helpers ──────────────────────────────────────────────────────
404
405
5.25k
    fn calc_skip_width(&self, text: &str) -> usize {
406
5.25k
        if let Some(
regex7
) = self.skip_to_pattern
  Branch (406:16): [True: 7, False: 5.13k]
-
  Branch (406:16): [True: 0, False: 110]
+
382
9
                line = line.style(self.theme.current);
383
6.94k
            }
384
6.95k
            line.into()
385
        }
386
6.97k
    }
387
388
6.97k
    fn base_style(&self, is_current: bool) -> ratatui::style::Style {
389
6.97k
        if is_current {
  Branch (389:12): [True: 2.17k, False: 4.68k]
+
  Branch (389:12): [True: 37, False: 79]
+
390
2.21k
            self.theme.current
391
        } else {
392
4.76k
            self.theme.normal
393
        }
394
6.97k
    }
395
396
6.96k
    fn into_static_line(line: Line<'_>) -> Line<'static> {
397
6.96k
        line.spans
398
6.96k
            .into_iter()
399
11.0k
            .
map6.96k
(|span| Span::styled(span.content.into_owned(), span.style))
400
6.96k
            .collect()
401
6.96k
    }
402
403
    // ── hscroll helpers ──────────────────────────────────────────────────────
404
405
5.23k
    fn calc_skip_width(&self, text: &str) -> usize {
406
5.23k
        if let Some(
regex7
) = self.skip_to_pattern
  Branch (406:16): [True: 7, False: 5.11k]
+
  Branch (406:16): [True: 0, False: 111]
 
407
7
            && let Some(mat) = regex.find(text)
  Branch (407:20): [True: 7, False: 0]
 
  Branch (407:20): [True: 0, False: 0]
-
408
        {
409
7
            return usize::try_from(display_width(&text[..mat.start()])).unwrap();
410
5.24k
        }
411
5.24k
        0
412
5.25k
    }
413
414
1.18k
    fn calc_hscroll(&self, text: &str, match_start_char: usize, match_end_char: usize) -> (usize, usize, bool, bool) {
415
1.18k
        let full_width = self.text_display_width(text);
416
417
1.18k
        self.calc_hscroll_for_width(text, match_start_char, match_end_char, full_width)
418
1.18k
    }
419
420
5.79k
    fn calc_line_hscroll(
421
5.79k
        &self,
422
5.79k
        line: &Line<'_>,
423
5.79k
        text: &str,
424
5.79k
        match_start_char: usize,
425
5.79k
        match_end_char: usize,
426
5.79k
    ) -> (usize, usize, bool, bool) {
427
5.79k
        self.calc_hscroll_for_width(text, match_start_char, match_end_char, self.line_display_width(line))
428
5.79k
    }
429
430
6.98k
    fn calc_hscroll_for_width(
431
6.98k
        &self,
432
6.98k
        text: &str,
433
6.98k
        match_start_char: usize,
434
6.98k
        match_end_char: usize,
435
6.98k
        full_width: usize,
436
6.98k
    ) -> (usize, usize, bool, bool) {
437
6.98k
        let ell_w = usize::try_from(display_width(self.ellipsis)).unwrap();
438
6.98k
        let 
available_width6.98k
= if self.container_width >= ell_w {
  Branch (438:34): [True: 6.86k, False: 0]
-
  Branch (438:34): [True: 111, False: 1]
-
439
6.98k
            self.container_width
440
        } else {
441
1
            return (0, full_width, false, false);
442
        };
443
444
6.98k
        let base_shift = if self.no_hscroll {
  Branch (444:29): [True: 3, False: 6.86k]
-
  Branch (444:29): [True: 0, False: 111]
-
445
3
            0
446
6.97k
        } else if match_start_char == 0 && 
match_end_char == 06.63k
{
  Branch (446:19): [True: 6.52k, False: 344]
-  Branch (446:44): [True: 5.14k, False: 1.38k]
-
  Branch (446:19): [True: 110, False: 1]
-  Branch (446:44): [True: 110, False: 0]
-
447
5.25k
            let skip_width = self.calc_skip_width(text);
448
5.25k
            if skip_width > 0 {
  Branch (448:16): [True: 7, False: 5.13k]
-
  Branch (448:16): [True: 0, False: 110]
-
449
7
                skip_width
450
5.24k
            } else if self.keep_right {
  Branch (450:23): [True: 0, False: 5.13k]
-
  Branch (450:23): [True: 1, False: 109]
-
451
1
                full_width.saturating_sub(available_width)
452
            } else {
453
5.24k
                0
454
            }
455
        } else {
456
1.72k
            let mut match_start_width = 0;
457
1.72k
            let mut match_end_width = 0;
458
1.72k
            let mut current_width = 0;
459
1.72k
            let mut found_start = false;
460
1.72k
            let mut found_end = false;
461
462
13.6k
            for (idx, ch) in 
text1.72k
.
chars1.72k
().
enumerate1.72k
() {
463
13.6k
                if idx == match_start_char {
  Branch (463:20): [True: 1.72k, False: 11.8k]
+
408
        {
409
7
            return usize::try_from(display_width(&text[..mat.start()])).unwrap();
410
5.22k
        }
411
5.22k
        0
412
5.23k
    }
413
414
1.18k
    fn calc_hscroll(&self, text: &str, match_start_char: usize, match_end_char: usize) -> (usize, usize, bool, bool) {
415
1.18k
        let full_width = self.text_display_width(text);
416
417
1.18k
        self.calc_hscroll_for_width(text, match_start_char, match_end_char, full_width)
418
1.18k
    }
419
420
5.77k
    fn calc_line_hscroll(
421
5.77k
        &self,
422
5.77k
        line: &Line<'_>,
423
5.77k
        text: &str,
424
5.77k
        match_start_char: usize,
425
5.77k
        match_end_char: usize,
426
5.77k
    ) -> (usize, usize, bool, bool) {
427
5.77k
        self.calc_hscroll_for_width(text, match_start_char, match_end_char, self.line_display_width(line))
428
5.77k
    }
429
430
6.96k
    fn calc_hscroll_for_width(
431
6.96k
        &self,
432
6.96k
        text: &str,
433
6.96k
        match_start_char: usize,
434
6.96k
        match_end_char: usize,
435
6.96k
        full_width: usize,
436
6.96k
    ) -> (usize, usize, bool, bool) {
437
6.96k
        let ell_w = usize::try_from(display_width(self.ellipsis)).unwrap();
438
6.96k
        let 
available_width6.95k
= if self.container_width >= ell_w {
  Branch (438:34): [True: 6.84k, False: 0]
+
  Branch (438:34): [True: 112, False: 1]
+
439
6.95k
            self.container_width
440
        } else {
441
1
            return (0, full_width, false, false);
442
        };
443
444
6.95k
        let base_shift = if self.no_hscroll {
  Branch (444:29): [True: 3, False: 6.84k]
+
  Branch (444:29): [True: 0, False: 112]
+
445
3
            0
446
6.95k
        } else if match_start_char == 0 && 
match_end_char == 06.61k
{
  Branch (446:19): [True: 6.50k, False: 341]
+  Branch (446:44): [True: 5.12k, False: 1.38k]
+
  Branch (446:19): [True: 111, False: 1]
+  Branch (446:44): [True: 111, False: 0]
+
447
5.23k
            let skip_width = self.calc_skip_width(text);
448
5.23k
            if skip_width > 0 {
  Branch (448:16): [True: 7, False: 5.11k]
+
  Branch (448:16): [True: 0, False: 111]
+
449
7
                skip_width
450
5.22k
            } else if self.keep_right {
  Branch (450:23): [True: 0, False: 5.11k]
+
  Branch (450:23): [True: 1, False: 110]
+
451
1
                full_width.saturating_sub(available_width)
452
            } else {
453
5.22k
                0
454
            }
455
        } else {
456
1.72k
            let mut match_start_width = 0;
457
1.72k
            let mut match_end_width = 0;
458
1.72k
            let mut current_width = 0;
459
1.72k
            let mut found_start = false;
460
1.72k
            let mut found_end = false;
461
462
13.6k
            for (idx, ch) in 
text1.72k
.
chars1.72k
().
enumerate1.72k
() {
463
13.6k
                if idx == match_start_char {
  Branch (463:20): [True: 1.72k, False: 11.8k]
 
  Branch (463:20): [True: 1, False: 10]
 
464
1.72k
                    match_start_width = current_width;
465
1.72k
                    found_start = true;
466
11.9k
                }
467
13.6k
                if idx == match_end_char {
  Branch (467:20): [True: 1.28k, False: 12.3k]
 
  Branch (467:20): [True: 1, False: 10]
-
468
1.28k
                    match_end_width = current_width;
469
1.28k
                    found_end = true;
470
1.28k
                    break;
471
12.3k
                }
472
12.3k
                current_width = self.add_char_width(current_width, ch);
473
            }
474
1.72k
            if found_start && 
!found_end1.72k
{
  Branch (474:16): [True: 1.72k, False: 3]
-  Branch (474:31): [True: 434, False: 1.28k]
+
468
1.29k
                    match_end_width = current_width;
469
1.29k
                    found_end = true;
470
1.29k
                    break;
471
12.3k
                }
472
12.3k
                current_width = self.add_char_width(current_width, ch);
473
            }
474
1.72k
            if found_start && 
!found_end1.72k
{
  Branch (474:16): [True: 1.72k, False: 3]
+  Branch (474:31): [True: 431, False: 1.28k]
 
  Branch (474:16): [True: 1, False: 0]
   Branch (474:31): [True: 0, False: 1]
-
475
434
                match_end_width = current_width;
476
1.29k
            }
477
478
1.72k
            let match_width = match_end_width.saturating_sub(match_start_width);
479
1.72k
            if match_width >= available_width {
  Branch (479:16): [True: 0, False: 1.72k]
+
475
431
                match_end_width = current_width;
476
1.29k
            }
477
478
1.72k
            let match_width = match_end_width.saturating_sub(match_start_width);
479
1.72k
            if match_width >= available_width {
  Branch (479:16): [True: 0, False: 1.72k]
 
  Branch (479:16): [True: 1, False: 0]
-
480
1
                match_start_width
481
            } else {
482
1.72k
                let desired = match_start_width.saturating_sub((available_width - match_width) / 2);
483
1.72k
                desired.min(full_width.saturating_sub(available_width))
484
            }
485
        };
486
487
6.98k
        let proposed = (i32::try_from(base_shift).unwrap_or(i32::MAX) + self.manual_hscroll)
488
6.98k
            .max(0)
489
6.98k
            .unsigned_abs() as usize;
490
6.98k
        let shift = if full_width > available_width {
  Branch (490:24): [True: 16, False: 6.85k]
-
  Branch (490:24): [True: 4, False: 107]
-
491
20
            proposed.min(full_width.saturating_sub(available_width))
492
        } else {
493
6.96k
            proposed
494
        };
495
496
6.98k
        (shift, full_width, shift > 0, shift + available_width < full_width)
497
6.98k
    }
498
499
1.18k
    fn text_display_width(&self, text: &str) -> usize {
500
2.78k
        
text1.18k
.
chars1.18k
().
fold1.18k
(0usize, |width, ch| self.add_char_width(width, ch))
501
1.18k
    }
502
503
5.79k
    fn line_display_width(&self, line: &Line<'_>) -> usize {
504
20.9k
        
line.spans.iter()5.79k
.
fold5.79k
(0usize, |width, span| {
505
20.9k
            span.content
506
20.9k
                .chars()
507
41.5k
                .
fold20.9k
(
width20.9k
, |span_width, ch| self.add_char_width(span_width, ch))
508
20.9k
        })
509
5.79k
    }
510
511
98.2k
    fn add_char_width(&self, width: usize, ch: char) -> usize {
512
98.2k
        if ch == '\t' {
  Branch (512:12): [True: 135, False: 97.4k]
-
  Branch (512:12): [True: 0, False: 643]
-
513
135
            width + self.tabstop - (width % self.tabstop)
514
        } else {
515
98.0k
            width + char_display_width(ch)
516
        }
517
98.2k
    }
518
519
6.97k
    fn apply_hscroll<'b>(&'b self, line: Line<'b>, shift: usize, full_width: usize) -> Line<'b> {
520
6.97k
        let container_width = self.container_width;
521
6.97k
        let has_left = shift > 0;
522
6.97k
        let has_right = shift + container_width < full_width;
523
524
6.97k
        let ell_w = usize::try_from(display_width(self.ellipsis)).unwrap();
525
6.97k
        let left_w = if has_left { 
ell_w18
} else {
06.96k
};
  Branch (525:25): [True: 17, False: 6.85k]
-
  Branch (525:25): [True: 1, False: 108]
-
526
6.97k
        let right_w = if has_right { 
ell_w14
} else {
06.96k
};
  Branch (526:26): [True: 13, False: 6.85k]
-
  Branch (526:26): [True: 1, False: 108]
-
527
6.97k
        let content_width = container_width.saturating_sub(left_w + right_w);
528
529
6.97k
        let mut result = Line::default();
530
6.97k
        if has_left {
  Branch (530:12): [True: 17, False: 6.85k]
-
  Branch (530:12): [True: 1, False: 108]
-
531
18
            result.push_span(Span::raw(self.ellipsis));
532
6.96k
        }
533
534
6.97k
        let mut current_char_index = 0;
535
6.97k
        let mut current_width = 0;
536
6.97k
        let shift_char_start = self.char_index_at_width(&line, shift);
537
6.97k
        let shift_char_end = self.char_index_at_width(&line, shift + content_width);
538
539
22.1k
        for span in 
line.spans6.97k
{
540
22.1k
            let span_text = span.content.as_ref();
541
22.1k
            let span_chars: Vec<char> = span_text.chars().collect();
542
22.1k
            let span_start = current_char_index;
543
22.1k
            let span_end = current_char_index + span_chars.len();
544
545
22.1k
            if span_end > shift_char_start && 
span_start < shift_char_end12.9k
{
  Branch (545:16): [True: 12.8k, False: 9.22k]
+
480
1
                match_start_width
481
            } else {
482
1.72k
                let desired = match_start_width.saturating_sub((available_width - match_width) / 2);
483
1.72k
                desired.min(full_width.saturating_sub(available_width))
484
            }
485
        };
486
487
6.95k
        let proposed = (i32::try_from(base_shift).unwrap_or(i32::MAX) + self.manual_hscroll)
488
6.95k
            .max(0)
489
6.95k
            .unsigned_abs() as usize;
490
6.95k
        let shift = if full_width > available_width {
  Branch (490:24): [True: 16, False: 6.83k]
+
  Branch (490:24): [True: 4, False: 108]
+
491
20
            proposed.min(full_width.saturating_sub(available_width))
492
        } else {
493
6.93k
            proposed
494
        };
495
496
6.95k
        (shift, full_width, shift > 0, shift + available_width < full_width)
497
6.96k
    }
498
499
1.18k
    fn text_display_width(&self, text: &str) -> usize {
500
2.78k
        
text1.18k
.
chars1.18k
().
fold1.18k
(0usize, |width, ch| self.add_char_width(width, ch))
501
1.18k
    }
502
503
5.77k
    fn line_display_width(&self, line: &Line<'_>) -> usize {
504
20.9k
        
line.spans.iter()5.77k
.
fold5.77k
(0usize, |width, span| {
505
20.9k
            span.content
506
20.9k
                .chars()
507
41.4k
                .
fold20.9k
(
width20.9k
, |span_width, ch| self.add_char_width(span_width, ch))
508
20.9k
        })
509
5.77k
    }
510
511
98.1k
    fn add_char_width(&self, width: usize, ch: char) -> usize {
512
98.1k
        if ch == '\t' {
  Branch (512:12): [True: 135, False: 97.3k]
+
  Branch (512:12): [True: 0, False: 645]
+
513
135
            width + self.tabstop - (width % self.tabstop)
514
        } else {
515
98.0k
            width + char_display_width(ch)
516
        }
517
98.1k
    }
518
519
6.95k
    fn apply_hscroll<'b>(&'b self, line: Line<'b>, shift: usize, full_width: usize) -> Line<'b> {
520
6.95k
        let container_width = self.container_width;
521
6.95k
        let has_left = shift > 0;
522
6.95k
        let has_right = shift + container_width < full_width;
523
524
6.95k
        let ell_w = usize::try_from(display_width(self.ellipsis)).unwrap();
525
6.95k
        let left_w = if has_left { 
ell_w18
} else {
06.93k
};
  Branch (525:25): [True: 17, False: 6.83k]
+
  Branch (525:25): [True: 1, False: 109]
+
526
6.95k
        let right_w = if has_right { 
ell_w14
} else {
06.94k
};
  Branch (526:26): [True: 13, False: 6.83k]
+
  Branch (526:26): [True: 1, False: 109]
+
527
6.95k
        let content_width = container_width.saturating_sub(left_w + right_w);
528
529
6.95k
        let mut result = Line::default();
530
6.95k
        if has_left {
  Branch (530:12): [True: 17, False: 6.83k]
+
  Branch (530:12): [True: 1, False: 109]
+
531
18
            result.push_span(Span::raw(self.ellipsis));
532
6.93k
        }
533
534
6.95k
        let mut current_char_index = 0;
535
6.95k
        let mut current_width = 0;
536
6.95k
        let shift_char_start = self.char_index_at_width(&line, shift);
537
6.95k
        let shift_char_end = self.char_index_at_width(&line, shift + content_width);
538
539
22.1k
        for span in 
line.spans6.95k
{
540
22.1k
            let span_text = span.content.as_ref();
541
22.1k
            let span_chars: Vec<char> = span_text.chars().collect();
542
22.1k
            let span_start = current_char_index;
543
22.1k
            let span_end = current_char_index + span_chars.len();
544
545
22.1k
            if span_end > shift_char_start && 
span_start < shift_char_end12.9k
{
  Branch (545:16): [True: 12.8k, False: 9.18k]
   Branch (545:47): [True: 12.4k, False: 416]
-
  Branch (545:16): [True: 109, False: 0]
-  Branch (545:47): [True: 109, False: 0]
+
  Branch (545:16): [True: 110, False: 2]
+  Branch (545:47): [True: 110, False: 0]
 
546
12.5k
                let vis_start = shift_char_start.saturating_sub(span_start);
547
12.5k
                let vis_end = if span_end > shift_char_end {
  Branch (547:34): [True: 12, False: 12.4k]
-
  Branch (547:34): [True: 2, False: 107]
+
  Branch (547:34): [True: 2, False: 108]
 
548
14
                    shift_char_end - span_start
549
                } else {
550
12.5k
                    span_chars.len()
551
                };
552
12.5k
                if vis_start < vis_end && 
vis_start11.0k
< span_chars.len() {
  Branch (552:20): [True: 10.9k, False: 1.51k]
   Branch (552:43): [True: 10.9k, False: 0]
-
  Branch (552:20): [True: 109, False: 0]
-  Branch (552:43): [True: 109, False: 0]
+
  Branch (552:20): [True: 110, False: 0]
+  Branch (552:43): [True: 110, False: 0]
 
553
11.0k
                    let visible: String = span_chars[vis_start..vis_end.min(span_chars.len())].iter().collect();
554
11.0k
                    let processed = if visible.contains('\t') {
  Branch (554:40): [True: 45, False: 10.8k]
-
  Branch (554:40): [True: 0, False: 109]
-
555
45
                        self.expand_tabs(&visible, current_width)
556
                    } else {
557
11.0k
                        visible
558
                    };
559
11.0k
                    if !processed.is_empty() {
  Branch (559:24): [True: 10.9k, False: 0]
-
  Branch (559:24): [True: 109, False: 0]
-
560
11.0k
                        result.push_span(Span::styled(processed, span.style));
561
11.0k
                    
}0
562
1.51k
                }
563
9.63k
            }
564
22.1k
            current_char_index += span_chars.len();
565
22.1k
            current_width += usize::try_from(display_width(span_text)).unwrap();
566
        }
567
568
6.97k
        if has_right {
  Branch (568:12): [True: 13, False: 6.85k]
-
  Branch (568:12): [True: 1, False: 108]
-
569
14
            result.push_span(Span::raw(self.ellipsis));
570
6.96k
        }
571
6.97k
        result
572
6.97k
    }
573
574
13.9k
    fn char_index_at_width(&self, line: &Line<'_>, target_width: usize) -> usize {
575
13.9k
        let mut current_width = 0;
576
13.9k
        let mut char_index = 0;
577
38.3k
        for span in 
&line.spans13.9k
{
578
48.3k
            for ch in 
span.content.chars()38.3k
{
579
48.3k
                if current_width >= target_width {
  Branch (579:20): [True: 6.84k, False: 41.0k]
-
  Branch (579:20): [True: 111, False: 314]
-
580
6.95k
                    return char_index;
581
41.4k
                }
582
41.4k
                current_width = self.add_char_width(current_width, ch);
583
41.4k
                char_index += 1;
584
            }
585
        }
586
7.00k
        char_index
587
13.9k
    }
588
589
45
    fn expand_tabs(&self, text: &str, start_width: usize) -> String {
590
45
        let mut result = String::new();
591
45
        let mut current_width = start_width;
592
180
        for ch in 
text45
.
chars45
() {
593
180
            if ch == '\t' {
  Branch (593:16): [True: 45, False: 135]
+
  Branch (554:40): [True: 0, False: 110]
+
555
45
                        self.expand_tabs(&visible, current_width)
556
                    } else {
557
10.9k
                        visible
558
                    };
559
11.0k
                    if !processed.is_empty() {
  Branch (559:24): [True: 10.9k, False: 0]
+
  Branch (559:24): [True: 110, False: 0]
+
560
11.0k
                        result.push_span(Span::styled(processed, span.style));
561
11.0k
                    
}0
562
1.51k
                }
563
9.60k
            }
564
22.1k
            current_char_index += span_chars.len();
565
22.1k
            current_width += usize::try_from(display_width(span_text)).unwrap();
566
        }
567
568
6.95k
        if has_right {
  Branch (568:12): [True: 13, False: 6.83k]
+
  Branch (568:12): [True: 1, False: 109]
+
569
14
            result.push_span(Span::raw(self.ellipsis));
570
6.94k
        }
571
6.95k
        result
572
6.95k
    }
573
574
13.9k
    fn char_index_at_width(&self, line: &Line<'_>, target_width: usize) -> usize {
575
13.9k
        let mut current_width = 0;
576
13.9k
        let mut char_index = 0;
577
38.2k
        for span in 
&line.spans13.9k
{
578
48.3k
            for ch in 
span.content.chars()38.2k
{
579
48.3k
                if current_width >= target_width {
  Branch (579:20): [True: 6.82k, False: 41.0k]
+
  Branch (579:20): [True: 112, False: 315]
+
580
6.93k
                    return char_index;
581
41.3k
                }
582
41.3k
                current_width = self.add_char_width(current_width, ch);
583
41.3k
                char_index += 1;
584
            }
585
        }
586
6.98k
        char_index
587
13.9k
    }
588
589
45
    fn expand_tabs(&self, text: &str, start_width: usize) -> String {
590
45
        let mut result = String::new();
591
45
        let mut current_width = start_width;
592
180
        for ch in 
text45
.
chars45
() {
593
180
            if ch == '\t' {
  Branch (593:16): [True: 45, False: 135]
 
  Branch (593:16): [True: 0, False: 0]
 
594
45
                let next_width = self.add_char_width(current_width, ch);
595
45
                let tab_width = next_width - current_width;
596
45
                result.push_str(&" ".repeat(tab_width));
597
45
                current_width = next_width;
598
135
            } else {
599
135
                result.push(ch);
600
135
                current_width = self.add_char_width(current_width, ch);
601
135
            }
602
        }
603
45
        result
604
45
    }
605
}
606
607
#[cfg(test)]
608
#[path = "item_renderer_tests.rs"]
609
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/layout.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/layout.rs.html index b8ac0da2..39a76640 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/tui/layout.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/layout.rs.html @@ -1,14 +1,14 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/layout.rs
Line
Count
Source
1
//! Layout computation for skim's TUI.
2
//!
3
//! The layout logic is split into two phases so that the expensive option-
4
//! inspection work is done only once (at startup and on option changes) while
5
//! the per-frame cost is reduced to a small number of cheap rect splits.
6
//!
7
//! * [`LayoutTemplate`] — built from [`SkimOptions`] and the static header
8
//!   height.  Stores pre-computed constraints and flags; no terminal-size
9
//!   dependency.  Stored on [`App`](super::App) and rebuilt only when options
10
//!   that affect layout change (e.g. [`TogglePreview`]).
11
//!
12
//! * [`AppLayout`] — produced by [`LayoutTemplate::apply`] from a concrete
13
//!   terminal [`Rect`].  Contains the final widget areas for one render frame.
14
//!   Computed in `render()` and cached on `App` so that code between renders
15
//!   (e.g. mouse hit-testing) can read it.
16
17
use ratatui::layout::{Constraint, Direction as RatatuiDirection, Layout, Rect};
18
19
use crate::SkimOptions;
20
use crate::tui::options::TuiLayout;
21
use crate::tui::statusline::InfoDisplay;
22
use crate::tui::{Direction, Size};
23
24
// ---------------------------------------------------------------------------
25
// LayoutTemplate
26
// ---------------------------------------------------------------------------
27
28
/// Orientation of the preview pane relative to the work area.
29
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30
enum PreviewPlacement {
31
    /// Preview splits the full area horizontally (left / right of everything).
32
    Left,
33
    Right,
34
    /// Preview splits the full area vertically (above / below everything).
35
    Up,
36
    Down,
37
    /// No preview.
38
    None,
39
}
40
41
/// Pre-computed layout descriptor built from [`SkimOptions`].
42
///
43
/// Contains everything needed to split a concrete [`Rect`] into widget areas,
44
/// but contains no coordinates itself — those only appear in [`AppLayout`].
45
/// Build with [`LayoutTemplate::from_options`] and store on
46
/// [`App`](super::App); call [`LayoutTemplate::apply`] in every render.
47
#[derive(Debug, Clone)]
48
pub struct LayoutTemplate {
49
    /// Whether the header widget should be rendered at all.
50
    show_header: bool,
51
    /// Where the preview pane is placed relative to the rest of the UI.
52
    preview_placement: PreviewPlacement,
53
    /// Whether the work layout emits slots in reverse order `[input, header,
54
    /// list]` instead of the default `[list, header, input]`.
55
    work_layout_reversed: bool,
56
    /// Pre-built [`Layout`] for carving the preview out of the full area
57
    /// (step 1).  `None` when no preview is visible.
58
    preview_layout: Option<Layout>,
59
    /// Whether adjacent bordered widgets share their touching row or column.
60
    collapse_borders: bool,
61
    /// Pre-built [`Layout`] for splitting the work area into three slots.
62
    ///
63
    /// When `work_layout_reversed` is `false` the slots map to
64
    /// `[list, header, input]`; when `true` they map to
65
    /// `[input, header, list]`.  Applied in step 2 of [`Self::apply`].
66
    work_layout: Layout,
67
}
68
69
impl LayoutTemplate {
70
    /// Build a [`LayoutTemplate`] from [`SkimOptions`] and the static header
71
    /// height (content rows only, excluding any border rows).
72
    ///
73
    /// `header_height` should come from
74
    /// [`Header::height()`](super::header::Header::height), which returns a
75
    /// value fixed at construction time from `options.header_lines` and the
76
    /// line-count of `options.header`.
77
    #[must_use]
78
586
    pub fn from_options(options: &SkimOptions, header_height: u16) -> Self {
79
586
        let has_border = options.border.is_some();
80
586
        let collapse_borders = has_border && 
!options.border_no_collapse32
;
  Branch (80:32): [True: 21, False: 367]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/layout.rs
Line
Count
Source
1
//! Layout computation for skim's TUI.
2
//!
3
//! The layout logic is split into two phases so that the expensive option-
4
//! inspection work is done only once (at startup and on option changes) while
5
//! the per-frame cost is reduced to a small number of cheap rect splits.
6
//!
7
//! * [`LayoutTemplate`] — built from [`SkimOptions`] and the static header
8
//!   height.  Stores pre-computed constraints and flags; no terminal-size
9
//!   dependency.  Stored on [`App`](super::App) and rebuilt only when options
10
//!   that affect layout change (e.g. [`TogglePreview`]).
11
//!
12
//! * [`AppLayout`] — produced by [`LayoutTemplate::apply`] from a concrete
13
//!   terminal [`Rect`].  Contains the final widget areas for one render frame.
14
//!   Computed in `render()` and cached on `App` so that code between renders
15
//!   (e.g. mouse hit-testing) can read it.
16
17
use ratatui::layout::{Constraint, Direction as RatatuiDirection, Layout, Rect};
18
19
use crate::SkimOptions;
20
use crate::tui::options::TuiLayout;
21
use crate::tui::statusline::InfoDisplay;
22
use crate::tui::{Direction, Size};
23
24
// ---------------------------------------------------------------------------
25
// LayoutTemplate
26
// ---------------------------------------------------------------------------
27
28
/// Orientation of the preview pane relative to the work area.
29
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30
enum PreviewPlacement {
31
    /// Preview splits the full area horizontally (left / right of everything).
32
    Left,
33
    Right,
34
    /// Preview splits the full area vertically (above / below everything).
35
    Up,
36
    Down,
37
    /// No preview.
38
    None,
39
}
40
41
/// Pre-computed layout descriptor built from [`SkimOptions`].
42
///
43
/// Contains everything needed to split a concrete [`Rect`] into widget areas,
44
/// but contains no coordinates itself — those only appear in [`AppLayout`].
45
/// Build with [`LayoutTemplate::from_options`] and store on
46
/// [`App`](super::App); call [`LayoutTemplate::apply`] in every render.
47
#[derive(Debug, Clone)]
48
pub struct LayoutTemplate {
49
    /// Whether the header widget should be rendered at all.
50
    show_header: bool,
51
    /// Where the preview pane is placed relative to the rest of the UI.
52
    preview_placement: PreviewPlacement,
53
    /// Whether the work layout emits slots in reverse order `[input, header,
54
    /// list]` instead of the default `[list, header, input]`.
55
    work_layout_reversed: bool,
56
    /// Pre-built [`Layout`] for carving the preview out of the full area
57
    /// (step 1).  `None` when no preview is visible.
58
    preview_layout: Option<Layout>,
59
    /// Whether adjacent bordered widgets share their touching row or column.
60
    collapse_borders: bool,
61
    /// Pre-built [`Layout`] for splitting the work area into three slots.
62
    ///
63
    /// When `work_layout_reversed` is `false` the slots map to
64
    /// `[list, header, input]`; when `true` they map to
65
    /// `[input, header, list]`.  Applied in step 2 of [`Self::apply`].
66
    work_layout: Layout,
67
}
68
69
impl LayoutTemplate {
70
    /// Build a [`LayoutTemplate`] from [`SkimOptions`] and the static header
71
    /// height (content rows only, excluding any border rows).
72
    ///
73
    /// `header_height` should come from
74
    /// [`Header::height()`](super::header::Header::height), which returns a
75
    /// value fixed at construction time from `options.header_lines` and the
76
    /// line-count of `options.header`.
77
    #[must_use]
78
586
    pub fn from_options(options: &SkimOptions, header_height: u16) -> Self {
79
586
        let has_border = options.border.is_some();
80
586
        let collapse_borders = has_border && 
!options.border_no_collapse33
;
  Branch (80:32): [True: 22, False: 366]
 
  Branch (80:32): [True: 11, False: 187]
-
81
586
        let overlap = u16::from(collapse_borders);
82
83
        // Rows consumed by the input widget.
84
586
        let input_rows: u16 = if has_border {
  Branch (84:34): [True: 21, False: 367]
+
81
586
        let overlap = u16::from(collapse_borders);
82
83
        // Rows consumed by the input widget.
84
586
        let input_rows: u16 = if has_border {
  Branch (84:34): [True: 22, False: 366]
 
  Branch (84:34): [True: 11, False: 187]
-
85
32
            3 // 1 content + 2 border rows
86
        } else {
87
554
            1 + u16::from(
matches!24
(
88
554
                options.info.display,
89
                InfoDisplay::Default | InfoDisplay::Left | InfoDisplay::Right
90
            ))
91
        };
92
93
        // Rows consumed by the header widget.
94
586
        let show_header = options.header.is_some() || 
options.header_lines > 0546
;
  Branch (94:27): [True: 28, False: 360]
+
85
33
            3 // 1 content + 2 border rows
86
        } else {
87
553
            1 + u16::from(
matches!24
(
88
553
                options.info.display,
89
                InfoDisplay::Default | InfoDisplay::Left | InfoDisplay::Right
90
            ))
91
        };
92
93
        // Rows consumed by the header widget.
94
586
        let show_header = options.header.is_some() || 
options.header_lines > 0545
;
  Branch (94:27): [True: 29, False: 359]
 
  Branch (94:27): [True: 12, False: 186]
-
95
586
        let header_rows: u16 = if show_header {
  Branch (95:35): [True: 38, False: 350]
+
95
586
        let header_rows: u16 = if show_header {
  Branch (95:35): [True: 39, False: 349]
 
  Branch (95:35): [True: 13, False: 185]
-
96
51
            if has_border { 
header_height + 29
} else {
header_height42
}
  Branch (96:16): [True: 6, False: 32]
+
96
52
            if has_border { 
header_height + 210
} else {
header_height42
}
  Branch (96:16): [True: 7, False: 32]
 
  Branch (96:16): [True: 3, False: 10]
-
97
        } else {
98
535
            0
99
        };
100
101
        // Preview placement and layout.
102
586
        let preview_visible = (options.preview.is_some() || 
options.preview_fn532
.
is_some532
())
  Branch (102:32): [True: 39, False: 349]
+
97
        } else {
98
534
            0
99
        };
100
101
        // Preview placement and layout.
102
586
        let preview_visible = (options.preview.is_some() || 
options.preview_fn532
.
is_some532
())
  Branch (102:32): [True: 39, False: 349]
   Branch (102:61): [True: 0, False: 349]
 
  Branch (102:32): [True: 15, False: 183]
   Branch (102:61): [True: 0, False: 183]
@@ -16,13 +16,13 @@
 
  Branch (103:16): [True: 14, False: 1]
 
104
53
            && !matches!(options.preview_window.size, Size::Fixed(0));
105
106
586
        let (preview_placement, preview_layout) = if preview_visible {
  Branch (106:54): [True: 39, False: 349]
 
  Branch (106:54): [True: 14, False: 184]
-
107
53
            let (preview_c, rest_c) = size_to_constraint(options.preview_window.size);
108
53
            let placement = match options.preview_window.direction {
109
14
                Direction::Left => PreviewPlacement::Left,
110
26
                Direction::Right => PreviewPlacement::Right,
111
7
                Direction::Up => PreviewPlacement::Up,
112
6
                Direction::Down => PreviewPlacement::Down,
113
            };
114
53
            let layout = match placement {
115
14
                PreviewPlacement::Left => Layout::new(RatatuiDirection::Horizontal, [preview_c, rest_c]),
116
26
                PreviewPlacement::Right => Layout::new(RatatuiDirection::Horizontal, [rest_c, preview_c]),
117
7
                PreviewPlacement::Up => Layout::new(RatatuiDirection::Vertical, [preview_c, rest_c]),
118
6
                PreviewPlacement::Down => Layout::new(RatatuiDirection::Vertical, [rest_c, preview_c]),
119
0
                PreviewPlacement::None => unreachable!(),
120
            };
121
53
            (placement, Some(layout))
122
        } else {
123
533
            (PreviewPlacement::None, None)
124
        };
125
126
        // Work-area layout: single 3-way vertical split into [list, header, input].
127
        //
128
        // For Default / ReverseList: slots are [list, header, input] top-to-bottom.
129
        // For Reverse:               slots are [input, header, list] top-to-bottom.
130
586
        let work_layout_reversed = options.layout == TuiLayout::Reverse;
131
586
        let work_layout = if show_header {
  Branch (131:30): [True: 38, False: 350]
+
107
53
            let (preview_c, rest_c) = size_to_constraint(options.preview_window.size);
108
53
            let placement = match options.preview_window.direction {
109
14
                Direction::Left => PreviewPlacement::Left,
110
26
                Direction::Right => PreviewPlacement::Right,
111
7
                Direction::Up => PreviewPlacement::Up,
112
6
                Direction::Down => PreviewPlacement::Down,
113
            };
114
53
            let layout = match placement {
115
14
                PreviewPlacement::Left => Layout::new(RatatuiDirection::Horizontal, [preview_c, rest_c]),
116
26
                PreviewPlacement::Right => Layout::new(RatatuiDirection::Horizontal, [rest_c, preview_c]),
117
7
                PreviewPlacement::Up => Layout::new(RatatuiDirection::Vertical, [preview_c, rest_c]),
118
6
                PreviewPlacement::Down => Layout::new(RatatuiDirection::Vertical, [rest_c, preview_c]),
119
0
                PreviewPlacement::None => unreachable!(),
120
            };
121
53
            (placement, Some(layout))
122
        } else {
123
533
            (PreviewPlacement::None, None)
124
        };
125
126
        // Work-area layout: single 3-way vertical split into [list, header, input].
127
        //
128
        // For Default / ReverseList: slots are [list, header, input] top-to-bottom.
129
        // For Reverse:               slots are [input, header, list] top-to-bottom.
130
586
        let work_layout_reversed = options.layout == TuiLayout::Reverse;
131
586
        let work_layout = if show_header {
  Branch (131:30): [True: 39, False: 349]
 
  Branch (131:30): [True: 13, False: 185]
-
132
51
            match options.layout {
133
42
                TuiLayout::Default | TuiLayout::ReverseList => Layout::vertical([
134
42
                    Constraint::Fill(1),
135
42
                    Constraint::Length(header_rows.saturating_sub(overlap)),
136
42
                    Constraint::Length(input_rows.saturating_sub(overlap)),
137
42
                ]),
138
9
                TuiLayout::Reverse => Layout::vertical([
139
9
                    Constraint::Length(input_rows),
140
9
                    Constraint::Length(header_rows.saturating_sub(overlap)),
141
9
                    Constraint::Fill(1),
142
9
                ]),
143
            }
144
        } else {
145
535
            match options.layout {
146
525
                TuiLayout::Default | TuiLayout::ReverseList => Layout::vertical([
147
525
                    Constraint::Fill(1),
148
525
                    Constraint::Length(0),
149
525
                    Constraint::Length(input_rows.saturating_sub(overlap)),
150
525
                ]),
151
10
                TuiLayout::Reverse => Layout::vertical([
152
10
                    Constraint::Length(input_rows),
153
10
                    Constraint::Length(0),
154
10
                    Constraint::Fill(1),
155
10
                ]),
156
            }
157
        };
158
159
586
        Self {
160
586
            show_header,
161
586
            preview_placement,
162
586
            work_layout_reversed,
163
586
            preview_layout,
164
586
            collapse_borders,
165
586
            work_layout,
166
586
        }
167
586
    }
168
169
    /// Apply this template to a concrete terminal `area`, producing the
170
    /// absolute [`AppLayout`] for one render frame.
171
    #[must_use]
172
3.24k
    pub fn apply(&self, area: Rect) -> AppLayout {
173
        // ── Step 1: carve out the preview from the full area ─────────────────
174
3.24k
        let (work_area, preview_area): (Rect, Option<Rect>) = match &self.preview_layout {
175
274
            Some(layout) => {
176
274
                let [a, mut b]: [Rect; 2] = layout.areas(area);
177
274
                if self.collapse_borders {
  Branch (177:20): [True: 0, False: 260]
+
132
52
            match options.layout {
133
43
                TuiLayout::Default | TuiLayout::ReverseList => Layout::vertical([
134
43
                    Constraint::Fill(1),
135
43
                    Constraint::Length(header_rows.saturating_sub(overlap)),
136
43
                    Constraint::Length(input_rows.saturating_sub(overlap)),
137
43
                ]),
138
9
                TuiLayout::Reverse => Layout::vertical([
139
9
                    Constraint::Length(input_rows),
140
9
                    Constraint::Length(header_rows.saturating_sub(overlap)),
141
9
                    Constraint::Fill(1),
142
9
                ]),
143
            }
144
        } else {
145
534
            match options.layout {
146
524
                TuiLayout::Default | TuiLayout::ReverseList => Layout::vertical([
147
524
                    Constraint::Fill(1),
148
524
                    Constraint::Length(0),
149
524
                    Constraint::Length(input_rows.saturating_sub(overlap)),
150
524
                ]),
151
10
                TuiLayout::Reverse => Layout::vertical([
152
10
                    Constraint::Length(input_rows),
153
10
                    Constraint::Length(0),
154
10
                    Constraint::Fill(1),
155
10
                ]),
156
            }
157
        };
158
159
586
        Self {
160
586
            show_header,
161
586
            preview_placement,
162
586
            work_layout_reversed,
163
586
            preview_layout,
164
586
            collapse_borders,
165
586
            work_layout,
166
586
        }
167
586
    }
168
169
    /// Apply this template to a concrete terminal `area`, producing the
170
    /// absolute [`AppLayout`] for one render frame.
171
    #[must_use]
172
3.23k
    pub fn apply(&self, area: Rect) -> AppLayout {
173
        // ── Step 1: carve out the preview from the full area ─────────────────
174
3.23k
        let (work_area, preview_area): (Rect, Option<Rect>) = match &self.preview_layout {
175
274
            Some(layout) => {
176
274
                let [a, mut b]: [Rect; 2] = layout.areas(area);
177
274
                if self.collapse_borders {
  Branch (177:20): [True: 0, False: 260]
 
  Branch (177:20): [True: 2, False: 12]
-
178
2
                    b = match self.preview_placement {
179
2
                        PreviewPlacement::Left | PreviewPlacement::Right => extend_left(b, area.x),
180
0
                        PreviewPlacement::Up | PreviewPlacement::Down => extend_up(b, area.y),
181
0
                        PreviewPlacement::None => unreachable!(),
182
                    };
183
272
                }
184
274
                match self.preview_placement {
185
                    // preview is the first segment for Left / Up
186
85
                    PreviewPlacement::Left | PreviewPlacement::Up => (b, Some(a)),
187
                    // preview is the second segment for Right / Down
188
189
                    _ => (a, Some(b)),
189
                }
190
            }
191
2.97k
            None => (area, None),
192
        };
193
194
        // ── Step 2: split work_area into list / header / input in one pass ───
195
        //
196
        // Slots are [list, header, input] when `work_layout_reversed` is false,
197
        // or [input, header, list] when true (Reverse layout).
198
3.24k
        let [slot0, slot1, slot2]: [Rect; 3] = self.work_layout.areas(work_area);
199
200
3.24k
        let (mut list_area, mut header_slot, mut input_area) = if self.work_layout_reversed {
  Branch (200:67): [True: 62, False: 2.96k]
+
178
2
                    b = match self.preview_placement {
179
2
                        PreviewPlacement::Left | PreviewPlacement::Right => extend_left(b, area.x),
180
0
                        PreviewPlacement::Up | PreviewPlacement::Down => extend_up(b, area.y),
181
0
                        PreviewPlacement::None => unreachable!(),
182
                    };
183
272
                }
184
274
                match self.preview_placement {
185
                    // preview is the first segment for Left / Up
186
85
                    PreviewPlacement::Left | PreviewPlacement::Up => (b, Some(a)),
187
                    // preview is the second segment for Right / Down
188
189
                    _ => (a, Some(b)),
189
                }
190
            }
191
2.96k
            None => (area, None),
192
        };
193
194
        // ── Step 2: split work_area into list / header / input in one pass ───
195
        //
196
        // Slots are [list, header, input] when `work_layout_reversed` is false,
197
        // or [input, header, list] when true (Reverse layout).
198
3.23k
        let [slot0, slot1, slot2]: [Rect; 3] = self.work_layout.areas(work_area);
199
200
3.23k
        let (mut list_area, mut header_slot, mut input_area) = if self.work_layout_reversed {
  Branch (200:67): [True: 61, False: 2.95k]
 
  Branch (200:67): [True: 5, False: 213]
-
201
67
            (slot2, slot1, slot0)
202
        } else {
203
3.17k
            (slot0, slot1, slot2)
204
        };
205
206
3.24k
        if self.collapse_borders {
  Branch (206:12): [True: 72, False: 2.95k]
+
201
66
            (slot2, slot1, slot0)
202
        } else {
203
3.17k
            (slot0, slot1, slot2)
204
        };
205
206
3.23k
        if self.collapse_borders {
  Branch (206:12): [True: 72, False: 2.94k]
 
  Branch (206:12): [True: 10, False: 208]
 
207
82
            if self.work_layout_reversed {
  Branch (207:16): [True: 8, False: 64]
 
  Branch (207:16): [True: 2, False: 8]
@@ -30,9 +30,9 @@
 
  Branch (208:20): [True: 1, False: 1]
 
209
5
                    header_slot = extend_up(header_slot, work_area.y);
210
5
                }
211
10
                list_area = extend_up(list_area, work_area.y);
212
            } else {
213
72
                if self.show_header {
  Branch (213:20): [True: 8, False: 56]
 
  Branch (213:20): [True: 1, False: 7]
-
214
9
                    header_slot = extend_up(header_slot, work_area.y);
215
63
                }
216
72
                input_area = extend_up(input_area, work_area.y);
217
            }
218
3.16k
        }
219
220
3.24k
        let header_area = if self.show_header { 
Some(header_slot)134
} else {
None3.11k
};
  Branch (220:30): [True: 122, False: 2.90k]
+
214
9
                    header_slot = extend_up(header_slot, work_area.y);
215
63
                }
216
72
                input_area = extend_up(input_area, work_area.y);
217
            }
218
3.15k
        }
219
220
3.23k
        let header_area = if self.show_header { 
Some(header_slot)138
} else {
None3.10k
};
  Branch (220:30): [True: 126, False: 2.89k]
 
  Branch (220:30): [True: 12, False: 206]
-
221
222
3.24k
        AppLayout {
223
3.24k
            list_area,
224
3.24k
            input_area,
225
3.24k
            header_area,
226
3.24k
            preview_area,
227
3.24k
        }
228
3.24k
    }
229
}
230
231
// ---------------------------------------------------------------------------
232
// AppLayout
233
// ---------------------------------------------------------------------------
234
235
/// Concrete widget areas for one render frame, produced by
236
/// [`LayoutTemplate::apply`].
237
///
238
/// Cached on [`App`](super::App) after each render so that code between frames
239
/// (e.g. mouse hit-testing in `handle_mouse`) can read the last known areas.
240
#[derive(Debug, Clone, PartialEq, Eq)]
241
pub struct AppLayout {
242
    /// Area for the item list widget.
243
    pub list_area: Rect,
244
    /// Area for the input / prompt widget.
245
    pub input_area: Rect,
246
    /// Area for the header widget (`None` when no header is shown).
247
    pub header_area: Option<Rect>,
248
    /// Area for the preview pane (`None` when preview is hidden or disabled).
249
    pub preview_area: Option<Rect>,
250
}
251
252
impl AppLayout {
253
    /// Convenience wrapper: build a [`LayoutTemplate`] from `options` and
254
    /// `header_height`, then immediately apply it to `area`.
255
    ///
256
    /// Prefer storing the [`LayoutTemplate`] and calling
257
    /// [`LayoutTemplate::apply`] directly when the template can be reused
258
    /// across frames.
259
    #[must_use]
260
30
    pub fn compute(area: Rect, options: &SkimOptions, header_height: u16) -> Self {
261
30
        LayoutTemplate::from_options(options, header_height).apply(area)
262
30
    }
263
}
264
265
// ---------------------------------------------------------------------------
266
// Helper
267
// ---------------------------------------------------------------------------
268
269
96
fn extend_up(mut rect: Rect, top: u16) -> Rect {
270
96
    if rect.y > top {
  Branch (270:8): [True: 63, False: 21]
+
221
222
3.23k
        AppLayout {
223
3.23k
            list_area,
224
3.23k
            input_area,
225
3.23k
            header_area,
226
3.23k
            preview_area,
227
3.23k
        }
228
3.23k
    }
229
}
230
231
// ---------------------------------------------------------------------------
232
// AppLayout
233
// ---------------------------------------------------------------------------
234
235
/// Concrete widget areas for one render frame, produced by
236
/// [`LayoutTemplate::apply`].
237
///
238
/// Cached on [`App`](super::App) after each render so that code between frames
239
/// (e.g. mouse hit-testing in `handle_mouse`) can read the last known areas.
240
#[derive(Debug, Clone, PartialEq, Eq)]
241
pub struct AppLayout {
242
    /// Area for the item list widget.
243
    pub list_area: Rect,
244
    /// Area for the input / prompt widget.
245
    pub input_area: Rect,
246
    /// Area for the header widget (`None` when no header is shown).
247
    pub header_area: Option<Rect>,
248
    /// Area for the preview pane (`None` when preview is hidden or disabled).
249
    pub preview_area: Option<Rect>,
250
}
251
252
impl AppLayout {
253
    /// Convenience wrapper: build a [`LayoutTemplate`] from `options` and
254
    /// `header_height`, then immediately apply it to `area`.
255
    ///
256
    /// Prefer storing the [`LayoutTemplate`] and calling
257
    /// [`LayoutTemplate::apply`] directly when the template can be reused
258
    /// across frames.
259
    #[must_use]
260
30
    pub fn compute(area: Rect, options: &SkimOptions, header_height: u16) -> Self {
261
30
        LayoutTemplate::from_options(options, header_height).apply(area)
262
30
    }
263
}
264
265
// ---------------------------------------------------------------------------
266
// Helper
267
// ---------------------------------------------------------------------------
268
269
96
fn extend_up(mut rect: Rect, top: u16) -> Rect {
270
96
    if rect.y > top {
  Branch (270:8): [True: 63, False: 21]
 
  Branch (270:8): [True: 12, False: 0]
 
271
75
        rect.y -= 1;
272
75
        rect.height = rect.height.saturating_add(1);
273
75
    
}21
274
96
    rect
275
96
}
276
277
2
fn extend_left(mut rect: Rect, left: u16) -> Rect {
278
2
    if rect.x > left {
  Branch (278:8): [True: 0, False: 0]
 
  Branch (278:8): [True: 2, False: 0]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/mod.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/mod.rs.html
index 2e2f1045..6bc865c5 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/tui/mod.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/mod.rs.html
@@ -1,14 +1,14 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/mod.rs
Line
Count
Source
1
//! Terminal UI components and rendering.
2
//!
3
//! This module provides the terminal user interface components for skim,
4
//! including the application state, event handling, rendering widgets,
5
//! and layout management.
6
7
use std::num::ParseIntError;
8
9
pub use app::App;
10
pub use event::Event;
11
pub use preview::PreviewCallback;
12
use thiserror::Error;
13
pub use widget::{SkimRender, SkimWidget};
14
mod app;
15
mod backend;
16
pub(crate) mod util;
17
#[cfg(windows)]
18
mod windows;
19
pub use backend::Tui;
20
/// Action definitions, catalog and parsing
21
pub mod actions;
22
/// Event handling
23
pub mod event;
24
/// Header display components
25
pub mod header;
26
mod input;
27
/// Item list display and management
28
pub mod item_list;
29
/// Single item rendering
30
pub(crate) mod item_renderer;
31
/// Pre-computed widget layout areas
32
pub mod layout;
33
/// TUI-specific options and configuration
34
pub mod options;
35
mod preview;
36
/// Status line display
37
pub mod statusline;
38
/// Widget rendering utilities
39
pub mod widget;
40
41
/// Number of heartbeats per second
42
pub const TICK_RATE: u32 = 120;
43
44
/// Represents a size value, either as a percentage or fixed value
45
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46
pub enum Size {
47
    /// Size as a percentage (0-100)
48
    Percent(u16),
49
    /// Fixed size in terminal cells
50
    Fixed(u16),
51
    /// Negative size is substracted from term height
52
    Neg(u16),
53
}
54
55
/// Direction for movement or layout
56
#[derive(PartialEq, Eq, Clone, Debug, Copy)]
57
pub enum Direction {
58
    /// Upward direction
59
    Up,
60
    /// Downward direction
61
    Down,
62
    /// Left direction
63
    Left,
64
    /// Right direction
65
    Right,
66
}
67
68
impl TryFrom<&str> for Direction {
69
    type Error = &'static str;
70
961
    fn try_from(value: &str) -> Result<Self, Self::Error> {
71
961
        match value.to_lowercase().as_str() {
72
961
            "up" => 
Ok(Self::Up)11
,
73
950
            "down" => 
Ok(Self::Down)9
,
74
941
            "left" => 
Ok(Self::Left)26
,
75
915
            "right" => 
Ok(Self::Right)449
,
76
466
            _ => Err("Unknown direction {value}"),
77
        }
78
961
    }
79
}
80
81
/// Error type for parsing size values
82
#[derive(Error, Debug, PartialEq, Eq)]
83
pub enum SizeParseError {
84
    /// Error parsing the size string
85
    #[error("Error parsing {0}: {1:?}")]
86
    ParseError(String, ParseIntError),
87
    /// Percentage value exceeds 100
88
    #[error("Invalid percentage {0}")]
89
    InvalidPercent(u16),
90
}
91
92
impl TryFrom<&str> for Size {
93
    type Error = SizeParseError;
94
95
1.36k
    fn try_from(value: &str) -> Result<Self, Self::Error> {
96
1.36k
        if value.ends_with('%') {
  Branch (96:12): [True: 801, False: 455]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/mod.rs
Line
Count
Source
1
//! Terminal UI components and rendering.
2
//!
3
//! This module provides the terminal user interface components for skim,
4
//! including the application state, event handling, rendering widgets,
5
//! and layout management.
6
7
use std::num::ParseIntError;
8
9
pub use app::App;
10
pub use event::Event;
11
pub use preview::PreviewCallback;
12
use thiserror::Error;
13
pub use widget::{SkimRender, SkimWidget};
14
mod app;
15
mod backend;
16
pub(crate) mod util;
17
#[cfg(windows)]
18
mod windows;
19
pub use backend::Tui;
20
/// Action definitions, catalog and parsing
21
pub mod actions;
22
/// Event handling
23
pub mod event;
24
/// Header display components
25
pub mod header;
26
mod input;
27
/// Item list display and management
28
pub mod item_list;
29
/// Single item rendering
30
pub(crate) mod item_renderer;
31
/// Pre-computed widget layout areas
32
pub mod layout;
33
/// TUI-specific options and configuration
34
pub mod options;
35
mod preview;
36
/// Status line display
37
pub mod statusline;
38
/// Widget rendering utilities
39
pub mod widget;
40
41
/// Number of heartbeats per second
42
pub const TICK_RATE: u32 = 120;
43
44
/// Represents a size value, either as a percentage or fixed value
45
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46
pub enum Size {
47
    /// Size as a percentage (0-100)
48
    Percent(u16),
49
    /// Fixed size in terminal cells
50
    Fixed(u16),
51
    /// Negative size is substracted from term height
52
    Neg(u16),
53
}
54
55
/// Direction for movement or layout
56
#[derive(PartialEq, Eq, Clone, Debug, Copy)]
57
pub enum Direction {
58
    /// Upward direction
59
    Up,
60
    /// Downward direction
61
    Down,
62
    /// Left direction
63
    Left,
64
    /// Right direction
65
    Right,
66
}
67
68
impl TryFrom<&str> for Direction {
69
    type Error = &'static str;
70
961
    fn try_from(value: &str) -> Result<Self, Self::Error> {
71
961
        match value.to_lowercase().as_str() {
72
961
            "up" => 
Ok(Self::Up)11
,
73
950
            "down" => 
Ok(Self::Down)9
,
74
941
            "left" => 
Ok(Self::Left)26
,
75
915
            "right" => 
Ok(Self::Right)449
,
76
466
            _ => Err("Unknown direction {value}"),
77
        }
78
961
    }
79
}
80
81
/// Error type for parsing size values
82
#[derive(Error, Debug, PartialEq, Eq)]
83
pub enum SizeParseError {
84
    /// Error parsing the size string
85
    #[error("Error parsing {0}: {1:?}")]
86
    ParseError(String, ParseIntError),
87
    /// Percentage value exceeds 100
88
    #[error("Invalid percentage {0}")]
89
    InvalidPercent(u16),
90
}
91
92
impl TryFrom<&str> for Size {
93
    type Error = SizeParseError;
94
95
1.36k
    fn try_from(value: &str) -> Result<Self, Self::Error> {
96
1.36k
        if value.ends_with('%') {
  Branch (96:12): [True: 801, False: 455]
 
  Branch (96:12): [True: 58, False: 51]
 
97
859
            let 
percent856
= value
98
859
                .strip_suffix("%")
99
859
                .unwrap_or_default()
100
859
                .parse::<u16>()
101
859
                .map_err(|e| SizeParseError::ParseError(
value3
.
to_string3
(),
e3
))
?3
;
102
856
            if percent > 100 {
  Branch (102:16): [True: 0, False: 801]
 
  Branch (102:16): [True: 1, False: 54]
 
103
1
                return Err(SizeParseError::InvalidPercent(percent));
104
855
            }
105
855
            Ok(Self::Percent(percent))
106
506
        } else if let Some(
neg5
) = value.strip_prefix('-') {
  Branch (106:23): [True: 4, False: 451]
 
  Branch (106:23): [True: 1, False: 50]
-
107
            Ok(Self::Neg(
108
5
                neg.parse::<u16>()
109
5
                    .map_err(|e| SizeParseError::ParseError(
value0
.
to_string0
(),
e0
))
?0
,
110
            ))
111
        } else {
112
            Ok(Self::Fixed(
113
501
                value
114
501
                    .parse::<u16>()
115
501
                    .map_err(|e| SizeParseError::ParseError(
value492
.
to_string492
(),
e492
))
?492
,
116
            ))
117
        }
118
1.36k
    }
119
}
120
121
impl Default for Size {
122
1
    fn default() -> Self {
123
1
        Self::Percent(100)
124
1
    }
125
}
126
127
impl std::fmt::Display for Size {
128
43
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129
43
        match self {
130
39
            Self::Percent(p) => f.write_fmt(format_args!("{p}%")),
131
3
            Self::Fixed(s) => f.write_fmt(format_args!("{s}")),
132
1
            Self::Neg(s) => f.write_fmt(format_args!("-{s}")),
133
        }
134
43
    }
135
}
136
137
/// This mirrors Ratatui's border type
138
///
139
/// We need it so that we can properly use `ValueEnum`
140
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
141
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
142
#[allow(missing_docs)]
143
pub enum BorderType {
144
    /// `ForceOff` disables borders around popups too
145
    /// set with `no_border`
146
    ForceOff,
147
    #[default]
148
    None,
149
    Plain,
150
    Rounded,
151
    Double,
152
    Thick,
153
154
    LightDoubleDashed,
155
    HeavyDoubleDashed,
156
157
    LightTripleDashed,
158
    HeavyTripleDashed,
159
160
    LightQuadrupleDashed,
161
    HeavyQuadrupleDashed,
162
163
    QuadrantInside,
164
    QuadrantOutside,
165
}
166
167
impl BorderType {
168
11.8k
    fn is_none(self) -> bool {
169
11.8k
        
matches!350
(self, BorderType::None | BorderType::ForceOff)
170
11.8k
    }
171
6.09k
    fn is_some(self) -> bool {
172
6.09k
        !self.is_none()
173
6.09k
    }
174
5.69k
    fn into_ratatui(self) -> Option<ratatui::widgets::BorderType> {
175
5.69k
        if self.is_none() {
  Branch (175:12): [True: 5.47k, False: 144]
+
107
            Ok(Self::Neg(
108
5
                neg.parse::<u16>()
109
5
                    .map_err(|e| SizeParseError::ParseError(
value0
.
to_string0
(),
e0
))
?0
,
110
            ))
111
        } else {
112
            Ok(Self::Fixed(
113
501
                value
114
501
                    .parse::<u16>()
115
501
                    .map_err(|e| SizeParseError::ParseError(
value492
.
to_string492
(),
e492
))
?492
,
116
            ))
117
        }
118
1.36k
    }
119
}
120
121
impl Default for Size {
122
1
    fn default() -> Self {
123
1
        Self::Percent(100)
124
1
    }
125
}
126
127
impl std::fmt::Display for Size {
128
43
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129
43
        match self {
130
39
            Self::Percent(p) => f.write_fmt(format_args!("{p}%")),
131
3
            Self::Fixed(s) => f.write_fmt(format_args!("{s}")),
132
1
            Self::Neg(s) => f.write_fmt(format_args!("-{s}")),
133
        }
134
43
    }
135
}
136
137
/// This mirrors Ratatui's border type
138
///
139
/// We need it so that we can properly use `ValueEnum`
140
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
141
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
142
#[allow(missing_docs)]
143
pub enum BorderType {
144
    /// `ForceOff` disables borders around popups too
145
    /// set with `no_border`
146
    ForceOff,
147
    #[default]
148
    None,
149
    Plain,
150
    Rounded,
151
    Double,
152
    Thick,
153
154
    LightDoubleDashed,
155
    HeavyDoubleDashed,
156
157
    LightTripleDashed,
158
    HeavyTripleDashed,
159
160
    LightQuadrupleDashed,
161
    HeavyQuadrupleDashed,
162
163
    QuadrantInside,
164
    QuadrantOutside,
165
}
166
167
impl BorderType {
168
11.8k
    fn is_none(self) -> bool {
169
11.8k
        
matches!369
(self, BorderType::None | BorderType::ForceOff)
170
11.8k
    }
171
6.07k
    fn is_some(self) -> bool {
172
6.07k
        !self.is_none()
173
6.07k
    }
174
5.68k
    fn into_ratatui(self) -> Option<ratatui::widgets::BorderType> {
175
5.68k
        if self.is_none() {
  Branch (175:12): [True: 5.45k, False: 153]
 
  Branch (175:12): [True: 69, False: 1]
-
176
5.54k
            return None;
177
145
        }
178
179
145
        Some(match self {
180
79
            BorderType::Plain => ratatui::widgets::BorderType::Plain,
181
6
            BorderType::Rounded => ratatui::widgets::BorderType::Rounded,
182
6
            BorderType::Double => ratatui::widgets::BorderType::Double,
183
6
            BorderType::Thick => ratatui::widgets::BorderType::Thick,
184
6
            BorderType::LightDoubleDashed => ratatui::widgets::BorderType::LightDoubleDashed,
185
6
            BorderType::HeavyDoubleDashed => ratatui::widgets::BorderType::HeavyDoubleDashed,
186
6
            BorderType::LightTripleDashed => ratatui::widgets::BorderType::LightTripleDashed,
187
6
            BorderType::HeavyTripleDashed => ratatui::widgets::BorderType::HeavyTripleDashed,
188
6
            BorderType::LightQuadrupleDashed => ratatui::widgets::BorderType::LightQuadrupleDashed,
189
6
            BorderType::HeavyQuadrupleDashed => ratatui::widgets::BorderType::HeavyQuadrupleDashed,
190
6
            BorderType::QuadrantInside => ratatui::widgets::BorderType::QuadrantInside,
191
6
            BorderType::QuadrantOutside => ratatui::widgets::BorderType::QuadrantOutside,
192
0
            BorderType::None | BorderType::ForceOff => unreachable!(),
193
        })
194
5.69k
    }
195
}
196
197
#[cfg(feature = "cli")]
198
impl std::str::FromStr for BorderType {
199
    type Err = String;
200
201
465
    fn from_str(s: &str) -> Result<Self, Self::Err> {
202
        use clap::ValueEnum as _;
203
1.01k
        for variant in 
Self::value_variants465
() {
204
1.01k
            if variant.to_possible_value().unwrap().matches(s, false) {
  Branch (204:16): [True: 454, False: 541]
+
176
5.52k
            return None;
177
154
        }
178
179
154
        Some(match self {
180
88
            BorderType::Plain => ratatui::widgets::BorderType::Plain,
181
6
            BorderType::Rounded => ratatui::widgets::BorderType::Rounded,
182
6
            BorderType::Double => ratatui::widgets::BorderType::Double,
183
6
            BorderType::Thick => ratatui::widgets::BorderType::Thick,
184
6
            BorderType::LightDoubleDashed => ratatui::widgets::BorderType::LightDoubleDashed,
185
6
            BorderType::HeavyDoubleDashed => ratatui::widgets::BorderType::HeavyDoubleDashed,
186
6
            BorderType::LightTripleDashed => ratatui::widgets::BorderType::LightTripleDashed,
187
6
            BorderType::HeavyTripleDashed => ratatui::widgets::BorderType::HeavyTripleDashed,
188
6
            BorderType::LightQuadrupleDashed => ratatui::widgets::BorderType::LightQuadrupleDashed,
189
6
            BorderType::HeavyQuadrupleDashed => ratatui::widgets::BorderType::HeavyQuadrupleDashed,
190
6
            BorderType::QuadrantInside => ratatui::widgets::BorderType::QuadrantInside,
191
6
            BorderType::QuadrantOutside => ratatui::widgets::BorderType::QuadrantOutside,
192
0
            BorderType::None | BorderType::ForceOff => unreachable!(),
193
        })
194
5.68k
    }
195
}
196
197
#[cfg(feature = "cli")]
198
impl std::str::FromStr for BorderType {
199
    type Err = String;
200
201
465
    fn from_str(s: &str) -> Result<Self, Self::Err> {
202
        use clap::ValueEnum as _;
203
1.01k
        for variant in 
Self::value_variants465
() {
204
1.01k
            if variant.to_possible_value().unwrap().matches(s, false) {
  Branch (204:16): [True: 454, False: 542]
 
  Branch (204:16): [True: 11, False: 11]
-
205
465
                return Ok(*variant);
206
552
            }
207
        }
208
0
        Ok(Self::Plain)
209
465
    }
210
}
211
#[cfg(test)]
212
mod size_test {
213
    use super::*;
214
    use std::num::IntErrorKind;
215
    #[test]
216
1
    fn fixed_success() {
217
1
        assert_eq!(Size::try_from("10"), Ok(Size::Fixed(10u16)));
218
1
    }
219
    #[test]
220
1
    fn percent_success() {
221
1
        assert_eq!(Size::try_from("10%"), Ok(Size::Percent(10u16)));
222
1
    }
223
    #[test]
224
1
    fn fixed_neg() {
225
1
        assert_eq!(Size::try_from("-10"), Ok(Size::Neg(10)));
226
1
    }
227
    #[test]
228
1
    fn percent_neg() {
229
1
        let SizeParseError::ParseError(err_value, internal_error) = Size::try_from("-10%").unwrap_err() else {
  Branch (229:13): [True: 1, False: 0]
+
205
465
                return Ok(*variant);
206
553
            }
207
        }
208
0
        Ok(Self::Plain)
209
465
    }
210
}
211
#[cfg(test)]
212
mod size_test {
213
    use super::*;
214
    use std::num::IntErrorKind;
215
    #[test]
216
1
    fn fixed_success() {
217
1
        assert_eq!(Size::try_from("10"), Ok(Size::Fixed(10u16)));
218
1
    }
219
    #[test]
220
1
    fn percent_success() {
221
1
        assert_eq!(Size::try_from("10%"), Ok(Size::Percent(10u16)));
222
1
    }
223
    #[test]
224
1
    fn fixed_neg() {
225
1
        assert_eq!(Size::try_from("-10"), Ok(Size::Neg(10)));
226
1
    }
227
    #[test]
228
1
    fn percent_neg() {
229
1
        let SizeParseError::ParseError(err_value, internal_error) = Size::try_from("-10%").unwrap_err() else {
  Branch (229:13): [True: 1, False: 0]
 
230
0
            panic!();
231
        };
232
1
        assert_eq!(internal_error.kind(), &IntErrorKind::InvalidDigit);
233
1
        assert_eq!(err_value, String::from("-10%"));
234
1
    }
235
    #[test]
236
1
    fn percent_over_100() {
237
1
        let SizeParseError::InvalidPercent(internal_error) = Size::try_from("110%").unwrap_err() else {
  Branch (237:13): [True: 1, False: 0]
 
238
0
            panic!();
239
        };
240
1
        assert_eq!(internal_error, 110u16);
241
1
    }
242
    #[test]
243
1
    fn fixed_invalid_char() {
244
1
        let SizeParseError::ParseError(value, internal_error) = Size::try_from("1-0").unwrap_err() else {
  Branch (244:13): [True: 1, False: 0]
 
245
0
            panic!();
246
        };
247
1
        assert_eq!(internal_error.kind(), &IntErrorKind::InvalidDigit);
248
1
        assert_eq!(value, String::from("1-0"));
249
1
    }
250
    #[test]
251
1
    fn percent_invalid_char() {
252
1
        let SizeParseError::ParseError(value, internal_error) = Size::try_from("1-0%").unwrap_err() else {
  Branch (252:13): [True: 1, False: 0]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/options.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/options.rs.html
index c6eedddb..7417e465 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/tui/options.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/options.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/options.rs
Line
Count
Source
1
use crate::tui::{Direction, Size};
2
3
/// Layout configuration for the TUI
4
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
5
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
6
pub enum TuiLayout {
7
    /// Display from the bottom of the screen
8
    #[default]
9
    Default,
10
    /// Display from the top of the screen
11
    Reverse,
12
    /// Display from the top of the screen, prompt at the bottom
13
    ReverseList,
14
}
15
16
/// Configuration for the preview pane layout
17
#[derive(Debug, Clone)]
18
pub struct PreviewLayout {
19
    /// Direction where preview pane is positioned
20
    pub direction: Direction,
21
    /// Size of the preview pane
22
    pub size: Size,
23
    /// Whether the preview pane is hidden
24
    pub hidden: bool,
25
    /// Optional offset for preview position
26
    pub offset: Option<String>,
27
    /// Whether or not to wrap the preview contents
28
    pub wrap: bool,
29
    /// Whether or not to run the preview in a PTY
30
    pub pty: bool,
31
}
32
33
impl Default for PreviewLayout {
34
892
    fn default() -> Self {
35
892
        Self {
36
892
            direction: Direction::Right,
37
892
            size: Size::Percent(50),
38
892
            hidden: false,
39
892
            offset: None,
40
892
            wrap: false,
41
892
            pty: false,
42
892
        }
43
892
    }
44
}
45
46
impl From<&str> for PreviewLayout {
47
497
    fn from(value: &str) -> Self {
48
497
        let mut res: Self = PreviewLayout::default();
49
        // Parse the remainder which can be: size:offset:hidden, offset:hidden, size:hidden, etc.
50
497
        let parts: Vec<&str> = value.split(':').collect();
51
52
999
        for part in 
parts497
{
53
999
            if part.is_empty() {
  Branch (53:16): [True: 5, False: 904]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/options.rs
Line
Count
Source
1
use crate::tui::{Direction, Size};
2
3
/// Layout configuration for the TUI
4
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
5
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
6
pub enum TuiLayout {
7
    /// Display from the bottom of the screen
8
    #[default]
9
    Default,
10
    /// Display from the top of the screen
11
    Reverse,
12
    /// Display from the top of the screen, prompt at the bottom
13
    ReverseList,
14
}
15
16
/// Configuration for the preview pane layout
17
#[derive(Debug, Clone)]
18
pub struct PreviewLayout {
19
    /// Direction where preview pane is positioned
20
    pub direction: Direction,
21
    /// Size of the preview pane
22
    pub size: Size,
23
    /// Whether the preview pane is hidden
24
    pub hidden: bool,
25
    /// Optional offset for preview position
26
    pub offset: Option<String>,
27
    /// Whether or not to wrap the preview contents
28
    pub wrap: bool,
29
    /// Whether or not to run the preview in a PTY
30
    pub pty: bool,
31
}
32
33
impl Default for PreviewLayout {
34
892
    fn default() -> Self {
35
892
        Self {
36
892
            direction: Direction::Right,
37
892
            size: Size::Percent(50),
38
892
            hidden: false,
39
892
            offset: None,
40
892
            wrap: false,
41
892
            pty: false,
42
892
        }
43
892
    }
44
}
45
46
impl From<&str> for PreviewLayout {
47
497
    fn from(value: &str) -> Self {
48
497
        let mut res: Self = PreviewLayout::default();
49
        // Parse the remainder which can be: size:offset:hidden, offset:hidden, size:hidden, etc.
50
497
        let parts: Vec<&str> = value.split(':').collect();
51
52
999
        for part in 
parts497
{
53
999
            if part.is_empty() {
  Branch (53:16): [True: 5, False: 904]
 
  Branch (53:16): [True: 1, False: 89]
 
54
6
                continue;
55
993
            }
56
57
993
            if part.starts_with('+') {
  Branch (57:16): [True: 6, False: 898]
 
  Branch (57:16): [True: 6, False: 83]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/preview.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/preview.rs.html
index 3f5322fc..046e521a 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/tui/preview.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/preview.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/preview.rs
Line
Count
Source
1
use ansi_to_tui::IntoText;
2
use eyre::{Result, eyre};
3
use portable_pty::{PtyPair, PtySize, native_pty_system};
4
use ratatui::layout::Alignment;
5
use ratatui::prelude::Backend;
6
use ratatui::style::Stylize;
7
use ratatui::text::{Line, Text};
8
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
9
#[cfg(feature = "image")]
10
use ratatui_image::picker::Picker;
11
#[cfg(feature = "image")]
12
use ratatui_image::protocol::Protocol as ImageProtocol;
13
use tui_term::vt100;
14
use tui_term::widget::PseudoTerminal;
15
16
use std::env;
17
use std::io::Read;
18
use std::sync::{Arc, RwLock, mpsc};
19
use std::thread::JoinHandle;
20
use std::time::Instant;
21
22
use super::statusline::spinner_char;
23
use super::util::{find_csi_end, find_osc_end, handle_csi_query, handle_osc_query};
24
use super::widget::{SkimRender, SkimWidget};
25
use super::{BorderType, Direction, Event, Tui};
26
27
use crate::theme::ColorTheme;
28
use crate::{SkimItem, SkimOptions};
29
30
// PreviewCallback for ratatui - returns Vec<String> instead of AnsiString
31
pub type PreviewCallbackFn = dyn Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static;
32
const PREVIEW_MAX_BYTES: usize = 1024 * 1024;
33
const VT_SCROLLBACK: usize = 100_000;
34
35
/// Preview content options
36
pub(crate) enum PreviewContent {
37
    /// Simple text content (for non-PTY previews and callbacks)
38
    Text(Text<'static>),
39
    /// Terminal screen (for PTY previews with cursor positioning)
40
    Terminal(Arc<RwLock<vt100::Parser>>),
41
    /// Image
42
    #[cfg(feature = "image")]
43
    Image {
44
        source: image::DynamicImage,
45
        protocol: Option<ImageProtocol>,
46
        size: ratatui::layout::Size,
47
    },
48
}
49
50
impl Default for PreviewContent {
51
538
    fn default() -> Self {
52
538
        PreviewContent::Text(Text::default())
53
538
    }
54
}
55
56
/// Callback function for generating preview content
57
#[derive(Clone)]
58
pub struct PreviewCallback {
59
    inner: Arc<PreviewCallbackFn>,
60
}
61
62
impl<F> From<F> for PreviewCallback
63
where
64
    F: Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static,
65
{
66
3
    fn from(func: F) -> Self {
67
3
        Self { inner: Arc::new(func) }
68
3
    }
69
}
70
71
impl std::ops::Deref for PreviewCallback {
72
    type Target = dyn Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static;
73
74
3
    fn deref(&self) -> &Self::Target {
75
3
        &*self.inner
76
3
    }
77
}
78
79
pub struct Preview {
80
    pub(crate) content: Arc<RwLock<PreviewContent>>,
81
    pub cmd: String,
82
    pub rows: u16,
83
    pub cols: u16,
84
    pub scroll_y: u16,
85
    pub scroll_x: u16,
86
    pub thread_handle: Option<JoinHandle<()>>,
87
    /// Channel to signal thread interruption
88
    interrupt_tx: Option<mpsc::Sender<()>>,
89
    pub theme: Arc<ColorTheme>,
90
    /// Border type
91
    pub border: BorderType,
92
    pub direction: Direction,
93
    pub wrap: bool,
94
    pty: Option<PtyPair>,
95
    pty_child: Option<Box<dyn portable_pty::Child + Send + Sync>>,
96
    #[cfg(feature = "image")]
97
    image: bool,
98
    #[cfg(feature = "image")]
99
    image_picker: Option<Picker>,
100
    pub total_lines: u16,
101
    loading: bool,
102
    spinner_start: Instant,
103
}
104
105
impl Default for Preview {
106
10
    fn default() -> Self {
107
10
        Self::_default()
108
10
    }
109
}
110
111
impl Preview {
112
    #[cfg(feature = "image")]
113
8
    fn image_protocol(
114
8
        picker: Option<&Picker>,
115
8
        source: image::DynamicImage,
116
8
        size: ratatui::layout::Size,
117
8
    ) -> std::result::Result<ImageProtocol, ratatui_image::errors::Errors> {
118
        let fallback;
119
8
        let picker = if let Some(
picker3
) = picker {
  Branch (119:29): [True: 0, False: 4]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/preview.rs
Line
Count
Source
1
use ansi_to_tui::IntoText;
2
use eyre::{Result, eyre};
3
use portable_pty::{PtyPair, PtySize, native_pty_system};
4
use ratatui::layout::Alignment;
5
use ratatui::prelude::Backend;
6
use ratatui::style::Stylize;
7
use ratatui::text::{Line, Text};
8
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
9
#[cfg(feature = "image")]
10
use ratatui_image::picker::Picker;
11
#[cfg(feature = "image")]
12
use ratatui_image::protocol::Protocol as ImageProtocol;
13
use tui_term::vt100;
14
use tui_term::widget::PseudoTerminal;
15
16
use std::env;
17
use std::io::Read;
18
use std::sync::{Arc, RwLock, mpsc};
19
use std::thread::JoinHandle;
20
use std::time::Instant;
21
22
use super::statusline::spinner_char;
23
use super::util::{find_csi_end, find_osc_end, handle_csi_query, handle_osc_query};
24
use super::widget::{SkimRender, SkimWidget};
25
use super::{BorderType, Direction, Event, Tui};
26
27
use crate::theme::ColorTheme;
28
use crate::{SkimItem, SkimOptions};
29
30
// PreviewCallback for ratatui - returns Vec<String> instead of AnsiString
31
pub type PreviewCallbackFn = dyn Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static;
32
const PREVIEW_MAX_BYTES: usize = 1024 * 1024;
33
const VT_SCROLLBACK: usize = 100_000;
34
35
/// Preview content options
36
pub(crate) enum PreviewContent {
37
    /// Simple text content (for non-PTY previews and callbacks)
38
    Text(Text<'static>),
39
    /// Terminal screen (for PTY previews with cursor positioning)
40
    Terminal(Arc<RwLock<vt100::Parser>>),
41
    /// Image
42
    #[cfg(feature = "image")]
43
    Image {
44
        source: image::DynamicImage,
45
        protocol: Option<ImageProtocol>,
46
        size: ratatui::layout::Size,
47
    },
48
}
49
50
impl Default for PreviewContent {
51
538
    fn default() -> Self {
52
538
        PreviewContent::Text(Text::default())
53
538
    }
54
}
55
56
/// Callback function for generating preview content
57
#[derive(Clone)]
58
pub struct PreviewCallback {
59
    inner: Arc<PreviewCallbackFn>,
60
}
61
62
impl<F> From<F> for PreviewCallback
63
where
64
    F: Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static,
65
{
66
3
    fn from(func: F) -> Self {
67
3
        Self { inner: Arc::new(func) }
68
3
    }
69
}
70
71
impl std::ops::Deref for PreviewCallback {
72
    type Target = dyn Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static;
73
74
3
    fn deref(&self) -> &Self::Target {
75
3
        &*self.inner
76
3
    }
77
}
78
79
pub struct Preview {
80
    pub(crate) content: Arc<RwLock<PreviewContent>>,
81
    pub cmd: String,
82
    pub rows: u16,
83
    pub cols: u16,
84
    pub scroll_y: u16,
85
    pub scroll_x: u16,
86
    pub thread_handle: Option<JoinHandle<()>>,
87
    /// Channel to signal thread interruption
88
    interrupt_tx: Option<mpsc::Sender<()>>,
89
    pub theme: Arc<ColorTheme>,
90
    /// Border type
91
    pub border: BorderType,
92
    pub direction: Direction,
93
    pub wrap: bool,
94
    pty: Option<PtyPair>,
95
    pty_child: Option<Box<dyn portable_pty::Child + Send + Sync>>,
96
    #[cfg(feature = "image")]
97
    image: bool,
98
    #[cfg(feature = "image")]
99
    image_picker: Option<Picker>,
100
    pub total_lines: u16,
101
    loading: bool,
102
    spinner_start: Instant,
103
}
104
105
impl Default for Preview {
106
10
    fn default() -> Self {
107
10
        Self::_default()
108
10
    }
109
}
110
111
impl Preview {
112
    #[cfg(feature = "image")]
113
8
    fn image_protocol(
114
8
        picker: Option<&Picker>,
115
8
        source: image::DynamicImage,
116
8
        size: ratatui::layout::Size,
117
8
    ) -> std::result::Result<ImageProtocol, ratatui_image::errors::Errors> {
118
        let fallback;
119
8
        let picker = if let Some(
picker3
) = picker {
  Branch (119:29): [True: 0, False: 4]
 
  Branch (119:29): [True: 3, False: 1]
 
120
3
            picker
121
        } else {
122
5
            fallback = Picker::halfblocks();
123
5
            &fallback
124
        };
125
126
8
        let font_size = picker.font_size();
127
8
        let max_pixel_width = u128::from(size.width) * u128::from(font_size.width);
128
8
        let max_pixel_height = u128::from(size.height) * u128::from(font_size.height);
129
8
        let image_width = u128::from(source.width());
130
8
        let image_height = u128::from(source.height());
131
132
8
        let size = if image_height * max_pixel_width <= max_pixel_height * image_width {
  Branch (132:23): [True: 3, False: 1]
 
  Branch (132:23): [True: 3, False: 1]
@@ -8,7 +8,7 @@
 
  Branch (162:36): [True: 2, False: 2]
 
163
4
                dimension.saturating_sub(n)
164
            }
165
        }
166
17
    }
167
168
36
    fn init_pty(&mut self) {
169
36
        let pty_system = native_pty_system();
170
36
        let cols = if self.wrap { 
self.cols2
} else {
102434
};
  Branch (170:23): [True: 2, False: 34]
 
  Branch (170:23): [True: 0, False: 0]
-
171
36
        let pair = pty_system.openpty(PtySize {
172
36
            rows: self.rows,
173
36
            cols,
174
36
            pixel_width: 0,
175
36
            pixel_height: 0,
176
36
        });
177
36
        match pair {
178
36
            Ok(p) => self.pty = Some(p),
179
0
            Err(e) => warn!("failed to init preview pty: {e:?}"),
180
        }
181
36
    }
182
183
    /// Filter out terminal query sequences from the output and respond to them.
184
    /// This prevents programs like delta from waiting for responses and timing out.
185
    /// Returns the filtered output with query sequences removed.
186
31
    fn filter_and_respond_to_queries(data: &[u8], writer: &mut Box<dyn std::io::Write + Send>) -> Vec<u8> {
187
31
        let mut result = Vec::new();
188
31
        let mut i = 0;
189
190
18.2k
        while i < data.len() {
  Branch (190:15): [True: 18.2k, False: 30]
+
171
36
        let pair = pty_system.openpty(PtySize {
172
36
            rows: self.rows,
173
36
            cols,
174
36
            pixel_width: 0,
175
36
            pixel_height: 0,
176
36
        });
177
36
        match pair {
178
36
            Ok(p) => self.pty = Some(p),
179
0
            Err(e) => warn!("failed to init preview pty: {e:?}"),
180
        }
181
36
    }
182
183
    /// Filter out terminal query sequences from the output and respond to them.
184
    /// This prevents programs like delta from waiting for responses and timing out.
185
    /// Returns the filtered output with query sequences removed.
186
36
    fn filter_and_respond_to_queries(data: &[u8], writer: &mut Box<dyn std::io::Write + Send>) -> Vec<u8> {
187
36
        let mut result = Vec::new();
188
36
        let mut i = 0;
189
190
18.2k
        while i < data.len() {
  Branch (190:15): [True: 18.2k, False: 35]
 
  Branch (190:15): [True: 7, False: 1]
 
191
18.2k
            if data[i] == b'\x1b' && 
i + 11
< data.len() {
  Branch (191:16): [True: 0, False: 18.2k]
   Branch (191:38): [True: 0, False: 0]
@@ -20,7 +20,7 @@
 
  Branch (204:32): [True: 1, False: 0]
 
205
1
                            let seq = &data[i..i + end];
206
1
                            if handle_csi_query(seq, writer) {
  Branch (206:32): [True: 0, False: 0]
 
  Branch (206:32): [True: 1, False: 0]
-
207
                                // It was a query, filter it out
208
1
                                i += end;
209
1
                                continue;
210
0
                            }
211
                            // Not a query, keep it in output
212
0
                        }
213
                    }
214
0
                    _ => {}
215
                }
216
18.2k
            }
217
18.2k
            result.push(data[i]);
218
18.2k
            i += 1;
219
        }
220
221
31
        result
222
31
    }
223
224
11
    pub fn content(&mut self, content: &[u8]) -> Result<()> {
225
11
        let text = content.to_owned().into_text()
?0
;
226
11
        let Ok(mut content) = self.content.write() else {
  Branch (226:13): [True: 0, False: 0]
+
207
                                // It was a query, filter it out
208
1
                                i += end;
209
1
                                continue;
210
0
                            }
211
                            // Not a query, keep it in output
212
0
                        }
213
                    }
214
0
                    _ => {}
215
                }
216
18.2k
            }
217
18.2k
            result.push(data[i]);
218
18.2k
            i += 1;
219
        }
220
221
36
        result
222
36
    }
223
224
11
    pub fn content(&mut self, content: &[u8]) -> Result<()> {
225
11
        let text = content.to_owned().into_text()
?0
;
226
11
        let Ok(mut content) = self.content.write() else {
  Branch (226:13): [True: 0, False: 0]
 
  Branch (226:13): [True: 11, False: 0]
 
227
0
            return Err(eyre::eyre!("Failed to acquire content for writing"));
228
        };
229
11
        self.total_lines = text.lines.len().try_into().unwrap();
230
11
        *content = PreviewContent::Text(text);
231
11
        self.scroll_y = 0;
232
11
        self.scroll_x = 0;
233
11
        self.loading = false;
234
11
        Ok(())
235
11
    }
236
237
1.74k
    pub(crate) fn is_loading(&self) -> bool {
238
1.74k
        self.loading
239
1.74k
    }
240
241
71
    pub(crate) fn mark_ready(&mut self) {
242
71
        self.loading = false;
243
71
    }
244
245
3
    pub fn content_with_position(&mut self, content: &[u8], position: crate::PreviewPosition) -> Result<()> {
246
3
        self.content(content).map(|()| {
247
            // Apply position offsets
248
3
            let v_scroll = self.size_to_offset(position.v_scroll, true);
249
3
            let v_offset = self.size_to_offset(position.v_offset, true);
250
3
            self.scroll_y = v_scroll.saturating_add(v_offset);
251
252
3
            let h_scroll = self.size_to_offset(position.h_scroll, false);
253
3
            let h_offset = self.size_to_offset(position.h_offset, false);
254
3
            self.scroll_x = h_scroll.saturating_add(h_offset);
255
3
        })
256
3
    }
257
258
5
    pub fn scroll_up(&mut self, lines: u16) {
259
5
        self.scroll_y = self.scroll_y.saturating_sub(lines);
260
5
    }
261
262
8
    pub fn scroll_down(&mut self, lines: u16) {
263
8
        trace!(
264
            "scrolling down by {lines} lines, ({} total, {} rows)",
265
            self.total_lines, self.rows
266
        );
267
8
        if self.total_lines > 0 {
  Branch (267:12): [True: 0, False: 0]
 
  Branch (267:12): [True: 2, False: 6]
@@ -220,14 +220,14 @@
 
  Branch (428:20): [True: 0, False: 0]
 
  Branch (428:20): [True: 0, False: 0]
 
  Branch (428:20): [True: 0, False: 0]
-
429
23
                *c = PreviewContent::Terminal(parser.clone());
430
23
            
}0
431
432
23
            self.thread_handle = Some(std::thread::spawn(move || {
433
23
                let mut buf = [0u8; 8192];
434
23
                let mut unprocessed_buf = Vec::new();
435
436
23
                trace!("preview reader thread started");
437
438
                loop {
439
53
                    if interrupt_rx.try_recv().is_ok() {
  Branch (439:24): [True: 0, False: 0]
+
429
23
                *c = PreviewContent::Terminal(parser.clone());
430
23
            
}0
431
432
23
            self.thread_handle = Some(std::thread::spawn(move || {
433
23
                let mut buf = [0u8; 8192];
434
23
                let mut unprocessed_buf = Vec::new();
435
436
23
                trace!("preview reader thread started");
437
438
                loop {
439
58
                    if interrupt_rx.try_recv().is_ok() {
  Branch (439:24): [True: 0, False: 0]
 
  Branch (439:24): [True: 0, False: 0]
 
  Branch (439:24): [True: 0, False: 0]
 
  Branch (439:24): [True: 0, False: 0]
 
  Branch (439:24): [True: 0, False: 0]
 
  Branch (439:24): [True: 0, False: 0]
 
  Branch (439:24): [True: 0, False: 0]
-
  Branch (439:24): [True: 0, False: 53]
+
  Branch (439:24): [True: 0, False: 58]
 
  Branch (439:24): [True: 0, False: 0]
 
  Branch (439:24): [True: 0, False: 0]
 
  Branch (439:24): [True: 0, False: 0]
@@ -239,14 +239,14 @@
 
  Branch (439:24): [True: 0, False: 0]
 
  Branch (439:24): [True: 0, False: 0]
 
  Branch (439:24): [True: 0, False: 0]
-
440
0
                        trace!("interrupt signal received, exiting");
441
0
                        return;
442
53
                    }
443
444
53
                    match reader.read(&mut buf) {
445
                        Ok(0) => {
446
23
                            trace!("reached EOF");
447
23
                            break;
448
                        }
449
30
                        Ok(size) => {
450
30
                            trace!("read {size} bytes");
451
30
                            unprocessed_buf.extend_from_slice(&buf[..size]);
452
453
                            // Filter out terminal query sequences and respond to them
454
                            // This prevents programs like delta from waiting for responses
455
30
                            let filtered = Self::filter_and_respond_to_queries(&unprocessed_buf, &mut esc_writer);
456
457
30
                            if let Ok(mut parser_guard) = parser.write() {
  Branch (457:36): [True: 0, False: 0]
+
440
0
                        trace!("interrupt signal received, exiting");
441
0
                        return;
442
58
                    }
443
444
58
                    match reader.read(&mut buf) {
445
                        Ok(0) => {
446
23
                            trace!("reached EOF");
447
23
                            break;
448
                        }
449
35
                        Ok(size) => {
450
35
                            trace!("read {size} bytes");
451
35
                            unprocessed_buf.extend_from_slice(&buf[..size]);
452
453
                            // Filter out terminal query sequences and respond to them
454
                            // This prevents programs like delta from waiting for responses
455
35
                            let filtered = Self::filter_and_respond_to_queries(&unprocessed_buf, &mut esc_writer);
456
457
35
                            if let Ok(mut parser_guard) = parser.write() {
  Branch (457:36): [True: 0, False: 0]
 
  Branch (457:36): [True: 0, False: 0]
 
  Branch (457:36): [True: 0, False: 0]
 
  Branch (457:36): [True: 0, False: 0]
 
  Branch (457:36): [True: 0, False: 0]
 
  Branch (457:36): [True: 0, False: 0]
 
  Branch (457:36): [True: 0, False: 0]
-
  Branch (457:36): [True: 30, False: 0]
+
  Branch (457:36): [True: 35, False: 0]
 
  Branch (457:36): [True: 0, False: 0]
 
  Branch (457:36): [True: 0, False: 0]
 
  Branch (457:36): [True: 0, False: 0]
@@ -258,14 +258,14 @@
 
  Branch (457:36): [True: 0, False: 0]
 
  Branch (457:36): [True: 0, False: 0]
 
  Branch (457:36): [True: 0, False: 0]
-
458
30
                                parser_guard.process(&filtered);
459
30
                                parser_guard.screen_mut().set_scrollback(VT_SCROLLBACK);
460
30
                            
}0
461
462
                            // Clear the processed portion of the buffer
463
30
                            unprocessed_buf.clear();
464
465
                            // Check interrupt after processing
466
30
                            if interrupt_rx.try_recv().is_ok() {
  Branch (466:32): [True: 0, False: 0]
+
458
35
                                parser_guard.process(&filtered);
459
35
                                parser_guard.screen_mut().set_scrollback(VT_SCROLLBACK);
460
35
                            
}0
461
462
                            // Clear the processed portion of the buffer
463
35
                            unprocessed_buf.clear();
464
465
                            // Check interrupt after processing
466
35
                            if interrupt_rx.try_recv().is_ok() {
  Branch (466:32): [True: 0, False: 0]
 
  Branch (466:32): [True: 0, False: 0]
 
  Branch (466:32): [True: 0, False: 0]
 
  Branch (466:32): [True: 0, False: 0]
 
  Branch (466:32): [True: 0, False: 0]
 
  Branch (466:32): [True: 0, False: 0]
 
  Branch (466:32): [True: 0, False: 0]
-
  Branch (466:32): [True: 0, False: 30]
+
  Branch (466:32): [True: 0, False: 35]
 
  Branch (466:32): [True: 0, False: 0]
 
  Branch (466:32): [True: 0, False: 0]
 
  Branch (466:32): [True: 0, False: 0]
@@ -277,7 +277,7 @@
 
  Branch (466:32): [True: 0, False: 0]
 
  Branch (466:32): [True: 0, False: 0]
 
  Branch (466:32): [True: 0, False: 0]
-
467
0
                                trace!("interrupt signal received during read block, exiting");
468
0
                                return;
469
30
                            }
470
                        }
471
0
                        Err(e) => {
472
0
                            trace!("read error {e:?}");
473
0
                            break;
474
                        }
475
                    }
476
                }
477
478
                // Last check, I promise
479
23
                if interrupt_rx.try_recv().is_ok() {
  Branch (479:20): [True: 0, False: 0]
+
467
0
                                trace!("interrupt signal received during read block, exiting");
468
0
                                return;
469
35
                            }
470
                        }
471
0
                        Err(e) => {
472
0
                            trace!("read error {e:?}");
473
0
                            break;
474
                        }
475
                    }
476
                }
477
478
                // Last check, I promise
479
23
                if interrupt_rx.try_recv().is_ok() {
  Branch (479:20): [True: 0, False: 0]
 
  Branch (479:20): [True: 0, False: 0]
 
  Branch (479:20): [True: 0, False: 0]
 
  Branch (479:20): [True: 0, False: 0]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/statusline.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/statusline.rs.html
index d10e5a2f..888af0c2 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/tui/statusline.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/statusline.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/statusline.rs
Line
Count
Source
1
use std::time::Instant;
2
3
/// Default inline info separator
4
pub const DEFAULT_SEPARATOR: &str = "  < ";
5
pub(crate) const SPINNER_DURATION: u32 = 200;
6
pub(crate) const SPINNERS_UNICODE: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
7
8
20
pub(crate) fn spinner_char(start: Instant) -> char {
9
20
    let spinner_elapsed_ms = start.elapsed().as_millis();
10
20
    let index = ((spinner_elapsed_ms / u128::from(SPINNER_DURATION)) % (SPINNERS_UNICODE.len() as u128)) as usize;
11
20
    SPINNERS_UNICODE[index]
12
20
}
13
14
/// Simplified display mode for the info/status line
15
#[derive(Debug, Clone, Default, Eq, PartialEq)]
16
pub enum InfoDisplay {
17
    /// Display info in a separate line (default)
18
    #[default]
19
    Default,
20
    /// Display all info in a separate line, left-aligned
21
    Left,
22
    /// Display all info in a separate line, right-aligned
23
    Right,
24
    /// Display info inline with the input
25
    Inline,
26
    /// Hide the info display
27
    Hidden,
28
    /// Inline and right-aligned
29
    InlineRight,
30
}
31
impl InfoDisplay {
32
486
    pub(crate) fn is_inline(&self) -> bool {
33
486
        
matches!474
(self, InfoDisplay::Inline | InfoDisplay::InlineRight)
34
486
    }
35
}
36
37
/// Full display mode for the info/status line
38
#[derive(Debug, Clone, Default, Eq, PartialEq)]
39
pub struct Info {
40
    /// The `InfoDisplay`
41
    pub display: InfoDisplay,
42
    /// The separator, specified if the display is inline
43
    pub separator: Option<String>,
44
}
45
46
impl Info {
47
2.63k
    pub(crate) fn separator(&self) -> Option<&str> {
48
2.63k
        self.separator.as_deref()
49
2.63k
    }
50
}
51
52
impl From<InfoDisplay> for Info {
53
6
    fn from(value: InfoDisplay) -> Self {
54
6
        let is_inline = value.is_inline();
55
        Self {
56
6
            display: value,
57
6
            separator: if is_inline {
  Branch (57:27): [True: 0, False: 0]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/statusline.rs
Line
Count
Source
1
use std::time::Instant;
2
3
/// Default inline info separator
4
pub const DEFAULT_SEPARATOR: &str = "  < ";
5
pub(crate) const SPINNER_DURATION: u32 = 200;
6
pub(crate) const SPINNERS_UNICODE: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
7
8
17
pub(crate) fn spinner_char(start: Instant) -> char {
9
17
    let spinner_elapsed_ms = start.elapsed().as_millis();
10
17
    let index = ((spinner_elapsed_ms / u128::from(SPINNER_DURATION)) % (SPINNERS_UNICODE.len() as u128)) as usize;
11
17
    SPINNERS_UNICODE[index]
12
17
}
13
14
/// Simplified display mode for the info/status line
15
#[derive(Debug, Clone, Default, Eq, PartialEq)]
16
pub enum InfoDisplay {
17
    /// Display info in a separate line (default)
18
    #[default]
19
    Default,
20
    /// Display all info in a separate line, left-aligned
21
    Left,
22
    /// Display all info in a separate line, right-aligned
23
    Right,
24
    /// Display info inline with the input
25
    Inline,
26
    /// Hide the info display
27
    Hidden,
28
    /// Inline and right-aligned
29
    InlineRight,
30
}
31
impl InfoDisplay {
32
486
    pub(crate) fn is_inline(&self) -> bool {
33
486
        
matches!474
(self, InfoDisplay::Inline | InfoDisplay::InlineRight)
34
486
    }
35
}
36
37
/// Full display mode for the info/status line
38
#[derive(Debug, Clone, Default, Eq, PartialEq)]
39
pub struct Info {
40
    /// The `InfoDisplay`
41
    pub display: InfoDisplay,
42
    /// The separator, specified if the display is inline
43
    pub separator: Option<String>,
44
}
45
46
impl Info {
47
2.62k
    pub(crate) fn separator(&self) -> Option<&str> {
48
2.62k
        self.separator.as_deref()
49
2.62k
    }
50
}
51
52
impl From<InfoDisplay> for Info {
53
6
    fn from(value: InfoDisplay) -> Self {
54
6
        let is_inline = value.is_inline();
55
        Self {
56
6
            display: value,
57
6
            separator: if is_inline {
  Branch (57:27): [True: 0, False: 0]
 
  Branch (57:27): [True: 2, False: 4]
 
58
2
                Some(String::from(DEFAULT_SEPARATOR))
59
            } else {
60
4
                None
61
            },
62
        }
63
6
    }
64
}
65
66
impl From<&str> for Info {
67
475
    fn from(s: &str) -> Self {
68
        use InfoDisplay::{Default, Hidden, Inline, InlineRight, Left, Right};
69
475
        let mut parts = s.split(':');
70
71
475
        let 
display474
= match parts.next() {
72
475
            None | Some("default") => 
Default449
,
73
26
            Some("left") => 
Left2
,
74
24
            Some("right") => 
Right2
,
75
22
            Some("inline") => 
Inline5
,
76
17
            Some("inline-right") => 
InlineRight3
,
77
14
            Some("hidden") => 
Hidden13
,
78
1
            Some(x) => panic!(
79
                "Failed to parse {x} as an InfoDisplay. Possible options are `default`, `left`, `right`, `inline`, `inline-right` or `hidden`"
80
            ),
81
        };
82
474
        let separator = if display.is_inline() {
  Branch (82:28): [True: 4, False: 450]
 
  Branch (82:28): [True: 4, False: 16]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/util.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/util.rs.html
index 8e265afd..790e3f8c 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/tui/util.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/util.rs.html
@@ -1,11 +1,11 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/util.rs
Line
Count
Source
1
use crossterm::terminal;
2
use ratatui::style::Style;
3
use ratatui::text::{Line, Span, Text};
4
use std::io::{self, Write};
5
#[cfg(unix)]
6
use std::{
7
    fs::OpenOptions,
8
    io::Read,
9
    os::fd::{AsFd, AsRawFd},
10
    os::unix::fs::OpenOptionsExt as _,
11
};
12
use unicode_display_width::is_double_width;
13
14
/// Clips a [`Line`] to at most `max_chars` characters, preserving per-span styles.
15
///
16
/// Iterates over the spans in `line` and collects characters until `max_chars`
17
/// is reached, splitting a span at the boundary if necessary.  The returned
18
/// line owns all its string data (`Line<'static>`), so it can be stored or
19
/// passed across lifetimes freely.
20
///
21
/// This is the shared primitive used whenever a fully-displayed item line
22
/// (potentially with ANSI colours or match highlights) must be clipped to the
23
/// character count of a single multiline sub-segment — for example, when
24
/// rendering the first sub-line of a `--multiline` item in both the item list
25
/// and the header-lines area.
26
676
pub(crate) fn clip_line_to_chars(line: Line<'_>, max_chars: usize) -> Line<'static> {
27
676
    let mut chars_seen = 0usize;
28
676
    let mut clipped: Vec<Span<'static>> = Vec::new();
29
2.02k
    for span in 
line.spans676
{
30
2.02k
        if chars_seen >= max_chars {
  Branch (30:12): [True: 13, False: 2.00k]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/util.rs
Line
Count
Source
1
use crossterm::terminal;
2
use ratatui::style::Style;
3
use ratatui::text::{Line, Span, Text};
4
use std::io::{self, Write};
5
#[cfg(unix)]
6
use std::{
7
    fs::OpenOptions,
8
    io::Read,
9
    os::fd::{AsFd, AsRawFd},
10
    os::unix::fs::OpenOptionsExt as _,
11
};
12
use unicode_display_width::is_double_width;
13
14
/// Clips a [`Line`] to at most `max_chars` characters, preserving per-span styles.
15
///
16
/// Iterates over the spans in `line` and collects characters until `max_chars`
17
/// is reached, splitting a span at the boundary if necessary.  The returned
18
/// line owns all its string data (`Line<'static>`), so it can be stored or
19
/// passed across lifetimes freely.
20
///
21
/// This is the shared primitive used whenever a fully-displayed item line
22
/// (potentially with ANSI colours or match highlights) must be clipped to the
23
/// character count of a single multiline sub-segment — for example, when
24
/// rendering the first sub-line of a `--multiline` item in both the item list
25
/// and the header-lines area.
26
676
pub(crate) fn clip_line_to_chars(line: Line<'_>, max_chars: usize) -> Line<'static> {
27
676
    let mut chars_seen = 0usize;
28
676
    let mut clipped: Vec<Span<'static>> = Vec::new();
29
2.02k
    for span in 
line.spans676
{
30
2.02k
        if chars_seen >= max_chars {
  Branch (30:12): [True: 13, False: 2.00k]
 
  Branch (30:12): [True: 3, False: 8]
 
31
16
            break;
32
2.01k
        }
33
2.01k
        let span_chars: Vec<char> = span.content.chars().collect();
34
2.01k
        let take = (max_chars - chars_seen).min(span_chars.len());
35
2.01k
        let text: String = span_chars[..take].iter().collect();
36
2.01k
        if !text.is_empty() {
  Branch (36:12): [True: 695, False: 1.30k]
 
  Branch (36:12): [True: 8, False: 0]
 
37
703
            clipped.push(Span::styled(text, span.style));
38
1.30k
        }
39
2.01k
        chars_seen += span_chars.len();
40
    }
41
676
    Line::from(clipped)
42
676
}
43
44
// Directly taken from https://docs.rs/unicode-display-width/0.3.0/src/unicode_display_width/lib.rs.html#77-81
45
#[inline]
46
101k
pub fn char_display_width(c: char) -> usize {
47
101k
    if c == '\u{FE0F}' || 
is_double_width101k
(
c101k
) {
  Branch (47:8): [True: 0, False: 100k]
   Branch (47:27): [True: 18, False: 100k]
-
  Branch (47:8): [True: 1, False: 852]
-  Branch (47:27): [True: 11, False: 841]
+
  Branch (47:8): [True: 1, False: 854]
+  Branch (47:27): [True: 11, False: 843]
 
48
30
        return 2;
49
101k
    }
50
101k
    1
51
101k
}
52
53
26
pub fn wrap_text(input: Text, width: usize) -> Text {
54
26
    if input.width() <= width {
  Branch (54:8): [True: 3, False: 12]
 
  Branch (54:8): [True: 4, False: 7]
 
55
7
        return input;
56
19
    }
57
58
19
    let mut output = Text::default();
59
60
20
    for input_line in 
input19
.
iter19
() {
61
20
        let mut current_line = Line::default();
62
20
        let mut w = 0;
63
53
        for span in 
&input_line.spans20
{
64
53
            let mut curr = Span::default().style(span.style);
65
53
            let mut curr_content = String::new();
66
1.52k
            for c in 
span.content.chars()53
{
67
1.52k
                if w + char_display_width(c) > width {
  Branch (67:20): [True: 15, False: 1.43k]
@@ -16,7 +16,7 @@
 
  Branch (84:16): [True: 11, False: 0]
 
85
47
                curr.content = curr_content.into();
86
47
                current_line.push_span(curr);
87
47
            
}6
88
        }
89
        // Push remaining line
90
20
        if !current_line.spans.is_empty() {
  Branch (90:12): [True: 12, False: 0]
 
  Branch (90:12): [True: 8, False: 0]
-
91
20
            output.push_line(current_line);
92
20
        
}0
93
    }
94
95
19
    output
96
26
}
97
98
/// Merges styles from right to left
99
/// left has higher priority
100
/// contrary to ratatui's `Style::patch`, this will override `Reset` with the new style if set
101
3.21k
pub(crate) fn merge_styles(left: Style, right: Style) -> Style {
102
    use ratatui::style::Color::Reset;
103
3.21k
    let mut res = Style::default();
104
    macro_rules! set_field {
105
        ($res:ident, $left:ident, $right:ident, $field:ident) => {
106
            if left.$field == Some(Reset) {
107
                $res.$field = $right.$field;
108
            } else if $right.$field == Some(Reset) {
109
                $res.$field = $left.$field;
110
            } else {
111
                $res.$field = $right.$field.or($left.$field);
112
            }
113
        };
114
    }
115
116
3.21k
    set_field!(res, left, right, fg);
117
3.21k
    set_field!(res, left, right, bg);
118
3.21k
    set_field!(res, left, right, underline_color);
119
3.21k
    res.add_modifier = left.add_modifier | right.add_modifier;
120
121
3.21k
    res
122
3.21k
}
123
124
2.93k
pub(crate) fn style_span(span: &mut Span, style: Style) {
125
2.93k
    span.style = merge_styles(style, span.style);
126
2.93k
}
127
2.92k
pub(crate) fn style_line(line: &mut Line, style: Style) {
128
2.93k
    
line2.92k
.
iter_mut2.92k
().
for_each2.92k
(|span| style_span(span, style));
129
2.92k
}
130
139
pub(crate) fn style_text(text: &mut Text, style: Style) {
131
151
    
text139
.
iter_mut139
().
for_each139
(|line| style_line(line, style));
132
139
}
133
134
/// Find the end of an OSC sequence (terminated by ESC \ or BEL)
135
3
pub(crate) fn find_osc_end(data: &[u8]) -> Option<usize> {
136
23
    for i in 
2..data.len()3
{
137
23
        if data[i] == b'\x07' {
  Branch (137:12): [True: 0, False: 0]
+
91
20
            output.push_line(current_line);
92
20
        
}0
93
    }
94
95
19
    output
96
26
}
97
98
/// Merges styles from right to left
99
/// left has higher priority
100
/// contrary to ratatui's `Style::patch`, this will override `Reset` with the new style if set
101
3.21k
pub(crate) fn merge_styles(left: Style, right: Style) -> Style {
102
    use ratatui::style::Color::Reset;
103
3.21k
    let mut res = Style::default();
104
    macro_rules! set_field {
105
        ($res:ident, $left:ident, $right:ident, $field:ident) => {
106
            if left.$field == Some(Reset) {
107
                $res.$field = $right.$field;
108
            } else if $right.$field == Some(Reset) {
109
                $res.$field = $left.$field;
110
            } else {
111
                $res.$field = $right.$field.or($left.$field);
112
            }
113
        };
114
    }
115
116
3.21k
    set_field!(res, left, right, fg);
117
3.21k
    set_field!(res, left, right, bg);
118
3.21k
    set_field!(res, left, right, underline_color);
119
3.21k
    res.add_modifier = left.add_modifier | right.add_modifier;
120
121
3.21k
    res
122
3.21k
}
123
124
2.94k
pub(crate) fn style_span(span: &mut Span, style: Style) {
125
2.94k
    span.style = merge_styles(style, span.style);
126
2.94k
}
127
2.93k
pub(crate) fn style_line(line: &mut Line, style: Style) {
128
2.94k
    
line2.93k
.
iter_mut2.93k
().
for_each2.93k
(|span| style_span(span, style));
129
2.93k
}
130
145
pub(crate) fn style_text(text: &mut Text, style: Style) {
131
157
    
text145
.
iter_mut145
().
for_each145
(|line| style_line(line, style));
132
145
}
133
134
/// Find the end of an OSC sequence (terminated by ESC \ or BEL)
135
3
pub(crate) fn find_osc_end(data: &[u8]) -> Option<usize> {
136
23
    for i in 
2..data.len()3
{
137
23
        if data[i] == b'\x07' {
  Branch (137:12): [True: 0, False: 0]
 
  Branch (137:12): [True: 1, False: 22]
 
138
            // BEL terminator
139
1
            return Some(i + 1);
140
22
        }
141
22
        if i + 1 < data.len() && 
data[i] == b'\x1b'21
&&
data[i + 1] == b'\\'1
{
  Branch (141:12): [True: 0, False: 0]
   Branch (141:34): [True: 0, False: 0]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/widget.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/widget.rs.html
index c47f4cae..dfba97f3 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/tui/widget.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/widget.rs.html
@@ -1 +1 @@
-

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/widget.rs
Line
Count
Source
1
use ratatui::buffer::Buffer;
2
use ratatui::layout::Rect;
3
use std::ops::BitOrAssign;
4
use std::sync::Arc;
5
6
use crate::options::SkimOptions;
7
use crate::theme::ColorTheme;
8
9
/// Result of rendering a `SkimWidget`
10
#[derive(Debug, Clone, Copy, Default)]
11
pub struct SkimRender {
12
    /// Whether the items in the list have been updated
13
    pub items_updated: bool,
14
    /// Whether or not we need to reload the preview
15
    pub run_preview: bool,
16
}
17
18
impl BitOrAssign for SkimRender {
19
5.68k
    fn bitor_assign(&mut self, rhs: Self) {
20
5.68k
        self.items_updated |= rhs.items_updated;
21
5.68k
        self.run_preview |= rhs.run_preview;
22
5.68k
    }
23
}
24
25
/// Trait for Skim TUI widgets
26
pub trait SkimWidget: Sized {
27
    /// Create a widget from options and theme
28
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self;
29
30
    /// Render the widget to the buffer
31
    fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender;
32
33
    /// Create a widget with default options and theme
34
    ///
35
    /// This is made to be reused from a blanket `std::default::Default` trait impl
36
    #[must_use]
37
140
    fn _default() -> Self {
38
140
        Self::from_options(&SkimOptions::default(), Arc::new(ColorTheme::default()))
39
140
    }
40
}
\ No newline at end of file +

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/widget.rs
Line
Count
Source
1
use ratatui::buffer::Buffer;
2
use ratatui::layout::Rect;
3
use std::ops::BitOrAssign;
4
use std::sync::Arc;
5
6
use crate::options::SkimOptions;
7
use crate::theme::ColorTheme;
8
9
/// Result of rendering a `SkimWidget`
10
#[derive(Debug, Clone, Copy, Default)]
11
pub struct SkimRender {
12
    /// Whether the items in the list have been updated
13
    pub items_updated: bool,
14
    /// Whether or not we need to reload the preview
15
    pub run_preview: bool,
16
}
17
18
impl BitOrAssign for SkimRender {
19
5.67k
    fn bitor_assign(&mut self, rhs: Self) {
20
5.67k
        self.items_updated |= rhs.items_updated;
21
5.67k
        self.run_preview |= rhs.run_preview;
22
5.67k
    }
23
}
24
25
/// Trait for Skim TUI widgets
26
pub trait SkimWidget: Sized {
27
    /// Create a widget from options and theme
28
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self;
29
30
    /// Render the widget to the buffer
31
    fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender;
32
33
    /// Create a widget with default options and theme
34
    ///
35
    /// This is made to be reused from a blanket `std::default::Default` trait impl
36
    #[must_use]
37
140
    fn _default() -> Self {
38
140
        Self::from_options(&SkimOptions::default(), Arc::new(ColorTheme::default()))
39
140
    }
40
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/util.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/util.rs.html index 61d17f6d..b61fbfa9 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/util.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/util.rs.html @@ -1,4 +1,4 @@ -

Coverage Report

Created: 2026-09-04 09:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/util.rs
Line
Count
Source
1
use crate::field::{FieldRange, get_string_by_field};
2
use crate::helper::item::strip_ansi;
3
use crate::item::MatchedItem;
4
use regex::Regex;
5
use std::fmt::Write as _;
6
use std::fs::File;
7
use std::io::{BufRead, BufReader};
8
use std::prelude::v1::*;
9
10
#[cfg(feature = "cli")]
11
/// Unescape a delimiter string to handle escape sequences like \x00, \t, \n, etc.
12
///
13
/// Supported escape sequences:
14
/// - `\x00` - `\xff`: hexadecimal byte values
15
/// - `\t`: tab
16
/// - `\n`: newline
17
/// - `\r`: carriage return
18
/// - `\\`: backslash
19
///
20
/// # Examples
21
///
22
/// ```ignore
23
/// use skim::util::unescape_delimiter;
24
///
25
/// assert_eq!(unescape_delimiter(r"\x00"), "\0");
26
/// assert_eq!(unescape_delimiter(r"\t"), "\t");
27
/// assert_eq!(unescape_delimiter(r"\n"), "\n");
28
/// assert_eq!(unescape_delimiter(r"\\"), "\\");
29
/// ```
30
479
pub fn unescape_delimiter(s: &str) -> String {
31
479
    let mut result = String::new();
32
479
    let mut chars = s.chars();
33
34
3.09k
    while let Some(
c2.61k
) = chars.next() {
  Branch (34:15): [True: 2.52k, False: 456]
+

Coverage Report

Created: 2026-09-04 10:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/util.rs
Line
Count
Source
1
use crate::field::{FieldRange, get_string_by_field};
2
use crate::helper::item::strip_ansi;
3
use crate::item::MatchedItem;
4
use regex::Regex;
5
use std::fmt::Write as _;
6
use std::fs::File;
7
use std::io::{BufRead, BufReader};
8
use std::prelude::v1::*;
9
10
#[cfg(feature = "cli")]
11
/// Unescape a delimiter string to handle escape sequences like \x00, \t, \n, etc.
12
///
13
/// Supported escape sequences:
14
/// - `\x00` - `\xff`: hexadecimal byte values
15
/// - `\t`: tab
16
/// - `\n`: newline
17
/// - `\r`: carriage return
18
/// - `\\`: backslash
19
///
20
/// # Examples
21
///
22
/// ```ignore
23
/// use skim::util::unescape_delimiter;
24
///
25
/// assert_eq!(unescape_delimiter(r"\x00"), "\0");
26
/// assert_eq!(unescape_delimiter(r"\t"), "\t");
27
/// assert_eq!(unescape_delimiter(r"\n"), "\n");
28
/// assert_eq!(unescape_delimiter(r"\\"), "\\");
29
/// ```
30
479
pub fn unescape_delimiter(s: &str) -> String {
31
479
    let mut result = String::new();
32
479
    let mut chars = s.chars();
33
34
3.09k
    while let Some(
c2.61k
) = chars.next() {
  Branch (34:15): [True: 2.52k, False: 456]
 
  Branch (34:15): [True: 89, False: 23]
 
35
2.61k
        if c == '\\' {
  Branch (35:12): [True: 831, False: 1.69k]
 
  Branch (35:12): [True: 35, False: 54]
@@ -6,15 +6,15 @@
 
  Branch (40:24): [True: 6, False: 1]
 
41
8
                        if let Ok(
byte7
) = u8::from_str_radix(&hex, 16) {
  Branch (41:32): [True: 2, False: 0]
 
  Branch (41:32): [True: 5, False: 1]
-
42
7
                            // For null byte and other non-UTF8 safe bytes, we need to handle carefully
43
7
                            // Regex works with strings, so we push the byte as a char
44
7
                            result.push(byte as char);
45
7
                        } else {
46
1
                            // Invalid hex, keep as literal
47
1
                            result.push('\\');
48
1
                            result.push('x');
49
1
                            result.push_str(&hex);
50
1
                        }
51
1
                    } else {
52
1
                        // Not enough hex digits
53
1
                        result.push('\\');
54
1
                        result.push('x');
55
1
                        result.push_str(&hex);
56
1
                    }
57
                }
58
427
                Some('t') => result.push('\t'),
59
427
                Some('n') => result.push('\n'),
60
1
                Some('r') => result.push('\r'),
61
1
                Some('\\') | None => result.push('\\'),
62
1
                Some(other) => {
63
1
                    // Unknown escape, keep both backslash and character
64
1
                    result.push('\\');
65
1
                    result.push(other);
66
1
                }
67
            }
68
1.74k
        } else {
69
1.74k
            result.push(c);
70
1.74k
        }
71
    }
72
73
479
    result
74
479
}
75
76
5
pub fn read_file_lines(filename: &str) -> std::result::Result<Vec<String>, std::io::Error> {
77
5
    let 
file4
= File::open(filename)
?1
;
78
4
    BufReader::new(file).lines().collect()
79
5
}
80
81
/// Replace the fields in `pattern` with the items, expanding {...} patterns
82
///
83
/// Replaces:
84
/// - `{}` -> currently selected item
85
/// - `{1..}` etc -> fields of currently selected item, whose index is `selected`
86
/// - `{+}` -> all selected items (multi-select)
87
/// - `{q}` -> current query
88
/// - `{cq}` -> current command query
89
#[allow(clippy::too_many_arguments)]
90
#[allow(clippy::too_many_lines)]
91
185
pub fn printf<'a>(
92
185
    pattern: &str,
93
185
    delimiter: &Regex,
94
185
    replstr: &str,
95
185
    selected: &(impl Iterator<Item = &'a MatchedItem> + std::clone::Clone),
96
185
    current: &Option<MatchedItem>,
97
185
    query: &str,
98
185
    command_query: &str,
99
185
    mut quote_args: bool,
100
185
) -> String {
101
    // Windows uses different shell quoting conventions (double quotes, caret escaping)
102
    // that are incompatible with the Unix-style single-quote escaping implemented here.
103
185
    if cfg!(windows) {
104
0
        quote_args = false;
105
185
    }
106
609
    let 
escape_arg185
= |s: &str, quote: bool| {
107
609
        let mut res = s.replace('\0', "\\0").clone();
108
609
        if quote && 
quote_args594
{
  Branch (108:12): [True: 472, False: 0]
-  Branch (108:21): [True: 438, False: 34]
+
42
7
                            // For null byte and other non-UTF8 safe bytes, we need to handle carefully
43
7
                            // Regex works with strings, so we push the byte as a char
44
7
                            result.push(byte as char);
45
7
                        } else {
46
1
                            // Invalid hex, keep as literal
47
1
                            result.push('\\');
48
1
                            result.push('x');
49
1
                            result.push_str(&hex);
50
1
                        }
51
1
                    } else {
52
1
                        // Not enough hex digits
53
1
                        result.push('\\');
54
1
                        result.push('x');
55
1
                        result.push_str(&hex);
56
1
                    }
57
                }
58
427
                Some('t') => result.push('\t'),
59
427
                Some('n') => result.push('\n'),
60
1
                Some('r') => result.push('\r'),
61
1
                Some('\\') | None => result.push('\\'),
62
1
                Some(other) => {
63
1
                    // Unknown escape, keep both backslash and character
64
1
                    result.push('\\');
65
1
                    result.push(other);
66
1
                }
67
            }
68
1.74k
        } else {
69
1.74k
            result.push(c);
70
1.74k
        }
71
    }
72
73
479
    result
74
479
}
75
76
5
pub fn read_file_lines(filename: &str) -> std::result::Result<Vec<String>, std::io::Error> {
77
5
    let 
file4
= File::open(filename)
?1
;
78
4
    BufReader::new(file).lines().collect()
79
5
}
80
81
/// Replace the fields in `pattern` with the items, expanding {...} patterns
82
///
83
/// Replaces:
84
/// - `{}` -> currently selected item
85
/// - `{1..}` etc -> fields of currently selected item, whose index is `selected`
86
/// - `{+}` -> all selected items (multi-select)
87
/// - `{q}` -> current query
88
/// - `{cq}` -> current command query
89
#[allow(clippy::too_many_arguments)]
90
#[allow(clippy::too_many_lines)]
91
184
pub fn printf<'a>(
92
184
    pattern: &str,
93
184
    delimiter: &Regex,
94
184
    replstr: &str,
95
184
    selected: &(impl Iterator<Item = &'a MatchedItem> + std::clone::Clone),
96
184
    current: &Option<MatchedItem>,
97
184
    query: &str,
98
184
    command_query: &str,
99
184
    mut quote_args: bool,
100
184
) -> String {
101
    // Windows uses different shell quoting conventions (double quotes, caret escaping)
102
    // that are incompatible with the Unix-style single-quote escaping implemented here.
103
184
    if cfg!(windows) {
104
0
        quote_args = false;
105
184
    }
106
606
    let 
escape_arg184
= |s: &str, quote: bool| {
107
606
        let mut res = s.replace('\0', "\\0").clone();
108
606
        if quote && 
quote_args591
{
  Branch (108:12): [True: 469, False: 0]
+  Branch (108:21): [True: 435, False: 34]
 
  Branch (108:12): [True: 20, False: 2]
   Branch (108:21): [True: 0, False: 20]
 
  Branch (108:12): [True: 51, False: 13]
   Branch (108:21): [True: 24, False: 27]
   Branch (108:12): [True: 51, False: 0]
   Branch (108:21): [True: 45, False: 6]
-
109
507
            res = format!("'{}'", res.replace('\'', "'\\''"));
110
507
        
}102
111
609
        res
112
609
    };
113
114
185
    let item_text = current.as_ref().map(|s| 
strip_ansi108
(
&s.output()108
).0).unwrap_or_default();
115
185
    let escaped_item = escape_arg(&item_text, true);
116
185
    let escaped_query = escape_arg(query, true);
117
185
    let escaped_cmd_query = escape_arg(command_query, true);
118
119
    // Split on replstr first
120
185
    let replstr_parts = pattern.split(replstr);
121
185
    let mut replaced_parts = Vec::new();
122
123
    // Deal with every part to expand inside
124
125
219
    for part in 
replstr_parts185
{
126
219
        let mut sub = part.split('{');
127
219
        let mut replaced = sub.next().unwrap_or_default().to_string();
128
219
        for 
s61
in sub {
129
61
            let mut inside = true;
130
61
            let mut content = String::new();
131
206
            for c in 
s61
.
chars61
() {
132
206
                if inside {
  Branch (132:20): [True: 73, False: 15]
+
109
504
            res = format!("'{}'", res.replace('\'', "'\\''"));
110
504
        
}102
111
606
        res
112
606
    };
113
114
184
    let item_text = current.as_ref().map(|s| 
strip_ansi108
(
&s.output()108
).0).unwrap_or_default();
115
184
    let escaped_item = escape_arg(&item_text, true);
116
184
    let escaped_query = escape_arg(query, true);
117
184
    let escaped_cmd_query = escape_arg(command_query, true);
118
119
    // Split on replstr first
120
184
    let replstr_parts = pattern.split(replstr);
121
184
    let mut replaced_parts = Vec::new();
122
123
    // Deal with every part to expand inside
124
125
218
    for part in 
replstr_parts184
{
126
218
        let mut sub = part.split('{');
127
218
        let mut replaced = sub.next().unwrap_or_default().to_string();
128
218
        for 
s61
in sub {
129
61
            let mut inside = true;
130
61
            let mut content = String::new();
131
206
            for c in 
s61
.
chars61
() {
132
206
                if inside {
  Branch (132:20): [True: 73, False: 15]
 
  Branch (132:20): [True: 25, False: 0]
 
  Branch (132:20): [True: 56, False: 31]
 
  Branch (132:20): [True: 6, False: 0]
@@ -82,4 +82,4 @@
 
  Branch (233:27): [True: 0, False: 0]
 
  Branch (233:27): [True: 0, False: 31]
 
  Branch (233:27): [True: 0, False: 0]
-
234
0
                    inside = true;
235
46
                } else {
236
46
                    replaced.push(c);
237
46
                }
238
            }
239
            // }
240
        }
241
219
        replaced_parts.push(replaced);
242
    }
243
244
    // Join back the replstr parts into the res
245
185
    replaced_parts
246
185
        .into_iter()
247
185
        .reduce(|a: String, b| 
a + &escaped_item34
+
&b34
)
248
185
        .unwrap_or_default()
249
185
}
250
251
#[cfg(test)]
252
#[path = "util_tests.rs"]
253
mod test;
\ No newline at end of file +
234
0
                    inside = true;
235
46
                } else {
236
46
                    replaced.push(c);
237
46
                }
238
            }
239
            // }
240
        }
241
218
        replaced_parts.push(replaced);
242
    }
243
244
    // Join back the replstr parts into the res
245
184
    replaced_parts
246
184
        .into_iter()
247
184
        .reduce(|a: String, b| 
a + &escaped_item34
+
&b34
)
248
184
        .unwrap_or_default()
249
184
}
250
251
#[cfg(test)]
252
#[path = "util_tests.rs"]
253
mod test;
\ No newline at end of file diff --git a/coverage/index.html b/coverage/index.html index e5d13be1..1ae9f769 100644 --- a/coverage/index.html +++ b/coverage/index.html @@ -1 +1 @@ -

Coverage Report

Created: 2026-09-04 09:43

Click here for information about interpreting this report.

FilenameFunction CoverageLine CoverageRegion CoverageBranch Coverage
bin/main.rs
 100.00% (8/8)
  90.71% (127/140)
  86.33% (240/278)
  77.78% (28/36)
binds.rs
 100.00% (21/21)
  98.77% (240/243)
  98.84% (597/604)
  97.50% (39/40)
engine/all.rs
 100.00% (5/5)
 100.00% (22/22)
 100.00% (22/22)
- (0/0)
engine/andor.rs
 100.00% (15/15)
 100.00% (91/91)
 100.00% (115/115)
 100.00% (4/4)
engine/exact.rs
 100.00% (6/6)
 100.00% (68/68)
 100.00% (113/113)
 100.00% (18/18)
engine/factory.rs
 100.00% (21/21)
 100.00% (156/156)
 100.00% (186/186)
 100.00% (20/20)
engine/fuzzy.rs
 100.00% (15/15)
 100.00% (114/114)
 100.00% (193/193)
 100.00% (10/10)
engine/normalized.rs
 100.00% (7/7)
 100.00% (37/37)
 100.00% (75/75)
 100.00% (2/2)
engine/regexp.rs
 100.00% (7/7)
 100.00% (49/49)
 100.00% (87/87)
 100.00% (4/4)
engine/split.rs
 100.00% (12/12)
 100.00% (75/75)
  98.57% (138/140)
 100.00% (6/6)
engine/util.rs
 100.00% (8/8)
 100.00% (66/66)
 100.00% (111/111)
 100.00% (16/16)
field.rs
 100.00% (14/14)
  99.02% (101/102)
  99.56% (226/227)
 100.00% (32/32)
fuzzy_matcher/arinae/algo.rs
 100.00% (6/6)
  88.43% (298/337)
  86.96% (567/652)
  72.07% (80/111)
fuzzy_matcher/arinae/atom.rs
  92.31% (12/13)
  93.33% (56/60)
  92.62% (113/122)
 100.00% (10/10)
fuzzy_matcher/arinae/banding.rs
 100.00% (4/4)
  97.56% (40/41)
  88.24% (75/85)
  62.50% (5/8)
fuzzy_matcher/arinae/helpers.rs
 100.00% (4/4)
 100.00% (47/47)
  98.67% (74/75)
 100.00% (4/4)
fuzzy_matcher/arinae/matrix.rs
 100.00% (7/7)
 100.00% (33/33)
 100.00% (49/49)
 100.00% (2/2)
fuzzy_matcher/arinae/mod.rs
 100.00% (20/20)
 100.00% (180/180)
  98.69% (378/383)
 100.00% (54/54)
fuzzy_matcher/arinae/prefilter.rs
 100.00% (5/5)
 100.00% (54/54)
 100.00% (88/88)
  94.44% (17/18)
fuzzy_matcher/clangd.rs
  96.15% (25/26)
  92.96% (264/284)
  91.24% (406/445)
  98.33% (59/60)
fuzzy_matcher/frizbee.rs
 100.00% (15/15)
 100.00% (51/51)
 100.00% (76/76)
- (0/0)
fuzzy_matcher/fzy.rs
 100.00% (37/37)
  99.30% (567/571)
  98.36% (959/975)
  93.16% (177/190)
fuzzy_matcher/mod.rs
 100.00% (3/3)
 100.00% (11/11)
 100.00% (26/26)
- (0/0)
fuzzy_matcher/skim.rs
 100.00% (38/38)
  99.06% (422/426)
  99.08% (644/650)
  98.44% (63/64)
fuzzy_matcher/util.rs
 100.00% (11/11)
 100.00% (89/89)
 100.00% (159/159)
 100.00% (26/26)
helper/item.rs
 100.00% (31/31)
  97.79% (398/407)
  96.81% (668/690)
  88.81% (119/134)
helper/item_reader.rs
  92.68% (38/41)
  90.76% (275/303)
  89.85% (407/453)
  70.83% (34/48)
helper/selector.rs
 100.00% (6/6)
 100.00% (42/42)
  96.43% (54/56)
  88.89% (16/18)
item.rs
  97.22% (35/36)
  97.17% (309/318)
  97.28% (465/478)
  92.50% (37/40)
lib.rs
 100.00% (8/8)
 100.00% (80/80)
 100.00% (176/176)
- (0/0)
manpage.rs
 100.00% (10/10)
  97.46% (192/197)
  94.60% (263/278)
 100.00% (8/8)
matcher.rs
 100.00% (23/23)
  98.71% (230/233)
  97.66% (334/342)
  91.67% (22/24)
options.rs
  86.96% (20/23)
  98.26% (339/345)
  97.70% (383/392)
  92.11% (35/38)
output.rs
 100.00% (2/2)
  96.43% (54/56)
  88.37% (76/86)
 100.00% (22/22)
popup/mod.rs
  63.64% (7/11)
  76.19% (144/189)
  79.34% (242/305)
  70.00% (42/60)
popup/tmux.rs
  85.71% (6/7)
  96.55% (56/58)
  96.81% (91/94)
  75.00% (3/4)
popup/zellij.rs
  81.82% (9/11)
  84.78% (78/92)
  90.00% (144/160)
  83.33% (5/6)
reader.rs
  95.00% (19/20)
  97.41% (113/116)
  96.67% (174/180)
 100.00% (8/8)
shell.rs
 100.00% (2/2)
  96.15% (25/26)
  95.00% (38/40)
 100.00% (4/4)
skim.rs
  82.50% (33/40)
  83.97% (351/418)
  79.19% (506/639)
  73.64% (81/110)
skim_item.rs
 100.00% (9/9)
 100.00% (27/27)
 100.00% (37/37)
- (0/0)
spinlock.rs
 100.00% (6/6)
 100.00% (28/28)
 100.00% (27/27)
 100.00% (2/2)
theme.rs
 100.00% (16/16)
  99.35% (304/306)
  99.55% (663/666)
  97.22% (35/36)
thread_pool.rs
 100.00% (18/18)
  97.98% (194/198)
  98.28% (285/290)
  88.46% (23/26)
tui/actions.rs
  75.00% (24/32)
  91.11% (82/90)
  88.65% (125/141)
 100.00% (2/2)
tui/app.rs
  97.96% (48/49)
  95.93% (1014/1057)
  93.23% (1570/1684)
  86.52% (199/230)
tui/backend.rs
  81.25% (26/32)
  74.02% (188/254)
  71.00% (284/400)
  56.82% (25/44)
tui/header.rs
 100.00% (11/11)
  98.46% (128/130)
  99.57% (232/233)
 100.00% (28/28)
tui/input.rs
  96.88% (31/32)
  96.58% (339/351)
  96.92% (630/650)
  81.25% (91/112)
tui/item_list.rs
  97.22% (35/36)
  95.23% (399/419)
  94.85% (626/660)
  88.10% (111/126)
tui/item_renderer.rs
 100.00% (29/29)
  98.30% (405/412)
  98.59% (767/778)
  89.68% (113/126)
tui/layout.rs
 100.00% (6/6)
  97.56% (120/123)
  96.77% (180/186)
  94.74% (36/38)
tui/mod.rs
  95.65% (22/23)
  93.94% (124/132)
  94.76% (199/210)
  75.00% (15/20)
tui/options.rs
 100.00% (2/2)
 100.00% (41/41)
 100.00% (45/45)
 100.00% (20/20)
tui/preview.rs
  91.67% (33/36)
  89.42% (431/482)
  90.15% (732/812)
  68.33% (82/120)
tui/statusline.rs
 100.00% (5/5)
 100.00% (33/33)
 100.00% (57/57)
 100.00% (4/4)
tui/util.rs
  78.26% (18/23)
  68.89% (186/270)
  65.19% (324/497)
  57.14% (48/84)
tui/widget.rs
 100.00% (2/2)
 100.00% (7/7)
 100.00% (10/10)
- (0/0)
util.rs
 100.00% (15/15)
  95.48% (169/177)
  96.75% (268/277)
  91.30% (42/46)
Totals
  94.90% (931/981)
  94.42% (10163/10764)
  93.51% (16799/17965)
  85.58% (1988/2323)
Generated by llvm-cov -- llvm version 23.1.1-rust-1.100.0-nightly
\ No newline at end of file +

Coverage Report

Created: 2026-09-04 10:09

Click here for information about interpreting this report.

FilenameFunction CoverageLine CoverageRegion CoverageBranch Coverage
bin/main.rs
 100.00% (8/8)
  90.71% (127/140)
  86.33% (240/278)
  77.78% (28/36)
binds.rs
 100.00% (21/21)
  98.77% (240/243)
  98.84% (597/604)
  97.50% (39/40)
engine/all.rs
 100.00% (5/5)
 100.00% (22/22)
 100.00% (22/22)
- (0/0)
engine/andor.rs
 100.00% (15/15)
 100.00% (91/91)
 100.00% (115/115)
 100.00% (4/4)
engine/exact.rs
 100.00% (6/6)
 100.00% (68/68)
 100.00% (113/113)
 100.00% (18/18)
engine/factory.rs
 100.00% (21/21)
 100.00% (156/156)
 100.00% (186/186)
 100.00% (20/20)
engine/fuzzy.rs
 100.00% (15/15)
 100.00% (114/114)
 100.00% (193/193)
 100.00% (10/10)
engine/normalized.rs
 100.00% (7/7)
 100.00% (37/37)
 100.00% (75/75)
 100.00% (2/2)
engine/regexp.rs
 100.00% (7/7)
 100.00% (49/49)
 100.00% (87/87)
 100.00% (4/4)
engine/split.rs
 100.00% (12/12)
 100.00% (75/75)
  98.57% (138/140)
 100.00% (6/6)
engine/util.rs
 100.00% (8/8)
 100.00% (66/66)
 100.00% (111/111)
 100.00% (16/16)
field.rs
 100.00% (14/14)
  99.02% (101/102)
  99.56% (226/227)
 100.00% (32/32)
fuzzy_matcher/arinae/algo.rs
 100.00% (6/6)
  88.43% (298/337)
  86.96% (567/652)
  72.07% (80/111)
fuzzy_matcher/arinae/atom.rs
  92.31% (12/13)
  93.33% (56/60)
  92.62% (113/122)
 100.00% (10/10)
fuzzy_matcher/arinae/banding.rs
 100.00% (4/4)
  97.56% (40/41)
  88.24% (75/85)
  62.50% (5/8)
fuzzy_matcher/arinae/helpers.rs
 100.00% (4/4)
 100.00% (47/47)
  98.67% (74/75)
 100.00% (4/4)
fuzzy_matcher/arinae/matrix.rs
 100.00% (7/7)
 100.00% (33/33)
 100.00% (49/49)
 100.00% (2/2)
fuzzy_matcher/arinae/mod.rs
 100.00% (20/20)
 100.00% (180/180)
  98.69% (378/383)
 100.00% (54/54)
fuzzy_matcher/arinae/prefilter.rs
 100.00% (5/5)
 100.00% (54/54)
 100.00% (88/88)
  94.44% (17/18)
fuzzy_matcher/clangd.rs
  96.15% (25/26)
  92.96% (264/284)
  91.24% (406/445)
  98.33% (59/60)
fuzzy_matcher/frizbee.rs
 100.00% (15/15)
 100.00% (51/51)
 100.00% (76/76)
- (0/0)
fuzzy_matcher/fzy.rs
 100.00% (37/37)
  99.30% (567/571)
  98.36% (959/975)
  93.16% (177/190)
fuzzy_matcher/mod.rs
 100.00% (3/3)
 100.00% (11/11)
 100.00% (26/26)
- (0/0)
fuzzy_matcher/skim.rs
 100.00% (38/38)
  99.06% (422/426)
  99.08% (644/650)
  98.44% (63/64)
fuzzy_matcher/util.rs
 100.00% (11/11)
 100.00% (89/89)
 100.00% (159/159)
 100.00% (26/26)
helper/item.rs
 100.00% (31/31)
  97.79% (398/407)
  96.81% (668/690)
  88.81% (119/134)
helper/item_reader.rs
  92.68% (38/41)
  90.76% (275/303)
  89.85% (407/453)
  70.83% (34/48)
helper/selector.rs
 100.00% (6/6)
 100.00% (42/42)
  96.43% (54/56)
  88.89% (16/18)
item.rs
  97.22% (35/36)
  97.17% (309/318)
  97.28% (465/478)
  92.50% (37/40)
lib.rs
 100.00% (8/8)
 100.00% (80/80)
 100.00% (176/176)
- (0/0)
manpage.rs
 100.00% (10/10)
  97.46% (192/197)
  94.60% (263/278)
 100.00% (8/8)
matcher.rs
 100.00% (23/23)
  98.71% (230/233)
  97.66% (334/342)
  91.67% (22/24)
options.rs
  86.96% (20/23)
  98.26% (339/345)
  97.70% (383/392)
  92.11% (35/38)
output.rs
 100.00% (2/2)
  96.43% (54/56)
  88.37% (76/86)
 100.00% (22/22)
popup/mod.rs
  63.64% (7/11)
  76.19% (144/189)
  79.34% (242/305)
  70.00% (42/60)
popup/tmux.rs
  85.71% (6/7)
  96.55% (56/58)
  96.81% (91/94)
  75.00% (3/4)
popup/zellij.rs
  81.82% (9/11)
  84.78% (78/92)
  90.00% (144/160)
  83.33% (5/6)
reader.rs
  95.00% (19/20)
  99.14% (115/116)
  97.22% (175/180)
 100.00% (8/8)
shell.rs
 100.00% (2/2)
  96.15% (25/26)
  95.00% (38/40)
 100.00% (4/4)
skim.rs
  82.50% (33/40)
  83.73% (350/418)
  79.03% (505/639)
  71.82% (79/110)
skim_item.rs
 100.00% (9/9)
 100.00% (27/27)
 100.00% (37/37)
- (0/0)
spinlock.rs
 100.00% (6/6)
 100.00% (28/28)
 100.00% (27/27)
 100.00% (2/2)
theme.rs
 100.00% (16/16)
  99.35% (304/306)
  99.55% (663/666)
  97.22% (35/36)
thread_pool.rs
 100.00% (18/18)
  97.98% (194/198)
  98.28% (285/290)
  88.46% (23/26)
tui/actions.rs
  75.00% (24/32)
  91.11% (82/90)
  88.65% (125/141)
 100.00% (2/2)
tui/app.rs
  97.96% (48/49)
  95.84% (1013/1057)
  93.11% (1568/1684)
  85.65% (197/230)
tui/backend.rs
  81.25% (26/32)
  74.02% (188/254)
  71.00% (284/400)
  56.82% (25/44)
tui/header.rs
 100.00% (11/11)
  98.46% (128/130)
  99.57% (232/233)
 100.00% (28/28)
tui/input.rs
  96.88% (31/32)
  96.58% (339/351)
  96.92% (630/650)
  80.36% (90/112)
tui/item_list.rs
  97.22% (35/36)
  95.23% (399/419)
  94.70% (625/660)
  87.30% (110/126)
tui/item_renderer.rs
 100.00% (29/29)
  98.30% (405/412)
  98.59% (767/778)
  89.68% (113/126)
tui/layout.rs
 100.00% (6/6)
  97.56% (120/123)
  96.77% (180/186)
  94.74% (36/38)
tui/mod.rs
  95.65% (22/23)
  93.94% (124/132)
  94.76% (199/210)
  75.00% (15/20)
tui/options.rs
 100.00% (2/2)
 100.00% (41/41)
 100.00% (45/45)
 100.00% (20/20)
tui/preview.rs
  91.67% (33/36)
  89.42% (431/482)
  90.15% (732/812)
  68.33% (82/120)
tui/statusline.rs
 100.00% (5/5)
 100.00% (33/33)
 100.00% (57/57)
 100.00% (4/4)
tui/util.rs
  78.26% (18/23)
  68.89% (186/270)
  65.19% (324/497)
  57.14% (48/84)
tui/widget.rs
 100.00% (2/2)
 100.00% (7/7)
 100.00% (10/10)
- (0/0)
util.rs
 100.00% (15/15)
  95.48% (169/177)
  96.75% (268/277)
  91.30% (42/46)
Totals
  94.90% (931/981)
  94.42% (10163/10764)
  93.49% (16796/17965)
  85.32% (1982/2323)
Generated by llvm-cov -- llvm version 23.1.1-rust-1.100.0-nightly
\ No newline at end of file