feat: add substring matching with exact flag

This commit is contained in:
nkxxll 2025-04-21 13:26:07 +02:00
parent ce02824f3c
commit 0abeb904de
8 changed files with 168 additions and 11 deletions

View File

@ -512,8 +512,9 @@ pub fn draw(c: &mut Criterion) {
]));
channel.find("television");
// Wait for the channel to finish loading
let mut tv =
Television::new(tx, channel, config, None, false, false);
let mut tv = Television::new(
tx, channel, config, None, false, false, false,
);
for _ in 0..5 {
// tick the matcher
let _ = tv.channel.results(10, 0);

View File

@ -16,7 +16,11 @@ use crate::{
render::{render, RenderingTask},
};
#[allow(clippy::struct_excessive_bools)]
pub struct AppOptions {
/// Whether the application should use subsring matching instead of fuzzy
/// matching.
pub exact: bool,
/// Whether the application should automatically select the first entry if there is only one
/// entry available.
pub select_1: bool,
@ -30,6 +34,7 @@ pub struct AppOptions {
impl Default for AppOptions {
fn default() -> Self {
Self {
exact: false,
select_1: false,
no_remote: false,
no_help: false,
@ -39,13 +44,16 @@ impl Default for AppOptions {
}
impl AppOptions {
#[allow(clippy::fn_params_excessive_bools)]
pub fn new(
exact: bool,
select_1: bool,
no_remote: bool,
no_help: bool,
tick_rate: f64,
) -> Self {
Self {
exact,
select_1,
no_remote,
no_help,
@ -149,6 +157,7 @@ impl App {
input,
options.no_remote,
options.no_help,
options.exact,
);
Self {

View File

@ -99,6 +99,14 @@ pub struct Cli {
#[arg(long, value_name = "STRING", verbatim_doc_comment)]
pub autocomplete_prompt: Option<String>,
/// Use substring matching instead of fuzzy matching.
///
/// This will use substring matching instead of fuzzy matching when
/// searching for entries. This is useful when the user wants to search for
/// an exact match instead of a fuzzy match e.g. to improve performance.
#[arg(long, default_value = "false", verbatim_doc_comment)]
pub exact: bool,
/// Automatically select and output the first entry if there is only one
/// entry.
///

View File

@ -31,6 +31,7 @@ pub struct PostProcessedCli {
pub working_directory: Option<String>,
pub autocomplete_prompt: Option<String>,
pub keybindings: Option<KeyBindings>,
pub exact: bool,
pub select_1: bool,
pub no_remote: bool,
pub no_help: bool,
@ -50,6 +51,7 @@ impl Default for PostProcessedCli {
working_directory: None,
autocomplete_prompt: None,
keybindings: None,
exact: false,
select_1: false,
no_remote: false,
no_help: false,
@ -118,6 +120,7 @@ impl From<Cli> for PostProcessedCli {
working_directory,
autocomplete_prompt: cli.autocomplete_prompt,
keybindings,
exact: cli.exact,
select_1: cli.select_1,
no_remote: cli.no_remote,
no_help: cli.no_help,

View File

@ -66,6 +66,7 @@ async fn main() -> Result<()> {
debug!("Creating application...");
let options = AppOptions::new(
args.exact,
args.select_1,
args.no_remote,
args.no_help,

View File

@ -9,6 +9,7 @@
/// default number of threads (which corresponds to the number of available logical
/// cores on the current machine).
#[derive(Copy, Clone, Debug)]
#[allow(clippy::struct_excessive_bools)]
pub struct Config {
/// The number of threads to use for the fuzzy matcher.
pub n_threads: Option<usize>,

View File

@ -31,6 +31,12 @@ pub enum Mode {
SendToChannel,
}
#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)]
pub enum MatchingMode {
Substring,
Fuzzy,
}
pub struct Television {
action_tx: UnboundedSender<Action>,
pub config: Config,
@ -38,6 +44,7 @@ pub struct Television {
pub remote_control: Option<TelevisionChannel>,
pub mode: Mode,
pub current_pattern: String,
pub matching_mode: MatchingMode,
pub results_picker: Picker,
pub rc_picker: Picker,
pub previewer: Previewer,
@ -60,6 +67,7 @@ impl Television {
input: Option<String>,
no_remote: bool,
no_help: bool,
exact: bool,
) -> Self {
let mut results_picker = Picker::new(input.clone());
if config.ui.input_bar_position == InputPosition::Bottom {
@ -103,6 +111,12 @@ impl Television {
config.ui.show_help_bar = false;
}
let matching_mode = if exact {
MatchingMode::Substring
} else {
MatchingMode::Fuzzy
};
Self {
action_tx,
config,
@ -111,6 +125,7 @@ impl Television {
mode: Mode::Channel,
current_pattern: EMPTY_STRING.to_string(),
results_picker,
matching_mode,
rc_picker: Picker::default(),
previewer,
preview_state,
@ -182,7 +197,10 @@ impl Television {
fn find(&mut self, pattern: &str) {
match self.mode {
Mode::Channel => {
self.channel.find(pattern);
self.channel.find(
Self::preprocess_pattern(self.matching_mode, pattern)
.as_str(),
);
}
Mode::RemoteControl | Mode::SendToChannel => {
self.remote_control.as_mut().unwrap().find(pattern);
@ -190,6 +208,21 @@ impl Television {
}
}
fn preprocess_pattern(mode: MatchingMode, pattern: &str) -> String {
if mode == MatchingMode::Substring {
return pattern
.split_ascii_whitespace()
.map(|x| {
let mut new = x.to_string();
new.insert(0, '\'');
new
})
.collect::<Vec<String>>()
.join(" ");
}
pattern.to_string()
}
#[must_use]
pub fn get_selected_entry(&self, mode: Option<Mode>) -> Option<Entry> {
match mode.unwrap_or(self.mode) {
@ -660,3 +693,24 @@ impl Television {
})
}
}
#[cfg(test)]
mod test {
use crate::television::{MatchingMode, Television};
#[test]
fn test_prompt_preprocessing() {
let one_word = "test";
let mult_word = "this is a specific test";
let expect_one = "'test";
let expect_mult = "'this 'is 'a 'specific 'test";
assert_eq!(
Television::preprocess_pattern(MatchingMode::Substring, one_word),
expect_one
);
assert_eq!(
Television::preprocess_pattern(MatchingMode::Substring, mult_word),
expect_mult
);
}
}

View File

@ -23,6 +23,7 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100);
fn setup_app(
channel: Option<TelevisionChannel>,
select_1: bool,
exact: bool,
) -> (
JoinHandle<television::app::AppOutput>,
tokio::sync::mpsc::UnboundedSender<Action>,
@ -41,8 +42,13 @@ fn setup_app(
config.application.tick_rate = 100.0;
let input = None;
let options =
AppOptions::new(select_1, false, false, config.application.tick_rate);
let options = AppOptions::new(
exact,
select_1,
false,
false,
config.application.tick_rate,
);
let mut app = App::new(chan, config, input, options);
// retrieve the app's action channel handle in order to send a quit action
@ -59,7 +65,7 @@ fn setup_app(
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_does_quit() {
let (f, tx) = setup_app(None, false);
let (f, tx) = setup_app(None, false, false);
// send a quit action to the app
tx.send(Action::Quit).unwrap();
@ -71,7 +77,7 @@ async fn test_app_does_quit() {
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_starts_normally() {
let (f, _) = setup_app(None, false);
let (f, _) = setup_app(None, false, false);
// assert that the app is still running after the default timeout
std::thread::sleep(DEFAULT_TIMEOUT);
@ -82,7 +88,7 @@ async fn test_app_starts_normally() {
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_basic_search() {
let (f, tx) = setup_app(None, false);
let (f, tx) = setup_app(None, false, false);
// send actions to the app
for c in "file1".chars() {
@ -111,7 +117,81 @@ async fn test_app_basic_search() {
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_basic_search_multiselect() {
let (f, tx) = setup_app(None, false);
let (f, tx) = setup_app(None, false, false);
// send actions to the app
for c in "file".chars() {
tx.send(Action::AddInputChar(c)).unwrap();
}
// select both files
tx.send(Action::ToggleSelectionDown).unwrap();
std::thread::sleep(Duration::from_millis(50));
tx.send(Action::ToggleSelectionDown).unwrap();
std::thread::sleep(Duration::from_millis(50));
tx.send(Action::ConfirmSelection).unwrap();
// check the output with a timeout
let output = timeout(DEFAULT_TIMEOUT, f)
.await
.expect("app did not finish within the default timeout")
.unwrap();
assert!(output.selected_entries.is_some());
assert_eq!(
output
.selected_entries
.as_ref()
.unwrap()
.iter()
.map(|e| &e.name)
.collect::<HashSet<_>>(),
HashSet::from([&"file1.txt".to_string(), &"file2.txt".to_string()])
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_exact_search_multiselect() {
let (f, tx) = setup_app(None, false, true);
// send actions to the app
for c in "fie".chars() {
tx.send(Action::AddInputChar(c)).unwrap();
}
tx.send(Action::ConfirmSelection).unwrap();
// check the output with a timeout
let output = timeout(DEFAULT_TIMEOUT, f)
.await
.expect("app did not finish within the default timeout")
.unwrap();
assert!(output.selected_entries.is_some());
assert!(&output
.selected_entries
.clone()
.unwrap()
.drain()
.next()
.unwrap()
.value
.is_none());
assert_eq!(
output
.selected_entries
.as_ref()
.unwrap()
.iter()
.map(|e| &e.name)
.collect::<HashSet<_>>(),
HashSet::from([&"fie".to_string()])
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_exact_search_positive() {
let (f, tx) = setup_app(None, false, true);
// send actions to the app
for c in "file".chars() {
@ -150,7 +230,7 @@ async fn test_app_exits_when_select_1_and_only_one_result() {
TelevisionChannel::Stdin(television::channels::stdin::Channel::from(
vec!["file1.txt".to_string()],
));
let (f, tx) = setup_app(Some(channel), true);
let (f, tx) = setup_app(Some(channel), true, false);
// tick a few times to get the results
for _ in 0..=10 {
@ -184,7 +264,7 @@ async fn test_app_does_not_exit_when_select_1_and_more_than_one_result() {
TelevisionChannel::Stdin(television::channels::stdin::Channel::from(
vec!["file1.txt".to_string(), "file2.txt".to_string()],
));
let (f, tx) = setup_app(Some(channel), true);
let (f, tx) = setup_app(Some(channel), true, false);
// tick a few times to get the results
for _ in 0..=10 {