Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_contains_wildcards() {
|
||||
assert!(contains_wildcards("*.rs"));
|
||||
assert!(contains_wildcards("file?.txt"));
|
||||
assert!(contains_wildcards("*test*"));
|
||||
assert!(!contains_wildcards("normal_file.rs"));
|
||||
assert!(!contains_wildcards("test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_ui_pattern() {
|
||||
// Debug the ui/* pattern step by step
|
||||
let text = "/src/ui/button.rs";
|
||||
let pattern = "ui/*";
|
||||
|
||||
// Test if it should hit the prefix fast path
|
||||
let is_prefix_pattern = pattern.ends_with('*')
|
||||
&& !pattern[..pattern.len() - 1].contains('*')
|
||||
&& !pattern[..pattern.len() - 1].contains('?');
|
||||
assert!(is_prefix_pattern);
|
||||
|
||||
let prefix = &pattern[..pattern.len() - 1];
|
||||
assert_eq!(prefix, "ui/");
|
||||
assert!(text.contains(prefix)); // Changed from starts_with to contains since text starts with "/src"
|
||||
|
||||
let result = match_wildcard_pattern(text, pattern);
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_pattern_star() {
|
||||
let result = match_wildcard_pattern("button.rs", "*.rs");
|
||||
assert!(result.is_some());
|
||||
let match_result = result.unwrap();
|
||||
assert!(match_result.score > 0);
|
||||
assert!(!match_result.matched_indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_pattern_question_mark() {
|
||||
let result = match_wildcard_pattern("button.rs", "butto?.rs");
|
||||
assert!(result.is_some());
|
||||
let match_result = result.unwrap();
|
||||
assert!(match_result.score > 0);
|
||||
assert!(!match_result.matched_indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_pattern_no_match() {
|
||||
let result = match_wildcard_pattern("button.rs", "*.py");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_pattern_case_insensitive() {
|
||||
let result = match_wildcard_pattern_case_insensitive("Button.RS", "*.rs");
|
||||
assert!(result.is_some());
|
||||
let match_result = result.unwrap();
|
||||
assert!(match_result.score > 0);
|
||||
assert!(!match_result.matched_indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_pattern_complex() {
|
||||
let result = match_wildcard_pattern("/src/ui/button.rs", "*/ui/*.rs");
|
||||
assert!(result.is_some());
|
||||
let match_result = result.unwrap();
|
||||
assert!(match_result.score > 0);
|
||||
assert!(!match_result.matched_indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_pattern_exact_match() {
|
||||
let result = match_wildcard_pattern("/src/components/button.rs", "/src/components/button.rs");
|
||||
assert!(result.is_some());
|
||||
let match_result = result.unwrap();
|
||||
assert!(match_result.score > 0);
|
||||
assert!(!match_result.matched_indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_escape_special_chars() {
|
||||
// Test that regex special characters are properly escaped
|
||||
let result = match_wildcard_pattern("test.file", "test.file");
|
||||
assert!(result.is_some());
|
||||
|
||||
let result = match_wildcard_pattern("testXfile", "test.file");
|
||||
assert!(result.is_none()); // Should not match because . is literal, not wildcard
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_still_works() {
|
||||
// Ensure regular fuzzy matching still works
|
||||
let result = match_indices_case_insensitive("button.rs", "btn");
|
||||
assert!(result.is_some());
|
||||
let match_result = result.unwrap();
|
||||
assert!(match_result.score > 0);
|
||||
assert!(!match_result.matched_indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_star_pattern() {
|
||||
// Test the specific ui/* pattern that's not working
|
||||
let result = match_wildcard_pattern_case_insensitive("/src/ui/button.rs", "ui/*");
|
||||
assert!(result.is_some());
|
||||
let match_result = result.unwrap();
|
||||
assert!(match_result.score > 0);
|
||||
assert!(!match_result.matched_indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefix_star_patterns() {
|
||||
// Test various prefix/* patterns
|
||||
let result1 = match_wildcard_pattern("src/ui/button.rs", "src/*");
|
||||
assert!(result1.is_some());
|
||||
|
||||
let result2 = match_wildcard_pattern("ui/button.rs", "ui/*");
|
||||
assert!(result2.is_some());
|
||||
|
||||
let result3 = match_wildcard_pattern("/src/ui/button.rs", "ui/*");
|
||||
// This should now match because we do substring matching
|
||||
assert!(result3.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partial_suffix_patterns() {
|
||||
// Test partial suffix patterns for progressive typing
|
||||
let result1 = match_wildcard_pattern("button.rs", "*.r");
|
||||
assert!(result1.is_some());
|
||||
|
||||
let result2 = match_wildcard_pattern("component.tsx", "*.t");
|
||||
assert!(result2.is_some());
|
||||
|
||||
let result3 = match_wildcard_pattern("test.js", "*.");
|
||||
assert!(result3.is_some());
|
||||
|
||||
// Should not match if there's no matching suffix
|
||||
let result4 = match_wildcard_pattern("button.rs", "*.py");
|
||||
assert!(result4.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_star_partial_patterns() {
|
||||
// Test the specific cases mentioned in the issue
|
||||
let path = "/src/ui/button.rs";
|
||||
|
||||
let result1 = match_wildcard_pattern_case_insensitive(path, "ui/*.rs");
|
||||
assert!(result1.is_some());
|
||||
|
||||
let result2 = match_wildcard_pattern_case_insensitive(path, "ui/*.r");
|
||||
assert!(result2.is_some());
|
||||
|
||||
let result3 = match_wildcard_pattern_case_insensitive(path, "ui/*.");
|
||||
assert!(result3.is_some());
|
||||
}
|
||||
|
||||
use crate::{
|
||||
match_indices, match_indices_case_insensitive, match_indices_case_insensitive_ignore_spaces,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_simple_fuzzy_match_indices() {
|
||||
let text = "axbycz";
|
||||
let query = "abc";
|
||||
|
||||
let result = match_indices(text, query).unwrap();
|
||||
assert_eq!(result.matched_indices, vec![0, 2, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multibyte_char_fuzzy_match_indices() {
|
||||
let text = "ay東cz";
|
||||
let query = "a東c";
|
||||
|
||||
let result = match_indices(text, query).unwrap();
|
||||
assert_eq!(result.matched_indices, vec![0, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_query_fuzzy_match_search() {
|
||||
let text = "abcdef";
|
||||
let query = "";
|
||||
|
||||
let result = match_indices(text, query).unwrap();
|
||||
assert_eq!(result.matched_indices.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_match_query_fuzzy_match_search() {
|
||||
let text = "abcdef";
|
||||
let query = "ghijk";
|
||||
|
||||
let result = match_indices(text, query);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive_fuzzy_match_search() {
|
||||
let text = "AXBYcz";
|
||||
let query = "abC";
|
||||
|
||||
let result = match_indices_case_insensitive(text, query).unwrap();
|
||||
assert_eq!(result.matched_indices, vec![0, 2, 4]);
|
||||
}
|
||||
|
||||
// Tests for match_indices_case_insensitive_ignore_spaces
|
||||
|
||||
#[test]
|
||||
fn test_ignore_spaces_basic_functionality() {
|
||||
let text = "myFunction";
|
||||
let query_with_spaces = "my func";
|
||||
let query_without_spaces = "myfunc";
|
||||
|
||||
// Both queries should produce the same result
|
||||
let result_with_spaces = match_indices_case_insensitive_ignore_spaces(text, query_with_spaces);
|
||||
let result_without_spaces = match_indices_case_insensitive(text, query_without_spaces);
|
||||
|
||||
assert!(result_with_spaces.is_some());
|
||||
assert!(result_without_spaces.is_some());
|
||||
|
||||
let with_spaces = result_with_spaces.unwrap();
|
||||
let without_spaces = result_without_spaces.unwrap();
|
||||
|
||||
assert_eq!(with_spaces.matched_indices, without_spaces.matched_indices);
|
||||
assert_eq!(with_spaces.score, without_spaces.score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignore_spaces_symbol_matching() {
|
||||
let text = "myFunction";
|
||||
let query = "my func";
|
||||
|
||||
let result = match_indices_case_insensitive_ignore_spaces(text, query).unwrap();
|
||||
// Should match "my" + "func" in "myFunction"
|
||||
assert_eq!(result.matched_indices, vec![0, 1, 2, 3, 4, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignore_spaces_case_insensitive() {
|
||||
let text = "MyFunction";
|
||||
let query = "my FUNC";
|
||||
|
||||
let result = match_indices_case_insensitive_ignore_spaces(text, query).unwrap();
|
||||
// Should match "My" + "Func" in "MyFunction" (case insensitive)
|
||||
assert_eq!(result.matched_indices, vec![0, 1, 2, 3, 4, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignore_spaces_multiple_spaces() {
|
||||
let text = "calculateSum";
|
||||
let query = "calc sum"; // Multiple spaces
|
||||
|
||||
let result = match_indices_case_insensitive_ignore_spaces(text, query).unwrap();
|
||||
// Should match "calc" + "Sum" in "calculateSum"
|
||||
assert_eq!(result.matched_indices, vec![0, 1, 2, 3, 9, 10, 11]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignore_spaces_leading_trailing_spaces() {
|
||||
let text = "myFunction";
|
||||
let query = " my func "; // Leading and trailing spaces
|
||||
|
||||
let result = match_indices_case_insensitive_ignore_spaces(text, query).unwrap();
|
||||
// Should still match "my" + "func" in "myFunction"
|
||||
assert_eq!(result.matched_indices, vec![0, 1, 2, 3, 4, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignore_spaces_tabs_and_newlines() {
|
||||
let text = "myFunction";
|
||||
let query = "my\tfunc\n"; // Tab and newline
|
||||
|
||||
let result = match_indices_case_insensitive_ignore_spaces(text, query).unwrap();
|
||||
// Should match "my" + "func" in "myFunction"
|
||||
assert_eq!(result.matched_indices, vec![0, 1, 2, 3, 4, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignore_spaces_only_spaces_query() {
|
||||
let text = "myFunction";
|
||||
let query = " "; // Only spaces
|
||||
|
||||
let result = match_indices_case_insensitive_ignore_spaces(text, query);
|
||||
// Should return None because query becomes empty after removing spaces
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignore_spaces_empty_query() {
|
||||
let text = "myFunction";
|
||||
let query = "";
|
||||
|
||||
let result = match_indices_case_insensitive_ignore_spaces(text, query);
|
||||
// Should return None because query is empty
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignore_spaces_no_match() {
|
||||
let text = "myFunction";
|
||||
let query = "hello world";
|
||||
|
||||
let result = match_indices_case_insensitive_ignore_spaces(text, query);
|
||||
// Should return None because "helloworld" doesn't match "myFunction"
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignore_spaces_partial_match() {
|
||||
let text = "getUserName";
|
||||
let query = "get user";
|
||||
|
||||
let result = match_indices_case_insensitive_ignore_spaces(text, query).unwrap();
|
||||
// Should match "get" + "User" in "getUserName"
|
||||
assert_eq!(result.matched_indices, vec![0, 1, 2, 3, 4, 5, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignore_spaces_scattered_match() {
|
||||
let text = "myAwesomeFunction";
|
||||
let query = "my func";
|
||||
|
||||
let result = match_indices_case_insensitive_ignore_spaces(text, query).unwrap();
|
||||
// Should match "my" + scattered "func" letters
|
||||
// This will depend on the fuzzy matching algorithm's behavior
|
||||
assert!(!result.matched_indices.is_empty());
|
||||
assert!(result.score > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignore_spaces_exact_match_with_spaces() {
|
||||
let text = "myfunction";
|
||||
let query = "my function";
|
||||
|
||||
// This won't be an exact match because the text doesn't contain spaces
|
||||
// But it should still match the characters
|
||||
let result = match_indices_case_insensitive_ignore_spaces(text, query).unwrap();
|
||||
assert!(!result.matched_indices.is_empty());
|
||||
assert!(result.score > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignore_spaces_vs_regular_matching() {
|
||||
let text = "myFunction";
|
||||
let query_with_spaces = "my func";
|
||||
|
||||
// Regular matching should fail
|
||||
let regular_result = match_indices_case_insensitive(text, query_with_spaces);
|
||||
assert!(regular_result.is_none());
|
||||
|
||||
// Space-ignoring matching should succeed
|
||||
let space_ignoring_result =
|
||||
match_indices_case_insensitive_ignore_spaces(text, query_with_spaces);
|
||||
assert!(space_ignoring_result.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignore_spaces_single_word_query() {
|
||||
let text = "myFunction";
|
||||
let query = "function";
|
||||
|
||||
// Single word query should work the same as regular matching
|
||||
let regular_result = match_indices_case_insensitive(text, query).unwrap();
|
||||
let space_ignoring_result = match_indices_case_insensitive_ignore_spaces(text, query).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
regular_result.matched_indices,
|
||||
space_ignoring_result.matched_indices
|
||||
);
|
||||
assert_eq!(regular_result.score, space_ignoring_result.score);
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
//! # Fuzzy Match
|
||||
//!
|
||||
//! This crate provides fuzzy matching capabilities with support for both traditional
|
||||
//! fuzzy search and glob-style wildcard patterns optimized for file path searching.
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! ### Traditional Fuzzy Matching
|
||||
//! - [`match_indices`] - Smart case fuzzy matching
|
||||
//! - [`match_indices_case_insensitive`] - Case-insensitive fuzzy matching
|
||||
//!
|
||||
//! ### Wildcard Pattern Matching
|
||||
//! - [`match_wildcard_pattern`] - Glob-style patterns with `*` and `?` support
|
||||
//! - [`match_wildcard_pattern_case_insensitive`] - Case-insensitive wildcard matching
|
||||
//! - [`contains_wildcards`] - Check if a query contains wildcard characters
|
||||
//!
|
||||
//! ## Wildcard Pattern Examples
|
||||
//!
|
||||
//! ```rust
|
||||
//! use fuzzy_match::{match_wildcard_pattern, match_wildcard_pattern_case_insensitive};
|
||||
//!
|
||||
//! // File extension matching
|
||||
//! assert!(match_wildcard_pattern("button.rs", "*.rs").is_some());
|
||||
//! assert!(match_wildcard_pattern("component.tsx", "*.tsx").is_some());
|
||||
//!
|
||||
//! // Progressive typing support
|
||||
//! assert!(match_wildcard_pattern("button.rs", "*.r").is_some()); // Matches .rs, .rb, etc.
|
||||
//! assert!(match_wildcard_pattern("test.js", "*.").is_some()); // Matches any extension start
|
||||
//!
|
||||
//! // Directory patterns
|
||||
//! assert!(match_wildcard_pattern("/src/ui/button.rs", "ui/*").is_some());
|
||||
//! assert!(match_wildcard_pattern("/src/components/button.rs", "src/*").is_some());
|
||||
//!
|
||||
//! // Complex patterns
|
||||
//! assert!(match_wildcard_pattern("/src/ui/button.rs", "*/ui/*.rs").is_some());
|
||||
//!
|
||||
//! // Case insensitive matching
|
||||
//! assert!(match_wildcard_pattern_case_insensitive("Button.RS", "*.rs").is_some());
|
||||
//! assert!(match_wildcard_pattern_case_insensitive("/src/UI/button.rs", "ui/*").is_some());
|
||||
//! ```
|
||||
//!
|
||||
//! ## Performance
|
||||
//!
|
||||
//! The wildcard matching is optimized for file search use cases:
|
||||
//! - **Fast paths** for common patterns like `*.rs` and `src/*`
|
||||
//! - **Substring matching** for file paths (patterns match anywhere in the path)
|
||||
//! - **No regex compilation** - uses efficient recursive algorithms
|
||||
//! - **Progressive typing** support for better user experience
|
||||
//!
|
||||
//! ## Wildcard Characters
|
||||
//!
|
||||
//! - `*` - Matches zero or more characters
|
||||
//! - `?` - Matches exactly one character
|
||||
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||
pub struct FuzzyMatchResult {
|
||||
pub score: i64,
|
||||
/// matched_indices is a vector of char indices
|
||||
pub matched_indices: Vec<usize>,
|
||||
}
|
||||
|
||||
impl FuzzyMatchResult {
|
||||
/// Constructs a dummy [`FuzzyMatchResult`] that represents an item that is unmatched. The item
|
||||
/// will have no matching indices and a score of 0.
|
||||
pub fn no_match() -> Self {
|
||||
Self {
|
||||
score: 0,
|
||||
matched_indices: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a fuzzy matching algorithm on text and query strings.
|
||||
/// If query contains no uppercase letters, then it will be insensitive to casing.
|
||||
/// If query does contain uppercase letters, then it will be case sensitive.
|
||||
/// Returns struct that contains the score of the match and a vector
|
||||
/// of matching byte indices.
|
||||
pub fn match_indices(text: &str, query: &str) -> Option<FuzzyMatchResult> {
|
||||
match_internal(text, query, SkimMatcherV2::default())
|
||||
}
|
||||
|
||||
/// Performs a case insensitive fuzzy matching algorithm on text and query strings.
|
||||
/// Returns struct that contains the score of the match and a vector
|
||||
/// of matching byte indices.
|
||||
pub fn match_indices_case_insensitive(text: &str, query: &str) -> Option<FuzzyMatchResult> {
|
||||
match_internal(text, query, SkimMatcherV2::default().ignore_case())
|
||||
}
|
||||
|
||||
/// Performs a case insensitive fuzzy matching algorithm on text and query strings,
|
||||
/// ignoring spaces in the query. This is useful for symbol name matching where
|
||||
/// spaces in the search query should be ignored.
|
||||
///
|
||||
/// The function removes all spaces from the query before performing the match,
|
||||
/// but returns indices that correspond to the original text.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use fuzzy_match::match_indices_case_insensitive_ignore_spaces;
|
||||
///
|
||||
/// // Query "my func" matches "myFunc" by ignoring spaces
|
||||
/// let result = match_indices_case_insensitive_ignore_spaces("myFunction", "my func");
|
||||
/// assert!(result.is_some());
|
||||
/// ```
|
||||
pub fn match_indices_case_insensitive_ignore_spaces(
|
||||
text: &str,
|
||||
query: &str,
|
||||
) -> Option<FuzzyMatchResult> {
|
||||
// Remove all spaces from the query
|
||||
let query_no_spaces: String = query.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
|
||||
// If the query becomes empty after removing spaces, return no match
|
||||
if query_no_spaces.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Perform the fuzzy match with the space-stripped query
|
||||
match_internal(
|
||||
text,
|
||||
&query_no_spaces,
|
||||
SkimMatcherV2::default().ignore_case(),
|
||||
)
|
||||
}
|
||||
|
||||
fn match_internal(text: &str, query: &str, matcher: SkimMatcherV2) -> Option<FuzzyMatchResult> {
|
||||
matcher
|
||||
// The fuzzy_indices API returns char indices, so we don't need to manually convert.
|
||||
.fuzzy_indices(text, query)
|
||||
.map(|(score, indices)| FuzzyMatchResult {
|
||||
score,
|
||||
matched_indices: indices,
|
||||
})
|
||||
}
|
||||
|
||||
/// Checks if a query contains wildcard characters (* or ?).
|
||||
///
|
||||
/// Wildcard characters are:
|
||||
/// - `*` matches zero or more characters
|
||||
/// - `?` matches exactly one character
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use fuzzy_match::contains_wildcards;
|
||||
///
|
||||
/// assert!(contains_wildcards("*.rs"));
|
||||
/// assert!(contains_wildcards("file?.txt"));
|
||||
/// assert!(contains_wildcards("*test*"));
|
||||
/// assert!(!contains_wildcards("normal_file.rs"));
|
||||
/// assert!(!contains_wildcards("test"));
|
||||
/// ```
|
||||
pub fn contains_wildcards(query: &str) -> bool {
|
||||
query.contains('*') || query.contains('?')
|
||||
}
|
||||
|
||||
/// Performs pattern matching with wildcard support (* and ?) on text and query strings.
|
||||
///
|
||||
/// This function supports glob-style patterns and is optimized for file path matching.
|
||||
/// It uses fast paths for common patterns and supports progressive typing.
|
||||
///
|
||||
/// # Wildcard characters
|
||||
///
|
||||
/// - `*` matches zero or more characters
|
||||
/// - `?` matches exactly one character
|
||||
///
|
||||
/// # Features
|
||||
///
|
||||
/// - **Fast paths**: Optimized for common patterns like `*.rs`, `src/*`
|
||||
/// - **Substring matching**: Patterns like `ui/*` match anywhere in the path
|
||||
/// - **Progressive typing**: Partial patterns like `*.r` match `.rs`, `.rb`, etc.
|
||||
/// - **Case sensitive**: Use [`match_wildcard_pattern_case_insensitive`] for case-insensitive matching
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use fuzzy_match::match_wildcard_pattern;
|
||||
///
|
||||
/// // Suffix patterns
|
||||
/// assert!(match_wildcard_pattern("button.rs", "*.rs").is_some());
|
||||
/// assert!(match_wildcard_pattern("component.tsx", "*.tsx").is_some());
|
||||
///
|
||||
/// // Progressive typing support
|
||||
/// assert!(match_wildcard_pattern("button.rs", "*.r").is_some());
|
||||
/// assert!(match_wildcard_pattern("test.js", "*.").is_some());
|
||||
///
|
||||
/// // Prefix patterns
|
||||
/// assert!(match_wildcard_pattern("src/components/button.rs", "src/*").is_some());
|
||||
///
|
||||
/// // Complex patterns
|
||||
/// assert!(match_wildcard_pattern("/src/ui/button.rs", "*/ui/*.rs").is_some());
|
||||
///
|
||||
/// // Question mark patterns
|
||||
/// assert!(match_wildcard_pattern("test1.rs", "test?.rs").is_some());
|
||||
/// assert!(match_wildcard_pattern("test12.rs", "test?.rs").is_none());
|
||||
///
|
||||
/// // No match
|
||||
/// assert!(match_wildcard_pattern("button.rs", "*.py").is_none());
|
||||
/// ```
|
||||
///
|
||||
/// # Performance
|
||||
///
|
||||
/// This function is optimized for common file search patterns:
|
||||
/// - Simple suffix patterns like `*.rs` use O(1) string operations
|
||||
/// - Simple prefix patterns like `src/*` use O(1) string operations
|
||||
/// - Complex patterns use efficient recursive matching without regex
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Some(FuzzyMatchResult)` with:
|
||||
/// - `score`: Higher scores (1000+) for exact matches, lower scores (800+) for partial matches
|
||||
/// - `matched_indices`: Character indices of the matched portions in the original text
|
||||
///
|
||||
/// Returns `None` if the pattern doesn't match.
|
||||
pub fn match_wildcard_pattern(text: &str, pattern: &str) -> Option<FuzzyMatchResult> {
|
||||
if pattern.is_empty() {
|
||||
return Some(FuzzyMatchResult::no_match());
|
||||
}
|
||||
|
||||
// Fast path for simple suffix patterns like "*.rs" or partial suffixes like "*.r"
|
||||
if pattern.starts_with('*') && !pattern[1..].contains('*') && !pattern[1..].contains('?') {
|
||||
let suffix = &pattern[1..];
|
||||
|
||||
// First try exact suffix match
|
||||
if text.ends_with(suffix) {
|
||||
let text_char_count = text.chars().count();
|
||||
let suffix_char_count = suffix.chars().count();
|
||||
let start_char_idx = text_char_count - suffix_char_count;
|
||||
let matched_indices: Vec<usize> = (start_char_idx..text_char_count).collect();
|
||||
return Some(FuzzyMatchResult {
|
||||
score: 1000,
|
||||
matched_indices,
|
||||
});
|
||||
}
|
||||
|
||||
// If no exact match, try partial suffix match (for progressive typing)
|
||||
if let Some(match_info) = find_partial_suffix_match(text, suffix) {
|
||||
return Some(FuzzyMatchResult {
|
||||
score: 800, // Lower score than exact match but still good
|
||||
matched_indices: match_info,
|
||||
});
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
// Fast path for simple prefix patterns like "src/*" - but only if it starts at the beginning
|
||||
if pattern.ends_with('*')
|
||||
&& !pattern[..pattern.len() - 1].contains('*')
|
||||
&& !pattern[..pattern.len() - 1].contains('?')
|
||||
{
|
||||
let prefix = &pattern[..pattern.len() - 1];
|
||||
if text.starts_with(prefix) {
|
||||
let prefix_char_count = prefix.chars().count();
|
||||
let matched_indices: Vec<usize> = (0..prefix_char_count).collect();
|
||||
return Some(FuzzyMatchResult {
|
||||
score: 1000,
|
||||
matched_indices,
|
||||
});
|
||||
}
|
||||
// Don't return None here - fall through to substring matching
|
||||
}
|
||||
|
||||
// For file paths, we want to match patterns anywhere in the path, not just at the beginning
|
||||
// Try to find the pattern as a substring match first
|
||||
if let Some(match_info) = find_substring_glob_match(text, pattern) {
|
||||
return Some(FuzzyMatchResult {
|
||||
score: 1000,
|
||||
matched_indices: match_info,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback to exact glob matching for complex patterns
|
||||
if is_glob_match(text, pattern) {
|
||||
// For complex patterns, we'll mark the entire text as matched
|
||||
// This is a simplification but much faster than tracking exact indices
|
||||
let matched_indices: Vec<usize> = (0..text.chars().count()).collect();
|
||||
|
||||
let score = if pattern.contains('*') || pattern.contains('?') {
|
||||
1000 // Good score for wildcard matches
|
||||
} else {
|
||||
2000 // Higher score for exact matches
|
||||
};
|
||||
|
||||
Some(FuzzyMatchResult {
|
||||
score,
|
||||
matched_indices,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds partial suffix matches for progressive typing support.
|
||||
///
|
||||
/// This function enables progressive typing by matching partial file extensions.
|
||||
/// For example, when a user types `*.r`, it should match files ending in `.rs`, `.rb`, etc.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `text` - The text to search in (e.g., "button.rs")
|
||||
/// * `partial_suffix` - The partial suffix to match (e.g., ".r")
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Some(Vec<usize>)` with character indices of the matched portion,
|
||||
/// or `None` if no match is found.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// // This would be called internally when matching "*.r" against "button.rs"
|
||||
/// // find_partial_suffix_match("button.rs", ".r") -> Some([6, 7])
|
||||
/// ```
|
||||
fn find_partial_suffix_match(text: &str, partial_suffix: &str) -> Option<Vec<usize>> {
|
||||
if partial_suffix.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Look for any suffix in the text that starts with our partial suffix
|
||||
let text_chars: Vec<char> = text.chars().collect();
|
||||
let partial_chars: Vec<char> = partial_suffix.chars().collect();
|
||||
|
||||
// Start from the end of the text and work backwards to find potential suffix matches
|
||||
for start_pos in (0..text_chars.len()).rev() {
|
||||
// Check if the remaining text starts with our partial suffix
|
||||
let remaining_text = &text_chars[start_pos..];
|
||||
|
||||
if remaining_text.len() >= partial_chars.len() {
|
||||
let matches = remaining_text
|
||||
.iter()
|
||||
.zip(partial_chars.iter())
|
||||
.all(|(t, p)| t.eq_ignore_ascii_case(p));
|
||||
|
||||
if matches {
|
||||
// Found a match! Return the indices of the matched part
|
||||
let matched_indices: Vec<usize> =
|
||||
(start_pos..start_pos + partial_chars.len()).collect();
|
||||
return Some(matched_indices);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Fast glob pattern matching without regex.
|
||||
///
|
||||
/// Implements a simple recursive algorithm for glob patterns that avoids
|
||||
/// the overhead of regex compilation and matching.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `text` - The text to match against
|
||||
/// * `pattern` - The glob pattern (supports * and ?)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `true` if the pattern matches the entire text from start to end.
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// This function requires the pattern to match the entire text, unlike
|
||||
/// [`find_substring_glob_match`] which finds patterns anywhere in the text.
|
||||
fn is_glob_match(text: &str, pattern: &str) -> bool {
|
||||
is_glob_match_recursive(text.as_bytes(), pattern.as_bytes(), 0, 0)
|
||||
}
|
||||
|
||||
/// Finds a glob pattern as a substring anywhere in the text.
|
||||
///
|
||||
/// This is the core function that enables substring matching for file paths.
|
||||
/// Unlike standard glob matching that requires patterns to match from the start,
|
||||
/// this function finds patterns anywhere within the text.
|
||||
///
|
||||
/// # Key Features
|
||||
///
|
||||
/// - **Substring matching**: `ui/*` matches `/src/ui/button.rs`
|
||||
/// - **Complex patterns**: `ui/*.r` matches `/src/ui/button.rs` (progressive typing)
|
||||
/// - **Performance optimized**: Uses fast paths for common patterns
|
||||
/// - **Recursion safe**: Prevents infinite recursion for complex patterns
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `text` - The text to search in (e.g., "/src/ui/button.rs")
|
||||
/// * `pattern` - The glob pattern to find (e.g., "ui/*.r")
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Some(Vec<usize>)` with character indices of all matched portions,
|
||||
/// or `None` if the pattern is not found anywhere in the text.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// // Pattern "ui/*.rs" in text "/src/ui/button.rs"
|
||||
/// // Would return indices for "ui/" and ".rs" portions
|
||||
/// // Some([5, 6, 7, 14, 15, 16])
|
||||
/// ```
|
||||
fn find_substring_glob_match(text: &str, pattern: &str) -> Option<Vec<usize>> {
|
||||
// Handle patterns like "ui/*.r" - need to find the prefix and match the suffix pattern
|
||||
if let Some(star_pos) = pattern.find('*') {
|
||||
let prefix = &pattern[..star_pos];
|
||||
let suffix_pattern = &pattern[star_pos..];
|
||||
|
||||
// Find the prefix in the text
|
||||
if let Some(prefix_start) = text.find(prefix) {
|
||||
let prefix_end = prefix_start + prefix.len();
|
||||
let remaining_text = &text[prefix_end..];
|
||||
|
||||
// Try to match the suffix pattern against the remaining text
|
||||
// Use a non-recursive approach for simple suffix patterns to avoid infinite recursion
|
||||
let suffix_result = if suffix_pattern.starts_with('*')
|
||||
&& !suffix_pattern[1..].contains('*')
|
||||
&& !suffix_pattern[1..].contains('?')
|
||||
{
|
||||
// Handle simple suffix patterns directly
|
||||
let suffix_part = &suffix_pattern[1..];
|
||||
if remaining_text.ends_with(suffix_part) {
|
||||
let remaining_char_count = remaining_text.chars().count();
|
||||
let suffix_char_count = suffix_part.chars().count();
|
||||
let start_idx = remaining_char_count - suffix_char_count;
|
||||
Some(FuzzyMatchResult {
|
||||
score: 1000,
|
||||
matched_indices: (start_idx..remaining_char_count).collect(),
|
||||
})
|
||||
} else {
|
||||
find_partial_suffix_match(remaining_text, suffix_part).map(|partial_match| {
|
||||
FuzzyMatchResult {
|
||||
score: 800,
|
||||
matched_indices: partial_match,
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// For complex patterns, use the full matching but prevent recursion
|
||||
if is_glob_match(remaining_text, suffix_pattern) {
|
||||
Some(FuzzyMatchResult {
|
||||
score: 1000,
|
||||
matched_indices: (0..remaining_text.chars().count()).collect(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(suffix_result) = suffix_result {
|
||||
// Combine the matched indices
|
||||
let prefix_start_char = text[..prefix_start].chars().count();
|
||||
let prefix_char_count = prefix.chars().count();
|
||||
let remaining_start_char = text[..prefix_end].chars().count();
|
||||
|
||||
let mut combined_indices: Vec<usize> =
|
||||
(prefix_start_char..prefix_start_char + prefix_char_count).collect();
|
||||
|
||||
// Add suffix match indices, adjusted for position
|
||||
for idx in suffix_result.matched_indices {
|
||||
combined_indices.push(remaining_start_char + idx);
|
||||
}
|
||||
|
||||
return Some(combined_indices);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For simple patterns like "ui/*", try to find "ui/" substring and match the rest
|
||||
if pattern.ends_with('*')
|
||||
&& !pattern[..pattern.len() - 1].contains('*')
|
||||
&& !pattern[..pattern.len() - 1].contains('?')
|
||||
{
|
||||
let prefix = &pattern[..pattern.len() - 1];
|
||||
if let Some(start_pos) = text.find(prefix) {
|
||||
// Found the prefix as a substring, create indices for the matched part
|
||||
let start_char_idx = text[..start_pos].chars().count();
|
||||
let prefix_char_count = prefix.chars().count();
|
||||
let matched_indices: Vec<usize> =
|
||||
(start_char_idx..start_char_idx + prefix_char_count).collect();
|
||||
return Some(matched_indices);
|
||||
}
|
||||
}
|
||||
|
||||
// For more complex patterns, try to find any substring that matches
|
||||
let text_chars: Vec<char> = text.chars().collect();
|
||||
let pattern_chars: Vec<char> = pattern.chars().collect();
|
||||
|
||||
// Try matching the pattern starting at each position in the text
|
||||
for start_idx in 0..text_chars.len() {
|
||||
if is_glob_match_at_position(&text_chars, &pattern_chars, start_idx) {
|
||||
// Found a match starting at start_idx
|
||||
// For simplicity, return indices from start_idx to end of match
|
||||
// In a more sophisticated implementation, we'd track exact matched characters
|
||||
let end_idx = find_glob_match_end(&text_chars, &pattern_chars, start_idx)
|
||||
.unwrap_or(text_chars.len());
|
||||
let matched_indices: Vec<usize> = (start_idx..end_idx.min(text_chars.len())).collect();
|
||||
return Some(matched_indices);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if a glob pattern matches starting at a specific position
|
||||
fn is_glob_match_at_position(
|
||||
text_chars: &[char],
|
||||
pattern_chars: &[char],
|
||||
start_idx: usize,
|
||||
) -> bool {
|
||||
is_glob_match_chars(&text_chars[start_idx..], pattern_chars)
|
||||
}
|
||||
|
||||
/// Find the end position of a glob match
|
||||
fn find_glob_match_end(
|
||||
text_chars: &[char],
|
||||
pattern_chars: &[char],
|
||||
start_idx: usize,
|
||||
) -> Option<usize> {
|
||||
// This is a simplified version - for complex patterns we'd need more sophisticated tracking
|
||||
// For now, assume the entire remaining text after start_idx
|
||||
if is_glob_match_at_position(text_chars, pattern_chars, start_idx) {
|
||||
Some(text_chars.len())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Glob matching for character slices
|
||||
fn is_glob_match_chars(text: &[char], pattern: &[char]) -> bool {
|
||||
is_glob_match_chars_recursive(text, pattern, 0, 0)
|
||||
}
|
||||
|
||||
fn is_glob_match_chars_recursive(
|
||||
text: &[char],
|
||||
pattern: &[char],
|
||||
text_idx: usize,
|
||||
pattern_idx: usize,
|
||||
) -> bool {
|
||||
// If we've consumed the entire pattern
|
||||
if pattern_idx >= pattern.len() {
|
||||
return text_idx >= text.len();
|
||||
}
|
||||
|
||||
// If we've consumed the entire text but pattern remains
|
||||
if text_idx >= text.len() {
|
||||
// Only match if the remaining pattern is all '*'
|
||||
return pattern[pattern_idx..].iter().all(|&c| c == '*');
|
||||
}
|
||||
|
||||
match pattern[pattern_idx] {
|
||||
'*' => {
|
||||
// Try matching zero characters (skip the *)
|
||||
if is_glob_match_chars_recursive(text, pattern, text_idx, pattern_idx + 1) {
|
||||
return true;
|
||||
}
|
||||
// Try matching one or more characters
|
||||
for i in text_idx..text.len() {
|
||||
if is_glob_match_chars_recursive(text, pattern, i + 1, pattern_idx + 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
'?' => {
|
||||
// ? matches exactly one character
|
||||
is_glob_match_chars_recursive(text, pattern, text_idx + 1, pattern_idx + 1)
|
||||
}
|
||||
c => {
|
||||
// Regular character - must match exactly (case insensitive)
|
||||
if text[text_idx].eq_ignore_ascii_case(&c) {
|
||||
is_glob_match_chars_recursive(text, pattern, text_idx + 1, pattern_idx + 1)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_glob_match_recursive(
|
||||
text: &[u8],
|
||||
pattern: &[u8],
|
||||
text_idx: usize,
|
||||
pattern_idx: usize,
|
||||
) -> bool {
|
||||
// If we've consumed the entire pattern
|
||||
if pattern_idx >= pattern.len() {
|
||||
return text_idx >= text.len();
|
||||
}
|
||||
|
||||
// If we've consumed the entire text but pattern remains
|
||||
if text_idx >= text.len() {
|
||||
// Only match if the remaining pattern is all '*'
|
||||
return pattern[pattern_idx..].iter().all(|&c| c == b'*');
|
||||
}
|
||||
|
||||
match pattern[pattern_idx] {
|
||||
b'*' => {
|
||||
// Try matching zero characters (skip the *)
|
||||
if is_glob_match_recursive(text, pattern, text_idx, pattern_idx + 1) {
|
||||
return true;
|
||||
}
|
||||
// Try matching one or more characters
|
||||
for i in text_idx..text.len() {
|
||||
if is_glob_match_recursive(text, pattern, i + 1, pattern_idx + 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
b'?' => {
|
||||
// ? matches exactly one character
|
||||
is_glob_match_recursive(text, pattern, text_idx + 1, pattern_idx + 1)
|
||||
}
|
||||
c => {
|
||||
// Regular character - must match exactly (case insensitive)
|
||||
if text[text_idx].eq_ignore_ascii_case(&c) {
|
||||
is_glob_match_recursive(text, pattern, text_idx + 1, pattern_idx + 1)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs case insensitive pattern matching with wildcard support.
|
||||
///
|
||||
/// This function works identically to [`match_wildcard_pattern`] but ignores case differences
|
||||
/// in both the text and pattern.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use fuzzy_match::match_wildcard_pattern_case_insensitive;
|
||||
///
|
||||
/// // Case insensitive matching
|
||||
/// assert!(match_wildcard_pattern_case_insensitive("Button.RS", "*.rs").is_some());
|
||||
/// assert!(match_wildcard_pattern_case_insensitive("/src/UI/button.rs", "ui/*").is_some());
|
||||
/// assert!(match_wildcard_pattern_case_insensitive("TEST.JS", "test.*").is_some());
|
||||
///
|
||||
/// // Mixed case patterns
|
||||
/// assert!(match_wildcard_pattern_case_insensitive("/src/Components/Button.tsx", "*/components/*.TSX").is_some());
|
||||
/// ```
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// The returned `matched_indices` correspond to character positions in the original text,
|
||||
/// not the lowercased version used internally for matching.
|
||||
pub fn match_wildcard_pattern_case_insensitive(
|
||||
text: &str,
|
||||
pattern: &str,
|
||||
) -> Option<FuzzyMatchResult> {
|
||||
if pattern.is_empty() {
|
||||
return Some(FuzzyMatchResult::no_match());
|
||||
}
|
||||
|
||||
// Convert both text and pattern to lowercase for case insensitive matching
|
||||
let text_lower = text.to_lowercase();
|
||||
let pattern_lower = pattern.to_lowercase();
|
||||
|
||||
match_wildcard_pattern(&text_lower, &pattern_lower).map(|mut result| {
|
||||
// Map the matched indices back to the original text
|
||||
// Since we're doing character-level matching and both strings should have
|
||||
// the same character positions, the indices should remain valid
|
||||
result
|
||||
.matched_indices
|
||||
.retain(|&idx| idx < text.chars().count());
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "fuzzy_test.rs"]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user