From a99596927d2984d199a265ebbb96ab8db91cae52 Mon Sep 17 00:00:00 2001 From: Mike Griese Date: Tue, 3 Jun 2025 13:34:14 -0500 Subject: [PATCH] I guess this is fuzzy.rs, for cmdpal --- .../Commands/MainListPage.cs | 6 +- .../ContextMenuStackViewModel.cs | 6 +- .../ListItemViewModel.cs | 10 +- .../ListViewModel.cs | 6 +- .../FuzzyStringMatcher.cs | 184 ++++++++++++++++++ 5 files changed, 197 insertions(+), 15 deletions(-) create mode 100644 src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FuzzyStringMatcher.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs index d60571643d..67df86bbfd 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs @@ -204,19 +204,19 @@ public partial class MainListPage : DynamicListPage, // * otherwise full weight match var nameMatch = isWhiteSpace ? (title.Contains(query) ? 1 : 0) : - StringMatcher.FuzzySearch(query, title).Score; + FuzzyStringMatcher.ScoreFuzzy(query, title); // Subtitle: // * whitespace query: 1/2 point // * otherwise ~half weight match. Minus a bit, because subtitles tend to be longer var descriptionMatch = isWhiteSpace ? (topLevelOrAppItem.Subtitle.Contains(query) ? .5 : 0) : - (StringMatcher.FuzzySearch(query, topLevelOrAppItem.Subtitle).Score - 4) / 2.0; + (FuzzyStringMatcher.ScoreFuzzy(query, topLevelOrAppItem.Subtitle) - 4) / 2.0; // Extension title: despite not being visible, give the extension name itself some weight // * whitespace query: 0 points // * otherwise more weight than a subtitle, but not much - var extensionTitleMatch = isWhiteSpace ? 0 : StringMatcher.FuzzySearch(query, extensionDisplayName).Score / 1.5; + var extensionTitleMatch = isWhiteSpace ? 0 : FuzzyStringMatcher.ScoreFuzzy(query, extensionDisplayName) / 1.5; var scores = new[] { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContextMenuStackViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContextMenuStackViewModel.cs index 2b16bd8f47..249d359e69 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContextMenuStackViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContextMenuStackViewModel.cs @@ -57,11 +57,11 @@ public partial class ContextMenuStackViewModel : ObservableObject return 0; } - var nameMatch = StringMatcher.FuzzySearch(query, item.Title); + var nameMatch = FuzzyStringMatcher.ScoreFuzzy(query, item.Title); - var descriptionMatch = StringMatcher.FuzzySearch(query, item.Subtitle); + var descriptionMatch = FuzzyStringMatcher.ScoreFuzzy(query, item.Subtitle); - return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max(); + return new[] { nameMatch, (descriptionMatch - 4) / 2, 0 }.Max(); } public CommandContextItemViewModel? CheckKeybinding(bool ctrl, bool alt, bool shift, bool win, VirtualKey key) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs index 48542ba628..f9a3938f83 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs @@ -5,7 +5,6 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.CmdPal.UI.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.UI.ViewModels; @@ -95,10 +94,9 @@ public partial class ListItemViewModel(IListItem model, WeakReference StringMatcher.FuzzySearch(filter, Title).Success || StringMatcher.FuzzySearch(filter, Subtitle).Success; - + // // TODO: Do we want filters to match descriptions and other properties? Tags, etc... Yes? + // // TODO: Do we want to save off the score here so we can sort by it in our ListViewModel? + // public bool MatchesFilter(string filter) => StringMatcher.FuzzySearch(filter, Title).Success || StringMatcher.FuzzySearch(filter, Subtitle).Success; public override string ToString() => $"{Name} ListItemViewModel"; public override bool Equals(object? obj) => obj is ListItemViewModel vm && vm.Model.Equals(this.Model); @@ -120,7 +118,7 @@ public partial class ListItemViewModel(IListItem model, WeakReference Positions) ScoreFuzzyWithPositions(string needle, string haystack, bool allowNonContiguousMatches) + { + if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(needle)) + { + return (NOMATCH, new List()); + } + + var target = haystack.ToCharArray(); + var query = needle.ToCharArray(); + + if (target.Length < query.Length) + { + return (NOMATCH, new List()); + } + + var targetLower = FoldCase(haystack); + var queryLower = FoldCase(needle); + var targetLowerChars = targetLower.ToCharArray(); + var queryLowerChars = queryLower.ToCharArray(); + + var area = query.Length * target.Length; + var scores = new int[area]; + var matches = new int[area]; + + for (var qi = 0; qi < query.Length; qi++) + { + var qiOffset = qi * target.Length; + var qiPrevOffset = qi > 0 ? (qi - 1) * target.Length : 0; + + for (var ti = 0; ti < target.Length; ti++) + { + var currentIndex = qiOffset + ti; + var diagIndex = (qi > 0 && ti > 0) ? qiPrevOffset + ti - 1 : 0; + var leftScore = ti > 0 ? scores[currentIndex - 1] : 0; + var diagScore = (qi > 0 && ti > 0) ? scores[diagIndex] : 0; + var matchSeqLen = (qi > 0 && ti > 0) ? matches[diagIndex] : 0; + + var score = (diagScore == 0 && qi != 0) ? 0 : + ComputeCharScore( + query[qi], + queryLowerChars[qi], + ti != 0 ? target[ti - 1] : null, + target[ti], + targetLowerChars[ti], + matchSeqLen); + + var isValidScore = score != 0 && diagScore + score >= leftScore && + (allowNonContiguousMatches || qi > 0 || + targetLowerChars.Skip(ti).Take(queryLowerChars.Length).SequenceEqual(queryLowerChars)); + + if (isValidScore) + { + matches[currentIndex] = matchSeqLen + 1; + scores[currentIndex] = diagScore + score; + } + else + { + matches[currentIndex] = NOMATCH; + scores[currentIndex] = leftScore; + } + } + } + + var positions = new List(); + if (query.Length > 0 && target.Length > 0) + { + var qi = query.Length - 1; + var ti = target.Length - 1; + + while (true) + { + var index = (qi * target.Length) + ti; + if (matches[index] == NOMATCH) + { + if (ti == 0) + { + break; + } + + ti--; + } + else + { + positions.Add(ti); + if (qi == 0 || ti == 0) + { + break; + } + + qi--; + ti--; + } + } + + positions.Reverse(); + } + + return (scores[area - 1], positions); + } + + private static string FoldCase(string input) + { + return input.ToLower(CultureInfo.InvariantCulture); + } + + private static int ComputeCharScore( + char query, + char queryLower, + char? targetPrev, + char targetCurr, + char targetLower, + int matchSeqLen) + { + if (!ConsiderAsEqual(queryLower, targetLower)) + { + return 0; + } + + var score = 1; // Character match bonus + + if (matchSeqLen > 0) + { + score += matchSeqLen * 5; // Consecutive match bonus + } + + if (query == targetCurr) + { + score += 1; // Same case bonus + } + + if (targetPrev.HasValue) + { + var sepBonus = ScoreSeparator(targetPrev.Value); + if (sepBonus > 0) + { + score += sepBonus; + } + else if (char.IsUpper(targetCurr) && matchSeqLen == 0) + { + score += 2; // CamelCase bonus + } + } + else + { + score += 8; // Start of word bonus + } + + return score; + } + + private static bool ConsiderAsEqual(char a, char b) + { + return a == b || (a == '/' && b == '\\') || (a == '\\' && b == '/'); + } + + private static int ScoreSeparator(char ch) + { + return ch switch + { + '/' or '\\' => 5, + '_' or '-' or '.' or ' ' or '\'' or '"' or ':' => 4, + _ => 0, + }; + } +}