From 3a33a9fb5b49a0820a4ef48f904acc4ab7e100a9 Mon Sep 17 00:00:00 2001 From: lilydjwg Date: Sun, 25 Jan 2026 13:22:02 +0800 Subject: feat(ui): highlight fulltext search as fulltext search instead of fuzzy search also parse query in one place --- crates/atuin-client/src/database.rs | 164 +++++++++++++++++++++++------------- 1 file changed, 105 insertions(+), 59 deletions(-) (limited to 'crates/atuin-client/src') diff --git a/crates/atuin-client/src/database.rs b/crates/atuin-client/src/database.rs index 82644182..b194e655 100644 --- a/crates/atuin-client/src/database.rs +++ b/crates/atuin-client/src/database.rs @@ -1,5 +1,4 @@ use std::{ - borrow::Cow, env, path::{Path, PathBuf}, str::FromStr, @@ -483,76 +482,49 @@ impl Database for Sqlite { SearchMode::Prefix => sql.and_where_like_left("command", query.replace('*', "%")), _ => { let mut is_or = false; - let mut regex = None; - for part in query.split_inclusive(' ') { - let query_part: Cow = match (&mut regex, part.starts_with("r/")) { - (None, false) => { - if part.trim_end().is_empty() { - continue; - } - Cow::Owned(part.trim_end().replace('*', "%")) // allow wildcard char - } - (None, true) => { - if part[2..].trim_end().ends_with('/') { - let end_pos = part.trim_end().len() - 1; - regexes.push(String::from(&part[2..end_pos])); - } else { - regex = Some(String::from(&part[2..])); - } - continue; - } - (Some(r), _) => { - if part.trim_end().ends_with('/') { - let end_pos = part.trim_end().len() - 1; - r.push_str(&part.trim_end()[..end_pos]); - regexes.push(regex.take().unwrap()); - } else { - r.push_str(part); - } - continue; - } - }; - + for token in QueryTokenizer::new(query) { + let mut is_inverse = false; // TODO smart case mode could be made configurable like in fzf - let (is_glob, glob) = if query_part.contains(char::is_uppercase) { + let (is_glob, glob) = if token.has_uppercase() { (true, "*") } else { (false, "%") }; - - let (is_inverse, query_part) = match query_part.strip_prefix('!') { - Some(stripped) => (true, Cow::Borrowed(stripped)), - None => (false, query_part), - }; - - #[allow(clippy::if_same_then_else)] - let param = if query_part == "|" { - if !is_or { - is_or = true; + let param = match token { + QueryToken::Regex(r) => { + regexes.push(String::from(r)); continue; - } else { - format!("{glob}|{glob}") } - } else if let Some(term) = query_part.strip_prefix('^') { - format!("{term}{glob}") - } else if let Some(term) = query_part.strip_suffix('$') { - format!("{glob}{term}") - } else if let Some(term) = query_part.strip_prefix('\'') { - format!("{glob}{term}{glob}") - } else if is_inverse { - format!("{glob}{query_part}{glob}") - } else if search_mode == SearchMode::FullText { - format!("{glob}{query_part}{glob}") - } else { - query_part.split("").join(glob) + QueryToken::Or => { + if !is_or { + is_or = true; + continue; + } else { + format!("{glob}|{glob}") + } + } + QueryToken::MatchStart(term) => { + format!("{term}{glob}") + } + QueryToken::MatchEnd(term) => { + format!("{glob}{term}") + } + QueryToken::Negation(term) => { + is_inverse = true; + format!("{glob}{term}{glob}") + } + QueryToken::Match(term) => { + if search_mode == SearchMode::FullText { + format!("{glob}{term}{glob}") + } else { + term.split("").join(glob) + } + } }; sql.fuzzy_condition("command", param, is_inverse, is_glob, is_or); is_or = false; } - if let Some(r) = regex { - regexes.push(r); - } &mut sql } @@ -1206,3 +1178,77 @@ mod test { assert!(duration < Duration::from_secs(15)); } } + +pub struct QueryTokenizer<'a> { + query: &'a str, + last_pos: usize, +} + +pub enum QueryToken<'a> { + Match(&'a str), + Negation(&'a str), + MatchStart(&'a str), + MatchEnd(&'a str), + Or, + Regex(&'a str), +} + +impl<'a> QueryToken<'a> { + pub fn has_uppercase(&self) -> bool { + match self { + Self::Match(term) + | Self::MatchStart(term) + | Self::MatchEnd(term) + | Self::Negation(term) => term.contains(char::is_uppercase), + _ => false, + } + } +} + +impl<'a> QueryTokenizer<'a> { + pub fn new(query: &'a str) -> Self { + Self { query, last_pos: 0 } + } +} + +impl<'a> Iterator for QueryTokenizer<'a> { + type Item = QueryToken<'a>; + fn next(&mut self) -> Option { + let remaining = &self.query[self.last_pos..]; + if remaining.is_empty() { + return None; + } + + if let Some(remaining) = remaining.strip_prefix("r/") { + let (regex, next_pos) = if let Some(end) = remaining.find("/ ") { + (&remaining[..end], self.last_pos + 2 + end + 2) + } else if let Some(remaining) = remaining.strip_suffix('/') { + (remaining, self.query.len()) + } else { + (remaining, self.query.len()) + }; + self.last_pos = next_pos; + Some(QueryToken::Regex(regex)) + } else { + let (part, next_pos) = if let Some(sp) = remaining.find(' ') { + (&remaining[..sp], self.last_pos + sp + 1) + } else { + (remaining, self.query.len()) + }; + self.last_pos = next_pos; + + let token = if let Some(s) = part.strip_prefix('^') { + QueryToken::MatchStart(s) + } else if let Some(s) = part.strip_suffix('$') { + QueryToken::MatchEnd(s) + } else if let Some(s) = part.strip_prefix('!') { + QueryToken::Negation(s) + } else if part == "|" { + QueryToken::Or + } else { + QueryToken::Match(part) + }; + Some(token) + } + } +} -- cgit v1.3.1 From 904422efb087b3cdec4a49c8e2f7b600e87ad967 Mon Sep 17 00:00:00 2001 From: lilydjwg Date: Sun, 25 Jan 2026 13:46:39 +0800 Subject: fix for 'term --- crates/atuin-client/src/database.rs | 6 ++++++ crates/atuin/src/command/client/search/engines/db.rs | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) (limited to 'crates/atuin-client/src') diff --git a/crates/atuin-client/src/database.rs b/crates/atuin-client/src/database.rs index b194e655..d51c2637 100644 --- a/crates/atuin-client/src/database.rs +++ b/crates/atuin-client/src/database.rs @@ -509,6 +509,9 @@ impl Database for Sqlite { QueryToken::MatchEnd(term) => { format!("{glob}{term}") } + QueryToken::MatchFull(term) => { + format!("{glob}{term}{glob}") + } QueryToken::Negation(term) => { is_inverse = true; format!("{glob}{term}{glob}") @@ -1189,6 +1192,7 @@ pub enum QueryToken<'a> { Negation(&'a str), MatchStart(&'a str), MatchEnd(&'a str), + MatchFull(&'a str), Or, Regex(&'a str), } @@ -1243,6 +1247,8 @@ impl<'a> Iterator for QueryTokenizer<'a> { QueryToken::MatchEnd(s) } else if let Some(s) = part.strip_prefix('!') { QueryToken::Negation(s) + } else if let Some(s) = part.strip_prefix('\'') { + QueryToken::MatchFull(s) } else if part == "|" { QueryToken::Or } else { diff --git a/crates/atuin/src/command/client/search/engines/db.rs b/crates/atuin/src/command/client/search/engines/db.rs index 9e7964f3..a1d22e9f 100644 --- a/crates/atuin/src/command/client/search/engines/db.rs +++ b/crates/atuin/src/command/client/search/engines/db.rs @@ -85,7 +85,7 @@ fn get_highlight_indices_fulltext(command: &str, search_input: &str) -> Vec { + QueryToken::Match(term) | QueryToken::MatchFull(term) => { for (idx, m) in matchee.match_indices(term) { ranges.push(idx..(idx + m.len())); } -- cgit v1.3.1 From 2c25a7f8e0c38cfc5d6d187e46bc1babb28f08ed Mon Sep 17 00:00:00 2001 From: lilydjwg Date: Sun, 25 Jan 2026 14:12:09 +0800 Subject: fix for inverse matching --- crates/atuin-client/src/database.rs | 65 ++++++++++++---------- .../atuin/src/command/client/search/engines/db.rs | 12 ++-- 2 files changed, 45 insertions(+), 32 deletions(-) (limited to 'crates/atuin-client/src') diff --git a/crates/atuin-client/src/database.rs b/crates/atuin-client/src/database.rs index d51c2637..408e8e52 100644 --- a/crates/atuin-client/src/database.rs +++ b/crates/atuin-client/src/database.rs @@ -483,7 +483,6 @@ impl Database for Sqlite { _ => { let mut is_or = false; for token in QueryTokenizer::new(query) { - let mut is_inverse = false; // TODO smart case mode could be made configurable like in fzf let (is_glob, glob) = if token.has_uppercase() { (true, "*") @@ -503,20 +502,16 @@ impl Database for Sqlite { format!("{glob}|{glob}") } } - QueryToken::MatchStart(term) => { + QueryToken::MatchStart(term, _) => { format!("{term}{glob}") } - QueryToken::MatchEnd(term) => { + QueryToken::MatchEnd(term, _) => { format!("{glob}{term}") } - QueryToken::MatchFull(term) => { + QueryToken::MatchFull(term, _) => { format!("{glob}{term}{glob}") } - QueryToken::Negation(term) => { - is_inverse = true; - format!("{glob}{term}{glob}") - } - QueryToken::Match(term) => { + QueryToken::Match(term, _) => { if search_mode == SearchMode::FullText { format!("{glob}{term}{glob}") } else { @@ -525,7 +520,7 @@ impl Database for Sqlite { } }; - sql.fuzzy_condition("command", param, is_inverse, is_glob, is_or); + sql.fuzzy_condition("command", param, token.is_inverse(), is_glob, is_or); is_or = false; } @@ -1188,11 +1183,10 @@ pub struct QueryTokenizer<'a> { } pub enum QueryToken<'a> { - Match(&'a str), - Negation(&'a str), - MatchStart(&'a str), - MatchEnd(&'a str), - MatchFull(&'a str), + Match(&'a str, bool), + MatchStart(&'a str, bool), + MatchEnd(&'a str, bool), + MatchFull(&'a str, bool), Or, Regex(&'a str), } @@ -1200,10 +1194,20 @@ pub enum QueryToken<'a> { impl<'a> QueryToken<'a> { pub fn has_uppercase(&self) -> bool { match self { - Self::Match(term) - | Self::MatchStart(term) - | Self::MatchEnd(term) - | Self::Negation(term) => term.contains(char::is_uppercase), + Self::Match(term, _) + | Self::MatchStart(term, _) + | Self::MatchEnd(term, _) + | Self::MatchFull(term, _) => term.contains(char::is_uppercase), + _ => false, + } + } + + pub fn is_inverse(&self) -> bool { + match self { + Self::Match(_, inv) + | Self::MatchStart(_, inv) + | Self::MatchEnd(_, inv) + | Self::MatchFull(_, inv) => *inv, _ => false, } } @@ -1234,25 +1238,30 @@ impl<'a> Iterator for QueryTokenizer<'a> { self.last_pos = next_pos; Some(QueryToken::Regex(regex)) } else { - let (part, next_pos) = if let Some(sp) = remaining.find(' ') { + let (mut part, next_pos) = if let Some(sp) = remaining.find(' ') { (&remaining[..sp], self.last_pos + sp + 1) } else { (remaining, self.query.len()) }; self.last_pos = next_pos; + if part == "|" { + return Some(QueryToken::Or); + } + + let mut is_inverse = false; + if let Some(s) = part.strip_prefix('!') { + part = s; + is_inverse = true; + } let token = if let Some(s) = part.strip_prefix('^') { - QueryToken::MatchStart(s) + QueryToken::MatchStart(s, is_inverse) } else if let Some(s) = part.strip_suffix('$') { - QueryToken::MatchEnd(s) - } else if let Some(s) = part.strip_prefix('!') { - QueryToken::Negation(s) + QueryToken::MatchEnd(s, is_inverse) } else if let Some(s) = part.strip_prefix('\'') { - QueryToken::MatchFull(s) - } else if part == "|" { - QueryToken::Or + QueryToken::MatchFull(s, is_inverse) } else { - QueryToken::Match(part) + QueryToken::Match(part, is_inverse) }; Some(token) } diff --git a/crates/atuin/src/command/client/search/engines/db.rs b/crates/atuin/src/command/client/search/engines/db.rs index a1d22e9f..f0ed424e 100644 --- a/crates/atuin/src/command/client/search/engines/db.rs +++ b/crates/atuin/src/command/client/search/engines/db.rs @@ -65,8 +65,12 @@ fn get_highlight_indices_fulltext(command: &str, search_input: &str) -> Vec {} + QueryToken::Or => {} QueryToken::Regex(r) => { if let Ok(re) = regex::Regex::new(r) { for m in re.find_iter(command) { @@ -74,18 +78,18 @@ fn get_highlight_indices_fulltext(command: &str, search_input: &str) -> Vec { + QueryToken::MatchStart(term, _) => { if matchee.starts_with(term) { ranges.push(0..term.len()); } } - QueryToken::MatchEnd(term) => { + QueryToken::MatchEnd(term, _) => { if matchee.ends_with(term) { let l = matchee.len(); ranges.push((l - term.len())..l); } } - QueryToken::Match(term) | QueryToken::MatchFull(term) => { + QueryToken::Match(term, _) | QueryToken::MatchFull(term, _) => { for (idx, m) in matchee.match_indices(term) { ranges.push(idx..(idx + m.len())); } -- cgit v1.3.1