Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion app/src/search/command_search/history/history_data_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use crate::search::async_snapshot_data_source::AsyncSnapshotDataSource;
use crate::search::command_search::searcher::CommandSearchItemAction;
use crate::search::data_source::{Query, QueryResult};
use crate::search::mixer::{BoxFuture, DataSourceRunErrorWrapper};
use crate::settings::AISettings;
use crate::settings::{AISettings, InputSettings};
use crate::terminal;
use crate::terminal::HistoryEntry;
use crate::terminal::model::session::SessionId;
Expand All @@ -23,19 +23,31 @@ pub(crate) struct HistorySnapshot {
commands: Arc<[Arc<HistoryEntry>]>,
query_text: String,
current_session_id: SessionId,
fuzzy_matching_enabled: bool,
}

/// Creates an async data source for shell history commands.
#[cfg(test)]
pub fn history_data_source(
commands: Vec<HistoryEntry>,
) -> AsyncSnapshotDataSource<HistorySnapshot, CommandSearchItemAction> {
history_data_source_with_fuzzy_matching(commands, true)
}

/// Test-only variant of [`history_data_source`] with an explicit fuzzy-matching setting, for
/// exercising the literal-substring path in [`fuzzy_match_history_literal`].
#[cfg(test)]
pub fn history_data_source_with_fuzzy_matching(
commands: Vec<HistoryEntry>,
fuzzy_matching_enabled: bool,
) -> AsyncSnapshotDataSource<HistorySnapshot, CommandSearchItemAction> {
let commands: Arc<[Arc<HistoryEntry>]> = commands.into_iter().map(Arc::new).collect();
AsyncSnapshotDataSource::new(
move |query: &Query, _app: &AppContext| HistorySnapshot {
commands: commands.clone(),
query_text: query.text.clone(),
current_session_id: SessionId::from(0),
fuzzy_matching_enabled,
},
fuzzy_match_history,
)
Expand All @@ -57,6 +69,8 @@ pub(crate) fn history_data_source_for_session(
commands,
query_text: query.text.clone(),
current_session_id: session_id,
fuzzy_matching_enabled: *InputSettings::as_ref(app)
.command_search_fuzzy_matching_enabled,
}
},
fuzzy_match_history,
Expand All @@ -67,6 +81,10 @@ pub(crate) fn fuzzy_match_history(
snapshot: HistorySnapshot,
) -> BoxFuture<'static, Result<Vec<QueryResult<CommandSearchItemAction>>, DataSourceRunErrorWrapper>>
{
if !snapshot.fuzzy_matching_enabled {
return fuzzy_match_history_literal(snapshot);
}

if !FeatureFlag::HistorySearchRankingV2.is_enabled() {
return fuzzy_match_history_legacy(snapshot);
}
Expand Down Expand Up @@ -143,3 +161,35 @@ fn fuzzy_match_history_legacy(
Ok(results)
})
}

fn fuzzy_match_history_literal(
snapshot: HistorySnapshot,
) -> BoxFuture<'static, Result<Vec<QueryResult<CommandSearchItemAction>>, DataSourceRunErrorWrapper>>
{
Box::pin(async move {
let mut results = Vec::new();
let query = snapshot.query_text.trim();
let score = rank::literal_match_score(query);

for chunk in snapshot.commands.chunks(CHUNK_SIZE) {
for entry in chunk {
let Some(match_result) = rank::match_literal_substring(&entry.command, query)
else {
continue;
};

results.push(
HistorySearchItem {
entry: entry.clone(),
match_result,
score,
}
.into(),
);
}
yield_now().await;
}

Ok(results)
})
}
50 changes: 50 additions & 0 deletions app/src/search/command_search/history/rank.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ const CONSECUTIVE_BONUS_PER_CHAR: f64 = 4.0;
/// prefix of a longer one.
const EXACT_WHOLE_LINE_BONUS: f64 = 12.0;

/// Fraction of a query's self-match score (see `literal_match_score`) used as every
/// literal-substring match's shared score. The full self-match score is the ceiling for a query
/// of that length -- using it outright would let a merely-containing history match tie the best
/// possible match from every other Command Search source. `0.5` instead matches the highest
/// per-field weight `FuzzyMatchWorkflowResult` already gives its own strongest field, so a
/// literal match competes with, rather than always beating, an equally strong match elsewhere.
const LITERAL_MATCH_SCORE_FRACTION: f64 = 0.5;

/// Recency assigned to entries with no timestamp (history-file rows with no matching sqlite
/// record), i.e. exactly between "as fresh as possible" (1.0) and "as stale as possible" (0.0):
/// there's no data to justify treating an untracked entry as either.
Expand Down Expand Up @@ -225,6 +233,48 @@ fn age_days(start_ts: DateTime<Local>, now: DateTime<Local>) -> f64 {
((now - start_ts).num_seconds() as f64 / seconds_per_day).max(0.0)
}

/// Matches `query` against `command` as a literal, contiguous, ASCII-case-insensitive substring
/// rather than as a fuzzy subsequence -- the semantics of bash/zsh's reverse history search. An
/// empty `query` matches everything at the start, the same as `str::contains("")`.
pub(crate) fn match_literal_substring(command: &str, query: &str) -> Option<FuzzyMatchResult> {
let command_chars: Vec<char> = command.chars().collect();
let query_chars: Vec<char> = query.chars().collect();
if query_chars.is_empty() {
return Some(FuzzyMatchResult {
score: 0,
matched_indices: vec![],
});
}
if query_chars.len() > command_chars.len() {
return None;
}

let start = (0..=command_chars.len() - query_chars.len()).find(|&start| {
command_chars[start..start + query_chars.len()]
.iter()
.zip(&query_chars)
.all(|(command_char, query_char)| command_char.eq_ignore_ascii_case(query_char))
})?;

Some(FuzzyMatchResult {
score: 0,
matched_indices: (start..start + query_chars.len()).collect(),
})
}

/// Score shared by every literal-substring match for a given `query`, derived from the raw Skim
/// score of matching `query` against itself (see [`LITERAL_MATCH_SCORE_FRACTION`]). Every
/// candidate that passes [`match_literal_substring`] gets this identical, genuinely
/// raw-Skim-scale score, so ties break via the mixer's stable sort, which preserves
/// `History::commands_shared`'s already-chronological candidate order -- ordering results
/// most-recent-first, the way bash/zsh's non-ranking reverse search does, while staying
/// comparable to every other Command Search source's own Skim-based score.
pub(crate) fn literal_match_score(query: &str) -> OrderedFloat<f64> {
let self_match_score =
fuzzy_match::match_indices_case_insensitive(query, query).map_or(0, |result| result.score);
OrderedFloat(self_match_score as f64 * LITERAL_MATCH_SCORE_FRACTION)
}

#[cfg(test)]
#[path = "rank_tests.rs"]
mod tests;
57 changes: 57 additions & 0 deletions app/src/search/command_search/history/rank_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,60 @@ fn blank_query_ignores_priors_and_yields_a_result() {
chronological order, not just bypass the score floor"
);
}

#[test]
fn literal_substring_matches_regardless_of_case() {
assert!(match_literal_substring("git STATUS", "status").is_some());
assert!(match_literal_substring("GIT status", "GIT").is_some());
}

#[test]
fn literal_substring_does_not_match_a_subsequence() {
assert!(
match_literal_substring("list docker containers", "ldc").is_none(),
"a fuzzy subsequence match must not count as a literal substring match"
);
}

#[test]
fn literal_substring_does_not_tokenize_the_query() {
assert!(
match_literal_substring("cd ~/projects/history_orm", "cd hi orm").is_none(),
"unlike the fuzzy path's whitespace-AND tokenization, the literal query must occur \
verbatim, not as separately-matched terms"
);
assert!(match_literal_substring("cd hi orm now", "cd hi orm").is_some());
}

#[test]
fn literal_substring_match_indices_span_the_match() {
let result = match_literal_substring("git checkout master", "checkout").expect("should match");
assert_eq!(result.matched_indices, vec![4, 5, 6, 7, 8, 9, 10, 11]);
}

#[test]
fn literal_substring_blank_query_matches_with_no_highlight() {
let result = match_literal_substring("anything", "").expect("blank query matches everything");
assert!(result.matched_indices.is_empty());
}

#[test]
fn literal_match_score_is_half_the_query_matched_against_itself() {
let self_match_score = fuzzy_match::match_indices_case_insensitive("git status", "git status")
.expect("a string should fuzzy-match itself")
.score;
assert_eq!(
literal_match_score("git status"),
OrderedFloat(self_match_score as f64 * 0.5)
);
}

#[test]
fn literal_match_score_is_identical_for_every_query_length() {
assert_eq!(literal_match_score("foo"), literal_match_score("foo"));
assert_ne!(
literal_match_score("foo"),
literal_match_score("foo bar baz"),
"a longer query should score higher, since it's matched against itself"
);
}
Loading
Loading