feat(cli): add substring matching with --exact flag (#477)

fixes #368 

@alexpasmantier I tried to parse down the exact flag to the find method,
but I can't find an elegant way to do so because the channel creates its
own configurations for the matcher inside of the `new` method. I started
adding the exact flag to the new function of each of the channels but it
just does not seem right to do so. Do you have an idea on how to improve
the change? I would think of passing the config struct to the new
function and exposing the config of the channel for channel transitions,
but I'm not sure... 🤔

I have done it here for the stdin channel just to show you how it would
look like and it works fine😄! Looking forward to hearing your thoughts
on that 👍

ref issue: #368

---------

Co-authored-by: Alexandre Pasmantier <alex.pasmant@gmail.com>
This commit is contained in:
nkxxll 2025-04-22 00:37:07 +02:00 committed by GitHub
parent ce02824f3c
commit bbbdcb0271
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 156 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,69 @@ 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();
let selected_entries = output.selected_entries.clone();
assert!(selected_entries.is_some());
// should contain a single entry with the prompt
assert!(!selected_entries.as_ref().unwrap().is_empty());
assert_eq!(
selected_entries.unwrap().drain().next().unwrap().name,
"fie"
);
}
#[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 +218,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 +252,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 {