CmdPal go brrrr (performance improvements) (#41959)

Still a WIP, but here's the deets so far:

## No more throwing canceled tokens

Throwing exceptions is expensive and since we essentially cancel tokens
anytime someone is typing beyond the debounce, we could be throwing
exceptions a ton during search. Since we don't care about those past
executions, now they just `return`.

## Reduced number of apps returned in search

While users can specify how many apps (no limit, 1, 5), if they specify
no limit, we hard limit it at 10. For a few reasons, fuzzy search gets
_really_ fuzzy sometimes and gives answers that users would think is
just plain wrong and they make the response list longer than it needs to
be.

## Fuzzy search: still fuzzy, but faster

Replaced `StringMatcher` class with `FuzzyStringMatcher`.
`FuzzyStringMatcher` is a C# port by @zadjii-msft of the Rust port by
@lhecker for [microsoft/edit](https://github.com/microsoft/edit), which
I believe originally came from [VS
Code](https://github.com/microsoft/vscode). It's a whole fuzzy rabbit
hole. But it's faster than the `StringMatcher` class it replaced.

## Fallbacks, you need to fall back

"In the beginning, fallbacks were created. This had made many people
very angry and has been widely regarded as a bad move."

Hitchhiker's Guide to the Galaxy jokes aside, fallbacks are one cause of
slower search results. A few modifications have been made to get them
out of the way without reverting their ability to do things dynamically.

1. Fallbacks are no longer scored and will always* appear at the bottom
of the search results
2. In updating their search text, we now use a cancellation token to
stop processing previous searches when a new keypress is recorded.

## * But Calculator & Run are special

So, remember when I said that all fallbacks will not be ranked and
always display at the bottom of the results? Surprise, some will be
ranked and displayed based on that score. Specifically, Calculator and
Run are fallbacks that are whitelisted from the restrictions mentioned
above. They will continue to act as they do today.

We do have the ability to add future fallbacks to that whitelist as
well.

---

## Current preview
Updated: 2025-09-24



https://github.com/user-attachments/assets/c74c9a8e-e438-4101-840b-1408d2acaefd

---

Closes #39763
Closes #39239
Closes #39948
Closes #38594
Closes #40330
This commit is contained in:
Michael Jolley
2025-09-25 13:48:13 -05:00
committed by GitHub
parent 4dab8e1eaa
commit d07f40eec3
16 changed files with 435 additions and 394 deletions

View File

@@ -0,0 +1,182 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CommandPalette.Extensions.Toolkit;
// Inspired by the fuzzy.rs from edit.exe
public static class FuzzyStringMatcher
{
private const int NOMATCH = 0;
public static int ScoreFuzzy(string needle, string haystack, bool allowNonContiguousMatches = true)
{
var (s, _) = ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches);
return s;
}
public static (int Score, List<int> Positions) ScoreFuzzyWithPositions(string needle, string haystack, bool allowNonContiguousMatches)
{
if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(needle))
{
return (NOMATCH, new List<int>());
}
var target = haystack.ToCharArray();
var query = needle.ToCharArray();
if (target.Length < query.Length)
{
return (NOMATCH, new List<int>());
}
var targetUpper = FoldCase(haystack);
var queryUpper = FoldCase(needle);
var targetUpperChars = targetUpper.ToCharArray();
var queryUpperChars = queryUpper.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],
queryUpperChars[qi],
ti != 0 ? target[ti - 1] : null,
target[ti],
targetUpperChars[ti],
matchSeqLen);
var isValidScore = score != 0 && diagScore + score >= leftScore &&
(allowNonContiguousMatches || qi > 0 ||
targetUpperChars.Skip(ti).Take(queryUpperChars.Length).SequenceEqual(queryUpperChars));
if (isValidScore)
{
matches[currentIndex] = matchSeqLen + 1;
scores[currentIndex] = diagScore + score;
}
else
{
matches[currentIndex] = NOMATCH;
scores[currentIndex] = leftScore;
}
}
}
var positions = new List<int>();
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.ToUpperInvariant();
}
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,
};
}
}

View File

@@ -19,17 +19,17 @@ public partial class ListHelpers
return 0;
}
var nameMatch = StringMatcher.FuzzySearch(query, listItem.Title);
var nameMatchScore = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Title);
// var locNameMatch = StringMatcher.FuzzySearch(query, NameLocalized);
var descriptionMatch = StringMatcher.FuzzySearch(query, listItem.Subtitle);
var descriptionMatchScore = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Subtitle);
// var executableNameMatch = StringMatcher.FuzzySearch(query, ExePath);
// var locExecutableNameMatch = StringMatcher.FuzzySearch(query, ExecutableNameLocalized);
// var lnkResolvedExecutableNameMatch = StringMatcher.FuzzySearch(query, LnkResolvedExecutableName);
// var locLnkResolvedExecutableNameMatch = StringMatcher.FuzzySearch(query, LnkResolvedExecutableNameLocalized);
// var score = new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, executableNameMatch.Score }.Max();
return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max();
return new[] { nameMatchScore, (descriptionMatchScore - 4) / 2, 0 }.Max();
}
public static IEnumerable<IListItem> FilterList(IEnumerable<IListItem> items, string query)

View File

@@ -1,311 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Globalization;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class StringMatcher
{
private readonly MatchOption _defaultMatchOption = new();
public SearchPrecisionScore UserSettingSearchPrecision { get; set; }
// private readonly IAlphabet _alphabet;
public StringMatcher(/*IAlphabet alphabet = null*/)
{
// _alphabet = alphabet;
}
private static StringMatcher? _instance;
public static StringMatcher Instance
{
get
{
_instance ??= new StringMatcher();
return _instance;
}
set => _instance = value;
}
private static readonly char[] Separator = new[] { ' ' };
public static MatchResult FuzzySearch(string query, string stringToCompare)
{
return Instance.FuzzyMatch(query, stringToCompare);
}
public MatchResult FuzzyMatch(string query, string stringToCompare)
{
try
{
return FuzzyMatch(query, stringToCompare, _defaultMatchOption);
}
catch (IndexOutOfRangeException)
{
return new MatchResult(false, UserSettingSearchPrecision);
}
}
/// <summary>
/// Current method:
/// Character matching + substring matching;
/// 1. Query search string is split into substrings, separator is whitespace.
/// 2. Check each query substring's characters against full compare string,
/// 3. if a character in the substring is matched, loop back to verify the previous character.
/// 4. If previous character also matches, and is the start of the substring, update list.
/// 5. Once the previous character is verified, move on to the next character in the query substring.
/// 6. Move onto the next substring's characters until all substrings are checked.
/// 7. Consider success and move onto scoring if every char or substring without whitespaces matched
/// </summary>
public MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption opt)
{
if (string.IsNullOrEmpty(stringToCompare))
{
return new MatchResult(false, UserSettingSearchPrecision);
}
var bestResult = new MatchResult(false, UserSettingSearchPrecision);
for (var startIndex = 0; startIndex < stringToCompare.Length; startIndex++)
{
MatchResult result = FuzzyMatch(query, stringToCompare, opt, startIndex);
if (result.Success && (!bestResult.Success || result.Score > bestResult.Score))
{
bestResult = result;
}
}
return bestResult;
}
private MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption opt, int startIndex)
{
if (string.IsNullOrEmpty(stringToCompare) || string.IsNullOrEmpty(query))
{
return new MatchResult(false, UserSettingSearchPrecision);
}
ArgumentNullException.ThrowIfNull(opt);
query = query.Trim();
// if (_alphabet is not null)
// {
// query = _alphabet.Translate(query);
// stringToCompare = _alphabet.Translate(stringToCompare);
// }
// Using InvariantCulture since this is internal
var fullStringToCompareWithoutCase = opt.IgnoreCase ? stringToCompare.ToUpper(CultureInfo.InvariantCulture) : stringToCompare;
var queryWithoutCase = opt.IgnoreCase ? query.ToUpper(CultureInfo.InvariantCulture) : query;
var querySubstrings = queryWithoutCase.Split(Separator, StringSplitOptions.RemoveEmptyEntries);
var currentQuerySubstringIndex = 0;
var currentQuerySubstring = querySubstrings[currentQuerySubstringIndex];
var currentQuerySubstringCharacterIndex = 0;
var firstMatchIndex = -1;
var firstMatchIndexInWord = -1;
var lastMatchIndex = 0;
var allQuerySubstringsMatched = false;
var matchFoundInPreviousLoop = false;
var allSubstringsContainedInCompareString = true;
var indexList = new List<int>();
List<int> spaceIndices = new List<int>();
for (var compareStringIndex = startIndex; compareStringIndex < fullStringToCompareWithoutCase.Length; compareStringIndex++)
{
// To maintain a list of indices which correspond to spaces in the string to compare
// To populate the list only for the first query substring
if (fullStringToCompareWithoutCase[compareStringIndex].Equals(' ') && currentQuerySubstringIndex == 0)
{
spaceIndices.Add(compareStringIndex);
}
bool compareResult;
if (opt.IgnoreCase)
{
var fullStringToCompare = fullStringToCompareWithoutCase[compareStringIndex].ToString();
var querySubstring = currentQuerySubstring[currentQuerySubstringCharacterIndex].ToString();
#pragma warning disable CA1309 // Use ordinal string comparison (We are looking for a fuzzy match here)
compareResult = string.Compare(fullStringToCompare, querySubstring, CultureInfo.CurrentCulture, CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace) != 0;
#pragma warning restore CA1309 // Use ordinal string comparison
}
else
{
compareResult = fullStringToCompareWithoutCase[compareStringIndex] != currentQuerySubstring[currentQuerySubstringCharacterIndex];
}
if (compareResult)
{
matchFoundInPreviousLoop = false;
continue;
}
if (firstMatchIndex < 0)
{
// first matched char will become the start of the compared string
firstMatchIndex = compareStringIndex;
}
if (currentQuerySubstringCharacterIndex == 0)
{
// first letter of current word
matchFoundInPreviousLoop = true;
firstMatchIndexInWord = compareStringIndex;
}
else if (!matchFoundInPreviousLoop)
{
// we want to verify that there is not a better match if this is not a full word
// in order to do so we need to verify all previous chars are part of the pattern
var startIndexToVerify = compareStringIndex - currentQuerySubstringCharacterIndex;
if (AllPreviousCharsMatched(startIndexToVerify, currentQuerySubstringCharacterIndex, fullStringToCompareWithoutCase, currentQuerySubstring))
{
matchFoundInPreviousLoop = true;
// if it's the beginning character of the first query substring that is matched then we need to update start index
firstMatchIndex = currentQuerySubstringIndex == 0 ? startIndexToVerify : firstMatchIndex;
indexList = GetUpdatedIndexList(startIndexToVerify, currentQuerySubstringCharacterIndex, firstMatchIndexInWord, indexList);
}
}
lastMatchIndex = compareStringIndex + 1;
indexList.Add(compareStringIndex);
currentQuerySubstringCharacterIndex++;
// if finished looping through every character in the current substring
if (currentQuerySubstringCharacterIndex == currentQuerySubstring.Length)
{
// if any of the substrings was not matched then consider as all are not matched
allSubstringsContainedInCompareString = matchFoundInPreviousLoop && allSubstringsContainedInCompareString;
currentQuerySubstringIndex++;
allQuerySubstringsMatched = AllQuerySubstringsMatched(currentQuerySubstringIndex, querySubstrings.Length);
if (allQuerySubstringsMatched)
{
break;
}
// otherwise move to the next query substring
currentQuerySubstring = querySubstrings[currentQuerySubstringIndex];
currentQuerySubstringCharacterIndex = 0;
}
}
// proceed to calculate score if every char or substring without whitespaces matched
if (allQuerySubstringsMatched)
{
var nearestSpaceIndex = CalculateClosestSpaceIndex(spaceIndices, firstMatchIndex);
var score = CalculateSearchScore(query, stringToCompare, firstMatchIndex - nearestSpaceIndex - 1, lastMatchIndex - firstMatchIndex, allSubstringsContainedInCompareString);
return new MatchResult(true, UserSettingSearchPrecision, indexList, score);
}
return new MatchResult(false, UserSettingSearchPrecision);
}
// To get the index of the closest space which precedes the first matching index
private static int CalculateClosestSpaceIndex(List<int> spaceIndices, int firstMatchIndex)
{
if (spaceIndices.Count == 0)
{
return -1;
}
else
{
return spaceIndices.OrderBy(item => (firstMatchIndex - item)).Where(item => firstMatchIndex > item).FirstOrDefault(-1);
}
}
private static bool AllPreviousCharsMatched(int startIndexToVerify, int currentQuerySubstringCharacterIndex, string fullStringToCompareWithoutCase, string currentQuerySubstring)
{
var allMatch = true;
for (var indexToCheck = 0; indexToCheck < currentQuerySubstringCharacterIndex; indexToCheck++)
{
if (fullStringToCompareWithoutCase[startIndexToVerify + indexToCheck] !=
currentQuerySubstring[indexToCheck])
{
allMatch = false;
}
}
return allMatch;
}
private static List<int> GetUpdatedIndexList(int startIndexToVerify, int currentQuerySubstringCharacterIndex, int firstMatchIndexInWord, List<int> indexList)
{
var updatedList = new List<int>();
indexList.RemoveAll(x => x >= firstMatchIndexInWord);
updatedList.AddRange(indexList);
for (var indexToCheck = 0; indexToCheck < currentQuerySubstringCharacterIndex; indexToCheck++)
{
updatedList.Add(startIndexToVerify + indexToCheck);
}
return updatedList;
}
private static bool AllQuerySubstringsMatched(int currentQuerySubstringIndex, int querySubstringsLength)
{
return currentQuerySubstringIndex >= querySubstringsLength;
}
private static int CalculateSearchScore(string query, string stringToCompare, int firstIndex, int matchLen, bool allSubstringsContainedInCompareString)
{
// A match found near the beginning of a string is scored more than a match found near the end
// A match is scored more if the characters in the patterns are closer to each other,
// while the score is lower if they are more spread out
// The length of the match is assigned a larger weight factor.
// I.e. the length is more important than the location where a match is found.
const int matchLenWeightFactor = 2;
var score = 100 * (query.Length + 1) * matchLenWeightFactor / ((1 + firstIndex) + (matchLenWeightFactor * (matchLen + 1)));
// A match with less characters assigning more weights
if (stringToCompare.Length - query.Length < 5)
{
score += 20;
}
else if (stringToCompare.Length - query.Length < 10)
{
score += 10;
}
if (allSubstringsContainedInCompareString)
{
var count = query.Count(c => !char.IsWhiteSpace(c));
var threshold = 4;
if (count <= threshold)
{
score += count * 10;
}
else
{
score += (threshold * 10) + ((count - threshold) * 5);
}
}
#pragma warning disable CA1309 // Use ordinal string comparison (Using CurrentCultureIgnoreCase since this relates to queries input by user)
if (string.Equals(query, stringToCompare, StringComparison.CurrentCultureIgnoreCase))
{
var bonusForExactMatch = 10;
score += bonusForExactMatch;
}
#pragma warning restore CA1309 // Use ordinal string comparison
return score;
}
}