aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-client/src
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@atuin.sh>2026-01-26 11:24:46 -0800
committerGitHub <noreply@github.com>2026-01-26 11:24:46 -0800
commit76484d463436e63bda8e677b009bacd042709834 (patch)
tree3379deef42e8d3db2782501977bca06a950d5405 /crates/atuin-client/src
parentdocs: add PowerShell and Windows install instructions (#3096) (diff)
parentfix for inverse matching (diff)
downloadatuin-76484d463436e63bda8e677b009bacd042709834.zip
feat(ui): highlight fulltext search as fulltext search instead of fuzzy search (#3098)
also parse query in one place. This supersedes #3097 and can highlight regex matches as well. <!-- Thank you for making a PR! Bug fixes are always welcome, but if you're adding a new feature or changing an existing one, we'd really appreciate if you open an issue, post on the forum, or drop in on Discord --> ## Checks - [x] I am happy for maintainers to push small adjustments to this PR, to speed up the review cycle - [x] I have checked that there are no existing pull requests for the same thing
Diffstat (limited to 'crates/atuin-client/src')
-rw-r--r--crates/atuin-client/src/database.rs181
1 files changed, 121 insertions, 60 deletions
diff --git a/crates/atuin-client/src/database.rs b/crates/atuin-client/src/database.rs
index 82644182..408e8e52 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,47 @@ 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<str> = 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) {
// 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::MatchFull(term, _) => {
+ 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);
+ sql.fuzzy_condition("command", param, token.is_inverse(), is_glob, is_or);
is_or = false;
}
- if let Some(r) = regex {
- regexes.push(r);
- }
&mut sql
}
@@ -1206,3 +1176,94 @@ 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, bool),
+ MatchStart(&'a str, bool),
+ MatchEnd(&'a str, bool),
+ MatchFull(&'a str, bool),
+ 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::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,
+ }
+ }
+}
+
+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<Self::Item> {
+ 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 (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, is_inverse)
+ } else if let Some(s) = part.strip_suffix('$') {
+ QueryToken::MatchEnd(s, is_inverse)
+ } else if let Some(s) = part.strip_prefix('\'') {
+ QueryToken::MatchFull(s, is_inverse)
+ } else {
+ QueryToken::Match(part, is_inverse)
+ };
+ Some(token)
+ }
+ }
+}