From 1bc8024b294b5f83ee0a934e53c553d309e73620 Mon Sep 17 00:00:00 2001 From: LoricAndre Date: Tue, 1 Sep 2026 09:15:23 +0000 Subject: [PATCH] deploy: a53ada910af6c9d10504fb7779302d1ea414921d --- .../work/skim/skim/src/bin/main.rs.html | 66 +-- .../runner/work/skim/skim/src/binds.rs.html | 26 +- .../work/skim/skim/src/engine/all.rs.html | 2 +- .../work/skim/skim/src/engine/andor.rs.html | 4 +- .../work/skim/skim/src/engine/exact.rs.html | 10 +- .../work/skim/skim/src/engine/factory.rs.html | 22 +- .../work/skim/skim/src/engine/fuzzy.rs.html | 4 +- .../skim/skim/src/engine/normalized.rs.html | 2 +- .../work/skim/skim/src/engine/regexp.rs.html | 2 +- .../work/skim/skim/src/engine/split.rs.html | 6 +- .../work/skim/skim/src/engine/util.rs.html | 6 +- .../runner/work/skim/skim/src/field.rs.html | 30 +- .../src/fuzzy_matcher/arinae/algo.rs.html | 6 +- .../src/fuzzy_matcher/arinae/atom.rs.html | 2 +- .../src/fuzzy_matcher/arinae/banding.rs.html | 2 +- .../src/fuzzy_matcher/arinae/helpers.rs.html | 2 +- .../src/fuzzy_matcher/arinae/matrix.rs.html | 4 +- .../skim/src/fuzzy_matcher/arinae/mod.rs.html | 6 +- .../fuzzy_matcher/arinae/prefilter.rs.html | 2 +- .../skim/src/fuzzy_matcher/clangd.rs.html | 2 +- .../skim/src/fuzzy_matcher/frizbee.rs.html | 2 +- .../skim/skim/src/fuzzy_matcher/fzy.rs.html | 4 +- .../skim/skim/src/fuzzy_matcher/mod.rs.html | 2 +- .../skim/skim/src/fuzzy_matcher/skim.rs.html | 2 +- .../skim/skim/src/fuzzy_matcher/util.rs.html | 2 +- .../work/skim/skim/src/helper/item.rs.html | 34 +- .../skim/skim/src/helper/item_reader.rs.html | 240 ++++----- .../skim/skim/src/helper/selector.rs.html | 8 +- .../runner/work/skim/skim/src/item.rs.html | 26 +- .../runner/work/skim/skim/src/lib.rs.html | 2 +- .../runner/work/skim/skim/src/manpage.rs.html | 2 +- .../runner/work/skim/skim/src/matcher.rs.html | 38 +- .../runner/work/skim/skim/src/options.rs.html | 30 +- .../runner/work/skim/skim/src/output.rs.html | 36 +- .../work/skim/skim/src/popup/mod.rs.html | 2 +- .../work/skim/skim/src/popup/tmux.rs.html | 2 +- .../work/skim/skim/src/popup/zellij.rs.html | 2 +- .../runner/work/skim/skim/src/reader.rs.html | 26 +- .../runner/work/skim/skim/src/shell.rs.html | 8 +- .../runner/work/skim/skim/src/skim.rs.html | 366 +++++++------- .../work/skim/skim/src/skim_item.rs.html | 2 +- .../work/skim/skim/src/spinlock.rs.html | 12 +- .../runner/work/skim/skim/src/theme.rs.html | 6 +- .../work/skim/skim/src/thread_pool.rs.html | 44 +- .../work/skim/skim/src/tui/actions.rs.html | 2 +- .../runner/work/skim/skim/src/tui/app.rs.html | 478 +++++++++--------- .../work/skim/skim/src/tui/backend.rs.html | 90 ++-- .../work/skim/skim/src/tui/header.rs.html | 34 +- .../work/skim/skim/src/tui/input.rs.html | 48 +- .../work/skim/skim/src/tui/item_list.rs.html | 106 ++-- .../skim/skim/src/tui/item_renderer.rs.html | 178 +++---- .../work/skim/skim/src/tui/layout.rs.html | 38 +- .../runner/work/skim/skim/src/tui/mod.rs.html | 14 +- .../work/skim/skim/src/tui/options.rs.html | 22 +- .../work/skim/skim/src/tui/preview.rs.html | 144 +++--- .../work/skim/skim/src/tui/statusline.rs.html | 6 +- .../work/skim/skim/src/tui/util.rs.html | 8 +- .../work/skim/skim/src/tui/widget.rs.html | 2 +- .../runner/work/skim/skim/src/util.rs.html | 106 ++-- coverage/index.html | 2 +- 60 files changed, 1191 insertions(+), 1191 deletions(-) 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 9c1fc80a..8caddabf 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,37 +1,37 @@ -

Coverage Report

Created: 2026-08-22 15:34

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
69
fn init_logger(opts: &SkimOptions) {
30
69
    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: 68]
-
31
1
        env_logger::Target::Pipe(Box::new(File::create(log_file).expect("Failed to create log file")))
32
    } else {
33
68
        env_logger::Target::Stdout
34
    };
35
36
69
    let env_var = "SKIM_LOG";
37
38
69
    let format = |buf: &mut env_logger::fmt::Formatter, record: &log::Record<'_>| 
{47
39
47
        writeln!(
40
47
            buf,
41
            "[{} {} {} ({}:{})] [{}/{:?}] {}",
42
47
            buf.timestamp_nanos(),
43
47
            record.level().as_str(),
44
47
            record.module_path().unwrap_or("sk"),
45
47
            record.file().unwrap_or_default(),
46
47
            record.line().unwrap_or_default(),
47
47
            std::thread::current().name().unwrap_or("?"),
48
47
            std::thread::current().id(),
49
47
            record.args()
50
        )
51
47
    };
52
53
69
    if let Some(
level0
) = opts.log_level {
  Branch (53:12): [Folded - Ignored]
-
  Branch (53:12): [True: 0, False: 69]
-
54
0
        env_logger::builder()
55
0
            .filter_level(level)
56
0
            .parse_env(env_var)
57
0
            .target(target)
58
0
            .format(format)
59
0
            .init();
60
69
    } else {
61
69
        env_logger::builder()
62
69
            .parse_env(env_var)
63
69
            .target(target)
64
69
            .format(format)
65
69
            .init();
66
69
    }
67
69
}
68
69
//------------------------------------------------------------------------------
70
74
fn main() -> Result<()> {
71
74
    let mut opts = SkimOptions::from_env().unwrap_or_else(|e| 
{5
72
5
        e.exit();
73
    });
74
74
    init_logger(&opts);
75
76
    // Build the options after setting the log target
77
74
    opts = opts.build();
78
74
    trace!("Command line: {:?}", 
std::env::args1
());
79
80
    // Shell completion scripts
81
74
    if let Some(
shell7
) = opts.shell {
  Branch (81:12): [Folded - Ignored]
-
  Branch (81:12): [True: 7, False: 67]
-
82
        // Generate completion script directly to stdout
83
7
        skim::shell::generate_completions(&shell, &mut std::io::stdout());
84
7
        if opts.shell_bindings {
  Branch (84:12): [Folded - Ignored]
-
  Branch (84:12): [True: 1, False: 6]
-
85
1
            skim::shell::generate_key_bindings(&shell, &mut std::io::stdout())
?0
;
86
6
        }
87
7
        return Ok(());
88
67
    }
89
    // Man page
90
67
    if opts.man {
  Branch (90:8): [Folded - Ignored]
-
  Branch (90:8): [True: 1, False: 66]
-
91
1
        crate::manpage::generate(&mut std::io::stdout())
?0
;
92
1
        return Ok(());
93
66
    }
94
95
    #[cfg(feature = "listen")]
96
66
    if let Some(
remote32
) = opts.remote {
  Branch (96:12): [Folded - Ignored]
-
  Branch (96:12): [True: 32, False: 34]
-
97
32
        let ns_name = remote
98
32
            .to_ns_name::<interprocess::local_socket::GenericNamespaced>()
99
32
            .unwrap();
100
32
        let stream = interprocess::local_socket::Stream::connect(ns_name)
?0
;
101
32
        let mut action_chain = String::new();
102
        loop {
103
71
            action_chain.clear();
104
71
            let len = std::io::stdin().read_line(&mut action_chain)
?0
;
105
71
            log::debug!("Got line {} from stdin", 
action_chain.trim()0
);
106
71
            if len == 0 {
  Branch (106:16): [Folded - Ignored]
-
  Branch (106:16): [True: 32, False: 39]
-
107
32
                break;
108
39
            }
109
39
            let actions = parse_action_chain(action_chain.trim())
?0
;
110
41
            for act in 
actions39
{
111
41
                stream
112
41
                    .as_write()
113
41
                    .write_all(format!("{}\n", ron::ser::to_string(&act)
?0
).as_bytes())
?0
;
114
41
                log::debug!("Sent action {act:?} to listener");
115
            }
116
        }
117
32
        return Ok(());
118
34
    }
119
120
34
    match sk_main(opts) {
121
34
        Ok(exit_code) => std::process::exit(exit_code),
122
0
        Err(err) => match err.downcast_ref::<clap::error::Error>() {
123
0
            Some(e) => e.exit(),
124
0
            None => Err(eyre!(err)),
125
        },
126
    }
127
40
}
128
129
/// Returns `None` if the popup should not open, otherwise run the popup and return the result
130
#[cfg(unix)]
131
#[allow(clippy::option_option)]
132
34
fn check_and_run_popup(opts: &SkimOptions) -> Option<Option<SkimOutput>> {
133
34
    if opts.popup.is_some() && 
popup::check_env5
() {
  Branch (133:8): [Folded - Ignored]
-  Branch (133:32): [Folded - Ignored]
-
  Branch (133:8): [True: 5, False: 29]
+

Coverage Report

Created: 2026-09-01 09:15

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
69
fn init_logger(opts: &SkimOptions) {
30
69
    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): [True: 1, False: 68]
+
  Branch (30:25): [Folded - Ignored]
+
31
1
        env_logger::Target::Pipe(Box::new(File::create(log_file).expect("Failed to create log file")))
32
    } else {
33
68
        env_logger::Target::Stdout
34
    };
35
36
69
    let env_var = "SKIM_LOG";
37
38
69
    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
69
    if let Some(
level0
) = opts.log_level {
  Branch (53:12): [True: 0, False: 69]
+
  Branch (53:12): [Folded - Ignored]
+
54
0
        env_logger::builder()
55
0
            .filter_level(level)
56
0
            .parse_env(env_var)
57
0
            .target(target)
58
0
            .format(format)
59
0
            .init();
60
69
    } else {
61
69
        env_logger::builder()
62
69
            .parse_env(env_var)
63
69
            .target(target)
64
69
            .format(format)
65
69
            .init();
66
69
    }
67
69
}
68
69
//------------------------------------------------------------------------------
70
74
fn main() -> Result<()> {
71
74
    let mut opts = SkimOptions::from_env().unwrap_or_else(|e| 
{5
72
5
        e.exit();
73
    });
74
74
    init_logger(&opts);
75
76
    // Build the options after setting the log target
77
74
    opts = opts.build();
78
74
    trace!("Command line: {:?}", 
std::env::args1
());
79
80
    // Shell completion scripts
81
74
    if let Some(
shell7
) = opts.shell {
  Branch (81:12): [True: 7, False: 67]
+
  Branch (81:12): [Folded - Ignored]
+
82
        // Generate completion script directly to stdout
83
7
        skim::shell::generate_completions(&shell, &mut std::io::stdout());
84
7
        if opts.shell_bindings {
  Branch (84:12): [True: 1, False: 6]
+
  Branch (84:12): [Folded - Ignored]
+
85
1
            skim::shell::generate_key_bindings(&shell, &mut std::io::stdout())
?0
;
86
6
        }
87
7
        return Ok(());
88
67
    }
89
    // Man page
90
67
    if opts.man {
  Branch (90:8): [True: 1, False: 66]
+
  Branch (90:8): [Folded - Ignored]
+
91
1
        crate::manpage::generate(&mut std::io::stdout())
?0
;
92
1
        return Ok(());
93
66
    }
94
95
    #[cfg(feature = "listen")]
96
66
    if let Some(
remote32
) = opts.remote {
  Branch (96:12): [True: 32, False: 34]
+
  Branch (96:12): [Folded - Ignored]
+
97
32
        let ns_name = remote
98
32
            .to_ns_name::<interprocess::local_socket::GenericNamespaced>()
99
32
            .unwrap();
100
32
        let stream = interprocess::local_socket::Stream::connect(ns_name)
?0
;
101
32
        let mut action_chain = String::new();
102
        loop {
103
71
            action_chain.clear();
104
71
            let len = std::io::stdin().read_line(&mut action_chain)
?0
;
105
71
            log::debug!("Got line {} from stdin", 
action_chain.trim()0
);
106
71
            if len == 0 {
  Branch (106:16): [True: 32, False: 39]
+
  Branch (106:16): [Folded - Ignored]
+
107
32
                break;
108
39
            }
109
39
            let actions = parse_action_chain(action_chain.trim())
?0
;
110
41
            for act in 
actions39
{
111
41
                stream
112
41
                    .as_write()
113
41
                    .write_all(format!("{}\n", ron::ser::to_string(&act)
?0
).as_bytes())
?0
;
114
41
                log::debug!("Sent action {act:?} to listener");
115
            }
116
        }
117
32
        return Ok(());
118
34
    }
119
120
34
    match sk_main(opts) {
121
34
        Ok(exit_code) => std::process::exit(exit_code),
122
0
        Err(err) => match err.downcast_ref::<clap::error::Error>() {
123
0
            Some(e) => e.exit(),
124
0
            None => Err(eyre!(err)),
125
        },
126
    }
127
40
}
128
129
/// Returns `None` if the popup should not open, otherwise run the popup and return the result
130
#[cfg(unix)]
131
#[allow(clippy::option_option)]
132
34
fn check_and_run_popup(opts: &SkimOptions) -> Option<Option<SkimOutput>> {
133
34
    if opts.popup.is_some() && 
popup::check_env5
() {
  Branch (133:8): [True: 5, False: 29]
   Branch (133:32): [True: 5, False: 0]
-
134
5
        Some(crate::popup::run_with(opts))
135
    } else {
136
29
        None
137
    }
138
34
}
139
#[cfg(not(unix))]
140
#[allow(clippy::option_option)]
141
fn check_and_run_popup(_opts: &SkimOptions) -> Option<Option<SkimOutput>> {
142
    None
143
}
144
145
34
fn sk_main(mut opts: SkimOptions) -> Result<i32> {
146
34
    let reader_opts = SkimItemReaderOption::from_options(&opts);
147
34
    let cmd_collector = Rc::new(RefCell::new(SkimItemReader::new(reader_opts)));
148
34
    opts.cmd_collector = cmd_collector.clone() as Rc<RefCell<dyn CommandCollector>>;
149
150
34
    let cmd_history = opts.cmd_history.clone();
151
34
    let cmd_history_size = opts.cmd_history_size;
152
34
    let cmd_history_file = opts.cmd_history_file.clone();
153
154
34
    let query_history = opts.query_history.clone();
155
34
    let history_size = opts.history_size;
156
34
    let history_file = opts.history_file.clone();
157
    //------------------------------------------------------------------------------
158
34
    let bin_options = BinOptions::from_opts(&opts);
159
160
    //------------------------------------------------------------------------------
161
    // output
162
163
34
    let Some(result) = check_and_run_popup(&opts).unwrap_or_else(|| 
{29
  Branch (163:9): [Folded - Ignored]
-
  Branch (163:9): [True: 34, False: 0]
-
164
        // read from pipe or command
165
29
        let rx_item = if io::stdin().is_terminal() || (opts.interactive && 
opts.cmd0
.
is_some0
()) {
  Branch (165:26): [Folded - Ignored]
-  Branch (165:56): [Folded - Ignored]
-  Branch (165:76): [Folded - Ignored]
-
  Branch (165:26): [True: 0, False: 29]
+
  Branch (133:8): [Folded - Ignored]
+  Branch (133:32): [Folded - Ignored]
+
134
5
        Some(crate::popup::run_with(opts))
135
    } else {
136
29
        None
137
    }
138
34
}
139
#[cfg(not(unix))]
140
#[allow(clippy::option_option)]
141
fn check_and_run_popup(_opts: &SkimOptions) -> Option<Option<SkimOutput>> {
142
    None
143
}
144
145
34
fn sk_main(mut opts: SkimOptions) -> Result<i32> {
146
34
    let reader_opts = SkimItemReaderOption::from_options(&opts);
147
34
    let cmd_collector = Rc::new(RefCell::new(SkimItemReader::new(reader_opts)));
148
34
    opts.cmd_collector = cmd_collector.clone() as Rc<RefCell<dyn CommandCollector>>;
149
150
34
    let cmd_history = opts.cmd_history.clone();
151
34
    let cmd_history_size = opts.cmd_history_size;
152
34
    let cmd_history_file = opts.cmd_history_file.clone();
153
154
34
    let query_history = opts.query_history.clone();
155
34
    let history_size = opts.history_size;
156
34
    let history_file = opts.history_file.clone();
157
    //------------------------------------------------------------------------------
158
34
    let bin_options = BinOptions::from_opts(&opts);
159
160
    //------------------------------------------------------------------------------
161
    // output
162
163
34
    let Some(result) = check_and_run_popup(&opts).unwrap_or_else(|| 
{29
  Branch (163:9): [True: 34, False: 0]
+
  Branch (163:9): [Folded - Ignored]
+
164
        // read from pipe or command
165
29
        let rx_item = if io::stdin().is_terminal() || (opts.interactive && 
opts.cmd0
.
is_some0
()) {
  Branch (165:26): [True: 0, False: 29]
   Branch (165:56): [True: 0, False: 29]
   Branch (165:76): [True: 0, False: 0]
-
166
0
            None
167
        } else {
168
29
            let rx_item = cmd_collector.borrow().of_bufread(BufReader::new(std::io::stdin()));
169
29
            Some(rx_item)
170
        };
171
29
        Skim::run_with(opts, rx_item).ok()
172
29
    }) else {
173
0
        return Ok(135);
174
    };
175
34
    log::debug!("result: {result:?}");
176
177
34
    if result.is_abort {
  Branch (177:8): [Folded - Ignored]
-
  Branch (177:8): [True: 5, False: 29]
-
178
5
        return Ok(130);
179
29
    }
180
181
    // Output — use a large BufWriter to batch all writes into a few syscalls
182
    // instead of one syscall per item (Rust's default LineWriter flushes on \n).
183
    {
184
29
        let stdout = io::stdout();
185
29
        let mut out = BufWriter::with_capacity(1 << 20, stdout.lock());
186
29
        result.write_output(&mut out, &bin_options)
?0
;
187
29
        out.flush()
?0
;
188
    }
189
190
    //------------------------------------------------------------------------------
191
    // write the history with latest item
192
29
    if let Some(
file1
) = history_file {
  Branch (192:12): [Folded - Ignored]
-
  Branch (192:12): [True: 1, False: 28]
-
193
1
        let limit = history_size;
194
1
        write_history_to_file(&query_history, &result.query, limit, &file)
?0
;
195
28
    }
196
197
29
    if let Some(
file0
) = cmd_history_file {
  Branch (197:12): [Folded - Ignored]
-
  Branch (197:12): [True: 0, False: 29]
-
198
0
        let limit = cmd_history_size;
199
0
        write_history_to_file(&cmd_history, &result.cmd, limit, &file)?;
200
29
    }
201
202
29
    Ok(i32::from(result.selected_items.is_empty()))
203
34
}
204
205
5
fn write_history_to_file(
206
5
    orig_history: &[String],
207
5
    latest: &str,
208
5
    limit: usize,
209
5
    filename: &str,
210
5
) -> Result<(), std::io::Error> {
211
5
    if orig_history.last().map(String::as_str) == Some(latest) {
  Branch (211:8): [True: 1, False: 3]
-
  Branch (211:8): [True: 0, False: 1]
-
212
        // no point of having at the end of the history 5x the same command...
213
1
        return Ok(());
214
4
    }
215
4
    let additional_lines = usize::from(!latest.trim().is_empty());
216
4
    let start_index = if orig_history.len() + additional_lines > limit {
  Branch (216:26): [True: 1, False: 2]
-
  Branch (216:26): [True: 0, False: 1]
+
  Branch (165:26): [Folded - Ignored]
+  Branch (165:56): [Folded - Ignored]
+  Branch (165:76): [Folded - Ignored]
+
166
0
            None
167
        } else {
168
29
            let rx_item = cmd_collector.borrow().of_bufread(BufReader::new(std::io::stdin()));
169
29
            Some(rx_item)
170
        };
171
29
        Skim::run_with(opts, rx_item).ok()
172
29
    }) else {
173
0
        return Ok(135);
174
    };
175
34
    log::debug!("result: {result:?}");
176
177
34
    if result.is_abort {
  Branch (177:8): [True: 5, False: 29]
+
  Branch (177:8): [Folded - Ignored]
+
178
5
        return Ok(130);
179
29
    }
180
181
    // Output — use a large BufWriter to batch all writes into a few syscalls
182
    // instead of one syscall per item (Rust's default LineWriter flushes on \n).
183
    {
184
29
        let stdout = io::stdout();
185
29
        let mut out = BufWriter::with_capacity(1 << 20, stdout.lock());
186
29
        result.write_output(&mut out, &bin_options)
?0
;
187
29
        out.flush()
?0
;
188
    }
189
190
    //------------------------------------------------------------------------------
191
    // write the history with latest item
192
29
    if let Some(
file1
) = history_file {
  Branch (192:12): [True: 1, False: 28]
+
  Branch (192:12): [Folded - Ignored]
+
193
1
        let limit = history_size;
194
1
        write_history_to_file(&query_history, &result.query, limit, &file)
?0
;
195
28
    }
196
197
29
    if let Some(
file0
) = cmd_history_file {
  Branch (197:12): [True: 0, False: 29]
+
  Branch (197:12): [Folded - Ignored]
+
198
0
        let limit = cmd_history_size;
199
0
        write_history_to_file(&cmd_history, &result.cmd, limit, &file)?;
200
29
    }
201
202
29
    Ok(i32::from(result.selected_items.is_empty()))
203
34
}
204
205
5
fn write_history_to_file(
206
5
    orig_history: &[String],
207
5
    latest: &str,
208
5
    limit: usize,
209
5
    filename: &str,
210
5
) -> Result<(), std::io::Error> {
211
5
    if orig_history.last().map(String::as_str) == Some(latest) {
  Branch (211:8): [True: 0, False: 1]
+
  Branch (211:8): [True: 1, False: 3]
+
212
        // no point of having at the end of the history 5x the same command...
213
1
        return Ok(());
214
4
    }
215
4
    let additional_lines = usize::from(!latest.trim().is_empty());
216
4
    let start_index = if orig_history.len() + additional_lines > limit {
  Branch (216:26): [True: 0, False: 1]
+
  Branch (216:26): [True: 1, False: 2]
 
217
1
        orig_history.len() + additional_lines - limit
218
    } else {
219
3
        0
220
    };
221
222
4
    let mut history = orig_history[start_index..].to_vec();
223
4
    history.push(latest.to_string());
224
225
4
    let file = File::create(filename)
?0
;
226
4
    let mut file = BufWriter::new(file);
227
4
    file.write_all(history.join("\n").as_bytes())
?0
;
228
4
    Ok(())
229
5
}
230
231
#[cfg(test)]
232
#[cfg_attr(coverage, coverage(off))]
233
mod tests {
234
    use super::*;
235
236
    fn read(path: &std::path::Path) -> String {
237
        std::fs::read_to_string(path).unwrap_or_default()
238
    }
239
240
    #[test]
241
    fn write_history_appends_latest_entry() {
242
        let dir = tempfile::tempdir().unwrap();
243
        let file = dir.path().join("hist");
244
        let file_str = file.to_str().unwrap();
245
        write_history_to_file(&["a".to_string(), "b".to_string()], "c", 10, file_str).unwrap();
246
        assert_eq!(read(&file), "a\nb\nc");
247
    }
248
249
    #[test]
250
    fn write_history_skips_duplicate_of_last() {
251
        let dir = tempfile::tempdir().unwrap();
252
        let file = dir.path().join("hist");
253
        let file_str = file.to_str().unwrap();
254
        // The latest equals the last entry → nothing is written, no file created.
255
        write_history_to_file(&["a".to_string(), "b".to_string()], "b", 10, file_str).unwrap();
256
        assert!(!file.exists());
257
    }
258
259
    #[test]
260
    fn write_history_truncates_to_limit() {
261
        let dir = tempfile::tempdir().unwrap();
262
        let file = dir.path().join("hist");
263
        let file_str = file.to_str().unwrap();
264
        // limit 2 with 3 existing + 1 new keeps only the newest entries.
265
        write_history_to_file(&["a".to_string(), "b".to_string(), "c".to_string()], "d", 2, file_str).unwrap();
266
        assert_eq!(read(&file), "c\nd");
267
    }
268
269
    #[test]
270
    fn write_history_empty_latest_does_not_count_towards_limit() {
271
        let dir = tempfile::tempdir().unwrap();
272
        let file = dir.path().join("hist");
273
        let file_str = file.to_str().unwrap();
274
        // An empty latest adds 0 to the length, so no truncation occurs at limit 3.
275
        write_history_to_file(&["a".to_string(), "b".to_string(), "c".to_string()], "", 3, file_str).unwrap();
276
        assert_eq!(read(&file), "a\nb\nc\n");
277
    }
278
}
\ No newline at end of file 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 5609c142..a1b59837 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,7 +1,7 @@ -

Coverage Report

Created: 2026-08-22 15:34

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
383
            SkimEvent::Start => KeyCode::F(254),
53
396
            SkimEvent::Load => KeyCode::F(253),
54
731
            SkimEvent::Result => KeyCode::F(252),
55
496
            SkimEvent::Focus => KeyCode::F(251),
56
168
            SkimEvent::Zero => KeyCode::F(250),
57
225
            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
466
    pub(crate) fn add_keymaps_str(&mut self, source: &str) {
125
466
        self.add_keymaps(split_top_level(source, ',').into_iter());
126
466
    }
127
128
    /// Adds keymaps from the source, parsing them using `parse_keymap`
129
470
    pub fn add_keymaps<'a, T>(&mut self, source: T)
130
470
    where
131
470
        T: Iterator<Item = &'a str>,
132
    {
133
481
        for map in 
source470
{
134
481
            if let Ok((
key54
,
action_chain54
)) = parse_keymap(map) {
  Branch (134:20): [True: 35, False: 427]
-
  Branch (134:20): [True: 2, False: 0]
-  Branch (134:20): [True: 17, 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
427
                debug!("Failed to parse key: {map}");
139
            }
140
        }
141
470
    }
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]
+

Coverage Report

Created: 2026-09-01 09:15

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.50k
    pub const fn key_code(self) -> KeyCode {
50
4.50k
        match self {
51
359
            SkimEvent::Change => KeyCode::F(255),
52
378
            SkimEvent::Start => KeyCode::F(254),
53
396
            SkimEvent::Load => KeyCode::F(253),
54
727
            SkimEvent::Result => KeyCode::F(252),
55
495
            SkimEvent::Focus => KeyCode::F(251),
56
163
            SkimEvent::Zero => KeyCode::F(250),
57
226
            SkimEvent::One => KeyCode::F(249),
58
1.75k
            SkimEvent::DoubleClick => KeyCode::F(248),
59
        }
60
4.50k
    }
61
62
    /// The reserved [`KeyEvent`] used to route this event through the keymap.
63
    #[must_use]
64
4.50k
    pub const fn key_event(self) -> KeyEvent {
65
4.50k
        KeyEvent::new(self.key_code(), KeyModifiers::NONE)
66
4.50k
    }
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.70k
    fn from(event: SkimEvent) -> Self {
89
2.70k
        event.key_event()
90
2.70k
    }
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.15k
    fn deref(&self) -> &Self::Target {
101
3.15k
        &self.0
102
3.15k
    }
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.40k
    fn default() -> Self {
118
1.40k
        get_default_key_map()
119
1.40k
    }
120
}
121
122
impl KeyMap {
123
    /// Adds keymaps from a comma-separated string.
124
461
    pub(crate) fn add_keymaps_str(&mut self, source: &str) {
125
461
        self.add_keymaps(split_top_level(source, ',').into_iter());
126
461
    }
127
128
    /// Adds keymaps from the source, parsing them using `parse_keymap`
129
465
    pub fn add_keymaps<'a, T>(&mut self, source: T)
130
465
    where
131
465
        T: Iterator<Item = &'a str>,
132
    {
133
476
        for map in 
source465
{
134
476
            if let Ok((
key54
,
action_chain54
)) = parse_keymap(map) {
  Branch (134:20): [True: 35, False: 422]
+
  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
422
                debug!("Failed to parse key: {map}");
139
            }
140
        }
141
465
    }
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.41k
pub fn get_default_key_map() -> KeyMap {
156
1.41k
    let mut ret = HashMap::new();
157
158
1.41k
    ret.insert(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), vec![Action::Down(1)]);
159
1.41k
    ret.insert(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), vec![Action::Up(1)]);
160
1.41k
    ret.insert(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE), vec![Action::PageUp(1)]);
161
1.41k
    ret.insert(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE), vec![Action::PageDown(1)]);
162
1.41k
    ret.insert(KeyEvent::new(KeyCode::End, KeyModifiers::NONE), vec![Action::EndOfLine]);
163
1.41k
    ret.insert(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE), vec![Action::BeginningOfLine]);
164
1.41k
    ret.insert(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE), vec![Action::DeleteChar]);
165
1.41k
    ret.insert(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), vec![Action::Toggle, Action::Down(1)]);
166
1.41k
    ret.insert(KeyEvent::new(KeyCode::BackTab, KeyModifiers::all()), vec![Action::Toggle, Action::Up(1)]);
167
1.41k
    ret.insert(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), vec![Action::Abort]);
168
1.41k
    ret.insert(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), vec![Action::Accept(None)]);
169
1.41k
    ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), vec![Action::BackwardChar]);
170
1.41k
    ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), vec![Action::ForwardChar]);
171
1.41k
    ret.insert(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), vec![Action::BackwardDeleteChar]);
172
1.41k
    ret.insert(SkimEvent::DoubleClick.key_event(), vec![Action::Accept(None)]);
173
174
175
1.41k
    ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT), vec![Action::BackwardWord]);
176
1.41k
    ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT), vec![Action::ForwardWord]);
177
1.41k
    ret.insert(KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT), vec![Action::PreviewUp(1)]);
178
1.41k
    ret.insert(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT), vec![Action::PreviewDown(1)]);
179
1.41k
    ret.insert(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT), vec![Action::Toggle, Action::Up(1)]);
180
1.41k
    ret.insert(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT), vec![Action::Toggle, Action::Up(1)]);
181
1.41k
    ret.insert(KeyEvent::new(KeyCode::Home, KeyModifiers::SHIFT), vec![Action::BeginningOfLine]);
182
183
184
1.41k
    ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL), vec![Action::BackwardWord]);
185
1.41k
    ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL), vec![Action::ForwardWord]);
186
187
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), vec![Action::BeginningOfLine]);
188
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL), vec![Action::BackwardChar]);
189
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), vec![Action::Abort]);
190
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), vec![Action::Abort]);
191
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), vec![Action::EndOfLine]);
192
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), vec![Action::ForwardChar]);
193
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL), vec![Action::Abort]);
194
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), vec![Action::BackwardDeleteChar]);
195
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), vec![Action::Down(1)]);
196
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), vec![Action::Up(1)]);
197
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL), vec![Action::ClearScreen]);
198
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), vec![Action::Down(1)]);
199
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), vec![Action::Up(1)]);
200
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL), vec![Action::ToggleInteractive]);
201
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), vec![Action::RotateMode]);
202
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), vec![Action::UnixLineDiscard]);
203
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), vec![Action::UnixWordRubout]);
204
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), vec![Action::Yank]);
205
206
207
1.41k
    ret.insert(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT), vec![Action::BackwardKillWord]);
208
209
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT), vec![Action::BackwardWord]);
210
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT), vec![Action::KillWord]);
211
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT), vec![Action::ForwardWord]);
212
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::ALT), vec![Action::ScrollLeft(1)]);
213
1.41k
    ret.insert(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::ALT), vec![Action::ScrollRight(1)]);
214
215
1.41k
    KeyMap(ret)
216
1.41k
}
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]
 
  Branch (227:8): [True: 1, False: 98]
 
228
1
        return Err(eyre!("Cannot parse empty key"));
229
168
    }
230
168
    if let Some(
event37
) = SkimEvent::from_name(key) {
  Branch (230:12): [True: 20, False: 50]
 
  Branch (230:12): [True: 17, False: 81]
@@ -15,26 +15,26 @@
 
  Branch (258:19): [True: 7, False: 34]
 
259
9
        && let Ok(
f_index1
) = f.parse::<u8>()
  Branch (259:16): [True: 0, False: 2]
 
  Branch (259:16): [True: 1, False: 6]
-
260
1
    {
261
1
        // A function key like `f10`. If the suffix isn't numeric (e.g. `focus`,
262
1
        // `first`), fall through to the named-key / event matching below.
263
1
        keycode = KeyCode::F(f_index);
264
1
    } else {
265
44
        keycode = match key.as_str() {
266
44
            "space" => 
KeyCode::Char(' ')1
,
267
43
            "enter" => 
KeyCode::Enter12
,
268
31
            "bspace" | 
"bs"30
=>
KeyCode::Backspace2
,
269
29
            "up" => 
KeyCode::Up7
,
270
22
            "down" => 
KeyCode::Down3
,
271
19
            "left" => 
KeyCode::Left1
,
272
18
            "right" => 
KeyCode::Right1
,
273
17
            "tab" => 
KeyCode::Tab1
,
274
16
            "btab" => 
KeyCode::BackTab1
,
275
15
            "esc" => 
KeyCode::Esc1
,
276
14
            "home" => 
KeyCode::Home1
,
277
13
            "end" => 
KeyCode::End1
,
278
12
            "pgup" => 
KeyCode::PageUp1
,
279
11
            "pgdown" => 
KeyCode::PageDown1
,
280
10
            s => match SkimEvent::from_name(s) {
281
0
                Some(event) => event.key_code(),
282
10
                None => return Err(eyre!("Unknown key {}", s)),
283
            },
284
        }
285
    }
286
287
103
    debug!("parsed key {keycode:?} and mods {mods:?}");
288
289
103
    Ok(KeyEvent::new(keycode, mods))
290
169
}
291
292
/// Parse an iterator of keymaps into a `KeyMap`
293
4
pub fn parse_keymaps<'a, T>(maps: T) -> KeyMap
294
4
where
295
4
    T: Iterator<Item = &'a str>,
296
{
297
4
    let mut res = KeyMap::default();
298
4
    res.add_keymaps(maps);
299
4
    res
300
4
}
301
302
1.06k
pub(crate) fn split_top_level(value: &str, separator: char) -> Vec<&str> {
303
1.06k
    let mut depth = 0_u32;
304
1.06k
    let mut start = 0;
305
1.06k
    let mut parts = Vec::new();
306
307
3.23k
    for (index, ch) in 
value1.06k
.
char_indices1.06k
() {
308
3.10k
        match ch {
309
66
            '(' => depth += 1,
310
67
            ')' => depth = depth.saturating_sub(1),
311
3.10k
            _ if ch == separator && 
depth39
==
035
=> {
  Branch (311:18): [True: 12, False: 2.13k]
+
260
1
    {
261
1
        // A function key like `f10`. If the suffix isn't numeric (e.g. `focus`,
262
1
        // `first`), fall through to the named-key / event matching below.
263
1
        keycode = KeyCode::F(f_index);
264
1
    } else {
265
44
        keycode = match key.as_str() {
266
44
            "space" => 
KeyCode::Char(' ')1
,
267
43
            "enter" => 
KeyCode::Enter12
,
268
31
            "bspace" | 
"bs"30
=>
KeyCode::Backspace2
,
269
29
            "up" => 
KeyCode::Up7
,
270
22
            "down" => 
KeyCode::Down3
,
271
19
            "left" => 
KeyCode::Left1
,
272
18
            "right" => 
KeyCode::Right1
,
273
17
            "tab" => 
KeyCode::Tab1
,
274
16
            "btab" => 
KeyCode::BackTab1
,
275
15
            "esc" => 
KeyCode::Esc1
,
276
14
            "home" => 
KeyCode::Home1
,
277
13
            "end" => 
KeyCode::End1
,
278
12
            "pgup" => 
KeyCode::PageUp1
,
279
11
            "pgdown" => 
KeyCode::PageDown1
,
280
10
            s => match SkimEvent::from_name(s) {
281
0
                Some(event) => event.key_code(),
282
10
                None => return Err(eyre!("Unknown key {}", s)),
283
            },
284
        }
285
    }
286
287
103
    debug!("parsed key {keycode:?} and mods {mods:?}");
288
289
103
    Ok(KeyEvent::new(keycode, mods))
290
169
}
291
292
/// Parse an iterator of keymaps into a `KeyMap`
293
4
pub fn parse_keymaps<'a, T>(maps: T) -> KeyMap
294
4
where
295
4
    T: Iterator<Item = &'a str>,
296
{
297
4
    let mut res = KeyMap::default();
298
4
    res.add_keymaps(maps);
299
4
    res
300
4
}
301
302
1.05k
pub(crate) fn split_top_level(value: &str, separator: char) -> Vec<&str> {
303
1.05k
    let mut depth = 0_u32;
304
1.05k
    let mut start = 0;
305
1.05k
    let mut parts = Vec::new();
306
307
3.23k
    for (index, ch) in 
value1.05k
.
char_indices1.05k
() {
308
3.10k
        match ch {
309
66
            '(' => depth += 1,
310
67
            ')' => depth = depth.saturating_sub(1),
311
3.10k
            _ if ch == separator && 
depth39
==
035
=> {
  Branch (311:18): [True: 12, False: 2.13k]
   Branch (311:37): [True: 12, False: 0]
 
  Branch (311:18): [True: 27, False: 928]
   Branch (311:37): [True: 23, False: 4]
-
312
35
                parts.push(&value[start..index]);
313
35
                start = index + ch.len_utf8();
314
35
            }
315
3.07k
            _ => {}
316
        }
317
    }
318
1.06k
    parts.push(&value[start..]);
319
1.06k
    parts
320
1.06k
}
321
322
/// Parses follow-up action bindings from raw `--bind` specs.
323
///
324
/// Any action can be bound as if it were an event: when the "key" of a bind is
325
/// not a real key but is a known action name, the bound chain becomes a
326
/// *follow-up* that runs right after that action. For example `reload:first`
327
/// queues `first` immediately after a `reload`. The returned map is keyed by the
328
/// action's canonical name (see [`Action::name`](crate::tui::actions::Action::name)),
329
/// so it can be looked up directly from the action that just ran.
330
///
331
/// Keys take precedence: if the "key" resolves to a real key it is left to the
332
/// key map, so a name shared by a key and an action (e.g. `up`) always binds the
333
/// key. To target the action in that case, prefix it with `act-` (`act-up`).
334
#[must_use]
335
472
pub fn parse_action_binds<'a, T>(maps: T) -> HashMap<String, Vec<Action>>
336
472
where
337
472
    T: Iterator<Item = &'a str>,
338
{
339
472
    let mut res = HashMap::new();
340
484
    for map in 
maps472
{
341
484
        let Some((
key57
,
chain57
)) = map.split_once(':') else {
  Branch (341:13): [True: 35, False: 427]
+
312
35
                parts.push(&value[start..index]);
313
35
                start = index + ch.len_utf8();
314
35
            }
315
3.07k
            _ => {}
316
        }
317
    }
318
1.05k
    parts.push(&value[start..]);
319
1.05k
    parts
320
1.05k
}
321
322
/// Parses follow-up action bindings from raw `--bind` specs.
323
///
324
/// Any action can be bound as if it were an event: when the "key" of a bind is
325
/// not a real key but is a known action name, the bound chain becomes a
326
/// *follow-up* that runs right after that action. For example `reload:first`
327
/// queues `first` immediately after a `reload`. The returned map is keyed by the
328
/// action's canonical name (see [`Action::name`](crate::tui::actions::Action::name)),
329
/// so it can be looked up directly from the action that just ran.
330
///
331
/// Keys take precedence: if the "key" resolves to a real key it is left to the
332
/// key map, so a name shared by a key and an action (e.g. `up`) always binds the
333
/// key. To target the action in that case, prefix it with `act-` (`act-up`).
334
#[must_use]
335
467
pub fn parse_action_binds<'a, T>(maps: T) -> HashMap<String, Vec<Action>>
336
467
where
337
467
    T: Iterator<Item = &'a str>,
338
{
339
467
    let mut res = HashMap::new();
340
479
    for map in 
maps467
{
341
479
        let Some((
key57
,
chain57
)) = map.split_once(':') else {
  Branch (341:13): [True: 35, False: 422]
+
  Branch (341:13): [True: 10, False: 0]
 
  Branch (341:13): [True: 4, False: 0]
 
  Branch (341:13): [True: 3, False: 0]
 
  Branch (341:13): [True: 5, False: 0]
-
  Branch (341:13): [True: 10, False: 0]
-
342
427
            continue;
343
        };
344
        // Keys win: anything that parses as a real key is not an action trigger.
345
57
        if parse_key(key).is_ok() {
  Branch (345:12): [True: 32, False: 3]
+
342
422
            continue;
343
        };
344
        // Keys win: anything that parses as a real key is not an action trigger.
345
57
        if parse_key(key).is_ok() {
  Branch (345:12): [True: 32, False: 3]
+
  Branch (345:12): [True: 6, False: 4]
 
  Branch (345:12): [True: 1, False: 3]
 
  Branch (345:12): [True: 0, False: 3]
 
  Branch (345:12): [True: 0, False: 5]
-
  Branch (345:12): [True: 6, False: 4]
 
346
39
            continue;
347
18
        }
348
18
        let Some(
name17
) = action_trigger_name(key) else {
  Branch (348:13): [True: 3, False: 0]
+
  Branch (348:13): [True: 4, False: 0]
 
  Branch (348:13): [True: 3, False: 0]
 
  Branch (348:13): [True: 2, False: 1]
 
  Branch (348:13): [True: 5, False: 0]
-
  Branch (348:13): [True: 4, False: 0]
-
349
1
            debug!("Ignoring bind `{map}`: `{key}` is neither a key nor an action");
350
1
            continue;
351
        };
352
17
        match parse_action_chain(chain) {
353
16
            Ok(actions) => {
354
16
                res.insert(name.to_string(), actions);
355
16
            }
356
1
            Err(err) => debug!("Ignoring bind `{map}`: invalid action chain `{chain}`: {err}"),
357
        }
358
    }
359
472
    res
360
472
}
361
362
/// Resolves a bind trigger to the canonical name of the action it targets
363
/// (see [`Action::name`](crate::tui::actions::Action::name)). `act-<name>`
364
/// explicitly targets the action `<name>`, even when `<name>` is also a key;
365
/// without the prefix, a bare action name works too. Returns `None` if the
366
/// name is not a known action.
367
///
368
/// This performs pure name resolution: callers that want "keys win" semantics
369
/// (e.g. [`parse_action_binds`]) must check [`parse_key`] first.
370
25
pub(crate) fn action_trigger_name(trigger: &str) -> Option<&'static str> {
371
25
    let action_name = trigger.strip_prefix("act-").unwrap_or(trigger);
372
    // Some actions require an argument when executed, but their canonical
373
    // name is still valid as a trigger. `()` supplies the parser's empty
374
    // placeholder solely for name validation.
375
25
    let 
action21
= actions::parse_action(action_name).or_else(||
actions::parse_action10
(
&format!("{action_name}()")10
))
?4
;
376
21
    Some(action.name())
377
25
}
378
379
/// Parses an action chain, separated by '+'s into the corresponding actions
380
///
381
/// # Errors
382
/// Returns an error if the action chain is empty or contains only unknown actions.
383
127
pub fn parse_action_chain(action_chain: &str) -> Result<Vec<Action>> {
384
127
    let mut actions: Vec<Action> = vec![];
385
127
    let mut split = split_top_level(action_chain, '+').into_iter();
386
387
268
    while let Some(
mut s141
) = split.next().map(String::from) {
  Branch (387:15): [True: 84, False: 78]
+
349
1
            debug!("Ignoring bind `{map}`: `{key}` is neither a key nor an action");
350
1
            continue;
351
        };
352
17
        match parse_action_chain(chain) {
353
16
            Ok(actions) => {
354
16
                res.insert(name.to_string(), actions);
355
16
            }
356
1
            Err(err) => debug!("Ignoring bind `{map}`: invalid action chain `{chain}`: {err}"),
357
        }
358
    }
359
467
    res
360
467
}
361
362
/// Resolves a bind trigger to the canonical name of the action it targets
363
/// (see [`Action::name`](crate::tui::actions::Action::name)). `act-<name>`
364
/// explicitly targets the action `<name>`, even when `<name>` is also a key;
365
/// without the prefix, a bare action name works too. Returns `None` if the
366
/// name is not a known action.
367
///
368
/// This performs pure name resolution: callers that want "keys win" semantics
369
/// (e.g. [`parse_action_binds`]) must check [`parse_key`] first.
370
25
pub(crate) fn action_trigger_name(trigger: &str) -> Option<&'static str> {
371
25
    let action_name = trigger.strip_prefix("act-").unwrap_or(trigger);
372
    // Some actions require an argument when executed, but their canonical
373
    // name is still valid as a trigger. `()` supplies the parser's empty
374
    // placeholder solely for name validation.
375
25
    let 
action21
= actions::parse_action(action_name).or_else(||
actions::parse_action10
(
&format!("{action_name}()")10
))
?4
;
376
21
    Some(action.name())
377
25
}
378
379
/// Parses an action chain, separated by '+'s into the corresponding actions
380
///
381
/// # Errors
382
/// Returns an error if the action chain is empty or contains only unknown actions.
383
127
pub fn parse_action_chain(action_chain: &str) -> Result<Vec<Action>> {
384
127
    let mut actions: Vec<Action> = vec![];
385
127
    let mut split = split_top_level(action_chain, '+').into_iter();
386
387
268
    while let Some(
mut s141
) = split.next().map(String::from) {
  Branch (387:15): [True: 84, False: 78]
 
  Branch (387:15): [True: 57, False: 49]
 
388
141
        if (s.starts_with("if-") || 
s.ends_with('{')138
)
  Branch (388:13): [True: 1, False: 83]
   Branch (388:37): [True: 0, False: 83]
@@ -46,6 +46,6 @@
 
  Branch (393:16): [True: 54, False: 3]
 
394
138
            actions.push(act);
395
138
        
}3
396
    }
397
127
    if actions.is_empty() {
  Branch (397:8): [True: 0, False: 78]
 
  Branch (397:8): [True: 3, False: 46]
-
398
3
        Err(eyre!("Empty action chain or unknown action `{}`", action_chain))
399
    } else {
400
124
        Ok(actions)
401
    }
402
127
}
403
404
/// Parse a single keymap and return the key and action(s)
405
///
406
/// # Errors
407
/// Returns an error if the string is empty, missing a colon separator, or the
408
/// action chain cannot be parsed.
409
481
pub fn parse_keymap(key_action: &str) -> Result<(&str, Vec<Action>)> {
410
481
    if key_action.is_empty() {
  Branch (410:8): [True: 427, False: 35]
+
398
3
        Err(eyre!("Empty action chain or unknown action `{}`", action_chain))
399
    } else {
400
124
        Ok(actions)
401
    }
402
127
}
403
404
/// Parse a single keymap and return the key and action(s)
405
///
406
/// # Errors
407
/// Returns an error if the string is empty, missing a colon separator, or the
408
/// action chain cannot be parsed.
409
476
pub fn parse_keymap(key_action: &str) -> Result<(&str, Vec<Action>)> {
410
476
    if key_action.is_empty() {
  Branch (410:8): [True: 422, False: 35]
 
  Branch (410:8): [True: 0, False: 19]
-
411
427
        return Err(eyre!("Got an empty keybind, skipping"));
412
54
    }
413
54
    debug!("got key_action: {key_action:?}");
414
54
    let (key, action_chain) = key_action
415
54
        .split_once(':')
416
54
        .ok_or(eyre!("Failed to parse {} as key and action", key_action))
?0
;
417
54
    debug!("parsed key_action: {key:?}: {action_chain:?}");
418
54
    Ok((key, parse_action_chain(action_chain)
?0
))
419
481
}
420
421
#[cfg(test)]
422
#[path = "binds_tests.rs"]
423
mod tests;
\ No newline at end of file +
411
422
        return Err(eyre!("Got an empty keybind, skipping"));
412
54
    }
413
54
    debug!("got key_action: {key_action:?}");
414
54
    let (key, action_chain) = key_action
415
54
        .split_once(':')
416
54
        .ok_or(eyre!("Failed to parse {} as key and action", key_action))
?0
;
417
54
    debug!("parsed key_action: {key:?}: {action_chain:?}");
418
54
    Ok((key, parse_action_chain(action_chain)
?0
))
419
476
}
420
421
#[cfg(test)]
422
#[path = "binds_tests.rs"]
423
mod tests;
\ No newline at end of file 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 8e3211c2..7cd7f163 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-08-22 15:34

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
443
    pub fn builder() -> Self {
15
443
        Self {
16
443
            rank_builder: Default::default(),
17
443
        }
18
443
    }
19
20
441
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
21
441
        self.rank_builder = rank_builder;
22
441
        self
23
441
    }
24
25
443
    pub fn build(self) -> Self {
26
443
        self
27
443
    }
28
}
29
30
impl MatchEngine for MatchAllEngine {
31
1.28k
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
32
1.28k
        let item_text = item.text();
33
1.28k
        Some(MatchResult {
34
1.28k
            rank: self.rank_builder.build_rank(0, 0, 0, &item_text),
35
1.28k
            matched_range: MatchRange::ByteRange(0, 0),
36
1.28k
        })
37
1.28k
    }
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-01 09:15

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.28k
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
32
1.28k
        let item_text = item.text();
33
1.28k
        Some(MatchResult {
34
1.28k
            rank: self.rank_builder.build_rank(0, 0, 0, &item_text),
35
1.28k
            matched_range: MatchRange::ByteRange(0, 0),
36
1.28k
        })
37
1.28k
    }
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 85e776b0..740f77e0 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,5 +1,5 @@ -

Coverage Report

Created: 2026-08-22 15:34

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
425
    pub fn builder() -> Self {
61
425
        Self { engines: vec![] }
62
425
    }
63
64
424
    pub fn engines(mut self, mut engines: Vec<Box<dyn MatchEngine>>) -> Self {
65
424
        self.engines.append(&mut engines);
66
424
        self
67
424
    }
68
69
425
    pub fn build(self) -> Self {
70
425
        self
71
425
    }
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-01 09:15

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
429
    pub fn builder() -> Self {
61
429
        Self { engines: vec![] }
62
429
    }
63
64
428
    pub fn engines(mut self, mut engines: Vec<Box<dyn MatchEngine>>) -> Self {
65
428
        self.engines.append(&mut engines);
66
428
        self
67
428
    }
68
69
429
    pub fn build(self) -> Self {
70
429
        self
71
429
    }
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]
-
121
1
            None
122
        } else {
123
1
            Some(Self::merge_matched_items(results, &item.text()))
124
        }
125
51.5k
    }
126
}
127
128
impl Display for AndEngine {
129
7
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
130
7
        write!(
131
7
            f,
132
            "(And: {})",
133
7
            self.engines
134
7
                .iter()
135
12
                .
map7
(|e| format!("{e}"))
136
7
                .collect::<Vec<_>>()
137
7
                .join(", ")
138
        )
139
7
    }
140
}
141
142
#[cfg(test)]
143
#[path = "andor_tests.rs"]
144
mod tests;
\ No newline at end of file +
121
1
            None
122
        } else {
123
1
            Some(Self::merge_matched_items(results, &item.text()))
124
        }
125
51.5k
    }
126
}
127
128
impl Display for AndEngine {
129
6
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
130
6
        write!(
131
6
            f,
132
            "(And: {})",
133
6
            self.engines
134
6
                .iter()
135
11
                .
map6
(|e| format!("{e}"))
136
6
                .collect::<Vec<_>>()
137
6
                .join(", ")
138
        )
139
6
    }
140
}
141
142
#[cfg(test)]
143
#[path = "andor_tests.rs"]
144
mod tests;
\ No newline at end of file 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 2b438123..fd583ec4 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,12 +1,12 @@ -

Coverage Report

Created: 2026-08-22 15:34

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-01 09:15

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
61
    pub fn builder(query: &str, param: ExactMatchingParam) -> Self {
32
61
        let case_sensitive = match param.case {
33
3
            CaseMatching::Respect => true,
34
6
            CaseMatching::Ignore => false,
35
52
            CaseMatching::Smart => contains_upper(query),
36
        };
37
38
61
        let mut query_builder = String::new();
39
61
        if !case_sensitive {
  Branch (39:12): [True: 18, 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]
+
40
57
            query_builder.push_str("(?i)");
41
57
        
}4
42
43
61
        if param.prefix {
  Branch (43:12): [True: 5, False: 13]
 
  Branch (43:12): [True: 6, False: 37]
-
44
11
            query_builder.push('^');
45
49
        }
46
47
60
        query_builder.push_str(&escape(query));
48
49
60
        if param.postfix {
  Branch (49:12): [True: 1, False: 16]
+
44
11
            query_builder.push('^');
45
50
        }
46
47
61
        query_builder.push_str(&escape(query));
48
49
61
        if param.postfix {
  Branch (49:12): [True: 1, False: 17]
 
  Branch (49:12): [True: 7, False: 36]
-
50
8
            query_builder.push('$');
51
52
        }
52
53
60
        let query_regex = if query.is_empty() {
  Branch (53:30): [True: 1, False: 16]
+
50
8
            query_builder.push('$');
51
53
        }
52
53
61
        let query_regex = if query.is_empty() {
  Branch (53:30): [True: 1, False: 17]
 
  Branch (53:30): [True: 1, False: 42]
-
54
2
            None
55
        } else {
56
58
            Regex::new(&query_builder).ok()
57
        };
58
59
60
        ExactEngine {
60
60
            query: query.to_string(),
61
60
            query_regex,
62
60
            rank_builder: Default::default(),
63
60
            inverse: param.inverse,
64
60
        }
65
60
    }
66
67
29
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
68
29
        self.rank_builder = rank_builder;
69
29
        self
70
29
    }
71
72
60
    pub fn build(self) -> Self {
73
60
        self
74
60
    }
75
}
76
77
impl MatchEngine for ExactEngine {
78
81
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
79
81
        let mut matched_result = None;
80
81
        let item_text = item.text();
81
81
        let default_range = [(0, item_text.len())];
82
81
        let ranges = item.get_matching_ranges().unwrap_or(&default_range);
83
84
81
        if ranges.is_empty() {
  Branch (84:12): [True: 0, False: 48]
+
54
2
            None
55
        } else {
56
59
            Regex::new(&query_builder).ok()
57
        };
58
59
61
        ExactEngine {
60
61
            query: query.to_string(),
61
61
            query_regex,
62
61
            rank_builder: Default::default(),
63
61
            inverse: param.inverse,
64
61
        }
65
61
    }
66
67
30
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
68
30
        self.rank_builder = rank_builder;
69
30
        self
70
30
    }
71
72
61
    pub fn build(self) -> Self {
73
61
        self
74
61
    }
75
}
76
77
impl MatchEngine for ExactEngine {
78
81
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
79
81
        let mut matched_result = None;
80
81
        let item_text = item.text();
81
81
        let default_range = [(0, item_text.len())];
82
81
        let ranges = item.get_matching_ranges().unwrap_or(&default_range);
83
84
81
        if ranges.is_empty() {
  Branch (84:12): [True: 0, False: 48]
 
  Branch (84:12): [True: 1, False: 32]
 
85
1
            // Nothing to match against (e.g. every `--nth` index is out of range): the item
86
1
            // stays unmatched, inverse or not.
87
80
        } else if self.query_regex.is_none() {
  Branch (87:19): [True: 3, False: 45]
 
  Branch (87:19): [True: 1, False: 31]
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 ed9b63a5..6f620847 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-08-22 15:34

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
538
    pub fn builder() -> Self {
30
538
        Self {
31
538
            exact_mode: false,
32
538
            fuzzy_algorithm: FuzzyAlgorithm::SkimV2,
33
538
            rank_builder: Default::default(),
34
538
            typos: Typos::Disabled,
35
538
            filter_mode: false,
36
538
            last_match: false,
37
538
        }
38
538
    }
39
40
    /// Sets whether to use exact matching mode
41
    #[must_use]
42
395
    pub fn exact_mode(mut self, exact_mode: bool) -> Self {
43
395
        self.exact_mode = exact_mode;
44
395
        self
45
395
    }
46
47
    /// Sets the fuzzy matching algorithm to use
48
    #[must_use]
49
395
    pub fn fuzzy_algorithm(mut self, fuzzy_algorithm: FuzzyAlgorithm) -> Self {
50
395
        self.fuzzy_algorithm = fuzzy_algorithm;
51
395
        self
52
395
    }
53
54
    /// Sets the rank builder for scoring matches
55
    #[must_use]
56
395
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
57
395
        self.rank_builder = rank_builder;
58
395
        self
59
395
    }
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
395
    pub fn typos(mut self, typos: Typos) -> Self {
68
395
        self.typos = typos;
69
395
        self
70
395
    }
71
72
    /// Sets filter mode (skips per-character match indices for faster matching)
73
    #[must_use]
74
395
    pub fn filter_mode(mut self, filter_mode: bool) -> Self {
75
395
        self.filter_mode = filter_mode;
76
395
        self
77
395
    }
78
79
    /// When true, prefer the last (rightmost) occurrence on tied scores
80
    #[must_use]
81
395
    pub fn last_match(mut self, last_match: bool) -> Self {
82
395
        self.last_match = last_match;
83
395
        self
84
395
    }
85
86
    /// Builds the factory (currently a no-op, returns self)
87
    #[must_use]
88
538
    pub fn build(self) -> Self {
89
538
        self
90
538
    }
91
}
92
93
impl MatchEngineFactory for ExactOrFuzzyEngineFactory {
94
1.01k
    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.01k
        let mut query = query;
104
1.01k
        let mut exact = self.exact_mode;
105
1.01k
        let mut param = ExactMatchingParam::default();
106
1.01k
        param.case = case;
107
108
1.01k
        if query.starts_with('\'') {
  Branch (108:12): [True: 5, False: 962]
+

Coverage Report

Created: 2026-09-01 09:15

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
533
    pub fn builder() -> Self {
30
533
        Self {
31
533
            exact_mode: false,
32
533
            fuzzy_algorithm: FuzzyAlgorithm::SkimV2,
33
533
            rank_builder: Default::default(),
34
533
            typos: Typos::Disabled,
35
533
            filter_mode: false,
36
533
            last_match: false,
37
533
        }
38
533
    }
39
40
    /// Sets whether to use exact matching mode
41
    #[must_use]
42
390
    pub fn exact_mode(mut self, exact_mode: bool) -> Self {
43
390
        self.exact_mode = exact_mode;
44
390
        self
45
390
    }
46
47
    /// Sets the fuzzy matching algorithm to use
48
    #[must_use]
49
390
    pub fn fuzzy_algorithm(mut self, fuzzy_algorithm: FuzzyAlgorithm) -> Self {
50
390
        self.fuzzy_algorithm = fuzzy_algorithm;
51
390
        self
52
390
    }
53
54
    /// Sets the rank builder for scoring matches
55
    #[must_use]
56
390
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
57
390
        self.rank_builder = rank_builder;
58
390
        self
59
390
    }
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
390
    pub fn typos(mut self, typos: Typos) -> Self {
68
390
        self.typos = typos;
69
390
        self
70
390
    }
71
72
    /// Sets filter mode (skips per-character match indices for faster matching)
73
    #[must_use]
74
390
    pub fn filter_mode(mut self, filter_mode: bool) -> Self {
75
390
        self.filter_mode = filter_mode;
76
390
        self
77
390
    }
78
79
    /// When true, prefer the last (rightmost) occurrence on tied scores
80
    #[must_use]
81
390
    pub fn last_match(mut self, last_match: bool) -> Self {
82
390
        self.last_match = last_match;
83
390
        self
84
390
    }
85
86
    /// Builds the factory (currently a no-op, returns self)
87
    #[must_use]
88
533
    pub fn build(self) -> Self {
89
533
        self
90
533
    }
91
}
92
93
impl MatchEngineFactory for ExactOrFuzzyEngineFactory {
94
1.01k
    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.01k
        let mut query = query;
104
1.01k
        let mut exact = self.exact_mode;
105
1.01k
        let mut param = ExactMatchingParam::default();
106
1.01k
        param.case = case;
107
108
1.01k
        if query.starts_with('\'') {
  Branch (108:12): [True: 5, False: 957]
 
  Branch (108:12): [True: 2, False: 47]
-
109
7
            exact = !exact;
110
7
            query = &query[1..];
111
1.00k
        }
112
113
1.01k
        if query.starts_with('!') {
  Branch (113:12): [True: 8, False: 959]
+
109
7
            exact = !exact;
110
7
            query = &query[1..];
111
1.00k
        }
112
113
1.01k
        if query.starts_with('!') {
  Branch (113:12): [True: 9, False: 953]
 
  Branch (113:12): [True: 4, False: 45]
-
114
12
            query = &query[1..];
115
12
            exact = true;
116
12
            param.inverse = true;
117
1.00k
        }
118
119
1.01k
        if query.is_empty() {
  Branch (119:12): [True: 418, False: 549]
+
114
13
            query = &query[1..];
115
13
            exact = true;
116
13
            param.inverse = true;
117
998
        }
118
119
1.01k
        if query.is_empty() {
  Branch (119:12): [True: 411, False: 551]
 
  Branch (119:12): [True: 22, False: 27]
-
120
            // if only "!" was provided, will still show all items
121
440
            return Box::new(
122
440
                MatchAllEngine::builder()
123
440
                    .rank_builder(self.rank_builder.clone())
124
440
                    .build(),
125
440
            );
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
433
            return Box::new(
122
433
                MatchAllEngine::builder()
123
433
                    .rank_builder(self.rank_builder.clone())
124
433
                    .build(),
125
433
            );
126
578
        }
127
128
578
        if query.starts_with('^') {
  Branch (128:12): [True: 5, False: 546]
 
  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
568
        }
133
134
578
        if query.ends_with('$') {
  Branch (134:12): [True: 1, False: 550]
 
  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
571
        }
139
140
578
        if exact {
  Branch (140:12): [True: 18, False: 533]
 
  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.01k
    }
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
398
    pub fn new(factory: impl MatchEngineFactory + 'static) -> Self {
171
398
        Self {
172
398
            inner: Box::new(factory),
173
398
        }
174
398
    }
175
176
841
    fn parse_andor(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
177
841
        if query.trim().is_empty() {
  Branch (177:12): [True: 404, False: 414]
+
141
30
            Box::new(
142
30
                ExactEngine::builder(query, param)
143
30
                    .rank_builder(self.rank_builder.clone())
144
30
                    .build(),
145
30
            )
146
        } else {
147
548
            Box::new(
148
548
                FuzzyEngine::builder()
149
548
                    .query(query)
150
548
                    .algorithm(self.fuzzy_algorithm)
151
548
                    .case(case)
152
548
                    .typos(self.typos)
153
548
                    .filter_mode(self.filter_mode)
154
548
                    .last_match(self.last_match)
155
548
                    .rank_builder(self.rank_builder.clone())
156
548
                    .build(),
157
548
            )
158
        }
159
1.01k
    }
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
393
    pub fn new(factory: impl MatchEngineFactory + 'static) -> Self {
171
393
        Self {
172
393
            inner: Box::new(factory),
173
393
        }
174
393
    }
175
176
838
    fn parse_andor(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
177
838
        if query.trim().is_empty() {
  Branch (177:12): [True: 397, False: 418]
 
  Branch (177:12): [True: 16, False: 7]
-
178
420
            return self.inner.create_engine_with_case(query, case);
179
421
        }
180
421
        let and_engines = RE_OR_WITH_SPACES
181
421
            .replace_all(&Self::mask_escape_space(query), "|")
182
421
            .split(' ')
183
521
            .
filter_map421
(|and_term| {
184
521
                if and_term.is_empty() {
  Branch (184:20): [True: 2, False: 506]
+
178
413
            return self.inner.create_engine_with_case(query, case);
179
425
        }
180
425
        let and_engines = RE_OR_WITH_SPACES
181
425
            .replace_all(&Self::mask_escape_space(query), "|")
182
425
            .split(' ')
183
523
            .
filter_map425
(|and_term| {
184
523
                if and_term.is_empty() {
  Branch (184:20): [True: 2, False: 508]
 
  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
520
                }
187
520
                let or_engines = and_term
188
520
                    .split('|')
189
576
                    .
filter_map520
(|term| {
190
576
                        if term.is_empty() {
  Branch (190:28): [True: 26, False: 534]
 
  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
549
                        }
193
549
                        debug!("Creating Or engine for {term}");
194
549
                        Some(
195
549
                            self.inner
196
549
                                .create_engine_with_case(&Self::unmask_escape_space(term), case),
197
549
                        )
198
576
                    })
199
520
                    .collect::<Vec<_>>();
200
520
                debug!("Building or matcher engine from Ors");
201
520
                if or_engines.len() == 1 {
  Branch (201:20): [True: 485, 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
421
            .collect();
207
421
        debug!("Creating and matcher engine from Ors");
208
421
        Box::new(AndEngine::builder().engines(and_engines).build())
209
841
    }
210
211
421
    fn mask_escape_space(string: &str) -> String {
212
421
        string.replace("\\ ", "\0")
213
421
    }
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
841
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
222
841
        self.parse_andor(query, case)
223
841
    }
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
494
                    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
523
            })
206
425
            .collect();
207
425
        debug!("Creating and matcher engine from Ors");
208
425
        Box::new(AndEngine::builder().engines(and_engines).build())
209
838
    }
210
211
425
    fn mask_escape_space(string: &str) -> String {
212
425
        string.replace("\\ ", "\0")
213
425
    }
214
215
549
    fn unmask_escape_space(string: &str) -> String {
216
549
        string.replace('\0', " ")
217
549
    }
218
}
219
220
impl MatchEngineFactory for AndOrEngineFactory {
221
838
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
222
838
        self.parse_andor(query, case)
223
838
    }
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 fb9bf58a..8e18e461 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,4 +1,4 @@ -

Coverage Report

Created: 2026-08-22 15:34

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
566
            Typos::Disabled => None,
101
4
            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
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
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
539
                let matcher = ArinaeMatcher::new(self.case, !
matches!2
(self.typos, Typos::Disabled), self.last_match);
146
539
                debug!("Initialized Arinae algorithm");
147
539
                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-01 09:15

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
573
    pub fn query(mut self, query: &str) -> Self {
58
573
        self.query = query.to_string();
59
573
        self
60
573
    }
61
62
560
    pub fn case(mut self, case: CaseMatching) -> Self {
63
560
        self.case = case;
64
560
        self
65
560
    }
66
67
563
    pub fn algorithm(mut self, algorithm: FuzzyAlgorithm) -> Self {
68
563
        self.algorithm = algorithm;
69
563
        self
70
563
    }
71
72
548
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
73
548
        self.rank_builder = rank_builder;
74
548
        self
75
548
    }
76
77
553
    pub fn typos(mut self, typos: Typos) -> Self {
78
553
        self.typos = typos;
79
553
        self
80
553
    }
81
82
    /// No-op: `fuzzy_match_range` is now always used (`ByteRange`).
83
    /// Kept for API backward compatibility.
84
548
    pub fn filter_mode(self, _filter_mode: bool) -> Self {
85
548
        self
86
548
    }
87
88
548
    pub fn last_match(mut self, last_match: bool) -> Self {
89
548
        self.last_match = last_match;
90
548
        self
91
548
    }
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
573
    fn effective_max_typos(&self) -> Option<usize> {
99
573
        match self.typos {
100
566
            Typos::Disabled => None,
101
5
            Typos::Smart => Some(self.query.chars().count().saturating_div(4)),
102
2
            Typos::Fixed(n) => Some(n),
103
        }
104
573
    }
105
106
    #[allow(deprecated)]
107
570
    pub fn build(self) -> FuzzyEngine {
108
        #[allow(unused_mut)]
109
570
        let mut algorithm = self.algorithm;
110
570
        let max_typos = self.effective_max_typos();
111
570
        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
5
            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
570
        FuzzyEngine {
152
570
            matcher,
153
570
            query: self.query,
154
570
            rank_builder: self.rank_builder,
155
570
        }
156
570
    }
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
573
    pub fn builder() -> FuzzyEngineBuilder {
170
573
        FuzzyEngineBuilder::default()
171
573
    }
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]
@@ -8,4 +8,4 @@
 
  Branch (202:16): [True: 28, False: 7]
 
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
11
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
229
11
        write!(f, "(Fuzzy: {})", self.query)
230
11
    }
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 +
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 26173add..4b318a8c 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-08-22 15:34

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]
+

Coverage Report

Created: 2026-09-01 09:15

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
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 d45d3e03..e4f2d078 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-08-22 15:34

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-01 09:15

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 373bb67d..59a1f1d9 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-08-22 15:34

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-01 09:15

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
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
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
66
    fn create_engine_with_case(&self, query: &str, case: crate::CaseMatching) -> Box<dyn MatchEngine> {
137
        // Check if the query contains the delimiter
138
66
        if let Some(
delimiter_pos32
) = query.find(self.delimiter) {
  Branch (138:16): [True: 31, False: 33]
 
  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
34
            self.inner.create_engine_with_case(query, case)
150
        }
151
66
    }
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 c07a6af2..43479854 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,4 +1,4 @@ -

Coverage Report

Created: 2026-08-22 15:34

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]
+

Coverage Report

Created: 2026-09-01 09:15

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
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]
@@ -12,6 +12,6 @@
   Branch (80:45): [True: 3, False: 1]
 
81
25
        let last_byte_orig_pos = byte_mapping[normalized_end - 1];
82
        // Find the end of the character at this position in the original string
83
25
        original_str[last_byte_orig_pos..]
84
25
            .chars()
85
25
            .next()
86
25
            .map_or(original_str.len(), |c| last_byte_orig_pos + c.len_utf8())
87
63
    } else if normalized_end >= byte_mapping.len() {
  Branch (87:15): [True: 0, False: 61]
 
  Branch (87:15): [True: 1, False: 1]
-
88
1
        original_str.len()
89
    } else {
90
62
        orig_start
91
    };
92
93
88
    (orig_start, orig_end)
94
90
}
95
96
92
pub fn regex_match(choice: &str, pattern: Option<&Regex>) -> Option<(usize, usize)> {
97
92
    let 
pat91
= pattern
?1
;
98
91
    let 
mat58
= pat.find(choice)
?33
;
99
58
    Some((mat.start(), mat.end()))
100
92
}
101
102
55
pub fn contains_upper(string: &str) -> bool {
103
148
    for ch in 
string55
.
chars55
() {
104
148
        if ch.is_uppercase() {
  Branch (104:12): [True: 0, False: 47]
+
88
1
        original_str.len()
89
    } else {
90
62
        orig_start
91
    };
92
93
88
    (orig_start, orig_end)
94
90
}
95
96
92
pub fn regex_match(choice: &str, pattern: Option<&Regex>) -> Option<(usize, usize)> {
97
92
    let 
pat91
= pattern
?1
;
98
91
    let 
mat58
= pat.find(choice)
?33
;
99
58
    Some((mat.start(), mat.end()))
100
92
}
101
102
56
pub fn contains_upper(string: &str) -> bool {
103
151
    for ch in 
string56
.
chars56
() {
104
151
        if ch.is_uppercase() {
  Branch (104:12): [True: 0, False: 50]
 
  Branch (104:12): [True: 3, False: 98]
-
105
3
            return true;
106
145
        }
107
    }
108
52
    false
109
55
}
110
111
#[cfg(test)]
112
#[cfg_attr(coverage, coverage(off))]
113
mod tests {
114
    use super::*;
115
116
    #[test]
117
    fn contains_upper_detects_case() {
118
        assert!(contains_upper("heLlo"));
119
        assert!(contains_upper("ABC"));
120
        assert!(!contains_upper("hello"));
121
        assert!(!contains_upper("123 -_"));
122
    }
123
124
    #[test]
125
    fn regex_match_finds_and_misses() {
126
        let re = Regex::new("ba.").unwrap();
127
        assert_eq!(regex_match("foobar", Some(&re)), Some((3, 6)));
128
        assert_eq!(regex_match("nope", Some(&re)), None);
129
        assert_eq!(regex_match("foobar", None), None);
130
    }
131
132
    #[test]
133
    fn byte_range_empty_mapping_returns_zero() {
134
        // Empty mapping or out-of-range start short-circuits to (0, 0).
135
        assert_eq!(map_byte_range_to_original(0, 2, &[], "abc"), (0, 0));
136
        assert_eq!(map_byte_range_to_original(5, 6, &[0, 1, 2], "abc"), (0, 0));
137
    }
138
139
    #[test]
140
    fn byte_range_end_past_mapping_uses_string_len() {
141
        // normalized_end beyond the mapping length clamps to the original length.
142
        assert_eq!(map_byte_range_to_original(0, 5, &[0, 1, 2], "abc"), (0, 3));
143
    }
144
145
    #[test]
146
    fn byte_range_zero_end_falls_back_to_start() {
147
        // normalized_end == 0 yields an empty range anchored at orig_start.
148
        assert_eq!(map_byte_range_to_original(1, 0, &[0, 1, 2], "abc"), (1, 1));
149
    }
150
151
    #[test]
152
    fn byte_range_maps_normal_range() {
153
        // A normal in-bounds range maps to the byte span of the original char.
154
        assert_eq!(map_byte_range_to_original(0, 2, &[0, 1, 2], "abc"), (0, 2));
155
    }
156
}
\ No newline at end of file +
105
3
            return true;
106
148
        }
107
    }
108
53
    false
109
56
}
110
111
#[cfg(test)]
112
#[cfg_attr(coverage, coverage(off))]
113
mod tests {
114
    use super::*;
115
116
    #[test]
117
    fn contains_upper_detects_case() {
118
        assert!(contains_upper("heLlo"));
119
        assert!(contains_upper("ABC"));
120
        assert!(!contains_upper("hello"));
121
        assert!(!contains_upper("123 -_"));
122
    }
123
124
    #[test]
125
    fn regex_match_finds_and_misses() {
126
        let re = Regex::new("ba.").unwrap();
127
        assert_eq!(regex_match("foobar", Some(&re)), Some((3, 6)));
128
        assert_eq!(regex_match("nope", Some(&re)), None);
129
        assert_eq!(regex_match("foobar", None), None);
130
    }
131
132
    #[test]
133
    fn byte_range_empty_mapping_returns_zero() {
134
        // Empty mapping or out-of-range start short-circuits to (0, 0).
135
        assert_eq!(map_byte_range_to_original(0, 2, &[], "abc"), (0, 0));
136
        assert_eq!(map_byte_range_to_original(5, 6, &[0, 1, 2], "abc"), (0, 0));
137
    }
138
139
    #[test]
140
    fn byte_range_end_past_mapping_uses_string_len() {
141
        // normalized_end beyond the mapping length clamps to the original length.
142
        assert_eq!(map_byte_range_to_original(0, 5, &[0, 1, 2], "abc"), (0, 3));
143
    }
144
145
    #[test]
146
    fn byte_range_zero_end_falls_back_to_start() {
147
        // normalized_end == 0 yields an empty range anchored at orig_start.
148
        assert_eq!(map_byte_range_to_original(1, 0, &[0, 1, 2], "abc"), (1, 1));
149
    }
150
151
    #[test]
152
    fn byte_range_maps_normal_range() {
153
        // A normal in-bounds range maps to the byte span of the original char.
154
        assert_eq!(map_byte_range_to_original(0, 2, &[0, 1, 2], "abc"), (0, 2));
155
    }
156
}
\ No newline at end of file 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 c95957a0..c1f0f4ed 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,33 +1,33 @@ -

Coverage Report

Created: 2026-08-22 15:34

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
65
    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
97
fn parse_index(s: &str) -> i32 {
30
97
    s.parse()
31
97
        .unwrap_or(if s.starts_with('-') { 
i32::MIN22
} else {
i32::MAX75
})
  Branch (31:23): [True: 12, False: 54]
+

Coverage Report

Created: 2026-09-01 09:15

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
65
    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
96
fn parse_index(s: &str) -> i32 {
30
96
    s.parse()
31
96
        .unwrap_or(if s.starts_with('-') { 
i32::MIN22
} else {
i32::MAX74
})
  Branch (31:23): [True: 12, False: 53]
 
  Branch (31:23): [True: 10, False: 21]
-
32
97
}
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
96
    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
96
        let opt_caps = FIELD_RANGE.captures(range);
42
96
        if let Some(
caps89
) = opt_caps {
  Branch (42:16): [True: 60, False: 0]
+
32
96
}
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
96
    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
96
        let opt_caps = FIELD_RANGE.captures(range);
42
96
        if let Some(
caps89
) = opt_caps {
  Branch (42:16): [True: 60, False: 0]
 
  Branch (42:16): [True: 29, False: 7]
-
43
89
            let opt_left = caps.name("left").map(|s| 
parse_index81
(
s.as_str()81
));
44
89
            let opt_right = caps.name("right").map(|s| 
parse_index16
(
s.as_str()16
));
45
89
            let opt_sep = caps.name("sep").map(|s| 
s.as_str()26
.
to_string26
());
46
47
89
            match (opt_left, opt_right) {
48
1
                (None, None) => Some(RightInf(0)),
49
72
                (Some(left), None) => {
50
72
                    match opt_sep {
51
63
                        None => Some(Single(left)),      // 1
52
9
                        Some(_) => Some(RightInf(left)), // 1..
53
                    }
54
                }
55
7
                (None, Some(right)) => {
56
7
                    match opt_sep {
57
0
                        None => Some(Single(right)),     // 1 (should not happen)
58
7
                        Some(_) => Some(LeftInf(right)), // ..1 (should not happen)
59
                    }
60
                }
61
9
                (Some(left), Some(right)) => Some(Both(left, right)), // 1..3
62
            }
63
        } else {
64
7
            None
65
        }
66
96
    }
67
68
    /// Converts a field range to an index pair (left, right).
69
    ///
70
    /// For example, 1..3 => (0, 4). Note that field range is inclusive while
71
    /// the output index will exclude the right end.
72
    #[must_use]
73
227
    pub fn to_index_pair(&self, length: usize) -> Option<(usize, usize)> {
74
        use self::FieldRange::{Both, LeftInf, RightInf, Single};
75
227
        match *self {
76
108
            Single(num) => {
77
108
                let num = FieldRange::translate_neg(num, length);
78
108
                if num == 0 || 
num > length97
{
  Branch (78:20): [True: 3, False: 54]
+
43
89
            let opt_left = caps.name("left").map(|s| 
parse_index80
(
s.as_str()80
));
44
89
            let opt_right = caps.name("right").map(|s| 
parse_index16
(
s.as_str()16
));
45
89
            let opt_sep = caps.name("sep").map(|s| 
s.as_str()26
.
to_string26
());
46
47
89
            match (opt_left, opt_right) {
48
1
                (None, None) => Some(RightInf(0)),
49
72
                (Some(left), None) => {
50
72
                    match opt_sep {
51
63
                        None => Some(Single(left)),      // 1
52
9
                        Some(_) => Some(RightInf(left)), // 1..
53
                    }
54
                }
55
8
                (None, Some(right)) => {
56
8
                    match opt_sep {
57
0
                        None => Some(Single(right)),     // 1 (should not happen)
58
8
                        Some(_) => Some(LeftInf(right)), // ..1 (should not happen)
59
                    }
60
                }
61
8
                (Some(left), Some(right)) => Some(Both(left, right)), // 1..3
62
            }
63
        } else {
64
7
            None
65
        }
66
96
    }
67
68
    /// Converts a field range to an index pair (left, right).
69
    ///
70
    /// For example, 1..3 => (0, 4). Note that field range is inclusive while
71
    /// the output index will exclude the right end.
72
    #[must_use]
73
227
    pub fn to_index_pair(&self, length: usize) -> Option<(usize, usize)> {
74
        use self::FieldRange::{Both, LeftInf, RightInf, Single};
75
227
        match *self {
76
108
            Single(num) => {
77
108
                let num = FieldRange::translate_neg(num, length);
78
108
                if num == 0 || 
num > length97
{
  Branch (78:20): [True: 3, False: 54]
   Branch (78:32): [True: 5, False: 49]
 
  Branch (78:20): [True: 8, False: 43]
   Branch (78:32): [True: 7, False: 36]
-
79
23
                    None
80
                } else {
81
85
                    Some((num - 1, num))
82
                }
83
            }
84
32
            LeftInf(right) => {
85
32
                let right = FieldRange::translate_neg(right, length);
86
32
                if length == 0 || 
right == 031
{
  Branch (86:20): [True: 0, False: 2]
-  Branch (86:35): [True: 0, False: 2]
+
79
23
                    None
80
                } else {
81
85
                    Some((num - 1, num))
82
                }
83
            }
84
33
            LeftInf(right) => {
85
33
                let right = FieldRange::translate_neg(right, length);
86
33
                if length == 0 || 
right == 032
{
  Branch (86:20): [True: 0, False: 3]
+  Branch (86:35): [True: 0, False: 3]
 
  Branch (86:20): [True: 1, False: 29]
   Branch (86:35): [True: 6, False: 23]
-
87
7
                    None
88
                } else {
89
25
                    let right = min(right, length);
90
25
                    Some((0, right))
91
                }
92
            }
93
37
            RightInf(left) => {
94
37
                let left = FieldRange::translate_neg(left, length);
95
37
                if length == 0 || 
left > length36
{
  Branch (95:20): [True: 0, False: 5]
+
87
7
                    None
88
                } else {
89
26
                    let right = min(right, length);
90
26
                    Some((0, right))
91
                }
92
            }
93
37
            RightInf(left) => {
94
37
                let left = FieldRange::translate_neg(left, length);
95
37
                if length == 0 || 
left > length36
{
  Branch (95:20): [True: 0, False: 5]
   Branch (95:35): [True: 0, False: 5]
 
  Branch (95:20): [True: 1, False: 31]
   Branch (95:35): [True: 4, False: 27]
-
96
5
                    None
97
                } else {
98
32
                    let left = max(left, 1);
99
32
                    Some((left - 1, length))
100
                }
101
            }
102
50
            Both(left, right) => {
103
50
                let left = FieldRange::translate_neg(left, length);
104
50
                let right = FieldRange::translate_neg(right, length);
105
50
                if length == 0 || 
right == 049
||
left > right43
||
left > length38
{
  Branch (105:20): [True: 0, False: 6]
-  Branch (105:35): [True: 0, False: 6]
-  Branch (105:49): [True: 2, False: 4]
-  Branch (105:65): [True: 0, False: 4]
+
96
5
                    None
97
                } else {
98
32
                    let left = max(left, 1);
99
32
                    Some((left - 1, length))
100
                }
101
            }
102
49
            Both(left, right) => {
103
49
                let left = FieldRange::translate_neg(left, length);
104
49
                let right = FieldRange::translate_neg(right, length);
105
49
                if length == 0 || 
right == 048
||
left > right42
||
left > length37
{
  Branch (105:20): [True: 0, False: 5]
+  Branch (105:35): [True: 0, False: 5]
+  Branch (105:49): [True: 2, False: 3]
+  Branch (105:65): [True: 0, False: 3]
 
  Branch (105:20): [True: 1, False: 43]
   Branch (105:35): [True: 6, False: 37]
   Branch (105:49): [True: 3, False: 34]
   Branch (105:65): [True: 2, False: 32]
-
106
14
                    None
107
                } else {
108
36
                    Some((max(left, 1) - 1, min(right, length)))
109
                }
110
            }
111
        }
112
227
    }
113
114
277
    fn translate_neg(idx: i32, length: usize) -> usize {
115
277
        let len = i32::try_from(length).unwrap_or(i32::MAX);
116
277
        let idx = if idx < 0 { 
idx + len + 174
} else {
idx203
};
  Branch (116:22): [True: 12, False: 64]
+
106
14
                    None
107
                } else {
108
35
                    Some((max(left, 1) - 1, min(right, length)))
109
                }
110
            }
111
        }
112
227
    }
113
114
276
    fn translate_neg(idx: i32, length: usize) -> usize {
115
276
        let len = i32::try_from(length).unwrap_or(i32::MAX);
116
276
        let idx = if idx < 0 { 
idx + len + 174
} else {
idx202
};
  Branch (116:22): [True: 12, False: 63]
 
  Branch (116:22): [True: 62, False: 139]
-
117
277
        max(0, idx).unsigned_abs() as usize
118
277
    }
119
}
120
121
// ("|", "a|b||c") -> [(0, 2), (2, 4), (4, 5), (5, 6)]
122
// explain: split to ["a|", "b|", "|", "c"]
123
144
fn get_ranges_by_delimiter(delimiter: &Regex, text: &str) -> Vec<(usize, usize)> {
124
144
    let mut ranges = Vec::new();
125
144
    let mut last = 0;
126
382
    for mat in 
delimiter144
.
find_iter144
(
text144
) {
127
382
        ranges.push((last, mat.start()));
128
382
        last = mat.end();
129
382
    }
130
144
    ranges.push((last, text.len()));
131
144
    ranges
132
144
}
133
134
/// Extracts a substring from text based on a field range and delimiter.
135
///
136
/// For example, with delimiter = `Regex::new(",").unwrap()`, text "a,b,c", and field Single(2),
137
/// this returns "b". Note that this is different from `to_index_pair`, it uses delimiters.
138
#[must_use]
139
74
pub fn get_string_by_field<'a>(delimiter: &Regex, text: &'a str, field: &FieldRange) -> Option<&'a str> {
140
74
    let ranges = get_ranges_by_delimiter(delimiter, text);
141
142
74
    if let Some((
start60
,
stop60
)) = field.to_index_pair(ranges.len()) {
  Branch (142:12): [True: 7, False: 2]
+
117
276
        max(0, idx).unsigned_abs() as usize
118
276
    }
119
}
120
121
// ("|", "a|b||c") -> [(0, 2), (2, 4), (4, 5), (5, 6)]
122
// explain: split to ["a|", "b|", "|", "c"]
123
144
fn get_ranges_by_delimiter(delimiter: &Regex, text: &str) -> Vec<(usize, usize)> {
124
144
    let mut ranges = Vec::new();
125
144
    let mut last = 0;
126
382
    for mat in 
delimiter144
.
find_iter144
(
text144
) {
127
382
        ranges.push((last, mat.start()));
128
382
        last = mat.end();
129
382
    }
130
144
    ranges.push((last, text.len()));
131
144
    ranges
132
144
}
133
134
/// Extracts a substring from text based on a field range and delimiter.
135
///
136
/// For example, with delimiter = `Regex::new(",").unwrap()`, text "a,b,c", and field Single(2),
137
/// this returns "b". Note that this is different from `to_index_pair`, it uses delimiters.
138
#[must_use]
139
74
pub fn get_string_by_field<'a>(delimiter: &Regex, text: &'a str, field: &FieldRange) -> Option<&'a str> {
140
74
    let ranges = get_ranges_by_delimiter(delimiter, text);
141
142
74
    if let Some((
start60
,
stop60
)) = field.to_index_pair(ranges.len()) {
  Branch (142:12): [True: 7, False: 2]
 
  Branch (142:12): [True: 53, False: 12]
-
143
60
        let &(begin, _) = &ranges[start];
144
60
        let &(_, end) = ranges.get(stop - 1).unwrap_or(&(text.len(), 0));
145
60
        Some(&text[begin..end])
146
    } else {
147
14
        None
148
    }
149
74
}
150
151
/// Extracts a substring from text by parsing a range string and using a delimiter
152
#[must_use]
153
4
pub fn get_string_by_range<'a>(delimiter: &Regex, text: &'a str, range: &str) -> Option<&'a str> {
154
4
    FieldRange::from_str(range).and_then(|field| 
get_string_by_field3
(
delimiter3
,
text3
,
&field3
))
155
4
}
156
157
/// Parses matching fields and returns a vector of byte ranges.
158
///
159
/// Given delimiter `,`, text: "a,b,c", and fields &[Single(2), LeftInf(2)],
160
/// this returns [(2, 4), (0, 4)].
161
#[must_use]
162
44
pub fn parse_matching_fields(delimiter: &Regex, text: &str, fields: &[FieldRange]) -> Vec<(usize, usize)> {
163
44
    let ranges = get_ranges_by_delimiter(delimiter, text);
164
165
44
    let mut ret = Vec::new();
166
63
    for field in 
fields44
{
167
63
        if let Some((
start55
,
stop55
)) = field.to_index_pair(ranges.len()) {
  Branch (167:16): [True: 34, False: 5]
+
143
60
        let &(begin, _) = &ranges[start];
144
60
        let &(_, end) = ranges.get(stop - 1).unwrap_or(&(text.len(), 0));
145
60
        Some(&text[begin..end])
146
    } else {
147
14
        None
148
    }
149
74
}
150
151
/// Extracts a substring from text by parsing a range string and using a delimiter
152
#[must_use]
153
4
pub fn get_string_by_range<'a>(delimiter: &Regex, text: &'a str, range: &str) -> Option<&'a str> {
154
4
    FieldRange::from_str(range).and_then(|field| 
get_string_by_field3
(
delimiter3
,
text3
,
&field3
))
155
4
}
156
157
/// Parses matching fields and returns a vector of byte ranges.
158
///
159
/// Given delimiter `,`, text: "a,b,c", and fields &[Single(2), LeftInf(2)],
160
/// this returns [(2, 4), (0, 4)].
161
#[must_use]
162
43
pub fn parse_matching_fields(delimiter: &Regex, text: &str, fields: &[FieldRange]) -> Vec<(usize, usize)> {
163
43
    let ranges = get_ranges_by_delimiter(delimiter, text);
164
165
43
    let mut ret = Vec::new();
166
62
    for field in 
fields43
{
167
62
        if let Some((
start54
,
stop54
)) = field.to_index_pair(ranges.len()) {
  Branch (167:16): [True: 33, False: 5]
 
  Branch (167:16): [True: 21, False: 3]
-
168
55
            let &(begin, _) = &ranges[start];
169
55
            let &(end, _) = ranges.get(stop).unwrap_or(&(text.len(), 0));
170
55
            ret.push((begin, end));
171
55
        
}8
172
    }
173
44
    ret
174
44
}
175
176
/// Extracts the specified fields from text using the delimiter
177
#[must_use]
178
26
pub fn parse_transform_fields(delimiter: &Regex, text: &str, fields: &[FieldRange]) -> String {
179
26
    let ranges = get_ranges_by_delimiter(delimiter, text);
180
181
26
    let mut ret = String::new();
182
39
    for field in 
fields26
{
183
39
        if let Some((
start33
,
stop33
)) = field.to_index_pair(ranges.len()) {
  Branch (183:16): [True: 19, False: 3]
+
168
54
            let &(begin, _) = &ranges[start];
169
54
            let &(end, _) = ranges.get(stop).unwrap_or(&(text.len(), 0));
170
54
            ret.push((begin, end));
171
54
        
}8
172
    }
173
43
    ret
174
43
}
175
176
/// Extracts the specified fields from text using the delimiter
177
#[must_use]
178
27
pub fn parse_transform_fields(delimiter: &Regex, text: &str, fields: &[FieldRange]) -> String {
179
27
    let ranges = get_ranges_by_delimiter(delimiter, text);
180
181
27
    let mut ret = String::new();
182
40
    for field in 
fields27
{
183
40
        if let Some((
start34
,
stop34
)) = field.to_index_pair(ranges.len()) {
  Branch (183:16): [True: 20, False: 3]
 
  Branch (183:16): [True: 14, False: 3]
-
184
33
            let &(begin, _) = &ranges[start];
185
33
            let &(end, _) = ranges.get(stop).unwrap_or(&(text.len(), 0));
186
33
            ret.push_str(&text[begin..end]);
187
33
        
}6
188
    }
189
26
    ret
190
26
}
191
192
#[cfg(test)]
193
#[path = "field_tests.rs"]
194
mod test;
\ No newline at end of file +
184
34
            let &(begin, _) = &ranges[start];
185
34
            let &(end, _) = ranges.get(stop).unwrap_or(&(text.len(), 0));
186
34
            ret.push_str(&text[begin..end]);
187
34
        
}6
188
    }
189
27
    ret
190
27
}
191
192
#[cfg(test)]
193
#[path = "field_tests.rs"]
194
mod test;
\ No newline at end of file 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 c8bdb9e8..0944d7c6 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-08-22 15:34

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-01 09:15

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::new355
(
SWMatrix::zero355
(
n + 1355
,
mcols355
)))
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::new323
(
Vec::new323
()));
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 9f39a857..90e3bf2b 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,4 +1,4 @@
-

Coverage Report

Created: 2026-08-22 15:34

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-01 09:15

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
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]
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 4f608222..97bc821f 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-08-22 15:34

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-01 09:15

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]
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 adb960ee..fe721d25 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-08-22 15:34

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-01 09:15

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 af677148..1be64797 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-08-22 15:34

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
369
    pub fn zero(rows: usize, cols: usize) -> Self {
78
369
        let mut res = SWMatrix::default();
79
369
        res.resize(rows, cols);
80
369
        res
81
369
    }
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: 343, False: 50.5k]
+

Coverage Report

Created: 2026-09-01 09:15

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
425
            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 adf83d88..6f26f17e 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-08-22 15:34

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]
+

Coverage Report

Created: 2026-09-01 09:15

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
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
540
    pub fn new(case: CaseMatching, allow_typos: bool, use_last_match: bool) -> Self {
88
540
        Self {
89
540
            case,
90
540
            allow_typos,
91
540
            use_last_match,
92
540
            ..Default::default()
93
540
        }
94
540
    }
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)
?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::new328
(
Vec::new328
())).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 af4c882a..a333ff14 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-08-22 15:34

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-01 09:15

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 ad0839ae..34e26d9c 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-08-22 15:34

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]
+

Coverage Report

Created: 2026-09-01 09:15

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 8b8ab6a1..4e05dd78 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-08-22 15:34

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_from2
(
x2
).
unwrap_or2
(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-01 09:15

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
14
    fn default() -> Self {
22
14
        Self {
23
14
            config: Config {
24
14
                casing: CaseMatching::Respect.into(),
25
14
                ..Config::default()
26
14
            },
27
14
        }
28
14
    }
29
}
30
31
impl FrizbeeMatcher {
32
    /// Set the max typos to use
33
    #[must_use]
34
6
    pub fn max_typos(mut self, typos: Option<usize>) -> Self {
35
6
        self.config.max_typos = Some(typos.map_or(0, |x| 
u16::try_from3
(
x3
).
unwrap_or3
(u16::MAX)));
36
6
        self
37
6
    }
38
39
    /// Set the case matching strategy
40
    #[must_use]
41
11
    pub fn case(mut self, case: CaseMatching) -> Self {
42
11
        self.config.casing = case.into();
43
11
        self
44
11
    }
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
25
    fn from(case: CaseMatching) -> Self {
80
25
        match case {
81
16
            CaseMatching::Respect => frizbee::CaseMatching::Respect,
82
2
            CaseMatching::Ignore => frizbee::CaseMatching::Ignore,
83
7
            CaseMatching::Smart => frizbee::CaseMatching::Smart,
84
        }
85
25
    }
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 90e3a333..84f2764e 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-08-22 15:34

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-01 09:15

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 8ee15ca8..62817d56 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-08-22 15:34

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-01 09:15

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 082f9ae7..51ff1e3c 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,4 +1,4 @@ -

Coverage Report

Created: 2026-08-22 15:34

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]
+

Coverage Report

Created: 2026-09-01 09:15

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]
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 2c299cbc..e19d7d06 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-08-22 15:34

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-01 09:15

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 d98b215d..5f3f0856 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,36 +1,36 @@
-

Coverage Report

Created: 2026-08-22 15:34

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
51.9k
    pub fn new(
65
51.9k
        orig_text: &str,
66
51.9k
        ansi_enabled: bool,
67
51.9k
        trans_fields: &[FieldRange],
68
51.9k
        matching_fields: &[FieldRange],
69
51.9k
        delimiter: &Regex,
70
51.9k
    ) -> Self {
71
51.9k
        let using_transform_fields = !trans_fields.is_empty();
72
51.9k
        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
51.9k
        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.8k]
+

Coverage Report

Created: 2026-09-01 09:15

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
51.9k
    pub fn new(
65
51.9k
        orig_text: &str,
66
51.9k
        ansi_enabled: bool,
67
51.9k
        trans_fields: &[FieldRange],
68
51.9k
        matching_fields: &[FieldRange],
69
51.9k
        delimiter: &Regex,
70
51.9k
    ) -> Self {
71
51.9k
        let using_transform_fields = !trans_fields.is_empty();
72
51.9k
        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
51.9k
        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
22
                let transformed = parse_transform_fields(delimiter, &escape_ansi(orig_text), trans_fields);
91
22
                (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.8k]
 
  Branch (93:31): [True: 1, False: 39]
-
94
51.9k
            (false, true | false) => (None, Box::from(orig_text)),
95
        };
96
97
        // Keep track of whether we have null bytes for special handling
98
51.9k
        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
51.9k
        if has_null_bytes && 
orig_text6
.
is_none6
() {
  Branch (101:12): [True: 5, False: 51.8k]
+
94
51.9k
            (false, true | false) => (None, Box::from(orig_text)),
95
        };
96
97
        // Keep track of whether we have null bytes for special handling
98
51.9k
        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
51.9k
        if has_null_bytes && 
orig_text6
.
is_none6
() {
  Branch (101:12): [True: 5, False: 51.9k]
   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
51.9k
        }
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
51.9k
        if has_null_bytes {
  Branch (108:12): [True: 5, False: 51.8k]
+
102
5
            orig_text = Some(temp_text.to_string());
103
51.9k
        }
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
51.9k
        if has_null_bytes {
  Branch (108:12): [True: 5, False: 51.9k]
 
  Branch (108:12): [True: 1, False: 53]
-
109
6
            temp_text = temp_text.to_string().replace('\0', "").into_boxed_str();
110
51.9k
        }
111
112
51.9k
        let (stripped_text, ansi_info) = if ansi_enabled && 
contains_ansi24
{
  Branch (112:45): [True: 11, False: 51.8k]
+
109
6
            temp_text = temp_text.to_string().replace('\0', "").into_boxed_str();
110
51.9k
        }
111
112
51.9k
        let (stripped_text, ansi_info) = if ansi_enabled && 
contains_ansi24
{
  Branch (112:45): [True: 11, False: 51.9k]
   Branch (112:61): [True: 8, False: 3]
 
  Branch (112:45): [True: 13, False: 41]
   Branch (112:61): [True: 13, False: 0]
-
113
21
            let (stripped, info) = strip_ansi(&temp_text);
114
21
            (Some(stripped), Some(info))
115
        } else {
116
51.9k
            (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
51.9k
        let matching_ranges = if matching_fields.is_empty() {
  Branch (121:34): [True: 51.8k, False: 24]
+
113
21
            let (stripped, info) = strip_ansi(&temp_text);
114
21
            (Some(stripped), Some(info))
115
        } else {
116
51.9k
            (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
51.9k
        let matching_ranges = if matching_fields.is_empty() {
  Branch (121:34): [True: 51.8k, False: 23]
 
  Branch (121:34): [True: 52, False: 2]
-
122
51.9k
            None
123
        } else {
124
            // Use stripped text for matching ranges when ANSI is enabled
125
26
            let text_for_matching = if let Some(
stripped1
) = stripped_text.as_ref() {
  Branch (125:44): [True: 0, False: 24]
+
122
51.9k
            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]
-
126
1
                stripped
127
            } else {
128
25
                temp_text.as_ref()
129
            };
130
131
            // Parse the original text with null bytes to determine field boundaries
132
            // Then extract those fields, strip null bytes, and recalculate positions
133
            // When has_null_bytes is true, orig_text was set to Some above, so unwrap is safe.
134
26
            let orig_text_for_fields = if has_null_bytes {
  Branch (134:43): [True: 1, False: 23]
+
126
1
                stripped
127
            } else {
128
24
                temp_text.as_ref()
129
            };
130
131
            // Parse the original text with null bytes to determine field boundaries
132
            // Then extract those fields, strip null bytes, and recalculate positions
133
            // When has_null_bytes is true, orig_text was set to Some above, so unwrap is safe.
134
25
            let orig_text_for_fields = if has_null_bytes {
  Branch (134:43): [True: 1, False: 22]
 
  Branch (134:43): [True: 1, False: 1]
-
135
2
                orig_text.as_deref().unwrap_or(text_for_matching)
136
            } else {
137
24
                text_for_matching
138
            };
139
140
26
            if has_null_bytes {
  Branch (140:16): [True: 1, False: 23]
+
135
2
                orig_text.as_deref().unwrap_or(text_for_matching)
136
            } else {
137
23
                text_for_matching
138
            };
139
140
25
            if has_null_bytes {
  Branch (140:16): [True: 1, False: 22]
 
  Branch (140:16): [True: 1, False: 1]
 
141
                // Extract each field from the original text (with null bytes)
142
                // then strip null bytes and build new ranges in the cleaned text
143
2
                let mut adjusted_ranges = Vec::new();
144
145
2
                for field in matching_fields {
146
                    // Get the field text from original (with null bytes)
147
2
                    if let Some(field_text) = crate::field::get_string_by_field(delimiter, orig_text_for_fields, field)
  Branch (147:28): [True: 1, False: 0]
 
  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
24
                Some(parse_matching_fields(delimiter, text_for_matching, matching_fields))
161
            }
162
        };
163
164
51.9k
        let metadata =
165
51.9k
            if orig_text.is_some() || 
stripped_text51.9k
.
is_some51.9k
() ||
ansi_info51.8k
.
is_some51.8k
() ||
matching_ranges51.8k
.
is_some51.8k
() {
  Branch (165:16): [True: 24, False: 51.8k]
+
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
51.9k
        let metadata =
165
51.9k
            if orig_text.is_some() || 
stripped_text51.9k
.
is_some51.9k
() ||
ansi_info51.9k
.
is_some51.9k
() ||
matching_ranges51.9k
.
is_some51.9k
() {
  Branch (165:16): [True: 25, False: 51.8k]
   Branch (165:39): [True: 8, False: 51.8k]
   Branch (165:66): [True: 0, False: 51.8k]
-  Branch (165:89): [True: 23, False: 51.8k]
+  Branch (165:89): [True: 22, False: 51.8k]
 
  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
70
                Some(Box::new(DefaultSkimItemMetadata {
167
70
                    orig_text: orig_text.map(std::string::String::into_boxed_str),
168
70
                    stripped_text: stripped_text.map(std::string::String::into_boxed_str),
169
70
                    ansi_info,
170
70
                    matching_ranges,
171
70
                    hidden_ranges: None,
172
70
                    disabled: false,
173
70
                }))
174
            } else {
175
51.8k
                None
176
            };
177
178
51.9k
        DefaultSkimItem {
179
51.9k
            text: temp_text,
180
51.9k
            metadata,
181
51.9k
        }
182
51.9k
    }
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
51.9k
    pub fn hidden_fields(mut self, hidden_fields: &[FieldRange], delimiter: &Regex) -> Self {
195
51.9k
        if hidden_fields.is_empty() {
  Branch (195:12): [True: 51.8k, False: 9]
+
166
70
                Some(Box::new(DefaultSkimItemMetadata {
167
70
                    orig_text: orig_text.map(std::string::String::into_boxed_str),
168
70
                    stripped_text: stripped_text.map(std::string::String::into_boxed_str),
169
70
                    ansi_info,
170
70
                    matching_ranges,
171
70
                    hidden_ranges: None,
172
70
                    disabled: false,
173
70
                }))
174
            } else {
175
51.9k
                None
176
            };
177
178
51.9k
        DefaultSkimItem {
179
51.9k
            text: temp_text,
180
51.9k
            metadata,
181
51.9k
        }
182
51.9k
    }
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
51.9k
    pub fn hidden_fields(mut self, hidden_fields: &[FieldRange], delimiter: &Regex) -> Self {
195
51.9k
        if hidden_fields.is_empty() {
  Branch (195:12): [True: 51.9k, False: 9]
 
  Branch (195:12): [True: 36, False: 5]
 
196
51.9k
            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]
@@ -92,10 +92,10 @@
 
  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.28k
        } else if let Some(
hidden30
) = self.hidden_ranges() {
  Branch (510:23): [True: 28, False: 6.25k]
 
  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.25k
            context.to_line(Cow::Borrowed(&self.text))
526
        }
527
6.38k
    }
528
529
63.4k
    fn disabled(&self) -> bool {
530
63.4k
        self.metadata.as_ref().is_some_and(|x| x.disabled)
531
63.4k
    }
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.26k, False: 2.77k]
-
  Branch (560:15): [True: 392, False: 91]
-
561
6.66k
        if ch == '\x1b' {
  Branch (561:12): [True: 28, False: 6.24k]
-
  Branch (561:12): [True: 54, False: 338]
+
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.25k
            context.to_line(Cow::Borrowed(&self.text))
526
        }
527
6.38k
    }
528
529
63.4k
    fn disabled(&self) -> bool {
530
63.4k
        self.metadata.as_ref().is_some_and(|x| x.disabled)
531
63.4k
    }
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.46k
    while let Some((
byte_pos6.61k
,
ch6.61k
)) = chars.next() {
  Branch (560:15): [True: 6.23k, False: 2.76k]
+
  Branch (560:15): [True: 388, False: 89]
+
561
6.61k
        if ch == '\x1b' {
  Branch (561:12): [True: 28, False: 6.20k]
+
  Branch (561:12): [True: 54, False: 334]
 
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
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]
@@ -110,9 +110,9 @@
 
  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.53k
        } else {
611
6.53k
            result.push(ch);
612
6.53k
            index_mapping.push((byte_pos, char_idx));
613
6.53k
        }
614
6.61k
        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
25
fn escape_ansi(raw: &str) -> String {
625
265
    unsafe { 
String::from_utf8_unchecked25
(
raw25
.
bytes25
().
map25
(|b| if b == 27 {
b'?'6
} else {
b259
}).
collect25
()) }
  Branch (625:65): [True: 4, False: 233]
 
  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]
+
626
25
}
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]
 
640
4
            && start <= last.1
  Branch (640:16): [True: 0, False: 1]
 
  Branch (640:16): [True: 2, False: 1]
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 4d2619f7..ca455065 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,63 +1,63 @@
-

Coverage Report

Created: 2026-08-22 15:34

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
357
    pub fn from_options(options: &SkimOptions) -> Self {
60
        Self {
61
            buf_size: READ_BUFFER_SIZE,
62
357
            line_ending: if options.read0 { 
b'\0'4
} else {
b'\n'353
},
  Branch (62:29): [True: 3, False: 352]
+

Coverage Report

Created: 2026-09-01 09:15

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
879
    fn default() -> Self {
42
879
        Self {
43
879
            buf_size: READ_BUFFER_SIZE,
44
879
            line_ending: b'\n',
45
879
            use_ansi_color: false,
46
879
            transform_fields: Vec::new(),
47
879
            matching_fields: Vec::new(),
48
879
            hidden_fields: Vec::new(),
49
879
            delimiter: Regex::new(DELIMITER_STR).unwrap(),
50
879
            show_error: false,
51
879
            disable_pattern: None,
52
879
        }
53
879
    }
54
}
55
56
impl SkimItemReaderOption {
57
    /// Creates reader options from skim options
58
    #[must_use]
59
352
    pub fn from_options(options: &SkimOptions) -> Self {
60
        Self {
61
            buf_size: READ_BUFFER_SIZE,
62
352
            line_ending: if options.read0 { 
b'\0'4
} else {
b'\n'348
},
  Branch (62:29): [True: 3, False: 347]
 
  Branch (62:29): [True: 1, False: 1]
-
63
357
            use_ansi_color: options.ansi,
64
357
            transform_fields: options
65
357
                .with_nth
66
357
                .iter()
67
357
                .filter_map(|f| if 
f356
.
is_empty356
() {
None337
} else {
FieldRange::from_str19
(
f19
)
}356
)
  Branch (67:36): [True: 337, False: 19]
+
63
352
            use_ansi_color: options.ansi,
64
352
            transform_fields: options
65
352
                .with_nth
66
352
                .iter()
67
352
                .filter_map(|f| if 
f351
.
is_empty351
() {
None331
} else {
FieldRange::from_str20
(
f20
)
}351
)
  Branch (67:36): [True: 331, False: 20]
 
  Branch (67:36): [True: 0, False: 0]
-
68
357
                .collect(),
69
357
            matching_fields: options
70
357
                .nth
71
357
                .iter()
72
358
                .
filter_map357
(|f| if f.is_empty() {
None335
} else {
FieldRange::from_str23
(
f23
) })
  Branch (72:36): [True: 335, False: 23]
+
68
352
                .collect(),
69
352
            matching_fields: options
70
352
                .nth
71
352
                .iter()
72
353
                .
filter_map352
(|f| if f.is_empty() {
None331
} else {
FieldRange::from_str22
(
f22
) })
  Branch (72:36): [True: 331, False: 22]
 
  Branch (72:36): [True: 0, False: 0]
-
73
357
                .collect(),
74
357
            hidden_fields: options
75
357
                .hide_nth
76
357
                .iter()
77
357
                .filter_map(|f| if 
f356
.
is_empty356
() {
None346
} else {
FieldRange::from_str10
(
f10
)
}356
)
  Branch (77:36): [True: 346, False: 10]
+
73
352
                .collect(),
74
352
            hidden_fields: options
75
352
                .hide_nth
76
352
                .iter()
77
352
                .filter_map(|f| if 
f351
.
is_empty351
() {
None341
} else {
FieldRange::from_str10
(
f10
)
}351
)
  Branch (77:36): [True: 341, False: 10]
 
  Branch (77:36): [True: 0, False: 0]
-
78
357
                .collect(),
79
357
            delimiter: options.delimiter.clone(),
80
357
            show_error: options.show_cmd_error,
81
357
            disable_pattern: options.disable_pattern.clone(),
82
        }
83
357
    }
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
352
                .collect(),
79
352
            delimiter: options.delimiter.clone(),
80
352
            show_error: options.show_cmd_error,
81
352
            disable_pattern: options.disable_pattern.clone(),
82
        }
83
352
    }
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.24k
fn default_thread_pool() -> Arc<ThreadPool> {
202
1.24k
    let n = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
203
1.24k
    let (reader_threads, _) = crate::thread_pool::partition_threads(n);
204
1.24k
    Arc::new(ThreadPool::new(reader_threads))
205
1.24k
}
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
361
    pub fn new(option: SkimItemReaderOption) -> Self {
220
361
        Self {
221
361
            option: Arc::new(option),
222
361
            thread_pool: default_thread_pool(),
223
361
        }
224
361
    }
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
364
    pub fn of_bufread(&self, source: impl BufRead + Send + 'static) -> SkimItemReceiver {
252
364
        self.parallel_bufread(source, None, &Arc::new(AtomicUsize::new(0))).0
253
364
    }
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
436
    fn parallel_bufread(
288
436
        &self,
289
436
        source: impl BufRead + Send + 'static,
290
436
        child: Option<Child>,
291
436
        components_to_stop: &Arc<AtomicUsize>,
292
436
    ) -> (SkimItemReceiver, crate::prelude::Sender<i32>) {
293
436
        let (tx_item, rx_item): (SkimItemSender, SkimItemReceiver) = kanal::bounded(1024 * 1024);
294
436
        let option = self.option.clone();
295
436
        let pool = Arc::clone(&self.thread_pool);
296
297
436
        let num_threads = pool.num_threads();
298
436
        let (tx_chunks, rx_chunks) = kanal::bounded::<(usize, Vec<u8>)>(num_threads * 4);
299
436
        let (tx_results, rx_results) = kanal::bounded::<(usize, Vec<Arc<dyn SkimItem>>)>(num_threads * 4);
300
301
436
        let line_ending = option.line_ending;
302
303
        // Stage 1: I/O thread.
304
436
        Self::spawn_io_reader(source, tx_chunks, line_ending);
305
306
        // Stage 2: dispatcher thread — bridges the bounded channel to the pool.
307
436
        thread::spawn(move || {
308
847
            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: 13, False: 13]
-
  Branch (308:23): [True: 2, False: 2]
+
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.22k
fn default_thread_pool() -> Arc<ThreadPool> {
202
1.22k
    let n = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
203
1.22k
    let (reader_threads, _) = crate::thread_pool::partition_threads(n);
204
1.22k
    Arc::new(ThreadPool::new(reader_threads))
205
1.22k
}
206
207
impl Default for SkimItemReader {
208
871
    fn default() -> Self {
209
871
        Self {
210
871
            option: Arc::new(Default::default()),
211
871
            thread_pool: default_thread_pool(),
212
871
        }
213
871
    }
214
}
215
216
impl SkimItemReader {
217
    /// Creates a new item reader with the given options
218
    #[must_use]
219
356
    pub fn new(option: SkimItemReaderOption) -> Self {
220
356
        Self {
221
356
            option: Arc::new(option),
222
356
            thread_pool: default_thread_pool(),
223
356
        }
224
356
    }
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
359
    pub fn of_bufread(&self, source: impl BufRead + Send + 'static) -> SkimItemReceiver {
252
359
        self.parallel_bufread(source, None, &Arc::new(AtomicUsize::new(0))).0
253
359
    }
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
431
    fn parallel_bufread(
288
431
        &self,
289
431
        source: impl BufRead + Send + 'static,
290
431
        child: Option<Child>,
291
431
        components_to_stop: &Arc<AtomicUsize>,
292
431
    ) -> (SkimItemReceiver, crate::prelude::Sender<i32>) {
293
431
        let (tx_item, rx_item): (SkimItemSender, SkimItemReceiver) = kanal::bounded(1024 * 1024);
294
431
        let option = self.option.clone();
295
431
        let pool = Arc::clone(&self.thread_pool);
296
297
431
        let num_threads = pool.num_threads();
298
431
        let (tx_chunks, rx_chunks) = kanal::bounded::<(usize, Vec<u8>)>(num_threads * 4);
299
431
        let (tx_results, rx_results) = kanal::bounded::<(usize, Vec<Arc<dyn SkimItem>>)>(num_threads * 4);
300
301
431
        let line_ending = option.line_ending;
302
303
        // Stage 1: I/O thread.
304
431
        Self::spawn_io_reader(source, tx_chunks, line_ending);
305
306
        // Stage 2: dispatcher thread — bridges the bounded channel to the pool.
307
431
        thread::spawn(move || {
308
837
            while let Ok((
seq406
,
chunk406
)) = rx_chunks.recv() {
  Branch (308:23): [True: 52, False: 29]
+
  Branch (308:23): [True: 19, False: 68]
 
  Branch (308:23): [True: 115, False: 115]
 
  Branch (308:23): [True: 4, False: 3]
 
  Branch (308:23): [True: 9, False: 9]
+
  Branch (308:23): [True: 22, False: 22]
+
  Branch (308:23): [True: 12, False: 12]
 
  Branch (308:23): [True: 3, False: 3]
+
  Branch (308:23): [True: 1, False: 1]
 
  Branch (308:23): [True: 4, False: 4]
 
  Branch (308:23): [True: 13, False: 13]
 
  Branch (308:23): [True: 1, False: 1]
 
  Branch (308:23): [True: 5, False: 5]
-
  Branch (308:23): [True: 22, False: 22]
-
  Branch (308:23): [True: 1, False: 1]
-
  Branch (308:23): [True: 52, False: 29]
-
  Branch (308:23): [True: 36, False: 36]
+
  Branch (308:23): [True: 5, False: 5]
+
  Branch (308:23): [True: 34, False: 34]
+
  Branch (308:23): [True: 11, False: 11]
 
  Branch (308:23): [True: 12, False: 12]
+
  Branch (308:23): [True: 10, False: 10]
+
  Branch (308:23): [True: 2, False: 2]
+
  Branch (308:23): [True: 9, False: 9]
+
  Branch (308:23): [True: 24, False: 24]
+
  Branch (308:23): [True: 30, False: 30]
+
  Branch (308:23): [True: 2, False: 2]
 
  Branch (308:23): [True: 1, False: 1]
 
  Branch (308:23): [True: 6, False: 6]
-
  Branch (308:23): [True: 9, False: 9]
-
  Branch (308:23): [True: 10, False: 10]
-
  Branch (308:23): [True: 24, False: 24]
-
  Branch (308:23): [True: 11, False: 11]
-
  Branch (308:23): [True: 5, False: 5]
-
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
436
        });
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
436
        let (tx_pipeline_done, rx_pipeline_done) = kanal::bounded::<()>(0);
325
326
        // Stage 4: reorder thread.
327
436
        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
436
        let (tx_interrupt, rx_interrupt) = crate::prelude::bounded::<i32>(8);
335
436
        let components_to_stop_killer = components_to_stop.clone();
336
436
        components_to_stop.fetch_add(1, Ordering::SeqCst);
337
436
        thread::spawn(move || {
338
436
            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
547
                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: 36]
-
  Branch (345:20): [True: 0, False: 2]
-
  Branch (345:20): [True: 0, False: 14]
+
309
406
                let tx = tx_results.clone();
310
406
                let opt = option.clone();
311
406
                pool.spawn(move || {
312
406
                    let result = Self::process_chunk(seq, &chunk, &opt);
313
406
                    let _ = tx.send(result);
314
406
                });
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
431
        });
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
431
        let (tx_pipeline_done, rx_pipeline_done) = kanal::bounded::<()>(0);
325
326
        // Stage 4: reorder thread.
327
431
        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
431
        let (tx_interrupt, rx_interrupt) = crate::prelude::bounded::<i32>(8);
335
431
        let components_to_stop_killer = components_to_stop.clone();
336
431
        components_to_stop.fetch_add(1, Ordering::SeqCst);
337
431
        thread::spawn(move || {
338
431
            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
578
                if rx_interrupt.try_recv().is_ok_and(|v| 
v92
.
is_some92
()) {
  Branch (345:20): [True: 0, False: 78]
+
  Branch (345:20): [True: 0, False: 74]
+
  Branch (345:20): [True: 0, False: 152]
 
  Branch (345:20): [True: 0, False: 3]
-
  Branch (345:20): [True: 0, False: 159]
-
  Branch (345:20): [True: 0, False: 3]
-
  Branch (345:20): [True: 0, False: 15]
-
  Branch (345:20): [True: 0, False: 3]
-
  Branch (345:20): [True: 0, False: 4]
-
  Branch (345:20): [True: 0, False: 20]
-
  Branch (345:20): [True: 0, False: 2]
-
  Branch (345:20): [True: 0, False: 6]
-
  Branch (345:20): [True: 0, False: 26]
-
  Branch (345:20): [True: 0, False: 1]
-
  Branch (345:20): [True: 0, False: 38]
-
  Branch (345:20): [True: 0, False: 49]
-
  Branch (345:20): [True: 0, False: 13]
-
  Branch (345:20): [True: 0, False: 1]
-
  Branch (345:20): [True: 0, False: 7]
-
  Branch (345:20): [True: 0, False: 9]
 
  Branch (345:20): [True: 0, False: 10]
-
  Branch (345:20): [True: 0, False: 24]
-
  Branch (345:20): [True: 0, False: 11]
+
  Branch (345:20): [True: 0, False: 27]
+
  Branch (345:20): [True: 0, False: 12]
+
  Branch (345:20): [True: 0, False: 3]
+
  Branch (345:20): [True: 0, False: 1]
+
  Branch (345:20): [True: 0, False: 8]
+
  Branch (345:20): [True: 0, False: 19]
+
  Branch (345:20): [True: 0, False: 1]
 
  Branch (345:20): [True: 0, False: 5]
+
  Branch (345:20): [True: 0, False: 5]
+
  Branch (345:20): [True: 0, False: 60]
+
  Branch (345:20): [True: 0, False: 11]
+
  Branch (345:20): [True: 0, False: 15]
+
  Branch (345:20): [True: 0, False: 13]
+
  Branch (345:20): [True: 0, False: 2]
+
  Branch (345:20): [True: 0, False: 11]
+
  Branch (345:20): [True: 0, False: 24]
+
  Branch (345:20): [True: 0, False: 33]
+
  Branch (345:20): [True: 0, False: 2]
+
  Branch (345:20): [True: 0, False: 1]
+
  Branch (345:20): [True: 0, False: 8]
 
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]
@@ -83,57 +83,57 @@
 
  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
547
                }
353
                // Channel closed = reorder thread exited = pipeline drained.
354
547
                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
435
                        if let Some(
mut c72
) = 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: 13]
-
  Branch (358:32): [True: 0, False: 2]
+
348
0
                        let _ = c.kill();
349
0
                        let _ = c.wait();
350
0
                    }
351
0
                    break;
352
578
                }
353
                // Channel closed = reorder thread exited = pipeline drained.
354
578
                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
430
                        if let Some(
mut c72
) = child {
  Branch (358:32): [True: 0, False: 29]
+
  Branch (358:32): [True: 68, False: 0]
 
  Branch (358:32): [True: 0, False: 115]
 
  Branch (358:32): [True: 0, False: 3]
 
  Branch (358:32): [True: 0, False: 9]
+
  Branch (358:32): [True: 0, False: 22]
+
  Branch (358:32): [True: 0, False: 12]
 
  Branch (358:32): [True: 0, False: 3]
+
  Branch (358:32): [True: 0, False: 1]
 
  Branch (358:32): [True: 4, False: 0]
 
  Branch (358:32): [True: 0, False: 12]
 
  Branch (358:32): [True: 0, False: 1]
 
  Branch (358:32): [True: 0, False: 5]
-
  Branch (358:32): [True: 0, False: 22]
-
  Branch (358:32): [True: 0, False: 1]
-
  Branch (358:32): [True: 0, False: 29]
-
  Branch (358:32): [True: 0, False: 36]
+
  Branch (358:32): [True: 0, False: 5]
+
  Branch (358:32): [True: 0, False: 34]
+
  Branch (358:32): [True: 0, False: 11]
 
  Branch (358:32): [True: 0, False: 12]
+
  Branch (358:32): [True: 0, False: 10]
+
  Branch (358:32): [True: 0, False: 2]
+
  Branch (358:32): [True: 0, False: 9]
+
  Branch (358:32): [True: 0, False: 24]
+
  Branch (358:32): [True: 0, False: 30]
+
  Branch (358:32): [True: 0, False: 2]
 
  Branch (358:32): [True: 0, False: 1]
 
  Branch (358:32): [True: 0, False: 6]
-
  Branch (358:32): [True: 0, False: 9]
-
  Branch (358:32): [True: 0, False: 10]
-
  Branch (358:32): [True: 0, False: 24]
-
  Branch (358:32): [True: 0, False: 11]
-
  Branch (358:32): [True: 0, False: 5]
-
359
72
                            let _ = c.wait();
360
363
                        }
361
435
                        break;
362
                    }
363
111
                    Err(kanal::ReceiveErrorTimeout::Timeout) => {
364
111
                        // Neither signal yet — loop.
365
111
                    }
366
                }
367
            }
368
369
436
            components_to_stop_killer.fetch_sub(1, Ordering::SeqCst);
370
436
            debug!("parallel reader: killer thread stop");
371
436
        });
372
373
436
        (rx_item, tx_interrupt)
374
436
    }
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
436
    fn spawn_io_reader(
379
436
        source: impl BufRead + Send + 'static,
380
436
        tx_chunks: kanal::Sender<(usize, Vec<u8>)>,
381
436
        line_ending: u8,
382
436
    ) {
383
436
        thread::spawn(move || {
384
436
            debug!("parallel reader: I/O thread start");
385
386
436
            let mut source = source;
387
436
            let mut leftover: Vec<u8> = Vec::new();
388
436
            let mut seq = 0usize;
389
436
            let mut read_buf = vec![0u8; PARALLEL_READ_BUF_SIZE];
390
391
            loop {
392
828
                let 
n392
= match std::io::Read::read(&mut source, &mut read_buf) {
393
                    Ok(0) => {
394
                        // EOF — flush any remaining leftover as the final chunk.
395
436
                        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: 13]
-
  Branch (395:28): [True: 0, False: 2]
+
359
72
                            let _ = c.wait();
360
358
                        }
361
430
                        break;
362
                    }
363
147
                    Err(kanal::ReceiveErrorTimeout::Timeout) => {
364
147
                        // Neither signal yet — loop.
365
147
                    }
366
                }
367
            }
368
369
431
            components_to_stop_killer.fetch_sub(1, Ordering::SeqCst);
370
431
            debug!("parallel reader: killer thread stop");
371
431
        });
372
373
431
        (rx_item, tx_interrupt)
374
431
    }
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
431
    fn spawn_io_reader(
379
431
        source: impl BufRead + Send + 'static,
380
431
        tx_chunks: kanal::Sender<(usize, Vec<u8>)>,
381
431
        line_ending: u8,
382
431
    ) {
383
431
        thread::spawn(move || {
384
431
            debug!("parallel reader: I/O thread start");
385
386
431
            let mut source = source;
387
431
            let mut leftover: Vec<u8> = Vec::new();
388
431
            let mut seq = 0usize;
389
431
            let mut read_buf = vec![0u8; PARALLEL_READ_BUF_SIZE];
390
391
            loop {
392
818
                let 
n387
= match std::io::Read::read(&mut source, &mut read_buf) {
393
                    Ok(0) => {
394
                        // EOF — flush any remaining leftover as the final chunk.
395
431
                        if !leftover.is_empty() {
  Branch (395:28): [True: 20, False: 9]
+
  Branch (395:28): [True: 5, False: 63]
 
  Branch (395:28): [True: 0, False: 115]
 
  Branch (395:28): [True: 3, False: 0]
 
  Branch (395:28): [True: 0, False: 9]
+
  Branch (395:28): [True: 0, False: 22]
+
  Branch (395:28): [True: 0, False: 12]
 
  Branch (395:28): [True: 0, False: 3]
+
  Branch (395:28): [True: 0, False: 1]
 
  Branch (395:28): [True: 0, False: 4]
 
  Branch (395:28): [True: 0, False: 13]
 
  Branch (395:28): [True: 0, False: 1]
 
  Branch (395:28): [True: 0, False: 5]
-
  Branch (395:28): [True: 0, False: 22]
-
  Branch (395:28): [True: 0, False: 1]
-
  Branch (395:28): [True: 20, False: 9]
-
  Branch (395:28): [True: 0, False: 36]
+
  Branch (395:28): [True: 0, False: 5]
+
  Branch (395:28): [True: 0, False: 34]
+
  Branch (395:28): [True: 0, False: 11]
 
  Branch (395:28): [True: 0, False: 12]
+
  Branch (395:28): [True: 0, False: 10]
+
  Branch (395:28): [True: 0, False: 2]
+
  Branch (395:28): [True: 0, False: 9]
+
  Branch (395:28): [True: 0, False: 24]
+
  Branch (395:28): [True: 0, False: 30]
+
  Branch (395:28): [True: 0, False: 2]
 
  Branch (395:28): [True: 0, False: 1]
 
  Branch (395:28): [True: 0, False: 6]
-
  Branch (395:28): [True: 0, False: 9]
-
  Branch (395:28): [True: 0, False: 10]
-
  Branch (395:28): [True: 0, False: 24]
-
  Branch (395:28): [True: 0, False: 11]
-
  Branch (395:28): [True: 0, False: 5]
-
396
28
                            let _ = tx_chunks.send((seq, std::mem::take(&mut leftover)));
397
408
                        }
398
436
                        break;
399
                    }
400
392
                    Ok(n) => n,
401
0
                    Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
  Branch (401:35): [True: 0, False: 0]
+
396
28
                            let _ = tx_chunks.send((seq, std::mem::take(&mut leftover)));
397
403
                        }
398
431
                        break;
399
                    }
400
387
                    Ok(n) => n,
401
0
                    Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
  Branch (401:35): [True: 0, False: 0]
 
  Branch (401:35): [True: 0, False: 0]
 
  Branch (401:35): [True: 0, False: 0]
 
  Branch (401:35): [True: 0, False: 0]
@@ -183,97 +183,97 @@
 
  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
392
                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: 13, False: 0]
-
  Branch (412:31): [True: 2, 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
387
                let data = if leftover.is_empty() {
  Branch (412:31): [True: 29, False: 9]
+
  Branch (412:31): [True: 15, False: 0]
 
  Branch (412:31): [True: 115, False: 0]
 
  Branch (412:31): [True: 3, False: 0]
 
  Branch (412:31): [True: 9, False: 0]
+
  Branch (412:31): [True: 22, False: 0]
+
  Branch (412:31): [True: 12, False: 0]
 
  Branch (412:31): [True: 3, False: 0]
+
  Branch (412:31): [True: 1, False: 0]
 
  Branch (412:31): [True: 4, False: 0]
 
  Branch (412:31): [True: 13, False: 0]
 
  Branch (412:31): [True: 1, False: 0]
 
  Branch (412:31): [True: 5, False: 0]
-
  Branch (412:31): [True: 22, False: 0]
-
  Branch (412:31): [True: 1, False: 0]
-
  Branch (412:31): [True: 29, False: 9]
-
  Branch (412:31): [True: 36, False: 0]
+
  Branch (412:31): [True: 5, False: 0]
+
  Branch (412:31): [True: 34, False: 0]
+
  Branch (412:31): [True: 11, False: 0]
 
  Branch (412:31): [True: 12, False: 0]
+
  Branch (412:31): [True: 10, False: 0]
+
  Branch (412:31): [True: 2, False: 0]
+
  Branch (412:31): [True: 9, False: 0]
+
  Branch (412:31): [True: 24, False: 0]
+
  Branch (412:31): [True: 30, False: 0]
+
  Branch (412:31): [True: 2, False: 0]
 
  Branch (412:31): [True: 1, False: 0]
 
  Branch (412:31): [True: 6, False: 0]
-
  Branch (412:31): [True: 9, False: 0]
-
  Branch (412:31): [True: 10, False: 0]
-
  Branch (412:31): [True: 24, False: 0]
-
  Branch (412:31): [True: 11, False: 0]
-
  Branch (412:31): [True: 5, False: 0]
-
413
383
                    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
392
                if let Some(
last_nl383
) = 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: 13, False: 0]
-
  Branch (422:24): [True: 2, False: 0]
+
413
378
                    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
387
                if let Some(
last_nl378
) = memchr::memrchr(line_ending, &data) {
  Branch (422:24): [True: 32, False: 6]
+
  Branch (422:24): [True: 14, False: 1]
 
  Branch (422:24): [True: 115, False: 0]
 
  Branch (422:24): [True: 1, False: 2]
 
  Branch (422:24): [True: 9, False: 0]
+
  Branch (422:24): [True: 22, False: 0]
+
  Branch (422:24): [True: 12, False: 0]
 
  Branch (422:24): [True: 3, False: 0]
+
  Branch (422:24): [True: 1, False: 0]
 
  Branch (422:24): [True: 4, False: 0]
 
  Branch (422:24): [True: 13, False: 0]
 
  Branch (422:24): [True: 1, False: 0]
 
  Branch (422:24): [True: 5, False: 0]
-
  Branch (422:24): [True: 22, False: 0]
-
  Branch (422:24): [True: 1, False: 0]
-
  Branch (422:24): [True: 32, False: 6]
-
  Branch (422:24): [True: 36, False: 0]
+
  Branch (422:24): [True: 5, False: 0]
+
  Branch (422:24): [True: 34, False: 0]
+
  Branch (422:24): [True: 11, False: 0]
 
  Branch (422:24): [True: 12, False: 0]
+
  Branch (422:24): [True: 10, False: 0]
+
  Branch (422:24): [True: 2, False: 0]
+
  Branch (422:24): [True: 9, False: 0]
+
  Branch (422:24): [True: 24, False: 0]
+
  Branch (422:24): [True: 30, False: 0]
+
  Branch (422:24): [True: 2, False: 0]
 
  Branch (422:24): [True: 1, False: 0]
 
  Branch (422:24): [True: 6, False: 0]
-
  Branch (422:24): [True: 9, False: 0]
-
  Branch (422:24): [True: 10, False: 0]
-
  Branch (422:24): [True: 24, False: 0]
-
  Branch (422:24): [True: 11, False: 0]
-
  Branch (422:24): [True: 5, False: 0]
-
423
383
                    leftover = data[last_nl + 1..].to_vec();
424
383
                    let mut chunk = data;
425
383
                    chunk.truncate(last_nl + 1);
426
383
                    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: 13]
-
  Branch (426:24): [True: 0, False: 2]
+
423
378
                    leftover = data[last_nl + 1..].to_vec();
424
378
                    let mut chunk = data;
425
378
                    chunk.truncate(last_nl + 1);
426
378
                    if tx_chunks.send((seq, chunk)).is_err() {
  Branch (426:24): [True: 0, False: 32]
+
  Branch (426:24): [True: 0, False: 14]
 
  Branch (426:24): [True: 0, False: 115]
 
  Branch (426:24): [True: 0, False: 1]
 
  Branch (426:24): [True: 0, False: 9]
+
  Branch (426:24): [True: 0, False: 22]
+
  Branch (426:24): [True: 0, False: 12]
 
  Branch (426:24): [True: 0, False: 3]
+
  Branch (426:24): [True: 0, False: 1]
 
  Branch (426:24): [True: 0, False: 4]
 
  Branch (426:24): [True: 0, False: 13]
 
  Branch (426:24): [True: 0, False: 1]
 
  Branch (426:24): [True: 0, False: 5]
-
  Branch (426:24): [True: 0, False: 22]
-
  Branch (426:24): [True: 0, False: 1]
-
  Branch (426:24): [True: 0, False: 32]
-
  Branch (426:24): [True: 0, False: 36]
+
  Branch (426:24): [True: 0, False: 5]
+
  Branch (426:24): [True: 0, False: 34]
+
  Branch (426:24): [True: 0, False: 11]
 
  Branch (426:24): [True: 0, False: 12]
+
  Branch (426:24): [True: 0, False: 10]
+
  Branch (426:24): [True: 0, False: 2]
+
  Branch (426:24): [True: 0, False: 9]
+
  Branch (426:24): [True: 0, False: 24]
+
  Branch (426:24): [True: 0, False: 30]
+
  Branch (426:24): [True: 0, False: 2]
 
  Branch (426:24): [True: 0, False: 1]
 
  Branch (426:24): [True: 0, False: 6]
-
  Branch (426:24): [True: 0, False: 9]
-
  Branch (426:24): [True: 0, False: 10]
-
  Branch (426:24): [True: 0, False: 24]
-
  Branch (426:24): [True: 0, False: 11]
-
  Branch (426:24): [True: 0, False: 5]
-
427
0
                        break;
428
383
                    }
429
383
                    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
436
            debug!("parallel reader: I/O thread stop (sent {seq} chunks)");
437
436
        });
438
436
    }
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: 365, False: 28]
+
427
0
                        break;
428
378
                    }
429
378
                    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
431
            debug!("parallel reader: I/O thread stop (sent {seq} chunks)");
437
431
        });
438
431
    }
439
440
    /// Parses a raw byte chunk into a tagged batch of items.
441
406
    fn process_chunk(seq: usize, chunk: &[u8], opt: &SkimItemReaderOption) -> (usize, Vec<Arc<dyn SkimItem>>) {
442
406
        let mut items = Vec::new();
443
406
        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
406
        let chunk_trimmed: &[u8] = if chunk.last() == Some(&line_ending) {
  Branch (450:39): [True: 360, False: 28]
 
  Branch (450:39): [True: 18, False: 0]
-
451
383
            &chunk[..chunk.len() - 1]
452
        } else {
453
28
            chunk
454
        };
455
456
671k
        for 
line_bytes51.9k
in
chunk_trimmed411
.
split411
(|&b: &u8| b == line_ending) {
457
            // Strip optional \r for \r\n endings.
458
51.9k
            let line_bytes: &[u8] = line_bytes.strip_suffix(b"\r").unwrap_or(line_bytes);
459
51.9k
            let Ok(
line51.9k
) = std::str::from_utf8(line_bytes) else {
  Branch (459:17): [True: 51.8k, False: 0]
+
451
378
            &chunk[..chunk.len() - 1]
452
        } else {
453
28
            chunk
454
        };
455
456
672k
        for 
line_bytes51.9k
in
chunk_trimmed406
.
split406
(|&b: &u8| b == line_ending) {
457
            // Strip optional \r for \r\n endings.
458
51.9k
            let line_bytes: &[u8] = line_bytes.strip_suffix(b"\r").unwrap_or(line_bytes);
459
51.9k
            let Ok(
line51.9k
) = std::str::from_utf8(line_bytes) else {
  Branch (459:17): [True: 51.9k, False: 0]
 
  Branch (459:17): [True: 36, False: 1]
-
460
1
                continue;
461
            };
462
51.9k
            let mut item = DefaultSkimItem::new(
463
51.9k
                line,
464
51.9k
                opt.use_ansi_color,
465
51.9k
                &opt.transform_fields,
466
51.9k
                &opt.matching_fields,
467
51.9k
                &opt.delimiter,
468
            )
469
51.9k
            .hidden_fields(&opt.hidden_fields, &opt.delimiter);
470
51.9k
            if opt.disable_pattern.as_ref().is_some_and(|re| 
re2
.
is_match2
(
line2
)) {
  Branch (470:16): [True: 0, False: 51.8k]
+
460
1
                continue;
461
            };
462
51.9k
            let mut item = DefaultSkimItem::new(
463
51.9k
                line,
464
51.9k
                opt.use_ansi_color,
465
51.9k
                &opt.transform_fields,
466
51.9k
                &opt.matching_fields,
467
51.9k
                &opt.delimiter,
468
            )
469
51.9k
            .hidden_fields(&opt.hidden_fields, &opt.delimiter);
470
51.9k
            if opt.disable_pattern.as_ref().is_some_and(|re| 
re2
.
is_match2
(
line2
)) {
  Branch (470:16): [True: 0, False: 51.9k]
 
  Branch (470:16): [True: 1, False: 35]
-
471
1
                item.disable();
472
51.9k
            }
473
51.9k
            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
436
    fn spawn_reorder_thread(
484
436
        rx_results: kanal::Receiver<(usize, Vec<Arc<dyn SkimItem>>)>,
485
436
        tx_item: SkimItemSender,
486
436
        tx_pipeline_done: kanal::Sender<()>,
487
436
    ) {
488
436
        thread::spawn(move || {
489
436
            debug!("parallel reader: reorder thread start");
490
436
            let mut expected = 0usize;
491
436
            let mut pending: BTreeMap<usize, Vec<Arc<dyn SkimItem>>> = BTreeMap::new();
492
493
847
            while let Ok((
seq411
,
items411
)) = rx_results.recv() {
  Branch (493:23): [True: 393, False: 418]
+
471
1
                item.disable();
472
51.9k
            }
473
51.9k
            items.push(Arc::new(item) as Arc<dyn SkimItem>);
474
        }
475
476
406
        (seq, items)
477
406
    }
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
431
    fn spawn_reorder_thread(
484
431
        rx_results: kanal::Receiver<(usize, Vec<Arc<dyn SkimItem>>)>,
485
431
        tx_item: SkimItemSender,
486
431
        tx_pipeline_done: kanal::Sender<()>,
487
431
    ) {
488
431
        thread::spawn(move || {
489
431
            debug!("parallel reader: reorder thread start");
490
431
            let mut expected = 0usize;
491
431
            let mut pending: BTreeMap<usize, Vec<Arc<dyn SkimItem>>> = BTreeMap::new();
492
493
837
            while let Ok((
seq406
,
items406
)) = rx_results.recv() {
  Branch (493:23): [True: 388, False: 413]
 
  Branch (493:23): [True: 18, False: 18]
-
494
411
                pending.insert(seq, items);
495
                // Flush consecutive batches starting from the expected seq.
496
822
                while let Some(
batch411
) = pending.remove(&expected) {
  Branch (496:27): [True: 393, False: 393]
+
494
406
                pending.insert(seq, items);
495
                // Flush consecutive batches starting from the expected seq.
496
812
                while let Some(
batch406
) = pending.remove(&expected) {
  Branch (496:27): [True: 388, False: 388]
 
  Branch (496:27): [True: 18, False: 18]
-
497
411
                    if tx_item.send(batch).is_err() {
  Branch (497:24): [True: 0, False: 393]
+
497
406
                    if tx_item.send(batch).is_err() {
  Branch (497:24): [True: 0, False: 388]
 
  Branch (497:24): [True: 0, False: 18]
-
498
0
                        return;
499
411
                    }
500
411
                    expected += 1;
501
                }
502
            }
503
            // Drain anything left (shouldn't normally happen).
504
436
            while let Some((&
seq0
, _)) = pending.first_key_value() {
  Branch (504:23): [True: 0, False: 418]
+
498
0
                        return;
499
406
                    }
500
406
                    expected += 1;
501
                }
502
            }
503
            // Drain anything left (shouldn't normally happen).
504
431
            while let Some((&
seq0
, _)) = pending.first_key_value() {
  Branch (504:23): [True: 0, False: 413]
 
  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
436
            drop(tx_pipeline_done);
512
436
            debug!("parallel reader: reorder thread stop");
513
436
        });
514
436
    }
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
391
    fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
529
391
        self.thread_pool = pool;
530
391
    }
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]
+
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
431
            drop(tx_pipeline_done);
512
431
            debug!("parallel reader: reorder thread stop");
513
431
        });
514
431
    }
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
386
    fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
529
386
        self.thread_pool = pool;
530
386
    }
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
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 1c846a76..809720c4 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-08-22 15:34

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
20
    pub fn first_n(mut self, first_n: usize) -> Self {
19
20
        trace!("select first_n: {first_n}");
20
20
        self.first_n = first_n;
21
20
        self
22
20
    }
23
24
    /// Selects items whose text matches any of the preset strings
25
    #[must_use]
26
19
    pub fn preset(mut self, preset: impl IntoIterator<Item = String>) -> Self {
27
19
        if self.preset.is_none() {
  Branch (27:12): [True: 17, False: 0]
+

Coverage Report

Created: 2026-09-01 09:15

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]
 
  Branch (27:12): [True: 2, False: 0]
-
28
19
            self.preset = Some(HashSet::new());
29
19
        
}0
30
31
19
        if let Some(set) = self.preset.as_mut() {
  Branch (31:16): [True: 17, 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]
 
  Branch (31:16): [True: 2, False: 0]
-
32
19
            set.extend(preset);
33
19
        
}0
34
19
        self
35
19
    }
36
37
    /// Selects items whose text matches the given regex pattern
38
    #[must_use]
39
19
    pub fn regex(mut self, regex: &str) -> Self {
40
19
        trace!("select regex: {regex}");
41
19
        if !regex.is_empty() {
  Branch (41:12): [True: 2, False: 15]
+
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]
 
  Branch (41:12): [True: 2, False: 0]
-
42
4
            self.regex = Regex::new(regex).ok();
43
15
        }
44
19
        self
45
19
    }
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
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]
 
  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 ed0f5a64..1fa15c5e 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,14 +1,14 @@
-

Coverage Report

Created: 2026-08-22 15:34

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.87k
    fn default() -> Self {
32
3.87k
        Self {
33
3.87k
            criterion: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End],
34
3.87k
            tac: false,
35
3.87k
        }
36
3.87k
    }
37
}
38
39
impl RankBuilder {
40
    /// Creates a new rank builder with the given criteria
41
    #[must_use]
42
400
    pub fn new(mut criterion: Vec<RankCriteria>) -> Self {
43
400
        if !criterion.contains(&RankCriteria::Score) && 
!6
criterion6
.contains(&RankCriteria::NegScore) {
  Branch (43:12): [True: 1, False: 373]
+

Coverage Report

Created: 2026-09-01 09:15

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
395
    pub fn new(mut criterion: Vec<RankCriteria>) -> Self {
43
395
        if !criterion.contains(&RankCriteria::Score) && 
!6
criterion6
.contains(&RankCriteria::NegScore) {
  Branch (43:12): [True: 1, False: 368]
   Branch (43:57): [True: 0, False: 1]
 
  Branch (43:12): [True: 5, False: 21]
   Branch (43:57): [True: 4, False: 1]
-
44
4
            criterion.insert(0, RankCriteria::Score);
45
396
        }
46
47
400
        criterion.dedup();
48
400
        Self { criterion, tac: false }
49
400
    }
50
51
    #[must_use]
52
401
    pub(crate) fn tac(mut self, tac: bool) -> Self {
53
401
        self.tac = tac;
54
401
        self
55
401
    }
56
57
    /// Returns the tiebreak criteria slice.
58
    #[must_use]
59
3
    pub fn criteria(&self) -> &[RankCriteria] {
60
3
        &self.criterion
61
3
    }
62
63
53.5k
    fn sort_key(&self, rank: &Rank) -> [i32; 6] {
64
53.5k
        let configured = rank.sort_key(&self.criterion);
65
53.5k
        let mut key = [0; 6];
66
53.5k
        key[..5].copy_from_slice(&configured);
67
68
53.5k
        if self.tac {
  Branch (68:12): [True: 8, False: 51.8k]
+
44
4
            criterion.insert(0, RankCriteria::Score);
45
391
        }
46
47
395
        criterion.dedup();
48
395
        Self { criterion, tac: false }
49
395
    }
50
51
    #[must_use]
52
396
    pub(crate) fn tac(mut self, tac: bool) -> Self {
53
396
        self.tac = tac;
54
396
        self
55
396
    }
56
57
    /// Returns the tiebreak criteria slice.
58
    #[must_use]
59
3
    pub fn criteria(&self) -> &[RankCriteria] {
60
3
        &self.criterion
61
3
    }
62
63
53.5k
    fn sort_key(&self, rank: &Rank) -> [i32; 6] {
64
53.5k
        let configured = rank.sort_key(&self.criterion);
65
53.5k
        let mut key = [0; 6];
66
53.5k
        key[..5].copy_from_slice(&configured);
67
68
53.5k
        if self.tac {
  Branch (68:12): [True: 8, False: 51.8k]
 
  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.5k
        }
77
53.5k
        key[5] = if self.tac {
  Branch (77:21): [True: 8, False: 51.8k]
 
  Branch (77:21): [True: 9, False: 1.72k]
-
78
17
            rank.index.saturating_neg()
79
        } else {
80
53.5k
            rank.index
81
        };
82
53.5k
        key
83
53.5k
    }
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
51.9k
    fn path_name_offset(text: &str) -> i32 {
92
51.9k
        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
51.9k
    }
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
51.9k
    pub fn build_rank(&self, score: i32, begin: usize, end: usize, item_text: &str) -> Rank {
106
51.9k
        Rank {
107
51.9k
            score,
108
51.9k
            begin: i32::try_from(begin).unwrap_or(i32::MAX),
109
51.9k
            end: i32::try_from(end).unwrap_or(i32::MAX),
110
51.9k
            length: i32::try_from(item_text.len()).unwrap_or(i32::MAX),
111
51.9k
            index: Default::default(),
112
51.9k
            path_name_offset: Self::path_name_offset(item_text),
113
51.9k
        }
114
51.9k
    }
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.5k
    pub fn sort_key(&self, criteria: &[RankCriteria]) -> [i32; 5] {
125
53.5k
        let mut key = [0i32; 5];
126
160k
        for (priority, criterion) in 
criteria53.5k
.
iter53.5k
().
take53.5k
(5).
enumerate53.5k
() {
127
160k
            key[priority] = match criterion {
128
53.5k
                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.5k
        key
147
53.5k
    }
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.5k
    pub fn new(
194
53.5k
        item: Arc<dyn SkimItem>,
195
53.5k
        rank: Rank,
196
53.5k
        matched_range: Option<MatchRange>,
197
53.5k
        rank_builder: &RankBuilder,
198
53.5k
    ) -> Self {
199
53.5k
        Self {
200
53.5k
            item,
201
53.5k
            rank,
202
53.5k
            matched_range,
203
53.5k
            sort_key: rank_builder.sort_key(&rank),
204
53.5k
        }
205
53.5k
    }
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: 8, False: 0]
+
78
17
            rank.index.saturating_neg()
79
        } else {
80
53.5k
            rank.index
81
        };
82
53.5k
        key
83
53.5k
    }
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
51.9k
    fn path_name_offset(text: &str) -> i32 {
92
51.9k
        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
51.9k
    }
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
51.9k
    pub fn build_rank(&self, score: i32, begin: usize, end: usize, item_text: &str) -> Rank {
106
51.9k
        Rank {
107
51.9k
            score,
108
51.9k
            begin: i32::try_from(begin).unwrap_or(i32::MAX),
109
51.9k
            end: i32::try_from(end).unwrap_or(i32::MAX),
110
51.9k
            length: i32::try_from(item_text.len()).unwrap_or(i32::MAX),
111
51.9k
            index: Default::default(),
112
51.9k
            path_name_offset: Self::path_name_offset(item_text),
113
51.9k
        }
114
51.9k
    }
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.5k
    pub fn sort_key(&self, criteria: &[RankCriteria]) -> [i32; 5] {
125
53.5k
        let mut key = [0i32; 5];
126
160k
        for (priority, criterion) in 
criteria53.5k
.
iter53.5k
().
take53.5k
(5).
enumerate53.5k
() {
127
160k
            key[priority] = match criterion {
128
53.5k
                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.5k
        key
147
53.5k
    }
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.5k
    pub fn new(
194
53.5k
        item: Arc<dyn SkimItem>,
195
53.5k
        rank: Rank,
196
53.5k
        matched_range: Option<MatchRange>,
197
53.5k
        rank_builder: &RankBuilder,
198
53.5k
    ) -> Self {
199
53.5k
        Self {
200
53.5k
            item,
201
53.5k
            rank,
202
53.5k
            matched_range,
203
53.5k
            sort_key: rank_builder.sort_key(&rank),
204
53.5k
        }
205
53.5k
    }
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]
 
  Branch (212:12): [True: 1, False: 8]
-
213
9
            return incoming;
214
8
        }
215
8
        if incoming.is_empty() {
  Branch (215:12): [True: 0, False: 0]
+
213
10
            return incoming;
214
8
        }
215
8
        if incoming.is_empty() {
  Branch (215:12): [True: 0, False: 0]
 
  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]
 
  Branch (221:12): [True: 2, False: 5]
@@ -16,15 +16,15 @@
 
  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
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
64
    pub fn merge_into_sorted(existing: &mut Vec<MatchedItem>, incoming: Vec<MatchedItem>) {
279
        const SMALL_INSERT_THRESHOLD: usize = 256;
280
281
64
        if incoming.is_empty() {
  Branch (281:12): [True: 2, False: 59]
+
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
67
    pub fn merge_into_sorted(existing: &mut Vec<MatchedItem>, incoming: Vec<MatchedItem>) {
279
        const SMALL_INSERT_THRESHOLD: usize = 256;
280
281
67
        if incoming.is_empty() {
  Branch (281:12): [True: 1, False: 63]
 
  Branch (281:12): [True: 0, False: 3]
-
282
2
            return;
283
62
        }
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
62
        if existing.is_empty() {
  Branch (287:12): [True: 59, False: 0]
+
282
1
            return;
283
66
        }
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
66
        if existing.is_empty() {
  Branch (287:12): [True: 62, False: 1]
 
  Branch (287:12): [True: 0, False: 3]
-
288
59
            existing.extend(incoming);
289
59
            return;
290
3
        }
291
292
        // Fast path: all existing ≤ first incoming — just append.
293
        #[allow(clippy::missing_panics_doc)]
294
3
        if existing.last().unwrap() <= incoming.first().unwrap() {
  Branch (294:12): [True: 0, False: 0]
+
288
62
            existing.extend(incoming);
289
62
            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
0
            existing.extend(incoming);
296
0
            return;
297
3
        }
298
299
3
        if incoming.len() <= SMALL_INSERT_THRESHOLD {
  Branch (299:12): [True: 0, False: 0]
+
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
64
    }
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
67
    }
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]
@@ -32,10 +32,10 @@
 
  Branch (351:20): [True: 599, False: 300]
 
352
599
                    ai -= 1;
353
599
                    std::ptr::copy_nonoverlapping(a_ptr.add(ai), a_ptr.add(write), 1);
354
599
                } else {
355
300
                    bi -= 1;
356
300
                    std::ptr::copy_nonoverlapping(b_ptr.add(bi), a_ptr.add(write), 1);
357
300
                }
358
            }
359
360
            // Remaining B elements go to the front of existing.
361
            // (Remaining A elements at 0..ai are already in their final positions.)
362
2
            if bi > 0 {
  Branch (362:16): [True: 0, False: 0]
 
  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
303
    fn eq(&self, other: &Self) -> bool {
386
303
        self.text().eq(&other.text()) && 
self.rank.index178
.
eq178
(
&other.rank.index178
)
  Branch (386:9): [True: 160, False: 119]
+
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
303
    }
388
}
389
390
impl std::cmp::Eq for MatchedItem {}
391
392
impl PartialOrd for MatchedItem {
393
5.08k
    fn partial_cmp(&self, other: &Self) -> Option<CmpOrd> {
394
5.08k
        Some(self.cmp(other))
395
5.08k
    }
396
}
397
398
impl Ord for MatchedItem {
399
5.09k
    fn cmp(&self, other: &Self) -> CmpOrd {
400
5.09k
        self.sort_key.cmp(&other.sort_key)
401
5.09k
    }
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
394
    pub fn from_options(options: &crate::SkimOptions) -> Self {
455
394
        Self {
456
394
            length: AtomicUsize::new(0),
457
394
            pool: SpinLock::new(Vec::with_capacity(ITEM_POOL_CAPACITY)),
458
394
            taken: AtomicUsize::new(0),
459
394
            reserved_items: SpinLock::new(Vec::new()),
460
394
            lines_to_reserve: options.header_lines,
461
394
            tac: options.tac,
462
394
            items_available: Arc::new(Notify::new()),
463
394
        }
464
394
    }
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.82k
    pub fn num_not_taken(&self) -> usize {
478
2.82k
        self.length.load(Ordering::SeqCst) - self.taken.load(Ordering::SeqCst)
479
2.82k
    }
480
481
    /// Returns the number of items that have been taken
482
846
    pub fn num_taken(&self) -> usize {
483
846
        self.taken.load(Ordering::SeqCst)
484
846
    }
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: 18, False: 376]
+
387
302
    }
388
}
389
390
impl std::cmp::Eq for MatchedItem {}
391
392
impl PartialOrd for MatchedItem {
393
5.08k
    fn partial_cmp(&self, other: &Self) -> Option<CmpOrd> {
394
5.08k
        Some(self.cmp(other))
395
5.08k
    }
396
}
397
398
impl Ord for MatchedItem {
399
5.09k
    fn cmp(&self, other: &Self) -> CmpOrd {
400
5.09k
        self.sort_key.cmp(&other.sort_key)
401
5.09k
    }
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
389
    pub fn from_options(options: &crate::SkimOptions) -> Self {
455
389
        Self {
456
389
            length: AtomicUsize::new(0),
457
389
            pool: SpinLock::new(Vec::with_capacity(ITEM_POOL_CAPACITY)),
458
389
            taken: AtomicUsize::new(0),
459
389
            reserved_items: SpinLock::new(Vec::new()),
460
389
            lines_to_reserve: options.header_lines,
461
389
            tac: options.tac,
462
389
            items_available: Arc::new(Notify::new()),
463
389
        }
464
389
    }
465
466
    /// Returns the total number of items in the pool
467
2.62k
    pub fn len(&self) -> usize {
468
2.62k
        self.length.load(Ordering::SeqCst)
469
2.62k
    }
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.84k
    pub fn num_not_taken(&self) -> usize {
478
2.84k
        self.length.load(Ordering::SeqCst) - self.taken.load(Ordering::SeqCst)
479
2.84k
    }
480
481
    /// Returns the number of items that have been taken
482
843
    pub fn num_taken(&self) -> usize {
483
843
        self.taken.load(Ordering::SeqCst)
484
843
    }
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
766
    pub fn reset(&self) {
498
        // lock to ensure consistency
499
766
        let _items = self.pool.lock();
500
501
766
        self.taken.store(0, Ordering::SeqCst);
502
766
    }
503
504
    /// append the items and return the `new_size` of the pool
505
422
    pub fn append(&self, mut items: Vec<Arc<dyn SkimItem>>) -> usize {
506
422
        let len = items.len();
507
422
        trace!("item pool, append {len} items");
508
422
        let mut pool = self.pool.lock();
509
422
        let mut header_items = self.reserved_items.lock();
510
511
422
        let to_reserve = self.lines_to_reserve - header_items.len();
512
422
        if to_reserve > 0 {
  Branch (512:12): [True: 17, False: 372]
 
  Branch (512:12): [True: 1, False: 32]
-
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
849
    pub fn take(&self) -> Vec<Arc<dyn SkimItem>> {
539
849
        let guard = self.pool.lock();
540
849
        let taken = self.taken.swap(guard.len(), Ordering::SeqCst);
541
        // Copy the new items out so we can release the lock immediately
542
849
        let mut items = guard[taken..].to_vec();
543
849
        if self.tac {
  Branch (543:12): [True: 3, False: 816]
+
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
404
        } else {
522
404
            pool.extend(items);
523
404
        }
524
422
        self.length.store(pool.len(), Ordering::SeqCst);
525
422
        trace!("item pool, done append {len} items, total: {}", 
pool.len()2
);
526
422
        let new_len = pool.len();
527
422
        drop(pool);
528
422
        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
422
        self.items_available.notify_one();
533
534
422
        new_len
535
422
    }
536
537
    /// Takes items from the pool, copying new items since last take and releasing lock immediately
538
846
    pub fn take(&self) -> Vec<Arc<dyn SkimItem>> {
539
846
        let guard = self.pool.lock();
540
846
        let taken = self.taken.swap(guard.len(), Ordering::SeqCst);
541
        // Copy the new items out so we can release the lock immediately
542
846
        let mut items = guard[taken..].to_vec();
543
846
        if self.tac {
  Branch (543:12): [True: 3, False: 813]
 
  Branch (543:12): [True: 1, False: 29]
-
544
4
            items.reverse();
545
845
        }
546
849
        drop(guard); // Explicitly release lock
547
849
        items
548
849
    }
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.41k
    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.41k
        Some(match self {
628
1.40k
            Score => PossibleValue::new("score"),
629
940
            Begin => PossibleValue::new("begin"),
630
482
            End => PossibleValue::new("end"),
631
941
            NegScore => PossibleValue::new("-score"),
632
483
            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.41k
    }
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
4
            items.reverse();
545
842
        }
546
846
        drop(guard); // Explicitly release lock
547
846
        items
548
846
    }
549
550
    /// Returns a copy of the reserved header items
551
2.66k
    pub fn reserved(&self) -> Vec<Arc<dyn SkimItem>> {
552
2.66k
        let guard = self.reserved_items.lock();
553
2.66k
        guard.clone()
554
2.66k
    }
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.39k
    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.39k
        &[
608
1.39k
            Score,
609
1.39k
            NegScore,
610
1.39k
            Begin,
611
1.39k
            NegBegin,
612
1.39k
            End,
613
1.39k
            NegEnd,
614
1.39k
            Length,
615
1.39k
            NegLength,
616
1.39k
            Index,
617
1.39k
            NegIndex,
618
1.39k
            PathName,
619
1.39k
            NegPathName,
620
1.39k
        ]
621
1.39k
    }
622
623
4.36k
    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.36k
        Some(match self {
628
1.39k
            Score => PossibleValue::new("score"),
629
930
            Begin => PossibleValue::new("begin"),
630
477
            End => PossibleValue::new("end"),
631
931
            NegScore => PossibleValue::new("-score"),
632
478
            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.36k
    }
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 77570dcd..2744b807 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-08-22 15:34

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.40k
    pub fn to_line(self, cow: Cow<str>) -> Line {
158
6.40k
        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.40k
        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.55k
            Matches::ByteRange(start, end) => {
200
4.55k
                let mut bytes = text.bytes();
201
4.55k
                let mut res = Line::default();
202
4.55k
                res.push_span(Span::styled(
203
4.55k
                    String::from_utf8(bytes.by_ref().take(*start).collect()).unwrap(),
204
4.55k
                    self.base_style,
205
                ));
206
4.55k
                let highlighted_bytes = bytes.by_ref().take(*end - *start).collect();
207
4.55k
                let highlighted_text = String::from_utf8(highlighted_bytes).unwrap();
208
209
4.55k
                res.push_span(Span::styled(
210
4.55k
                    highlighted_text,
211
4.55k
                    self.base_style.patch(self.matched_style),
212
                ));
213
4.55k
                res.push_span(Span::styled(
214
4.55k
                    String::from_utf8(bytes.collect()).unwrap(),
215
4.55k
                    self.base_style,
216
                ));
217
4.55k
                res
218
            }
219
204
            Matches::None => Line::from(vec![Span::styled(text, self.base_style)]),
220
        }
221
6.40k
    }
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.11k
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
398
3.11k
        (**self).create_engine_with_case(query, case)
399
3.11k
    }
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-01 09:15

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.40k
    pub fn to_line(self, cow: Cow<str>) -> Line {
158
6.40k
        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.40k
        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.57k
                for &index in 
indices1.63k
{
168
3.57k
                    let span_content = chars.by_ref().take(index - prev_index);
169
3.57k
                    res.push_span(Span::styled(span_content.collect::<String>(), self.base_style));
170
3.57k
                    let highlighted_char = chars.next().unwrap_or_default().to_string();
171
3.57k
172
3.57k
                    res.push_span(Span::styled(
173
3.57k
                        highlighted_char,
174
3.57k
                        self.base_style.patch(self.matched_style),
175
3.57k
                    ));
176
3.57k
                    prev_index = index + 1;
177
3.57k
                }
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.56k
            Matches::ByteRange(start, end) => {
200
4.56k
                let mut bytes = text.bytes();
201
4.56k
                let mut res = Line::default();
202
4.56k
                res.push_span(Span::styled(
203
4.56k
                    String::from_utf8(bytes.by_ref().take(*start).collect()).unwrap(),
204
4.56k
                    self.base_style,
205
                ));
206
4.56k
                let highlighted_bytes = bytes.by_ref().take(*end - *start).collect();
207
4.56k
                let highlighted_text = String::from_utf8(highlighted_bytes).unwrap();
208
209
4.56k
                res.push_span(Span::styled(
210
4.56k
                    highlighted_text,
211
4.56k
                    self.base_style.patch(self.matched_style),
212
                ));
213
4.56k
                res.push_span(Span::styled(
214
4.56k
                    String::from_utf8(bytes.collect()).unwrap(),
215
4.56k
                    self.base_style,
216
                ));
217
4.56k
                res
218
            }
219
198
            Matches::None => Line::from(vec![Span::styled(text, self.base_style)]),
220
        }
221
6.40k
    }
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 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 e98a9fc7..c5235132 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-08-22 15:34

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.93k
    
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-01 09:15

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.92k
    
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 5c306a37..ffd46ff2 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-08-22 15:34

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.8k
fn input_index(tac: bool, start: usize, batch_len: usize, batch_index: usize) -> usize {
29
51.8k
    debug_assert!(
batch_index < batch_len0
);
30
51.8k
    if tac {
  Branch (30:8): [True: 8, False: 51.8k]
-
  Branch (30:8): [True: 5, False: 21]
-
31
13
        start + batch_len - 1 - batch_index
32
    } else {
33
51.8k
        start + batch_index
34
    }
35
51.8k
}
36
37
839
fn merge_worker_results(
38
839
    worker_results: Vec<Vec<MatchedItem>>,
39
839
    no_sort: bool,
40
839
    processed_items: &SpinLock<Option<ProcessedItems>>,
41
839
    merge_strategy: MergeStrategy,
42
839
    needs_render: &AtomicBool,
43
839
) {
44
839
    let total_len: usize = worker_results.iter().map(Vec::len).sum();
45
839
    let mut items = Vec::with_capacity(total_len);
46
1.39k
    for chunk in 
worker_results839
{
47
1.39k
        items.extend(chunk);
48
1.39k
    }
49
50
839
    if !no_sort {
  Branch (50:8): [True: 811, False: 8]
-
  Branch (50:8): [True: 14, False: 6]
-
51
825
        // Each worker's sub-list is already sorted by `prepare`, so stable
52
825
        // sort detects the pre-existing runs and merges them efficiently.
53
825
        items.sort();
54
825
    
}14
55
56
839
    trace!("matcher stop, total matched: {}", 
items2
.
len2
());
57
58
    // Single lock, single write into processed_items.
59
839
    let mut guard = processed_items.lock();
60
839
    if 
matches!76
(merge_strategy, MergeStrategy::Replace) {
61
763
        *guard = Some(ProcessedItems {
62
763
            items,
63
763
            merge: MergeStrategy::Replace,
64
763
        });
65
763
        drop(guard);
66
763
        needs_render.store(true, Ordering::Relaxed);
67
763
        return;
68
76
    }
69
76
    match &mut *guard {
70
66
        Some(existing) => {
71
66
            if no_sort {
  Branch (71:16): [True: 3, False: 61]
+

Coverage Report

Created: 2026-09-01 09:15

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.8k
fn input_index(tac: bool, start: usize, batch_len: usize, batch_index: usize) -> usize {
29
51.8k
    debug_assert!(
batch_index < batch_len0
);
30
51.8k
    if tac {
  Branch (30:8): [True: 8, False: 51.8k]
+
  Branch (30:8): [True: 5, False: 22]
+
31
13
        start + batch_len - 1 - batch_index
32
    } else {
33
51.8k
        start + batch_index
34
    }
35
51.8k
}
36
37
837
fn merge_worker_results(
38
837
    worker_results: Vec<Vec<MatchedItem>>,
39
837
    no_sort: bool,
40
837
    processed_items: &SpinLock<Option<ProcessedItems>>,
41
837
    merge_strategy: MergeStrategy,
42
837
    needs_render: &AtomicBool,
43
837
) {
44
837
    let total_len: usize = worker_results.iter().map(Vec::len).sum();
45
837
    let mut items = Vec::with_capacity(total_len);
46
1.40k
    for chunk in 
worker_results837
{
47
1.40k
        items.extend(chunk);
48
1.40k
    }
49
50
837
    if !no_sort {
  Branch (50:8): [True: 810, False: 6]
+
  Branch (50:8): [True: 15, False: 6]
+
51
825
        // Each worker's sub-list is already sorted by `prepare`, so stable
52
825
        // sort detects the pre-existing runs and merges them efficiently.
53
825
        items.sort();
54
825
    
}12
55
56
837
    trace!("matcher stop, total matched: {}", 
items1
.
len1
());
57
58
    // Single lock, single write into processed_items.
59
837
    let mut guard = processed_items.lock();
60
837
    if 
matches!78
(merge_strategy, MergeStrategy::Replace) {
61
759
        *guard = Some(ProcessedItems {
62
759
            items,
63
759
            merge: MergeStrategy::Replace,
64
759
        });
65
759
        drop(guard);
66
759
        needs_render.store(true, Ordering::Relaxed);
67
759
        return;
68
78
    }
69
78
    match &mut *guard {
70
67
        Some(existing) => {
71
67
            if no_sort {
  Branch (71:16): [True: 1, False: 64]
 
  Branch (71:16): [True: 2, False: 0]
-
72
5
                if 
matches!4
(merge_strategy, MergeStrategy::Prepend) {
73
1
                    items.append(&mut existing.items);
74
1
                    existing.items = items;
75
4
                } else {
76
4
                    existing.items.extend(items);
77
4
                }
78
61
            } else {
79
61
                // Both sides are fully sorted — one O(n+m) merge.
80
61
                MatchedItem::merge_into_sorted(&mut existing.items, items);
81
61
            }
82
        }
83
10
        None => {
84
10
            *guard = Some(ProcessedItems {
85
10
                items,
86
10
                merge: merge_strategy,
87
10
            });
88
10
        }
89
    }
90
    // Guard is dropped here, releasing the lock before we signal the render flag.
91
92
76
    needs_render.store(true, Ordering::Relaxed);
93
839
}
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
529
    fn default() -> Self {
108
529
        Self {
109
529
            stopped: Arc::new(AtomicBool::new(true)),
110
529
            interrupt: Arc::new(AtomicBool::new(false)),
111
529
            processed: Default::default(),
112
529
            matched: Default::default(),
113
529
        }
114
529
    }
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
762
    pub fn get_num_matched(&self) -> usize {
127
762
        self.matched.load(Ordering::Relaxed)
128
762
    }
129
130
    /// Signals the matcher to stop processing.
131
2.22k
    pub fn kill(&mut self) {
132
2.22k
        self.interrupt.store(true, Ordering::Relaxed);
133
2.22k
    }
134
135
    /// Returns true if the matcher has stopped (either completed or killed).
136
    #[must_use]
137
5.82k
    pub fn stopped(&self) -> bool {
138
5.82k
        self.stopped.load(Ordering::Relaxed)
139
5.82k
    }
140
}
141
142
impl Drop for MatcherControl {
143
1.37k
    fn drop(&mut self) {
144
1.37k
        self.kill();
145
1.37k
    }
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
533
    pub fn builder(engine_factory: Rc<dyn MatchEngineFactory>) -> Self {
160
533
        Self {
161
533
            engine_factory,
162
533
            case_matching: CaseMatching::default(),
163
533
            rank_builder: Arc::new(RankBuilder::default()),
164
533
        }
165
533
    }
166
167
    /// Sets the case matching mode (smart, ignore, or respect).
168
    #[must_use]
169
533
    pub fn case(mut self, case_matching: CaseMatching) -> Self {
170
533
        self.case_matching = case_matching;
171
533
        self
172
533
    }
173
174
    /// Sets the rank builder (carries tiebreak criteria).
175
    #[must_use]
176
396
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
177
396
        self.rank_builder = rank_builder;
178
396
        self
179
396
    }
180
181
    /// Finalizes the builder and returns the configured Matcher.
182
    #[must_use]
183
533
    pub fn build(self) -> Self {
184
533
        self
185
533
    }
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
398
    pub fn create_engine_factory_with_builder(options: &SkimOptions) -> (Rc<dyn MatchEngineFactory>, Arc<RankBuilder>) {
202
398
        if options.regex {
  Branch (202:12): [True: 1, False: 374]
+
72
3
                if 
matches!2
(merge_strategy, MergeStrategy::Prepend) {
73
1
                    items.append(&mut existing.items);
74
1
                    existing.items = items;
75
2
                } else {
76
2
                    existing.items.extend(items);
77
2
                }
78
64
            } else {
79
64
                // Both sides are fully sorted — one O(n+m) merge.
80
64
                MatchedItem::merge_into_sorted(&mut existing.items, items);
81
64
            }
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
78
    needs_render.store(true, Ordering::Relaxed);
93
837
}
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
524
    fn default() -> Self {
108
524
        Self {
109
524
            stopped: Arc::new(AtomicBool::new(true)),
110
524
            interrupt: Arc::new(AtomicBool::new(false)),
111
524
            processed: Default::default(),
112
524
            matched: Default::default(),
113
524
        }
114
524
    }
115
}
116
117
impl MatcherControl {
118
    /// Returns the number of items that have been processed so far.
119
    #[must_use]
120
2.60k
    pub fn get_num_processed(&self) -> usize {
121
2.60k
        self.processed.load(Ordering::Relaxed)
122
2.60k
    }
123
124
    /// Returns the number of items that have matched so far.
125
    #[must_use]
126
757
    pub fn get_num_matched(&self) -> usize {
127
757
        self.matched.load(Ordering::Relaxed)
128
757
    }
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.85k
    pub fn stopped(&self) -> bool {
138
5.85k
        self.stopped.load(Ordering::Relaxed)
139
5.85k
    }
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
528
    pub fn builder(engine_factory: Rc<dyn MatchEngineFactory>) -> Self {
160
528
        Self {
161
528
            engine_factory,
162
528
            case_matching: CaseMatching::default(),
163
528
            rank_builder: Arc::new(RankBuilder::default()),
164
528
        }
165
528
    }
166
167
    /// Sets the case matching mode (smart, ignore, or respect).
168
    #[must_use]
169
528
    pub fn case(mut self, case_matching: CaseMatching) -> Self {
170
528
        self.case_matching = case_matching;
171
528
        self
172
528
    }
173
174
    /// Sets the rank builder (carries tiebreak criteria).
175
    #[must_use]
176
391
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
177
391
        self.rank_builder = rank_builder;
178
391
        self
179
391
    }
180
181
    /// Finalizes the builder and returns the configured Matcher.
182
    #[must_use]
183
528
    pub fn build(self) -> Self {
184
528
        self
185
528
    }
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
393
    pub fn create_engine_factory_with_builder(options: &SkimOptions) -> (Rc<dyn MatchEngineFactory>, Arc<RankBuilder>) {
202
393
        if options.regex {
  Branch (202:12): [True: 1, False: 369]
 
  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
395
            let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()).tac(options.tac));
212
395
            log::debug!("Creating matcher for algo {:?}", options.algorithm);
213
395
            let fuzzy_engine_factory = ExactOrFuzzyEngineFactory::builder()
214
395
                .fuzzy_algorithm(options.algorithm)
215
395
                .exact_mode(options.exact)
216
395
                .typos(options.typos)
217
395
                .filter_mode(options.filter.is_some())
218
395
                .last_match(options.last_match)
219
395
                .rank_builder(rank_builder.clone())
220
395
                .build();
221
222
395
            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
395
            if let Some(
delimiter9
) = options.split_match {
  Branch (225:20): [True: 9, False: 365]
+
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
390
            let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()).tac(options.tac));
212
390
            log::debug!("Creating matcher for algo {:?}", options.algorithm);
213
390
            let fuzzy_engine_factory = ExactOrFuzzyEngineFactory::builder()
214
390
                .fuzzy_algorithm(options.algorithm)
215
390
                .exact_mode(options.exact)
216
390
                .typos(options.typos)
217
390
                .filter_mode(options.filter.is_some())
218
390
                .last_match(options.last_match)
219
390
                .rank_builder(rank_builder.clone())
220
390
                .build();
221
222
390
            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
390
            if let Some(
delimiter9
) = options.split_match {
  Branch (225:20): [True: 9, False: 360]
 
  Branch (225:20): [True: 0, False: 21]
-
226
9
                factory = Box::new(SplitMatchEngineFactory::new(factory, delimiter));
227
386
            }
228
229
            // Wrap with AndOrEngineFactory so that queries like "foo:bar baz:qux" work
230
395
            factory = Box::new(AndOrEngineFactory::new(factory));
231
232
            // Wrap with NormalizedEngineFactory if normalization is requested
233
395
            if options.normalize {
  Branch (233:16): [True: 11, False: 363]
+
226
9
                factory = Box::new(SplitMatchEngineFactory::new(factory, delimiter));
227
381
            }
228
229
            // Wrap with AndOrEngineFactory so that queries like "foo:bar baz:qux" work
230
390
            factory = Box::new(AndOrEngineFactory::new(factory));
231
232
            // Wrap with NormalizedEngineFactory if normalization is requested
233
390
            if options.normalize {
  Branch (233:16): [True: 11, False: 358]
 
  Branch (233:16): [True: 0, False: 21]
-
234
11
                factory = Box::new(NormalizedEngineFactory::new(factory));
235
384
            }
236
237
395
            let factory: Rc<dyn MatchEngineFactory> = Rc::new(factory);
238
395
            (factory, rank_builder)
239
        }
240
398
    }
241
242
    /// Creates a Matcher configured from the given `SkimOptions`.
243
    #[must_use]
244
396
    pub fn from_options(options: &SkimOptions) -> Self {
245
396
        let (engine_factory, rank_builder) = Self::create_engine_factory_with_builder(options);
246
396
        Matcher::builder(engine_factory)
247
396
            .case(options.case)
248
396
            .rank_builder(rank_builder)
249
396
            .build()
250
396
    }
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
843
    pub(crate) fn run(
274
843
        &self,
275
843
        query: &str,
276
843
        item_pool: &Arc<ItemPool>,
277
843
        thread_pool: &Arc<ThreadPool>,
278
843
        processed_items: Arc<SpinLock<Option<ProcessedItems>>>,
279
843
        merge_strategy: MergeStrategy,
280
843
        no_sort: bool,
281
843
        tac: bool,
282
843
        needs_render: Arc<AtomicBool>,
283
843
    ) -> MatcherControl {
284
843
        let matcher_engine = self.engine_factory.create_engine_with_case(query, self.case_matching);
285
843
        debug!("engine: {matcher_engine}");
286
843
        let stopped = Arc::new(AtomicBool::new(false));
287
843
        let stopped_clone = stopped.clone();
288
843
        let interrupt = Arc::new(AtomicBool::new(false));
289
843
        let interrupt_clone = interrupt.clone();
290
843
        let processed = Arc::new(AtomicUsize::new(0));
291
843
        let processed_clone = processed.clone();
292
843
        let matched = Arc::new(AtomicUsize::new(0));
293
843
        let matched_clone = matched.clone();
294
843
        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
843
        let start = item_pool.num_taken();
301
843
        let items = item_pool.take();
302
843
        let total = items.len();
303
843
        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
843
        let num_workers = thread_pool.num_threads();
309
843
        let pool_for_work = Arc::clone(thread_pool);
310
311
843
        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
843
            let shared_items: Arc<[Arc<dyn SkimItem>]> = items.into();
324
325
            // Clones for the process_chunk closure.
326
843
            let matcher_engine: Arc<dyn crate::MatchEngine> = Arc::from(matcher_engine);
327
843
            let interrupt_for_work = Arc::clone(&interrupt);
328
843
            let processed_for_work = Arc::clone(&processed);
329
843
            let matched_for_work = Arc::clone(&matched);
330
843
            let rank_builder_for_work = Arc::clone(&rank_builder);
331
332
843
            thread_pool::parallel_work_queue(
333
843
                &pool_for_work,
334
843
                num_workers,
335
843
                &shared_items,
336
                CHUNK_SIZE,
337
843
                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
710
                move |chunk_start, chunk: &[Arc<dyn crate::SkimItem>]| {
342
                    // Check interrupt before processing this chunk.
343
710
                    if interrupt_for_work.load(Ordering::Relaxed) {
  Branch (343:24): [True: 0, False: 693]
-
  Branch (343:24): [True: 7, False: 10]
-
344
7
                        return Vec::new();
345
703
                    }
346
347
703
                    let mut local_matches = Vec::new();
348
703
                    let mut chunk_matched: usize = 0;
349
350
52.8k
                    for (i, item) in 
chunk703
.
iter703
().
enumerate703
() {
351
52.8k
                        if let Some(
match_result51.8k
) = matcher_engine.match_item(item.as_ref()) {
  Branch (351:32): [True: 51.8k, False: 968]
-
  Branch (351:32): [True: 19, False: 1]
-
352
51.8k
                            chunk_matched += 1;
353
51.8k
                            let mut rank = match_result.rank;
354
51.8k
                            let batch_index = chunk_start + i;
355
51.8k
                            // `take()` reverses each tac batch, so recover the
356
51.8k
                            // item's stable ordinal in the original input stream.
357
51.8k
                            let index = input_index(tac, start, total, batch_index);
358
51.8k
                            rank.index = i32::try_from(index).unwrap_or(i32::MAX);
359
51.8k
                            local_matches.push(MatchedItem::new(
360
51.8k
                                Arc::clone(item),
361
51.8k
                                rank,
362
51.8k
                                Some(match_result.matched_range),
363
51.8k
                                &rank_builder_for_work,
364
51.8k
                            ));
365
51.8k
                        
}969
366
                    }
367
368
                    // Flush counters for this chunk so the UI sees progress.
369
703
                    processed_for_work.fetch_add(chunk.len(), Ordering::Relaxed);
370
703
                    if chunk_matched > 0 {
  Branch (370:24): [True: 599, False: 94]
-
  Branch (370:24): [True: 10, False: 0]
-
371
609
                        matched_for_work.fetch_add(chunk_matched, Ordering::Relaxed);
372
609
                    
}94
373
374
703
                    local_matches
375
710
                },
376
                // reduce – accumulate chunk matches into the worker-local Vec.
377
                // No sorting here — that would be O(m²/chunk_size) per worker.
378
692
                |acc: &mut Vec<MatchedItem>, mut partial: Vec<MatchedItem>| {
379
692
                    if acc.len() >= partial.len() {
  Branch (379:24): [True: 94, False: 581]
-
  Branch (379:24): [True: 7, False: 10]
-
380
101
                        acc.extend(partial);
381
591
                    } else {
382
591
                        partial.append(acc);
383
591
                        *acc = partial;
384
591
                    }
385
692
                },
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.38k
                |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
843
                |worker_results: Vec<Vec<MatchedItem>>| {
400
843
                    if interrupt.load(Ordering::SeqCst) {
  Branch (400:24): [True: 0, False: 819]
-
  Branch (400:24): [True: 10, False: 14]
-
401
10
                        return;
402
833
                    }
403
404
833
                    merge_worker_results(worker_results, no_sort, &processed_items, merge_strategy, &needs_render);
405
843
                },
406
            );
407
843
            stopped.store(true, Ordering::Relaxed);
408
843
        });
409
410
843
        MatcherControl {
411
843
            stopped: stopped_clone,
412
843
            interrupt: interrupt_clone,
413
843
            matched: matched_clone,
414
843
            processed: processed_clone,
415
843
        }
416
843
    }
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 +
234
11
                factory = Box::new(NormalizedEngineFactory::new(factory));
235
379
            }
236
237
390
            let factory: Rc<dyn MatchEngineFactory> = Rc::new(factory);
238
390
            (factory, rank_builder)
239
        }
240
393
    }
241
242
    /// Creates a Matcher configured from the given `SkimOptions`.
243
    #[must_use]
244
391
    pub fn from_options(options: &SkimOptions) -> Self {
245
391
        let (engine_factory, rank_builder) = Self::create_engine_factory_with_builder(options);
246
391
        Matcher::builder(engine_factory)
247
391
            .case(options.case)
248
391
            .rank_builder(rank_builder)
249
391
            .build()
250
391
    }
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
840
    pub(crate) fn run(
274
840
        &self,
275
840
        query: &str,
276
840
        item_pool: &Arc<ItemPool>,
277
840
        thread_pool: &Arc<ThreadPool>,
278
840
        processed_items: Arc<SpinLock<Option<ProcessedItems>>>,
279
840
        merge_strategy: MergeStrategy,
280
840
        no_sort: bool,
281
840
        tac: bool,
282
840
        needs_render: Arc<AtomicBool>,
283
840
    ) -> MatcherControl {
284
840
        let matcher_engine = self.engine_factory.create_engine_with_case(query, self.case_matching);
285
840
        debug!("engine: {matcher_engine}");
286
840
        let stopped = Arc::new(AtomicBool::new(false));
287
840
        let stopped_clone = stopped.clone();
288
840
        let interrupt = Arc::new(AtomicBool::new(false));
289
840
        let interrupt_clone = interrupt.clone();
290
840
        let processed = Arc::new(AtomicUsize::new(0));
291
840
        let processed_clone = processed.clone();
292
840
        let matched = Arc::new(AtomicUsize::new(0));
293
840
        let matched_clone = matched.clone();
294
840
        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
840
        let start = item_pool.num_taken();
301
840
        let items = item_pool.take();
302
840
        let total = items.len();
303
840
        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
840
        let num_workers = thread_pool.num_threads();
309
840
        let pool_for_work = Arc::clone(thread_pool);
310
311
840
        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
840
            let shared_items: Arc<[Arc<dyn SkimItem>]> = items.into();
324
325
            // Clones for the process_chunk closure.
326
840
            let matcher_engine: Arc<dyn crate::MatchEngine> = Arc::from(matcher_engine);
327
840
            let interrupt_for_work = Arc::clone(&interrupt);
328
840
            let processed_for_work = Arc::clone(&processed);
329
840
            let matched_for_work = Arc::clone(&matched);
330
840
            let rank_builder_for_work = Arc::clone(&rank_builder);
331
332
840
            thread_pool::parallel_work_queue(
333
840
                &pool_for_work,
334
840
                num_workers,
335
840
                &shared_items,
336
                CHUNK_SIZE,
337
840
                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
708
                move |chunk_start, chunk: &[Arc<dyn crate::SkimItem>]| {
342
                    // Check interrupt before processing this chunk.
343
708
                    if interrupt_for_work.load(Ordering::Relaxed) {
  Branch (343:24): [True: 0, False: 693]
+
  Branch (343:24): [True: 4, False: 11]
+
344
4
                        return Vec::new();
345
704
                    }
346
347
704
                    let mut local_matches = Vec::new();
348
704
                    let mut chunk_matched: usize = 0;
349
350
52.8k
                    for (i, item) in 
chunk704
.
iter704
().
enumerate704
() {
351
52.8k
                        if let Some(
match_result51.8k
) = matcher_engine.match_item(item.as_ref()) {
  Branch (351:32): [True: 51.8k, False: 968]
+
  Branch (351:32): [True: 20, False: 1]
+
352
51.8k
                            chunk_matched += 1;
353
51.8k
                            let mut rank = match_result.rank;
354
51.8k
                            let batch_index = chunk_start + i;
355
51.8k
                            // `take()` reverses each tac batch, so recover the
356
51.8k
                            // item's stable ordinal in the original input stream.
357
51.8k
                            let index = input_index(tac, start, total, batch_index);
358
51.8k
                            rank.index = i32::try_from(index).unwrap_or(i32::MAX);
359
51.8k
                            local_matches.push(MatchedItem::new(
360
51.8k
                                Arc::clone(item),
361
51.8k
                                rank,
362
51.8k
                                Some(match_result.matched_range),
363
51.8k
                                &rank_builder_for_work,
364
51.8k
                            ));
365
51.8k
                        
}969
366
                    }
367
368
                    // Flush counters for this chunk so the UI sees progress.
369
704
                    processed_for_work.fetch_add(chunk.len(), Ordering::Relaxed);
370
704
                    if chunk_matched > 0 {
  Branch (370:24): [True: 599, False: 94]
+
  Branch (370:24): [True: 11, False: 0]
+
371
610
                        matched_for_work.fetch_add(chunk_matched, Ordering::Relaxed);
372
610
                    
}94
373
374
704
                    local_matches
375
708
                },
376
                // reduce – accumulate chunk matches into the worker-local Vec.
377
                // No sorting here — that would be O(m²/chunk_size) per worker.
378
691
                |acc: &mut Vec<MatchedItem>, mut partial: Vec<MatchedItem>| {
379
691
                    if acc.len() >= partial.len() {
  Branch (379:24): [True: 94, False: 582]
+
  Branch (379:24): [True: 4, False: 11]
+
380
98
                        acc.extend(partial);
381
593
                    } else {
382
593
                        partial.append(acc);
383
593
                        *acc = partial;
384
593
                    }
385
691
                },
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.38k
                |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: 816]
+
  Branch (400:24): [True: 7, False: 15]
+
401
7
                        return;
402
831
                    }
403
404
831
                    merge_worker_results(worker_results, no_sort, &processed_items, merge_strategy, &needs_render);
405
838
                },
406
            );
407
840
            stopped.store(true, Ordering::Relaxed);
408
840
        });
409
410
840
        MatcherControl {
411
840
            stopped: stopped_clone,
412
840
            interrupt: interrupt_clone,
413
840
            matched: matched_clone,
414
840
            processed: processed_clone,
415
840
        }
416
840
    }
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 21d9d5d5..46f20f76 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,34 +1,34 @@ -

Coverage Report

Created: 2026-08-22 15:34

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
#[cfg(feature = "cli")]
27
/// Custom value parser for delimiter that handles escape sequences
28
469
fn parse_delimiter_value(s: &str) -> Result<Regex, String> {
29
469
    let unescaped = crate::util::unescape_delimiter(s);
30
469
    Regex::new(&unescaped).map_err(|e| 
format!0
("Invalid regex delimiter: {e}"))
31
469
}
32
33
/// Custom value parser for border
34
///
35
/// Any undefined value falls back to [`BorderType::Plain`] (see the `FromStr` impl in `tui`)
36
/// instead of producing a parse error like the default `ValueEnum` parser would, while still
37
/// advertising the known variants in `--help` and shell completions by delegating
38
/// [`possible_values`](clap::builder::TypedValueParser::possible_values) to
39
/// [`BorderType`]'s [`ValueEnum`](clap::ValueEnum) members.
40
#[cfg(feature = "cli")]
41
#[derive(Clone)]
42
struct BorderValueParser;
43
44
#[cfg(feature = "cli")]
45
impl clap::builder::TypedValueParser for BorderValueParser {
46
    type Value = BorderType;
47
48
467
    fn parse_ref(
49
467
        &self,
50
467
        _cmd: &clap::Command,
51
467
        _arg: Option<&clap::Arg>,
52
467
        value: &std::ffi::OsStr,
53
467
    ) -> Result<Self::Value, clap::Error> {
54
        // `FromStr for BorderType` is infallible: unknown values map to `BorderType::Plain`.
55
467
        Ok(value.to_string_lossy().parse().unwrap_or(BorderType::Plain))
56
467
    }
57
58
17
    fn possible_values(&self) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
59
17
        Some(Box::new(
60
17
            <BorderType as clap::ValueEnum>::value_variants()
61
17
                .iter()
62
17
                .filter_map(clap::ValueEnum::to_possible_value),
63
17
        ))
64
17
    }
65
}
66
67
#[cfg(feature = "cli")]
68
/// Custom value parser for typo tolerance
69
///
70
/// - `"smart"` → `Typos::Smart` (adaptive: `pattern_length` / 4)
71
/// - `"disabled"` → `Typos::Disabled`
72
/// - `"N"` (N >= 0) → `Typos::Fixed(N)`
73
467
fn parse_typos(s: &str) -> Result<Typos, String> {
74
467
    if s.eq_ignore_ascii_case("smart") {
  Branch (74:8): [True: 3, False: 453]
+

Coverage Report

Created: 2026-09-01 09:15

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

Coverage Report

Created: 2026-08-22 15:34

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-01 09:15

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): [True: 6, False: 23]
+
  Branch (52:16): [Folded - Ignored]
 
  Branch (52:16): [True: 1, False: 11]
-
  Branch (52:16): [True: 6, False: 23]
-
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]
+
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): [True: 2, False: 21]
+
  Branch (71:12): [Folded - Ignored]
 
  Branch (71:12): [True: 1, False: 10]
-
  Branch (71:12): [True: 2, False: 21]
-
72
3
            write!(out, "{}{}", self.query, opts.output_ending)
?0
;
73
31
        }
74
75
34
        if opts.print_cmd {
  Branch (75:12): [Folded - Ignored]
+
72
3
            write!(out, "{}{}", self.query, opts.output_ending)
?0
;
73
31
        }
74
75
34
        if opts.print_cmd {
  Branch (75:12): [True: 1, False: 22]
+
  Branch (75:12): [Folded - Ignored]
 
  Branch (75:12): [True: 1, False: 10]
-
  Branch (75:12): [True: 1, False: 22]
-
76
2
            write!(out, "{}{}", self.cmd, opts.output_ending)
?0
;
77
32
        }
78
79
34
        if opts.print_header {
  Branch (79:12): [Folded - Ignored]
+
76
2
            write!(out, "{}{}", self.cmd, opts.output_ending)
?0
;
77
32
        }
78
79
34
        if opts.print_header {
  Branch (79:12): [True: 1, False: 22]
+
  Branch (79:12): [Folded - Ignored]
 
  Branch (79:12): [True: 1, False: 10]
-
  Branch (79:12): [True: 1, False: 22]
-
80
2
            write!(out, "{}{}", self.header, opts.output_ending)
?0
;
81
32
        }
82
83
34
        if opts.print_current {
  Branch (83:12): [Folded - Ignored]
+
80
2
            write!(out, "{}{}", self.header, opts.output_ending)
?0
;
81
32
        }
82
83
34
        if opts.print_current {
  Branch (83:12): [True: 1, False: 22]
+
  Branch (83:12): [Folded - Ignored]
 
  Branch (83:12): [True: 2, False: 9]
-
  Branch (83:12): [True: 1, False: 22]
-
84
3
            if let Some(
ref current2
) = self.current {
  Branch (84:20): [Folded - Ignored]
+
84
3
            if let Some(
ref current2
) = self.current {
  Branch (84:20): [True: 1, False: 0]
+
  Branch (84:20): [Folded - Ignored]
 
  Branch (84:20): [True: 1, False: 1]
-
  Branch (84:20): [True: 1, False: 0]
-
85
2
                write!(out, "{}{}", current.output(), opts.output_ending)
?0
;
86
            } else {
87
1
                write!(out, "{}", opts.output_ending)
?0
;
88
            }
89
31
        }
90
91
33
        if let Event::Action(Action::Accept(Some(
accept_key2
))) = &self.final_event {
  Branch (91:16): [Folded - Ignored]
+
85
2
                write!(out, "{}{}", current.output(), opts.output_ending)
?0
;
86
            } else {
87
1
                write!(out, "{}", opts.output_ending)
?0
;
88
            }
89
31
        }
90
91
33
        if let Event::Action(Action::Accept(Some(
accept_key2
))) = &self.final_event {
  Branch (91:16): [True: 1, False: 22]
+
  Branch (91:16): [Folded - Ignored]
 
  Branch (91:16): [True: 1, False: 10]
-
  Branch (91:16): [True: 1, False: 22]
-
92
2
            write!(out, "{}{}", accept_key, opts.output_ending)
?0
;
93
32
        }
94
95
50.0k
        for item in 
&self.selected_items34
{
96
50.0k
            if opts.strip_ansi {
  Branch (96:16): [Folded - Ignored]
+
92
2
            write!(out, "{}{}", accept_key, opts.output_ending)
?0
;
93
32
        }
94
95
50.0k
        for item in 
&self.selected_items34
{
96
50.0k
            if opts.strip_ansi {
  Branch (96:16): [True: 0, False: 50.0k]
+
  Branch (96:16): [Folded - Ignored]
 
  Branch (96:16): [True: 2, False: 13]
-
  Branch (96:16): [True: 0, False: 50.0k]
-
97
2
                write!(
98
2
                    out,
99
                    "{}{}",
100
2
                    crate::helper::item::strip_ansi(&item.output()).0,
101
                    opts.output_ending
102
0
                )?;
103
            } else {
104
50.0k
                write!(out, "{}{}", item.output(), opts.output_ending)
?0
;
105
            }
106
50.0k
            if opts.print_score {
  Branch (106:16): [Folded - Ignored]
+
97
2
                write!(
98
2
                    out,
99
                    "{}{}",
100
2
                    crate::helper::item::strip_ansi(&item.output()).0,
101
                    opts.output_ending
102
0
                )?;
103
            } else {
104
50.0k
                write!(out, "{}{}", item.output(), opts.output_ending)
?0
;
105
            }
106
50.0k
            if opts.print_score {
  Branch (106:16): [True: 1, False: 50.0k]
+
  Branch (106:16): [Folded - Ignored]
 
  Branch (106:16): [True: 2, False: 13]
-
  Branch (106:16): [True: 1, False: 50.0k]
 
107
3
                write!(out, "{}{}", item.rank.score, opts.output_ending)
?0
;
108
50.0k
            }
109
        }
110
111
34
        Ok(())
112
41
    }
113
}
114
115
/// Options controlling how a [`SkimOutput`] is serialized to the terminal.
116
///
117
/// These mirror the CLI's output-related flags (`--print-query`, `--print0`,
118
/// `--print-score`, …) and are derived from [`SkimOptions`] via
119
/// [`BinOptions::from_opts`].
120
#[derive(Builder)]
121
#[allow(missing_docs, clippy::struct_excessive_bools)]
122
pub struct BinOptions {
123
    output_ending: String,
124
    print_query: bool,
125
    print_cmd: bool,
126
    print_score: bool,
127
    print_header: bool,
128
    print_current: bool,
129
    strip_ansi: bool,
130
    output_format: Option<String>,
131
    delimiter: regex::Regex,
132
    replstr: String,
133
}
134
135
impl BinOptions {
136
    /// Build the output options from the parsed [`SkimOptions`].
137
    #[must_use]
138
50
    pub fn from_opts(opts: &SkimOptions) -> Self {
139
        Self {
140
50
            print_query: opts.print_query,
141
50
            print_cmd: opts.print_cmd,
142
50
            print_score: opts.print_score,
143
50
            print_header: opts.print_header,
144
50
            print_current: opts.print_current,
145
50
            output_ending: String::from(if opts.print0 { 
"\0"3
} else {
"\n"47
}),
  Branch (145:44): [True: 1, False: 33]
 
  Branch (145:44): [True: 2, False: 14]
 
146
50
            strip_ansi: opts.ansi && 
!opts.no_strip_ansi3
,
  Branch (146:25): [True: 0, False: 34]
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 c35abdb9..2fa8c568 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-08-22 15:34

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-01 09:15

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 2f25fe2b..2fa803a0 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-08-22 15:34

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-01 09:15

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 17cf3af2..e7db3069 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-08-22 15:34

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-01 09:15

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 8cee527f..c8445c66 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-08-22 15:34

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
520
    pub fn kill(&mut self) {
48
520
        debug!(
49
            "kill reader, components before: {}",
50
2
            self.components_to_stop.load(Ordering::SeqCst)
51
        );
52
53
520
        let _ = self.tx_interrupt_cmd.clone().map(|tx| 
tx113
.
send113
(1));
54
520
        let _ = self.tx_interrupt.send(1);
55
3.09k
        while self.components_to_stop.load(Ordering::SeqCst) != 0 
{}2.57k
  Branch (55:15): [True: 0, False: 490]
-
  Branch (55:15): [True: 2.57k, False: 30]
-
56
520
    }
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: 143]
-
  Branch (71:9): [True: 25, False: 4]
-
72
10.4k
    }
73
}
74
75
impl Drop for ReaderControl {
76
441
    fn drop(&mut self) {
77
441
        self.kill();
78
441
    }
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
391
    pub fn from_options(options: &SkimOptions) -> Self {
91
391
        Self {
92
391
            cmd_collector: options.cmd_collector.clone(),
93
391
            rx_item: None,
94
391
        }
95
391
    }
96
97
    /// Sets the item source (if None, will use command collector)
98
    #[must_use]
99
396
    pub fn source(mut self, rx_item: Option<SkimItemReceiver>) -> Self {
100
396
        self.rx_item = rx_item;
101
396
        self
102
396
    }
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
391
    pub fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
108
391
        self.cmd_collector.borrow_mut().set_thread_pool(pool);
109
391
    }
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
439
    pub fn collect(&mut self, item_pool: Arc<ItemPool>, cmd: &str) -> ReaderControl {
139
439
        let components_to_stop: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
140
439
        let items = Arc::new(SpinLock::new(Vec::new()));
141
142
439
        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
370
            |rx| (rx, None),
149
        );
150
151
439
        let components_to_stop_clone = components_to_stop.clone();
152
439
        let tx_interrupt = collect_items(components_to_stop_clone, rx_item, move |items| 
{414
153
414
            item_pool.append(items);
154
414
        });
155
439
        debug!("collect: started ({components_to_stop:?} components)");
156
157
439
        ReaderControl {
158
439
            tx_interrupt,
159
439
            tx_interrupt_cmd,
160
439
            components_to_stop,
161
439
            items,
162
439
        }
163
439
    }
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
441
fn collect_items<F>(components_to_stop: Arc<AtomicUsize>, rx_item: SkimItemReceiver, callback: F) -> Sender<i32>
176
441
where
177
441
    F: Fn(Vec<Arc<dyn SkimItem>>) + Send + 'static,
178
{
179
441
    let (tx_interrupt, rx_interrupt) = crate::prelude::bounded(8);
180
181
441
    let started = Arc::new(AtomicBool::new(false));
182
441
    let started_clone = started.clone();
183
441
    std::thread::spawn(move || {
184
441
        debug!("collect_item start");
185
441
        components_to_stop.fetch_add(1, Ordering::SeqCst);
186
441
        started_clone.store(true, Ordering::SeqCst); // notify parent that it is started
187
188
        loop {
189
886
            if let Ok(Some(
msg1
)) = rx_interrupt.try_recv() {
  Branch (189:20): [True: 0, False: 0]
-  Branch (189:20): [True: 0, False: 839]
-
  Branch (189:20): [True: 0, False: 4]
-  Branch (189:20): [True: 1, False: 42]
-
190
1
                debug!("interrupt: {msg}");
191
1
                break;
192
885
            }
193
885
            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
29
                Err(kanal::ReceiveErrorTimeout::Timeout) => {
199
29
                    // No items within the timeout — loop back to check the
200
29
                    // interrupt channel before blocking again.
201
29
                }
202
                Err(kanal::ReceiveErrorTimeout::Closed | kanal::ReceiveErrorTimeout::SendClosed) => {
203
440
                    break;
204
                }
205
            }
206
        }
207
208
441
        components_to_stop.fetch_sub(1, Ordering::SeqCst);
209
441
        debug!("collect_item stop");
210
441
    });
211
212
66.2M
    while !started.load(Ordering::SeqCst) {
  Branch (212:11): [True: 0, False: 0]
-  Branch (212:11): [True: 62.0M, False: 418]
-
  Branch (212:11): [True: 380k, False: 2]
-  Branch (212:11): [True: 3.74M, False: 21]
-
213
66.2M
        // busy waiting for the thread to start. (components_to_stop is added)
214
66.2M
    }
215
216
441
    tx_interrupt
217
441
}
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 +

Coverage Report

Created: 2026-09-01 09:15

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
515
    pub fn kill(&mut self) {
48
515
        debug!(
49
            "kill reader, components before: {}",
50
2
            self.components_to_stop.load(Ordering::SeqCst)
51
        );
52
53
515
        let _ = self.tx_interrupt_cmd.clone().map(|tx| 
tx113
.
send113
(1));
54
515
        let _ = self.tx_interrupt.send(1);
55
515
        while self.components_to_stop.load(Ordering::SeqCst) != 0 
{}0
  Branch (55:15): [True: 0, False: 485]
+
  Branch (55:15): [True: 0, False: 30]
+
56
515
    }
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.3k
    pub fn is_done(&self) -> bool {
70
10.3k
        let items = self.items.lock();
71
10.3k
        self.components_to_stop.load(Ordering::SeqCst) == 0 && 
items.is_empty()10.2k
  Branch (71:9): [True: 10.2k, False: 142]
+
  Branch (71:9): [True: 23, False: 6]
+
72
10.3k
    }
73
}
74
75
impl Drop for ReaderControl {
76
436
    fn drop(&mut self) {
77
436
        self.kill();
78
436
    }
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
386
    pub fn from_options(options: &SkimOptions) -> Self {
91
386
        Self {
92
386
            cmd_collector: options.cmd_collector.clone(),
93
386
            rx_item: None,
94
386
        }
95
386
    }
96
97
    /// Sets the item source (if None, will use command collector)
98
    #[must_use]
99
391
    pub fn source(mut self, rx_item: Option<SkimItemReceiver>) -> Self {
100
391
        self.rx_item = rx_item;
101
391
        self
102
391
    }
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
386
    pub fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
108
386
        self.cmd_collector.borrow_mut().set_thread_pool(pool);
109
386
    }
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
434
    pub fn collect(&mut self, item_pool: Arc<ItemPool>, cmd: &str) -> ReaderControl {
139
434
        let components_to_stop: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
140
434
        let items = Arc::new(SpinLock::new(Vec::new()));
141
142
434
        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
365
            |rx| (rx, None),
149
        );
150
151
434
        let components_to_stop_clone = components_to_stop.clone();
152
434
        let tx_interrupt = collect_items(components_to_stop_clone, rx_item, move |items| 
{409
153
409
            item_pool.append(items);
154
409
        });
155
434
        debug!("collect: started ({components_to_stop:?} components)");
156
157
434
        ReaderControl {
158
434
            tx_interrupt,
159
434
            tx_interrupt_cmd,
160
434
            components_to_stop,
161
434
            items,
162
434
        }
163
434
    }
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
436
fn collect_items<F>(components_to_stop: Arc<AtomicUsize>, rx_item: SkimItemReceiver, callback: F) -> Sender<i32>
176
436
where
177
436
    F: Fn(Vec<Arc<dyn SkimItem>>) + Send + 'static,
178
{
179
436
    let (tx_interrupt, rx_interrupt) = crate::prelude::bounded(8);
180
181
436
    let started = Arc::new(AtomicBool::new(false));
182
436
    let started_clone = started.clone();
183
436
    std::thread::spawn(move || {
184
436
        debug!("collect_item start");
185
436
        components_to_stop.fetch_add(1, Ordering::SeqCst);
186
436
        started_clone.store(true, Ordering::SeqCst); // notify parent that it is started
187
188
        loop {
189
904
            if let Ok(Some(
msg0
)) = rx_interrupt.try_recv() {
  Branch (189:20): [True: 0, False: 0]
+  Branch (189:20): [True: 0, False: 854]
+
  Branch (189:20): [True: 0, False: 7]
+  Branch (189:20): [True: 0, False: 43]
+
190
0
                debug!("interrupt: {msg}");
191
0
                break;
192
904
            }
193
904
            match rx_item.recv_timeout(std::time::Duration::from_millis(1)) {
194
411
                Ok(items) => {
195
411
                    trace!("collect_item: got {} items", 
items2
.
len2
());
196
411
                    callback(items);
197
                }
198
57
                Err(kanal::ReceiveErrorTimeout::Timeout) => {
199
57
                    // No items within the timeout — loop back to check the
200
57
                    // interrupt channel before blocking again.
201
57
                }
202
                Err(kanal::ReceiveErrorTimeout::Closed | kanal::ReceiveErrorTimeout::SendClosed) => {
203
436
                    break;
204
                }
205
            }
206
        }
207
208
436
        components_to_stop.fetch_sub(1, Ordering::SeqCst);
209
436
        debug!("collect_item stop");
210
436
    });
211
212
22.7M
    while !started.load(Ordering::SeqCst) {
  Branch (212:11): [True: 0, False: 0]
+  Branch (212:11): [True: 21.5M, False: 413]
+
  Branch (212:11): [True: 268k, False: 2]
+  Branch (212:11): [True: 984k, False: 21]
+
213
22.7M
        // busy waiting for the thread to start. (components_to_stop is added)
214
22.7M
    }
215
216
436
    tx_interrupt
217
436
}
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 45507052..7b5a6eed 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,7 +1,7 @@ -

Coverage Report

Created: 2026-08-22 15:34

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-01 09:15

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): [True: 0, False: 2]
+
  Branch (31:8): [Folded - Ignored]
 
  Branch (31:8): [True: 1, False: 5]
-
  Branch (31:8): [True: 0, False: 2]
-
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]
+
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): [True: 1, False: 0]
+
  Branch (57:8): [Folded - Ignored]
 
  Branch (57:8): [True: 3, False: 3]
-
  Branch (57:8): [True: 1, False: 0]
 
58
4
        output.write_all(binds_script.as_bytes())
?0
;
59
3
    }
60
7
    Ok(())
61
7
}
62
63
#[cfg(test)]
64
#[path = "shell_tests.rs"]
65
mod tests;
\ No newline at end of file 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 e32a9d55..26c97843 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-08-22 15:34

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
29
    pub fn run_with(options: SkimOptions, source: Option<SkimItemReceiver>) -> Result<SkimOutput> {
73
29
        trace!("running skim");
74
29
        let mut skim = Self::init(options, source)
?0
;
75
76
29
        skim.start();
77
78
29
        if skim.should_enter() {
  Branch (78:12): [True: 7, False: 22]
+

Coverage Report

Created: 2026-09-01 09:15

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

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
344
    fn disabled(&self) -> bool {
91
344
        false
92
344
    }
93
}
94
95
//------------------------------------------------------------------------------
96
// Implement SkimItem for raw strings
97
98
impl<T: AsRef<str> + Send + Sync + 'static> SkimItem for T {
99
588
    fn text(&self) -> Cow<'_, str> {
100
588
        Cow::Borrowed(self.as_ref())
101
588
    }
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-01 09:15

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
586
    fn text(&self) -> Cow<'_, str> {
100
586
        Cow::Borrowed(self.as_ref())
101
586
    }
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 9da42fbd..75105d1c 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-08-22 15:34

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
94.9k
        while self
  Branch (50:15): [True: 0, False: 3.49k]
-  Branch (50:15): [True: 11.7k, False: 15.4k]
+

Coverage Report

Created: 2026-09-01 09:15

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.05k
    pub fn new(t: T) -> SpinLock<T> {
40
2.05k
        Self {
41
2.05k
            locked: AtomicBool::new(false),
42
2.05k
            data: UnsafeCell::new(t),
43
2.05k
        }
44
2.05k
    }
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
52.0k
        while self
  Branch (50:15): [True: 26.4k, False: 15.4k]
+  Branch (50:15): [True: 0, False: 3.47k]
   Branch (50:15): [True: 0, False: 2]
-  Branch (50:15): [True: 0, False: 68]
-  Branch (50:15): [True: 26, False: 184]
-  Branch (50:15): [True: 57.9k, False: 6.00k]
+  Branch (50:15): [True: 16, False: 182]
+  Branch (50:15): [True: 0, False: 67]
+  Branch (50:15): [True: 377, False: 6.00k]
   Branch (50:15): [True: 0, False: 2]
-
51
94.9k
            .locked
52
94.9k
            .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed)
53
94.9k
            .is_err()
54
69.7k
        {
55
69.7k
            core::hint::spin_loop();
56
69.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
52.0k
            .locked
52
52.0k
            .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed)
53
52.0k
            .is_err()
54
26.8k
        {
55
26.8k
            core::hint::spin_loop();
56
26.8k
        }
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 fffbcc65..23462356 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,8 +1,8 @@ -

Coverage Report

Created: 2026-08-22 15:34

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
294
    fn default() -> Self {
50
294
        ColorTheme::dark256()
51
294
    }
52
}
53
54
#[allow(dead_code)]
55
impl ColorTheme {
56
    /// Setup the theme from the skim options
57
    #[must_use]
58
394
    pub fn init_from_options(options: &SkimOptions) -> ColorTheme {
59
        // register
60
394
        if let Some(
color6
) = options.color.clone() {
  Branch (60:16): [True: 5, False: 370]
+

Coverage Report

Created: 2026-09-01 09:15

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
292
    fn default() -> Self {
50
292
        ColorTheme::dark256()
51
292
    }
52
}
53
54
#[allow(dead_code)]
55
impl ColorTheme {
56
    /// Setup the theme from the skim options
57
    #[must_use]
58
389
    pub fn init_from_options(options: &SkimOptions) -> ColorTheme {
59
        // register
60
389
        if let Some(
color6
) = options.color.clone() {
  Branch (60:16): [True: 5, False: 365]
 
  Branch (60:16): [True: 1, False: 18]
-
61
6
            ColorTheme::from_options(&color)
62
        } else {
63
            // Check for NO_COLOR environment variable
64
388
            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
383
            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
387
                _ => ColorTheme::dark256(),
67
            }
68
        }
69
394
    }
70
71
802
    fn none() -> Self {
72
802
        let def = Style::default();
73
802
        Self {
74
802
            spinner: Style::default().bold(),
75
802
            normal: def,
76
802
            matched: def,
77
802
            current: def,
78
802
            current_match: def,
79
802
            query: def,
80
802
            info: def,
81
802
            prompt: def,
82
802
            cursor: def,
83
802
            selected: def,
84
802
            header: def,
85
802
            border: def,
86
802
            scrollbar: def,
87
802
        }
88
802
    }
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
775
    fn dark256() -> Self {
119
775
        let base = ColorTheme::none();
120
775
        ColorTheme {
121
775
            matched: base.matched.fg(Color::Indexed(108)).bg(Color::Indexed(0)),
122
775
            current: base.current.bg(Color::Indexed(236)),
123
775
            current_match: base.current_match.fg(Color::Indexed(151)).bg(Color::Indexed(236)),
124
775
            spinner: base.spinner.fg(Color::Indexed(148)),
125
775
            info: base.info.fg(Color::Indexed(144)),
126
775
            prompt: base.prompt.fg(Color::Indexed(110)),
127
775
            cursor: base.cursor.fg(Color::Indexed(161)),
128
775
            selected: base.selected.fg(Color::Indexed(168)),
129
775
            header: base.header.fg(Color::Indexed(109)),
130
775
            border: base.border.fg(Color::Indexed(59)),
131
775
            scrollbar: base.scrollbar.fg(Color::Indexed(59)),
132
775
            ..base
133
775
        }
134
775
    }
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]
+
66
382
                _ => ColorTheme::dark256(),
67
            }
68
        }
69
389
    }
70
71
795
    fn none() -> Self {
72
795
        let def = Style::default();
73
795
        Self {
74
795
            spinner: Style::default().bold(),
75
795
            normal: def,
76
795
            matched: def,
77
795
            current: def,
78
795
            current_match: def,
79
795
            query: def,
80
795
            info: def,
81
795
            prompt: def,
82
795
            cursor: def,
83
795
            selected: def,
84
795
            header: def,
85
795
            border: def,
86
795
            scrollbar: def,
87
795
        }
88
795
    }
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
768
    fn dark256() -> Self {
119
768
        let base = ColorTheme::none();
120
768
        ColorTheme {
121
768
            matched: base.matched.fg(Color::Indexed(108)).bg(Color::Indexed(0)),
122
768
            current: base.current.bg(Color::Indexed(236)),
123
768
            current_match: base.current_match.fg(Color::Indexed(151)).bg(Color::Indexed(236)),
124
768
            spinner: base.spinner.fg(Color::Indexed(148)),
125
768
            info: base.info.fg(Color::Indexed(144)),
126
768
            prompt: base.prompt.fg(Color::Indexed(110)),
127
768
            cursor: base.cursor.fg(Color::Indexed(161)),
128
768
            selected: base.selected.fg(Color::Indexed(168)),
129
768
            header: base.header.fg(Color::Indexed(109)),
130
768
            border: base.border.fg(Color::Indexed(59)),
131
768
            scrollbar: base.scrollbar.fg(Color::Indexed(59)),
132
768
            ..base
133
768
        }
134
768
    }
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]
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 223df865..337b9a30 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-08-22 15:34

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.62k
            let worker_shared = Arc::clone(&shared);
91
4.62k
            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
705
    pub fn spawn_batch<I>(&self, jobs: I)
136
705
    where
137
705
        I: IntoIterator<Item = Box<dyn FnOnce() + Send + 'static>>,
138
    {
139
705
        let count = {
140
705
            let mut queue = self.shared.queue.lock().unwrap();
141
705
            let before = queue.jobs.len();
142
1.42k
            for job in 
jobs705
{
143
1.42k
                queue.jobs.push_back(job);
144
1.42k
            }
145
705
            queue.jobs.len() - before
146
        }; // lock dropped before notify
147
705
        if count >= self.num_threads {
  Branch (147:12): [True: 682, False: 0]
+

Coverage Report

Created: 2026-09-01 09:15

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.82k
pub fn partition_threads(n: usize) -> (usize, usize) {
26
1.82k
    let reader = n.div_ceil(3); // ⌈n/3⌉
27
1.82k
    let matcher = (2 * n) / 3; // ⌊2n/3⌋
28
1.82k
    (reader.max(1), matcher.max(1))
29
1.82k
}
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.28k
    pub fn new(num_threads: usize) -> Self {
77
2.28k
        assert!(num_threads > 0, "ThreadPool requires at least 1 thread");
78
79
2.28k
        let shared = Arc::new(SharedState {
80
2.28k
            queue: Mutex::new(QueueInner {
81
2.28k
                jobs: VecDeque::new(),
82
2.28k
                shutdown: false,
83
2.28k
            }),
84
2.28k
            job_available: Condvar::new(),
85
2.28k
        });
86
87
2.28k
        let mut workers = Vec::with_capacity(num_threads);
88
89
2.28k
        for _ in 0..num_threads {
90
4.57k
            let worker_shared = Arc::clone(&shared);
91
4.57k
            workers.push(thread::spawn(move || 
worker_loop4.57k
(
&worker_shared4.57k
)));
92
        }
93
94
2.28k
        Self {
95
2.28k
            shared,
96
2.28k
            workers,
97
2.28k
            num_threads,
98
2.28k
        }
99
2.28k
    }
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
408
    pub fn spawn<F>(&self, f: F)
117
408
    where
118
408
        F: FnOnce() + Send + 'static,
119
    {
120
408
        {
121
408
            let mut queue = self.shared.queue.lock().unwrap();
122
408
            queue.jobs.push_back(Box::new(f));
123
408
        } // lock dropped before notify
124
408
        self.shared.job_available.notify_one();
125
408
    }
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
705
    pub fn spawn_batch<I>(&self, jobs: I)
136
705
    where
137
705
        I: IntoIterator<Item = Box<dyn FnOnce() + Send + 'static>>,
138
    {
139
705
        let count = {
140
705
            let mut queue = self.shared.queue.lock().unwrap();
141
705
            let before = queue.jobs.len();
142
1.42k
            for job in 
jobs705
{
143
1.42k
                queue.jobs.push_back(job);
144
1.42k
            }
145
705
            queue.jobs.len() - before
146
        }; // lock dropped before notify
147
705
        if count >= self.num_threads {
  Branch (147:12): [True: 682, False: 0]
 
  Branch (147:12): [True: 23, False: 0]
-
148
705
            self.shared.job_available.notify_all();
149
705
        } else {
150
0
            for _ in 0..count {
151
0
                self.shared.job_available.notify_one();
152
0
            }
153
        }
154
705
    }
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.62k
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.2k
                if let Some(
ready1.83k
) = queue.jobs.pop_front() {
  Branch (182:24): [True: 1.75k, False: 8.12k]
-
  Branch (182:24): [True: 82, False: 2.24k]
-
183
1.83k
                    break Some(ready);
184
10.3k
                }
185
10.3k
                if queue.shutdown {
  Branch (185:20): [True: 3.29k, False: 4.83k]
-
  Branch (185:20): [True: 1.33k, False: 909]
-
186
4.62k
                    break None;
187
5.74k
                }
188
5.74k
                queue = shared.job_available.wait(queue).unwrap();
189
            }
190
        };
191
192
6.46k
        match next_job {
193
1.83k
            Some(runnable) => runnable(),
194
4.62k
            None => return, // shutdown
195
        }
196
    }
197
4.62k
}
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
849
pub fn parallel_work_queue<S, T, R, P, M, I, W, G>(
311
849
    pool: &ThreadPool,
312
849
    num_workers: usize,
313
849
    items: &Arc<S>,
314
849
    chunk_size: usize,
315
849
    preserve_chunk_order: bool,
316
849
    identity: I,
317
849
    process_chunk: P,
318
849
    reduce: M,
319
849
    prepare: W,
320
849
    merge: G,
321
849
) where
322
849
    S: AsRef<[T]> + Send + Sync + ?Sized + 'static,
323
849
    T: Send + Sync + 'static,
324
849
    R: Send + 'static,
325
849
    P: Fn(usize, &[T]) -> R + Send + Sync + 'static,
326
849
    M: Fn(&mut R, R) + Send + Sync + 'static,
327
849
    I: Fn() -> R + Send + Sync + 'static,
328
849
    W: Fn(&mut R) + Send + Sync + 'static,
329
849
    G: FnOnce(Vec<R>),
330
{
331
849
    let items_slice: &[T] = AsRef::<[T]>::as_ref(&**items);
332
849
    let total = items_slice.len();
333
849
    if total == 0 {
  Branch (333:8): [True: 137, False: 682]
+
148
705
            self.shared.job_available.notify_all();
149
705
        } else {
150
0
            for _ in 0..count {
151
0
                self.shared.job_available.notify_one();
152
0
            }
153
        }
154
705
    }
155
}
156
157
impl Drop for ThreadPool {
158
2.28k
    fn drop(&mut self) {
159
        // Signal shutdown.
160
2.28k
        {
161
2.28k
            let mut queue = self.shared.queue.lock().unwrap();
162
2.28k
            queue.shutdown = true;
163
2.28k
        }
164
2.28k
        self.shared.job_available.notify_all();
165
166
        // Join all workers (ignore panics from individual threads).
167
4.57k
        for handle in 
self.workers2.28k
.
drain2.28k
(
..2.28k
) {
168
4.57k
            let _ = handle.join();
169
4.57k
        }
170
2.28k
    }
171
}
172
173
// ---------------------------------------------------------------------------
174
// Worker loop
175
// ---------------------------------------------------------------------------
176
177
4.57k
fn worker_loop(shared: &SharedState) {
178
    loop {
179
6.40k
        let next_job = {
180
6.40k
            let mut queue = shared.queue.lock().unwrap();
181
            loop {
182
12.0k
                if let Some(
ready1.83k
) = queue.jobs.pop_front() {
  Branch (182:24): [True: 1.75k, False: 8.01k]
+
  Branch (182:24): [True: 78, False: 2.23k]
+
183
1.83k
                    break Some(ready);
184
10.2k
                }
185
10.2k
                if queue.shutdown {
  Branch (185:20): [True: 3.24k, False: 4.77k]
+
  Branch (185:20): [True: 1.33k, False: 901]
+
186
4.57k
                    break None;
187
5.67k
                }
188
5.67k
                queue = shared.job_available.wait(queue).unwrap();
189
            }
190
        };
191
192
6.40k
        match next_job {
193
1.83k
            Some(runnable) => runnable(),
194
4.57k
            None => return, // shutdown
195
        }
196
    }
197
4.57k
}
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
846
pub fn parallel_work_queue<S, T, R, P, M, I, W, G>(
311
846
    pool: &ThreadPool,
312
846
    num_workers: usize,
313
846
    items: &Arc<S>,
314
846
    chunk_size: usize,
315
846
    preserve_chunk_order: bool,
316
846
    identity: I,
317
846
    process_chunk: P,
318
846
    reduce: M,
319
846
    prepare: W,
320
846
    merge: G,
321
846
) where
322
846
    S: AsRef<[T]> + Send + Sync + ?Sized + 'static,
323
846
    T: Send + Sync + 'static,
324
846
    R: Send + 'static,
325
846
    P: Fn(usize, &[T]) -> R + Send + Sync + 'static,
326
846
    M: Fn(&mut R, R) + Send + Sync + 'static,
327
846
    I: Fn() -> R + Send + Sync + 'static,
328
846
    W: Fn(&mut R) + Send + Sync + 'static,
329
846
    G: FnOnce(Vec<R>),
330
{
331
846
    let items_slice: &[T] = AsRef::<[T]>::as_ref(&**items);
332
846
    let total = items_slice.len();
333
846
    if total == 0 {
  Branch (333:8): [True: 134, False: 682]
 
  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
145
        merge(Vec::new());
335
145
        return;
336
704
    }
337
338
704
    let num_chunks = total.div_ceil(chunk_size);
339
340
    // Shared atomic counter – workers fetch-add to grab the next chunk index.
341
704
    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
704
    let num_slots = if preserve_chunk_order { 
num_chunks8
} else {
num_workers696
};
  Branch (345:24): [True: 7, False: 675]
+
334
142
        merge(Vec::new());
335
142
        return;
336
704
    }
337
338
704
    let num_chunks = total.div_ceil(chunk_size);
339
340
    // Shared atomic counter – workers fetch-add to grab the next chunk index.
341
704
    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
704
    let num_slots = if preserve_chunk_order { 
num_chunks7
} else {
num_workers697
};
  Branch (345:24): [True: 6, False: 676]
 
  Branch (345:24): [True: 0, False: 17]
 
  Branch (345:24): [True: 1, False: 0]
 
  Branch (345:24): [True: 0, False: 1]
@@ -20,15 +20,15 @@
 
  Branch (345:24): [True: 0, False: 1]
 
  Branch (345:24): [True: 0, False: 1]
 
  Branch (345:24): [True: 0, False: 1]
-
346
1.42k
    let 
slots704
:
Arc<Vec<Slot<R>>>704
=
Arc::new704
((
0..num_slots704
).
map704
(|_| Slot::new()).
collect704
());
347
348
    // Barrier: we wait until all workers have finished.
349
704
    let remaining = Arc::new(AtomicCounter::new(num_workers));
350
    // Register the coordinator thread so workers can unpark it.
351
704
    remaining.set_waiter();
352
353
704
    let process_chunk = Arc::new(process_chunk);
354
704
    let reduce = Arc::new(reduce);
355
704
    let prepare = Arc::new(prepare);
356
704
    let identity = Arc::new(identity);
357
358
    // Build all jobs up-front and submit them in a single batch to minimise
359
    // lock acquisitions on the work queue.
360
704
    let jobs: Vec<Box<dyn FnOnce() + Send + 'static>> = (0..num_workers)
361
1.41k
        .
map704
(|worker_id| {
362
1.41k
            let w_items = Arc::clone(items);
363
1.41k
            let w_next_chunk = Arc::clone(&next_chunk);
364
1.41k
            let w_slots: Arc<Vec<Slot<R>>> = Arc::clone(&slots);
365
1.41k
            let w_remaining = Arc::clone(&remaining);
366
1.41k
            let w_process_chunk = Arc::clone(&process_chunk);
367
1.41k
            let w_reduce = Arc::clone(&reduce);
368
1.41k
            let w_prepare = Arc::clone(&prepare);
369
1.41k
            let w_identity = Arc::clone(&identity);
370
371
1.41k
            let job: Box<dyn FnOnce() + Send + 'static> = Box::new(move || {
372
                // Scope all Arc-holding work so clones are dropped before we
373
                // signal completion.  This lets the coordinator safely unwrap
374
                // the outer Arcs.
375
1.41k
                let local_acc = if preserve_chunk_order {
  Branch (375:36): [True: 14, False: 1.35k]
-
  Branch (375:36): [True: 0, False: 34]
+
346
1.42k
    let 
slots704
:
Arc<Vec<Slot<R>>>704
=
Arc::new704
((
0..num_slots704
).
map704
(|_| Slot::new()).
collect704
());
347
348
    // Barrier: we wait until all workers have finished.
349
704
    let remaining = Arc::new(AtomicCounter::new(num_workers));
350
    // Register the coordinator thread so workers can unpark it.
351
704
    remaining.set_waiter();
352
353
704
    let process_chunk = Arc::new(process_chunk);
354
704
    let reduce = Arc::new(reduce);
355
704
    let prepare = Arc::new(prepare);
356
704
    let identity = Arc::new(identity);
357
358
    // Build all jobs up-front and submit them in a single batch to minimise
359
    // lock acquisitions on the work queue.
360
704
    let jobs: Vec<Box<dyn FnOnce() + Send + 'static>> = (0..num_workers)
361
1.41k
        .
map704
(|worker_id| {
362
1.41k
            let w_items = Arc::clone(items);
363
1.41k
            let w_next_chunk = Arc::clone(&next_chunk);
364
1.41k
            let w_slots: Arc<Vec<Slot<R>>> = Arc::clone(&slots);
365
1.41k
            let w_remaining = Arc::clone(&remaining);
366
1.41k
            let w_process_chunk = Arc::clone(&process_chunk);
367
1.41k
            let w_reduce = Arc::clone(&reduce);
368
1.41k
            let w_prepare = Arc::clone(&prepare);
369
1.41k
            let w_identity = Arc::clone(&identity);
370
371
1.41k
            let job: Box<dyn FnOnce() + Send + 'static> = Box::new(move || 
{1.41k
372
                // Scope all Arc-holding work so clones are dropped before we
373
                // signal completion.  This lets the coordinator safely unwrap
374
                // the outer Arcs.
375
1.41k
                let local_acc = if preserve_chunk_order {
  Branch (375:36): [True: 12, False: 1.35k]
+
  Branch (375:36): [True: 0, False: 30]
 
  Branch (375:36): [True: 4, False: 0]
 
  Branch (375:36): [True: 0, False: 1]
 
  Branch (375:36): [True: 0, False: 0]
 
  Branch (375:36): [True: 0, False: 1]
 
  Branch (375:36): [True: 0, False: 4]
 
  Branch (375:36): [True: 0, False: 8]
-
376
                    loop {
377
40
                        let chunk_idx = w_next_chunk.fetch_add(1, Ordering::Relaxed);
378
40
                        if chunk_idx >= num_chunks {
  Branch (378:28): [True: 14, False: 18]
+
376
                    loop {
377
37
                        let chunk_idx = w_next_chunk.fetch_add(1, Ordering::Relaxed);
378
37
                        if chunk_idx >= num_chunks {
  Branch (378:28): [True: 12, False: 17]
 
  Branch (378:28): [True: 0, False: 0]
 
  Branch (378:28): [True: 4, False: 4]
 
  Branch (378:28): [True: 0, False: 0]
@@ -36,23 +36,23 @@
 
  Branch (378:28): [True: 0, False: 0]
 
  Branch (378:28): [True: 0, False: 0]
 
  Branch (378:28): [True: 0, False: 0]
-
379
18
                            break;
380
22
                        }
381
382
22
                        let start = chunk_idx * chunk_size;
383
22
                        let end = total.min(start + chunk_size);
384
22
                        let slice: &[T] = AsRef::<[T]>::as_ref(&*w_items);
385
22
                        let partial = w_process_chunk(start, &slice[start..end]);
386
387
                        // SAFETY: every chunk index is handed out once, so each
388
                        // slot has one writer. The coordinator reads after the barrier.
389
22
                        unsafe { *w_slots[chunk_idx].value.get() = Some(partial) };
390
                    }
391
18
                    None
392
                } else {
393
1.39k
                    let mut local_acc = w_identity();
394
                    loop {
395
2.12k
                        let chunk_idx = w_next_chunk.fetch_add(1, Ordering::Relaxed);
396
2.12k
                        if chunk_idx >= num_chunks {
  Branch (396:28): [True: 1.35k, False: 675]
-
  Branch (396:28): [True: 34, False: 17]
+
379
16
                            break;
380
21
                        }
381
382
21
                        let start = chunk_idx * chunk_size;
383
21
                        let end = total.min(start + chunk_size);
384
21
                        let slice: &[T] = AsRef::<[T]>::as_ref(&*w_items);
385
21
                        let partial = w_process_chunk(start, &slice[start..end]);
386
387
                        // SAFETY: every chunk index is handed out once, so each
388
                        // slot has one writer. The coordinator reads after the barrier.
389
21
                        unsafe { *w_slots[chunk_idx].value.get() = Some(partial) };
390
                    }
391
16
                    None
392
                } else {
393
1.39k
                    let mut local_acc = w_identity();
394
                    loop {
395
2.12k
                        let chunk_idx = w_next_chunk.fetch_add(1, Ordering::Relaxed);
396
2.12k
                        if chunk_idx >= num_chunks {
  Branch (396:28): [True: 1.35k, False: 676]
+
  Branch (396:28): [True: 30, False: 15]
 
  Branch (396:28): [True: 0, False: 0]
 
  Branch (396:28): [True: 1, False: 10]
 
  Branch (396:28): [True: 0, False: 0]
 
  Branch (396:28): [True: 1, False: 10]
 
  Branch (396:28): [True: 4, False: 16]
 
  Branch (396:28): [True: 8, False: 2]
-
397
1.39k
                            break;
398
730
                        }
399
400
730
                        let start = chunk_idx * chunk_size;
401
730
                        let end = total.min(start + chunk_size);
402
730
                        let slice: &[T] = AsRef::<[T]>::as_ref(&*w_items);
403
730
                        let partial = w_process_chunk(start, &slice[start..end]);
404
730
                        w_reduce(&mut local_acc, partial);
405
                    }
406
407
                    // Run prepare (e.g. sort) while still on the worker thread
408
                    // so that this work happens in parallel across workers.
409
1.39k
                    w_prepare(&mut local_acc);
410
1.39k
                    Some(local_acc)
411
                };
412
413
1.41k
                if let Some(
local_acc1.39k
) = local_acc {
  Branch (413:24): [True: 1.35k, False: 14]
-
  Branch (413:24): [True: 34, False: 0]
+
397
1.39k
                            break;
398
729
                        }
399
400
729
                        let start = chunk_idx * chunk_size;
401
729
                        let end = total.min(start + chunk_size);
402
729
                        let slice: &[T] = AsRef::<[T]>::as_ref(&*w_items);
403
729
                        let partial = w_process_chunk(start, &slice[start..end]);
404
729
                        w_reduce(&mut local_acc, partial);
405
                    }
406
407
                    // Run prepare (e.g. sort) while still on the worker thread
408
                    // so that this work happens in parallel across workers.
409
1.39k
                    w_prepare(&mut local_acc);
410
1.39k
                    Some(local_acc)
411
                };
412
413
1.41k
                if let Some(
local_acc1.39k
) = local_acc {
  Branch (413:24): [True: 1.35k, False: 12]
+
  Branch (413:24): [True: 30, False: 0]
 
  Branch (413:24): [True: 0, False: 4]
 
  Branch (413:24): [True: 1, False: 0]
 
  Branch (413:24): [True: 0, False: 0]
 
  Branch (413:24): [True: 1, False: 0]
 
  Branch (413:24): [True: 4, False: 0]
 
  Branch (413:24): [True: 8, False: 0]
-
414
1.39k
                    // SAFETY: each worker_id is unique; no other thread writes to
415
1.39k
                    // this slot, and the coordinator reads only after the barrier.
416
1.39k
                    unsafe { *w_slots[worker_id].value.get() = Some(local_acc) };
417
1.39k
                
}18
418
419
                // Drop the slots Arc *before* signalling completion.
420
                // The coordinator calls Arc::into_inner(slots) after wait_for_zero
421
                // returns; that requires the strong count to be exactly 1.  Without
422
                // this explicit drop, w_slots would still be alive in the closure
423
                // frame when dec_and_notify wakes the coordinator, causing
424
                // Arc::into_inner to spuriously return None and silently lose results.
425
1.41k
                drop(w_slots);
426
427
                // Signal completion.
428
1.41k
                w_remaining.dec_and_notify();
429
1.41k
            });
430
1.41k
            job
431
1.41k
        })
432
704
        .collect();
433
434
704
    pool.spawn_batch(jobs);
435
436
    // Block until all workers are done.
437
704
    remaining.wait_for_zero();
438
439
    // Collect worker or chunk results in slot order and hand them to `merge`.
440
    // Workers dropped their `w_slots` Arc clone explicitly before signalling
441
    // completion, so we are the sole owner here.
442
704
    if let Some(slots) = Arc::into_inner(slots) {
  Branch (442:12): [True: 682, False: 0]
+
414
1.39k
                    // SAFETY: each worker_id is unique; no other thread writes to
415
1.39k
                    // this slot, and the coordinator reads only after the barrier.
416
1.39k
                    unsafe { *w_slots[worker_id].value.get() = Some(local_acc) };
417
1.39k
                
}16
418
419
                // Drop the slots Arc *before* signalling completion.
420
                // The coordinator calls Arc::into_inner(slots) after wait_for_zero
421
                // returns; that requires the strong count to be exactly 1.  Without
422
                // this explicit drop, w_slots would still be alive in the closure
423
                // frame when dec_and_notify wakes the coordinator, causing
424
                // Arc::into_inner to spuriously return None and silently lose results.
425
1.41k
                drop(w_slots);
426
427
                // Signal completion.
428
1.41k
                w_remaining.dec_and_notify();
429
1.41k
            });
430
1.41k
            job
431
1.41k
        })
432
704
        .collect();
433
434
704
    pool.spawn_batch(jobs);
435
436
    // Block until all workers are done.
437
704
    remaining.wait_for_zero();
438
439
    // Collect worker or chunk results in slot order and hand them to `merge`.
440
    // Workers dropped their `w_slots` Arc clone explicitly before signalling
441
    // completion, so we are the sole owner here.
442
704
    if let Some(slots) = Arc::into_inner(slots) {
  Branch (442:12): [True: 682, False: 0]
 
  Branch (442:12): [True: 17, False: 0]
 
  Branch (442:12): [True: 1, False: 0]
 
  Branch (442:12): [True: 1, False: 0]
@@ -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 
results704
:
Vec<R>704
=
slots704
.
into_iter704
().
filter_map704
(|slot| slot.value.into_inner()).
collect704
();
444
445
704
        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
849
}
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
704
    fn new(n: usize) -> Self {
473
704
        Self {
474
704
            count: AtomicUsize::new(n),
475
704
            waiter: UnsafeCell::new(None),
476
704
        }
477
704
    }
478
479
    /// Register the current thread as the waiter.
480
    ///
481
    /// Must be called exactly once, before any worker calls `dec_and_notify`.
482
704
    fn set_waiter(&self) {
483
        // SAFETY: called once by the coordinator before workers start.
484
704
        unsafe { *self.waiter.get() = Some(thread::current()) };
485
704
    }
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.41k
    fn dec_and_notify(&self) {
494
1.41k
        let prev = self.count.fetch_sub(1, Ordering::AcqRel);
495
1.41k
        debug_assert!(
prev > 00
, "AtomicCounter decremented below zero — double-decrement bug?");
496
1.41k
        if prev == 1 {
  Branch (496:12): [True: 682, False: 682]
-
  Branch (496:12): [True: 22, False: 30]
-
497
            // We just decremented from 1 → 0.
498
            // SAFETY: waiter was set before workers were dispatched.
499
704
            if let Some(t) = unsafe { &*self.waiter.get() } {
  Branch (499:20): [True: 682, False: 0]
-
  Branch (499:20): [True: 22, False: 0]
-
500
704
                t.unpark();
501
704
            
}0
502
712
        }
503
1.41k
    }
504
505
    /// Blocks until the counter reaches zero.
506
704
    fn wait_for_zero(&self) {
507
1.31k
        while self.count.load(Ordering::Acquire) > 0 {
  Branch (507:15): [True: 590, False: 682]
-
  Branch (507:15): [True: 16, False: 22]
-
508
606
            thread::park();
509
606
        }
510
704
    }
511
}
512
513
#[cfg(test)]
514
#[path = "thread_pool_tests.rs"]
515
mod tests;
\ No newline at end of file +
443
1.41k
        let 
results704
:
Vec<R>704
=
slots704
.
into_iter704
().
filter_map704
(|slot| slot.value.into_inner()).
collect704
();
444
445
704
        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
846
}
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
704
    fn new(n: usize) -> Self {
473
704
        Self {
474
704
            count: AtomicUsize::new(n),
475
704
            waiter: UnsafeCell::new(None),
476
704
        }
477
704
    }
478
479
    /// Register the current thread as the waiter.
480
    ///
481
    /// Must be called exactly once, before any worker calls `dec_and_notify`.
482
704
    fn set_waiter(&self) {
483
        // SAFETY: called once by the coordinator before workers start.
484
704
        unsafe { *self.waiter.get() = Some(thread::current()) };
485
704
    }
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.41k
    fn dec_and_notify(&self) {
494
1.41k
        let prev = self.count.fetch_sub(1, Ordering::AcqRel);
495
1.41k
        debug_assert!(
prev > 00
, "AtomicCounter decremented below zero — double-decrement bug?");
496
1.41k
        if prev == 1 {
  Branch (496:12): [True: 682, False: 682]
+
  Branch (496:12): [True: 20, False: 28]
+
497
            // We just decremented from 1 → 0.
498
            // SAFETY: waiter was set before workers were dispatched.
499
702
            if let Some(t) = unsafe { &*self.waiter.get() } {
  Branch (499:20): [True: 682, False: 0]
+
  Branch (499:20): [True: 20, False: 0]
+
500
702
                t.unpark();
501
702
            
}0
502
710
        }
503
1.41k
    }
504
505
    /// Blocks until the counter reaches zero.
506
704
    fn wait_for_zero(&self) {
507
1.32k
        while self.count.load(Ordering::Acquire) > 0 {
  Branch (507:15): [True: 612, False: 682]
+
  Branch (507:15): [True: 13, False: 22]
+
508
625
            thread::park();
509
625
        }
510
704
    }
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 cfcc3333..b1d8ca5b 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-08-22 15:34

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-01 09:15

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.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 b0dd6d7f..37bf277c 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,175 +1,175 @@ -

Coverage Report

Created: 2026-08-22 15:34

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
520
static NUM_THREADS: LazyLock<usize> = LazyLock::new(|| {
39
520
    std::thread::available_parallelism()
40
520
        .ok()
41
520
        .map_or_else(|| 0, std::num::NonZero::get)
42
520
});
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]
-
  Branch (163:12): [True: 0, False: 29]
-
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_area99
) = self.layout.header_area {
  Branch (169:16): [True: 99, False: 2.55k]
-
  Branch (169:16): [True: 0, False: 29]
-
170
99
            res |= self.header.render(header_area, buf);
171
2.58k
        }
172
173
2.68k
        if let Some(
preview_area228
) = self.layout.preview_area {
  Branch (173:16): [True: 226, False: 2.42k]
-
  Branch (173:16): [True: 2, False: 27]
-
174
228
            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]
-
  Branch (180:37): [True: 1, False: 28]
-
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.58k]
-
  Branch (188:34): [True: 1, False: 27]
-
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.border48
.
is_none48
())),
  Branch (212:31): [True: 47, False: 2.60k]
-
  Branch (212:31): [True: 1, False: 28]
-
213
        );
214
2.68k
        if res.run_preview {
  Branch (214:12): [True: 370, False: 2.28k]
-
  Branch (214:12): [True: 1, False: 28]
-
215
371
            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
392
    pub fn from_options(options: SkimOptions, theme: Arc<crate::theme::ColorTheme>, cmd: String) -> Self {
292
392
        let header = Header::from_options(&options, theme.clone());
293
392
        let initial_header_height = header.height();
294
392
        let layout_template = LayoutTemplate::from_options(&options, initial_header_height);
295
392
        let layout = layout_template.apply(Rect::default());
296
392
        let (mut reader_threads, mut matcher_threads) = thread_pool::partition_threads(*NUM_THREADS);
297
392
        if options.flags.contains(&crate::options::FeatureFlag::SingleReader) {
  Branch (297:12): [True: 0, False: 375]
+

Coverage Report

Created: 2026-09-01 09:15

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
515
static NUM_THREADS: LazyLock<usize> = LazyLock::new(|| {
39
515
    std::thread::available_parallelism()
40
515
        .ok()
41
515
        .map_or_else(|| 0, std::num::NonZero::get)
42
515
});
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.66k
    fn render(self, area: Rect, buf: &mut Buffer) {
153
2.66k
        let mut res = SkimRender::default();
154
2.66k
        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.66k
        self.header.set_header_lines(self.item_pool.reserved());
162
2.66k
        let current_header_height = self.header.height();
163
2.66k
        if current_header_height != self.last_header_height {
  Branch (163:12): [True: 7, False: 2.63k]
+
  Branch (163:12): [True: 0, False: 27]
+
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.66k
        self.layout = self.layout_template.apply(area);
168
169
2.66k
        if let Some(
header_area99
) = self.layout.header_area {
  Branch (169:16): [True: 99, False: 2.54k]
+
  Branch (169:16): [True: 0, False: 27]
+
170
99
            res |= self.header.render(header_area, buf);
171
2.56k
        }
172
173
2.66k
        if let Some(
preview_area223
) = self.layout.preview_area {
  Branch (173:16): [True: 221, False: 2.41k]
+
  Branch (173:16): [True: 2, False: 25]
+
174
223
            res |= self.preview.render(preview_area, buf);
175
2.44k
        }
176
177
2.66k
        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.66k
        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: 26]
+
181
60
            None
182
        } else {
183
            Some(StatusInfo {
184
2.60k
                total: self.item_pool.len(),
185
2.60k
                matched: self.item_list.count(),
186
2.60k
                processed: self.matcher_control.get_num_processed(),
187
2.60k
                show_spinner: self.show_spinner,
188
2.60k
                matcher_mode: if self.options.regex {
  Branch (188:34): [True: 3, False: 2.57k]
+
  Branch (188:34): [True: 1, False: 25]
+
189
4
                    "RE".to_string()
190
                } else {
191
2.60k
                    String::new()
192
                },
193
2.60k
                multi_selection: self.options.multi,
194
2.60k
                selected: self.item_list.selection.len(),
195
2.60k
                current_item_idx: self.item_list.current,
196
2.60k
                hscroll_offset: i64::from(self.item_list.manual_hscroll),
197
2.60k
                start: Some(self.spinner_start),
198
2.60k
                inline_separator: self
199
2.60k
                    .options
200
2.60k
                    .info
201
2.60k
                    .separator()
202
2.60k
                    .unwrap_or(super::statusline::DEFAULT_SEPARATOR)
203
2.60k
                    .to_string(),
204
            })
205
        };
206
2.66k
        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.66k
            self.layout.input_area.x + self.input.cursor_pos() + u16::from(has_border),
211
2.66k
            self.layout.input_area.y
212
2.66k
                + u16::from(!(self.options.layout == TuiLayout::Reverse && 
self.options.border51
.
is_none51
())),
  Branch (212:31): [True: 50, False: 2.59k]
+
  Branch (212:31): [True: 1, False: 26]
+
213
        );
214
2.66k
        if res.run_preview {
  Branch (214:12): [True: 370, False: 2.27k]
+
  Branch (214:12): [True: 0, False: 27]
+
215
370
            self.pending_preview_run = true;
216
2.29k
        }
217
2.66k
    }
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
387
    pub fn from_options(options: SkimOptions, theme: Arc<crate::theme::ColorTheme>, cmd: String) -> Self {
292
387
        let header = Header::from_options(&options, theme.clone());
293
387
        let initial_header_height = header.height();
294
387
        let layout_template = LayoutTemplate::from_options(&options, initial_header_height);
295
387
        let layout = layout_template.apply(Rect::default());
296
387
        let (mut reader_threads, mut matcher_threads) = thread_pool::partition_threads(*NUM_THREADS);
297
387
        if options.flags.contains(&crate::options::FeatureFlag::SingleReader) {
  Branch (297:12): [True: 0, False: 370]
 
  Branch (297:12): [True: 1, False: 16]
-
298
1
            reader_threads = 1;
299
391
        }
300
392
        if options.flags.contains(&crate::options::FeatureFlag::SingleMatcher) {
  Branch (300:12): [True: 0, False: 375]
+
298
1
            reader_threads = 1;
299
386
        }
300
387
        if options.flags.contains(&crate::options::FeatureFlag::SingleMatcher) {
  Branch (300:12): [True: 0, False: 370]
 
  Branch (300:12): [True: 1, False: 16]
-
301
1
            matcher_threads = 1;
302
391
        }
303
392
        Self {
304
392
            input: Input::from_options(&options, theme.clone()),
305
392
            preview: Preview::from_options(&options, theme.clone()),
306
392
            header,
307
392
            matcher_pool: Arc::new(ThreadPool::new(matcher_threads)),
308
392
            reader_pool: Arc::new(ThreadPool::new(reader_threads)),
309
392
            item_pool: Arc::new(ItemPool::from_options(&options)),
310
392
            item_list: ItemList::from_options(&options, theme.clone()),
311
392
            theme,
312
392
            should_quit: false,
313
392
            final_action: None,
314
392
            cursor_pos: (0, 0),
315
392
            matcher: Matcher::from_options(&options),
316
392
            yank_register: String::new(),
317
392
            matcher_control: MatcherControl::default(),
318
392
            reader_timer: std::time::Instant::now(),
319
392
            matcher_timer: std::time::Instant::now(),
320
392
            last_matcher_restart: std::time::Instant::now(),
321
392
            pending_matcher_restart: false,
322
392
            needs_render: Arc::new(AtomicBool::new(true)),
323
392
            last_render_timer: std::time::Instant::now()
324
392
                .checked_sub(std::time::Duration::from_secs(1))
325
392
                .unwrap(),
326
392
            // spinner initial state
327
392
            spinner_last_change: std::time::Instant::now(),
328
392
            show_spinner: false,
329
392
            spinner_start: std::time::Instant::now(),
330
392
            items_just_updated: false,
331
392
            query_history: options.query_history.clone(),
332
392
            history_index: None,
333
392
            saved_input: String::new(),
334
392
            cmd_history: options.cmd_history.clone(),
335
392
            cmd_history_index: None,
336
392
            saved_cmd_input: String::new(),
337
392
            options,
338
392
            cmd,
339
392
            last_header_height: initial_header_height,
340
392
            layout_template,
341
392
            layout,
342
392
            last_preview_spawn: std::time::Instant::now()
343
392
                .checked_sub(std::time::Duration::from_secs(1))
344
392
                .unwrap(),
345
392
            pending_preview_run: false,
346
392
            currently_scrolling: false,
347
392
            last_left_click: std::time::Instant::now()
348
392
                .checked_sub(DOUBLE_CLICK_INTERVAL * 2)
349
392
                .unwrap(),
350
392
            reader_done: false,
351
392
            load_event_fired: false,
352
392
            result_pending: false,
353
392
            last_focused: None,
354
392
        }
355
392
    }
356
357
    /// Rebuild the layout template for the given terminal dimensions.
358
    ///
359
    /// The template encodes all option-derived constraints; `apply` in render
360
    /// is then a cheap set of rect splits with no option inspection.
361
    /// Called on `Event::Resize(cols, rows)` and whenever a layout-affecting
362
    /// option changes (e.g. `TogglePreview`).
363
4
    pub fn resize(&mut self, cols: u16, rows: u16) {
364
4
        self.layout_template = LayoutTemplate::from_options(&self.options, self.header.height());
365
4
        self.layout = self.layout_template.apply(Rect::new(0, 0, cols, rows));
366
4
    }
367
368
    /// Calculate preview offset from offset expression (e.g., "+123", "+{2}", "+{2}-2")
369
10
    fn calculate_preview_offset(&self, offset_expr: &str) -> u16 {
370
        // Remove the leading '+'
371
10
        let expr = offset_expr.trim_start_matches('+');
372
373
        // Substitute field placeholders using printf
374
10
        let substituted = self.expand_cmd(expr, true);
375
        // Evaluate the expression (handle simple arithmetic like "321-2")
376
10
        if let Some((
left4
,
right4
)) = substituted.split_once('-') {
  Branch (376:16): [True: 2, False: 4]
+
301
1
            matcher_threads = 1;
302
386
        }
303
387
        Self {
304
387
            input: Input::from_options(&options, theme.clone()),
305
387
            preview: Preview::from_options(&options, theme.clone()),
306
387
            header,
307
387
            matcher_pool: Arc::new(ThreadPool::new(matcher_threads)),
308
387
            reader_pool: Arc::new(ThreadPool::new(reader_threads)),
309
387
            item_pool: Arc::new(ItemPool::from_options(&options)),
310
387
            item_list: ItemList::from_options(&options, theme.clone()),
311
387
            theme,
312
387
            should_quit: false,
313
387
            final_action: None,
314
387
            cursor_pos: (0, 0),
315
387
            matcher: Matcher::from_options(&options),
316
387
            yank_register: String::new(),
317
387
            matcher_control: MatcherControl::default(),
318
387
            reader_timer: std::time::Instant::now(),
319
387
            matcher_timer: std::time::Instant::now(),
320
387
            last_matcher_restart: std::time::Instant::now(),
321
387
            pending_matcher_restart: false,
322
387
            needs_render: Arc::new(AtomicBool::new(true)),
323
387
            last_render_timer: std::time::Instant::now()
324
387
                .checked_sub(std::time::Duration::from_secs(1))
325
387
                .unwrap(),
326
387
            // spinner initial state
327
387
            spinner_last_change: std::time::Instant::now(),
328
387
            show_spinner: false,
329
387
            spinner_start: std::time::Instant::now(),
330
387
            items_just_updated: false,
331
387
            query_history: options.query_history.clone(),
332
387
            history_index: None,
333
387
            saved_input: String::new(),
334
387
            cmd_history: options.cmd_history.clone(),
335
387
            cmd_history_index: None,
336
387
            saved_cmd_input: String::new(),
337
387
            options,
338
387
            cmd,
339
387
            last_header_height: initial_header_height,
340
387
            layout_template,
341
387
            layout,
342
387
            last_preview_spawn: std::time::Instant::now()
343
387
                .checked_sub(std::time::Duration::from_secs(1))
344
387
                .unwrap(),
345
387
            pending_preview_run: false,
346
387
            currently_scrolling: false,
347
387
            last_left_click: std::time::Instant::now()
348
387
                .checked_sub(DOUBLE_CLICK_INTERVAL * 2)
349
387
                .unwrap(),
350
387
            reader_done: false,
351
387
            load_event_fired: false,
352
387
            result_pending: false,
353
387
            last_focused: None,
354
387
        }
355
387
    }
356
357
    /// Rebuild the layout template for the given terminal dimensions.
358
    ///
359
    /// The template encodes all option-derived constraints; `apply` in render
360
    /// is then a cheap set of rect splits with no option inspection.
361
    /// Called on `Event::Resize(cols, rows)` and whenever a layout-affecting
362
    /// option changes (e.g. `TogglePreview`).
363
4
    pub fn resize(&mut self, cols: u16, rows: u16) {
364
4
        self.layout_template = LayoutTemplate::from_options(&self.options, self.header.height());
365
4
        self.layout = self.layout_template.apply(Rect::new(0, 0, cols, rows));
366
4
    }
367
368
    /// Calculate preview offset from offset expression (e.g., "+123", "+{2}", "+{2}-2")
369
10
    fn calculate_preview_offset(&self, offset_expr: &str) -> u16 {
370
        // Remove the leading '+'
371
10
        let expr = offset_expr.trim_start_matches('+');
372
373
        // Substitute field placeholders using printf
374
10
        let substituted = self.expand_cmd(expr, true);
375
        // Evaluate the expression (handle simple arithmetic like "321-2")
376
10
        if let Some((
left4
,
right4
)) = substituted.split_once('-') {
  Branch (376:16): [True: 2, False: 4]
 
  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.86k
            (Some(prev), Some(curr)) => !Arc::ptr_eq(prev, curr),
427
573
            (None, None) => false,
428
373
            _ => true,
429
        };
430
2.81k
        if changed {
  Branch (430:12): [True: 447, False: 2.30k]
-
  Branch (430:12): [True: 45, False: 15]
-
431
492
            self.last_focused = focused;
432
492
            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.73k, False: 0]
+
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.79k
    fn take_focus_event(&mut self) -> Option<Event> {
424
2.79k
        let focused = self.item_list.selected().map(|m| m.item);
425
2.79k
        let changed = match (&self.last_focused, &focused) {
426
1.86k
            (Some(prev), Some(curr)) => !Arc::ptr_eq(prev, curr),
427
560
            (None, None) => false,
428
372
            _ => true,
429
        };
430
2.79k
        if changed {
  Branch (430:12): [True: 447, False: 2.29k]
+
  Branch (430:12): [True: 44, False: 14]
+
431
491
            self.last_focused = focused;
432
491
            Some(Event::Key(SkimEvent::Focus.into()))
433
        } else {
434
2.30k
            None
435
        }
436
2.79k
    }
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.72k
    fn poll_completion_events(&mut self) -> Vec<Event> {
446
1.72k
        let mut events = Vec::new();
447
448
1.72k
        if self.reader_done
  Branch (448:12): [True: 1.71k, False: 3]
 
  Branch (448:12): [True: 4, False: 2]
-
449
1.73k
            && !self.load_event_fired
  Branch (449:16): [True: 411, False: 1.32k]
+
449
1.72k
            && !self.load_event_fired
  Branch (449:16): [True: 401, False: 1.31k]
 
  Branch (449:16): [True: 3, False: 1]
-
450
414
            && self.matcher_control.stopped()
  Branch (450:16): [True: 411, False: 0]
+
450
404
            && self.matcher_control.stopped()
  Branch (450:16): [True: 401, False: 0]
 
  Branch (450:16): [True: 3, False: 0]
-
451
414
            && self.item_pool.num_not_taken() == 0
  Branch (451:16): [True: 385, False: 26]
+
451
404
            && self.item_pool.num_not_taken() == 0
  Branch (451:16): [True: 385, False: 16]
 
  Branch (451:16): [True: 2, False: 1]
-
452
387
        {
453
387
            self.load_event_fired = true;
454
387
            events.push(Event::Key(SkimEvent::Load.into()));
455
1.35k
        }
456
457
1.74k
        if self.result_pending && 
self.matcher_control728
.
stopped728
() {
  Branch (457:12): [True: 725, False: 1.01k]
-  Branch (457:35): [True: 724, False: 1]
+
452
387
        {
453
387
            self.load_event_fired = true;
454
387
            events.push(Event::Key(SkimEvent::Load.into()));
455
1.34k
        }
456
457
1.72k
        if self.result_pending && 
self.matcher_control725
.
stopped725
() {
  Branch (457:12): [True: 722, False: 1.00k]
+  Branch (457:35): [True: 720, False: 2]
 
  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: 724, False: 0]
+
458
722
            self.result_pending = false;
459
722
            events.push(Event::Key(SkimEvent::Result.into()));
460
722
            if self.reader_done {
  Branch (460:16): [True: 719, False: 1]
 
  Branch (460:16): [True: 1, False: 1]
-
461
725
                match self.matcher_control.get_num_matched() {
462
160
                    0 => events.push(Event::Key(SkimEvent::Zero.into())),
463
220
                    1 => events.push(Event::Key(SkimEvent::One.into())),
464
345
                    _ => {}
465
                }
466
1
            }
467
1.01k
        }
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]
+
461
720
                match self.matcher_control.get_num_matched() {
462
155
                    0 => events.push(Event::Key(SkimEvent::Zero.into())),
463
221
                    1 => events.push(Event::Key(SkimEvent::One.into())),
464
344
                    _ => {}
465
                }
466
2
            }
467
1.00k
        }
468
469
1.72k
        events
470
1.72k
    }
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
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.73k
    fn update_spinner(&mut self) {
487
1.73k
        let matcher_running = !self.matcher_control.stopped();
488
1.73k
        let time_since_match = self.matcher_timer.elapsed();
489
1.73k
        let reading = self.item_pool.num_not_taken() != 0;
490
491
1.73k
        let should_show_spinner = reading || (
matcher_running1.71k
&&
time_since_match.as_millis() > MATCHER_DEBOUNCE_MS0
);
  Branch (491:35): [True: 26, False: 1.70k]
-  Branch (491:47): [True: 0, False: 1.70k]
+
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.72k
    fn update_spinner(&mut self) {
487
1.72k
        let matcher_running = !self.matcher_control.stopped();
488
1.72k
        let time_since_match = self.matcher_timer.elapsed();
489
1.72k
        let reading = self.item_pool.num_not_taken() != 0;
490
491
1.72k
        let should_show_spinner = reading || (
matcher_running1.70k
&&
time_since_match.as_millis() > MATCHER_DEBOUNCE_MS1
);
  Branch (491:35): [True: 16, False: 1.70k]
+  Branch (491:47): [True: 1, False: 1.70k]
 
  Branch (491:35): [True: 2, False: 1]
   Branch (491:47): [True: 0, False: 1]
-
492
493
1.73k
        if should_show_spinner && 
!self.show_spinner28
{
  Branch (493:12): [True: 26, False: 1.70k]
-  Branch (493:35): [True: 10, False: 16]
+
492
493
1.72k
        if should_show_spinner && 
!self.show_spinner18
{
  Branch (493:12): [True: 16, False: 1.70k]
+  Branch (493:35): [True: 5, False: 11]
 
  Branch (493:12): [True: 2, False: 1]
   Branch (493:35): [True: 2, False: 0]
-
494
12
            self.toggle_spinner();
495
1.72k
        } else if !should_show_spinner && 
self.show_spinner1.71k
{
  Branch (495:19): [True: 1.70k, False: 16]
+
494
7
            self.toggle_spinner();
495
1.71k
        } else if !should_show_spinner && 
self.show_spinner1.70k
{
  Branch (495:19): [True: 1.70k, False: 11]
   Branch (495:43): [True: 4, False: 1.70k]
 
  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
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
4
            }
500
1.72k
        }
501
1.73k
        if self.show_spinner {
  Branch (501:12): [True: 30, False: 1.70k]
+
498
1
                self.toggle_spinner();
499
4
            }
500
1.71k
        }
501
1.72k
        if self.show_spinner {
  Branch (501:12): [True: 20, False: 1.70k]
 
  Branch (501:12): [True: 2, False: 1]
-
502
32
            self.needs_render.store(true, Ordering::Relaxed);
503
1.70k
        }
504
1.73k
    }
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: 7]
-
  Branch (516:12): [True: 29, False: 50]
-
  Branch (516:12): [True: 65, False: 41]
-
  Branch (516:12): [True: 0, False: 5]
-
  Branch (516:12): [True: 171, False: 223]
+
502
22
            self.needs_render.store(true, Ordering::Relaxed);
503
1.70k
        }
504
1.72k
    }
505
506
    #[allow(clippy::too_many_lines)]
507
1.36k
    fn run_preview<B: Backend>(&mut self, tui: &mut Tui<B>) -> Result<()>
508
1.36k
    where
509
1.36k
        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.36k
        let now = std::time::Instant::now();
514
1.36k
        let elapsed = now.duration_since(self.last_preview_spawn);
515
516
1.36k
        if elapsed.as_millis() < u128::from(DEBOUNCE_MS) {
  Branch (516:12): [True: 0, False: 7]
+
  Branch (516:12): [True: 166, False: 222]
 
  Branch (516:12): [True: 0, False: 9]
+
  Branch (516:12): [True: 19, False: 48]
+
  Branch (516:12): [True: 65, False: 41]
 
  Branch (516:12): [True: 10, False: 12]
+
  Branch (516:12): [True: 4, False: 2]
 
  Branch (516:12): [True: 4, False: 9]
 
  Branch (516:12): [True: 6, False: 10]
-
  Branch (516:12): [True: 19, False: 48]
-
  Branch (516:12): [True: 3, False: 2]
-
  Branch (516:12): [True: 67, False: 56]
-
  Branch (516:12): [True: 81, False: 89]
-
  Branch (516:12): [True: 0, False: 22]
-
  Branch (516:12): [True: 49, False: 58]
-
  Branch (516:12): [True: 0, False: 9]
-
  Branch (516:12): [True: 51, False: 61]
-
  Branch (516:12): [True: 44, False: 51]
 
  Branch (516:12): [True: 4, False: 9]
-
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
771
        }
521
522
771
        self.pending_preview_run = false;
523
771
        self.last_preview_spawn = now;
524
525
771
        if let Some(
preview_opt74
) = &self.options.preview
  Branch (525:16): [True: 0, False: 7]
-
  Branch (525:16): [True: 0, False: 50]
-
  Branch (525:16): [True: 0, False: 41]
-
  Branch (525:16): [True: 0, False: 5]
-
  Branch (525:16): [True: 5, False: 218]
+
  Branch (516:12): [True: 67, False: 56]
+
  Branch (516:12): [True: 44, False: 51]
+
  Branch (516:12): [True: 81, False: 89]
+
  Branch (516:12): [True: 0, False: 9]
+
  Branch (516:12): [True: 0, False: 5]
+
  Branch (516:12): [True: 49, False: 58]
+
  Branch (516:12): [True: 51, False: 61]
+
  Branch (516:12): [True: 29, False: 50]
+
  Branch (516:12): [True: 0, False: 22]
+
517
            // Mark that we have a pending preview to run after the debounce period
518
599
            self.pending_preview_run = true;
519
599
            return Ok(());
520
770
        }
521
522
770
        self.pending_preview_run = false;
523
770
        self.last_preview_spawn = now;
524
525
770
        if let Some(
preview_opt74
) = &self.options.preview
  Branch (525:16): [True: 0, False: 7]
+
  Branch (525:16): [True: 5, False: 217]
 
  Branch (525:16): [True: 0, False: 9]
+
  Branch (525:16): [True: 0, False: 48]
+
  Branch (525:16): [True: 0, False: 41]
 
  Branch (525:16): [True: 0, False: 12]
+
  Branch (525:16): [True: 2, False: 0]
 
  Branch (525:16): [True: 6, False: 3]
 
  Branch (525:16): [True: 0, False: 10]
-
  Branch (525:16): [True: 0, False: 48]
-
  Branch (525:16): [True: 2, False: 0]
+
  Branch (525:16): [True: 0, False: 9]
 
  Branch (525:16): [True: 56, False: 0]
-
  Branch (525:16): [True: 0, False: 89]
-
  Branch (525:16): [True: 0, False: 22]
-
  Branch (525:16): [True: 0, False: 58]
-
  Branch (525:16): [True: 0, False: 9]
-
  Branch (525:16): [True: 5, False: 56]
 
  Branch (525:16): [True: 0, False: 51]
+
  Branch (525:16): [True: 0, False: 89]
 
  Branch (525:16): [True: 0, False: 9]
+
  Branch (525:16): [True: 0, False: 5]
+
  Branch (525:16): [True: 0, False: 58]
+
  Branch (525:16): [True: 5, False: 56]
+
  Branch (525:16): [True: 0, False: 50]
+
  Branch (525:16): [True: 0, False: 22]
 
526
74
            && let Some(item) = self.item_list.selected()
  Branch (526:20): [True: 0, False: 0]
-
  Branch (526:20): [True: 0, False: 0]
-
  Branch (526:20): [True: 0, False: 0]
-
  Branch (526:20): [True: 0, False: 0]
 
  Branch (526:20): [True: 5, False: 0]
 
  Branch (526:20): [True: 0, False: 0]
 
  Branch (526:20): [True: 0, False: 0]
-
  Branch (526:20): [True: 6, False: 0]
 
  Branch (526:20): [True: 0, False: 0]
 
  Branch (526:20): [True: 0, False: 0]
 
  Branch (526:20): [True: 2, False: 0]
+
  Branch (526:20): [True: 6, False: 0]
+
  Branch (526:20): [True: 0, False: 0]
+
  Branch (526:20): [True: 0, False: 0]
 
  Branch (526:20): [True: 56, False: 0]
 
  Branch (526:20): [True: 0, False: 0]
 
  Branch (526:20): [True: 0, False: 0]
 
  Branch (526:20): [True: 0, False: 0]
 
  Branch (526:20): [True: 0, False: 0]
+
  Branch (526:20): [True: 0, False: 0]
 
  Branch (526:20): [True: 5, False: 0]
 
  Branch (526:20): [True: 0, False: 0]
 
  Branch (526:20): [True: 0, False: 0]
 
527
        {
528
74
            let selection: Vec<_> = self.item_list.selection.iter().map(|i| 
i.text()12
.
into_owned12
()).collect();
529
74
            let selection_str: Vec<_> = selection.iter().map(std::string::String::as_str).collect();
530
74
            let selected = self.item_list.selected();
531
74
            let ctx = PreviewContext {
532
74
                query: &self.input.value,
533
74
                cmd_query: if self.options.interactive {
  Branch (533:31): [True: 0, False: 0]
-
  Branch (533:31): [True: 0, False: 0]
-
  Branch (533:31): [True: 0, False: 0]
-
  Branch (533:31): [True: 0, False: 0]
 
  Branch (533:31): [True: 0, False: 5]
 
  Branch (533:31): [True: 0, False: 0]
 
  Branch (533:31): [True: 0, False: 0]
-
  Branch (533:31): [True: 0, False: 6]
 
  Branch (533:31): [True: 0, False: 0]
 
  Branch (533:31): [True: 0, False: 0]
 
  Branch (533:31): [True: 0, False: 2]
+
  Branch (533:31): [True: 0, False: 6]
+
  Branch (533:31): [True: 0, False: 0]
+
  Branch (533:31): [True: 0, False: 0]
 
  Branch (533:31): [True: 0, False: 56]
 
  Branch (533:31): [True: 0, False: 0]
 
  Branch (533:31): [True: 0, False: 0]
 
  Branch (533:31): [True: 0, False: 0]
 
  Branch (533:31): [True: 0, False: 0]
+
  Branch (533:31): [True: 0, False: 0]
 
  Branch (533:31): [True: 0, False: 5]
 
  Branch (533:31): [True: 0, False: 0]
 
  Branch (533:31): [True: 0, False: 0]
 
534
0
                    &self.input.value
535
                } else {
536
74
                    self.options.cmd_query.as_deref().unwrap_or(&self.input.value)
537
                },
538
74
                width: self.preview.cols as usize,
539
74
                height: self.preview.rows as usize,
540
74
                current_index: selected
541
74
                    .as_ref()
542
74
                    .map(|i| i.rank.index.unsigned_abs() as usize)
543
74
                    .unwrap_or_default(),
544
74
                current_selection: &selected.map(|i| i.text().into_owned()).unwrap_or_default(),
545
74
                selected_indices: &self
546
74
                    .item_list
547
74
                    .selection
548
74
                    .iter()
549
74
                    .map(|v| 
v.rank.index12
.
unsigned_abs12
() as usize)
550
74
                    .collect::<Vec<_>>(),
551
74
                selections: &selection_str,
552
            };
553
74
            let preview = item.preview(ctx);
554
74
            let preview_ready = !
matches!3
(
555
74
                preview,
556
                ItemPreview::Global | ItemPreview::Command(_) | ItemPreview::CommandWithPos(_, _)
557
            );
558
            #[cfg(feature = "image")]
559
74
            let quote_cmd = self.options.image.is_none();
560
            #[cfg(not(feature = "image"))]
561
            let quote_cmd = true;
562
74
            match preview {
563
1
                ItemPreview::Command(cmd) => self.preview.spawn(tui, &self.expand_cmd(&cmd, quote_cmd))
?0
,
564
1
                ItemPreview::Text(t) | ItemPreview::AnsiText(
t0
) => {
565
1
                    self.preview.content(&t.bytes().collect::<Vec<_>>())
?0
;
566
                }
567
2
                ItemPreview::CommandWithPos(cmd, preview_position) => {
568
                    // Execute command and apply position after content is ready
569
2
                    self.preview.spawn(tui, &self.expand_cmd(&cmd, quote_cmd))
?0
;
570
                    // Apply position offsets
571
2
                    let v_scroll = match preview_position.v_scroll {
572
0
                        crate::tui::Size::Fixed(n) => n,
573
1
                        crate::tui::Size::Neg(n) => self.preview.rows.saturating_sub(n),
574
1
                        crate::tui::Size::Percent(p) => {
575
1
                            u16::try_from(u32::from(self.preview.rows) * u32::from(p) / 100).unwrap_or(u16::MAX)
576
                        }
577
                    };
578
2
                    let v_offset = match preview_position.v_offset {
579
0
                        crate::tui::Size::Fixed(n) => n,
580
1
                        crate::tui::Size::Neg(n) => self.preview.rows.saturating_sub(n),
581
1
                        crate::tui::Size::Percent(p) => {
582
1
                            u16::try_from(u32::from(self.preview.rows) * u32::from(p) / 100).unwrap_or(u16::MAX)
583
                        }
584
                    };
585
2
                    self.preview.scroll_y = v_scroll;
586
2
                    self.preview.scroll_down(v_offset);
587
588
2
                    let h_scroll = match preview_position.h_scroll {
589
0
                        crate::tui::Size::Fixed(n) => n,
590
1
                        crate::tui::Size::Neg(n) => self.preview.cols.saturating_sub(n),
591
1
                        crate::tui::Size::Percent(p) => {
592
1
                            u16::try_from(u32::from(self.preview.cols) * u32::from(p) / 100).unwrap_or(u16::MAX)
593
                        }
594
                    };
595
2
                    let h_offset = match preview_position.h_offset {
596
1
                        crate::tui::Size::Fixed(n) => n,
597
1
                        crate::tui::Size::Neg(n) => self.preview.cols.saturating_sub(n),
598
0
                        crate::tui::Size::Percent(p) => {
599
0
                            u16::try_from(u32::from(self.preview.cols) * u32::from(p) / 100).unwrap_or(u16::MAX)
600
                        }
601
                    };
602
2
                    self.preview.scroll_x = h_scroll.saturating_add(h_offset);
603
                }
604
2
                ItemPreview::TextWithPos(t, preview_position) | ItemPreview::AnsiWithPos(
t0
,
preview_position0
) => self
605
2
                    .preview
606
2
                    .content_with_position(&t.bytes().collect::<Vec<_>>(), preview_position)
?0
,
607
68
                ItemPreview::Global => self.preview.spawn(tui, &self.expand_cmd(preview_opt, quote_cmd))
?0
,
608
            }
609
74
            if preview_ready {
  Branch (609:16): [True: 0, False: 0]
-
  Branch (609:16): [True: 0, False: 0]
-
  Branch (609:16): [True: 0, False: 0]
-
  Branch (609:16): [True: 0, False: 0]
 
  Branch (609:16): [True: 0, False: 5]
 
  Branch (609:16): [True: 0, False: 0]
 
  Branch (609:16): [True: 0, False: 0]
-
  Branch (609:16): [True: 3, False: 3]
 
  Branch (609:16): [True: 0, False: 0]
 
  Branch (609:16): [True: 0, False: 0]
 
  Branch (609:16): [True: 0, False: 2]
+
  Branch (609:16): [True: 3, False: 3]
+
  Branch (609:16): [True: 0, False: 0]
+
  Branch (609:16): [True: 0, False: 0]
 
  Branch (609:16): [True: 0, False: 56]
 
  Branch (609:16): [True: 0, False: 0]
 
  Branch (609:16): [True: 0, False: 0]
 
  Branch (609:16): [True: 0, False: 0]
 
  Branch (609:16): [True: 0, False: 0]
+
  Branch (609:16): [True: 0, False: 0]
 
  Branch (609:16): [True: 0, False: 5]
 
  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
697
        } else if let Some(
cb3
) = &self.options.preview_fn {
  Branch (612:23): [True: 0, False: 7]
-
  Branch (612:23): [True: 0, False: 50]
-
  Branch (612:23): [True: 0, False: 41]
-
  Branch (612:23): [True: 0, False: 5]
-
  Branch (612:23): [True: 0, False: 218]
+
610
3
                let _ = tui.event_tx.try_send(Event::PreviewReady);
611
71
            }
612
696
        } else if let Some(
cb3
) = &self.options.preview_fn {
  Branch (612:23): [True: 0, False: 7]
+
  Branch (612:23): [True: 0, False: 217]
 
  Branch (612:23): [True: 0, False: 9]
+
  Branch (612:23): [True: 0, False: 48]
+
  Branch (612:23): [True: 0, False: 41]
 
  Branch (612:23): [True: 0, False: 12]
+
  Branch (612:23): [True: 0, False: 0]
 
  Branch (612:23): [True: 3, False: 0]
 
  Branch (612:23): [True: 0, False: 10]
-
  Branch (612:23): [True: 0, False: 48]
-
  Branch (612:23): [True: 0, False: 0]
-
  Branch (612:23): [True: 0, False: 0]
-
  Branch (612:23): [True: 0, False: 89]
-
  Branch (612:23): [True: 0, False: 22]
-
  Branch (612:23): [True: 0, False: 58]
 
  Branch (612:23): [True: 0, False: 9]
-
  Branch (612:23): [True: 0, False: 56]
+
  Branch (612:23): [True: 0, False: 0]
 
  Branch (612:23): [True: 0, False: 51]
+
  Branch (612:23): [True: 0, False: 89]
 
  Branch (612:23): [True: 0, False: 9]
+
  Branch (612:23): [True: 0, False: 5]
+
  Branch (612:23): [True: 0, False: 58]
+
  Branch (612:23): [True: 0, False: 56]
+
  Branch (612:23): [True: 0, False: 50]
+
  Branch (612:23): [True: 0, False: 22]
 
613
            let selection: Vec<Arc<dyn SkimItem>>;
614
3
            if self.options.multi {
  Branch (614:16): [True: 0, False: 0]
 
  Branch (614:16): [True: 0, False: 0]
 
  Branch (614:16): [True: 0, False: 0]
@@ -208,197 +208,197 @@
 
  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
694
        }
623
771
        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.62k
    pub fn handle_event<B: Backend>(&mut self, tui: &mut Tui<B>, event: &Event) -> Result<()>
633
8.62k
    where
634
8.62k
        B::Error: Send + Sync + 'static,
635
    {
636
8.62k
        trace!("handling event {event:?}");
637
8.62k
        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(
event379
) = self.take_focus_event() {
  Branch (647:24): [True: 7, False: 8]
-
  Branch (647:24): [True: 33, False: 113]
-
  Branch (647:24): [True: 25, False: 86]
-
  Branch (647:24): [True: 3, False: 20]
-
  Branch (647:24): [True: 137, False: 515]
+
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
693
        }
623
770
        Ok(())
624
1.36k
    }
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.58k
    pub fn handle_event<B: Backend>(&mut self, tui: &mut Tui<B>, event: &Event) -> Result<()>
633
8.58k
    where
634
8.58k
        B::Error: Send + Sync + 'static,
635
    {
636
8.58k
        trace!("handling event {event:?}");
637
8.58k
        match event {
638
            Event::Render => {
639
                // Always render to avoid freezing, but the render function itself can optimize
640
2.64k
                tui.get_frame();
641
2.64k
                tui.draw(|f| {
642
2.64k
                    f.render_widget(&mut *self, f.area());
643
2.64k
                    f.set_cursor_position(self.cursor_pos);
644
2.64k
                })
?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.64k
                if let Some(
event378
) = self.take_focus_event() {
  Branch (647:24): [True: 7, False: 11]
+
  Branch (647:24): [True: 137, False: 516]
 
  Branch (647:24): [True: 9, False: 18]
-
  Branch (647:24): [True: 15, False: 31]
-
  Branch (647:24): [True: 3, False: 2]
-
  Branch (647:24): [True: 5, False: 220]
 
  Branch (647:24): [True: 10, False: 221]
+
  Branch (647:24): [True: 25, False: 83]
+
  Branch (647:24): [True: 15, False: 31]
 
  Branch (647:24): [True: 1, False: 6]
-
  Branch (647:24): [True: 34, False: 156]
-
  Branch (647:24): [True: 18, False: 261]
-
  Branch (647:24): [True: 12, False: 71]
-
  Branch (647:24): [True: 10, False: 173]
-
  Branch (647:24): [True: 9, False: 21]
-
  Branch (647:24): [True: 28, False: 181]
-
  Branch (647:24): [True: 16, False: 146]
+
  Branch (647:24): [True: 2, False: 1]
+
  Branch (647:24): [True: 5, False: 220]
 
  Branch (647:24): [True: 4, False: 28]
-
648
379
                    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.73k
                self.update_spinner();
654
1.73k
                if self.preview.is_loading() {
  Branch (654:20): [True: 0, False: 33]
-
  Branch (654:20): [True: 0, False: 112]
-
  Branch (654:20): [True: 0, False: 103]
-
  Branch (654:20): [True: 0, False: 11]
-
  Branch (654:20): [True: 0, False: 497]
+
  Branch (647:24): [True: 34, False: 150]
+
  Branch (647:24): [True: 16, False: 146]
+
  Branch (647:24): [True: 18, False: 261]
+
  Branch (647:24): [True: 9, False: 21]
+
  Branch (647:24): [True: 3, False: 20]
+
  Branch (647:24): [True: 10, False: 173]
+
  Branch (647:24): [True: 28, False: 181]
+
  Branch (647:24): [True: 33, False: 107]
+
  Branch (647:24): [True: 12, False: 71]
+
648
378
                    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.72k
                self.update_spinner();
654
1.72k
                if self.preview.is_loading() {
  Branch (654:20): [True: 0, False: 34]
+
  Branch (654:20): [True: 0, False: 492]
 
  Branch (654:20): [True: 0, False: 18]
+
  Branch (654:20): [True: 0, False: 146]
+
  Branch (654:20): [True: 0, False: 101]
 
  Branch (654:20): [True: 0, False: 34]
+
  Branch (654:20): [True: 0, False: 7]
 
  Branch (654:20): [True: 0, False: 1]
 
  Branch (654:20): [True: 0, False: 133]
-
  Branch (654:20): [True: 0, False: 146]
-
  Branch (654:20): [True: 0, False: 6]
-
  Branch (654:20): [True: 0, False: 146]
-
  Branch (654:20): [True: 0, False: 120]
-
  Branch (654:20): [True: 0, False: 40]
-
  Branch (654:20): [True: 0, False: 76]
-
  Branch (654:20): [True: 0, False: 20]
-
  Branch (654:20): [True: 0, False: 145]
-
  Branch (654:20): [True: 0, False: 78]
 
  Branch (654:20): [True: 0, False: 17]
-
655
0
                    self.needs_render.store(true, Ordering::Relaxed);
656
1.73k
                }
657
658
1.73k
                if self.pending_matcher_restart {
  Branch (658:20): [True: 0, False: 33]
-
  Branch (658:20): [True: 0, False: 112]
-
  Branch (658:20): [True: 0, False: 103]
-
  Branch (658:20): [True: 0, False: 11]
-
  Branch (658:20): [True: 0, False: 497]
+
  Branch (654:20): [True: 0, False: 142]
+
  Branch (654:20): [True: 0, False: 78]
+
  Branch (654:20): [True: 0, False: 120]
+
  Branch (654:20): [True: 0, False: 20]
+
  Branch (654:20): [True: 0, False: 11]
+
  Branch (654:20): [True: 0, False: 76]
+
  Branch (654:20): [True: 0, False: 145]
+
  Branch (654:20): [True: 0, False: 108]
+
  Branch (654:20): [True: 0, False: 40]
+
655
0
                    self.needs_render.store(true, Ordering::Relaxed);
656
1.72k
                }
657
658
1.72k
                if self.pending_matcher_restart {
  Branch (658:20): [True: 0, False: 34]
+
  Branch (658:20): [True: 0, False: 492]
 
  Branch (658:20): [True: 0, False: 18]
+
  Branch (658:20): [True: 0, False: 146]
+
  Branch (658:20): [True: 0, False: 101]
 
  Branch (658:20): [True: 0, False: 34]
+
  Branch (658:20): [True: 0, False: 7]
 
  Branch (658:20): [True: 1, False: 0]
 
  Branch (658:20): [True: 0, False: 133]
-
  Branch (658:20): [True: 0, False: 146]
-
  Branch (658:20): [True: 0, False: 6]
-
  Branch (658:20): [True: 0, False: 146]
-
  Branch (658:20): [True: 0, False: 120]
-
  Branch (658:20): [True: 0, False: 40]
-
  Branch (658:20): [True: 0, False: 76]
-
  Branch (658:20): [True: 0, False: 20]
-
  Branch (658:20): [True: 1, False: 144]
-
  Branch (658:20): [True: 0, False: 78]
 
  Branch (658:20): [True: 0, False: 17]
-
659
2
                    self.restart_matcher(true);
660
1.73k
                }
661
1.73k
                if self.needs_render.load(Ordering::Relaxed)
  Branch (661:20): [True: 3, False: 30]
-
  Branch (661:20): [True: 40, False: 72]
-
  Branch (661:20): [True: 34, False: 69]
-
  Branch (661:20): [True: 7, False: 4]
-
  Branch (661:20): [True: 194, False: 303]
+
  Branch (658:20): [True: 0, False: 142]
+
  Branch (658:20): [True: 0, False: 78]
+
  Branch (658:20): [True: 0, False: 120]
+
  Branch (658:20): [True: 0, False: 20]
+
  Branch (658:20): [True: 0, False: 11]
+
  Branch (658:20): [True: 0, False: 76]
+
  Branch (658:20): [True: 1, False: 144]
+
  Branch (658:20): [True: 0, False: 108]
+
  Branch (658:20): [True: 0, False: 40]
+
659
2
                    self.restart_matcher(true);
660
1.72k
                }
661
1.72k
                if self.needs_render.load(Ordering::Relaxed)
  Branch (661:20): [True: 4, False: 30]
+
  Branch (661:20): [True: 194, False: 298]
 
  Branch (661:20): [True: 9, False: 9]
+
  Branch (661:20): [True: 58, False: 88]
+
  Branch (661:20): [True: 32, False: 69]
 
  Branch (661:20): [True: 13, False: 21]
+
  Branch (661:20): [True: 4, False: 3]
 
  Branch (661:20): [True: 1, False: 0]
 
  Branch (661:20): [True: 57, False: 76]
-
  Branch (661:20): [True: 58, False: 88]
-
  Branch (661:20): [True: 4, False: 2]
-
  Branch (661:20): [True: 95, False: 51]
-
  Branch (661:20): [True: 88, False: 32]
-
  Branch (661:20): [True: 25, False: 15]
-
  Branch (661:20): [True: 58, False: 18]
-
  Branch (661:20): [True: 11, False: 9]
-
  Branch (661:20): [True: 55, False: 90]
-
  Branch (661:20): [True: 50, False: 28]
 
  Branch (661:20): [True: 10, False: 7]
-
662
812
                    && self.last_render_timer.elapsed().as_millis() > 1000 / u128::from(TICK_RATE)
  Branch (662:24): [True: 1, False: 2]
-
  Branch (662:24): [True: 38, False: 2]
-
  Branch (662:24): [True: 33, False: 1]
-
  Branch (662:24): [True: 7, False: 0]
-
  Branch (662:24): [True: 185, False: 9]
+
  Branch (661:20): [True: 90, False: 52]
+
  Branch (661:20): [True: 50, False: 28]
+
  Branch (661:20): [True: 88, False: 32]
+
  Branch (661:20): [True: 11, False: 9]
+
  Branch (661:20): [True: 7, False: 4]
+
  Branch (661:20): [True: 58, False: 18]
+
  Branch (661:20): [True: 55, False: 90]
+
  Branch (661:20): [True: 36, False: 72]
+
  Branch (661:20): [True: 25, False: 15]
+
662
802
                    && self.last_render_timer.elapsed().as_millis() > 1000 / u128::from(TICK_RATE)
  Branch (662:24): [True: 2, False: 2]
+
  Branch (662:24): [True: 186, False: 8]
 
  Branch (662:24): [True: 9, False: 0]
+
  Branch (662:24): [True: 58, False: 0]
+
  Branch (662:24): [True: 32, False: 0]
 
  Branch (662:24): [True: 13, False: 0]
+
  Branch (662:24): [True: 4, False: 0]
 
  Branch (662:24): [True: 1, False: 0]
 
  Branch (662:24): [True: 57, False: 0]
-
  Branch (662:24): [True: 58, False: 0]
-
  Branch (662:24): [True: 4, False: 0]
-
  Branch (662:24): [True: 92, False: 3]
-
  Branch (662:24): [True: 88, False: 0]
-
  Branch (662:24): [True: 25, False: 0]
-
  Branch (662:24): [True: 58, False: 0]
-
  Branch (662:24): [True: 10, False: 1]
-
  Branch (662:24): [True: 54, False: 1]
-
  Branch (662:24): [True: 50, False: 0]
 
  Branch (662:24): [True: 10, False: 0]
-
663
                {
664
793
                    debug!("Triggering render");
665
793
                    self.needs_render.store(false, Ordering::Relaxed);
666
793
                    self.last_render_timer = std::time::Instant::now();
667
793
                    tui.event_tx.try_send(Event::Render)
?0
;
668
943
                }
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.73k
                let completion_events = self.poll_completion_events();
676
1.73k
                if !completion_events.is_empty() {
  Branch (676:20): [True: 7, False: 26]
-
  Branch (676:20): [True: 38, False: 74]
-
  Branch (676:20): [True: 33, False: 70]
-
  Branch (676:20): [True: 7, False: 4]
-
  Branch (676:20): [True: 175, False: 322]
+
  Branch (662:24): [True: 90, False: 0]
+
  Branch (662:24): [True: 50, False: 0]
+
  Branch (662:24): [True: 88, False: 0]
+
  Branch (662:24): [True: 10, False: 1]
+
  Branch (662:24): [True: 7, False: 0]
+
  Branch (662:24): [True: 58, False: 0]
+
  Branch (662:24): [True: 54, False: 1]
+
  Branch (662:24): [True: 36, False: 0]
+
  Branch (662:24): [True: 25, False: 0]
+
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
933
                }
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.72k
                let completion_events = self.poll_completion_events();
676
1.72k
                if !completion_events.is_empty() {
  Branch (676:20): [True: 8, False: 26]
+
  Branch (676:20): [True: 175, False: 317]
 
  Branch (676:20): [True: 9, False: 9]
+
  Branch (676:20): [True: 58, False: 88]
+
  Branch (676:20): [True: 32, False: 69]
 
  Branch (676:20): [True: 13, False: 21]
+
  Branch (676:20): [True: 1, False: 6]
 
  Branch (676:20): [True: 0, False: 1]
 
  Branch (676:20): [True: 57, False: 76]
-
  Branch (676:20): [True: 58, False: 88]
-
  Branch (676:20): [True: 1, False: 5]
-
  Branch (676:20): [True: 36, False: 110]
-
  Branch (676:20): [True: 88, False: 32]
-
  Branch (676:20): [True: 25, False: 15]
-
  Branch (676:20): [True: 58, False: 18]
-
  Branch (676:20): [True: 10, False: 10]
-
  Branch (676:20): [True: 49, False: 96]
-
  Branch (676:20): [True: 50, False: 28]
 
  Branch (676:20): [True: 10, False: 7]
-
677
724
                    tui.event_tx.try_send(Event::Render)
?0
;
678
1.48k
                    for evt in 
completion_events724
{
679
1.48k
                        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.73k
                if self.pending_preview_run
  Branch (684:20): [True: 7, False: 26]
-
  Branch (684:20): [True: 60, False: 52]
-
  Branch (684:20): [True: 86, False: 17]
-
  Branch (684:20): [True: 0, False: 11]
-
  Branch (684:20): [True: 299, False: 198]
+
  Branch (676:20): [True: 34, False: 108]
+
  Branch (676:20): [True: 50, False: 28]
+
  Branch (676:20): [True: 88, False: 32]
+
  Branch (676:20): [True: 10, False: 10]
+
  Branch (676:20): [True: 7, False: 4]
+
  Branch (676:20): [True: 58, False: 18]
+
  Branch (676:20): [True: 49, False: 96]
+
  Branch (676:20): [True: 36, False: 72]
+
  Branch (676:20): [True: 25, False: 15]
+
677
720
                    tui.event_tx.try_send(Event::Render)
?0
;
678
1.48k
                    for evt in 
completion_events720
{
679
1.48k
                        tui.event_tx.try_send(evt)
?0
;
680
                    }
681
1.00k
                }
682
683
                // Check if a debounced preview run needs to be executed
684
1.72k
                if self.pending_preview_run
  Branch (684:20): [True: 7, False: 27]
+
  Branch (684:20): [True: 293, False: 199]
 
  Branch (684:20): [True: 9, False: 9]
+
  Branch (684:20): [True: 25, False: 121]
+
  Branch (684:20): [True: 86, False: 15]
 
  Branch (684:20): [True: 19, False: 15]
+
  Branch (684:20): [True: 4, False: 3]
 
  Branch (684:20): [True: 0, False: 1]
 
  Branch (684:20): [True: 10, False: 123]
-
  Branch (684:20): [True: 25, False: 121]
-
  Branch (684:20): [True: 3, False: 3]
-
  Branch (684:20): [True: 63, False: 83]
-
  Branch (684:20): [True: 94, False: 26]
-
  Branch (684:20): [True: 4, False: 36]
-
  Branch (684:20): [True: 58, False: 18]
-
  Branch (684:20): [True: 9, False: 11]
-
  Branch (684:20): [True: 67, False: 78]
-
  Branch (684:20): [True: 56, False: 22]
 
  Branch (684:20): [True: 8, False: 9]
-
685
877
                    && let Err(
e0
) = self.run_preview(tui)
  Branch (685:28): [True: 0, False: 7]
-
  Branch (685:28): [True: 0, False: 60]
-
  Branch (685:28): [True: 0, False: 86]
-
  Branch (685:28): [True: 0, False: 0]
-
  Branch (685:28): [True: 0, False: 299]
+
  Branch (684:20): [True: 63, False: 79]
+
  Branch (684:20): [True: 56, False: 22]
+
  Branch (684:20): [True: 94, False: 26]
+
  Branch (684:20): [True: 9, False: 11]
+
  Branch (684:20): [True: 0, False: 11]
+
  Branch (684:20): [True: 58, False: 18]
+
  Branch (684:20): [True: 67, False: 78]
+
  Branch (684:20): [True: 60, False: 48]
+
  Branch (684:20): [True: 4, False: 36]
+
685
872
                    && let Err(
e0
) = self.run_preview(tui)
  Branch (685:28): [True: 0, False: 7]
+
  Branch (685:28): [True: 0, False: 293]
 
  Branch (685:28): [True: 0, False: 9]
+
  Branch (685:28): [True: 0, False: 25]
+
  Branch (685:28): [True: 0, False: 86]
 
  Branch (685:28): [True: 0, False: 19]
+
  Branch (685:28): [True: 0, False: 4]
 
  Branch (685:28): [True: 0, False: 0]
 
  Branch (685:28): [True: 0, False: 10]
-
  Branch (685:28): [True: 0, False: 25]
-
  Branch (685:28): [True: 0, False: 3]
-
  Branch (685:28): [True: 0, False: 63]
-
  Branch (685:28): [True: 0, False: 94]
-
  Branch (685:28): [True: 0, False: 4]
-
  Branch (685:28): [True: 0, False: 58]
-
  Branch (685:28): [True: 0, False: 9]
-
  Branch (685:28): [True: 0, False: 67]
-
  Branch (685:28): [True: 0, False: 56]
 
  Branch (685:28): [True: 0, False: 8]
-
686
                {
687
0
                    warn!("Heartbeat RunPreview: error {e:?}");
688
1.73k
                }
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: 20]
-
  Branch (691:24): [True: 0, False: 5]
+
  Branch (685:28): [True: 0, False: 63]
+
  Branch (685:28): [True: 0, False: 56]
+
  Branch (685:28): [True: 0, False: 94]
+
  Branch (685:28): [True: 0, False: 9]
+
  Branch (685:28): [True: 0, False: 0]
+
  Branch (685:28): [True: 0, False: 58]
+
  Branch (685:28): [True: 0, False: 67]
+
  Branch (685:28): [True: 0, False: 60]
+
  Branch (685:28): [True: 0, False: 4]
+
686
                {
687
0
                    warn!("Heartbeat RunPreview: error {e:?}");
688
1.72k
                }
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: 95]
 
  Branch (691:24): [True: 0, False: 0]
+
  Branch (691:24): [True: 0, False: 42]
+
  Branch (691:24): [True: 0, False: 20]
 
  Branch (691:24): [True: 0, False: 3]
+
  Branch (691:24): [True: 0, False: 2]
 
  Branch (691:24): [True: 0, False: 1]
 
  Branch (691:24): [True: 0, False: 6]
-
  Branch (691:24): [True: 0, False: 42]
-
  Branch (691:24): [True: 0, False: 2]
-
  Branch (691:24): [True: 0, False: 60]
-
  Branch (691:24): [True: 0, False: 76]
-
  Branch (691:24): [True: 0, False: 18]
-
  Branch (691:24): [True: 0, False: 49]
-
  Branch (691:24): [True: 0, False: 0]
-
  Branch (691:24): [True: 0, False: 45]
-
  Branch (691:24): [True: 0, False: 39]
 
  Branch (691:24): [True: 0, False: 5]
+
  Branch (691:24): [True: 0, False: 60]
+
  Branch (691:24): [True: 0, False: 39]
+
  Branch (691:24): [True: 0, False: 76]
+
  Branch (691:24): [True: 0, False: 0]
+
  Branch (691:24): [True: 0, False: 5]
+
  Branch (691:24): [True: 0, False: 49]
+
  Branch (691:24): [True: 0, False: 45]
+
  Branch (691:24): [True: 0, False: 19]
+
  Branch (691:24): [True: 0, False: 18]
 
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: 0]
-
  Branch (722:24): [True: 0, False: 0]
 
  Branch (722:24): [True: 0, False: 5]
 
  Branch (722:24): [True: 0, False: 0]
 
  Branch (722:24): [True: 0, False: 0]
-
  Branch (722:24): [True: 0, False: 1]
 
  Branch (722:24): [True: 0, False: 0]
 
  Branch (722:24): [True: 0, False: 0]
 
  Branch (722:24): [True: 0, False: 2]
+
  Branch (722:24): [True: 0, False: 1]
+
  Branch (722:24): [True: 0, False: 0]
+
  Branch (722:24): [True: 0, False: 0]
 
  Branch (722:24): [True: 6, False: 50]
 
  Branch (722:24): [True: 0, False: 0]
 
  Branch (722:24): [True: 0, False: 0]
 
  Branch (722:24): [True: 0, False: 0]
 
  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]
 
  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
536
            Event::Action(act) => {
733
536
                let events = self.handle_action(act)
?0
;
734
877
                for evt in 
events536
{
735
877
                    tui.event_tx.try_send(evt)
?0
;
736
                }
737
536
                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 
evt522
in events {
742
522
                    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
536
            Event::Action(act) => {
733
536
                let events = self.handle_action(act)
?0
;
734
877
                for evt in 
events536
{
735
877
                    tui.event_tx.try_send(evt)
?0
;
736
                }
737
536
                tui.event_tx.try_send(Event::Render)
?0
;
738
            }
739
3.10k
            Event::Key(key) => {
740
3.10k
                let events = self.handle_key(key);
741
3.10k
                for 
evt522
in events {
742
522
                    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,15 +474,15 @@
 
  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.62k
        Ok(())
796
8.62k
    }
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.12k
    fn handle_key(&mut self, key: &KeyEvent) -> Vec<Event> {
804
3.12k
        let normalized_key = KeyEvent::new(key.code, key.modifiers);
805
3.12k
        debug!("key event: {key:?}, normalized: {normalized_key:?}");
806
807
3.12k
        if let Some(
act180
) = &self.options.keymap.get(&normalized_key) {
  Branch (807:16): [True: 176, 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.58k
        Ok(())
796
8.58k
    }
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.11k
    fn handle_key(&mut self, key: &KeyEvent) -> Vec<Event> {
804
3.11k
        let normalized_key = KeyEvent::new(key.code, key.modifiers);
805
3.11k
        debug!("key event: {key:?}, normalized: {normalized_key:?}");
806
807
3.11k
        if let Some(
act180
) = &self.options.keymap.get(&normalized_key) {
  Branch (807:16): [True: 176, False: 2.92k]
 
  Branch (807:16): [True: 4, False: 8]
-
808
180
            debug!("{act:?}");
809
192
            return 
act.iter()180
.
map180
(|a| Event::Action(a.clone())).
collect180
();
810
2.94k
        }
811
2.94k
        match key.modifiers {
812
            KeyModifiers::CONTROL => {
813
3
                if let Char('c') = key.code {
  Branch (813:24): [True: 0, False: 0]
+
808
180
            debug!("{act:?}");
809
192
            return 
act.iter()180
.
map180
(|a| Event::Action(a.clone())).
collect180
();
810
2.93k
        }
811
2.93k
        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.94k
                if let Char(
c333
) = key.code {
  Branch (818:24): [True: 331, False: 2.61k]
+
814
1
                    return vec![Event::Quit];
815
2
                }
816
            }
817
            KeyModifiers::NONE => {
818
2.93k
                if let Char(
c333
) = key.code {
  Branch (818:24): [True: 331, False: 2.59k]
 
  Branch (818:24): [True: 2, 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]
+
819
333
                    return vec![Event::Action(Action::AddChar(c))];
820
2.59k
                }
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.61k
        vec![]
830
3.12k
    }
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
675
    fn handle_action(&mut self, act: &Action) -> Result<Vec<Event>> {
838
675
        let follow = self.options.action_binds.get(act.name()).cloned();
839
675
        let suppress_default = follow
840
675
            .as_ref()
841
675
            .is_some_and(|chain| 
chain.iter()5
.
any5
(|a| matches!(
a5
, Action::Suppress)));
842
843
675
        let mut events = if suppress_default {
  Branch (843:29): [True: 1, False: 532]
+
824
1
                    return vec![Event::Action(Action::AddChar(c.to_uppercase().next().unwrap()))];
825
1
                }
826
            }
827
1
            _ => (),
828
        }
829
2.60k
        vec![]
830
3.11k
    }
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
675
    fn handle_action(&mut self, act: &Action) -> Result<Vec<Event>> {
838
675
        let follow = self.options.action_binds.get(act.name()).cloned();
839
675
        let suppress_default = follow
840
675
            .as_ref()
841
675
            .is_some_and(|chain| 
chain.iter()5
.
any5
(|a| matches!(
a5
, Action::Suppress)));
842
843
675
        let mut events = if suppress_default {
  Branch (843:29): [True: 1, False: 532]
 
  Branch (843:29): [True: 1, False: 141]
 
844
2
            Vec::new()
845
        } else {
846
673
            self.dispatch_action(act)
?0
847
        };
848
675
        if let Some(
chain5
) = follow {
  Branch (848:16): [True: 3, False: 530]
 
  Branch (848:16): [True: 2, False: 140]
@@ -538,33 +538,33 @@
   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
21
        } else if let Some(
sel17
) = self.item_list.selected() {
  Branch (1354:23): [True: 15, False: 0]
-
  Branch (1354:23): [True: 2, False: 4]
-
1355
17
            vec![sel]
1356
        } else {
1357
4
            vec![]
1358
        }
1359
39
    }
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.36k
    pub fn query_below_min_length(&self) -> bool {
1368
1.36k
        self.options
1369
1.36k
            .min_query_length
1370
1.36k
            .is_some_and(|min| 
!self.options.disabled20
&&
self.input.value.chars()20
.count() < min)
  Branch (1370:32): [True: 13, False: 0]
+
1353
2
            self.item_list.selection.clone().into_iter().collect()
1354
21
        } else if let Some(
sel16
) = self.item_list.selected() {
  Branch (1354:23): [True: 15, False: 0]
+
  Branch (1354:23): [True: 1, False: 5]
+
1355
16
            vec![sel]
1356
        } else {
1357
5
            vec![]
1358
        }
1359
39
    }
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.44k
    pub fn query_below_min_length(&self) -> bool {
1368
1.44k
        self.options
1369
1.44k
            .min_query_length
1370
1.44k
            .is_some_and(|min| 
!self.options.disabled20
&&
self.input.value.chars()20
.count() < min)
  Branch (1370:32): [True: 13, False: 0]
 
  Branch (1370:32): [True: 7, False: 0]
-
1371
1.36k
    }
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: 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.26k
        }
1389
1390
1.26k
        let matcher_stopped = self.matcher_control.stopped();
1391
1.26k
        if force || (
matcher_stopped495
&&
self.item_pool.num_not_taken() > 0378
) {
  Branch (1391:12): [True: 747, False: 479]
-  Branch (1391:22): [True: 369, False: 110]
-  Branch (1391:41): [True: 72, False: 297]
-
  Branch (1391:12): [True: 23, False: 16]
-  Branch (1391:22): [True: 9, False: 7]
-  Branch (1391:41): [True: 1, False: 8]
-
1392
843
            trace!("restarting matcher, force={force}");
1393
            // Reset debounce timer on any restart to prevent interference
1394
843
            self.last_matcher_restart = std::time::Instant::now();
1395
843
            self.pending_matcher_restart = false;
1396
843
            self.matcher_control.kill();
1397
            // record matcher start time for statusline spinner/progress
1398
843
            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
843
            let query = if self.options.disabled {
  Branch (1401:28): [True: 1, False: 818]
+
1371
1.44k
    }
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.31k
    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.31k
        if self.query_below_min_length() {
  Branch (1381:12): [True: 10, False: 1.26k]
+
  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.30k
        }
1389
1390
1.30k
        let matcher_stopped = self.matcher_control.stopped();
1391
1.30k
        if force || (
matcher_stopped538
&&
self.item_pool.num_not_taken() > 0399
) {
  Branch (1391:12): [True: 742, False: 523]
+  Branch (1391:22): [True: 391, False: 132]
+  Branch (1391:41): [True: 74, False: 317]
+
  Branch (1391:12): [True: 23, False: 15]
+  Branch (1391:22): [True: 8, False: 7]
+  Branch (1391:41): [True: 1, False: 7]
+
1392
840
            trace!("restarting matcher, force={force}");
1393
            // Reset debounce timer on any restart to prevent interference
1394
840
            self.last_matcher_restart = std::time::Instant::now();
1395
840
            self.pending_matcher_restart = false;
1396
840
            self.matcher_control.kill();
1397
            // record matcher start time for statusline spinner/progress
1398
840
            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
840
            let query = if self.options.disabled {
  Branch (1401:28): [True: 1, False: 815]
 
  Branch (1401:28): [True: 0, False: 24]
-
1402
1
                ""
1403
842
            } else if self.options.interactive {
  Branch (1403:23): [True: 84, False: 734]
+
1402
1
                ""
1403
839
            } else if self.options.interactive {
  Branch (1403:23): [True: 82, False: 733]
 
  Branch (1403:23): [True: 2, False: 22]
-
1404
86
                &input::Input::default()
1405
            } else {
1406
756
                &self.input
1407
            };
1408
843
            let item_pool = self.item_pool.clone();
1409
843
            let thread_pool = &self.matcher_pool;
1410
843
            let no_sort = self.options.no_sort;
1411
1412
843
            if force {
  Branch (1412:16): [True: 747, False: 72]
+
1404
84
                &input::Input::default()
1405
            } else {
1406
755
                &self.input
1407
            };
1408
840
            let item_pool = self.item_pool.clone();
1409
840
            let thread_pool = &self.matcher_pool;
1410
840
            let no_sort = self.options.no_sort;
1411
1412
840
            if force {
  Branch (1412:16): [True: 742, False: 74]
 
  Branch (1412:16): [True: 23, False: 1]
-
1413
770
                self.item_pool.reset();
1414
770
            
}73
1415
1416
843
            let merge_strategy = if force {
  Branch (1416:37): [True: 747, False: 72]
+
1413
765
                self.item_pool.reset();
1414
765
            
}75
1415
1416
840
            let merge_strategy = if force {
  Branch (1416:37): [True: 742, False: 74]
 
  Branch (1416:37): [True: 23, False: 1]
-
1417
770
                MergeStrategy::Replace
1418
73
            } else if no_sort && 
self.options.tac3
{
  Branch (1418:23): [True: 3, False: 69]
-  Branch (1418:34): [True: 0, False: 3]
+
1417
765
                MergeStrategy::Replace
1418
75
            } else if no_sort && 
self.options.tac1
{
  Branch (1418:23): [True: 1, False: 73]
+  Branch (1418:34): [True: 0, False: 1]
 
  Branch (1418:23): [True: 0, False: 1]
   Branch (1418:34): [True: 0, False: 0]
-
1419
0
                MergeStrategy::Prepend
1420
73
            } else if no_sort {
  Branch (1420:23): [True: 3, False: 69]
+
1419
0
                MergeStrategy::Prepend
1420
75
            } else if no_sort {
  Branch (1420:23): [True: 1, False: 73]
 
  Branch (1420:23): [True: 0, False: 1]
-
1421
3
                MergeStrategy::Append
1422
            } else {
1423
70
                MergeStrategy::SortedMerge
1424
            };
1425
1426
843
            self.matcher_control = self.matcher.run(
1427
843
                query,
1428
843
                &item_pool,
1429
843
                thread_pool,
1430
843
                self.item_list.processed_items.clone(),
1431
843
                merge_strategy,
1432
843
                no_sort,
1433
843
                self.options.tac,
1434
843
                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
843
            self.result_pending = true;
1439
422
        }
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]
+
1421
1
                MergeStrategy::Append
1422
            } else {
1423
74
                MergeStrategy::SortedMerge
1424
            };
1425
1426
840
            self.matcher_control = self.matcher.run(
1427
840
                query,
1428
840
                &item_pool,
1429
840
                thread_pool,
1430
840
                self.item_list.processed_items.clone(),
1431
840
                merge_strategy,
1432
840
                no_sort,
1433
840
                self.options.tac,
1434
840
                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
840
            self.result_pending = true;
1439
463
        }
1440
1.31k
    }
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
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]
@@ -602,4 +602,4 @@
 
  Branch (1593:29): [True: 6, False: 7]
 
1594
6
            Vec::new()
1595
        } else {
1596
8
            self.on_selection_changed()
1597
        };
1598
14
        if double_click {
  Branch (1598:12): [True: 0, False: 1]
 
  Branch (1598:12): [True: 1, False: 12]
-
1599
1
            events.push(Event::Key(SkimEvent::DoubleClick.into()));
1600
13
        }
1601
14
        Ok(events)
1602
18
    }
1603
14
    fn toggle_spinner(&mut self) {
1604
14
        self.show_spinner = !self.show_spinner;
1605
14
        self.spinner_last_change = std::time::Instant::now();
1606
14
        self.needs_render.store(true, Ordering::Relaxed);
1607
14
    }
1608
}
\ No newline at end of file +
1599
1
            events.push(Event::Key(SkimEvent::DoubleClick.into()));
1600
13
        }
1601
14
        Ok(events)
1602
18
    }
1603
9
    fn toggle_spinner(&mut self) {
1604
9
        self.show_spinner = !self.show_spinner;
1605
9
        self.spinner_last_change = std::time::Instant::now();
1606
9
        self.needs_render.store(true, Ordering::Relaxed);
1607
9
    }
1608
}
\ No newline at end of file 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 8fff2716..8c261bfd 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,22 +1,22 @@ -

Coverage Report

Created: 2026-08-22 15:34

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
    enable_mouse: bool,
46
}
47
48
impl Tui {
49
    /// Creates a TUI with the default backend (buffered stderr) and the specified height
50
    ///
51
    /// # Errors
52
    ///
53
    /// Returns an error if the TUI backend cannot be initialized.
54
7
    pub fn new_with_height(height: Size) -> Result<Self> {
55
7
        let backend = CrosstermBackend::new(std::io::BufWriter::new(stderr()));
56
7
        Self::new_with_height_and_backend(backend, height)
57
7
    }
58
    /// Disable mouse handling.
59
    /// Needs to be called before enter.
60
0
    pub fn disable_mouse(&mut self) -> &mut Self {
61
0
        self.enable_mouse = false;
62
0
        self
63
0
    }
64
}
65
66
impl<B: Backend> Tui<B>
67
where
68
    B::Error: Send + Sync + 'static,
69
{
70
    /// Creates a new TUI with the specified backend and height
71
    ///
72
    /// # Errors
73
    ///
74
    /// Returns an error if the terminal size cannot be determined or setup fails.
75
    ///
76
    /// # Panics
77
    ///
78
    /// Panics if the terminal size cannot be read from the backend.
79
407
    pub fn new_with_height_and_backend(backend: B, height: Size) -> Result<Self> {
80
407
        let event_channel = channel(1024 * 1024);
81
82
407
        let term_height = backend.size().expect("Failed to get terminal height").height;
83
407
        let lines = match height {
84
403
            Size::Percent(100) => None,
85
0
            Size::Fixed(lines) => Some(lines),
86
4
            Size::Percent(p) => Some(term_height * p / 100),
87
0
            Size::Neg(lines) => Some(term_height.saturating_sub(lines)),
88
        };
89
90
407
        let viewport = if let Some(
mut height4
) = lines {
  Branch (90:31): [True: 4, False: 3]
-
  Branch (90:31): [True: 0, False: 34]
-
  Branch (90:31): [True: 0, False: 13]
-
  Branch (90:31): [True: 0, False: 2]
+

Coverage Report

Created: 2026-09-01 09:15

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
    enable_mouse: bool,
46
}
47
48
impl Tui {
49
    /// Creates a TUI with the default backend (buffered stderr) and the specified height
50
    ///
51
    /// # Errors
52
    ///
53
    /// Returns an error if the TUI backend cannot be initialized.
54
7
    pub fn new_with_height(height: Size) -> Result<Self> {
55
7
        let backend = CrosstermBackend::new(std::io::BufWriter::new(stderr()));
56
7
        Self::new_with_height_and_backend(backend, height)
57
7
    }
58
    /// Disable mouse handling.
59
    /// Needs to be called before enter.
60
0
    pub fn disable_mouse(&mut self) -> &mut Self {
61
0
        self.enable_mouse = false;
62
0
        self
63
0
    }
64
}
65
66
impl<B: Backend> Tui<B>
67
where
68
    B::Error: Send + Sync + 'static,
69
{
70
    /// Creates a new TUI with the specified backend and height
71
    ///
72
    /// # Errors
73
    ///
74
    /// Returns an error if the terminal size cannot be determined or setup fails.
75
    ///
76
    /// # Panics
77
    ///
78
    /// Panics if the terminal size cannot be read from the backend.
79
402
    pub fn new_with_height_and_backend(backend: B, height: Size) -> Result<Self> {
80
402
        let event_channel = channel(1024 * 1024);
81
82
402
        let term_height = backend.size().expect("Failed to get terminal height").height;
83
402
        let lines = match height {
84
398
            Size::Percent(100) => None,
85
0
            Size::Fixed(lines) => Some(lines),
86
4
            Size::Percent(p) => Some(term_height * p / 100),
87
0
            Size::Neg(lines) => Some(term_height.saturating_sub(lines)),
88
        };
89
90
402
        let viewport = if let Some(
mut height4
) = lines {
  Branch (90:31): [True: 4, False: 3]
 
  Branch (90:31): [True: 0, False: 123]
 
  Branch (90:31): [True: 0, False: 9]
+
  Branch (90:31): [True: 0, False: 22]
+
  Branch (90:31): [True: 0, False: 12]
 
  Branch (90:31): [True: 0, False: 4]
+
  Branch (90:31): [True: 0, False: 1]
 
  Branch (90:31): [True: 0, False: 54]
 
  Branch (90:31): [True: 0, False: 22]
-
  Branch (90:31): [True: 0, False: 22]
-
  Branch (90:31): [True: 0, False: 1]
-
  Branch (90:31): [True: 0, False: 36]
-
  Branch (90:31): [True: 0, False: 12]
-
  Branch (90:31): [True: 0, False: 7]
-
  Branch (90:31): [True: 0, False: 9]
-
  Branch (90:31): [True: 0, False: 10]
-
  Branch (90:31): [True: 0, False: 26]
-
  Branch (90:31): [True: 0, False: 11]
 
  Branch (90:31): [True: 0, False: 5]
+
  Branch (90:31): [True: 0, False: 34]
+
  Branch (90:31): [True: 0, False: 11]
+
  Branch (90:31): [True: 0, False: 12]
+
  Branch (90:31): [True: 0, False: 10]
+
  Branch (90:31): [True: 0, False: 2]
+
  Branch (90:31): [True: 0, False: 9]
+
  Branch (90:31): [True: 0, False: 26]
+
  Branch (90:31): [True: 0, False: 32]
+
  Branch (90:31): [True: 0, False: 7]
 
91
            // Until https://github.com/crossterm-rs/crossterm/issues/919 is fixed, we need to do it ourselves
92
4
            let cursor_pos = cursor_pos_from_tty()
?0
;
93
4
            let mut y = cursor_pos.1 - 1;
94
4
            height = height.min(term_height);
95
4
            if term_height - cursor_pos.1 < height {
  Branch (95:16): [True: 0, False: 4]
 
  Branch (95:16): [True: 0, False: 0]
 
  Branch (95:16): [True: 0, False: 0]
@@ -36,26 +36,26 @@
 
  Branch (95:16): [True: 0, False: 0]
 
  Branch (95:16): [True: 0, False: 0]
 
  Branch (95:16): [True: 0, False: 0]
-
96
0
                let to_scroll = height - (term_height - cursor_pos.1) - 1;
97
0
                crossterm::execute!(stderr(), crossterm::terminal::ScrollUp(to_scroll))?;
98
0
                y = y.saturating_sub(to_scroll);
99
4
            }
100
4
            Viewport::Fixed(Rect::new(
101
4
                0,
102
4
                y,
103
4
                backend.size().expect("Failed to get terminal width").width - 1,
104
4
                height,
105
4
            ))
106
        } else {
107
403
            Viewport::Fullscreen
108
        };
109
110
407
        set_panic_hook();
111
        Ok(Self {
112
407
            terminal: ratatui::Terminal::with_options(backend, TerminalOptions { viewport })
?0
,
113
407
            task: None,
114
407
            event_rx: event_channel.1,
115
407
            event_tx: event_channel.0,
116
407
            tick_rate: f64::from(TICK_RATE),
117
407
            cancellation_token: CancellationToken::default(),
118
407
            is_fullscreen: lines.is_none(),
119
            enable_mouse: true,
120
        })
121
407
    }
122
123
    /// Enters the TUI by enabling raw mode and starting event handling
124
    ///
125
    /// # Errors
126
    ///
127
    /// Returns an error if enabling raw mode or mouse capture fails.
128
0
    pub fn enter(&mut self) -> Result<()> {
129
0
        self.enter_terminal()?;
130
0
        self.start();
131
0
        Ok(())
132
0
    }
133
134
    /// Enables terminal modes and enters the alternate screen without starting event polling.
135
    ///
136
    /// This lets callers run terminal queries after alternate-screen entry but
137
    /// before the event stream starts reading terminal input.
138
    ///
139
    /// # Errors
140
    ///
141
    /// Returns an error if enabling raw mode or terminal features fails.
142
7
    pub fn enter_terminal(&mut self) -> Result<()> {
143
7
        crossterm::terminal::enable_raw_mode()
?0
;
144
        // On Windows, install a console ctrl handler so that CTRL_C_EVENT
145
        // performs terminal cleanup instead of killing the process abruptly.
146
        #[cfg(windows)]
147
        super::windows::install_ctrl_c_handler()?;
148
149
7
        self.execute_enter()
?0
;
150
7
        Ok(())
151
7
    }
152
153
    /// Exits the TUI by stopping event handling and disabling raw mode
154
    ///
155
    /// # Errors
156
    ///
157
    /// Returns an error if disabling raw mode or mouse capture fails.
158
408
    pub fn exit(&mut self) -> Result<()> {
159
408
        self.stop();
160
408
        cleanup_terminal()
?0
;
161
        // Remove our console ctrl handler now that raw mode is off.
162
        #[cfg(windows)]
163
        super::windows::uninstall_ctrl_c_handler();
164
        // When using the inline layout, we want to remove all previous output
165
        //  -> reset cursor at the top of the drawing area
166
408
        if !self.is_fullscreen {
  Branch (166:12): [True: 4, False: 3]
-
  Branch (166:12): [True: 0, False: 34]
-
  Branch (166:12): [True: 0, False: 13]
-
  Branch (166:12): [True: 0, False: 2]
+
96
0
                let to_scroll = height - (term_height - cursor_pos.1) - 1;
97
0
                crossterm::execute!(stderr(), crossterm::terminal::ScrollUp(to_scroll))?;
98
0
                y = y.saturating_sub(to_scroll);
99
4
            }
100
4
            Viewport::Fixed(Rect::new(
101
4
                0,
102
4
                y,
103
4
                backend.size().expect("Failed to get terminal width").width - 1,
104
4
                height,
105
4
            ))
106
        } else {
107
398
            Viewport::Fullscreen
108
        };
109
110
402
        set_panic_hook();
111
        Ok(Self {
112
402
            terminal: ratatui::Terminal::with_options(backend, TerminalOptions { viewport })
?0
,
113
402
            task: None,
114
402
            event_rx: event_channel.1,
115
402
            event_tx: event_channel.0,
116
402
            tick_rate: f64::from(TICK_RATE),
117
402
            cancellation_token: CancellationToken::default(),
118
402
            is_fullscreen: lines.is_none(),
119
            enable_mouse: true,
120
        })
121
402
    }
122
123
    /// Enters the TUI by enabling raw mode and starting event handling
124
    ///
125
    /// # Errors
126
    ///
127
    /// Returns an error if enabling raw mode or mouse capture fails.
128
0
    pub fn enter(&mut self) -> Result<()> {
129
0
        self.enter_terminal()?;
130
0
        self.start();
131
0
        Ok(())
132
0
    }
133
134
    /// Enables terminal modes and enters the alternate screen without starting event polling.
135
    ///
136
    /// This lets callers run terminal queries after alternate-screen entry but
137
    /// before the event stream starts reading terminal input.
138
    ///
139
    /// # Errors
140
    ///
141
    /// Returns an error if enabling raw mode or terminal features fails.
142
7
    pub fn enter_terminal(&mut self) -> Result<()> {
143
7
        crossterm::terminal::enable_raw_mode()
?0
;
144
        // On Windows, install a console ctrl handler so that CTRL_C_EVENT
145
        // performs terminal cleanup instead of killing the process abruptly.
146
        #[cfg(windows)]
147
        super::windows::install_ctrl_c_handler()?;
148
149
7
        self.execute_enter()
?0
;
150
7
        Ok(())
151
7
    }
152
153
    /// Exits the TUI by stopping event handling and disabling raw mode
154
    ///
155
    /// # Errors
156
    ///
157
    /// Returns an error if disabling raw mode or mouse capture fails.
158
403
    pub fn exit(&mut self) -> Result<()> {
159
403
        self.stop();
160
403
        cleanup_terminal()
?0
;
161
        // Remove our console ctrl handler now that raw mode is off.
162
        #[cfg(windows)]
163
        super::windows::uninstall_ctrl_c_handler();
164
        // When using the inline layout, we want to remove all previous output
165
        //  -> reset cursor at the top of the drawing area
166
403
        if !self.is_fullscreen {
  Branch (166:12): [True: 4, False: 3]
 
  Branch (166:12): [True: 0, False: 123]
 
  Branch (166:12): [True: 0, False: 9]
+
  Branch (166:12): [True: 0, False: 22]
+
  Branch (166:12): [True: 0, False: 12]
 
  Branch (166:12): [True: 0, False: 4]
+
  Branch (166:12): [True: 0, False: 1]
 
  Branch (166:12): [True: 1, False: 54]
 
  Branch (166:12): [True: 0, False: 22]
-
  Branch (166:12): [True: 0, False: 22]
-
  Branch (166:12): [True: 0, False: 1]
-
  Branch (166:12): [True: 0, False: 36]
-
  Branch (166:12): [True: 0, False: 12]
-
  Branch (166:12): [True: 0, False: 7]
-
  Branch (166:12): [True: 0, False: 9]
-
  Branch (166:12): [True: 0, False: 10]
-
  Branch (166:12): [True: 0, False: 26]
-
  Branch (166:12): [True: 0, False: 11]
 
  Branch (166:12): [True: 0, False: 5]
-
167
5
            let area = self.get_frame().area();
168
5
            let orig = ratatui::layout::Position { x: area.x, y: area.y };
169
5
            crossterm::execute!(
170
5
                stderr(),
171
                cursor::MoveTo(orig.x, orig.y),
172
                Clear(ClearType::FromCursorDown)
173
0
            )?;
174
5
            self.set_cursor_position(orig)
?0
;
175
403
        }
176
408
        Ok(())
177
408
    }
178
    /// Stops the TUI event loop
179
    /// Equivalent to `self.cancel()`
180
409
    pub fn stop(&self) {
181
409
        self.cancel();
182
409
    }
183
    /// Forces the next [`draw`](ratatui::Terminal::draw) to repaint every cell.
184
    ///
185
    /// ratatui only writes cells that differ from the previously drawn buffer.
186
    /// After the display has been disturbed out from under it — e.g. an
187
    /// `execute` action that ran a child program and re-entered the alternate
188
    /// screen — that cached buffer is stale and a normal draw would leave the
189
    /// screen partially blank. Resetting *both* double buffers makes the next
190
    /// draw diff against an empty buffer and thus repaint everything.
191
    ///
192
    /// Unlike [`ratatui::Terminal::clear`], this performs no cursor-position
193
    /// query (which crossterm writes to stdout and which stalls when stdout is
194
    /// redirected), and it is viewport-agnostic (works for fullscreen and
195
    /// inline layouts alike).
196
2
    pub fn force_full_redraw(&mut self) {
197
2
        self.terminal.swap_buffers();
198
2
        self.terminal.swap_buffers();
199
2
    }
200
    /// Stops the input reader and waits for it to release the terminal.
201
    ///
202
    /// Unlike [`stop`](Self::stop), this blocks until the background task has
203
    /// observed the cancellation and dropped its `EventStream`, so crossterm's
204
    /// internal reader thread has stopped reading the terminal before this
205
    /// returns. Call this before handing the terminal to a foreground child
206
    /// process (e.g. an `execute` action): otherwise skim's reader competes
207
    /// with the child for keystrokes and interactive TUIs appear to freeze.
208
    ///
209
    /// Restart the reader afterwards with [`start`](Self::start).
210
    ///
211
    /// # Panics
212
    ///
213
    /// Panics if called from outside a multi-threaded Tokio runtime, since it
214
    /// uses `block_in_place` to await the reader task from synchronous code.
215
1
    pub fn stop_and_join(&mut self) {
216
1
        self.cancel();
217
1
        if let Some(
task0
) = self.task.take() {
  Branch (217:16): [True: 0, False: 0]
+
  Branch (166:12): [True: 0, False: 34]
+
  Branch (166:12): [True: 0, False: 11]
+
  Branch (166:12): [True: 0, False: 12]
+
  Branch (166:12): [True: 0, False: 10]
+
  Branch (166:12): [True: 0, False: 2]
+
  Branch (166:12): [True: 0, False: 9]
+
  Branch (166:12): [True: 0, False: 26]
+
  Branch (166:12): [True: 0, False: 32]
+
  Branch (166:12): [True: 0, False: 7]
+
167
5
            let area = self.get_frame().area();
168
5
            let orig = ratatui::layout::Position { x: area.x, y: area.y };
169
5
            crossterm::execute!(
170
5
                stderr(),
171
                cursor::MoveTo(orig.x, orig.y),
172
                Clear(ClearType::FromCursorDown)
173
0
            )?;
174
5
            self.set_cursor_position(orig)
?0
;
175
398
        }
176
403
        Ok(())
177
403
    }
178
    /// Stops the TUI event loop
179
    /// Equivalent to `self.cancel()`
180
404
    pub fn stop(&self) {
181
404
        self.cancel();
182
404
    }
183
    /// Forces the next [`draw`](ratatui::Terminal::draw) to repaint every cell.
184
    ///
185
    /// ratatui only writes cells that differ from the previously drawn buffer.
186
    /// After the display has been disturbed out from under it — e.g. an
187
    /// `execute` action that ran a child program and re-entered the alternate
188
    /// screen — that cached buffer is stale and a normal draw would leave the
189
    /// screen partially blank. Resetting *both* double buffers makes the next
190
    /// draw diff against an empty buffer and thus repaint everything.
191
    ///
192
    /// Unlike [`ratatui::Terminal::clear`], this performs no cursor-position
193
    /// query (which crossterm writes to stdout and which stalls when stdout is
194
    /// redirected), and it is viewport-agnostic (works for fullscreen and
195
    /// inline layouts alike).
196
2
    pub fn force_full_redraw(&mut self) {
197
2
        self.terminal.swap_buffers();
198
2
        self.terminal.swap_buffers();
199
2
    }
200
    /// Stops the input reader and waits for it to release the terminal.
201
    ///
202
    /// Unlike [`stop`](Self::stop), this blocks until the background task has
203
    /// observed the cancellation and dropped its `EventStream`, so crossterm's
204
    /// internal reader thread has stopped reading the terminal before this
205
    /// returns. Call this before handing the terminal to a foreground child
206
    /// process (e.g. an `execute` action): otherwise skim's reader competes
207
    /// with the child for keystrokes and interactive TUIs appear to freeze.
208
    ///
209
    /// Restart the reader afterwards with [`start`](Self::start).
210
    ///
211
    /// # Panics
212
    ///
213
    /// Panics if called from outside a multi-threaded Tokio runtime, since it
214
    /// uses `block_in_place` to await the reader task from synchronous code.
215
1
    pub fn stop_and_join(&mut self) {
216
1
        self.cancel();
217
1
        if let Some(
task0
) = self.task.take() {
  Branch (217:16): [True: 0, False: 0]
 
  Branch (217:16): [True: 0, False: 0]
 
  Branch (217:16): [True: 0, False: 0]
 
  Branch (217:16): [True: 0, False: 0]
@@ -74,7 +74,7 @@
 
  Branch (217:16): [True: 0, False: 0]
 
  Branch (217:16): [True: 0, False: 0]
 
  Branch (217:16): [True: 0, False: 0]
-
218
            // We are on a synchronous call stack nested inside the async event
219
            // loop. `block_in_place` moves this worker off the async pool so we
220
            // can block on the task's completion without starving the runtime.
221
0
            let _ = tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(task));
222
1
        }
223
1
    }
224
    /// Cancels all background tasks
225
412
    pub fn cancel(&self) {
226
412
        self.cancellation_token.cancel();
227
412
    }
228
    /// Starts the event loop for handling keyboard and timer events
229
8
    pub fn start(&mut self) {
230
8
        let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate);
231
8
        let event_tx_clone = self.event_tx.clone();
232
        // Cancel any previously running reader before spawning a new one.
233
8
        if self.task.is_some() {
  Branch (233:12): [True: 0, False: 7]
+
218
            // We are on a synchronous call stack nested inside the async event
219
            // loop. `block_in_place` moves this worker off the async pool so we
220
            // can block on the task's completion without starving the runtime.
221
0
            let _ = tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(task));
222
1
        }
223
1
    }
224
    /// Cancels all background tasks
225
407
    pub fn cancel(&self) {
226
407
        self.cancellation_token.cancel();
227
407
    }
228
    /// Starts the event loop for handling keyboard and timer events
229
8
    pub fn start(&mut self) {
230
8
        let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate);
231
8
        let event_tx_clone = self.event_tx.clone();
232
        // Cancel any previously running reader before spawning a new one.
233
8
        if self.task.is_some() {
  Branch (233:12): [True: 0, False: 7]
 
  Branch (233:12): [True: 0, False: 0]
 
  Branch (233:12): [True: 0, False: 0]
 
  Branch (233:12): [True: 0, False: 0]
@@ -93,7 +93,7 @@
 
  Branch (233:12): [True: 0, False: 0]
 
  Branch (233:12): [True: 0, False: 0]
 
  Branch (233:12): [True: 0, False: 0]
-
234
0
            self.cancel();
235
8
        }
236
        // Install a fresh cancellation token: a `CancellationToken` stays
237
        // cancelled once cancelled, so reusing the old one (after `stop`,
238
        // `stop_and_join`, or a prior `start`) would make the new task observe
239
        // the cancellation immediately and exit without reading any input.
240
        // This is what lets the reader resume after an `execute` action.
241
8
        self.cancellation_token = CancellationToken::new();
242
8
        let cancellation_token_clone = self.cancellation_token.clone();
243
8
        self.task = Some(tokio::spawn(async move {
244
8
            let mut reader = crossterm::event::EventStream::new();
245
8
            let mut tick_interval = tokio::time::interval(tick_delay);
246
            loop {
247
44
                let tick_delay = tick_interval.tick();
248
44
                let crossterm_event = reader.next().fuse();
249
44
                tokio::select! {
250
44
                    () = cancellation_token_clone.cancelled() => {
251
1
                        break;
252
                    }
253
44
                    
maybe_event3
= crossterm_event => {
254
4
                      match maybe_event {
255
4
                        Some(Ok(crossterm::event::Event::Key(key))) => {
256
4
                          if key.kind == KeyEventKind::Press {
  Branch (256:30): [True: 4, False: 0]
+
234
0
            self.cancel();
235
8
        }
236
        // Install a fresh cancellation token: a `CancellationToken` stays
237
        // cancelled once cancelled, so reusing the old one (after `stop`,
238
        // `stop_and_join`, or a prior `start`) would make the new task observe
239
        // the cancellation immediately and exit without reading any input.
240
        // This is what lets the reader resume after an `execute` action.
241
8
        self.cancellation_token = CancellationToken::new();
242
8
        let cancellation_token_clone = self.cancellation_token.clone();
243
8
        self.task = Some(tokio::spawn(async move {
244
8
            let mut reader = crossterm::event::EventStream::new();
245
8
            let mut tick_interval = tokio::time::interval(tick_delay);
246
            loop {
247
45
                let tick_delay = tick_interval.tick();
248
45
                let crossterm_event = reader.next().fuse();
249
45
                tokio::select! {
250
45
                    () = cancellation_token_clone.cancelled() => {
251
1
                        break;
252
                    }
253
45
                    
maybe_event3
= crossterm_event => {
254
4
                      match maybe_event {
255
4
                        Some(Ok(crossterm::event::Event::Key(key))) => {
256
4
                          if key.kind == KeyEventKind::Press {
  Branch (256:30): [True: 4, False: 0]
 
  Branch (256:30): [True: 0, False: 0]
 
  Branch (256:30): [True: 0, False: 0]
 
  Branch (256:30): [True: 0, False: 0]
@@ -112,7 +112,7 @@
 
  Branch (256:30): [True: 0, False: 0]
 
  Branch (256:30): [True: 0, False: 0]
 
  Branch (256:30): [True: 0, False: 0]
-
257
4
                            _ = event_tx_clone.try_send(Event::Key(key));
258
4
                          
}0
259
                        }
260
0
                        Some(Ok(crossterm::event::Event::Paste(text))) => {
261
0
                          _ = event_tx_clone.try_send(Event::Paste(text));
262
0
                        }
263
0
                        Some(Ok(crossterm::event::Event::Mouse(mouse))) => {
264
0
                          _ = event_tx_clone.try_send(Event::Mouse(mouse));
265
0
                        }
266
0
                        Some(Ok(crossterm::event::Event::Resize(cols, rows))) => {
267
0
                          _ = event_tx_clone.try_send(Event::Resize(cols, rows));
268
0
                          _ = event_tx_clone.try_send(Event::Render);
269
0
                        }
270
0
                        Some(Err(e)) => {
271
0
                          _ = event_tx_clone.try_send(Event::Error(e.to_string()));
272
0
                        }
273
18.4E
                        None | Some(Ok(_)) => {},
274
                      }
275
                    },
276
44
                    _ = tick_delay => {
277
33
                        _ = event_tx_clone.try_send(Event::Heartbeat);
278
33
                    },
279
                }
280
            }
281
1
        }));
282
8
    }
283
284
    /// Gets the next event from the event queue
285
107
    pub async fn next(&mut self) -> Option<Event> 
{99
286
99
        self.event_rx.recv().await
287
93
    }
288
289
7
    fn execute_enter(&self) -> Result<()> {
290
7
        crossterm::execute!(stderr(), EnableBracketedPaste)
?0
;
291
7
        if self.enable_mouse {
  Branch (291:12): [True: 7, False: 0]
+
257
4
                            _ = event_tx_clone.try_send(Event::Key(key));
258
4
                          
}0
259
                        }
260
0
                        Some(Ok(crossterm::event::Event::Paste(text))) => {
261
0
                          _ = event_tx_clone.try_send(Event::Paste(text));
262
0
                        }
263
0
                        Some(Ok(crossterm::event::Event::Mouse(mouse))) => {
264
0
                          _ = event_tx_clone.try_send(Event::Mouse(mouse));
265
0
                        }
266
0
                        Some(Ok(crossterm::event::Event::Resize(cols, rows))) => {
267
0
                          _ = event_tx_clone.try_send(Event::Resize(cols, rows));
268
0
                          _ = event_tx_clone.try_send(Event::Render);
269
0
                        }
270
0
                        Some(Err(e)) => {
271
0
                          _ = event_tx_clone.try_send(Event::Error(e.to_string()));
272
0
                        }
273
18.4E
                        None | Some(Ok(_)) => {},
274
                      }
275
                    },
276
45
                    _ = tick_delay => {
277
34
                        _ = event_tx_clone.try_send(Event::Heartbeat);
278
34
                    },
279
                }
280
            }
281
1
        }));
282
8
    }
283
284
    /// Gets the next event from the event queue
285
113
    pub async fn next(&mut self) -> Option<Event> 
{106
286
106
        self.event_rx.recv().await
287
97
    }
288
289
7
    fn execute_enter(&self) -> Result<()> {
290
7
        crossterm::execute!(stderr(), EnableBracketedPaste)
?0
;
291
7
        if self.enable_mouse {
  Branch (291:12): [True: 7, False: 0]
 
  Branch (291:12): [True: 0, False: 0]
 
  Branch (291:12): [True: 0, False: 0]
 
  Branch (291:12): [True: 0, False: 0]
@@ -302,25 +302,25 @@
 
  Branch (376:12): [True: 0, False: 0]
 
  Branch (376:12): [True: 0, False: 0]
 
  Branch (376:12): [True: 0, False: 0]
-
377
0
            restore_result = self.resume(in_raw_mode);
378
1
        }
379
380
        // Resume skim's input reader now that the terminal is ours again.
381
1
        self.start();
382
1
        restore_result
383
1
    }
384
}
385
386
impl<B: Backend> Deref for Tui<B>
387
where
388
    B::Error: Send + Sync + 'static,
389
{
390
    type Target = ratatui::Terminal<B>;
391
392
602
    fn deref(&self) -> &Self::Target {
393
602
        &self.terminal
394
602
    }
395
}
396
397
impl<B: Backend> DerefMut for Tui<B>
398
where
399
    B::Error: Send + Sync + 'static,
400
{
401
5.32k
    fn deref_mut(&mut self) -> &mut Self::Target {
402
5.32k
        &mut self.terminal
403
5.32k
    }
404
}
405
406
impl<B: Backend> Drop for Tui<B>
407
where
408
    B::Error: Send + Sync + 'static,
409
{
410
407
    fn drop(&mut self) {
411
407
        if let Some(
t8
) = self.task.take() {
  Branch (411:16): [True: 7, False: 0]
-
  Branch (411:16): [True: 0, False: 34]
-
  Branch (411:16): [True: 0, False: 13]
-
  Branch (411:16): [True: 0, False: 2]
+
377
0
            restore_result = self.resume(in_raw_mode);
378
1
        }
379
380
        // Resume skim's input reader now that the terminal is ours again.
381
1
        self.start();
382
1
        restore_result
383
1
    }
384
}
385
386
impl<B: Backend> Deref for Tui<B>
387
where
388
    B::Error: Send + Sync + 'static,
389
{
390
    type Target = ratatui::Terminal<B>;
391
392
597
    fn deref(&self) -> &Self::Target {
393
597
        &self.terminal
394
597
    }
395
}
396
397
impl<B: Backend> DerefMut for Tui<B>
398
where
399
    B::Error: Send + Sync + 'static,
400
{
401
5.29k
    fn deref_mut(&mut self) -> &mut Self::Target {
402
5.29k
        &mut self.terminal
403
5.29k
    }
404
}
405
406
impl<B: Backend> Drop for Tui<B>
407
where
408
    B::Error: Send + Sync + 'static,
409
{
410
402
    fn drop(&mut self) {
411
402
        if let Some(
t8
) = self.task.take() {
  Branch (411:16): [True: 7, False: 0]
 
  Branch (411:16): [True: 0, False: 123]
 
  Branch (411:16): [True: 0, False: 9]
+
  Branch (411:16): [True: 0, False: 22]
+
  Branch (411:16): [True: 0, False: 12]
 
  Branch (411:16): [True: 0, False: 4]
+
  Branch (411:16): [True: 0, False: 1]
 
  Branch (411:16): [True: 1, False: 53]
 
  Branch (411:16): [True: 0, False: 22]
-
  Branch (411:16): [True: 0, False: 22]
-
  Branch (411:16): [True: 0, False: 1]
-
  Branch (411:16): [True: 0, False: 36]
-
  Branch (411:16): [True: 0, False: 12]
-
  Branch (411:16): [True: 0, False: 7]
-
  Branch (411:16): [True: 0, False: 9]
-
  Branch (411:16): [True: 0, False: 10]
-
  Branch (411:16): [True: 0, False: 26]
-
  Branch (411:16): [True: 0, False: 11]
 
  Branch (411:16): [True: 0, False: 5]
-
412
8
            t.abort();
413
399
        }
414
407
        let _ = self.exit();
415
407
    }
416
}
417
418
407
fn set_panic_hook() {
419
407
    PANIC_HOOK_SET.call_once(|| 
{406
420
406
        let hook = std::panic::take_hook();
421
406
        std::panic::set_hook(Box::new(move |panic_info| 
{9
422
9
            let _ = cleanup_terminal();
423
            #[cfg(windows)]
424
            super::windows::uninstall_ctrl_c_handler();
425
9
            hook(panic_info);
426
9
        }));
427
406
    });
428
407
}
429
430
/// Perform terminal cleanup: disable mouse capture, bracketed paste,
431
/// leave alternate screen, show cursor, and disable raw mode.
432
///
433
/// This is safe to call from any thread since:
434
/// - Escape sequences are written atomically to stderr
435
/// - `SetConsoleMode` (used by `disable_raw_mode`) is thread-safe on Windows
436
417
pub(crate) fn cleanup_terminal() -> std::io::Result<()> {
437
417
    if let Err(
e0
) = crossterm::execute!(stderr(), PopKeyboardEnhancementFlags) {
  Branch (437:12): [True: 0, False: 361]
+
  Branch (411:16): [True: 0, False: 34]
+
  Branch (411:16): [True: 0, False: 11]
+
  Branch (411:16): [True: 0, False: 12]
+
  Branch (411:16): [True: 0, False: 10]
+
  Branch (411:16): [True: 0, False: 2]
+
  Branch (411:16): [True: 0, False: 9]
+
  Branch (411:16): [True: 0, False: 26]
+
  Branch (411:16): [True: 0, False: 32]
+
  Branch (411:16): [True: 0, False: 7]
+
412
8
            t.abort();
413
394
        }
414
402
        let _ = self.exit();
415
402
    }
416
}
417
418
402
fn set_panic_hook() {
419
402
    PANIC_HOOK_SET.call_once(|| 
{401
420
401
        let hook = std::panic::take_hook();
421
401
        std::panic::set_hook(Box::new(move |panic_info| 
{4
422
4
            let _ = cleanup_terminal();
423
            #[cfg(windows)]
424
            super::windows::uninstall_ctrl_c_handler();
425
4
            hook(panic_info);
426
4
        }));
427
401
    });
428
402
}
429
430
/// Perform terminal cleanup: disable mouse capture, bracketed paste,
431
/// leave alternate screen, show cursor, and disable raw mode.
432
///
433
/// This is safe to call from any thread since:
434
/// - Escape sequences are written atomically to stderr
435
/// - `SetConsoleMode` (used by `disable_raw_mode`) is thread-safe on Windows
436
407
pub(crate) fn cleanup_terminal() -> std::io::Result<()> {
437
407
    if let Err(
e0
) = crossterm::execute!(stderr(), PopKeyboardEnhancementFlags) {
  Branch (437:12): [True: 0, False: 351]
 
  Branch (437:12): [True: 0, False: 56]
-
438
0
        warn!("Failed to remove keyboard enhancement flags: {e}");
439
417
    }
440
417
    crossterm::execute!(
441
417
        stderr(),
442
        DisableMouseCapture,
443
        DisableBracketedPaste,
444
        LeaveAlternateScreen,
445
        cursor::Show
446
0
    )?;
447
417
    crossterm::terminal::disable_raw_mode()
?0
;
448
417
    Ok(())
449
417
}
450
451
/// Build the stdin handle for an `execute` child process.
452
///
453
/// skim's own stdin (fd 0) is frequently a pipe carrying the item list
454
/// (e.g. `find | sk`), which is useless as a keyboard source for an
455
/// interactive child. Hand the child a fresh handle to the controlling
456
/// terminal instead, so programs like `ncdu` or other ncurses TUIs can read
457
/// the keyboard even when skim's stdin is a pipe. Falls back to inheriting
458
/// skim's stdin if the terminal cannot be opened.
459
1
fn execute_child_stdin() -> std::process::Stdio {
460
    #[cfg(unix)]
461
1
    let tty = std::fs::File::open("/dev/tty");
462
    #[cfg(windows)]
463
    let tty = std::fs::OpenOptions::new().read(true).write(true).open("CONIN$");
464
1
    tty.map_or_else(|_| Stdio::inherit(), Stdio::from)
465
1
}
466
467
#[cfg(test)]
468
#[cfg_attr(coverage, coverage(off))]
469
mod tests {
470
    use super::*;
471
    use ratatui::backend::TestBackend;
472
473
    fn fullscreen_tui() -> Tui<TestBackend> {
474
        // Percent(100) selects the fullscreen viewport, avoiding any TTY cursor query.
475
        Tui::new_with_height_and_backend(TestBackend::new(80, 24), Size::Percent(100))
476
            .expect("failed to build test TUI")
477
    }
478
479
    #[test]
480
    fn new_with_full_height_is_fullscreen() {
481
        let tui = fullscreen_tui();
482
        assert!(tui.is_fullscreen);
483
        assert!(tui.enable_mouse);
484
    }
485
486
    #[test]
487
    fn stop_cancels_token() {
488
        let tui = fullscreen_tui();
489
        assert!(!tui.cancellation_token.is_cancelled());
490
        tui.stop();
491
        assert!(tui.cancellation_token.is_cancelled());
492
    }
493
494
    #[test]
495
    fn cancel_is_idempotent() {
496
        let tui = fullscreen_tui();
497
        tui.cancel();
498
        tui.cancel();
499
        assert!(tui.cancellation_token.is_cancelled());
500
    }
501
502
    #[test]
503
    fn deref_exposes_terminal_frame() {
504
        let mut tui = fullscreen_tui();
505
        // Deref/DerefMut should expose the underlying ratatui terminal.
506
        let area = tui.get_frame().area();
507
        assert_eq!(area.width, 80);
508
        assert_eq!(area.height, 24);
509
    }
510
}
\ No newline at end of file +
438
0
        warn!("Failed to remove keyboard enhancement flags: {e}");
439
407
    }
440
407
    crossterm::execute!(
441
407
        stderr(),
442
        DisableMouseCapture,
443
        DisableBracketedPaste,
444
        LeaveAlternateScreen,
445
        cursor::Show
446
0
    )?;
447
407
    crossterm::terminal::disable_raw_mode()
?0
;
448
407
    Ok(())
449
407
}
450
451
/// Build the stdin handle for an `execute` child process.
452
///
453
/// skim's own stdin (fd 0) is frequently a pipe carrying the item list
454
/// (e.g. `find | sk`), which is useless as a keyboard source for an
455
/// interactive child. Hand the child a fresh handle to the controlling
456
/// terminal instead, so programs like `ncdu` or other ncurses TUIs can read
457
/// the keyboard even when skim's stdin is a pipe. Falls back to inheriting
458
/// skim's stdin if the terminal cannot be opened.
459
1
fn execute_child_stdin() -> std::process::Stdio {
460
    #[cfg(unix)]
461
1
    let tty = std::fs::File::open("/dev/tty");
462
    #[cfg(windows)]
463
    let tty = std::fs::OpenOptions::new().read(true).write(true).open("CONIN$");
464
1
    tty.map_or_else(|_| Stdio::inherit(), Stdio::from)
465
1
}
466
467
#[cfg(test)]
468
#[cfg_attr(coverage, coverage(off))]
469
mod tests {
470
    use super::*;
471
    use ratatui::backend::TestBackend;
472
473
    fn fullscreen_tui() -> Tui<TestBackend> {
474
        // Percent(100) selects the fullscreen viewport, avoiding any TTY cursor query.
475
        Tui::new_with_height_and_backend(TestBackend::new(80, 24), Size::Percent(100))
476
            .expect("failed to build test TUI")
477
    }
478
479
    #[test]
480
    fn new_with_full_height_is_fullscreen() {
481
        let tui = fullscreen_tui();
482
        assert!(tui.is_fullscreen);
483
        assert!(tui.enable_mouse);
484
    }
485
486
    #[test]
487
    fn stop_cancels_token() {
488
        let tui = fullscreen_tui();
489
        assert!(!tui.cancellation_token.is_cancelled());
490
        tui.stop();
491
        assert!(tui.cancellation_token.is_cancelled());
492
    }
493
494
    #[test]
495
    fn cancel_is_idempotent() {
496
        let tui = fullscreen_tui();
497
        tui.cancel();
498
        tui.cancel();
499
        assert!(tui.cancellation_token.is_cancelled());
500
    }
501
502
    #[test]
503
    fn deref_exposes_terminal_frame() {
504
        let mut tui = fullscreen_tui();
505
        // Deref/DerefMut should expose the underlying ratatui terminal.
506
        let area = tui.get_frame().area();
507
        assert_eq!(area.width, 80);
508
        assert_eq!(area.height, 24);
509
    }
510
}
\ No newline at end of file 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 8241a4b4..3342fc7d 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-08-22 15:34

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.93k, False: 96]
-
  Branch (67:31): [True: 199, False: 2]
-
68
3.13k
            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.23k
        let dynamic_lines = if self.header_lines.is_empty() {
  Branch (75:32): [True: 2.97k, False: 58]
-
  Branch (75:32): [True: 200, False: 1]
-
76
3.17k
            self.header_lines_count
77
59
        } else if let Some(
sep3
) = self.multiline.as_deref() {
  Branch (77:23): [True: 3, False: 55]
+

Coverage Report

Created: 2026-09-01 09:15

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.21k
    pub fn height(&self) -> u16 {
67
3.21k
        let static_lines = if self.header.is_empty() {
  Branch (67:31): [True: 2.92k, False: 96]
+
  Branch (67:31): [True: 197, False: 2]
+
68
3.11k
            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.21k
        let dynamic_lines = if self.header_lines.is_empty() {
  Branch (75:32): [True: 2.96k, False: 55]
+
  Branch (75:32): [True: 198, False: 1]
+
76
3.16k
            self.header_lines_count
77
56
        } else if let Some(
sep3
) = self.multiline.as_deref() {
  Branch (77:23): [True: 3, False: 52]
 
  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
56
            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: 63]
-
  Branch (91:12): [True: 30, False: 0]
-
92
2.61k
            self.header_lines.reverse();
93
2.61k
        
}63
94
2.68k
    }
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
548
fn apply_tabstop(text: &str, tabstop: usize) -> String {
104
548
    let mut result = String::new();
105
548
    let mut current_width = 0;
106
107
548
    for 
ch223
in text.chars() {
108
223
        if ch == '\t' {
  Branch (108:12): [True: 0, False: 176]
+
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.21k
        static_lines + dynamic_lines
86
3.21k
    }
87
88
    /// Sets the dynamic header lines from input (--header-lines)
89
2.66k
    pub fn set_header_lines(&mut self, items: Vec<Arc<dyn SkimItem>>) {
90
2.66k
        self.header_lines = items;
91
2.66k
        if self.reverse_lines {
  Branch (91:12): [True: 2.57k, False: 66]
+
  Branch (91:12): [True: 28, False: 0]
+
92
2.60k
            self.header_lines.reverse();
93
2.60k
        
}66
94
2.66k
    }
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
543
fn apply_tabstop(text: &str, tabstop: usize) -> String {
104
543
    let mut result = String::new();
105
543
    let mut current_width = 0;
106
107
543
    for 
ch228
in text.chars() {
108
228
        if ch == '\t' {
  Branch (108:12): [True: 0, False: 181]
 
  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
220
        } else {
113
220
            result.push(ch);
114
220
            current_width += char_display_width(ch);
115
220
        }
116
    }
117
118
548
    result
119
548
}
120
121
impl SkimWidget for Header {
122
544
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
123
544
        let tabstop = max(1, options.tabstop);
124
544
        let header = options.header.clone().unwrap_or_default();
125
126
        // Expand tabs once during initialization
127
544
        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
544
        let reverse_lines = options.layout == TuiLayout::Default;
132
133
544
        Self {
134
544
            header: expanded_header,
135
544
            header_lines: Vec::new(),
136
544
            header_lines_count: options
137
544
                .header_lines
138
544
                .try_into()
139
544
                .expect("header_lines count overflows u16"),
140
544
            multiline: options.multiline.as_ref().and_then(std::clone::Clone::clone),
141
544
            indent_size: (options.selector_icon.chars().count() + options.multi_select_icon.chars().count())
142
544
                .try_into()
143
544
                .expect("Failed to fit selector lens into an u16"),
144
544
            theme,
145
544
            border: options.border,
146
544
            reverse_lines,
147
544
            reverse: options.layout == TuiLayout::Reverse,
148
544
        }
149
544
    }
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]
+
109
3
            let tab_width = tabstop - (current_width % tabstop);
110
3
            result.push_str(&" ".repeat(tab_width));
111
3
            current_width += tab_width;
112
225
        } else {
113
225
            result.push(ch);
114
225
            current_width += char_display_width(ch);
115
225
        }
116
    }
117
118
543
    result
119
543
}
120
121
impl SkimWidget for Header {
122
539
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
123
539
        let tabstop = max(1, options.tabstop);
124
539
        let header = options.header.clone().unwrap_or_default();
125
126
        // Expand tabs once during initialization
127
539
        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
539
        let reverse_lines = options.layout == TuiLayout::Default;
132
133
539
        Self {
134
539
            header: expanded_header,
135
539
            header_lines: Vec::new(),
136
539
            header_lines_count: options
137
539
                .header_lines
138
539
                .try_into()
139
539
                .expect("header_lines count overflows u16"),
140
539
            multiline: options.multiline.as_ref().and_then(std::clone::Clone::clone),
141
539
            indent_size: (options.selector_icon.chars().count() + options.multi_select_icon.chars().count())
142
539
                .try_into()
143
539
                .expect("Failed to fit selector lens into an u16"),
144
539
            theme,
145
539
            border: options.border,
146
539
            reverse_lines,
147
539
            reverse: options.layout == TuiLayout::Reverse,
148
539
        }
149
539
    }
150
151
101
    fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender {
152
101
        let block = if let Some(
border_type18
) = self.border.into_ratatui() {
  Branch (152:28): [True: 18, False: 81]
 
  Branch (152:28): [True: 0, False: 2]
-
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]
+
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
83
            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
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
92
            self.header_lines.len()
173
        };
174
175
101
        let container_height = if self.border.is_some() {
  Branch (175:35): [True: 21, 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: 18, False: 81]
 
  Branch (175:35): [True: 0, False: 2]
-
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]
+
176
18
            area.height - 2
177
        } else {
178
83
            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()24
{
  Branch (182:38): [True: 24, False: 75]
+  Branch (182:54): [True: 18, False: 6]
 
  Branch (182:38): [True: 0, False: 2]
   Branch (182:54): [True: 0, False: 0]
-
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]
+
183
18
            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
101
        let display_context = DisplayContext {
193
101
            base_style: self.theme.header,
194
101
            ..Default::default()
195
101
        };
196
197
101
        for 
item91
in &self.header_lines {
198
91
            if let Some(
sep3
) = self.multiline.as_deref() {
  Branch (198:20): [True: 3, False: 88]
 
  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
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]
+
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
101
        if !self.reverse && 
!self.header.is_empty()77
{
  Branch (224:12): [True: 75, False: 24]
+  Branch (224:29): [True: 53, False: 22]
 
  Branch (224:12): [True: 2, False: 0]
   Branch (224:29): [True: 1, False: 1]
-
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 +
225
54
            combined_header += self.header_text();
226
54
        
}47
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 621a4da1..207a18a3 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,20 +1,20 @@ -

Coverage Report

Created: 2026-08-22 15:34

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: 21, False: 2.52k]
-
  Branch (54:12): [True: 1, False: 30]
-
55
22
            && let Some(start) = self.start
  Branch (55:20): [True: 21, False: 0]
+

Coverage Report

Created: 2026-09-01 09:15

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.56k
    pub fn left_title(&self) -> String {
51
2.56k
        let mut parts = String::new();
52
53
        // Spinner
54
2.56k
        if self.show_spinner
  Branch (54:12): [True: 6, False: 2.52k]
+
  Branch (54:12): [True: 1, False: 28]
+
55
7
            && let Some(start) = self.start
  Branch (55:20): [True: 6, False: 0]
 
  Branch (55:20): [True: 1, False: 0]
-
56
22
        {
57
22
            parts.push(spinner_char(start));
58
22
            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: 29]
-
68
5
            let _ = write!(parts, "/{}", self.matcher_mode);
69
2.56k
        }
70
71
        // Progress percentage
72
2.57k
        if self.show_spinner && 
self.total > 022
&&
self.processed != self.total22
{
  Branch (72:12): [True: 21, False: 2.52k]
-  Branch (72:33): [True: 21, False: 0]
-  Branch (72:51): [True: 21, False: 0]
-
  Branch (72:12): [True: 1, False: 30]
+
56
7
        {
57
7
            parts.push(spinner_char(start));
58
7
            parts.push(' ');
59
2.55k
        } else {
60
2.55k
            parts.push_str("  ");
61
2.55k
        }
62
63
        // Matched/total
64
2.56k
        let _ = write!(parts, "{}/{}", self.matched, self.total);
65
66
        // Matcher mode
67
2.56k
        if !self.matcher_mode.is_empty() {
  Branch (67:12): [True: 3, False: 2.52k]
+
  Branch (67:12): [True: 2, False: 27]
+
68
5
            let _ = write!(parts, "/{}", self.matcher_mode);
69
2.55k
        }
70
71
        // Progress percentage
72
2.56k
        if self.show_spinner && 
self.total > 07
&&
self.processed != self.total7
{
  Branch (72:12): [True: 6, False: 2.52k]
+  Branch (72:33): [True: 6, False: 0]
+  Branch (72:51): [True: 6, False: 0]
+
  Branch (72:12): [True: 1, False: 28]
   Branch (72:33): [True: 1, False: 0]
   Branch (72:51): [True: 1, False: 0]
-
73
22
            let pct = self.processed.saturating_mul(100) / self.total;
74
22
            let _ = write!(parts, " ({pct}%)");
75
2.55k
        }
76
77
        // Selection count
78
2.57k
        if self.multi_selection && 
self.selected > 0151
{
  Branch (78:12): [True: 150, False: 2.39k]
-  Branch (78:36): [True: 87, False: 63]
-
  Branch (78:12): [True: 1, False: 30]
+
73
7
            let pct = self.processed.saturating_mul(100) / self.total;
74
7
            let _ = write!(parts, " ({pct}%)");
75
2.55k
        }
76
77
        // Selection count
78
2.56k
        if self.multi_selection && 
self.selected > 0142
{
  Branch (78:12): [True: 141, False: 2.39k]
+  Branch (78:36): [True: 87, False: 54]
+
  Branch (78:12): [True: 1, False: 28]
   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
52
    pub fn inline_separator_or_spinner(&self) -> String {
88
52
        if self.show_spinner
  Branch (88:12): [True: 3, False: 47]
+
79
88
            let _ = write!(parts, " [{}]", self.selected);
80
2.47k
        }
81
82
2.56k
        parts
83
2.56k
    }
84
85
    /// Get the inline separator character: spinner when active, '<' otherwise
86
    /// Used for Inline info display mode
87
52
    pub fn inline_separator_or_spinner(&self) -> String {
88
52
        if self.show_spinner
  Branch (88:12): [True: 3, False: 47]
 
  Branch (88:12): [True: 1, False: 1]
 
89
4
            && let Some(start) = self.start
  Branch (89:20): [True: 3, False: 0]
 
  Branch (89:20): [True: 1, False: 0]
@@ -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
50
        }
124
125
51
        parts
126
51
    }
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
104
    fn default() -> Self {
156
104
        Self::_default()
157
104
    }
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]
+
122
1
            let _ = write!(parts, " [{}]", self.selected);
123
50
        }
124
125
51
        parts
126
51
    }
127
128
    /// Build the right-aligned title string (current index / hscroll)
129
2.60k
    pub fn right_title(&self) -> String {
130
2.60k
        format!("{}/{}", self.current_item_idx, self.hscroll_offset)
131
2.60k
    }
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
102
    fn default() -> Self {
156
102
        Self::_default()
157
102
    }
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]
@@ -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
633
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
404
633
        let mut res = Self {
405
633
            theme,
406
633
            border: options.border,
407
633
            info: options.info.clone(),
408
633
            reverse: options.layout == TuiLayout::Reverse,
409
633
            prompt: String::new(),
410
633
            alternate_prompt: String::new(),
411
633
            value: String::new(),
412
633
            alternate_value: String::new(),
413
633
            cursor_pos: 0,
414
633
            alternate_cursor_pos: 0,
415
633
            status_info: None,
416
633
        };
417
633
        if options.interactive {
  Branch (417:12): [True: 30, False: 429]
+
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.66k
    pub fn cursor_pos(&self) -> u16 {
391
2.66k
        (display_width(&self.value[..(self.cursor_pos as usize)]) + display_width(&strip_ansi(&self.prompt).0))
392
2.66k
            .try_into()
393
2.66k
            .expect("Failed to fit cursor char into an u16")
394
2.66k
    }
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
626
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
404
626
        let mut res = Self {
405
626
            theme,
406
626
            border: options.border,
407
626
            info: options.info.clone(),
408
626
            reverse: options.layout == TuiLayout::Reverse,
409
626
            prompt: String::new(),
410
626
            alternate_prompt: String::new(),
411
626
            value: String::new(),
412
626
            alternate_value: String::new(),
413
626
            cursor_pos: 0,
414
626
            alternate_cursor_pos: 0,
415
626
            status_info: None,
416
626
        };
417
626
        if options.interactive {
  Branch (417:12): [True: 30, False: 422]
 
  Branch (417:12): [True: 1, False: 173]
-
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
602
        } else {
423
602
            res.prompt.clone_from(&options.prompt);
424
602
            res.alternate_prompt.clone_from(&options.cmd_prompt);
425
602
            res.value = options.query.clone().unwrap_or_default();
426
602
            res.alternate_value = options.cmd_query.clone().unwrap_or_default();
427
602
        }
428
633
        res.cursor_pos = u16::try_from(res.value.len()).unwrap_or(u16::MAX);
429
633
        res.alternate_cursor_pos = u16::try_from(res.alternate_value.len()).unwrap_or(u16::MAX);
430
633
        res
431
633
    }
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_type66
) = self.border.into_ratatui() {
  Branch (448:16): [True: 66, False: 2.58k]
-
  Branch (448:16): [True: 0, False: 30]
-
449
66
            block = block
450
66
                .borders(Borders::ALL)
451
66
                .border_type(border_type)
452
66
                .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
50
                if let Some(ref status) = self.status_info {
  Branch (461:24): [True: 50, False: 0]
+
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
595
        } else {
423
595
            res.prompt.clone_from(&options.prompt);
424
595
            res.alternate_prompt.clone_from(&options.cmd_prompt);
425
595
            res.value = options.query.clone().unwrap_or_default();
426
595
            res.alternate_value = options.cmd_query.clone().unwrap_or_default();
427
595
        }
428
626
        res.cursor_pos = u16::try_from(res.value.len()).unwrap_or(u16::MAX);
429
626
        res.alternate_cursor_pos = u16::try_from(res.alternate_value.len()).unwrap_or(u16::MAX);
430
626
        res
431
626
    }
432
433
2.66k
    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.66k
        let mut line = self.prompt.into_text().map_or_else(
439
0
            |_| Line::from(self.prompt.as_str()),
440
2.66k
            |t| t.lines.into_iter().next().unwrap_or_default(),
441
        );
442
2.66k
        style_line(&mut line, self.theme.prompt);
443
2.66k
        line.push_span(Span::styled(&self.value, self.theme.query));
444
445
2.66k
        let mut block = Block::default();
446
447
        // Add borders if enabled
448
2.66k
        if let Some(
border_type63
) = self.border.into_ratatui() {
  Branch (448:16): [True: 63, False: 2.57k]
+
  Branch (448:16): [True: 0, False: 28]
+
449
63
            block = block
450
63
                .borders(Borders::ALL)
451
63
                .border_type(border_type)
452
63
                .border_style(self.theme.border);
453
2.60k
        }
454
455
        // Handle different info display modes
456
2.66k
        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
50
                if let Some(ref status) = self.status_info {
  Branch (461:24): [True: 50, False: 0]
 
  Branch (461:24): [True: 0, False: 0]
 
462
50
                    let separator = status.inline_separator_or_spinner();
463
50
                    let inline_status = status.inline_status();
464
50
                    let right_status = status.right_title();
465
466
                    // Calculate available width for padding
467
                    // Format: " X " where X is separator (3 chars total)
468
50
                    let prompt_width = display_width(&self.prompt);
469
50
                    let value_width = display_width(&self.value);
470
50
                    let separator_width = display_width(&separator); // "  X " (2xspace + separator + space)
471
50
                    let inline_status_width = display_width(&inline_status);
472
50
                    let right_status_width = display_width(&right_status);
473
474
50
                    let used_width =
475
50
                        prompt_width + value_width + separator_width + inline_status_width + right_status_width;
476
50
                    let available_width = u64::from(area.width);
477
50
                    let padding_width = available_width.saturating_sub(used_width);
478
479
50
                    if self.info.display == InfoDisplay::InlineRight {
  Branch (479:24): [True: 14, False: 36]
 
  Branch (479:24): [True: 0, False: 0]
 
480
14
                        line.push_span(Span::raw(" ".repeat(usize::try_from(padding_width).unwrap() - 2)));
481
36
                    }
482
50
                    line.push_span(Span::styled(separator, self.theme.info));
483
50
                    line.push_span(Span::styled(inline_status, self.theme.info));
484
50
                    if self.info.display == InfoDisplay::Inline {
  Branch (484:24): [True: 36, False: 14]
 
  Branch (484:24): [True: 0, False: 0]
-
485
36
                        line.push_span(Span::raw(" ".repeat(padding_width.try_into().unwrap())));
486
36
                    } else {
487
14
                        line.push_span(Span::raw(" ".repeat(2)));
488
14
                    }
489
50
                    line.push_span(Span::styled(right_status, self.theme.info));
490
491
50
                    Paragraph::new(line)
492
50
                        .block(block)
493
50
                        .style(self.theme.normal)
494
50
                        .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]
-
  Branch (509:24): [True: 29, 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.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]
+
485
36
                        line.push_span(Span::raw(" ".repeat(padding_width.try_into().unwrap())));
486
36
                    } else {
487
14
                        line.push_span(Span::raw(" ".repeat(2)));
488
14
                    }
489
50
                    line.push_span(Span::styled(right_status, self.theme.info));
490
491
50
                    Paragraph::new(line)
492
50
                        .block(block)
493
50
                        .style(self.theme.normal)
494
50
                        .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.55k
                if let Some(
ref status2.55k
) = self.status_info {
  Branch (509:24): [True: 2.53k, False: 0]
+
  Branch (509:24): [True: 27, False: 1]
+
510
2.55k
                    let left_title = status.left_title();
511
2.55k
                    let right_title = status.right_title();
512
513
2.55k
                    if 
matches!2.54k
(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.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.49k]
-
  Branch (532:36): [True: 0, False: 29]
-
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.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
891
    fn deref(&self) -> &Self::Target {
562
891
        &self.value
563
891
    }
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 +
523
0
                            block.title_bottom(title)
524
                        } else {
525
14
                            block.title_top(title)
526
                        };
527
                    } else {
528
2.54k
                        let info_line = Line::from(left_title).style(self.theme.info).alignment(Alignment::Left);
529
2.54k
                        let index_line = Line::from(right_title)
530
2.54k
                            .style(self.theme.info)
531
2.54k
                            .alignment(Alignment::Right);
532
2.54k
                        block = if self.reverse {
  Branch (532:36): [True: 38, False: 2.47k]
+
  Branch (532:36): [True: 0, False: 27]
+
533
38
                            block.title_bottom(info_line).title_bottom(index_line)
534
                        } else {
535
2.50k
                            block.title_top(info_line).title_top(index_line)
536
                        };
537
                    }
538
1
                }
539
540
2.55k
                Paragraph::new(line)
541
2.55k
                    .block(block)
542
2.55k
                    .style(self.theme.normal)
543
2.55k
                    .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.66k
        SkimRender::default()
555
2.66k
    }
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 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 e145bf57..51af3b16 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-08-22 15:34

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.58k]
-
  Branch (110:12): [True: 1, False: 29]
-
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.73k
) {
  Branch (117:12): [True: 6.58k, False: 2.77k]
-
  Branch (117:12): [True: 152, False: 34]
-
118
6.73k
            item.cloned()
119
        } else {
120
2.80k
            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]
+

Coverage Report

Created: 2026-09-01 09:15

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.51k
    fn cursor(&self) -> usize {
102
9.51k
        self.current
103
9.51k
    }
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.60k
    pub fn count(&self) -> usize {
110
2.60k
        if self.showing_stale_items { 
05
} else {
self.items2.60k
.
len2.60k
() }
  Branch (110:12): [True: 4, False: 2.57k]
+
  Branch (110:12): [True: 1, False: 27]
+
111
2.60k
    }
112
113
    /// Returns the currently selected item, if any
114
    #[must_use]
115
9.48k
    pub fn selected(&self) -> Option<MatchedItem> {
116
9.48k
        let item = self.items.get(self.cursor());
117
9.48k
        if item.is_some_and(|i| !
i.item.disabled()6.73k
) {
  Branch (117:12): [True: 6.58k, False: 2.72k]
+
  Branch (117:12): [True: 147, False: 31]
+
118
6.73k
            item.cloned()
119
        } else {
120
2.75k
            None
121
        }
122
9.48k
    }
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]
@@ -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(
sep692
) = self.multiline.as_deref()
  Branch (323:16): [True: 692, False: 3.05k]
-
  Branch (323:16): [True: 0, False: 343]
-
324
692
            && let Some(
item648
) = self.items.get(index)
  Branch (324:20): [True: 648, False: 44]
+
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.07k
    fn item_row_count(&self, index: usize) -> usize {
323
4.07k
        if let Some(
sep686
) = self.multiline.as_deref()
  Branch (323:16): [True: 686, False: 3.05k]
+
  Branch (323:16): [True: 0, False: 341]
+
324
686
            && let Some(
item648
) = self.items.get(index)
  Branch (324:20): [True: 648, False: 38]
 
  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
203
    fn rows_for_range(&self, from: usize, count: usize) -> usize {
334
1.26k
        (
from203
..from + count).
map203
(|i| self.item_row_count(i)).
sum203
()
335
203
    }
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]
-
  Branch (340:12): [True: 0, False: 52]
-
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: 186]
-
  Branch (344:12): [True: 35, False: 17]
-
345
2.52k
            return top_rows;
346
203
        }
347
203
        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]
+
325
        {
326
648
            item.item.text().split(sep).count().max(1)
327
        } else {
328
3.42k
            1
329
        }
330
4.07k
    }
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.71k
    fn rows_visible(&self, offset: usize, sub_offset: usize, current: usize) -> usize {
340
2.71k
        if offset > current {
  Branch (340:12): [True: 0, False: 2.66k]
+
  Branch (340:12): [True: 0, False: 50]
+
341
0
            return 0;
342
2.71k
        }
343
2.71k
        let top_rows = self.item_row_count(offset).saturating_sub(sub_offset);
344
2.71k
        if offset == current {
  Branch (344:12): [True: 2.48k, False: 185]
+
  Branch (344:12): [True: 33, False: 17]
+
345
2.51k
            return top_rows;
346
202
        }
347
202
        top_rows + self.rows_for_range(offset + 1, current - offset)
348
2.71k
    }
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
553
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
372
        use crate::helper::selector::DefaultSkimSelector;
373
        use crate::util::read_file_lines;
374
375
553
        let skip_to_pattern = options
376
553
            .skip_to_pattern
377
553
            .as_ref()
378
553
            .and_then(|pattern| 
Regex::new1
(
pattern1
).
ok1
());
379
380
        // Build the selector from options and calculate pre-select target
381
553
        let (selector, pre_select_target) = if options.pre_select_n > 0
  Branch (381:48): [True: 13, False: 362]
+
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
548
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
372
        use crate::helper::selector::DefaultSkimSelector;
373
        use crate::util::read_file_lines;
374
375
548
        let skip_to_pattern = options
376
548
            .skip_to_pattern
377
548
            .as_ref()
378
548
            .and_then(|pattern| 
Regex::new1
(
pattern1
).
ok1
());
379
380
        // Build the selector from options and calculate pre-select target
381
548
        let (selector, pre_select_target) = if options.pre_select_n > 0
  Branch (381:48): [True: 11, False: 359]
 
  Branch (381:48): [True: 0, False: 178]
-
382
540
            || !options.pre_select_pat.is_empty()
  Branch (382:16): [True: 2, False: 360]
+
382
537
            || !options.pre_select_pat.is_empty()
  Branch (382:16): [True: 2, False: 357]
 
  Branch (382:16): [True: 0, False: 178]
-
383
538
            || !options.pre_select_items.is_empty()
  Branch (383:16): [True: 1, False: 359]
+
383
535
            || !options.pre_select_items.is_empty()
  Branch (383:16): [True: 1, False: 356]
 
  Branch (383:16): [True: 0, False: 178]
-
384
537
            || options.pre_select_file.is_some()
  Branch (384:16): [True: 1, False: 358]
+
384
534
            || options.pre_select_file.is_some()
  Branch (384:16): [True: 1, False: 355]
 
  Branch (384:16): [True: 0, False: 178]
-
385
536
            || options.selector.is_some()
  Branch (385:16): [True: 0, False: 358]
+
385
533
            || options.selector.is_some()
  Branch (385:16): [True: 0, False: 355]
 
  Branch (385:16): [True: 0, False: 178]
-
386
        {
387
17
            if let Some(
s0
) = options.selector.clone() {
  Branch (387:20): [True: 0, False: 17]
+
386
        {
387
15
            if let Some(
s0
) = options.selector.clone() {
  Branch (387:20): [True: 0, False: 15]
 
  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
17
                let mut preset_items: Vec<String> = options
392
17
                    .pre_select_items
393
17
                    .split('\n')
394
17
                    .filter(|s| !s.is_empty())
395
17
                    .map(std::string::ToString::to_string)
396
17
                    .collect();
397
398
17
                if let Some(
ref pre_select_file1
) = options.pre_select_file
  Branch (398:24): [True: 1, False: 16]
+
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]
 
  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
16
                }
403
404
17
                let selector = DefaultSkimSelector::default()
405
17
                    .first_n(options.pre_select_n)
406
17
                    .regex(&options.pre_select_pat)
407
17
                    .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
17
                let target = if options.pre_select_n > 0 {
  Branch (411:33): [True: 13, False: 4]
+
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]
 
  Branch (411:33): [True: 0, False: 0]
-
412
13
                    options.pre_select_n
413
                } else {
414
4
                    usize::MAX // No target - keep selecting matching items
415
                };
416
417
17
                (Some(Rc::new(selector) as Rc<dyn Selector>), target)
418
            }
419
        } else {
420
536
            (None, 0)
421
        };
422
423
553
        let processed_items = Arc::new(SpinLock::new(None));
424
425
553
        let interactive = options.interactive;
426
553
        let no_clear_if_empty = options.no_clear_if_empty;
427
553
        let multi_select = options.multi;
428
429
        // Spawn background processing thread with the appropriate configuration
430
        Self {
431
553
            processed_items,
432
            reserved: 0, // header_lines are now displayed in the Header widget, not ItemList
433
553
            direction: match options.layout {
434
534
                TuiLayout::Default => ratatui::widgets::ListDirection::BottomToTop,
435
19
                TuiLayout::Reverse | TuiLayout::ReverseList => ratatui::widgets::ListDirection::TopToBottom,
436
            },
437
            current: 0,
438
553
            theme,
439
553
            multi_select,
440
553
            no_hscroll: options.no_hscroll,
441
553
            ellipsis: options.ellipsis.clone(),
442
553
            keep_right: options.keep_right,
443
553
            skip_to_pattern,
444
553
            tabstop: options.tabstop.max(1),
445
553
            selector,
446
553
            pre_select_target,
447
553
            no_clear_if_empty,
448
553
            interactive,
449
            showing_stale_items: false,
450
            manual_hscroll: 0,
451
553
            items: Default::default(),
452
553
            selection: Default::default(),
453
553
            offset: Default::default(),
454
            sub_offset: 0,
455
553
            height: Default::default(),
456
553
            selector_icon: options.selector_icon.clone(),
457
553
            multi_select_icon: options.multi_select_icon.clone(),
458
553
            cycle: options.cycle,
459
553
            wrap: options.wrap_items,
460
553
            multiline: options
461
553
                .multiline
462
553
                .clone()
463
553
                .map(|opt_m| 
opt_m38
.
unwrap_or38
(
String::from38
("\\n"))),
464
553
            border: options.border,
465
553
            show_score: feature_flag!(options, ShowScore),
466
553
            show_index: feature_flag!(options, ShowIndex),
467
553
            highlight_line: options.highlight_line,
468
553
            scrollbar_thumb: options.scrollbar.clone(),
469
        }
470
553
    }
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: 36]
-
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.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: 889, False: 1.76k]
-
  Branch (494:12): [True: 4, False: 32]
-
495
893
            this.current = 0;
496
893
            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.65k]
-
  Branch (501:12): [True: 0, False: 36]
-
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: 35]
-
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: 733, False: 1.91k]
-
  Branch (515:36): [True: 6, 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()572
.
all572
(|item|
item.item.text().trim()572
.
is_empty572
());
  Branch (520:17): [True: 167, False: 566]
-
  Branch (520:17): [True: 0, False: 6]
-
521
522
739
            if this.interactive && 
this.no_clear_if_empty84
&&
items_are_empty_or_blank2
&&
!this.items.is_empty()1
{
  Branch (522:16): [True: 84, False: 649]
+
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
533
            (None, 0)
421
        };
422
423
548
        let processed_items = Arc::new(SpinLock::new(None));
424
425
548
        let interactive = options.interactive;
426
548
        let no_clear_if_empty = options.no_clear_if_empty;
427
548
        let multi_select = options.multi;
428
429
        // Spawn background processing thread with the appropriate configuration
430
        Self {
431
548
            processed_items,
432
            reserved: 0, // header_lines are now displayed in the Header widget, not ItemList
433
548
            direction: match options.layout {
434
528
                TuiLayout::Default => ratatui::widgets::ListDirection::BottomToTop,
435
20
                TuiLayout::Reverse | TuiLayout::ReverseList => ratatui::widgets::ListDirection::TopToBottom,
436
            },
437
            current: 0,
438
548
            theme,
439
548
            multi_select,
440
548
            no_hscroll: options.no_hscroll,
441
548
            ellipsis: options.ellipsis.clone(),
442
548
            keep_right: options.keep_right,
443
548
            skip_to_pattern,
444
548
            tabstop: options.tabstop.max(1),
445
548
            selector,
446
548
            pre_select_target,
447
548
            no_clear_if_empty,
448
548
            interactive,
449
            showing_stale_items: false,
450
            manual_hscroll: 0,
451
548
            items: Default::default(),
452
548
            selection: Default::default(),
453
548
            offset: Default::default(),
454
            sub_offset: 0,
455
548
            height: Default::default(),
456
548
            selector_icon: options.selector_icon.clone(),
457
548
            multi_select_icon: options.multi_select_icon.clone(),
458
548
            cycle: options.cycle,
459
548
            wrap: options.wrap_items,
460
548
            multiline: options
461
548
                .multiline
462
548
                .clone()
463
548
                .map(|opt_m| 
opt_m36
.
unwrap_or36
(
String::from36
("\\n"))),
464
548
            border: options.border,
465
548
            show_score: feature_flag!(options, ShowScore),
466
548
            show_index: feature_flag!(options, ShowIndex),
467
548
            highlight_line: options.highlight_line,
468
548
            scrollbar_thumb: options.scrollbar.clone(),
469
        }
470
548
    }
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.67k
    fn render(&mut self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) -> SkimRender {
476
2.67k
        let this = &mut *self;
477
478
        // Calculate inner area if borders are enabled
479
2.67k
        let inner_area = if this.border.is_some() {
  Branch (479:29): [True: 63, False: 2.57k]
+
  Branch (479:29): [True: 0, False: 34]
+
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.61k
            area
488
        };
489
490
2.67k
        this.height = inner_area.height;
491
2.67k
        let available_rows = inner_area.height as usize;
492
493
        // Clamp current to valid range after any item list replacement.
494
2.67k
        if this.items.is_empty() {
  Branch (494:12): [True: 877, False: 1.76k]
+
  Branch (494:12): [True: 2, False: 32]
+
495
879
            this.current = 0;
496
879
            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.67k
        if this.current < this.offset {
  Branch (501:12): [True: 0, False: 2.64k]
+
  Branch (501:12): [True: 0, False: 34]
+
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.67k
        } else if this.rows_visible(this.offset, this.sub_offset, this.current) > available_rows {
  Branch (505:19): [True: 6, False: 2.63k]
+
  Branch (505:19): [True: 1, False: 33]
+
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.66k
        }
509
2.67k
        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.67k
        let processed = this.processed_items.lock().take();
515
2.67k
        let items_updated = if let Some(
processed734
) = processed {
  Branch (515:36): [True: 729, False: 1.91k]
+
  Branch (515:36): [True: 5, False: 29]
+
516
734
            debug!("Render: Got {} processed items", 
processed.items0
.
len0
());
517
518
            // Check if items are empty or blank for no_clear_if_empty handling
519
734
            let items_are_empty_or_blank =
520
734
                processed.items.is_empty() || 
processed.items.iter()571
.
all571
(|item|
item.item.text().trim()571
.
is_empty571
());
  Branch (520:17): [True: 163, False: 566]
+
  Branch (520:17): [True: 0, False: 5]
+
521
522
734
            if this.interactive && 
this.no_clear_if_empty84
&&
items_are_empty_or_blank2
&&
!this.items.is_empty()1
{
  Branch (522:16): [True: 84, False: 645]
   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: 6]
+
  Branch (522:16): [True: 0, False: 5]
   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
726
                    MergeStrategy::Replace => {
531
726
                        this.items = processed.items;
532
726
                        this.sub_offset = 0;
533
726
                    }
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
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: 30, False: 702]
-
  Branch (550:20): [True: 0, False: 6]
-
551
30
                    && let Some(
selector17
) = &this.selector
  Branch (551:28): [True: 17, False: 13]
+
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
733
                match processed.merge {
530
720
                    MergeStrategy::Replace => {
531
720
                        this.items = processed.items;
532
720
                        this.sub_offset = 0;
533
720
                    }
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
733
                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
733
                if this.multi_select
  Branch (550:20): [True: 27, False: 701]
+
  Branch (550:20): [True: 0, False: 5]
+
551
27
                    && let Some(
selector15
) = &this.selector
  Branch (551:28): [True: 15, False: 12]
 
  Branch (551:28): [True: 0, False: 0]
-
552
17
                    && this.selection.len() < this.pre_select_target
  Branch (552:24): [True: 17, False: 0]
+
552
15
                    && this.selection.len() < this.pre_select_target
  Branch (552:24): [True: 15, False: 0]
 
  Branch (552:24): [True: 0, False: 0]
-
553
                {
554
17
                    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()17
.
enumerate17
() {
561
45
                        if this.selection.len() >= this.pre_select_target {
  Branch (561:28): [True: 11, False: 34]
+
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]
 
  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
17
                    debug!("Pre-selected {} items total", 
this.selection0
.
len0
());
571
721
                }
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.47k
        for (idx, item) in 
this.items.iter()2.68k
.
enumerate2.68k
().
skip2.68k
(
this.offset2.68k
) {
589
6.47k
            if rows_used >= available_rows {
  Branch (589:16): [True: 70, False: 6.28k]
-
  Branch (589:16): [True: 15, False: 106]
-
590
85
                break;
591
6.39k
            }
592
6.39k
            let is_current = idx == this.current;
593
6.39k
            let is_selected = this.selection.contains(item);
594
6.39k
            let skip_subs = if idx == this.offset { 
sub_offset2.08k
} else {
04.30k
};
  Branch (594:32): [True: 2.05k, False: 4.23k]
-
  Branch (594:32): [True: 33, False: 73]
-
595
6.39k
            rows_used += renderer.render_item(
596
6.39k
                item,
597
6.39k
                is_current,
598
6.39k
                is_selected,
599
6.39k
                skip_subs,
600
6.39k
                available_rows,
601
6.39k
                rows_used,
602
6.39k
                &mut flat_rows,
603
6.39k
            );
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: 36]
-
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.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: 6]
-  Branch (626:48): [True: 46, False: 2.59k]
-
  Branch (626:12): [True: 5, False: 31]
+
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
718
                }
572
            }
573
574
734
            true
575
        } else {
576
1.94k
            false
577
        };
578
579
2.67k
        let icon_width = this.selector_icon.chars().count() + this.multi_select_icon.chars().count();
580
2.67k
        let container_width = (inner_area.width as usize).saturating_sub(icon_width);
581
2.67k
        let sub_offset = this.sub_offset;
582
583
2.67k
        let renderer = ItemRenderer::new_for(this, container_width);
584
585
2.67k
        let mut flat_rows: Vec<ListItem<'static>> = Vec::with_capacity(available_rows + 1);
586
2.67k
        let mut rows_used = 0usize;
587
588
6.48k
        for (idx, item) in 
this.items.iter()2.67k
.
enumerate2.67k
().
skip2.67k
(
this.offset2.67k
) {
589
6.48k
            if rows_used >= available_rows {
  Branch (589:16): [True: 70, False: 6.29k]
+
  Branch (589:16): [True: 15, False: 105]
+
590
85
                break;
591
6.39k
            }
592
6.39k
            let is_current = idx == this.current;
593
6.39k
            let is_selected = this.selection.contains(item);
594
6.39k
            let skip_subs = if idx == this.offset { 
sub_offset2.08k
} else {
04.31k
};
  Branch (594:32): [True: 2.05k, False: 4.24k]
+
  Branch (594:32): [True: 32, False: 73]
+
595
6.39k
            rows_used += renderer.render_item(
596
6.39k
                item,
597
6.39k
                is_current,
598
6.39k
                is_selected,
599
6.39k
                skip_subs,
600
6.39k
                available_rows,
601
6.39k
                rows_used,
602
6.39k
                &mut flat_rows,
603
6.39k
            );
604
        }
605
606
2.67k
        let list = List::new(flat_rows).direction(this.direction).style(this.theme.normal);
607
608
2.67k
        Widget::render(Clear, area, buf);
609
610
        // Render border if enabled
611
2.67k
        if let Some(
border_type63
) = this.border.into_ratatui() {
  Branch (611:16): [True: 63, False: 2.57k]
+
  Branch (611:16): [True: 0, False: 34]
+
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.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.67k
        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.67k
        if !this.scrollbar_thumb.is_empty() && 
this.items2.63k
.len() > available_rows {
  Branch (626:12): [True: 2.63k, False: 6]
+  Branch (626:48): [True: 46, False: 2.58k]
+
  Branch (626:12): [True: 5, False: 29]
   Branch (626:48): [True: 5, False: 0]
-
627
51
            // Use offset directly as the scroll position for all layout directions:
628
51
            // offset == 0           → thumb at top    (beginning of content)
629
51
            // offset == max_offset  → thumb at bottom (end of content)
630
51
            // This matches conventional scrollbar behaviour regardless of list direction.
631
51
            let mut scrollbar_state = ScrollbarState::new(this.items.len()).position(this.current);
632
51
            // .viewport_content_length(available_rows);
633
51
634
51
            // Both Default and Custom use a thumb-only style (no track/begin/end arrows).
635
51
            // Default uses ▐ (right half-block), which gives a clean minimal look.
636
51
            // Without an explicit thumb_style the thumb inherits whatever fg/bg the
637
51
            // row painted underneath it, so it picks up the current-line highlight as
638
51
            // the cursor scrolls past; the themed `scrollbar` color keeps it uniform.
639
51
            let scrollbar: Scrollbar<'_> = Scrollbar::new(ScrollbarOrientation::VerticalRight)
640
51
                .thumb_symbol(&self.scrollbar_thumb)
641
51
                .thumb_style(self.theme.scrollbar)
642
51
                .track_symbol(None)
643
51
                .begin_symbol(None)
644
51
                .end_symbol(None);
645
51
            StatefulWidget::render(scrollbar, inner_area, buf, &mut scrollbar_state);
646
2.63k
        }
647
648
2.68k
        let run_preview = if let Some(
curr2.08k
) = self.selected()
  Branch (648:34): [True: 2.05k, False: 601]
-
  Branch (648:34): [True: 33, False: 3]
+
627
51
            // Use offset directly as the scroll position for all layout directions:
628
51
            // offset == 0           → thumb at top    (beginning of content)
629
51
            // offset == max_offset  → thumb at bottom (end of content)
630
51
            // This matches conventional scrollbar behaviour regardless of list direction.
631
51
            let mut scrollbar_state = ScrollbarState::new(this.items.len()).position(this.current);
632
51
            // .viewport_content_length(available_rows);
633
51
634
51
            // Both Default and Custom use a thumb-only style (no track/begin/end arrows).
635
51
            // Default uses ▐ (right half-block), which gives a clean minimal look.
636
51
            // Without an explicit thumb_style the thumb inherits whatever fg/bg the
637
51
            // row painted underneath it, so it picks up the current-line highlight as
638
51
            // the cursor scrolls past; the themed `scrollbar` color keeps it uniform.
639
51
            let scrollbar: Scrollbar<'_> = Scrollbar::new(ScrollbarOrientation::VerticalRight)
640
51
                .thumb_symbol(&self.scrollbar_thumb)
641
51
                .thumb_style(self.theme.scrollbar)
642
51
                .track_symbol(None)
643
51
                .begin_symbol(None)
644
51
                .end_symbol(None);
645
51
            StatefulWidget::render(scrollbar, inner_area, buf, &mut scrollbar_state);
646
2.62k
        }
647
648
2.67k
        let run_preview = if let Some(
curr2.08k
) = self.selected()
  Branch (648:34): [True: 2.05k, False: 589]
+
  Branch (648:34): [True: 32, False: 2]
 
649
2.08k
            && let Some(
prev1.76k
) = initial_current
  Branch (649:20): [True: 1.73k, False: 314]
-
  Branch (649:20): [True: 32, False: 1]
-
650
        {
651
1.76k
            curr.text() != prev.text()
652
        } else {
653
919
            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 (649:20): [True: 32, False: 0]
+
650
        {
651
1.76k
            curr.text() != prev.text()
652
        } else {
653
905
            self.selected().is_some() != initial_current.is_some()
654
        };
655
2.67k
        SkimRender {
656
2.67k
            items_updated,
657
2.67k
            run_preview,
658
2.67k
        }
659
2.67k
    }
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 477f4d52..014c69ce 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-08-22 15:34

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.39k
    pub fn render_item(
85
6.39k
        &self,
86
6.39k
        item: &MatchedItem,
87
6.39k
        is_current: bool,
88
6.39k
        is_selected: bool,
89
6.39k
        skip_subs: usize,
90
6.39k
        available_rows: usize,
91
6.39k
        rows_used: usize,
92
6.39k
        out: &mut Vec<ListItem<'static>>,
93
6.39k
    ) -> usize {
94
6.39k
        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.39k
        let (display_text, match_start_char, match_end_char): (std::borrow::Cow<'_, str>, usize, usize) =
101
6.39k
            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-01 09:15

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.67k
    pub fn new_for(list: &'a ItemList, container_width: usize) -> Self {
55
2.67k
        Self {
56
2.67k
            theme: &list.theme,
57
2.67k
            selector_icon: &list.selector_icon,
58
2.67k
            multi_select_icon: &list.multi_select_icon,
59
2.67k
            ellipsis: &list.ellipsis,
60
2.67k
            container_width,
61
2.67k
            wrap: list.wrap,
62
2.67k
            multiline: list.multiline.as_deref(),
63
2.67k
            show_score: list.show_score,
64
2.67k
            show_index: list.show_index,
65
2.67k
            multi_select: list.multi_select,
66
2.67k
            tabstop: list.tabstop,
67
2.67k
            no_hscroll: list.no_hscroll,
68
2.67k
            keep_right: list.keep_right,
69
2.67k
            manual_hscroll: list.manual_hscroll,
70
2.67k
            skip_to_pattern: list.skip_to_pattern.as_ref(),
71
2.67k
            reverse_sub_lines: list.direction == ListDirection::BottomToTop,
72
2.67k
            highlight_line: list.highlight_line,
73
2.67k
        }
74
2.67k
    }
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.39k
    pub fn render_item(
85
6.39k
        &self,
86
6.39k
        item: &MatchedItem,
87
6.39k
        is_current: bool,
88
6.39k
        is_selected: bool,
89
6.39k
        skip_subs: usize,
90
6.39k
        available_rows: usize,
91
6.39k
        rows_used: usize,
92
6.39k
        out: &mut Vec<ListItem<'static>>,
93
6.39k
    ) -> usize {
94
6.39k
        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.39k
        let (display_text, match_start_char, match_end_char): (std::borrow::Cow<'_, str>, usize, usize) =
101
6.39k
            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.34k
                    let (start, end) = Self::matched_range(item_text.as_ref(), item.matched_range.as_ref());
114
6.34k
                    (item_text, start, end)
115
                }
116
            };
117
6.39k
        let sub_lines = self.split_sub_lines(display_text.as_ref());
118
119
6.39k
        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.39k
        let mut item_rows: Vec<ListItem<'static>> = Vec::new();
123
124
6.91k
        for (sub_idx, sub_text) in 
sub_lines.iter()6.39k
.
enumerate6.39k
().
skip6.39k
(
skip_subs6.39k
) {
125
6.91k
            if rows_used + added >= available_rows {
  Branch (125:16): [True: 0, False: 6.81k]
-
  Branch (125:16): [True: 0, False: 108]
-
126
0
                break;
127
6.91k
            }
128
129
6.91k
            let is_first = sub_idx == skip_subs;
130
6.91k
            let is_top_cutoff = is_first && 
skip_subs > 06.39k
;
  Branch (130:33): [True: 6.28k, False: 524]
-
  Branch (130:33): [True: 107, False: 1]
-
131
6.91k
            let is_bottom_cutoff = rows_used + added + 1 >= available_rows && 
sub_idx + 1100
< sub_lines.len();
  Branch (131:36): [True: 84, False: 6.72k]
-
  Branch (131:36): [True: 16, False: 92]
-
132
6.91k
            let list_item = self.render_sub_line(
133
6.91k
                item,
134
6.91k
                sub_text,
135
                &SubLineState {
136
6.91k
                    is_current,
137
6.91k
                    is_selected,
138
6.91k
                    is_first,
139
6.91k
                    is_first_sub_line: sub_idx == 0,
140
6.91k
                    needs_ellipsis: is_top_cutoff || 
is_bottom_cutoff6.91k
,
  Branch (140:37): [True: 0, False: 6.81k]
-
  Branch (140:37): [True: 1, False: 107]
-
141
                },
142
6.91k
                match_start_char,
143
6.91k
                match_end_char,
144
            );
145
146
6.91k
            item_rows.push(list_item);
147
6.91k
            added += 1;
148
        }
149
150
6.39k
        if self.reverse_sub_lines {
  Branch (150:12): [True: 6.02k, False: 258]
-
  Branch (150:12): [True: 106, False: 1]
+
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.35k
                    let (start, end) = Self::matched_range(item_text.as_ref(), item.matched_range.as_ref());
114
6.35k
                    (item_text, start, end)
115
                }
116
            };
117
6.39k
        let sub_lines = self.split_sub_lines(display_text.as_ref());
118
119
6.39k
        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.39k
        let mut item_rows: Vec<ListItem<'static>> = Vec::new();
123
124
6.92k
        for (sub_idx, sub_text) in 
sub_lines.iter()6.39k
.
enumerate6.39k
().
skip6.39k
(
skip_subs6.39k
) {
125
6.92k
            if rows_used + added >= available_rows {
  Branch (125:16): [True: 0, False: 6.81k]
+
  Branch (125:16): [True: 0, False: 107]
+
126
0
                break;
127
6.92k
            }
128
129
6.92k
            let is_first = sub_idx == skip_subs;
130
6.92k
            let is_top_cutoff = is_first && 
skip_subs > 06.39k
;
  Branch (130:33): [True: 6.29k, False: 524]
+
  Branch (130:33): [True: 106, False: 1]
+
131
6.92k
            let is_bottom_cutoff = rows_used + added + 1 >= available_rows && 
sub_idx + 1100
< sub_lines.len();
  Branch (131:36): [True: 84, False: 6.73k]
+
  Branch (131:36): [True: 16, False: 91]
+
132
6.92k
            let list_item = self.render_sub_line(
133
6.92k
                item,
134
6.92k
                sub_text,
135
                &SubLineState {
136
6.92k
                    is_current,
137
6.92k
                    is_selected,
138
6.92k
                    is_first,
139
6.92k
                    is_first_sub_line: sub_idx == 0,
140
6.92k
                    needs_ellipsis: is_top_cutoff || 
is_bottom_cutoff6.92k
,
  Branch (140:37): [True: 0, False: 6.81k]
+
  Branch (140:37): [True: 1, False: 106]
+
141
                },
142
6.92k
                match_start_char,
143
6.92k
                match_end_char,
144
            );
145
146
6.92k
            item_rows.push(list_item);
147
6.92k
            added += 1;
148
        }
149
150
6.39k
        if self.reverse_sub_lines {
  Branch (150:12): [True: 6.03k, False: 258]
+
  Branch (150:12): [True: 105, False: 1]
 
151
6.13k
            item_rows.reverse();
152
6.13k
        
}259
153
6.39k
        out.extend(item_rows);
154
155
6.39k
        added
156
6.39k
    }
157
158
6.39k
    fn split_sub_lines<'b>(&self, item_text: &'b str) -> Vec<&'b str> {
159
6.39k
        if let Some(
sep667
) = self.multiline {
  Branch (159:16): [True: 665, False: 5.62k]
-
  Branch (159:16): [True: 2, False: 107]
-
160
667
            item_text.split(sep).collect()
161
        } else {
162
5.72k
            vec![item_text]
163
        }
164
6.39k
    }
165
166
6.35k
    fn matched_range(item_text: &str, matched_range: Option<&MatchRange>) -> (usize, usize) {
167
6.24k
        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 (159:16): [True: 2, False: 106]
+
160
667
            item_text.split(sep).collect()
161
        } else {
162
5.73k
            vec![item_text]
163
        }
164
6.39k
    }
165
166
6.35k
    fn matched_range(item_text: &str, matched_range: Option<&MatchRange>) -> (usize, usize) {
167
6.24k
        match matched_range {
168
1.65k
            Some(MatchRange::Chars(indices)) => {
169
1.65k
                if indices.is_empty() {
  Branch (169:20): [True: 6, False: 1.64k]
 
  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.58k
            Some(MatchRange::ByteRange(start, end)) => {
176
4.58k
                let start_char = item_text[..*start].chars().count();
177
4.58k
                let len = item_text[*start..*end].chars().count();
178
4.58k
                (start_char, start_char + len)
179
            }
180
1
            Some(MatchRange::CharRange(start, end)) => (*start, *end),
181
108
            None => (0, 0),
182
        }
183
6.35k
    }
184
185
6.91k
    fn render_sub_line(
186
6.91k
        &self,
187
6.91k
        item: &MatchedItem,
188
6.91k
        sub_text: &str,
189
6.91k
        state: &SubLineState,
190
6.91k
        match_start_char: usize,
191
6.91k
        match_end_char: usize,
192
6.91k
    ) -> ListItem<'static> {
193
6.91k
        let mut all_spans = self.prefix_spans(item, state);
194
6.91k
        let content_line = self.content_line(item, sub_text, state, match_start_char, match_end_char);
195
196
6.91k
        if state.needs_ellipsis {
  Branch (196:12): [True: 0, False: 6.81k]
-
  Branch (196:12): [True: 1, False: 107]
-
197
1
            all_spans.extend(self.trim_with_ellipsis(content_line, state.is_current));
198
6.91k
        } else {
199
6.91k
            all_spans.extend(content_line.spans);
200
6.91k
        }
201
202
6.91k
        if item.item.disabled() {
  Branch (202:12): [True: 0, False: 6.81k]
-
  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.91k
        }
207
208
6.91k
        self.list_item_from_spans(all_spans, state.is_current)
209
6.91k
    }
210
211
6.92k
    fn prefix_spans(&self, item: &MatchedItem, state: &SubLineState) -> Vec<Span<'static>> {
212
6.92k
        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.92k
        let prefix_cursor_style = if self.highlight_line && 
state.is_current208
{
  Branch (216:38): [True: 207, False: 6.60k]
+
170
7
                    (0, 0)
171
                } else {
172
1.64k
                    (indices[0], indices[indices.len() - 1] + 1)
173
                }
174
            }
175
4.59k
            Some(MatchRange::ByteRange(start, end)) => {
176
4.59k
                let start_char = item_text[..*start].chars().count();
177
4.59k
                let len = item_text[*start..*end].chars().count();
178
4.59k
                (start_char, start_char + len)
179
            }
180
1
            Some(MatchRange::CharRange(start, end)) => (*start, *end),
181
108
            None => (0, 0),
182
        }
183
6.35k
    }
184
185
6.92k
    fn render_sub_line(
186
6.92k
        &self,
187
6.92k
        item: &MatchedItem,
188
6.92k
        sub_text: &str,
189
6.92k
        state: &SubLineState,
190
6.92k
        match_start_char: usize,
191
6.92k
        match_end_char: usize,
192
6.92k
    ) -> ListItem<'static> {
193
6.92k
        let mut all_spans = self.prefix_spans(item, state);
194
6.92k
        let content_line = self.content_line(item, sub_text, state, match_start_char, match_end_char);
195
196
6.92k
        if state.needs_ellipsis {
  Branch (196:12): [True: 0, False: 6.81k]
+
  Branch (196:12): [True: 1, False: 106]
+
197
1
            all_spans.extend(self.trim_with_ellipsis(content_line, state.is_current));
198
6.92k
        } else {
199
6.92k
            all_spans.extend(content_line.spans);
200
6.92k
        }
201
202
6.92k
        if item.item.disabled() {
  Branch (202:12): [True: 0, False: 6.81k]
+
  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.92k
        }
207
208
6.92k
        self.list_item_from_spans(all_spans, state.is_current)
209
6.92k
    }
210
211
6.92k
    fn prefix_spans(&self, item: &MatchedItem, state: &SubLineState) -> Vec<Span<'static>> {
212
6.92k
        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.92k
        let prefix_cursor_style = if self.highlight_line && 
state.is_current208
{
  Branch (216:38): [True: 207, False: 6.60k]
   Branch (216:61): [True: 9, False: 198]
-
  Branch (216:38): [True: 1, False: 110]
+
  Branch (216:38): [True: 1, False: 109]
   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.91k
            self.theme.cursor
220
        };
221
6.92k
        let prefix_selected_style = if self.highlight_line && 
state.is_current208
{
  Branch (221:40): [True: 207, False: 6.60k]
   Branch (221:63): [True: 9, False: 198]
-
  Branch (221:40): [True: 1, False: 110]
+
  Branch (221:40): [True: 1, False: 109]
   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.91k
            self.theme.selected
225
        };
226
227
6.92k
        prefix.push(Span::styled(
228
6.92k
            if state.is_first && 
state.is_current6.39k
{
  Branch (228:16): [True: 6.28k, False: 524]
-  Branch (228:34): [True: 2.05k, False: 4.23k]
-
  Branch (228:16): [True: 109, False: 2]
-  Branch (228:34): [True: 35, False: 74]
-
229
2.08k
                self.selector_icon.to_owned()
230
            } else {
231
4.83k
                str::repeat(" ", self.selector_icon.chars().count())
232
            },
233
6.92k
            prefix_cursor_style,
234
        ));
235
6.92k
        prefix.push(Span::styled(
236
6.92k
            if state.is_first && 
self.multi_select6.39k
&&
state.is_selected394
{
  Branch (236:16): [True: 6.28k, False: 524]
+
222
10
            self.theme.selected.bg(self.theme.selected.bg.unwrap_or(Color::Reset))
223
        } else {
224
6.91k
            self.theme.selected
225
        };
226
227
6.92k
        prefix.push(Span::styled(
228
6.92k
            if state.is_first && 
state.is_current6.39k
{
  Branch (228:16): [True: 6.29k, False: 524]
+  Branch (228:34): [True: 2.05k, False: 4.24k]
+
  Branch (228:16): [True: 108, False: 2]
+  Branch (228:34): [True: 34, False: 74]
+
229
2.08k
                self.selector_icon.to_owned()
230
            } else {
231
4.84k
                str::repeat(" ", self.selector_icon.chars().count())
232
            },
233
6.92k
            prefix_cursor_style,
234
        ));
235
6.92k
        prefix.push(Span::styled(
236
6.92k
            if state.is_first && 
self.multi_select6.39k
&&
state.is_selected394
{
  Branch (236:16): [True: 6.29k, False: 524]
   Branch (236:34): [True: 392, False: 5.89k]
   Branch (236:55): [True: 150, False: 242]
-
  Branch (236:16): [True: 109, False: 2]
-  Branch (236:34): [True: 2, False: 107]
+
  Branch (236:16): [True: 108, False: 2]
+  Branch (236:34): [True: 2, False: 106]
   Branch (236:55): [True: 2, False: 0]
-
237
152
                self.multi_select_icon.to_owned()
238
            } else {
239
6.76k
                str::repeat(" ", self.multi_select_icon.chars().count())
240
            },
241
6.92k
            prefix_selected_style,
242
        ));
243
6.92k
        if self.show_score {
  Branch (243:12): [True: 0, False: 6.81k]
-
  Branch (243:12): [True: 2, False: 109]
-
244
2
            self.push_first_line_field(&mut prefix, state, item.rank.score);
245
6.91k
        }
246
6.92k
        if self.show_index {
  Branch (246:12): [True: 0, False: 6.81k]
-
  Branch (246:12): [True: 2, False: 109]
-
247
2
            self.push_first_line_field(&mut prefix, state, item.rank.index);
248
6.91k
        }
249
250
6.92k
        prefix
251
6.92k
    }
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.77k
                str::repeat(" ", self.multi_select_icon.chars().count())
240
            },
241
6.92k
            prefix_selected_style,
242
        ));
243
6.92k
        if self.show_score {
  Branch (243:12): [True: 0, False: 6.81k]
+
  Branch (243:12): [True: 2, False: 108]
+
244
2
            self.push_first_line_field(&mut prefix, state, item.rank.score);
245
6.92k
        }
246
6.92k
        if self.show_index {
  Branch (246:12): [True: 0, False: 6.81k]
+
  Branch (246:12): [True: 2, False: 108]
+
247
2
            self.push_first_line_field(&mut prefix, state, item.rank.index);
248
6.92k
        }
249
250
6.92k
        prefix
251
6.92k
    }
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.91k
    fn content_line(
271
6.91k
        &self,
272
6.91k
        item: &MatchedItem,
273
6.91k
        sub_text: &str,
274
6.91k
        state: &SubLineState,
275
6.91k
        match_start_char: usize,
276
6.91k
        match_end_char: usize,
277
6.91k
    ) -> Line<'static> {
278
6.91k
        if state.is_first && 
state.is_first_sub_line6.39k
{
  Branch (278:12): [True: 6.28k, False: 524]
-  Branch (278:30): [True: 6.28k, False: 0]
-
  Branch (278:12): [True: 107, False: 1]
-  Branch (278:30): [True: 106, False: 1]
-
279
6.39k
            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.91k
    }
284
285
6.39k
    fn first_sub_line_content(
286
6.39k
        &self,
287
6.39k
        item: &MatchedItem,
288
6.39k
        first_sub_line: &str,
289
6.39k
        is_current: bool,
290
6.39k
        match_start_char: usize,
291
6.39k
        match_end_char: usize,
292
6.39k
    ) -> Line<'static> {
293
6.39k
        let first_sub_char_len = first_sub_line.chars().count();
294
6.39k
        let dl = item.item.display(DisplayContext {
295
6.39k
            score: item.rank.score,
296
6.39k
            matches: Self::display_matches(item.matched_range.as_ref()),
297
6.39k
            container_width: self.container_width,
298
6.39k
            base_style: self.base_style(is_current),
299
6.39k
            matched_style: if is_current {
  Branch (299:31): [True: 2.05k, False: 4.23k]
-
  Branch (299:31): [True: 33, False: 74]
+
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.92k
    fn content_line(
271
6.92k
        &self,
272
6.92k
        item: &MatchedItem,
273
6.92k
        sub_text: &str,
274
6.92k
        state: &SubLineState,
275
6.92k
        match_start_char: usize,
276
6.92k
        match_end_char: usize,
277
6.92k
    ) -> Line<'static> {
278
6.92k
        if state.is_first && 
state.is_first_sub_line6.39k
{
  Branch (278:12): [True: 6.29k, False: 524]
+  Branch (278:30): [True: 6.29k, False: 0]
+
  Branch (278:12): [True: 106, False: 1]
+  Branch (278:30): [True: 105, False: 1]
+
279
6.39k
            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.92k
    }
284
285
6.39k
    fn first_sub_line_content(
286
6.39k
        &self,
287
6.39k
        item: &MatchedItem,
288
6.39k
        first_sub_line: &str,
289
6.39k
        is_current: bool,
290
6.39k
        match_start_char: usize,
291
6.39k
        match_end_char: usize,
292
6.39k
    ) -> Line<'static> {
293
6.39k
        let first_sub_char_len = first_sub_line.chars().count();
294
6.39k
        let dl = item.item.display(DisplayContext {
295
6.39k
            score: item.rank.score,
296
6.39k
            matches: Self::display_matches(item.matched_range.as_ref()),
297
6.39k
            container_width: self.container_width,
298
6.39k
            base_style: self.base_style(is_current),
299
6.39k
            matched_style: if is_current {
  Branch (299:31): [True: 2.05k, False: 4.24k]
+
  Branch (299:31): [True: 32, False: 74]
 
300
2.08k
                self.theme.current_match
301
            } else {
302
4.31k
                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.39k
        let mut line = if self.multiline.is_some() {
  Branch (308:27): [True: 665, False: 5.62k]
-
  Branch (308:27): [True: 0, False: 107]
-
309
665
            clip_line_to_chars(dl, first_sub_char_len)
310
        } else {
311
5.72k
            dl
312
        };
313
6.39k
        if !self.wrap {
  Branch (313:12): [True: 6.27k, False: 9]
-
  Branch (313:12): [True: 107, False: 0]
-
314
6.38k
            let (shift, full_width, _, _) = if self.multiline.is_some() {
  Branch (314:48): [True: 659, False: 5.61k]
-
  Branch (314:48): [True: 0, False: 107]
+
  Branch (308:27): [True: 0, False: 106]
+
309
665
            clip_line_to_chars(dl, first_sub_char_len)
310
        } else {
311
5.73k
            dl
312
        };
313
6.39k
        if !self.wrap {
  Branch (313:12): [True: 6.28k, False: 9]
+
  Branch (313:12): [True: 106, False: 0]
+
314
6.38k
            let (shift, full_width, _, _) = if self.multiline.is_some() {
  Branch (314:48): [True: 659, False: 5.62k]
+
  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.72k
                self.calc_line_hscroll(&line, first_sub_line, match_start_char, match_end_char)
318
            };
319
6.38k
            line = self.apply_hscroll(line, shift, full_width);
320
9
        }
321
6.39k
        Self::into_static_line(line)
322
6.39k
    }
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.44k
    fn display_matches(matched_range: Option<&MatchRange>) -> crate::Matches {
337
6.33k
        match matched_range {
338
4.64k
            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.44k
    }
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.44k
    fn display_matches(matched_range: Option<&MatchRange>) -> crate::Matches {
337
6.33k
        match matched_range {
338
4.65k
            Some(MatchRange::ByteRange(start, end)) => crate::Matches::ByteRange(*start, *end),
339
1
            Some(MatchRange::CharRange(start, end)) => crate::Matches::CharRange(*start, *end),
340
1.68k
            Some(MatchRange::Chars(chars)) => crate::Matches::CharIndices(chars.clone()),
341
108
            None => crate::Matches::None,
342
        }
343
6.44k
    }
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.91k
    fn list_item_from_spans(&self, spans: Vec<Span<'static>>, is_current: bool) -> ListItem<'static> {
371
6.91k
        if self.wrap {
  Branch (371:12): [True: 15, False: 6.79k]
-
  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.90k
            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.90k
            if self.highlight_line && 
is_current207
{
  Branch (381:16): [True: 207, False: 6.58k]
+
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.92k
    fn list_item_from_spans(&self, spans: Vec<Span<'static>>, is_current: bool) -> ListItem<'static> {
371
6.92k
        if self.wrap {
  Branch (371:12): [True: 15, False: 6.80k]
+
  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.90k
            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.90k
            if self.highlight_line && 
is_current207
{
  Branch (381:16): [True: 207, False: 6.59k]
   Branch (381:39): [True: 9, False: 198]
-
  Branch (381:16): [True: 0, False: 108]
+
  Branch (381:16): [True: 0, False: 107]
   Branch (381:39): [True: 0, False: 0]
-
382
9
                line = line.style(self.theme.current);
383
6.89k
            }
384
6.90k
            line.into()
385
        }
386
6.91k
    }
387
388
6.92k
    fn base_style(&self, is_current: bool) -> ratatui::style::Style {
389
6.92k
        if is_current {
  Branch (389:12): [True: 2.16k, False: 4.64k]
-
  Branch (389:12): [True: 37, False: 79]
-
390
2.20k
            self.theme.current
391
        } else {
392
4.72k
            self.theme.normal
393
        }
394
6.92k
    }
395
396
6.91k
    fn into_static_line(line: Line<'_>) -> Line<'static> {
397
6.91k
        line.spans
398
6.91k
            .into_iter()
399
11.0k
            .
map6.91k
(|span| Span::styled(span.content.into_owned(), span.style))
400
6.91k
            .collect()
401
6.91k
    }
402
403
    // ── hscroll helpers ──────────────────────────────────────────────────────
404
405
5.18k
    fn calc_skip_width(&self, text: &str) -> usize {
406
5.18k
        if let Some(
regex7
) = self.skip_to_pattern
  Branch (406:16): [True: 7, False: 5.06k]
-
  Branch (406:16): [True: 0, False: 111]
+
382
9
                line = line.style(self.theme.current);
383
6.89k
            }
384
6.90k
            line.into()
385
        }
386
6.92k
    }
387
388
6.93k
    fn base_style(&self, is_current: bool) -> ratatui::style::Style {
389
6.93k
        if is_current {
  Branch (389:12): [True: 2.16k, False: 4.64k]
+
  Branch (389:12): [True: 36, False: 79]
+
390
2.20k
            self.theme.current
391
        } else {
392
4.72k
            self.theme.normal
393
        }
394
6.93k
    }
395
396
6.91k
    fn into_static_line(line: Line<'_>) -> Line<'static> {
397
6.91k
        line.spans
398
6.91k
            .into_iter()
399
11.0k
            .
map6.91k
(|span| Span::styled(span.content.into_owned(), span.style))
400
6.91k
            .collect()
401
6.91k
    }
402
403
    // ── hscroll helpers ──────────────────────────────────────────────────────
404
405
5.19k
    fn calc_skip_width(&self, text: &str) -> usize {
406
5.19k
        if let Some(
regex7
) = self.skip_to_pattern
  Branch (406:16): [True: 7, False: 5.07k]
+
  Branch (406:16): [True: 0, False: 110]
 
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.17k
        }
411
5.17k
        0
412
5.18k
    }
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.72k
    fn calc_line_hscroll(
421
5.72k
        &self,
422
5.72k
        line: &Line<'_>,
423
5.72k
        text: &str,
424
5.72k
        match_start_char: usize,
425
5.72k
        match_end_char: usize,
426
5.72k
    ) -> (usize, usize, bool, bool) {
427
5.72k
        self.calc_hscroll_for_width(text, match_start_char, match_end_char, self.line_display_width(line))
428
5.72k
    }
429
430
6.90k
    fn calc_hscroll_for_width(
431
6.90k
        &self,
432
6.90k
        text: &str,
433
6.90k
        match_start_char: usize,
434
6.90k
        match_end_char: usize,
435
6.90k
        full_width: usize,
436
6.90k
    ) -> (usize, usize, bool, bool) {
437
6.90k
        let ell_w = usize::try_from(display_width(self.ellipsis)).unwrap();
438
6.90k
        let 
available_width6.90k
= if self.container_width >= ell_w {
  Branch (438:34): [True: 6.79k, False: 0]
-
  Branch (438:34): [True: 112, False: 1]
-
439
6.90k
            self.container_width
440
        } else {
441
1
            return (0, full_width, false, false);
442
        };
443
444
6.90k
        let base_shift = if self.no_hscroll {
  Branch (444:29): [True: 3, False: 6.79k]
-
  Branch (444:29): [True: 0, False: 112]
-
445
3
            0
446
6.90k
        } else if match_start_char == 0 && 
match_end_char == 06.56k
{
  Branch (446:19): [True: 6.44k, False: 343]
-  Branch (446:44): [True: 5.06k, False: 1.38k]
-
  Branch (446:19): [True: 111, False: 1]
-  Branch (446:44): [True: 111, False: 0]
-
447
5.18k
            let skip_width = self.calc_skip_width(text);
448
5.18k
            if skip_width > 0 {
  Branch (448:16): [True: 7, False: 5.06k]
-
  Branch (448:16): [True: 0, False: 111]
-
449
7
                skip_width
450
5.17k
            } else if self.keep_right {
  Branch (450:23): [True: 0, False: 5.06k]
-
  Branch (450:23): [True: 1, False: 110]
-
451
1
                full_width.saturating_sub(available_width)
452
            } else {
453
5.17k
                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.18k
        }
411
5.18k
        0
412
5.19k
    }
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.72k
    fn calc_line_hscroll(
421
5.72k
        &self,
422
5.72k
        line: &Line<'_>,
423
5.72k
        text: &str,
424
5.72k
        match_start_char: usize,
425
5.72k
        match_end_char: usize,
426
5.72k
    ) -> (usize, usize, bool, bool) {
427
5.72k
        self.calc_hscroll_for_width(text, match_start_char, match_end_char, self.line_display_width(line))
428
5.72k
    }
429
430
6.91k
    fn calc_hscroll_for_width(
431
6.91k
        &self,
432
6.91k
        text: &str,
433
6.91k
        match_start_char: usize,
434
6.91k
        match_end_char: usize,
435
6.91k
        full_width: usize,
436
6.91k
    ) -> (usize, usize, bool, bool) {
437
6.91k
        let ell_w = usize::try_from(display_width(self.ellipsis)).unwrap();
438
6.91k
        let 
available_width6.91k
= if self.container_width >= ell_w {
  Branch (438:34): [True: 6.80k, False: 0]
+
  Branch (438:34): [True: 111, False: 1]
+
439
6.91k
            self.container_width
440
        } else {
441
1
            return (0, full_width, false, false);
442
        };
443
444
6.91k
        let base_shift = if self.no_hscroll {
  Branch (444:29): [True: 3, False: 6.79k]
+
  Branch (444:29): [True: 0, False: 111]
+
445
3
            0
446
6.90k
        } else if match_start_char == 0 && 
match_end_char == 06.56k
{
  Branch (446:19): [True: 6.45k, False: 338]
+  Branch (446:44): [True: 5.08k, False: 1.37k]
+
  Branch (446:19): [True: 110, False: 1]
+  Branch (446:44): [True: 110, False: 0]
+
447
5.19k
            let skip_width = self.calc_skip_width(text);
448
5.19k
            if skip_width > 0 {
  Branch (448:16): [True: 7, False: 5.07k]
+
  Branch (448:16): [True: 0, False: 110]
+
449
7
                skip_width
450
5.18k
            } else if self.keep_right {
  Branch (450:23): [True: 0, False: 5.07k]
+
  Branch (450:23): [True: 1, False: 109]
+
451
1
                full_width.saturating_sub(available_width)
452
            } else {
453
5.18k
                0
454
            }
455
        } else {
456
1.71k
            let mut match_start_width = 0;
457
1.71k
            let mut match_end_width = 0;
458
1.71k
            let mut current_width = 0;
459
1.71k
            let mut found_start = false;
460
1.71k
            let mut found_end = false;
461
462
13.6k
            for (idx, ch) in 
text1.71k
.
chars1.71k
().
enumerate1.71k
() {
463
13.6k
                if idx == match_start_char {
  Branch (463:20): [True: 1.71k, 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]
+
464
1.71k
                    match_start_width = current_width;
465
1.71k
                    found_start = true;
466
11.8k
                }
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.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]
+
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.71k
            if found_start && 
!found_end1.71k
{
  Branch (474:16): [True: 1.71k, 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
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]
+
475
431
                match_end_width = current_width;
476
1.28k
            }
477
478
1.71k
            let match_width = match_end_width.saturating_sub(match_start_width);
479
1.71k
            if match_width >= available_width {
  Branch (479:16): [True: 0, False: 1.71k]
 
  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.90k
        let proposed = (i32::try_from(base_shift).unwrap_or(i32::MAX) + self.manual_hscroll)
488
6.90k
            .max(0)
489
6.90k
            .unsigned_abs() as usize;
490
6.90k
        let shift = if full_width > available_width {
  Branch (490:24): [True: 16, False: 6.77k]
-
  Branch (490:24): [True: 4, False: 108]
-
491
20
            proposed.min(full_width.saturating_sub(available_width))
492
        } else {
493
6.88k
            proposed
494
        };
495
496
6.90k
        (shift, full_width, shift > 0, shift + available_width < full_width)
497
6.90k
    }
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.72k
    fn line_display_width(&self, line: &Line<'_>) -> usize {
504
20.7k
        
line.spans.iter()5.72k
.
fold5.72k
(0usize, |width, span| {
505
20.7k
            span.content
506
20.7k
                .chars()
507
40.4k
                .
fold20.7k
(
width20.7k
, |span_width, ch| self.add_char_width(span_width, ch))
508
20.7k
        })
509
5.72k
    }
510
511
96.0k
    fn add_char_width(&self, width: usize, ch: char) -> usize {
512
96.0k
        if ch == '\t' {
  Branch (512:12): [True: 135, False: 95.2k]
-
  Branch (512:12): [True: 0, False: 645]
-
513
135
            width + self.tabstop - (width % self.tabstop)
514
        } else {
515
95.9k
            width + char_display_width(ch)
516
        }
517
96.0k
    }
518
519
6.90k
    fn apply_hscroll<'b>(&'b self, line: Line<'b>, shift: usize, full_width: usize) -> Line<'b> {
520
6.90k
        let container_width = self.container_width;
521
6.90k
        let has_left = shift > 0;
522
6.90k
        let has_right = shift + container_width < full_width;
523
524
6.90k
        let ell_w = usize::try_from(display_width(self.ellipsis)).unwrap();
525
6.90k
        let left_w = if has_left { 
ell_w18
} else {
06.88k
};
  Branch (525:25): [True: 17, False: 6.77k]
-
  Branch (525:25): [True: 1, False: 109]
+
480
1
                match_start_width
481
            } else {
482
1.71k
                let desired = match_start_width.saturating_sub((available_width - match_width) / 2);
483
1.71k
                desired.min(full_width.saturating_sub(available_width))
484
            }
485
        };
486
487
6.91k
        let proposed = (i32::try_from(base_shift).unwrap_or(i32::MAX) + self.manual_hscroll)
488
6.91k
            .max(0)
489
6.91k
            .unsigned_abs() as usize;
490
6.91k
        let shift = if full_width > available_width {
  Branch (490:24): [True: 16, False: 6.78k]
+
  Branch (490:24): [True: 4, False: 107]
+
491
20
            proposed.min(full_width.saturating_sub(available_width))
492
        } else {
493
6.89k
            proposed
494
        };
495
496
6.91k
        (shift, full_width, shift > 0, shift + available_width < full_width)
497
6.91k
    }
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.72k
    fn line_display_width(&self, line: &Line<'_>) -> usize {
504
20.7k
        
line.spans.iter()5.72k
.
fold5.72k
(0usize, |width, span| {
505
20.7k
            span.content
506
20.7k
                .chars()
507
40.4k
                .
fold20.7k
(
width20.7k
, |span_width, ch| self.add_char_width(span_width, ch))
508
20.7k
        })
509
5.72k
    }
510
511
96.0k
    fn add_char_width(&self, width: usize, ch: char) -> usize {
512
96.0k
        if ch == '\t' {
  Branch (512:12): [True: 135, False: 95.2k]
+
  Branch (512:12): [True: 0, False: 643]
+
513
135
            width + self.tabstop - (width % self.tabstop)
514
        } else {
515
95.8k
            width + char_display_width(ch)
516
        }
517
96.0k
    }
518
519
6.90k
    fn apply_hscroll<'b>(&'b self, line: Line<'b>, shift: usize, full_width: usize) -> Line<'b> {
520
6.90k
        let container_width = self.container_width;
521
6.90k
        let has_left = shift > 0;
522
6.90k
        let has_right = shift + container_width < full_width;
523
524
6.90k
        let ell_w = usize::try_from(display_width(self.ellipsis)).unwrap();
525
6.90k
        let left_w = if has_left { 
ell_w18
} else {
06.89k
};
  Branch (525:25): [True: 17, False: 6.78k]
+
  Branch (525:25): [True: 1, False: 108]
 
526
6.90k
        let right_w = if has_right { 
ell_w14
} else {
06.89k
};
  Branch (526:26): [True: 13, False: 6.78k]
-
  Branch (526:26): [True: 1, False: 109]
-
527
6.90k
        let content_width = container_width.saturating_sub(left_w + right_w);
528
529
6.90k
        let mut result = Line::default();
530
6.90k
        if has_left {
  Branch (530:12): [True: 17, False: 6.77k]
-
  Branch (530:12): [True: 1, False: 109]
-
531
18
            result.push_span(Span::raw(self.ellipsis));
532
6.88k
        }
533
534
6.90k
        let mut current_char_index = 0;
535
6.90k
        let mut current_width = 0;
536
6.90k
        let shift_char_start = self.char_index_at_width(&line, shift);
537
6.90k
        let shift_char_end = self.char_index_at_width(&line, shift + content_width);
538
539
21.9k
        for span in 
line.spans6.90k
{
540
21.9k
            let span_text = span.content.as_ref();
541
21.9k
            let span_chars: Vec<char> = span_text.chars().collect();
542
21.9k
            let span_start = current_char_index;
543
21.9k
            let span_end = current_char_index + span_chars.len();
544
545
21.9k
            if span_end > shift_char_start && 
span_start < shift_char_end12.9k
{
  Branch (545:16): [True: 12.7k, False: 9.07k]
+
  Branch (526:26): [True: 1, False: 108]
+
527
6.90k
        let content_width = container_width.saturating_sub(left_w + right_w);
528
529
6.90k
        let mut result = Line::default();
530
6.90k
        if has_left {
  Branch (530:12): [True: 17, False: 6.78k]
+
  Branch (530:12): [True: 1, False: 108]
+
531
18
            result.push_span(Span::raw(self.ellipsis));
532
6.89k
        }
533
534
6.90k
        let mut current_char_index = 0;
535
6.90k
        let mut current_width = 0;
536
6.90k
        let shift_char_start = self.char_index_at_width(&line, shift);
537
6.90k
        let shift_char_end = self.char_index_at_width(&line, shift + content_width);
538
539
21.9k
        for span in 
line.spans6.90k
{
540
21.9k
            let span_text = span.content.as_ref();
541
21.9k
            let span_chars: Vec<char> = span_text.chars().collect();
542
21.9k
            let span_start = current_char_index;
543
21.9k
            let span_end = current_char_index + span_chars.len();
544
545
21.9k
            if span_end > shift_char_start && 
span_start < shift_char_end12.8k
{
  Branch (545:16): [True: 12.7k, False: 9.09k]
   Branch (545:47): [True: 12.3k, False: 416]
-
  Branch (545:16): [True: 110, False: 2]
-  Branch (545:47): [True: 110, False: 0]
+
  Branch (545:16): [True: 109, False: 0]
+  Branch (545:47): [True: 109, False: 0]
 
546
12.4k
                let vis_start = shift_char_start.saturating_sub(span_start);
547
12.4k
                let vis_end = if span_end > shift_char_end {
  Branch (547:34): [True: 12, False: 12.3k]
-
  Branch (547:34): [True: 2, False: 108]
+
  Branch (547:34): [True: 2, False: 107]
 
548
14
                    shift_char_end - span_start
549
                } else {
550
12.4k
                    span_chars.len()
551
                };
552
12.4k
                if vis_start < vis_end && 
vis_start10.9k
< span_chars.len() {
  Branch (552:20): [True: 10.8k, False: 1.51k]
   Branch (552:43): [True: 10.8k, False: 0]
-
  Branch (552:20): [True: 110, False: 0]
-  Branch (552:43): [True: 110, False: 0]
+
  Branch (552:20): [True: 109, False: 0]
+  Branch (552:43): [True: 109, False: 0]
 
553
10.9k
                    let visible: String = span_chars[vis_start..vis_end.min(span_chars.len())].iter().collect();
554
10.9k
                    let processed = if visible.contains('\t') {
  Branch (554:40): [True: 45, False: 10.8k]
-
  Branch (554:40): [True: 0, False: 110]
+
  Branch (554:40): [True: 0, False: 109]
 
555
45
                        self.expand_tabs(&visible, current_width)
556
                    } else {
557
10.9k
                        visible
558
                    };
559
10.9k
                    if !processed.is_empty() {
  Branch (559:24): [True: 10.8k, False: 0]
-
  Branch (559:24): [True: 110, False: 0]
-
560
10.9k
                        result.push_span(Span::styled(processed, span.style));
561
10.9k
                    
}0
562
1.51k
                }
563
9.49k
            }
564
21.9k
            current_char_index += span_chars.len();
565
21.9k
            current_width += usize::try_from(display_width(span_text)).unwrap();
566
        }
567
568
6.90k
        if has_right {
  Branch (568:12): [True: 13, False: 6.78k]
-
  Branch (568:12): [True: 1, False: 109]
+
  Branch (559:24): [True: 109, False: 0]
+
560
10.9k
                        result.push_span(Span::styled(processed, span.style));
561
10.9k
                    
}0
562
1.51k
                }
563
9.51k
            }
564
21.9k
            current_char_index += span_chars.len();
565
21.9k
            current_width += usize::try_from(display_width(span_text)).unwrap();
566
        }
567
568
6.90k
        if has_right {
  Branch (568:12): [True: 13, False: 6.78k]
+
  Branch (568:12): [True: 1, False: 108]
 
569
14
            result.push_span(Span::raw(self.ellipsis));
570
6.89k
        }
571
6.90k
        result
572
6.90k
    }
573
574
13.8k
    fn char_index_at_width(&self, line: &Line<'_>, target_width: usize) -> usize {
575
13.8k
        let mut current_width = 0;
576
13.8k
        let mut char_index = 0;
577
37.9k
        for span in 
&line.spans13.8k
{
578
47.2k
            for ch in 
span.content.chars()37.9k
{
579
47.2k
                if current_width >= target_width {
  Branch (579:20): [True: 6.77k, False: 40.0k]
-
  Branch (579:20): [True: 112, False: 315]
-
580
6.88k
                    return char_index;
581
40.3k
                }
582
40.3k
                current_width = self.add_char_width(current_width, ch);
583
40.3k
                char_index += 1;
584
            }
585
        }
586
6.92k
        char_index
587
13.8k
    }
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 (579:20): [True: 111, False: 314]
+
580
6.88k
                    return char_index;
581
40.3k
                }
582
40.3k
                current_width = self.add_char_width(current_width, ch);
583
40.3k
                char_index += 1;
584
            }
585
        }
586
6.93k
        char_index
587
13.8k
    }
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 b46f4f6f..d51741a4 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,39 +1,39 @@ -

Coverage Report

Created: 2026-08-22 15:34

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
587
    pub fn from_options(options: &SkimOptions, header_height: u16) -> Self {
79
587
        let has_border = options.border.is_some();
80
587
        let collapse_borders = has_border && 
!options.border_no_collapse33
;
  Branch (80:32): [True: 22, False: 367]
+

Coverage Report

Created: 2026-09-01 09:15

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
582
    pub fn from_options(options: &SkimOptions, header_height: u16) -> Self {
79
582
        let has_border = options.border.is_some();
80
582
        let collapse_borders = has_border && 
!options.border_no_collapse32
;
  Branch (80:32): [True: 21, False: 363]
 
  Branch (80:32): [True: 11, False: 187]
-
81
587
        let overlap = u16::from(collapse_borders);
82
83
        // Rows consumed by the input widget.
84
587
        let input_rows: u16 = if has_border {
  Branch (84:34): [True: 22, False: 367]
+
81
582
        let overlap = u16::from(collapse_borders);
82
83
        // Rows consumed by the input widget.
84
582
        let input_rows: u16 = if has_border {
  Branch (84:34): [True: 21, False: 363]
 
  Branch (84:34): [True: 11, False: 187]
-
85
33
            3 // 1 content + 2 border rows
86
        } else {
87
554
            1 + u16::from(
matches!25
(
88
554
                options.info.display,
89
                InfoDisplay::Default | InfoDisplay::Left | InfoDisplay::Right
90
            ))
91
        };
92
93
        // Rows consumed by the header widget.
94
587
        let show_header = options.header.is_some() || 
options.header_lines > 0546
;
  Branch (94:27): [True: 29, False: 360]
+
85
32
            3 // 1 content + 2 border rows
86
        } else {
87
550
            1 + u16::from(
matches!25
(
88
550
                options.info.display,
89
                InfoDisplay::Default | InfoDisplay::Left | InfoDisplay::Right
90
            ))
91
        };
92
93
        // Rows consumed by the header widget.
94
582
        let show_header = options.header.is_some() || 
options.header_lines > 0541
;
  Branch (94:27): [True: 29, False: 355]
 
  Branch (94:27): [True: 12, False: 186]
-
95
587
        let header_rows: u16 = if show_header {
  Branch (95:35): [True: 39, False: 350]
+
95
582
        let header_rows: u16 = if show_header {
  Branch (95:35): [True: 39, False: 345]
 
  Branch (95:35): [True: 13, False: 185]
-
96
52
            if has_border { 
header_height + 210
} else {
header_height42
}
  Branch (96:16): [True: 7, False: 32]
+
96
52
            if has_border { 
header_height + 29
} else {
header_height43
}
  Branch (96:16): [True: 6, False: 33]
 
  Branch (96:16): [True: 3, False: 10]
-
97
        } else {
98
535
            0
99
        };
100
101
        // Preview placement and layout.
102
587
        let preview_visible = (options.preview.is_some() || 
options.preview_fn531
.
is_some531
())
  Branch (102:32): [True: 41, False: 348]
-  Branch (102:61): [True: 0, False: 348]
+
97
        } else {
98
530
            0
99
        };
100
101
        // Preview placement and layout.
102
582
        let preview_visible = (options.preview.is_some() || 
options.preview_fn528
.
is_some528
())
  Branch (102:32): [True: 39, False: 345]
+  Branch (102:61): [True: 0, False: 345]
 
  Branch (102:32): [True: 15, False: 183]
   Branch (102:61): [True: 0, False: 183]
-
103
56
            && !options.preview_window.hidden
  Branch (103:16): [True: 41, False: 0]
+
103
54
            && !options.preview_window.hidden
  Branch (103:16): [True: 39, False: 0]
 
  Branch (103:16): [True: 14, False: 1]
-
104
55
            && !matches!(options.preview_window.size, Size::Fixed(0));
105
106
587
        let (preview_placement, preview_layout) = if preview_visible {
  Branch (106:54): [True: 41, False: 348]
+
104
53
            && !matches!(options.preview_window.size, Size::Fixed(0));
105
106
582
        let (preview_placement, preview_layout) = if preview_visible {
  Branch (106:54): [True: 39, False: 345]
 
  Branch (106:54): [True: 14, False: 184]
-
107
55
            let (preview_c, rest_c) = size_to_constraint(options.preview_window.size);
108
55
            let placement = match options.preview_window.direction {
109
15
                Direction::Left => PreviewPlacement::Left,
110
27
                Direction::Right => PreviewPlacement::Right,
111
7
                Direction::Up => PreviewPlacement::Up,
112
6
                Direction::Down => PreviewPlacement::Down,
113
            };
114
55
            let layout = match placement {
115
15
                PreviewPlacement::Left => Layout::new(RatatuiDirection::Horizontal, [preview_c, rest_c]),
116
27
                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
55
            (placement, Some(layout))
122
        } else {
123
532
            (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
587
        let work_layout_reversed = options.layout == TuiLayout::Reverse;
131
587
        let work_layout = if show_header {
  Branch (131:30): [True: 39, 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
529
            (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
582
        let work_layout_reversed = options.layout == TuiLayout::Reverse;
131
582
        let work_layout = if show_header {
  Branch (131:30): [True: 39, False: 345]
 
  Branch (131:30): [True: 13, False: 185]
-
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
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
587
        Self {
160
587
            show_header,
161
587
            preview_placement,
162
587
            work_layout_reversed,
163
587
            preview_layout,
164
587
            collapse_borders,
165
587
            work_layout,
166
587
        }
167
587
    }
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
281
            Some(layout) => {
176
281
                let [a, mut b]: [Rect; 2] = layout.areas(area);
177
281
                if self.collapse_borders {
  Branch (177:20): [True: 0, False: 267]
+
132
52
            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
10
                TuiLayout::Reverse => Layout::vertical([
139
10
                    Constraint::Length(input_rows),
140
10
                    Constraint::Length(header_rows.saturating_sub(overlap)),
141
10
                    Constraint::Fill(1),
142
10
                ]),
143
            }
144
        } else {
145
530
            match options.layout {
146
520
                TuiLayout::Default | TuiLayout::ReverseList => Layout::vertical([
147
520
                    Constraint::Fill(1),
148
520
                    Constraint::Length(0),
149
520
                    Constraint::Length(input_rows.saturating_sub(overlap)),
150
520
                ]),
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
582
        Self {
160
582
            show_header,
161
582
            preview_placement,
162
582
            work_layout_reversed,
163
582
            preview_layout,
164
582
            collapse_borders,
165
582
            work_layout,
166
582
        }
167
582
    }
168
169
    /// Apply this template to a concrete terminal `area`, producing the
170
    /// absolute [`AppLayout`] for one render frame.
171
    #[must_use]
172
3.22k
    pub fn apply(&self, area: Rect) -> AppLayout {
173
        // ── Step 1: carve out the preview from the full area ─────────────────
174
3.22k
        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
279
                }
184
281
                match self.preview_placement {
185
                    // preview is the first segment for Left / Up
186
89
                    PreviewPlacement::Left | PreviewPlacement::Up => (b, Some(a)),
187
                    // preview is the second segment for Right / Down
188
192
                    _ => (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.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: 61, False: 2.96k]
-
  Branch (200:67): [True: 5, False: 214]
-
201
66
            (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]
-
  Branch (206:12): [True: 10, False: 209]
+
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.95k
            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.22k
        let [slot0, slot1, slot2]: [Rect; 3] = self.work_layout.areas(work_area);
199
200
3.22k
        let (mut list_area, mut header_slot, mut input_area) = if self.work_layout_reversed {
  Branch (200:67): [True: 65, False: 2.94k]
+
  Branch (200:67): [True: 5, False: 212]
+
201
70
            (slot2, slot1, slot0)
202
        } else {
203
3.15k
            (slot0, slot1, slot2)
204
        };
205
206
3.22k
        if self.collapse_borders {
  Branch (206:12): [True: 72, False: 2.93k]
+
  Branch (206:12): [True: 10, False: 207]
 
207
82
            if self.work_layout_reversed {
  Branch (207:16): [True: 8, False: 64]
 
  Branch (207:16): [True: 2, False: 8]
 
208
10
                if self.show_header {
  Branch (208:20): [True: 4, False: 4]
 
  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)138
} else {
None3.10k
};
  Branch (220:30): [True: 126, False: 2.90k]
-
  Branch (220:30): [True: 12, False: 207]
-
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]
+
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.14k
        }
219
220
3.22k
        let header_area = if self.show_header { 
Some(header_slot)138
} else {
None3.08k
};
  Branch (220:30): [True: 126, False: 2.88k]
+
  Branch (220:30): [True: 12, False: 205]
+
221
222
3.22k
        AppLayout {
223
3.22k
            list_area,
224
3.22k
            input_area,
225
3.22k
            header_area,
226
3.22k
            preview_area,
227
3.22k
        }
228
3.22k
    }
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]
-
279
2
        rect.x -= 1;
280
2
        rect.width = rect.width.saturating_add(1);
281
2
    
}0
282
2
    rect
283
2
}
284
285
55
fn size_to_constraint(size: Size) -> (Constraint, Constraint) {
286
55
    match size {
287
6
        Size::Fixed(n) => (Constraint::Length(n), Constraint::Fill(1)),
288
45
        Size::Percent(p) => (Constraint::Percentage(p), Constraint::Fill(1)),
289
4
        Size::Neg(n) => (Constraint::Fill(1), Constraint::Length(n)),
290
    }
291
55
}
292
293
// ---------------------------------------------------------------------------
294
// Tests
295
// ---------------------------------------------------------------------------
296
297
#[cfg(test)]
298
#[path = "layout_tests.rs"]
299
mod tests;
\ No newline at end of file +
279
2
        rect.x -= 1;
280
2
        rect.width = rect.width.saturating_add(1);
281
2
    
}0
282
2
    rect
283
2
}
284
285
53
fn size_to_constraint(size: Size) -> (Constraint, Constraint) {
286
53
    match size {
287
6
        Size::Fixed(n) => (Constraint::Length(n), Constraint::Fill(1)),
288
43
        Size::Percent(p) => (Constraint::Percentage(p), Constraint::Fill(1)),
289
4
        Size::Neg(n) => (Constraint::Fill(1), Constraint::Length(n)),
290
    }
291
53
}
292
293
// ---------------------------------------------------------------------------
294
// Tests
295
// ---------------------------------------------------------------------------
296
297
#[cfg(test)]
298
#[path = "layout_tests.rs"]
299
mod tests;
\ No newline at end of file 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 731c0e78..c67b53bd 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-08-22 15:34

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
962
    fn try_from(value: &str) -> Result<Self, Self::Error> {
71
962
        match value.to_lowercase().as_str() {
72
962
            "up" => 
Ok(Self::Up)11
,
73
951
            "down" => 
Ok(Self::Down)9
,
74
942
            "left" => 
Ok(Self::Left)27
,
75
915
            "right" => 
Ok(Self::Right)449
,
76
466
            _ => Err("Unknown direction {value}"),
77
        }
78
962
    }
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: 802, False: 456]
+

Coverage Report

Created: 2026-09-01 09:15

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
955
    fn try_from(value: &str) -> Result<Self, Self::Error> {
71
955
        match value.to_lowercase().as_str() {
72
955
            "up" => 
Ok(Self::Up)11
,
73
944
            "down" => 
Ok(Self::Down)9
,
74
935
            "left" => 
Ok(Self::Left)26
,
75
909
            "right" => 
Ok(Self::Right)446
,
76
463
            _ => Err("Unknown direction {value}"),
77
        }
78
955
    }
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.35k
    fn try_from(value: &str) -> Result<Self, Self::Error> {
96
1.35k
        if value.ends_with('%') {
  Branch (96:12): [True: 794, False: 452]
 
  Branch (96:12): [True: 58, False: 51]
-
97
860
            let 
percent857
= value
98
860
                .strip_suffix("%")
99
860
                .unwrap_or_default()
100
860
                .parse::<u16>()
101
860
                .map_err(|e| SizeParseError::ParseError(
value3
.
to_string3
(),
e3
))
?3
;
102
857
            if percent > 100 {
  Branch (102:16): [True: 0, False: 802]
+
97
852
            let 
percent849
= value
98
852
                .strip_suffix("%")
99
852
                .unwrap_or_default()
100
852
                .parse::<u16>()
101
852
                .map_err(|e| SizeParseError::ParseError(
value3
.
to_string3
(),
e3
))
?3
;
102
849
            if percent > 100 {
  Branch (102:16): [True: 0, False: 794]
 
  Branch (102:16): [True: 1, False: 54]
-
103
1
                return Err(SizeParseError::InvalidPercent(percent));
104
856
            }
105
856
            Ok(Self::Percent(percent))
106
507
        } else if let Some(
neg5
) = value.strip_prefix('-') {
  Branch (106:23): [True: 4, False: 452]
+
103
1
                return Err(SizeParseError::InvalidPercent(percent));
104
848
            }
105
848
            Ok(Self::Percent(percent))
106
503
        } else if let Some(
neg5
) = value.strip_prefix('-') {
  Branch (106:23): [True: 4, False: 448]
 
  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
502
                value
114
502
                    .parse::<u16>()
115
502
                    .map_err(|e| SizeParseError::ParseError(
value493
.
to_string493
(),
e493
))
?493
,
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.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: 153]
-
  Branch (175:12): [True: 71, False: 1]
-
176
5.54k
            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.69k
    }
195
}
196
197
#[cfg(feature = "cli")]
198
impl std::str::FromStr for BorderType {
199
    type Err = String;
200
201
467
    fn from_str(s: &str) -> Result<Self, Self::Err> {
202
        use clap::ValueEnum as _;
203
1.02k
        for variant in 
Self::value_variants467
() {
204
1.02k
            if variant.to_possible_value().unwrap().matches(s, false) {
  Branch (204:16): [True: 456, False: 544]
+
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
498
                value
114
498
                    .parse::<u16>()
115
498
                    .map_err(|e| SizeParseError::ParseError(
value489
.
to_string489
(),
e489
))
?489
,
116
            ))
117
        }
118
1.35k
    }
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.7k
    fn is_none(self) -> bool {
169
11.7k
        
matches!350
(self, BorderType::None | BorderType::ForceOff)
170
11.7k
    }
171
6.06k
    fn is_some(self) -> bool {
172
6.06k
        !self.is_none()
173
6.06k
    }
174
5.66k
    fn into_ratatui(self) -> Option<ratatui::widgets::BorderType> {
175
5.66k
        if self.is_none() {
  Branch (175:12): [True: 5.45k, False: 144]
+
  Branch (175:12): [True: 67, False: 1]
+
176
5.52k
            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.66k
    }
195
}
196
197
#[cfg(feature = "cli")]
198
impl std::str::FromStr for BorderType {
199
    type Err = String;
200
201
462
    fn from_str(s: &str) -> Result<Self, Self::Err> {
202
        use clap::ValueEnum as _;
203
1.01k
        for variant in 
Self::value_variants462
() {
204
1.01k
            if variant.to_possible_value().unwrap().matches(s, false) {
  Branch (204:16): [True: 451, False: 538]
 
  Branch (204:16): [True: 11, False: 11]
-
205
467
                return Ok(*variant);
206
555
            }
207
        }
208
0
        Ok(Self::Plain)
209
467
    }
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
462
                return Ok(*variant);
206
549
            }
207
        }
208
0
        Ok(Self::Plain)
209
462
    }
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 e9a5d3c7..ae972385 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,21 +1,21 @@
-

Coverage Report

Created: 2026-08-22 15:34

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
893
    fn default() -> Self {
35
893
        Self {
36
893
            direction: Direction::Right,
37
893
            size: Size::Percent(50),
38
893
            hidden: false,
39
893
            offset: None,
40
893
            wrap: false,
41
893
            pty: false,
42
893
        }
43
893
    }
44
}
45
46
impl From<&str> for PreviewLayout {
47
499
    fn from(value: &str) -> Self {
48
499
        let mut res: Self = PreviewLayout::default();
49
        // Parse the remainder which can be: size:offset:hidden, offset:hidden, size:hidden, etc.
50
499
        let parts: Vec<&str> = value.split(':').collect();
51
52
1.00k
        for part in 
parts499
{
53
1.00k
            if part.is_empty() {
  Branch (53:16): [True: 6, False: 908]
+

Coverage Report

Created: 2026-09-01 09:15

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
886
    fn default() -> Self {
35
886
        Self {
36
886
            direction: Direction::Right,
37
886
            size: Size::Percent(50),
38
886
            hidden: false,
39
886
            offset: None,
40
886
            wrap: false,
41
886
            pty: false,
42
886
        }
43
886
    }
44
}
45
46
impl From<&str> for PreviewLayout {
47
494
    fn from(value: &str) -> Self {
48
494
        let mut res: Self = PreviewLayout::default();
49
        // Parse the remainder which can be: size:offset:hidden, offset:hidden, size:hidden, etc.
50
494
        let parts: Vec<&str> = value.split(':').collect();
51
52
993
        for part in 
parts494
{
53
993
            if part.is_empty() {
  Branch (53:16): [True: 5, False: 898]
 
  Branch (53:16): [True: 1, False: 89]
-
54
7
                continue;
55
997
            }
56
57
997
            if part.starts_with('+') {
  Branch (57:16): [True: 7, False: 901]
+
54
6
                continue;
55
987
            }
56
57
987
            if part.starts_with('+') {
  Branch (57:16): [True: 6, False: 892]
 
  Branch (57:16): [True: 6, False: 83]
-
58
13
                // This is an offset expression
59
13
                res.offset = Some(part.to_string());
60
984
            } else if part == "hidden" {
  Branch (60:23): [True: 0, False: 901]
+
58
12
                // This is an offset expression
59
12
                res.offset = Some(part.to_string());
60
975
            } else if part == "hidden" {
  Branch (60:23): [True: 0, False: 892]
 
  Branch (60:23): [True: 4, False: 79]
-
61
4
                res.hidden = true;
62
980
            } else if part == "nohidden" {
  Branch (62:23): [True: 0, False: 901]
+
61
4
                res.hidden = true;
62
971
            } else if part == "nohidden" {
  Branch (62:23): [True: 0, False: 892]
 
  Branch (62:23): [True: 1, False: 78]
-
63
1
                res.hidden = false;
64
979
            } else if part == "wrap" {
  Branch (64:23): [True: 3, False: 898]
+
63
1
                res.hidden = false;
64
970
            } else if part == "wrap" {
  Branch (64:23): [True: 3, False: 889]
 
  Branch (64:23): [True: 1, False: 77]
-
65
4
                res.wrap = true;
66
975
            } else if part == "nowrap" {
  Branch (66:23): [True: 0, False: 898]
+
65
4
                res.wrap = true;
66
966
            } else if part == "nowrap" {
  Branch (66:23): [True: 0, False: 889]
 
  Branch (66:23): [True: 1, False: 76]
-
67
1
                res.wrap = false;
68
974
            } else if part == "pty" {
  Branch (68:23): [True: 15, False: 883]
+
67
1
                res.wrap = false;
68
965
            } else if part == "pty" {
  Branch (68:23): [True: 13, False: 876]
 
  Branch (68:23): [True: 1, False: 75]
-
69
16
                res.pty = true;
70
958
            } else if part == "nopty" {
  Branch (70:23): [True: 0, False: 883]
+
69
14
                res.pty = true;
70
951
            } else if part == "nopty" {
  Branch (70:23): [True: 0, False: 876]
 
  Branch (70:23): [True: 1, False: 74]
-
71
1
                res.pty = false;
72
1
            } else {
73
                // Try to parse as size
74
957
                if let Ok(
size465
) = part.try_into() {
  Branch (74:24): [True: 433, False: 450]
+
71
1
                res.pty = false;
72
1
            } else {
73
                // Try to parse as size
74
950
                if let Ok(
size462
) = part.try_into() {
  Branch (74:24): [True: 430, False: 446]
 
  Branch (74:24): [True: 32, False: 42]
-
75
465
                    res.size = size;
76
492
                }
77
957
                if let Ok(
dir492
) = part.try_into() {
  Branch (77:24): [True: 450, False: 433]
+
75
462
                    res.size = size;
76
488
                }
77
950
                if let Ok(
dir488
) = part.try_into() {
  Branch (77:24): [True: 446, False: 430]
 
  Branch (77:24): [True: 42, False: 32]
-
78
492
                    res.direction = dir;
79
492
                
}465
80
            }
81
        }
82
499
        res
83
499
    }
84
}
85
86
// impl PreviewLayout {
87
88
// }
89
90
#[cfg(test)]
91
#[path = "options_tests.rs"]
92
mod tests;
\ No newline at end of file +
78
488
                    res.direction = dir;
79
488
                
}462
80
            }
81
        }
82
494
        res
83
494
    }
84
}
85
86
// impl PreviewLayout {
87
88
// }
89
90
#[cfg(test)]
91
#[path = "options_tests.rs"]
92
mod tests;
\ No newline at end of file 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 b8cf4aa6..d1dcdf90 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-08-22 15:34

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
539
    fn default() -> Self {
52
539
        PreviewContent::Text(Text::default())
53
539
    }
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-01 09:15

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
534
    fn default() -> Self {
52
534
        PreviewContent::Text(Text::default())
53
534
    }
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]
@@ -6,9 +6,9 @@
 
  Branch (157:36): [True: 2, False: 2]
 
158
                // Result is at most dimension (a u16), so truncation cannot occur.
159
4
                u16::try_from(u32::from(dimension) * u32::from(p) / 100).unwrap_or(u16::MAX)
160
            }
161
4
            super::Size::Neg(n) => {
162
4
                let dimension = if is_vertical { 
self.rows2
} else {
self.cols2
};
  Branch (162:36): [True: 0, False: 0]
 
  Branch (162:36): [True: 2, False: 2]
-
163
4
                dimension.saturating_sub(n)
164
            }
165
        }
166
17
    }
167
168
38
    fn init_pty(&mut self) {
169
38
        let pty_system = native_pty_system();
170
38
        let cols = if self.wrap { 
self.cols2
} else {
102436
};
  Branch (170:23): [True: 2, False: 36]
+
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
38
        let pair = pty_system.openpty(PtySize {
172
38
            rows: self.rows,
173
38
            cols,
174
38
            pixel_width: 0,
175
38
            pixel_height: 0,
176
38
        });
177
38
        match pair {
178
38
            Ok(p) => self.pty = Some(p),
179
0
            Err(e) => warn!("failed to init preview pty: {e:?}"),
180
        }
181
38
    }
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
480
    fn filter_and_respond_to_queries(data: &[u8], writer: &mut Box<dyn std::io::Write + Send>) -> Vec<u8> {
187
480
        let mut result = Vec::new();
188
480
        let mut i = 0;
189
190
18.7k
        while i < data.len() {
  Branch (190:15): [True: 18.2k, False: 479]
+
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
34
    fn filter_and_respond_to_queries(data: &[u8], writer: &mut Box<dyn std::io::Write + Send>) -> Vec<u8> {
187
34
        let mut result = Vec::new();
188
34
        let mut i = 0;
189
190
18.2k
        while i < data.len() {
  Branch (190:15): [True: 18.2k, False: 33]
 
  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,32 +20,32 @@
 
  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
480
        result
222
480
    }
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
34
        result
222
34
    }
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.73k
    pub(crate) fn is_loading(&self) -> bool {
238
1.73k
        self.loading
239
1.73k
    }
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]
+
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.72k
    pub(crate) fn is_loading(&self) -> bool {
238
1.72k
        self.loading
239
1.72k
    }
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]
-
268
2
            self.scroll_y = self
269
2
                .scroll_y
270
2
                .saturating_add(lines)
271
2
                .min(self.total_lines.saturating_sub(self.rows.saturating_sub(1)));
272
6
        } else {
273
6
            // We might not have the actual total_lines value
274
6
            self.scroll_y = self.scroll_y.saturating_add(lines);
275
6
        }
276
8
    }
277
278
3
    pub fn scroll_left(&mut self, cols: u16) {
279
3
        self.scroll_x = self.scroll_x.saturating_sub(cols);
280
3
    }
281
282
2
    pub fn scroll_right(&mut self, cols: u16) {
283
2
        self.scroll_x = self.scroll_x.saturating_add(cols);
284
2
    }
285
286
8
    pub fn set_offset(&mut self, offset: u16) {
287
8
        self.scroll_y = offset.saturating_sub(1); // -1 because line numbers are 1-indexed
288
8
    }
289
290
2
    pub fn page_up(&mut self) {
291
2
        let page_size = self.rows.saturating_sub(2); // Account for borders
292
2
        self.scroll_up(page_size);
293
2
    }
294
295
2
    pub fn page_down(&mut self) {
296
2
        let page_size = self.rows.saturating_sub(2); // Account for borders
297
2
        self.scroll_down(page_size);
298
2
    }
299
    /// Kill the preview child process and interrupt the reader thread.
300
614
    pub fn kill(&mut self) {
301
614
        if let Some(
tx71
) = self.interrupt_tx.take() {
  Branch (301:16): [True: 68, False: 375]
+
268
2
            self.scroll_y = self
269
2
                .scroll_y
270
2
                .saturating_add(lines)
271
2
                .min(self.total_lines.saturating_sub(self.rows.saturating_sub(1)));
272
6
        } else {
273
6
            // We might not have the actual total_lines value
274
6
            self.scroll_y = self.scroll_y.saturating_add(lines);
275
6
        }
276
8
    }
277
278
3
    pub fn scroll_left(&mut self, cols: u16) {
279
3
        self.scroll_x = self.scroll_x.saturating_sub(cols);
280
3
    }
281
282
2
    pub fn scroll_right(&mut self, cols: u16) {
283
2
        self.scroll_x = self.scroll_x.saturating_add(cols);
284
2
    }
285
286
8
    pub fn set_offset(&mut self, offset: u16) {
287
8
        self.scroll_y = offset.saturating_sub(1); // -1 because line numbers are 1-indexed
288
8
    }
289
290
2
    pub fn page_up(&mut self) {
291
2
        let page_size = self.rows.saturating_sub(2); // Account for borders
292
2
        self.scroll_up(page_size);
293
2
    }
294
295
2
    pub fn page_down(&mut self) {
296
2
        let page_size = self.rows.saturating_sub(2); // Account for borders
297
2
        self.scroll_down(page_size);
298
2
    }
299
    /// Kill the preview child process and interrupt the reader thread.
300
609
    pub fn kill(&mut self) {
301
609
        if let Some(
tx71
) = self.interrupt_tx.take() {
  Branch (301:16): [True: 68, False: 370]
 
  Branch (301:16): [True: 3, False: 168]
-
302
71
            let _ = tx.send(());
303
543
        }
304
305
614
        if let Some(
mut child23
) = self.pty_child.take() {
  Branch (305:16): [True: 23, False: 420]
+
302
71
            let _ = tx.send(());
303
538
        }
304
305
609
        if let Some(
mut child23
) = self.pty_child.take() {
  Branch (305:16): [True: 23, False: 415]
 
  Branch (305:16): [True: 0, False: 171]
 
306
23
            trace!("killing pty child process");
307
23
            match child.try_wait() {
308
23
                Ok(Some(status)) => {
309
23
                    trace!("child already exited with status: {status:?}");
310
                }
311
                Ok(None) => {
312
0
                    trace!("child still running, sending kill signal");
313
0
                    if let Err(e) = child.kill() {
  Branch (313:28): [True: 0, False: 0]
 
  Branch (313:28): [True: 0, False: 0]
-
314
0
                        trace!("failed to kill pty child: {e:?}");
315
0
                    }
316
                }
317
0
                Err(e) => {
318
0
                    debug!("error checking child status: {e:?}");
319
0
                    let _ = child.kill();
320
                }
321
            }
322
591
        }
323
614
    }
324
325
    #[allow(clippy::too_many_lines)]
326
71
    pub fn spawn<B: Backend>(&mut self, tui: &mut Tui<B>, cmd: &str) -> Result<()>
327
71
    where
328
71
        B::Error: Send + Sync + 'static,
329
    {
330
71
        self.kill();
331
71
        self.cmd = cmd.to_string();
332
71
        self.loading = true;
333
334
        // Reset scroll position and manual_scroll flag for new preview
335
71
        self.scroll_y = 0;
336
71
        self.scroll_x = 0;
337
338
71
        let event_tx_clone = tui.event_tx.clone();
339
71
        let content = self.content.clone();
340
341
        #[cfg(feature = "image")]
342
71
        if self.image {
  Branch (342:12): [True: 0, False: 0]
-
  Branch (342:12): [True: 0, False: 0]
-
  Branch (342:12): [True: 0, False: 0]
-
  Branch (342:12): [True: 0, False: 0]
+
314
0
                        trace!("failed to kill pty child: {e:?}");
315
0
                    }
316
                }
317
0
                Err(e) => {
318
0
                    debug!("error checking child status: {e:?}");
319
0
                    let _ = child.kill();
320
                }
321
            }
322
586
        }
323
609
    }
324
325
    #[allow(clippy::too_many_lines)]
326
71
    pub fn spawn<B: Backend>(&mut self, tui: &mut Tui<B>, cmd: &str) -> Result<()>
327
71
    where
328
71
        B::Error: Send + Sync + 'static,
329
    {
330
71
        self.kill();
331
71
        self.cmd = cmd.to_string();
332
71
        self.loading = true;
333
334
        // Reset scroll position and manual_scroll flag for new preview
335
71
        self.scroll_y = 0;
336
71
        self.scroll_x = 0;
337
338
71
        let event_tx_clone = tui.event_tx.clone();
339
71
        let content = self.content.clone();
340
341
        #[cfg(feature = "image")]
342
71
        if self.image {
  Branch (342:12): [True: 0, False: 0]
 
  Branch (342:12): [True: 0, False: 5]
 
  Branch (342:12): [True: 0, False: 0]
 
  Branch (342:12): [True: 0, False: 0]
-
  Branch (342:12): [True: 0, False: 3]
 
  Branch (342:12): [True: 0, False: 0]
 
  Branch (342:12): [True: 0, False: 0]
 
  Branch (342:12): [True: 0, False: 2]
+
  Branch (342:12): [True: 0, False: 3]
+
  Branch (342:12): [True: 0, False: 0]
+
  Branch (342:12): [True: 0, False: 0]
 
  Branch (342:12): [True: 6, False: 50]
 
  Branch (342:12): [True: 0, False: 0]
 
  Branch (342:12): [True: 0, False: 0]
 
  Branch (342:12): [True: 0, False: 0]
 
  Branch (342:12): [True: 0, False: 0]
+
  Branch (342:12): [True: 0, False: 0]
 
  Branch (342:12): [True: 0, False: 5]
 
  Branch (342:12): [True: 0, False: 0]
 
  Branch (342:12): [True: 0, False: 0]
@@ -59,7 +59,6 @@
 
  Branch (350:24): [True: 0, False: 0]
 
  Branch (350:24): [True: 0, False: 0]
 
  Branch (350:24): [True: 0, False: 0]
-
  Branch (350:24): [True: 0, False: 0]
 
  Branch (350:24): [True: 4, False: 2]
 
  Branch (350:24): [True: 0, False: 0]
 
  Branch (350:24): [True: 0, False: 0]
@@ -68,6 +67,7 @@
 
  Branch (350:24): [True: 0, False: 0]
 
  Branch (350:24): [True: 0, False: 0]
 
  Branch (350:24): [True: 0, False: 0]
+
  Branch (350:24): [True: 0, False: 0]
 
351
4
                    res = PreviewContent::Image {
352
4
                        source: decoded,
353
4
                        protocol: None,
354
4
                        size: ratatui::layout::Size::default(),
355
4
                    };
356
4
                } else {
357
2
                    res = PreviewContent::Text(Text::raw(format!("Failed to open {cmd} as image")));
358
2
                }
359
360
6
                if interrupt_rx.try_recv().is_ok() {
  Branch (360:20): [True: 0, False: 0]
 
  Branch (360:20): [True: 0, False: 0]
 
  Branch (360:20): [True: 0, False: 0]
@@ -78,7 +78,6 @@
 
  Branch (360:20): [True: 0, False: 0]
 
  Branch (360:20): [True: 0, False: 0]
 
  Branch (360:20): [True: 0, False: 0]
-
  Branch (360:20): [True: 0, False: 0]
 
  Branch (360:20): [True: 0, False: 6]
 
  Branch (360:20): [True: 0, False: 0]
 
  Branch (360:20): [True: 0, False: 0]
@@ -87,6 +86,7 @@
 
  Branch (360:20): [True: 0, False: 0]
 
  Branch (360:20): [True: 0, False: 0]
 
  Branch (360:20): [True: 0, False: 0]
+
  Branch (360:20): [True: 0, False: 0]
 
361
0
                    trace!("interrupt signal received, exiting");
362
0
                    return;
363
6
                }
364
365
6
                if let Ok(mut c) = content.write() {
  Branch (365:24): [True: 0, False: 0]
 
  Branch (365:24): [True: 0, False: 0]
 
  Branch (365:24): [True: 0, False: 0]
@@ -97,7 +97,6 @@
 
  Branch (365:24): [True: 0, False: 0]
 
  Branch (365:24): [True: 0, False: 0]
 
  Branch (365:24): [True: 0, False: 0]
-
  Branch (365:24): [True: 0, False: 0]
 
  Branch (365:24): [True: 6, False: 0]
 
  Branch (365:24): [True: 0, False: 0]
 
  Branch (365:24): [True: 0, False: 0]
@@ -106,22 +105,23 @@
 
  Branch (365:24): [True: 0, False: 0]
 
  Branch (365:24): [True: 0, False: 0]
 
  Branch (365:24): [True: 0, False: 0]
+
  Branch (365:24): [True: 0, False: 0]
 
366
6
                    *c = res;
367
6
                    let _ = event_tx_clone.blocking_send(Event::PreviewReady);
368
6
                
}0
369
6
            }));
370
371
6
            return Ok(());
372
65
        }
373
374
65
        if let Some(
pty23
) = self.pty.take() {
  Branch (374:16): [True: 0, False: 0]
-
  Branch (374:16): [True: 0, False: 0]
-
  Branch (374:16): [True: 0, False: 0]
-
  Branch (374:16): [True: 0, False: 0]
 
  Branch (374:16): [True: 0, False: 5]
 
  Branch (374:16): [True: 0, False: 0]
 
  Branch (374:16): [True: 0, False: 0]
-
  Branch (374:16): [True: 0, False: 3]
 
  Branch (374:16): [True: 0, False: 0]
 
  Branch (374:16): [True: 0, False: 0]
 
  Branch (374:16): [True: 0, False: 2]
+
  Branch (374:16): [True: 0, False: 3]
+
  Branch (374:16): [True: 0, False: 0]
+
  Branch (374:16): [True: 0, False: 0]
 
  Branch (374:16): [True: 23, False: 27]
 
  Branch (374:16): [True: 0, False: 0]
 
  Branch (374:16): [True: 0, False: 0]
 
  Branch (374:16): [True: 0, False: 0]
 
  Branch (374:16): [True: 0, False: 0]
+
  Branch (374:16): [True: 0, False: 0]
 
  Branch (374:16): [True: 0, False: 5]
 
  Branch (374:16): [True: 0, False: 0]
 
  Branch (374:16): [True: 0, False: 0]
@@ -145,8 +145,6 @@
   Branch (380:33): [True: 0, False: 0]
 
  Branch (380:16): [True: 0, False: 0]
   Branch (380:33): [True: 0, False: 0]
-
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
 
  Branch (380:16): [True: 23, False: 0]
   Branch (380:33): [True: 23, False: 0]
 
  Branch (380:16): [True: 0, False: 0]
@@ -163,6 +161,8 @@
   Branch (380:33): [True: 0, False: 0]
 
  Branch (380:16): [True: 0, False: 0]
   Branch (380:33): [True: 0, False: 0]
+
  Branch (380:16): [True: 0, False: 0]
+  Branch (380:33): [True: 0, False: 0]
 
381
23
                let _ = pty.master.resize(PtySize {
382
23
                    rows: self.rows,
383
23
                    cols: self.cols,
384
23
                    pixel_width: 0,
385
23
                    pixel_height: 0,
386
23
                });
387
23
            
}0
388
23
            trace!(
389
                "spawning preview cmd {cmd} in pty (rows={}, cols={})",
390
                self.rows, self.cols
391
            );
392
23
            self.init_pty();
393
23
            trace!("initialized pty");
394
            // no PTY on windows, we can keep sh
395
23
            let mut shell_cmd = portable_pty::CommandBuilder::new("/bin/sh");
396
23
            shell_cmd.env("ROWS", self.rows.to_string());
397
23
            shell_cmd.env("COLUMNS", self.cols.to_string());
398
23
            shell_cmd.env("PAGER", "");
399
23
            shell_cmd.arg("-c");
400
23
            if let Ok(cwd) = env::current_dir() {
  Branch (400:20): [True: 0, False: 0]
 
  Branch (400:20): [True: 0, False: 0]
 
  Branch (400:20): [True: 0, False: 0]
@@ -173,7 +173,6 @@
 
  Branch (400:20): [True: 0, False: 0]
 
  Branch (400:20): [True: 0, False: 0]
 
  Branch (400:20): [True: 0, False: 0]
-
  Branch (400:20): [True: 0, False: 0]
 
  Branch (400:20): [True: 23, False: 0]
 
  Branch (400:20): [True: 0, False: 0]
 
  Branch (400:20): [True: 0, False: 0]
@@ -182,6 +181,7 @@
 
  Branch (400:20): [True: 0, False: 0]
 
  Branch (400:20): [True: 0, False: 0]
 
  Branch (400:20): [True: 0, False: 0]
+
  Branch (400:20): [True: 0, False: 0]
 
401
23
                shell_cmd.cwd(cwd);
402
23
            
}0
403
23
            shell_cmd.arg(cmd);
404
23
            self.pty_child = Some(pty.slave.spawn_command(shell_cmd).map_err(|e| 
{0
405
0
                warn!("{:#?}", e.backtrace());
406
0
                eyre!(Box::<dyn std::error::Error + Send + Sync + 'static>::from(e))
407
0
            })?);
408
409
23
            let mut reader = pty
410
23
                .master
411
23
                .try_clone_reader()
412
23
                .map_err(|e| 
eyre!0
(
Box::<dyn std::error::Error + Send + Sync + 'static>::from(e)0
))
?0
;
413
414
            // Get a writer to respond to terminal queries
415
23
            let mut esc_writer = pty
416
23
                .master
417
23
                .take_writer()
418
23
                .map_err(|e| 
eyre!0
(
Box::<dyn std::error::Error + Send + Sync + 'static>::from(e)0
))
?0
;
419
420
23
            let (interrupt_tx, interrupt_rx) = mpsc::channel();
421
23
            self.interrupt_tx = Some(interrupt_tx);
422
423
            // Create vt100 parser for PTY output with large scrollback buffer
424
23
            let cols = if self.wrap { 
self.cols1
} else {
102422
};
  Branch (424:27): [True: 0, False: 0]
 
  Branch (424:27): [True: 0, False: 0]
 
  Branch (424:27): [True: 0, False: 0]
@@ -192,7 +192,6 @@
 
  Branch (424:27): [True: 0, False: 0]
 
  Branch (424:27): [True: 0, False: 0]
 
  Branch (424:27): [True: 0, False: 0]
-
  Branch (424:27): [True: 0, False: 0]
 
  Branch (424:27): [True: 1, False: 22]
 
  Branch (424:27): [True: 0, False: 0]
 
  Branch (424:27): [True: 0, False: 0]
@@ -201,6 +200,7 @@
 
  Branch (424:27): [True: 0, False: 0]
 
  Branch (424:27): [True: 0, False: 0]
 
  Branch (424:27): [True: 0, False: 0]
+
  Branch (424:27): [True: 0, False: 0]
 
425
23
            let parser = Arc::new(RwLock::new(vt100::Parser::new(self.rows, cols, VT_SCROLLBACK)));
426
427
            // Update content to use the parser
428
23
            if let Ok(mut c) = content.write() {
  Branch (428:20): [True: 0, False: 0]
 
  Branch (428:20): [True: 0, False: 0]
 
  Branch (428:20): [True: 0, False: 0]
@@ -211,7 +211,6 @@
 
  Branch (428:20): [True: 0, False: 0]
 
  Branch (428:20): [True: 0, False: 0]
 
  Branch (428:20): [True: 0, False: 0]
-
  Branch (428:20): [True: 0, False: 0]
 
  Branch (428:20): [True: 23, False: 0]
 
  Branch (428:20): [True: 0, False: 0]
 
  Branch (428:20): [True: 0, False: 0]
@@ -220,7 +219,8 @@
 
  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
502
                    if interrupt_rx.try_recv().is_ok() {
  Branch (439:24): [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
56
                    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]
@@ -230,8 +230,7 @@
 
  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: 502]
+
  Branch (439:24): [True: 0, False: 56]
 
  Branch (439:24): [True: 0, False: 0]
 
  Branch (439:24): [True: 0, False: 0]
 
  Branch (439:24): [True: 0, False: 0]
@@ -239,7 +238,8 @@
 
  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
502
                    }
443
444
502
                    match reader.read(&mut buf) {
445
                        Ok(0) => {
446
23
                            trace!("reached EOF");
447
23
                            break;
448
                        }
449
479
                        Ok(size) => {
450
479
                            trace!("read {size} bytes");
451
479
                            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
479
                            let filtered = Self::filter_and_respond_to_queries(&unprocessed_buf, &mut esc_writer);
456
457
479
                            if let Ok(mut parser_guard) = parser.write() {
  Branch (457:36): [True: 0, False: 0]
+
  Branch (439:24): [True: 0, False: 0]
+
440
0
                        trace!("interrupt signal received, exiting");
441
0
                        return;
442
56
                    }
443
444
56
                    match reader.read(&mut buf) {
445
                        Ok(0) => {
446
23
                            trace!("reached EOF");
447
23
                            break;
448
                        }
449
33
                        Ok(size) => {
450
33
                            trace!("read {size} bytes");
451
33
                            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
33
                            let filtered = Self::filter_and_respond_to_queries(&unprocessed_buf, &mut esc_writer);
456
457
33
                            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]
@@ -249,8 +249,7 @@
 
  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: 479, False: 0]
+
  Branch (457:36): [True: 33, False: 0]
 
  Branch (457:36): [True: 0, False: 0]
 
  Branch (457:36): [True: 0, False: 0]
 
  Branch (457:36): [True: 0, False: 0]
@@ -258,7 +257,8 @@
 
  Branch (457:36): [True: 0, False: 0]
 
  Branch (457:36): [True: 0, False: 0]
 
  Branch (457:36): [True: 0, False: 0]
-
458
479
                                parser_guard.process(&filtered);
459
479
                                parser_guard.screen_mut().set_scrollback(VT_SCROLLBACK);
460
479
                            
}0
461
462
                            // Clear the processed portion of the buffer
463
479
                            unprocessed_buf.clear();
464
465
                            // Check interrupt after processing
466
479
                            if interrupt_rx.try_recv().is_ok() {
  Branch (466:32): [True: 0, False: 0]
+
  Branch (457:36): [True: 0, False: 0]
+
458
33
                                parser_guard.process(&filtered);
459
33
                                parser_guard.screen_mut().set_scrollback(VT_SCROLLBACK);
460
33
                            
}0
461
462
                            // Clear the processed portion of the buffer
463
33
                            unprocessed_buf.clear();
464
465
                            // Check interrupt after processing
466
33
                            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]
@@ -268,8 +268,7 @@
 
  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: 479]
+
  Branch (466:32): [True: 0, False: 33]
 
  Branch (466:32): [True: 0, False: 0]
 
  Branch (466:32): [True: 0, False: 0]
 
  Branch (466:32): [True: 0, False: 0]
@@ -277,8 +276,8 @@
 
  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
479
                            }
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 (466:32): [True: 0, False: 0]
+
467
0
                                trace!("interrupt signal received during read block, exiting");
468
0
                                return;
469
33
                            }
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]
@@ -296,127 +295,128 @@
 
  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]
 
480
0
                    trace!("interrupt signal received, exiting");
481
0
                    return;
482
23
                }
483
484
23
                trace!("read complete");
485
23
                let _ = event_tx_clone.blocking_send(Event::PreviewReady);
486
23
            }));
487
        } else {
488
42
            trace!("spawning preview cmd {cmd}");
489
42
            let mut shell_cmd = crate::shell_cmd(cmd);
490
42
            shell_cmd
491
42
                .env("ROWS", self.rows.to_string())
492
42
                .env("COLUMNS", self.cols.to_string())
493
42
                .env("PAGER", "");
494
42
            if let Ok(cwd) = env::current_dir() {
  Branch (494:20): [True: 0, False: 0]
-
  Branch (494:20): [True: 0, False: 0]
-
  Branch (494:20): [True: 0, False: 0]
-
  Branch (494:20): [True: 0, False: 0]
 
  Branch (494:20): [True: 5, False: 0]
 
  Branch (494:20): [True: 0, False: 0]
 
  Branch (494:20): [True: 0, False: 0]
-
  Branch (494:20): [True: 3, False: 0]
 
  Branch (494:20): [True: 0, False: 0]
 
  Branch (494:20): [True: 0, False: 0]
 
  Branch (494:20): [True: 2, False: 0]
+
  Branch (494:20): [True: 3, False: 0]
+
  Branch (494:20): [True: 0, False: 0]
+
  Branch (494:20): [True: 0, False: 0]
 
  Branch (494:20): [True: 27, False: 0]
 
  Branch (494:20): [True: 0, False: 0]
 
  Branch (494:20): [True: 0, False: 0]
 
  Branch (494:20): [True: 0, False: 0]
 
  Branch (494:20): [True: 0, False: 0]
+
  Branch (494:20): [True: 0, False: 0]
 
  Branch (494:20): [True: 5, False: 0]
 
  Branch (494:20): [True: 0, False: 0]
 
  Branch (494:20): [True: 0, False: 0]
 
495
42
                shell_cmd.current_dir(cwd);
496
42
            
}0
497
498
42
            let (interrupt_tx, interrupt_rx) = mpsc::channel();
499
42
            self.interrupt_tx = Some(interrupt_tx);
500
501
42
            self.thread_handle = Some(std::thread::spawn(move || {
502
42
                if interrupt_rx.try_recv().is_ok() {
  Branch (502:20): [True: 0, False: 0]
-
  Branch (502:20): [True: 0, False: 0]
-
  Branch (502:20): [True: 0, False: 0]
-
  Branch (502:20): [True: 0, False: 0]
 
  Branch (502:20): [True: 0, False: 5]
 
  Branch (502:20): [True: 0, False: 0]
 
  Branch (502:20): [True: 0, False: 0]
-
  Branch (502:20): [True: 3, False: 0]
 
  Branch (502:20): [True: 0, False: 0]
 
  Branch (502:20): [True: 0, False: 0]
 
  Branch (502:20): [True: 0, False: 2]
+
  Branch (502:20): [True: 3, False: 0]
+
  Branch (502:20): [True: 0, False: 0]
+
  Branch (502:20): [True: 0, False: 0]
 
  Branch (502:20): [True: 0, False: 27]
 
  Branch (502:20): [True: 0, False: 0]
 
  Branch (502:20): [True: 0, False: 0]
 
  Branch (502:20): [True: 0, False: 0]
 
  Branch (502:20): [True: 0, False: 0]
+
  Branch (502:20): [True: 0, False: 0]
 
  Branch (502:20): [True: 0, False: 5]
 
  Branch (502:20): [True: 0, False: 0]
 
  Branch (502:20): [True: 0, False: 0]
 
503
3
                    return;
504
39
                }
505
506
39
                let try_out = shell_cmd.output();
507
39
                if try_out.is_err() {
  Branch (507:20): [True: 0, False: 0]
-
  Branch (507:20): [True: 0, False: 0]
-
  Branch (507:20): [True: 0, False: 0]
-
  Branch (507:20): [True: 0, False: 0]
 
  Branch (507:20): [True: 0, False: 5]
 
  Branch (507:20): [True: 0, False: 0]
 
  Branch (507:20): [True: 0, False: 0]
 
  Branch (507:20): [True: 0, False: 0]
 
  Branch (507:20): [True: 0, False: 0]
-
  Branch (507:20): [True: 0, False: 0]
 
  Branch (507:20): [True: 0, False: 2]
+
  Branch (507:20): [True: 0, False: 0]
+
  Branch (507:20): [True: 0, False: 0]
+
  Branch (507:20): [True: 0, False: 0]
 
  Branch (507:20): [True: 0, False: 27]
 
  Branch (507:20): [True: 0, False: 0]
 
  Branch (507:20): [True: 0, False: 0]
 
  Branch (507:20): [True: 0, False: 0]
 
  Branch (507:20): [True: 0, False: 0]
+
  Branch (507:20): [True: 0, False: 0]
 
  Branch (507:20): [True: 0, False: 5]
 
  Branch (507:20): [True: 0, False: 0]
 
  Branch (507:20): [True: 0, False: 0]
 
508
0
                    log::info!("Shell cmd in error: {try_out:?}");
509
0
                    let _ = event_tx_clone.blocking_send(Event::PreviewReady);
510
0
                    return;
511
39
                }
512
513
39
                let mut out = try_out.unwrap();
514
515
39
                if interrupt_rx.try_recv().is_ok() {
  Branch (515:20): [True: 0, False: 0]
-
  Branch (515:20): [True: 0, False: 0]
-
  Branch (515:20): [True: 0, False: 0]
-
  Branch (515:20): [True: 0, False: 0]
 
  Branch (515:20): [True: 0, False: 5]
 
  Branch (515:20): [True: 0, False: 0]
 
  Branch (515:20): [True: 0, False: 0]
 
  Branch (515:20): [True: 0, False: 0]
 
  Branch (515:20): [True: 0, False: 0]
-
  Branch (515:20): [True: 0, False: 0]
 
  Branch (515:20): [True: 0, False: 2]
+
  Branch (515:20): [True: 0, False: 0]
+
  Branch (515:20): [True: 0, False: 0]
+
  Branch (515:20): [True: 0, False: 0]
 
  Branch (515:20): [True: 0, False: 27]
 
  Branch (515:20): [True: 0, False: 0]
 
  Branch (515:20): [True: 0, False: 0]
 
  Branch (515:20): [True: 0, False: 0]
 
  Branch (515:20): [True: 0, False: 0]
+
  Branch (515:20): [True: 0, False: 0]
 
  Branch (515:20): [True: 0, False: 5]
 
  Branch (515:20): [True: 0, False: 0]
 
  Branch (515:20): [True: 0, False: 0]
 
516
0
                    return;
517
39
                }
518
519
39
                if let Ok(mut c) = content.write() {
  Branch (519:24): [True: 0, False: 0]
-
  Branch (519:24): [True: 0, False: 0]
-
  Branch (519:24): [True: 0, False: 0]
-
  Branch (519:24): [True: 0, False: 0]
 
  Branch (519:24): [True: 5, False: 0]
 
  Branch (519:24): [True: 0, False: 0]
 
  Branch (519:24): [True: 0, False: 0]
 
  Branch (519:24): [True: 0, False: 0]
 
  Branch (519:24): [True: 0, False: 0]
-
  Branch (519:24): [True: 0, False: 0]
 
  Branch (519:24): [True: 2, False: 0]
+
  Branch (519:24): [True: 0, False: 0]
+
  Branch (519:24): [True: 0, False: 0]
+
  Branch (519:24): [True: 0, False: 0]
 
  Branch (519:24): [True: 27, False: 0]
 
  Branch (519:24): [True: 0, False: 0]
 
  Branch (519:24): [True: 0, False: 0]
 
  Branch (519:24): [True: 0, False: 0]
 
  Branch (519:24): [True: 0, False: 0]
+
  Branch (519:24): [True: 0, False: 0]
 
  Branch (519:24): [True: 5, False: 0]
 
  Branch (519:24): [True: 0, False: 0]
 
  Branch (519:24): [True: 0, False: 0]
 
520
39
                    if out.status.success() {
  Branch (520:24): [True: 0, False: 0]
-
  Branch (520:24): [True: 0, False: 0]
-
  Branch (520:24): [True: 0, False: 0]
-
  Branch (520:24): [True: 0, False: 0]
 
  Branch (520:24): [True: 5, False: 0]
 
  Branch (520:24): [True: 0, False: 0]
 
  Branch (520:24): [True: 0, False: 0]
 
  Branch (520:24): [True: 0, False: 0]
 
  Branch (520:24): [True: 0, False: 0]
-
  Branch (520:24): [True: 0, False: 0]
 
  Branch (520:24): [True: 2, False: 0]
+
  Branch (520:24): [True: 0, False: 0]
+
  Branch (520:24): [True: 0, False: 0]
+
  Branch (520:24): [True: 0, False: 0]
 
  Branch (520:24): [True: 27, False: 0]
 
  Branch (520:24): [True: 0, False: 0]
 
  Branch (520:24): [True: 0, False: 0]
 
  Branch (520:24): [True: 0, False: 0]
 
  Branch (520:24): [True: 0, False: 0]
+
  Branch (520:24): [True: 0, False: 0]
 
  Branch (520:24): [True: 5, False: 0]
 
  Branch (520:24): [True: 0, False: 0]
 
  Branch (520:24): [True: 0, False: 0]
-
521
39
                        out.stdout.resize(PREVIEW_MAX_BYTES.min(out.stdout.len()), 0);
522
39
                        *c = PreviewContent::Text(out.stdout.into_text().unwrap_or_default());
523
39
                    } else {
524
0
                        *c = PreviewContent::Text(out.stderr.clone().into_text().unwrap_or_default());
525
0
                    }
526
0
                }
527
528
39
                trace!("sending ready ping");
529
39
                let _ = event_tx_clone.blocking_send(Event::PreviewReady);
530
42
            }));
531
        }
532
65
        Ok(())
533
71
    }
534
535
173
    fn render_text(
536
173
        &self,
537
173
        mut outer: Block,
538
173
        area: ratatui::layout::Rect,
539
173
        buf: &mut ratatui::prelude::Buffer,
540
173
        text: &Text,
541
173
    ) -> u16 {
542
        // Calculate total lines in content
543
173
        let total_lines: u16 = text.lines.len().try_into().unwrap();
544
545
        // Create paragraph with optional block
546
173
        let mut paragraph = Paragraph::new(text.clone()).scroll((self.scroll_y, self.scroll_x));
547
548
        // Enable wrapping if wrap is true
549
173
        if self.wrap {
  Branch (549:12): [True: 10, False: 161]
+
521
39
                        out.stdout.resize(PREVIEW_MAX_BYTES.min(out.stdout.len()), 0);
522
39
                        *c = PreviewContent::Text(out.stdout.into_text().unwrap_or_default());
523
39
                    } else {
524
0
                        *c = PreviewContent::Text(out.stderr.clone().into_text().unwrap_or_default());
525
0
                    }
526
0
                }
527
528
39
                trace!("sending ready ping");
529
39
                let _ = event_tx_clone.blocking_send(Event::PreviewReady);
530
42
            }));
531
        }
532
65
        Ok(())
533
71
    }
534
535
168
    fn render_text(
536
168
        &self,
537
168
        mut outer: Block,
538
168
        area: ratatui::layout::Rect,
539
168
        buf: &mut ratatui::prelude::Buffer,
540
168
        text: &Text,
541
168
    ) -> u16 {
542
        // Calculate total lines in content
543
168
        let total_lines: u16 = text.lines.len().try_into().unwrap();
544
545
        // Create paragraph with optional block
546
168
        let mut paragraph = Paragraph::new(text.clone()).scroll((self.scroll_y, self.scroll_x));
547
548
        // Enable wrapping if wrap is true
549
168
        if self.wrap {
  Branch (549:12): [True: 10, False: 156]
 
  Branch (549:12): [True: 0, False: 2]
-
550
10
            paragraph = paragraph.wrap(ratatui::widgets::Wrap { trim: false });
551
163
        }
552
553
        // Add scroll position indicator at top-right if scrolled
554
173
        if self.scroll_y > 0 && 
total_lines > 06
{
  Branch (554:12): [True: 6, False: 165]
+
550
10
            paragraph = paragraph.wrap(ratatui::widgets::Wrap { trim: false });
551
158
        }
552
553
        // Add scroll position indicator at top-right if scrolled
554
168
        if self.scroll_y > 0 && 
total_lines > 06
{
  Branch (554:12): [True: 6, False: 160]
   Branch (554:33): [True: 6, False: 0]
 
  Branch (554:12): [True: 0, False: 2]
   Branch (554:33): [True: 0, False: 0]
-
555
6
            let current_line = (self.scroll_y + 1) as usize; // +1 because scroll_y is 0-indexed but we want 1-indexed display
556
6
            let title = format!("{current_line}/{total_lines}");
557
6
558
6
            outer = outer.title_top(Line::from(title).alignment(Alignment::Right).reversed());
559
167
        }
560
561
173
        paragraph = paragraph.block(outer);
562
173
        paragraph.render(area, buf);
563
173
        total_lines
564
173
    }
565
566
48
    fn render_pty(
567
48
        &self,
568
48
        mut outer: Block,
569
48
        area: ratatui::layout::Rect,
570
48
        buf: &mut ratatui::prelude::Buffer,
571
48
        parser: &std::sync::RwLock<tui_term::vt100::Parser>,
572
48
    ) -> u16 {
573
48
        let mut total_lines = 0u16;
574
        // For terminal content, manipulate scrollback to implement scrolling
575
48
        if let Ok(mut parser_guard) = parser.try_write() {
  Branch (575:16): [True: 48, False: 0]
+
555
6
            let current_line = (self.scroll_y + 1) as usize; // +1 because scroll_y is 0-indexed but we want 1-indexed display
556
6
            let title = format!("{current_line}/{total_lines}");
557
6
558
6
            outer = outer.title_top(Line::from(title).alignment(Alignment::Right).reversed());
559
162
        }
560
561
168
        paragraph = paragraph.block(outer);
562
168
        paragraph.render(area, buf);
563
168
        total_lines
564
168
    }
565
566
48
    fn render_pty(
567
48
        &self,
568
48
        mut outer: Block,
569
48
        area: ratatui::layout::Rect,
570
48
        buf: &mut ratatui::prelude::Buffer,
571
48
        parser: &std::sync::RwLock<tui_term::vt100::Parser>,
572
48
    ) -> u16 {
573
48
        let mut total_lines = 0u16;
574
        // For terminal content, manipulate scrollback to implement scrolling
575
48
        if let Ok(mut parser_guard) = parser.try_write() {
  Branch (575:16): [True: 48, False: 0]
 
  Branch (575:16): [True: 0, False: 0]
 
576
48
            let scrollback_len = parser_guard.screen().scrollback();
577
            // Reset scrollback to its full size first
578
48
            parser_guard.screen_mut().set_scrollback(VT_SCROLLBACK);
579
            // If the scrollback is not empty, we seem to be off by one
580
48
            total_lines = (scrollback_len.saturating_sub(1) + parser_guard.screen().contents().lines().count())
581
48
                .try_into()
582
48
                .unwrap();
583
48
            if self.scroll_y > 0 {
  Branch (583:16): [True: 6, False: 42]
 
  Branch (583:16): [True: 0, False: 0]
@@ -440,22 +440,22 @@
   Branch (633:84): [True: 0, False: 0]
 
634
4
            match Self::image_protocol(self.image_picker.as_ref(), source.clone(), image_size) {
635
4
                Ok(new_protocol) => {
636
4
                    *protocol = Some(new_protocol);
637
4
                    *size = image_size;
638
4
                }
639
0
                Err(err) => {
640
0
                    warn!("failed to render image preview: {err:?}");
641
                }
642
            }
643
3
        }
644
7
        if let Some(protocol) = protocol {
  Branch (644:16): [True: 7, False: 0]
 
  Branch (644:16): [True: 0, False: 0]
-
645
7
            let image = ratatui_image::Image::new(protocol).allow_clipping(true);
646
7
            image.render(inner, buf);
647
7
        
}0
648
7
    }
649
}
650
651
impl Drop for Preview {
652
539
    fn drop(&mut self) {
653
539
        if let Some(
pty15
) = self.pty.take() {
  Branch (653:16): [True: 15, False: 360]
+
645
7
            let image = ratatui_image::Image::new(protocol).allow_clipping(true);
646
7
            image.render(inner, buf);
647
7
        
}0
648
7
    }
649
}
650
651
impl Drop for Preview {
652
534
    fn drop(&mut self) {
653
534
        if let Some(
pty13
) = self.pty.take() {
  Branch (653:16): [True: 13, False: 357]
 
  Branch (653:16): [True: 0, False: 164]
-
654
15
            let w = pty.master.take_writer();
655
15
            drop(w);
656
524
        }
657
539
        self.kill();
658
539
    }
659
}
660
661
impl SkimWidget for Preview {
662
539
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
663
        #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
664
539
        let mut res = Self {
665
539
            theme,
666
539
            border: options.border,
667
539
            direction: options.preview_window.direction,
668
539
            wrap: options.preview_window.wrap,
669
539
            content: Arc::new(RwLock::new(PreviewContent::default())),
670
539
            cmd: Default::default(),
671
539
            rows: 0,
672
539
            cols: 0,
673
539
            scroll_y: 0,
674
539
            scroll_x: 0,
675
539
            thread_handle: None,
676
539
            interrupt_tx: None,
677
539
            pty: None,
678
539
            pty_child: None,
679
539
            #[cfg(feature = "image")]
680
539
            image: options.image.is_some(),
681
539
            #[cfg(feature = "image")]
682
539
            image_picker: options.image_picker.clone(),
683
539
            total_lines: 0,
684
539
            loading: false,
685
539
            spinner_start: Instant::now(),
686
539
        };
687
        #[cfg(target_os = "linux")]
688
539
        if options.preview_window.pty {
  Branch (688:12): [True: 15, False: 360]
+
654
13
            let w = pty.master.take_writer();
655
13
            drop(w);
656
521
        }
657
534
        self.kill();
658
534
    }
659
}
660
661
impl SkimWidget for Preview {
662
534
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
663
        #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
664
534
        let mut res = Self {
665
534
            theme,
666
534
            border: options.border,
667
534
            direction: options.preview_window.direction,
668
534
            wrap: options.preview_window.wrap,
669
534
            content: Arc::new(RwLock::new(PreviewContent::default())),
670
534
            cmd: Default::default(),
671
534
            rows: 0,
672
534
            cols: 0,
673
534
            scroll_y: 0,
674
534
            scroll_x: 0,
675
534
            thread_handle: None,
676
534
            interrupt_tx: None,
677
534
            pty: None,
678
534
            pty_child: None,
679
534
            #[cfg(feature = "image")]
680
534
            image: options.image.is_some(),
681
534
            #[cfg(feature = "image")]
682
534
            image_picker: options.image_picker.clone(),
683
534
            total_lines: 0,
684
534
            loading: false,
685
534
            spinner_start: Instant::now(),
686
534
        };
687
        #[cfg(target_os = "linux")]
688
534
        if options.preview_window.pty {
  Branch (688:12): [True: 13, False: 357]
 
  Branch (688:12): [True: 0, False: 164]
-
689
15
            res.init_pty();
690
524
        }
691
539
        res
692
539
    }
693
694
228
    fn render(&mut self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) -> SkimRender {
695
228
        if self.rows != area.height || 
self.cols != area.width185
{
  Branch (695:12): [True: 41, False: 185]
-  Branch (695:40): [True: 0, False: 185]
+
689
13
            res.init_pty();
690
521
        }
691
534
        res
692
534
    }
693
694
223
    fn render(&mut self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) -> SkimRender {
695
223
        if self.rows != area.height || 
self.cols != area.width182
{
  Branch (695:12): [True: 39, False: 182]
+  Branch (695:40): [True: 0, False: 182]
 
  Branch (695:12): [True: 2, False: 0]
   Branch (695:40): [True: 0, False: 0]
-
696
43
            self.rows = area.height;
697
43
            self.cols = area.width;
698
43
            self.pty.as_ref().map(|p| 
{15
699
15
                p.master.resize(PtySize {
700
15
                    rows: self.rows,
701
15
                    cols: self.cols,
702
15
                    ..Default::default()
703
15
                })
704
15
            });
705
185
        }
706
228
        let Ok(mut content) = self.content.try_write() else {
  Branch (706:13): [True: 226, False: 0]
+
696
41
            self.rows = area.height;
697
41
            self.cols = area.width;
698
41
            self.pty.as_ref().map(|p| 
{13
699
13
                p.master.resize(PtySize {
700
13
                    rows: self.rows,
701
13
                    cols: self.cols,
702
13
                    ..Default::default()
703
13
                })
704
13
            });
705
182
        }
706
223
        let Ok(mut content) = self.content.try_write() else {
  Branch (706:13): [True: 221, False: 0]
 
  Branch (706:13): [True: 2, False: 0]
-
707
0
            return SkimRender::default();
708
        };
709
710
228
        let mut block = Block::new().style(self.theme.normal).border_style(self.theme.border);
711
712
        // Add borders based on direction and border setting
713
228
        if let Some(
border_type0
) = self.border.into_ratatui() {
  Branch (713:16): [True: 0, False: 226]
+
707
0
            return SkimRender::default();
708
        };
709
710
223
        let mut block = Block::new().style(self.theme.normal).border_style(self.theme.border);
711
712
        // Add borders based on direction and border setting
713
223
        if let Some(
border_type0
) = self.border.into_ratatui() {
  Branch (713:16): [True: 0, False: 221]
 
  Branch (713:16): [True: 0, False: 2]
-
714
0
            block = block.borders(Borders::ALL).border_type(border_type);
715
0
        } else {
716
            // No border on preview itself - separator will be drawn between areas
717
228
            match self.direction {
718
20
                Direction::Up => block = block.borders(Borders::BOTTOM),
719
16
                Direction::Down => block = block.borders(Borders::TOP),
720
47
                Direction::Left => block = block.borders(Borders::RIGHT),
721
145
                Direction::Right => block = block.borders(Borders::LEFT),
722
            }
723
        }
724
725
228
        Clear.render(area, buf);
726
228
        let spinner_area = block.inner(area);
727
728
228
        match &mut *content {
729
173
            PreviewContent::Text(text) => self.total_lines = self.render_text(block, area, buf, text),
730
48
            PreviewContent::Terminal(parser) => self.total_lines = self.render_pty(block, area, buf, parser.as_ref()),
731
            #[cfg(feature = "image")]
732
7
            PreviewContent::Image { source, protocol, size } => {
733
7
                self.render_image(block, area, buf, source, protocol, size);
734
7
            }
735
        }
736
737
228
        if self.loading && 
spinner_area.width > 03
&&
spinner_area.height > 03
{
  Branch (737:12): [True: 3, False: 223]
-  Branch (737:28): [True: 3, False: 0]
-  Branch (737:54): [True: 3, False: 0]
+
714
0
            block = block.borders(Borders::ALL).border_type(border_type);
715
0
        } else {
716
            // No border on preview itself - separator will be drawn between areas
717
223
            match self.direction {
718
20
                Direction::Up => block = block.borders(Borders::BOTTOM),
719
16
                Direction::Down => block = block.borders(Borders::TOP),
720
44
                Direction::Left => block = block.borders(Borders::RIGHT),
721
143
                Direction::Right => block = block.borders(Borders::LEFT),
722
            }
723
        }
724
725
223
        Clear.render(area, buf);
726
223
        let spinner_area = block.inner(area);
727
728
223
        match &mut *content {
729
168
            PreviewContent::Text(text) => self.total_lines = self.render_text(block, area, buf, text),
730
48
            PreviewContent::Terminal(parser) => self.total_lines = self.render_pty(block, area, buf, parser.as_ref()),
731
            #[cfg(feature = "image")]
732
7
            PreviewContent::Image { source, protocol, size } => {
733
7
                self.render_image(block, area, buf, source, protocol, size);
734
7
            }
735
        }
736
737
223
        if self.loading && 
spinner_area.width > 02
&&
spinner_area.height > 02
{
  Branch (737:12): [True: 2, False: 219]
+  Branch (737:28): [True: 2, False: 0]
+  Branch (737:54): [True: 2, False: 0]
 
  Branch (737:12): [True: 0, False: 2]
   Branch (737:28): [True: 0, False: 0]
   Branch (737:54): [True: 0, False: 0]
-
738
3
            let x = spinner_area.x + spinner_area.width.saturating_sub(1);
739
3
            let y = spinner_area.y + spinner_area.height.saturating_sub(1);
740
3
            buf.set_string(x, y, spinner_char(self.spinner_start).to_string(), self.theme.spinner);
741
225
        }
742
743
228
        SkimRender::default()
744
228
    }
745
}
746
747
#[cfg(test)]
748
#[path = "preview_tests.rs"]
749
mod tests;
\ No newline at end of file +
738
2
            let x = spinner_area.x + spinner_area.width.saturating_sub(1);
739
2
            let y = spinner_area.y + spinner_area.height.saturating_sub(1);
740
2
            buf.set_string(x, y, spinner_char(self.spinner_start).to_string(), self.theme.spinner);
741
221
        }
742
743
223
        SkimRender::default()
744
223
    }
745
}
746
747
#[cfg(test)]
748
#[path = "preview_tests.rs"]
749
mod tests;
\ No newline at end of file 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 9d0022a6..dabd587d 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,5 +1,5 @@ -

Coverage Report

Created: 2026-08-22 15:34

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
30
pub(crate) fn spinner_char(start: Instant) -> char {
9
30
    let spinner_elapsed_ms = start.elapsed().as_millis();
10
30
    let index = ((spinner_elapsed_ms / u128::from(SPINNER_DURATION)) % (SPINNERS_UNICODE.len() as u128)) as usize;
11
30
    SPINNERS_UNICODE[index]
12
30
}
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
488
    pub(crate) fn is_inline(&self) -> bool {
33
488
        
matches!475
(self, InfoDisplay::Inline | InfoDisplay::InlineRight)
34
488
    }
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]
+

Coverage Report

Created: 2026-09-01 09:15

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
14
pub(crate) fn spinner_char(start: Instant) -> char {
9
14
    let spinner_elapsed_ms = start.elapsed().as_millis();
10
14
    let index = ((spinner_elapsed_ms / u128::from(SPINNER_DURATION)) % (SPINNERS_UNICODE.len() as u128)) as usize;
11
14
    SPINNERS_UNICODE[index]
12
14
}
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
483
    pub(crate) fn is_inline(&self) -> bool {
33
483
        
matches!471
(self, InfoDisplay::Inline | InfoDisplay::InlineRight)
34
483
    }
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.61k
    pub(crate) fn separator(&self) -> Option<&str> {
48
2.61k
        self.separator.as_deref()
49
2.61k
    }
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
477
    fn from(s: &str) -> Self {
68
        use InfoDisplay::{Default, Hidden, Inline, InlineRight, Left, Right};
69
477
        let mut parts = s.split(':');
70
71
477
        let 
display476
= match parts.next() {
72
477
            None | Some("default") => 
Default450
,
73
27
            Some("left") => 
Left2
,
74
25
            Some("right") => 
Right2
,
75
23
            Some("inline") => 
Inline6
,
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
476
        let separator = if display.is_inline() {
  Branch (82:28): [True: 5, False: 451]
+
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
472
    fn from(s: &str) -> Self {
68
        use InfoDisplay::{Default, Hidden, Inline, InlineRight, Left, Right};
69
472
        let mut parts = s.split(':');
70
71
472
        let 
display471
= match parts.next() {
72
472
            None | Some("default") => 
Default446
,
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
471
        let separator = if display.is_inline() {
  Branch (82:28): [True: 4, False: 447]
 
  Branch (82:28): [True: 4, False: 16]
-
83
9
            parts.next().or(Some(DEFAULT_SEPARATOR)).map(String::from)
84
        } else {
85
467
            None
86
        };
87
476
        Self { display, separator }
88
476
    }
89
}
90
91
#[cfg(test)]
92
#[cfg_attr(coverage, coverage(off))]
93
mod tests {
94
    use super::*;
95
96
    #[test]
97
    fn spinner_char_returns_first_frame_immediately() {
98
        // No elapsed time → index 0.
99
        assert_eq!(spinner_char(Instant::now()), SPINNERS_UNICODE[0]);
100
    }
101
102
    #[test]
103
    fn info_display_is_inline() {
104
        assert!(InfoDisplay::Inline.is_inline());
105
        assert!(InfoDisplay::InlineRight.is_inline());
106
        assert!(!InfoDisplay::Default.is_inline());
107
        assert!(!InfoDisplay::Left.is_inline());
108
        assert!(!InfoDisplay::Right.is_inline());
109
        assert!(!InfoDisplay::Hidden.is_inline());
110
    }
111
112
    #[test]
113
    fn info_from_display_sets_separator_only_when_inline() {
114
        let inline = Info::from(InfoDisplay::Inline);
115
        assert_eq!(inline.separator(), Some(DEFAULT_SEPARATOR));
116
117
        let right = Info::from(InfoDisplay::InlineRight);
118
        assert_eq!(right.separator(), Some(DEFAULT_SEPARATOR));
119
120
        let default = Info::from(InfoDisplay::Default);
121
        assert_eq!(default.separator(), None);
122
123
        let left = Info::from(InfoDisplay::Left);
124
        assert_eq!(left.separator(), None);
125
126
        let right = Info::from(InfoDisplay::Right);
127
        assert_eq!(right.separator(), None);
128
129
        let hidden = Info::from(InfoDisplay::Hidden);
130
        assert_eq!(hidden.separator(), None);
131
    }
132
133
    #[test]
134
    fn info_from_str_parses_each_mode() {
135
        assert_eq!(Info::from("default").display, InfoDisplay::Default);
136
        assert_eq!(Info::from("left").display, InfoDisplay::Left);
137
        assert_eq!(Info::from("right").display, InfoDisplay::Right);
138
        assert_eq!(Info::from("inline").display, InfoDisplay::Inline);
139
        assert_eq!(Info::from("inline-right").display, InfoDisplay::InlineRight);
140
        assert_eq!(Info::from("hidden").display, InfoDisplay::Hidden);
141
    }
142
143
    #[test]
144
    fn info_from_str_uses_custom_and_default_separator() {
145
        // Inline with an explicit separator after the colon.
146
        assert_eq!(Info::from("inline: | ").separator(), Some(" | "));
147
        // Inline without a separator falls back to the default.
148
        assert_eq!(Info::from("inline").separator(), Some(DEFAULT_SEPARATOR));
149
        // Non-inline modes never carry a separator.
150
        assert_eq!(Info::from("hidden").separator(), None);
151
    }
152
153
    #[test]
154
    #[should_panic(expected = "Failed to parse")]
155
    fn info_from_str_panics_on_unknown_mode() {
156
        let _ = Info::from("bogus");
157
    }
158
}
\ No newline at end of file +
83
8
            parts.next().or(Some(DEFAULT_SEPARATOR)).map(String::from)
84
        } else {
85
463
            None
86
        };
87
471
        Self { display, separator }
88
471
    }
89
}
90
91
#[cfg(test)]
92
#[cfg_attr(coverage, coverage(off))]
93
mod tests {
94
    use super::*;
95
96
    #[test]
97
    fn spinner_char_returns_first_frame_immediately() {
98
        // No elapsed time → index 0.
99
        assert_eq!(spinner_char(Instant::now()), SPINNERS_UNICODE[0]);
100
    }
101
102
    #[test]
103
    fn info_display_is_inline() {
104
        assert!(InfoDisplay::Inline.is_inline());
105
        assert!(InfoDisplay::InlineRight.is_inline());
106
        assert!(!InfoDisplay::Default.is_inline());
107
        assert!(!InfoDisplay::Left.is_inline());
108
        assert!(!InfoDisplay::Right.is_inline());
109
        assert!(!InfoDisplay::Hidden.is_inline());
110
    }
111
112
    #[test]
113
    fn info_from_display_sets_separator_only_when_inline() {
114
        let inline = Info::from(InfoDisplay::Inline);
115
        assert_eq!(inline.separator(), Some(DEFAULT_SEPARATOR));
116
117
        let right = Info::from(InfoDisplay::InlineRight);
118
        assert_eq!(right.separator(), Some(DEFAULT_SEPARATOR));
119
120
        let default = Info::from(InfoDisplay::Default);
121
        assert_eq!(default.separator(), None);
122
123
        let left = Info::from(InfoDisplay::Left);
124
        assert_eq!(left.separator(), None);
125
126
        let right = Info::from(InfoDisplay::Right);
127
        assert_eq!(right.separator(), None);
128
129
        let hidden = Info::from(InfoDisplay::Hidden);
130
        assert_eq!(hidden.separator(), None);
131
    }
132
133
    #[test]
134
    fn info_from_str_parses_each_mode() {
135
        assert_eq!(Info::from("default").display, InfoDisplay::Default);
136
        assert_eq!(Info::from("left").display, InfoDisplay::Left);
137
        assert_eq!(Info::from("right").display, InfoDisplay::Right);
138
        assert_eq!(Info::from("inline").display, InfoDisplay::Inline);
139
        assert_eq!(Info::from("inline-right").display, InfoDisplay::InlineRight);
140
        assert_eq!(Info::from("hidden").display, InfoDisplay::Hidden);
141
    }
142
143
    #[test]
144
    fn info_from_str_uses_custom_and_default_separator() {
145
        // Inline with an explicit separator after the colon.
146
        assert_eq!(Info::from("inline: | ").separator(), Some(" | "));
147
        // Inline without a separator falls back to the default.
148
        assert_eq!(Info::from("inline").separator(), Some(DEFAULT_SEPARATOR));
149
        // Non-inline modes never carry a separator.
150
        assert_eq!(Info::from("hidden").separator(), None);
151
    }
152
153
    #[test]
154
    #[should_panic(expected = "Failed to parse")]
155
    fn info_from_str_panics_on_unknown_mode() {
156
        let _ = Info::from("bogus");
157
    }
158
}
\ No newline at end of file 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 e87b91b0..3f90ffd2 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-08-22 15:34

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-01 09:15

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
99.1k
pub fn char_display_width(c: char) -> usize {
47
99.1k
    if c == '\u{FE0F}' || 
is_double_width99.1k
(
c99.1k
) {
  Branch (47:8): [True: 0, False: 98.3k]
   Branch (47:27): [True: 18, False: 98.3k]
-
  Branch (47:8): [True: 1, False: 854]
-  Branch (47:27): [True: 11, False: 843]
+
  Branch (47:8): [True: 1, False: 852]
+  Branch (47:27): [True: 11, False: 841]
 
48
30
        return 2;
49
99.1k
    }
50
99.1k
    1
51
99.1k
}
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.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]
+
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.19k
pub(crate) fn merge_styles(left: Style, right: Style) -> Style {
102
    use ratatui::style::Color::Reset;
103
3.19k
    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.19k
    set_field!(res, left, right, fg);
117
3.19k
    set_field!(res, left, right, bg);
118
3.19k
    set_field!(res, left, right, underline_color);
119
3.19k
    res.add_modifier = left.add_modifier | right.add_modifier;
120
121
3.19k
    res
122
3.19k
}
123
124
2.92k
pub(crate) fn style_span(span: &mut Span, style: Style) {
125
2.92k
    span.style = merge_styles(style, span.style);
126
2.92k
}
127
2.92k
pub(crate) fn style_line(line: &mut Line, style: Style) {
128
2.92k
    
line2.92k
.
iter_mut2.92k
().
for_each2.92k
(|span| style_span(span, style));
129
2.92k
}
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 40a2744d..c2219ec9 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-08-22 15:34

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
141
    fn _default() -> Self {
38
141
        Self::from_options(&SkimOptions::default(), Arc::new(ColorTheme::default()))
39
141
    }
40
}
\ No newline at end of file +

Coverage Report

Created: 2026-09-01 09:15

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.65k
    fn bitor_assign(&mut self, rhs: Self) {
20
5.65k
        self.items_updated |= rhs.items_updated;
21
5.65k
        self.run_preview |= rhs.run_preview;
22
5.65k
    }
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
139
    fn _default() -> Self {
38
139
        Self::from_options(&SkimOptions::default(), Arc::new(ColorTheme::default()))
39
139
    }
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 5d1ca9d2..7e6c25ad 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,85 +1,85 @@ -

Coverage Report

Created: 2026-08-22 15:34

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
481
pub fn unescape_delimiter(s: &str) -> String {
31
481
    let mut result = String::new();
32
481
    let mut chars = s.chars();
33
34
3.10k
    while let Some(
c2.62k
) = chars.next() {
  Branch (34:15): [True: 2.53k, False: 458]
+

Coverage Report

Created: 2026-09-01 09:15

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
476
pub fn unescape_delimiter(s: &str) -> String {
31
476
    let mut result = String::new();
32
476
    let mut chars = s.chars();
33
34
3.06k
    while let Some(
c2.59k
) = chars.next() {
  Branch (34:15): [True: 2.50k, False: 453]
 
  Branch (34:15): [True: 89, False: 23]
-
35
2.62k
        if c == '\\' {
  Branch (35:12): [True: 833, False: 1.70k]
+
35
2.59k
        if c == '\\' {
  Branch (35:12): [True: 823, False: 1.68k]
 
  Branch (35:12): [True: 35, False: 54]
-
36
868
            match chars.next() {
37
                Some('x') => {
38
                    // Handle \xNN hex escape
39
9
                    let hex: String = chars.by_ref().take(2).collect();
40
9
                    if hex.len() == 2 {
  Branch (40:24): [True: 2, False: 0]
+
36
858
            match chars.next() {
37
                Some('x') => {
38
                    // Handle \xNN hex escape
39
9
                    let hex: String = chars.by_ref().take(2).collect();
40
9
                    if hex.len() == 2 {
  Branch (40:24): [True: 2, False: 0]
 
  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
428
                Some('t') => result.push('\t'),
59
428
                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.75k
        } else {
69
1.75k
            result.push(c);
70
1.75k
        }
71
    }
72
73
481
    result
74
481
}
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: 51, False: 13]
-  Branch (108:21): [True: 24, False: 27]
-  Branch (108:12): [True: 51, False: 0]
-  Branch (108:21): [True: 45, False: 6]
-
  Branch (108:12): [True: 20, False: 2]
+
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
423
                Some('t') => result.push('\t'),
59
423
                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.73k
        } else {
69
1.73k
            result.push(c);
70
1.73k
        }
71
    }
72
73
476
    result
74
476
}
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: 20, False: 2]
   Branch (108:21): [True: 0, False: 20]
-
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: 56, False: 31]
+
  Branch (108:12): [True: 469, False: 0]
+  Branch (108:21): [True: 435, False: 34]
+
  Branch (108:12): [True: 51, False: 0]
+  Branch (108:21): [True: 45, False: 6]
+  Branch (108:12): [True: 51, False: 13]
+  Branch (108:21): [True: 24, False: 27]
+
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: 25, False: 0]
+
  Branch (132:20): [True: 73, False: 15]
 
  Branch (132:20): [True: 6, False: 0]
-
  Branch (132:20): [True: 25, False: 0]
-
133
160
                    if c == '}' {
  Branch (133:24): [True: 38, False: 35]
-
  Branch (133:24): [True: 17, False: 39]
+
  Branch (132:20): [True: 56, False: 31]
+
133
160
                    if c == '}' {
  Branch (133:24): [True: 3, False: 22]
+
  Branch (133:24): [True: 38, False: 35]
 
  Branch (133:24): [True: 3, False: 3]
-
  Branch (133:24): [True: 3, False: 22]
+
  Branch (133:24): [True: 17, False: 39]
 
134
61
                        match content.as_str() {
135
61
                            "" => 
replaced4
.
push_str4
(
"{}"4
),
136
57
                            "q" => 
replaced15
.
push_str15
(
&escaped_query15
),
137
42
                            "cq" => 
replaced1
.
push_str1
(
&escaped_cmd_query1
),
138
41
                            "n" if 
current.as_ref()2
.
is_some2
(
)1
=> {
  Branch (138:36): [True: 0, False: 0]
+
  Branch (138:36): [True: 0, False: 0]
+
  Branch (138:36): [True: 0, False: 0]
 
  Branch (138:36): [True: 1, False: 1]
-
  Branch (138:36): [True: 0, False: 0]
-
  Branch (138:36): [True: 0, False: 0]
-
139
1
                                replaced.push_str(
140
1
                                    current
141
1
                                        .as_ref()
142
1
                                        .map(|i| i.rank.index)
143
1
                                        .unwrap_or_default()
144
1
                                        .to_string()
145
1
                                        .as_str(),
146
                                );
147
                            }
148
40
                            
s26
if s == "+n" ||
s39
.
starts_with39
("+n:") ||
s == "+"37
||
s15
.
starts_with15
("+:"
)26
=> {
  Branch (148:34): [True: 0, False: 24]
+
139
1
                                replaced.push_str(
140
1
                                    current
141
1
                                        .as_ref()
142
1
                                        .map(|i| i.rank.index)
143
1
                                        .unwrap_or_default()
144
1
                                        .to_string()
145
1
                                        .as_str(),
146
                                );
147
                            }
148
40
                            
s26
if s == "+n" ||
s39
.
starts_with39
("+n:") ||
s == "+"37
||
s15
.
starts_with15
("+:"
)26
=> {
  Branch (148:34): [True: 0, False: 3]
+  Branch (148:47): [True: 0, False: 3]
+  Branch (148:71): [True: 1, False: 2]
+  Branch (148:83): [True: 0, False: 2]
+
  Branch (148:34): [True: 0, False: 24]
   Branch (148:47): [True: 0, False: 24]
   Branch (148:71): [True: 18, False: 6]
   Branch (148:83): [True: 0, False: 6]
-
  Branch (148:34): [True: 1, False: 12]
-  Branch (148:47): [True: 2, False: 10]
-  Branch (148:71): [True: 3, False: 7]
-  Branch (148:83): [True: 1, False: 6]
 
  Branch (148:34): [True: 0, False: 0]
   Branch (148:47): [True: 0, False: 0]
   Branch (148:71): [True: 0, False: 0]
   Branch (148:83): [True: 0, False: 0]
-
  Branch (148:34): [True: 0, False: 3]
-  Branch (148:47): [True: 0, False: 3]
-  Branch (148:71): [True: 1, False: 2]
-  Branch (148:83): [True: 0, False: 2]
-
149
26
                                let is_n = s.starts_with("+n");
150
26
                                let accessor = if is_n {
  Branch (150:51): [True: 0, False: 18]
-
  Branch (150:51): [True: 3, False: 4]
+
  Branch (148:34): [True: 1, False: 12]
+  Branch (148:47): [True: 2, False: 10]
+  Branch (148:71): [True: 3, False: 7]
+  Branch (148:83): [True: 1, False: 6]
+
149
26
                                let is_n = s.starts_with("+n");
150
26
                                let accessor = if is_n {
  Branch (150:51): [True: 0, False: 1]
+
  Branch (150:51): [True: 0, False: 18]
 
  Branch (150:51): [True: 0, False: 0]
-
  Branch (150:51): [True: 0, False: 1]
-
151
7
                                    |i: &MatchedItem| i.rank.index.to_string()
152
                                } else {
153
35
                                    |i: &MatchedItem| strip_ansi(&i.output()).0
154
                                };
155
26
                                let mut quote_individually = false;
156
157
26
                                let delim = s.rsplit_once(':').map_or_else(
158
23
                                    || {
159
23
                                        quote_individually = quote_args;
160
23
                                        " "
161
23
                                    },
162
                                    |x| x.1,
163
                                );
164
165
26
                                let mut expanded = selected
166
26
                                    .clone()
167
30
                                    .
map26
(|i| escape_arg(&accessor(i), quote_individually))
168
26
                                    .reduce(|a: String, b| 
a16
.clone() + delim +
b.as_str()16
)
169
26
                                    .unwrap_or_default();
170
26
                                if expanded.is_empty() {
  Branch (170:36): [True: 10, False: 8]
-
  Branch (170:36): [True: 2, False: 5]
+
  Branch (150:51): [True: 3, False: 4]
+
151
7
                                    |i: &MatchedItem| i.rank.index.to_string()
152
                                } else {
153
35
                                    |i: &MatchedItem| strip_ansi(&i.output()).0
154
                                };
155
26
                                let mut quote_individually = false;
156
157
26
                                let delim = s.rsplit_once(':').map_or_else(
158
23
                                    || {
159
23
                                        quote_individually = quote_args;
160
23
                                        " "
161
23
                                    },
162
                                    |x| x.1,
163
                                );
164
165
26
                                let mut expanded = selected
166
26
                                    .clone()
167
30
                                    .
map26
(|i| escape_arg(&accessor(i), quote_individually))
168
26
                                    .reduce(|a: String, b| 
a16
.clone() + delim +
b.as_str()16
)
169
26
                                    .unwrap_or_default();
170
26
                                if expanded.is_empty() {
  Branch (170:36): [True: 0, False: 1]
+
  Branch (170:36): [True: 10, False: 8]
 
  Branch (170:36): [True: 0, False: 0]
-
  Branch (170:36): [True: 0, False: 1]
-
171
12
                                    expanded = current
172
12
                                        .as_ref()
173
12
                                        .map(|i| escape_arg(&accessor(i), quote_args))
174
12
                                        .unwrap_or_default();
175
14
                                }
176
177
26
                                if quote_args && 
!quote_individually23
{
  Branch (177:36): [True: 18, False: 0]
+
  Branch (170:36): [True: 2, False: 5]
+
171
12
                                    expanded = current
172
12
                                        .as_ref()
173
12
                                        .map(|i| escape_arg(&accessor(i), quote_args))
174
12
                                        .unwrap_or_default();
175
14
                                }
176
177
26
                                if quote_args && 
!quote_individually23
{
  Branch (177:36): [True: 0, False: 1]
+  Branch (177:50): [True: 0, False: 0]
+
  Branch (177:36): [True: 18, False: 0]
   Branch (177:50): [True: 0, False: 18]
-
  Branch (177:36): [True: 5, False: 2]
-  Branch (177:50): [True: 2, False: 3]
 
  Branch (177:36): [True: 0, False: 0]
   Branch (177:50): [True: 0, False: 0]
-
  Branch (177:36): [True: 0, False: 1]
-  Branch (177:50): [True: 0, False: 0]
-
178
2
                                    let _ = write!(replaced, "'{expanded}'");
179
24
                                } else {
180
24
                                    replaced.push_str(&expanded);
181
24
                                }
182
                            }
183
14
                            s => {
184
14
                                let (is_plus, stripped) = match s.strip_prefix('+') {
185
2
                                    Some(x) => (true, x),
186
12
                                    None => (false, s),
187
                                };
188
14
                                if is_plus {
  Branch (188:36): [True: 0, False: 6]
-
  Branch (188:36): [True: 2, False: 4]
+
  Branch (177:36): [True: 5, False: 2]
+  Branch (177:50): [True: 2, False: 3]
+
178
2
                                    let _ = write!(replaced, "'{expanded}'");
179
24
                                } else {
180
24
                                    replaced.push_str(&expanded);
181
24
                                }
182
                            }
183
14
                            s => {
184
14
                                let (is_plus, stripped) = match s.strip_prefix('+') {
185
2
                                    Some(x) => (true, x),
186
12
                                    None => (false, s),
187
                                };
188
14
                                if is_plus {
  Branch (188:36): [True: 0, False: 2]
+
  Branch (188:36): [True: 0, False: 6]
 
  Branch (188:36): [True: 0, False: 0]
-
  Branch (188:36): [True: 0, False: 2]
+
  Branch (188:36): [True: 2, False: 4]
 
189
2
                                    let mut quote_individually = false;
190
191
2
                                    let (stripped, delim) = stripped.rsplit_once(':').unwrap_or_else(|| 
{1
192
1
                                        quote_individually = quote_args;
193
1
                                        (stripped, " ")
194
1
                                    });
195
2
                                    if let Some(
range1
) = FieldRange::from_str(stripped) {
  Branch (195:44): [True: 0, False: 0]
+
  Branch (195:44): [True: 0, False: 0]
+
  Branch (195:44): [True: 0, False: 0]
 
  Branch (195:44): [True: 1, False: 1]
-
  Branch (195:44): [True: 0, False: 0]
-
  Branch (195:44): [True: 0, False: 0]
 
196
1
                                        let expanded = selected
197
1
                                            .clone()
198
2
                                            .
map1
(|i| {
199
2
                                                escape_arg(
200
2
                                                    get_string_by_field(delimiter, &strip_ansi(&i.output()).0, &range)
201
2
                                                        .unwrap_or_default(),
202
2
                                                    quote_individually,
203
2
                                                )
204
2
                                            })
205
1
                                            .reduce(|a: String, b| a.clone() + delim + b.as_str())
206
1
                                            .unwrap_or_default();
207
208
1
                                        if quote_args && 
!quote_individually0
{
  Branch (208:44): [True: 0, False: 0]
   Branch (208:58): [True: 0, False: 0]
+
  Branch (208:44): [True: 0, False: 0]
+  Branch (208:58): [True: 0, False: 0]
+
  Branch (208:44): [True: 0, False: 0]
+  Branch (208:58): [True: 0, False: 0]
 
  Branch (208:44): [True: 0, False: 1]
   Branch (208:58): [True: 0, False: 0]
-
  Branch (208:44): [True: 0, False: 0]
-  Branch (208:58): [True: 0, False: 0]
-
  Branch (208:44): [True: 0, False: 0]
-  Branch (208:58): [True: 0, False: 0]
-
209
0
                                            let _ = write!(replaced, "'{expanded}'");
210
1
                                        } else {
211
1
                                            replaced.push_str(&expanded);
212
1
                                        }
213
                                    } else {
214
1
                                        log::warn!("Failed to build multi-item field range from {content}");
215
1
                                        let _ = write!(replaced, "{{{s}}}");
216
                                    }
217
12
                                } else if let Some(
range10
) = FieldRange::from_str(stripped) {
  Branch (217:47): [True: 6, False: 0]
-
  Branch (217:47): [True: 2, False: 2]
+
209
0
                                            let _ = write!(replaced, "'{expanded}'");
210
1
                                        } else {
211
1
                                            replaced.push_str(&expanded);
212
1
                                        }
213
                                    } else {
214
1
                                        log::warn!("Failed to build multi-item field range from {content}");
215
1
                                        let _ = write!(replaced, "{{{s}}}");
216
                                    }
217
12
                                } else if let Some(
range10
) = FieldRange::from_str(stripped) {
  Branch (217:47): [True: 2, False: 0]
+
  Branch (217:47): [True: 6, False: 0]
 
  Branch (217:47): [True: 0, False: 0]
-
  Branch (217:47): [True: 2, False: 0]
-
218
10
                                    let replacement =
219
10
                                        get_string_by_field(delimiter, &item_text, &range).unwrap_or_default();
220
10
                                    replaced.push_str(&escape_arg(replacement, true));
221
10
                                } else {
222
2
                                    log::warn!("Failed to build field range from {content}");
223
2
                                    let _ = write!(replaced, "{{{s}}}");
224
                                }
225
                            }
226
                        }
227
228
61
                        content.clear();
229
61
                        inside = false;
230
99
                    } else {
231
99
                        content.push(c);
232
99
                    }
233
46
                } else if c == '{' {
  Branch (233:27): [True: 0, False: 15]
+
  Branch (217:47): [True: 2, False: 2]
+
218
10
                                    let replacement =
219
10
                                        get_string_by_field(delimiter, &item_text, &range).unwrap_or_default();
220
10
                                    replaced.push_str(&escape_arg(replacement, true));
221
10
                                } else {
222
2
                                    log::warn!("Failed to build field range from {content}");
223
2
                                    let _ = write!(replaced, "{{{s}}}");
224
                                }
225
                            }
226
                        }
227
228
61
                        content.clear();
229
61
                        inside = false;
230
99
                    } else {
231
99
                        content.push(c);
232
99
                    }
233
46
                } else if c == '{' {
  Branch (233:27): [True: 0, False: 0]
+
  Branch (233:27): [True: 0, False: 15]
+
  Branch (233:27): [True: 0, False: 0]
 
  Branch (233:27): [True: 0, False: 31]
-
  Branch (233:27): [True: 0, False: 0]
-
  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
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 15778140..610714a0 100644 --- a/coverage/index.html +++ b/coverage/index.html @@ -1 +1 @@ -

Coverage Report

Created: 2026-08-22 15:34

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
  84.21% (16/19)
  98.22% (331/337)
  97.62% (369/378)
  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.65% (348/416)
  79.05% (498/630)
  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
  80.00% (24/30)
  69.78% (157/225)
  65.90% (230/349)
  47.22% (17/36)
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.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.87% (925/975)
  94.38% (10122/10725)
  93.47% (16722/17891)
  85.31% (1975/2315)
Generated by llvm-cov -- llvm version 23.1.0-rust-1.100.0-nightly
\ No newline at end of file +

Coverage Report

Created: 2026-09-01 09:15

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
  84.21% (16/19)
  98.22% (331/337)
  97.62% (369/378)
  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)
  75.00% (6/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.89% (349/416)
  79.21% (499/630)
  72.73% (80/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.09% (198/230)
tui/backend.rs
  80.00% (24/30)
  69.78% (157/225)
  65.90% (230/349)
  47.22% (17/36)
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.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.87% (925/975)
  94.38% (10122/10725)
  93.48% (16724/17891)
  85.31% (1975/2315)
Generated by llvm-cov -- llvm version 23.1.0-rust-1.100.0-nightly
\ No newline at end of file