CmdPal: Add precomputed fuzzy string matching to Command Palette (#44090)

## Summary of the Pull Request

This PR improves fuzzy matching in Command Palette by:
- Precomputing normalized strings to enable faster comparisons
- Reducing memory allocations during matching, effectively down to zero

It also introduces several behavioral improvements:
- Strips diacritics from the normalized search string to improve
matching across languages
- Suppresses the same-case bonus when the query consists entirely of
lowercase characters -- reflecting typical user input patterns
- Allows skipping word separators -- enabling queries like Power Point
to match PowerPoint

This implementation is currently kept internal and is used only on the
home page. For other scenarios, the `FuzzyStringMatcher` from
`Microsoft.CommandPalette.Extensions.Toolkit` is being improved instead.

`PrecomputedFuzzyMatcher` offers up to a 100× performance improvement
over the current `FuzzyStringMatcher`, and approximately 2–5× better
performance compared to the improved version.

The improvement might seem small, but it adds up and becomes quite
noticeable when filtering the entire home page—whether the user starts a
new search or changes the query non-incrementally (e.g., using
backspace).


<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #45226
- [x] Closes: #44066
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
This commit is contained in:
Jiří Polášek
2026-02-09 20:37:59 +01:00
committed by GitHub
parent 740dbf5699
commit 7477b561a1
38 changed files with 2626 additions and 138 deletions

View File

@@ -0,0 +1,78 @@
// 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 Microsoft.CmdPal.Core.Common.Text;
namespace Microsoft.CmdPal.Common.UnitTests.Text;
[TestClass]
public sealed class PrecomputedFuzzyMatcherEmojiTests
{
private readonly PrecomputedFuzzyMatcher _matcher = new();
[TestMethod]
public void ExactMatch_SimpleEmoji_ReturnsScore()
{
const string needle = "🚀";
const string haystack = "Launch 🚀 sequence";
var query = _matcher.PrecomputeQuery(needle);
var target = _matcher.PrecomputeTarget(haystack);
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected match for simple emoji");
}
[TestMethod]
public void ExactMatch_SkinTone_ReturnsScore()
{
const string needle = "👍🏽"; // Medium skin tone
const string haystack = "Thumbs up 👍🏽 here";
var query = _matcher.PrecomputeQuery(needle);
var target = _matcher.PrecomputeTarget(haystack);
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected match for emoji with skin tone");
}
[TestMethod]
public void ZWJSequence_Family_Match()
{
const string needle = "👨‍👩‍👧‍👦"; // Family: Man, Woman, Girl, Boy
const string haystack = "Emoji 👨‍👩‍👧‍👦 Test";
var query = _matcher.PrecomputeQuery(needle);
var target = _matcher.PrecomputeTarget(haystack);
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected match for ZWJ sequence");
}
[TestMethod]
public void Flags_Match()
{
const string needle = "🇺🇸"; // US Flag (Regional Indicator U + Regional Indicator S)
const string haystack = "USA 🇺🇸";
var query = _matcher.PrecomputeQuery(needle);
var target = _matcher.PrecomputeTarget(haystack);
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected match for flag emoji");
}
[TestMethod]
public void Emoji_MixedWithText_Search()
{
const string needle = "t🌮o"; // "t" + taco + "o"
const string haystack = "taco 🌮 on tuesday";
var query = _matcher.PrecomputeQuery(needle);
var target = _matcher.PrecomputeTarget(haystack);
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected match for emoji mixed with text");
}
}

View File

@@ -0,0 +1,84 @@
// 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 Microsoft.CmdPal.Core.Common.Text;
namespace Microsoft.CmdPal.Common.UnitTests.Text;
[TestClass]
public sealed class PrecomputedFuzzyMatcherOptionsTests
{
[TestMethod]
public void Score_RemoveDiacriticsOption_AffectsMatching()
{
var withDiacriticsRemoved = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = true });
var withoutDiacriticsRemoved = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = false });
const string needle = "cafe";
const string haystack = "CAFÉ";
var scoreWithRemoval = withDiacriticsRemoved.Score(
withDiacriticsRemoved.PrecomputeQuery(needle),
withDiacriticsRemoved.PrecomputeTarget(haystack));
var scoreWithoutRemoval = withoutDiacriticsRemoved.Score(
withoutDiacriticsRemoved.PrecomputeQuery(needle),
withoutDiacriticsRemoved.PrecomputeTarget(haystack));
Assert.IsTrue(scoreWithRemoval > 0, "Expected match when diacritics are removed.");
Assert.AreEqual(0, scoreWithoutRemoval, "Expected no match when diacritics are preserved.");
}
[TestMethod]
public void Score_SkipWordSeparatorsOption_AffectsMatching()
{
var skipSeparators = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions { SkipWordSeparators = true });
var keepSeparators = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions { SkipWordSeparators = false });
const string needle = "a b";
const string haystack = "ab";
var scoreSkip = skipSeparators.Score(
skipSeparators.PrecomputeQuery(needle),
skipSeparators.PrecomputeTarget(haystack));
var scoreKeep = keepSeparators.Score(
keepSeparators.PrecomputeQuery(needle),
keepSeparators.PrecomputeTarget(haystack));
Assert.IsTrue(scoreSkip > 0, "Expected match when word separators are skipped.");
Assert.AreEqual(0, scoreKeep, "Expected no match when word separators are preserved.");
}
[TestMethod]
public void Score_IgnoreSameCaseBonusOption_AffectsLowercaseQuery()
{
var ignoreSameCase = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions
{
IgnoreSameCaseBonusIfQueryIsAllLowercase = true,
SameCaseBonus = 10,
});
var applySameCase = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions
{
IgnoreSameCaseBonusIfQueryIsAllLowercase = false,
SameCaseBonus = 10,
});
const string needle = "test";
const string haystack = "test";
var scoreIgnore = ignoreSameCase.Score(
ignoreSameCase.PrecomputeQuery(needle),
ignoreSameCase.PrecomputeTarget(haystack));
var scoreApply = applySameCase.Score(
applySameCase.PrecomputeQuery(needle),
applySameCase.PrecomputeTarget(haystack));
Assert.IsTrue(scoreApply > scoreIgnore, "Expected same-case bonus to apply when not ignored.");
}
}

View File

@@ -0,0 +1,227 @@
// 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 Microsoft.CmdPal.Core.Common.Text;
namespace Microsoft.CmdPal.Common.UnitTests.Text;
[TestClass]
public sealed class PrecomputedFuzzyMatcherSecondaryInputTests
{
private readonly PrecomputedFuzzyMatcher _matcher = new();
private readonly StringFolder _folder = new();
private readonly BloomFilter _bloom = new();
[TestMethod]
public void Score_PrimaryQueryMatchesSecondaryTarget_ShouldMatch()
{
// Scenario: Searching for "calc" should match a file "calculator.exe" where primary is filename, secondary is path
var query = CreateQuery("calc");
var target = CreateTarget(primary: "important.txt", secondary: "C:\\Programs\\Calculator\\");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected primary query to match secondary target");
}
[TestMethod]
public void Score_SecondaryQueryMatchesPrimaryTarget_ShouldMatch()
{
// Scenario: User types "documents\\report" and we want to match against filename
var query = CreateQuery(primary: "documents", secondary: "report");
var target = CreateTarget(primary: "report.docx");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected secondary query to match primary target");
}
[TestMethod]
public void Score_SecondaryQueryMatchesSecondaryTarget_ShouldMatch()
{
// Scenario: Both query and target have secondary info that matches
var query = CreateQuery(primary: "test", secondary: "documents");
var target = CreateTarget(primary: "something.txt", secondary: "C:\\Users\\Documents\\");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected secondary query to match secondary target");
}
[TestMethod]
public void Score_PrimaryQueryMatchesBothTargets_ShouldReturnBestScore()
{
// The same query matches both primary and secondary of target
var query = CreateQuery("test");
var target = CreateTarget(primary: "test.txt", secondary: "test_folder\\");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected query to match when it appears in both primary and secondary");
}
[TestMethod]
public void Score_NoSecondaryInQuery_MatchesSecondaryTarget()
{
// Query without secondary can still match target's secondary
var query = CreateQuery("downloads");
var target = CreateTarget(primary: "file.txt", secondary: "C:\\Downloads\\");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected primary query to match secondary target");
}
[TestMethod]
public void Score_NoSecondaryInTarget_SecondaryQueryShouldNotMatch()
{
// Query with secondary but target without secondary - secondary query shouldn't interfere
var query = CreateQuery(primary: "test", secondary: "extra");
var target = CreateTarget(primary: "test.txt");
var score = _matcher.Score(query, target);
// Primary should still match, secondary query just doesn't contribute
Assert.IsTrue(score > 0, "Expected primary query to match primary target");
}
[TestMethod]
public void Score_SecondaryQueryNoMatch_PrimaryCanStillMatch()
{
// Secondary doesn't match anything, but primary does
var query = CreateQuery(primary: "file", secondary: "nomatch");
var target = CreateTarget(primary: "myfile.txt", secondary: "C:\\Documents\\");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected primary query to match even when secondary doesn't");
}
[TestMethod]
public void Score_OnlySecondaryMatches_ShouldReturnScore()
{
// Only the secondary parts match, primary doesn't
var query = CreateQuery(primary: "xyz", secondary: "documents");
var target = CreateTarget(primary: "abc.txt", secondary: "C:\\Users\\Documents\\");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected match when only secondary parts match");
}
[TestMethod]
public void Score_BothQueriesMatchDifferentTargets_ShouldReturnBestScore()
{
// Primary query matches secondary target, secondary query matches primary target
var query = CreateQuery(primary: "docs", secondary: "report");
var target = CreateTarget(primary: "report.pdf", secondary: "C:\\Documents\\");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected match when queries cross-match with targets");
}
[TestMethod]
public void Score_CompletelyDifferent_ShouldNotMatch()
{
var query = CreateQuery(primary: "xyz", secondary: "abc");
var target = CreateTarget(primary: "hello", secondary: "world");
var score = _matcher.Score(query, target);
Assert.AreEqual(0, score, "Expected no match when nothing matches");
}
[TestMethod]
public void Score_EmptySecondaryInputs_ShouldMatchOnPrimary()
{
var query = CreateQuery(primary: "test", secondary: string.Empty);
var target = CreateTarget(primary: "test.txt", secondary: string.Empty);
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected match on primary when secondaries are empty");
}
[TestMethod]
public void Score_WordSeparatorMatching_AcrossSecondary()
{
// Test that "Power Point" matches "PowerPoint" using secondary
var query = CreateQuery(primary: "power", secondary: "point");
var target = CreateTarget(primary: "PowerPoint.exe");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected 'power' + 'point' to match 'PowerPoint'");
}
private FuzzyQuery CreateQuery(string primary, string? secondary = null)
{
var primaryFolded = _folder.Fold(primary, removeDiacritics: true);
var primaryBloom = _bloom.Compute(primaryFolded);
var primaryEffectiveLength = primaryFolded.Length;
var primaryIsAllLowercase = IsAllLowercaseAsciiOrNonLetter(primary);
string? secondaryFolded = null;
ulong secondaryBloom = 0;
var secondaryEffectiveLength = 0;
var secondaryIsAllLowercase = true;
if (!string.IsNullOrEmpty(secondary))
{
secondaryFolded = _folder.Fold(secondary, removeDiacritics: true);
secondaryBloom = _bloom.Compute(secondaryFolded);
secondaryEffectiveLength = secondaryFolded.Length;
secondaryIsAllLowercase = IsAllLowercaseAsciiOrNonLetter(secondary);
}
return new FuzzyQuery(
original: primary,
folded: primaryFolded,
bloom: primaryBloom,
effectiveLength: primaryEffectiveLength,
isAllLowercaseAsciiOrNonLetter: primaryIsAllLowercase,
secondaryOriginal: secondary,
secondaryFolded: secondaryFolded,
secondaryBloom: secondaryBloom,
secondaryEffectiveLength: secondaryEffectiveLength,
secondaryIsAllLowercaseAsciiOrNonLetter: secondaryIsAllLowercase);
}
private FuzzyTarget CreateTarget(string primary, string? secondary = null)
{
var primaryFolded = _folder.Fold(primary, removeDiacritics: true);
var primaryBloom = _bloom.Compute(primaryFolded);
string? secondaryFolded = null;
ulong secondaryBloom = 0;
if (!string.IsNullOrEmpty(secondary))
{
secondaryFolded = _folder.Fold(secondary, removeDiacritics: true);
secondaryBloom = _bloom.Compute(secondaryFolded);
}
return new FuzzyTarget(
original: primary,
folded: primaryFolded,
bloom: primaryBloom,
secondaryOriginal: secondary,
secondaryFolded: secondaryFolded,
secondaryBloom: secondaryBloom);
}
private static bool IsAllLowercaseAsciiOrNonLetter(string s)
{
foreach (var c in s)
{
if ((uint)(c - 'A') <= ('Z' - 'A'))
{
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,209 @@
// 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 Microsoft.CmdPal.Core.Common.Text;
namespace Microsoft.CmdPal.Common.UnitTests.Text;
[TestClass]
public class PrecomputedFuzzyMatcherTests
{
private readonly PrecomputedFuzzyMatcher _matcher = new();
public static IEnumerable<object[]> MatchData =>
[
["a", "a"],
["abc", "abc"],
["a", "ab"],
["b", "ab"],
["abc", "axbycz"],
["pt", "PowerToys"],
["calc", "Calculator"],
["vs", "Visual Studio"],
["code", "Visual Studio Code"],
// Diacritics
["abc", "ÁBC"],
// Separators
["p/t", "power\\toys"],
];
public static IEnumerable<object[]> NonMatchData =>
[
["z", "abc"],
["verylongstring", "short"],
];
[TestMethod]
[DynamicData(nameof(MatchData))]
public void Score_Matches_ShouldHavePositiveScore(string needle, string haystack)
{
var query = _matcher.PrecomputeQuery(needle);
var target = _matcher.PrecomputeTarget(haystack);
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, $"Expected positive score for needle='{needle}', haystack='{haystack}'");
}
[TestMethod]
[DynamicData(nameof(NonMatchData))]
public void Score_NonMatches_ShouldHaveZeroScore(string needle, string haystack)
{
var query = _matcher.PrecomputeQuery(needle);
var target = _matcher.PrecomputeTarget(haystack);
var score = _matcher.Score(query, target);
Assert.AreEqual(0, score, $"Expected 0 score for needle='{needle}', haystack='{haystack}'");
}
[TestMethod]
public void Score_EmptyQuery_ReturnsZero()
{
var query = _matcher.PrecomputeQuery(string.Empty);
var target = _matcher.PrecomputeTarget("something");
Assert.AreEqual(0, _matcher.Score(query, target));
}
[TestMethod]
public void Score_EmptyTarget_ReturnsZero()
{
var query = _matcher.PrecomputeQuery("something");
var target = _matcher.PrecomputeTarget(string.Empty);
Assert.AreEqual(0, _matcher.Score(query, target));
}
[TestMethod]
public void SchemaId_DefaultMatcher_IsConsistent()
{
var matcher1 = new PrecomputedFuzzyMatcher();
var matcher2 = new PrecomputedFuzzyMatcher();
Assert.AreEqual(matcher1.SchemaId, matcher2.SchemaId, "Default matchers should have the same SchemaId");
}
[TestMethod]
public void SchemaId_SameOptions_ProducesSameId()
{
var options = new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = true };
var matcher1 = new PrecomputedFuzzyMatcher(options);
var matcher2 = new PrecomputedFuzzyMatcher(options);
Assert.AreEqual(matcher1.SchemaId, matcher2.SchemaId, "Matchers with same options should have the same SchemaId");
}
[TestMethod]
public void SchemaId_DifferentRemoveDiacriticsOption_ProducesDifferentId()
{
var matcherWithDiacriticsRemoval = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = true });
var matcherWithoutDiacriticsRemoval = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = false });
Assert.AreNotEqual(
matcherWithDiacriticsRemoval.SchemaId,
matcherWithoutDiacriticsRemoval.SchemaId,
"Different RemoveDiacritics option should produce different SchemaId");
}
[TestMethod]
public void SchemaId_ScoringOptionsDoNotAffectId()
{
// SchemaId should only be affected by options that affect folding/bloom, not scoring
var matcher1 = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions { CharMatchBonus = 1, CamelCaseBonus = 2 });
var matcher2 = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions { CharMatchBonus = 100, CamelCaseBonus = 200 });
Assert.AreEqual(matcher1.SchemaId, matcher2.SchemaId, "Scoring options should not affect SchemaId");
}
[TestMethod]
public void Score_WordSeparatorMatching_PowerPoint()
{
// Test that "Power Point" can match "PowerPoint" when word separators are skipped
var query = _matcher.PrecomputeQuery("Power Point");
var target = _matcher.PrecomputeTarget("PowerPoint");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected 'Power Point' to match 'PowerPoint'");
}
[TestMethod]
public void Score_WordSeparatorMatching_UnderscoreDash()
{
// Test that different word separators match each other
var query = _matcher.PrecomputeQuery("hello_world");
var target = _matcher.PrecomputeTarget("hello-world");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected 'hello_world' to match 'hello-world'");
}
[TestMethod]
public void Score_WordSeparatorMatching_MixedSeparators()
{
// Test multiple different separators
var query = _matcher.PrecomputeQuery("my.file_name");
var target = _matcher.PrecomputeTarget("my-file.name");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected mixed separators to match");
}
[TestMethod]
public void Score_PrecomputedQueryReuse_ShouldWorkConsistently()
{
// Test that precomputed query can be reused across multiple targets
var query = _matcher.PrecomputeQuery("test");
var target1 = _matcher.PrecomputeTarget("test123");
var target2 = _matcher.PrecomputeTarget("mytest");
var target3 = _matcher.PrecomputeTarget("unrelated");
var score1 = _matcher.Score(query, target1);
var score2 = _matcher.Score(query, target2);
var score3 = _matcher.Score(query, target3);
Assert.IsTrue(score1 > 0, "Expected query to match first target");
Assert.IsTrue(score2 > 0, "Expected query to match second target");
Assert.AreEqual(0, score3, "Expected query not to match third target");
}
[TestMethod]
public void Score_PrecomputedTargetReuse_ShouldWorkConsistently()
{
// Test that precomputed target can be reused across multiple queries
var target = _matcher.PrecomputeTarget("calculator");
var query1 = _matcher.PrecomputeQuery("calc");
var query2 = _matcher.PrecomputeQuery("lator");
var query3 = _matcher.PrecomputeQuery("xyz");
var score1 = _matcher.Score(query1, target);
var score2 = _matcher.Score(query2, target);
var score3 = _matcher.Score(query3, target);
Assert.IsTrue(score1 > 0, "Expected first query to match target");
Assert.IsTrue(score2 > 0, "Expected second query to match target");
Assert.AreEqual(0, score3, "Expected third query not to match target");
}
[TestMethod]
public void Score_CaseInsensitiveMatching_Works()
{
// Test various case combinations
var query1 = _matcher.PrecomputeQuery("test");
var query2 = _matcher.PrecomputeQuery("TEST");
var query3 = _matcher.PrecomputeQuery("TeSt");
var target = _matcher.PrecomputeTarget("TestFile");
var score1 = _matcher.Score(query1, target);
var score2 = _matcher.Score(query2, target);
var score3 = _matcher.Score(query3, target);
Assert.IsTrue(score1 > 0, "Expected lowercase query to match");
Assert.IsTrue(score2 > 0, "Expected uppercase query to match");
Assert.IsTrue(score3 > 0, "Expected mixed case query to match");
}
}

View File

@@ -0,0 +1,124 @@
// 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 Microsoft.CmdPal.Core.Common.Text;
namespace Microsoft.CmdPal.Common.UnitTests.Text;
[TestClass]
public sealed class PrecomputedFuzzyMatcherUnicodeTests
{
private readonly PrecomputedFuzzyMatcher _defaultMatcher = new();
[TestMethod]
public void UnpairedHighSurrogateInNeedle_ShouldNotThrow()
{
const string needle = "\uD83D"; // high surrogate (unpaired)
const string haystack = "abc";
var q = _defaultMatcher.PrecomputeQuery(needle);
var t = _defaultMatcher.PrecomputeTarget(haystack);
_ = _defaultMatcher.Score(q, t);
}
[TestMethod]
public void UnpairedLowSurrogateInNeedle_ShouldNotThrow()
{
const string needle = "\uDC00"; // low surrogate (unpaired)
const string haystack = "abc";
var q = _defaultMatcher.PrecomputeQuery(needle);
var t = _defaultMatcher.PrecomputeTarget(haystack);
_ = _defaultMatcher.Score(q, t);
}
[TestMethod]
public void UnpairedHighSurrogateInHaystack_ShouldNotThrow()
{
const string needle = "a";
const string haystack = "a\uD83D" + "bc"; // inject unpaired high surrogate
var q = _defaultMatcher.PrecomputeQuery(needle);
var t = _defaultMatcher.PrecomputeTarget(haystack);
_ = _defaultMatcher.Score(q, t);
}
[TestMethod]
public void MixedSurrogatesAndMarks_ShouldNotThrow()
{
// "Garbage smoothie": unpaired surrogate + combining mark + emoji surrogate pair
const string needle = "a\uD83D\u0301"; // 'a' + unpaired high surrogate + combining acute
const string haystack = "a\u0301 \U0001F600"; // 'a' + combining acute + space + 😀 (valid pair)
var q = _defaultMatcher.PrecomputeQuery(needle);
var t = _defaultMatcher.PrecomputeTarget(haystack);
_ = _defaultMatcher.Score(q, t);
}
[TestMethod]
public void ValidEmojiSurrogatePair_ShouldNotThrow_AndCanMatch()
{
// 😀 U+1F600 encoded as surrogate pair in UTF-16
const string needle = "\U0001F600";
const string haystack = "x \U0001F600 y";
var q = _defaultMatcher.PrecomputeQuery(needle);
var t = _defaultMatcher.PrecomputeTarget(haystack);
var score = _defaultMatcher.Score(q, t);
Assert.IsTrue(score > 0, "Expected emoji to produce a match score > 0.");
}
[TestMethod]
public void RandomUtf16Garbage_ShouldNotThrow()
{
// Deterministic pseudo-random "UTF-16 garbage", including surrogates.
var s1 = MakeDeterministicGarbage(seed: 1234, length: 512);
var s2 = MakeDeterministicGarbage(seed: 5678, length: 1024);
var q = _defaultMatcher.PrecomputeQuery(s1);
var t = _defaultMatcher.PrecomputeTarget(s2);
_ = _defaultMatcher.Score(q, t);
}
[TestMethod]
public void HighSurrogateAtEndOfHaystack_ShouldNotThrow()
{
const string needle = "a";
const string haystack = "abc\uD83D"; // Ends with high surrogate
var q = _defaultMatcher.PrecomputeQuery(needle);
var t = _defaultMatcher.PrecomputeTarget(haystack);
_ = _defaultMatcher.Score(q, t);
}
[TestMethod]
public void VeryLongStrings_ShouldNotThrow()
{
var needle = new string('a', 100);
var haystack = new string('b', 10000) + needle + new string('c', 10000);
var q = _defaultMatcher.PrecomputeQuery(needle);
var t = _defaultMatcher.PrecomputeTarget(haystack);
_ = _defaultMatcher.Score(q, t);
}
private static string MakeDeterministicGarbage(int seed, int length)
{
// LCG for deterministic generation without Randoms platform/version surprises.
var x = (uint)seed;
var chars = length <= 2048 ? stackalloc char[length] : new char[length];
for (var i = 0; i < chars.Length; i++)
{
// LCG: x = (a*x + c) mod 2^32
x = unchecked((1664525u * x) + 1013904223u);
// Take top 16 bits as UTF-16 code unit (includes surrogates).
chars[i] = (char)(x >> 16);
}
return new string(chars);
}
}

View File

@@ -0,0 +1,117 @@
// 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;
using Microsoft.CmdPal.Core.Common.Text;
namespace Microsoft.CmdPal.Common.UnitTests.Text;
[TestClass]
public class PrecomputedFuzzyMatcherWithPinyinTests
{
private PrecomputedFuzzyMatcherWithPinyin CreateMatcher(PinyinMode mode = PinyinMode.On, bool removeApostrophes = true)
{
return new PrecomputedFuzzyMatcherWithPinyin(
new PrecomputedFuzzyMatcherOptions(),
new PinyinFuzzyMatcherOptions { Mode = mode, RemoveApostrophesForQuery = removeApostrophes },
new StringFolder(),
new BloomFilter());
}
[TestMethod]
[DataRow("bj", "北京")]
[DataRow("sh", "上海")]
[DataRow("nihao", "你好")]
[DataRow("beijing", "北京")]
[DataRow("ce", "测试")]
public void Score_PinyinMatches_ShouldHavePositiveScore(string needle, string haystack)
{
var matcher = CreateMatcher(PinyinMode.On);
var query = matcher.PrecomputeQuery(needle);
var target = matcher.PrecomputeTarget(haystack);
var score = matcher.Score(query, target);
Assert.IsTrue(score > 0, $"Expected positive score for needle='{needle}', haystack='{haystack}'");
}
[TestMethod]
public void Score_PinyinOff_ShouldNotMatchPinyin()
{
var matcher = CreateMatcher(PinyinMode.Off);
var needle = "bj";
var haystack = "北京";
var query = matcher.PrecomputeQuery(needle);
var target = matcher.PrecomputeTarget(haystack);
var score = matcher.Score(query, target);
Assert.AreEqual(0, score, "Pinyin match should be disabled.");
}
[TestMethod]
public void Score_StandardMatch_WorksWithPinyinMatcher()
{
var matcher = CreateMatcher(PinyinMode.On);
var needle = "abc";
var haystack = "abc";
var query = matcher.PrecomputeQuery(needle);
var target = matcher.PrecomputeTarget(haystack);
var score = matcher.Score(query, target);
Assert.IsTrue(score > 0, "Standard match should still work.");
}
[TestMethod]
public void Score_ApostropheRemoval_Works()
{
var matcher = CreateMatcher(PinyinMode.On, removeApostrophes: true);
var needle = "xi'an";
// "xi'an" -> "xian" -> matches "西安" (Xi An)
var haystack = "西安";
var query = matcher.PrecomputeQuery(needle);
var target = matcher.PrecomputeTarget(haystack);
var score = matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected match for 'xi'an' -> '西安' with apostrophe removal.");
}
[TestMethod]
public void AutoMode_EnablesForChineseCulture()
{
var originalCulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentUICulture = new CultureInfo("zh-CN");
var matcher = CreateMatcher(PinyinMode.AutoSimplifiedChineseUi);
var score = matcher.Score(matcher.PrecomputeQuery("bj"), matcher.PrecomputeTarget("北京"));
Assert.IsTrue(score > 0, "Should match when UI culture is zh-CN");
}
finally
{
CultureInfo.CurrentUICulture = originalCulture;
}
}
[TestMethod]
public void AutoMode_DisablesForNonChineseCulture()
{
var originalCulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentUICulture = new CultureInfo("en-US");
var matcher = CreateMatcher(PinyinMode.AutoSimplifiedChineseUi);
var score = matcher.Score(matcher.PrecomputeQuery("bj"), matcher.PrecomputeTarget("北京"));
Assert.AreEqual(0, score, "Should NOT match when UI culture is en-US");
}
finally
{
CultureInfo.CurrentUICulture = originalCulture;
}
}
}

View File

@@ -0,0 +1,55 @@
// 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 Microsoft.CmdPal.Core.Common.Text;
namespace Microsoft.CmdPal.Common.UnitTests.Text;
[TestClass]
public class StringFolderTests
{
private readonly StringFolder _folder = new();
[TestMethod]
[DataRow(null, "")]
[DataRow("", "")]
[DataRow("abc", "ABC")]
[DataRow("ABC", "ABC")]
[DataRow("a\\b", "A/B")]
[DataRow("a/b", "A/B")]
[DataRow("ÁBC", "ABC")] // Diacritic removal
[DataRow("ñ", "N")]
[DataRow("hello world", "HELLO WORLD")]
public void Fold_RemoveDiacritics_Works(string input, string expected)
{
Assert.AreEqual(expected, _folder.Fold(input, removeDiacritics: true));
}
[TestMethod]
[DataRow("abc", "ABC")]
[DataRow("ÁBC", "ÁBC")] // No diacritic removal
[DataRow("a\\b", "A/B")]
public void Fold_KeepDiacritics_Works(string input, string expected)
{
Assert.AreEqual(expected, _folder.Fold(input, removeDiacritics: false));
}
[TestMethod]
public void Fold_IsAlreadyFolded_ReturnsSameInstance()
{
var input = "ALREADY/FOLDED";
var result = _folder.Fold(input, removeDiacritics: true);
Assert.AreSame(input, result);
}
[TestMethod]
public void Fold_WithNonAsciiButNoDiacritics_ReturnsFolded()
{
// E.g. Cyrillic or other scripts that might not decompose in a simple way or just upper case
// "привет" -> "ПРИВЕТ"
var input = "привет";
var expected = "ПРИВЕТ";
Assert.AreEqual(expected, _folder.Fold(input, removeDiacritics: true));
}
}

View File

@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -43,38 +44,34 @@ public partial class MainListPageResultFactoryTests
public override string ToString() => Title;
}
private static Scored<IListItem> S(string title, int score)
private static RoScored<IListItem> S(string title, int score)
{
return new Scored<IListItem>
{
Score = score,
Item = new MockListItem { Title = title },
};
return new RoScored<IListItem>(score: score, item: new MockListItem { Title = title });
}
[TestMethod]
public void Merge_PrioritizesListsCorrectly()
{
var filtered = new List<Scored<IListItem>>
var filtered = new List<RoScored<IListItem>>
{
S("F1", 100),
S("F2", 50),
};
var scoredFallback = new List<Scored<IListItem>>
var scoredFallback = new List<RoScored<IListItem>>
{
S("SF1", 100),
S("SF2", 60),
};
var apps = new List<Scored<IListItem>>
var apps = new List<RoScored<IListItem>>
{
S("A1", 100),
S("A2", 55),
};
// Fallbacks are not scored.
var fallbacks = new List<Scored<IListItem>>
var fallbacks = new List<RoScored<IListItem>>
{
S("FB1", 0),
S("FB2", 0),
@@ -104,7 +101,7 @@ public partial class MainListPageResultFactoryTests
[TestMethod]
public void Merge_AppliesAppLimit()
{
var apps = new List<Scored<IListItem>>
var apps = new List<RoScored<IListItem>>
{
S("A1", 100),
S("A2", 90),
@@ -126,7 +123,7 @@ public partial class MainListPageResultFactoryTests
[TestMethod]
public void Merge_FiltersEmptyFallbacks()
{
var fallbacks = new List<Scored<IListItem>>
var fallbacks = new List<RoScored<IListItem>>
{
S("FB1", 0),
S("FB3", 0),

View File

@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CmdPal.Core.Common.Text;
using Microsoft.CmdPal.Ext.UnitTestBase;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.CommandPalette.Extensions;
@@ -263,10 +264,12 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
};
var history = CreateHistory(items.Reverse<ListItemMock>().ToList());
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("C");
var scoreA = MainListPage.ScoreTopLevelItem("C", items[0], history);
var scoreB = MainListPage.ScoreTopLevelItem("C", items[1], history);
var scoreC = MainListPage.ScoreTopLevelItem("C", items[2], history);
var scoreA = MainListPage.ScoreTopLevelItem(q, items[0], history, fuzzyMatcher);
var scoreB = MainListPage.ScoreTopLevelItem(q, items[1], history, fuzzyMatcher);
var scoreC = MainListPage.ScoreTopLevelItem(q, items[2], history, fuzzyMatcher);
// Assert
// All of these equally match the query, and they're all in the same bucket,
@@ -296,6 +299,11 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
return history;
}
private static IPrecomputedFuzzyMatcher CreateMatcher()
{
return new PrecomputedFuzzyMatcher(new PrecomputedFuzzyMatcherOptions());
}
private sealed record ScoredItem(ListItemMock Item, int Score)
{
public string Title => Item.Title;
@@ -337,9 +345,11 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var fuzzyMatcher = CreateMatcher();
var unweightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, emptyHistory)).ToList();
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, history)).ToList();
var q = fuzzyMatcher.PrecomputeQuery("C");
var unweightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, emptyHistory, fuzzyMatcher)).ToList();
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
Assert.AreEqual(unweightedScores.Count, weightedScores.Count, "Both score lists should have the same number of items");
for (var i = 0; i < unweightedScores.Count; i++)
{
@@ -380,7 +390,10 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("te", item, history)).ToList();
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("te");
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
var weightedMatches = GetMatches(items, weightedScores).ToList();
Assert.AreEqual(3, weightedMatches.Count, "Find Terminal, VsCode and Run commands");
@@ -398,6 +411,8 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("te");
// Add extra uses of VS Code to try and push it above Terminal
for (var i = 0; i < 10; i++)
@@ -405,7 +420,7 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
history.AddHistoryItem(items[1].Id);
}
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("te", item, history)).ToList();
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
var weightedMatches = GetMatches(items, weightedScores).ToList();
Assert.AreEqual(3, weightedMatches.Count, "Find Terminal, VsCode and Run commands");
@@ -423,6 +438,8 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("C");
// We're gonna run this test and keep adding more uses of VS Code till
// it breaks past Command Prompt
@@ -431,7 +448,7 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
{
history.AddHistoryItem(vsCodeId);
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, history)).ToList();
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
var weightedMatches = GetMatches(items, weightedScores).ToList();
Assert.AreEqual(4, weightedMatches.Count);