chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "jcode-fuzzy"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "jcode_fuzzy"
|
||||
path = "src/lib.rs"
|
||||
@@ -0,0 +1,359 @@
|
||||
//! Small typo-resistant fuzzy matcher shared by jcode's terminal and desktop UIs.
|
||||
//!
|
||||
//! The matcher combines subsequence matching with a bounded number of
|
||||
//! substitutions, adjacent transpositions, and extra typed characters. Exact,
|
||||
//! consecutive, boundary, and prefix matches receive bonuses, so typo tolerance
|
||||
//! does not displace stronger literal matches.
|
||||
|
||||
fn is_boundary(c: char) -> bool {
|
||||
matches!(c, '/' | '-' | '_' | ' ' | '.' | ':')
|
||||
}
|
||||
|
||||
const MATCH: i32 = 16;
|
||||
const CONSECUTIVE: i32 = 8;
|
||||
const BOUNDARY: i32 = 9;
|
||||
const FIRST: i32 = 12;
|
||||
const GAP: i32 = -1;
|
||||
const LEADING_GAP: i32 = -3;
|
||||
const SUBSTITUTION: i32 = -10;
|
||||
const DELETION: i32 = -12;
|
||||
const TRANSPOSITION: i32 = 2 * MATCH - 22;
|
||||
const EXACT: i32 = 32;
|
||||
|
||||
/// Result of a successful fuzzy match.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FuzzyMatch {
|
||||
/// Higher scores are better.
|
||||
pub score: i32,
|
||||
/// Matched haystack character indices, sorted ascending. Substitutions and
|
||||
/// deleted pattern characters are intentionally not highlighted.
|
||||
pub positions: Vec<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Cell {
|
||||
score: i32,
|
||||
errors: u8,
|
||||
last: i32,
|
||||
tail_true: bool,
|
||||
positions: Vec<usize>,
|
||||
}
|
||||
|
||||
fn keep_best(slot: &mut Option<Cell>, candidate: Cell) {
|
||||
let replace = match slot {
|
||||
None => true,
|
||||
Some(existing) => {
|
||||
candidate.score > existing.score
|
||||
|| (candidate.score == existing.score && candidate.errors < existing.errors)
|
||||
|| (candidate.score == existing.score
|
||||
&& candidate.errors == existing.errors
|
||||
&& candidate.positions.len() > existing.positions.len())
|
||||
}
|
||||
};
|
||||
if replace {
|
||||
*slot = Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
fn error_budget(meaningful_len: usize) -> u8 {
|
||||
match meaningful_len {
|
||||
0..=2 => 0,
|
||||
3..=8 => 1,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn fuzzy_match_impl(
|
||||
needle: &str,
|
||||
haystack: &str,
|
||||
anchor_first_true_match: bool,
|
||||
strip_leading_slash: bool,
|
||||
require_true_tail: bool,
|
||||
) -> Option<FuzzyMatch> {
|
||||
let (hay_offset, hay_src) = if strip_leading_slash {
|
||||
match haystack.strip_prefix('/') {
|
||||
Some(rest) => (1usize, rest),
|
||||
None => (0usize, haystack),
|
||||
}
|
||||
} else {
|
||||
(0usize, haystack)
|
||||
};
|
||||
let needle_src = if strip_leading_slash {
|
||||
needle.strip_prefix('/').unwrap_or(needle)
|
||||
} else {
|
||||
needle
|
||||
};
|
||||
|
||||
let pat: Vec<char> = needle_src.chars().flat_map(char::to_lowercase).collect();
|
||||
let hay: Vec<char> = hay_src.chars().flat_map(char::to_lowercase).collect();
|
||||
|
||||
if pat.iter().all(|c| c.is_whitespace()) {
|
||||
return Some(FuzzyMatch {
|
||||
score: 0,
|
||||
positions: Vec::new(),
|
||||
});
|
||||
}
|
||||
if hay.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let m = pat.len();
|
||||
let n = hay.len();
|
||||
let meaningful = pat.iter().filter(|c| !c.is_whitespace()).count();
|
||||
let max_err = error_budget(meaningful);
|
||||
let mut dp: Vec<Vec<Option<Cell>>> = vec![vec![None; n + 1]; m + 1];
|
||||
dp[0][0] = Some(Cell {
|
||||
score: 0,
|
||||
errors: 0,
|
||||
last: -1,
|
||||
tail_true: true,
|
||||
positions: Vec::new(),
|
||||
});
|
||||
for j in 1..=n {
|
||||
if let Some(prev) = dp[0][j - 1].clone() {
|
||||
dp[0][j] = Some(Cell {
|
||||
score: prev.score + LEADING_GAP,
|
||||
errors: prev.errors,
|
||||
last: prev.last,
|
||||
tail_true: prev.tail_true,
|
||||
positions: prev.positions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for i in 1..=m {
|
||||
for j in 0..=n {
|
||||
let mut best = None;
|
||||
|
||||
if j >= 1
|
||||
&& let Some(prev) = dp[i][j - 1].clone()
|
||||
{
|
||||
keep_best(
|
||||
&mut best,
|
||||
Cell {
|
||||
score: prev.score + GAP,
|
||||
errors: prev.errors,
|
||||
last: prev.last,
|
||||
tail_true: prev.tail_true,
|
||||
positions: prev.positions,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if j >= 1
|
||||
&& let Some(prev) = dp[i - 1][j - 1].clone()
|
||||
{
|
||||
let pos = j - 1;
|
||||
if pat[i - 1] == hay[pos] {
|
||||
let mut score = prev.score + MATCH;
|
||||
if prev.last == pos as i32 - 1 {
|
||||
score += CONSECUTIVE;
|
||||
}
|
||||
if pos == 0 || is_boundary(hay[pos - 1]) {
|
||||
score += BOUNDARY;
|
||||
}
|
||||
if i == 1 && pos == 0 {
|
||||
score += FIRST;
|
||||
}
|
||||
let mut positions = prev.positions.clone();
|
||||
positions.push(pos);
|
||||
keep_best(
|
||||
&mut best,
|
||||
Cell {
|
||||
score,
|
||||
errors: prev.errors,
|
||||
last: pos as i32,
|
||||
tail_true: true,
|
||||
positions,
|
||||
},
|
||||
);
|
||||
} else if prev.errors < max_err
|
||||
&& !pat[i - 1].is_whitespace()
|
||||
&& !hay[pos].is_whitespace()
|
||||
{
|
||||
keep_best(
|
||||
&mut best,
|
||||
Cell {
|
||||
score: prev.score + SUBSTITUTION,
|
||||
errors: prev.errors + 1,
|
||||
last: pos as i32,
|
||||
tail_true: false,
|
||||
positions: prev.positions,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !pat[i - 1].is_whitespace()
|
||||
&& let Some(prev) = dp[i - 1][j].clone()
|
||||
&& prev.errors < max_err
|
||||
{
|
||||
keep_best(
|
||||
&mut best,
|
||||
Cell {
|
||||
score: prev.score + DELETION,
|
||||
errors: prev.errors + 1,
|
||||
last: prev.last,
|
||||
tail_true: false,
|
||||
positions: prev.positions,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if i >= 2
|
||||
&& j >= 2
|
||||
&& pat[i - 1] == hay[j - 2]
|
||||
&& pat[i - 2] == hay[j - 1]
|
||||
&& pat[i - 1] != pat[i - 2]
|
||||
&& !pat[i - 1].is_whitespace()
|
||||
&& !pat[i - 2].is_whitespace()
|
||||
&& let Some(prev) = dp[i - 2][j - 2].clone()
|
||||
&& prev.errors < max_err
|
||||
{
|
||||
let first = j - 2;
|
||||
let mut score = prev.score + TRANSPOSITION;
|
||||
if first == 0 || is_boundary(hay[first - 1]) {
|
||||
score += BOUNDARY;
|
||||
}
|
||||
let mut positions = prev.positions.clone();
|
||||
positions.push(first);
|
||||
positions.push(j - 1);
|
||||
keep_best(
|
||||
&mut best,
|
||||
Cell {
|
||||
score,
|
||||
errors: prev.errors + 1,
|
||||
last: (j - 1) as i32,
|
||||
tail_true: true,
|
||||
positions,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
dp[i][j] = best;
|
||||
}
|
||||
}
|
||||
|
||||
let mut answer = None;
|
||||
for row in &dp[m] {
|
||||
if let Some(cell) = row.clone()
|
||||
&& (!require_true_tail || cell.tail_true)
|
||||
{
|
||||
keep_best(&mut answer, cell);
|
||||
}
|
||||
}
|
||||
|
||||
let cell = answer?;
|
||||
if anchor_first_true_match && cell.positions.first() != Some(&0) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let exact = pat == hay;
|
||||
Some(FuzzyMatch {
|
||||
score: cell.score + if exact { EXACT } else { 0 },
|
||||
positions: cell.positions.into_iter().map(|p| p + hay_offset).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Match free-form picker/search text. The match may begin at any word boundary
|
||||
/// or interior position, with earlier and boundary-aligned matches scoring higher.
|
||||
pub fn fuzzy_match(needle: &str, haystack: &str) -> Option<FuzzyMatch> {
|
||||
fuzzy_match_impl(needle, haystack, false, false, false)
|
||||
}
|
||||
|
||||
/// Return only the free-form fuzzy score.
|
||||
pub fn fuzzy_score(needle: &str, haystack: &str) -> Option<i32> {
|
||||
fuzzy_match(needle, haystack).map(|matched| matched.score)
|
||||
}
|
||||
|
||||
/// Score search text composed of whitespace-separated metadata fields. A
|
||||
/// single-token query must match within one field, which prevents a weak match
|
||||
/// from stitching characters across unrelated model, provider, and detail
|
||||
/// columns. Multi-word queries may intentionally span the full text.
|
||||
pub fn fuzzy_score_tokens(needle: &str, haystack: &str) -> Option<i32> {
|
||||
let needle = needle.trim();
|
||||
if needle.is_empty() {
|
||||
return Some(0);
|
||||
}
|
||||
if needle.contains(char::is_whitespace) {
|
||||
return fuzzy_score(needle, haystack);
|
||||
}
|
||||
haystack
|
||||
.split_whitespace()
|
||||
.filter_map(|token| fuzzy_score(needle, token))
|
||||
.max()
|
||||
}
|
||||
|
||||
/// Return matched positions for free-form picker highlighting.
|
||||
pub fn fuzzy_match_positions(needle: &str, haystack: &str) -> Vec<usize> {
|
||||
fuzzy_match(needle, haystack)
|
||||
.map(|matched| matched.positions)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Match a slash command. A leading slash is ignored for scoring, and the first
|
||||
/// true character match remains anchored to the command's first letter to keep
|
||||
/// short slash suggestions precise.
|
||||
pub fn command_fuzzy_match(needle: &str, haystack: &str) -> Option<FuzzyMatch> {
|
||||
fuzzy_match_impl(needle, haystack, true, true, true)
|
||||
}
|
||||
|
||||
/// Return only the slash-command fuzzy score.
|
||||
pub fn command_fuzzy_score(needle: &str, haystack: &str) -> Option<i32> {
|
||||
command_fuzzy_match(needle, haystack).map(|matched| matched.score)
|
||||
}
|
||||
|
||||
/// Return matched positions for slash-command highlighting.
|
||||
pub fn command_fuzzy_match_positions(needle: &str, haystack: &str) -> Vec<usize> {
|
||||
command_fuzzy_match(needle, haystack)
|
||||
.map(|matched| matched.positions)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn free_form_matching_tolerates_common_typos() {
|
||||
assert!(fuzzy_match("codxe", "gpt-5-codex").is_some());
|
||||
assert!(fuzzy_match("opuz", "claude-opus-4.6").is_some());
|
||||
assert!(fuzzy_match("tikcet", "ticket-workspace").is_some());
|
||||
assert!(fuzzy_match("coonfig", "config").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_and_prefix_matches_outrank_typo_matches() {
|
||||
let exact = fuzzy_score("codex", "codex").unwrap();
|
||||
let prefix = fuzzy_score("codex", "codex-mini").unwrap();
|
||||
let typo = fuzzy_score("codxe", "codex").unwrap();
|
||||
assert!(exact > prefix);
|
||||
assert!(prefix > typo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_token_match_outranks_a_longer_prefix_token() {
|
||||
let exact = fuzzy_score_tokens("gpt-5", "gpt-5 openai responses").unwrap();
|
||||
let longer = fuzzy_score_tokens("gpt-5", "gpt-5.5 openai responses").unwrap();
|
||||
assert!(exact > longer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_matching_preserves_anchor_and_positions() {
|
||||
let matched = command_fuzzy_match("/conifg", "/config").unwrap();
|
||||
assert_eq!(matched.positions.first(), Some(&1));
|
||||
assert!(command_fuzzy_match("/g", "/config").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_short_or_distant_noise() {
|
||||
assert!(fuzzy_match("xz", "config").is_none());
|
||||
assert!(fuzzy_match("configuration", "model").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_scoring_does_not_stitch_across_metadata_fields() {
|
||||
assert!(fuzzy_score_tokens("codxe", "gpt-5-codex openai coding model").is_some());
|
||||
assert!(fuzzy_score_tokens("codxe", "claude-opus anthropic premium").is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user