diff --git a/.github/actions/spell-check/excludes.txt b/.github/actions/spell-check/excludes.txt
index 9e587fa284..b029f1dbcb 100644
--- a/.github/actions/spell-check/excludes.txt
+++ b/.github/actions/spell-check/excludes.txt
@@ -111,6 +111,7 @@
^src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage\.cs$
^src/modules/cmdpal/Microsoft\.CmdPal\.UI/Settings/InternalPage\.SampleData\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Core\.Common\.UnitTests/.*\.TestData\.cs$
+^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Core\.Common\.UnitTests/Text/.*\.cs$
^src/modules/colorPicker/ColorPickerUI/Shaders/GridShader\.cso$
^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/
^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/IPrecomputedListItem.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/IPrecomputedListItem.cs
new file mode 100644
index 0000000000..2847ee7b12
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/IPrecomputedListItem.cs
@@ -0,0 +1,27 @@
+// 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.Core.Common.Helpers;
+
+///
+/// Represents an item that can provide precomputed fuzzy matching targets for its title and subtitle.
+///
+public interface IPrecomputedListItem
+{
+ ///
+ /// Gets the fuzzy matching target for the item's title.
+ ///
+ /// The precomputed fuzzy matcher used to build the target.
+ /// The fuzzy target for the title.
+ FuzzyTarget GetTitleTarget(IPrecomputedFuzzyMatcher matcher);
+
+ ///
+ /// Gets the fuzzy matching target for the item's subtitle.
+ ///
+ /// The precomputed fuzzy matcher used to build the target.
+ /// The fuzzy target for the subtitle.
+ FuzzyTarget GetSubtitleTarget(IPrecomputedFuzzyMatcher matcher);
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/InternalListHelpers.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/InternalListHelpers.cs
new file mode 100644
index 0000000000..60d841aaf8
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/InternalListHelpers.cs
@@ -0,0 +1,142 @@
+// 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.Buffers;
+using System.Diagnostics;
+using Microsoft.CmdPal.Core.Common.Text;
+
+namespace Microsoft.CmdPal.Core.Common.Helpers;
+
+public static partial class InternalListHelpers
+{
+ public static RoScored[] FilterListWithScores(
+ IEnumerable? items,
+ in FuzzyQuery query,
+ in ScoringFunction scoreFunction)
+ {
+ if (items == null)
+ {
+ return [];
+ }
+
+ // Try to get initial capacity hint
+ var initialCapacity = items switch
+ {
+ ICollection col => col.Count,
+ IReadOnlyCollection rc => rc.Count,
+ _ => 64,
+ };
+
+ var buffer = ArrayPool>.Shared.Rent(initialCapacity);
+ var count = 0;
+
+ try
+ {
+ foreach (var item in items)
+ {
+ var score = scoreFunction(in query, item);
+ if (score <= 0)
+ {
+ continue;
+ }
+
+ if (count == buffer.Length)
+ {
+ GrowBuffer(ref buffer, count);
+ }
+
+ buffer[count++] = new RoScored(item, score);
+ }
+
+ Array.Sort(buffer, 0, count, default(RoScoredDescendingComparer));
+ var result = GC.AllocateUninitializedArray>(count);
+ buffer.AsSpan(0, count).CopyTo(result);
+ return result;
+ }
+ finally
+ {
+ ArrayPool>.Shared.Return(buffer);
+ }
+ }
+
+ private static void GrowBuffer(ref RoScored[] buffer, int count)
+ {
+ var newBuffer = ArrayPool>.Shared.Rent(buffer.Length * 2);
+ buffer.AsSpan(0, count).CopyTo(newBuffer);
+ ArrayPool>.Shared.Return(buffer);
+ buffer = newBuffer;
+ }
+
+ public static T[] FilterList(IEnumerable items, in FuzzyQuery query, ScoringFunction scoreFunction)
+ {
+ // Try to get initial capacity hint
+ var initialCapacity = items switch
+ {
+ ICollection col => col.Count,
+ IReadOnlyCollection rc => rc.Count,
+ _ => 64,
+ };
+
+ var buffer = ArrayPool>.Shared.Rent(initialCapacity);
+ var count = 0;
+
+ try
+ {
+ foreach (var item in items)
+ {
+ var score = scoreFunction(in query, item);
+ if (score <= 0)
+ {
+ continue;
+ }
+
+ if (count == buffer.Length)
+ {
+ GrowBuffer(ref buffer, count);
+ }
+
+ buffer[count++] = new RoScored(item, score);
+ }
+
+ Array.Sort(buffer, 0, count, default(RoScoredDescendingComparer));
+
+ var result = GC.AllocateUninitializedArray(count);
+ for (var i = 0; i < count; i++)
+ {
+ result[i] = buffer[i].Item;
+ }
+
+ return result;
+ }
+ finally
+ {
+ ArrayPool>.Shared.Return(buffer);
+ }
+ }
+
+ private readonly struct RoScoredDescendingComparer : IComparer>
+ {
+ public int Compare(RoScored x, RoScored y) => y.Score.CompareTo(x.Score);
+ }
+}
+
+public delegate int ScoringFunction(in FuzzyQuery query, T item);
+
+[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
+public readonly struct RoScored
+{
+ public readonly int Score;
+ public readonly T Item;
+
+ public RoScored(T item, int score)
+ {
+ Score = score;
+ Item = item;
+ }
+
+ private string GetDebuggerDisplay()
+ {
+ return "Score = " + Score + ", Item = " + Item;
+ }
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/BloomFilter.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/BloomFilter.cs
new file mode 100644
index 0000000000..59255a1bae
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/BloomFilter.cs
@@ -0,0 +1,40 @@
+// 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.Runtime.CompilerServices;
+
+namespace Microsoft.CmdPal.Core.Common.Text;
+
+public sealed class BloomFilter : IBloomFilter
+{
+ public ulong Compute(string input)
+ {
+ ulong bloom = 0;
+
+ foreach (var ch in input)
+ {
+ if (SymbolClassifier.Classify(ch) == SymbolKind.WordSeparator)
+ {
+ continue;
+ }
+
+ var h = (uint)ch * 0x45d9f3b;
+ bloom |= 1UL << (int)(h & 31);
+ bloom |= 1UL << (int)(((h >> 16) & 31) + 32);
+
+ if (bloom == ulong.MaxValue)
+ {
+ break;
+ }
+ }
+
+ return bloom;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public bool MightContain(ulong candidateBloom, ulong queryBloom)
+ {
+ return (candidateBloom & queryBloom) == queryBloom;
+ }
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyMatcherProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyMatcherProvider.cs
new file mode 100644
index 0000000000..80c5fa9ace
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyMatcherProvider.cs
@@ -0,0 +1,52 @@
+// 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.CmdPal.Core.Common.Text;
+
+public sealed class FuzzyMatcherProvider : IFuzzyMatcherProvider
+{
+ private readonly IBloomFilter _bloomCalculator = new BloomFilter();
+ private readonly IStringFolder _normalizer = new StringFolder();
+
+ private IPrecomputedFuzzyMatcher _current;
+
+ public FuzzyMatcherProvider(PrecomputedFuzzyMatcherOptions core, PinyinFuzzyMatcherOptions? pinyin = null)
+ {
+ _current = CreateMatcher(core, pinyin);
+ }
+
+ public IPrecomputedFuzzyMatcher Current => Volatile.Read(ref _current);
+
+ public void UpdateSettings(PrecomputedFuzzyMatcherOptions core, PinyinFuzzyMatcherOptions? pinyin = null)
+ {
+ Volatile.Write(ref _current, CreateMatcher(core, pinyin));
+ }
+
+ private IPrecomputedFuzzyMatcher CreateMatcher(PrecomputedFuzzyMatcherOptions core, PinyinFuzzyMatcherOptions? pinyin)
+ {
+ return pinyin is null || !IsPinyinEnabled(pinyin)
+ ? new PrecomputedFuzzyMatcher(core, _normalizer, _bloomCalculator)
+ : new PrecomputedFuzzyMatcherWithPinyin(core, pinyin, _normalizer, _bloomCalculator);
+ }
+
+ private static bool IsPinyinEnabled(PinyinFuzzyMatcherOptions o)
+ {
+ return o.Mode switch
+ {
+ PinyinMode.Off => false,
+ PinyinMode.On => true,
+ PinyinMode.AutoSimplifiedChineseUi => IsSimplifiedChineseUi(),
+ _ => false,
+ };
+ }
+
+ private static bool IsSimplifiedChineseUi()
+ {
+ var culture = CultureInfo.CurrentUICulture;
+ return culture.Name.StartsWith("zh-CN", StringComparison.OrdinalIgnoreCase)
+ || culture.Name.StartsWith("zh-Hans", StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyQuery.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyQuery.cs
new file mode 100644
index 0000000000..80de31bd7a
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyQuery.cs
@@ -0,0 +1,65 @@
+// 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.CmdPal.Core.Common.Text;
+
+public readonly struct FuzzyQuery
+{
+ public readonly string Original;
+
+ public readonly string Folded;
+
+ public readonly ulong Bloom;
+
+ public readonly int EffectiveLength;
+
+ public readonly bool IsAllLowercaseAsciiOrNonLetter;
+
+ public readonly string? SecondaryOriginal;
+
+ public readonly string? SecondaryFolded;
+
+ public readonly ulong SecondaryBloom;
+
+ public readonly int SecondaryEffectiveLength;
+
+ public readonly bool SecondaryIsAllLowercaseAsciiOrNonLetter;
+
+ public int Length => Folded.Length;
+
+ public bool HasSecondary => SecondaryFolded is not null;
+
+ public ReadOnlySpan OriginalSpan => Original.AsSpan();
+
+ public ReadOnlySpan FoldedSpan => Folded.AsSpan();
+
+ public ReadOnlySpan SecondaryOriginalSpan => SecondaryOriginal.AsSpan();
+
+ public ReadOnlySpan SecondaryFoldedSpan => SecondaryFolded.AsSpan();
+
+ public FuzzyQuery(
+ string original,
+ string folded,
+ ulong bloom,
+ int effectiveLength,
+ bool isAllLowercaseAsciiOrNonLetter,
+ string? secondaryOriginal = null,
+ string? secondaryFolded = null,
+ ulong secondaryBloom = 0,
+ int secondaryEffectiveLength = 0,
+ bool secondaryIsAllLowercaseAsciiOrNonLetter = true)
+ {
+ Original = original;
+ Folded = folded;
+ Bloom = bloom;
+ EffectiveLength = effectiveLength;
+ IsAllLowercaseAsciiOrNonLetter = isAllLowercaseAsciiOrNonLetter;
+
+ SecondaryOriginal = secondaryOriginal;
+ SecondaryFolded = secondaryFolded;
+ SecondaryBloom = secondaryBloom;
+ SecondaryEffectiveLength = secondaryEffectiveLength;
+ SecondaryIsAllLowercaseAsciiOrNonLetter = secondaryIsAllLowercaseAsciiOrNonLetter;
+ }
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyTarget.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyTarget.cs
new file mode 100644
index 0000000000..b0c2927f20
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyTarget.cs
@@ -0,0 +1,46 @@
+// 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.CmdPal.Core.Common.Text;
+
+public readonly struct FuzzyTarget
+{
+ public readonly string Original;
+ public readonly string Folded;
+ public readonly ulong Bloom;
+
+ public readonly string? SecondaryOriginal;
+ public readonly string? SecondaryFolded;
+ public readonly ulong SecondaryBloom;
+
+ public int Length => Folded.Length;
+
+ public bool HasSecondary => SecondaryFolded is not null;
+
+ public int SecondaryLength => SecondaryFolded?.Length ?? 0;
+
+ public ReadOnlySpan OriginalSpan => Original.AsSpan();
+
+ public ReadOnlySpan FoldedSpan => Folded.AsSpan();
+
+ public ReadOnlySpan SecondaryOriginalSpan => SecondaryOriginal.AsSpan();
+
+ public ReadOnlySpan SecondaryFoldedSpan => SecondaryFolded.AsSpan();
+
+ public FuzzyTarget(
+ string original,
+ string folded,
+ ulong bloom,
+ string? secondaryOriginal = null,
+ string? secondaryFolded = null,
+ ulong secondaryBloom = 0)
+ {
+ Original = original;
+ Folded = folded;
+ Bloom = bloom;
+ SecondaryOriginal = secondaryOriginal;
+ SecondaryFolded = secondaryFolded;
+ SecondaryBloom = secondaryBloom;
+ }
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyTargetCache.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyTargetCache.cs
new file mode 100644
index 0000000000..dc5ec6e011
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyTargetCache.cs
@@ -0,0 +1,34 @@
+// 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.CmdPal.Core.Common.Text;
+
+public struct FuzzyTargetCache
+{
+ private string? _lastRaw;
+ private uint _schemaId;
+ private FuzzyTarget _target;
+
+ public FuzzyTarget GetOrUpdate(IPrecomputedFuzzyMatcher matcher, string? raw)
+ {
+ raw ??= string.Empty;
+
+ if (_schemaId == matcher.SchemaId && string.Equals(_lastRaw, raw, StringComparison.Ordinal))
+ {
+ return _target;
+ }
+
+ _target = matcher.PrecomputeTarget(raw);
+ _schemaId = matcher.SchemaId;
+ _lastRaw = raw;
+ return _target;
+ }
+
+ public void Invalidate()
+ {
+ _lastRaw = null;
+ _target = default;
+ _schemaId = 0;
+ }
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IBloomFilter.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IBloomFilter.cs
new file mode 100644
index 0000000000..e9234e7adf
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IBloomFilter.cs
@@ -0,0 +1,12 @@
+// 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.CmdPal.Core.Common.Text;
+
+public interface IBloomFilter
+{
+ ulong Compute(string input);
+
+ bool MightContain(ulong candidateBloom, ulong queryBloom);
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IFuzzyMatcherProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IFuzzyMatcherProvider.cs
new file mode 100644
index 0000000000..706dd0d8bf
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IFuzzyMatcherProvider.cs
@@ -0,0 +1,12 @@
+// 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.CmdPal.Core.Common.Text;
+
+public interface IFuzzyMatcherProvider
+{
+ IPrecomputedFuzzyMatcher Current { get; }
+
+ void UpdateSettings(PrecomputedFuzzyMatcherOptions core, PinyinFuzzyMatcherOptions? pinyin = null);
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IPrecomputedFuzzyMatcher.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IPrecomputedFuzzyMatcher.cs
new file mode 100644
index 0000000000..dfb8af378e
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IPrecomputedFuzzyMatcher.cs
@@ -0,0 +1,16 @@
+// 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.CmdPal.Core.Common.Text;
+
+public interface IPrecomputedFuzzyMatcher
+{
+ uint SchemaId { get; }
+
+ FuzzyQuery PrecomputeQuery(string? input);
+
+ FuzzyTarget PrecomputeTarget(string? input);
+
+ int Score(scoped in FuzzyQuery query, scoped in FuzzyTarget target);
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IStringFolder.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IStringFolder.cs
new file mode 100644
index 0000000000..6fcfbfaf61
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IStringFolder.cs
@@ -0,0 +1,10 @@
+// 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.CmdPal.Core.Common.Text;
+
+public interface IStringFolder
+{
+ string Fold(string input, bool removeDiacritics);
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PinyinFuzzyMatcherOptions.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PinyinFuzzyMatcherOptions.cs
new file mode 100644
index 0000000000..c060c33c92
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PinyinFuzzyMatcherOptions.cs
@@ -0,0 +1,13 @@
+// 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.CmdPal.Core.Common.Text;
+
+public sealed class PinyinFuzzyMatcherOptions
+{
+ public PinyinMode Mode { get; init; } = PinyinMode.AutoSimplifiedChineseUi;
+
+ /// Remove IME syllable separators (') for query secondary variant.
+ public bool RemoveApostrophesForQuery { get; init; } = true;
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PinyinMode.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PinyinMode.cs
new file mode 100644
index 0000000000..0da88e14c0
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PinyinMode.cs
@@ -0,0 +1,12 @@
+// 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.CmdPal.Core.Common.Text;
+
+public enum PinyinMode
+{
+ Off = 0,
+ AutoSimplifiedChineseUi = 1,
+ On = 2,
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PrecomputedFuzzyMatcher.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PrecomputedFuzzyMatcher.cs
new file mode 100644
index 0000000000..0994f1d328
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PrecomputedFuzzyMatcher.cs
@@ -0,0 +1,575 @@
+// 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.Buffers;
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace Microsoft.CmdPal.Core.Common.Text;
+
+public sealed class PrecomputedFuzzyMatcher : IPrecomputedFuzzyMatcher
+{
+ private const int NoMatchScore = 0;
+ private const int StackallocThresholdChars = 512;
+ private const int FolderSchemaVersion = 1;
+ private const int BloomSchemaVersion = 1;
+
+ private readonly PrecomputedFuzzyMatcherOptions _options;
+ private readonly IStringFolder _stringFolder;
+ private readonly IBloomFilter _bloom;
+
+ public PrecomputedFuzzyMatcher(
+ PrecomputedFuzzyMatcherOptions? options = null,
+ IStringFolder? normalization = null,
+ IBloomFilter? bloomCalculator = null)
+ {
+ _options = options ?? PrecomputedFuzzyMatcherOptions.Default;
+ _bloom = bloomCalculator ?? new BloomFilter();
+ _stringFolder = normalization ?? new StringFolder();
+
+ SchemaId = ComputeSchemaId(_options);
+ }
+
+ public uint SchemaId { get; }
+
+ public FuzzyQuery PrecomputeQuery(string? input) => PrecomputeQuery(input, null);
+
+ public FuzzyTarget PrecomputeTarget(string? input) => PrecomputeTarget(input, null);
+
+ public int Score(in FuzzyQuery query, in FuzzyTarget target)
+ {
+ var qFold = query.FoldedSpan;
+ var tLen = target.Length;
+
+ if (query.EffectiveLength == 0 || tLen == 0)
+ {
+ return NoMatchScore;
+ }
+
+ var skipWordSeparators = _options.SkipWordSeparators;
+ var bestScore = 0;
+
+ // 1. Primary → Primary
+ if (tLen >= query.EffectiveLength && _bloom.MightContain(target.Bloom, query.Bloom))
+ {
+ if (CanMatchSubsequence(qFold, target.FoldedSpan, skipWordSeparators))
+ {
+ bestScore = ScoreNonContiguous(
+ qRaw: query.OriginalSpan,
+ qFold: qFold,
+ qEffectiveLen: query.EffectiveLength,
+ tRaw: target.OriginalSpan,
+ tFold: target.FoldedSpan,
+ ignoreSameCaseBonusForThisQuery: _options.IgnoreSameCaseBonusIfQueryIsAllLowercase && query.IsAllLowercaseAsciiOrNonLetter);
+ }
+ }
+
+ // 2. Secondary → Secondary
+ if (query.HasSecondary && target.HasSecondary)
+ {
+ var qSecFold = query.SecondaryFoldedSpan;
+
+ if (target.SecondaryLength >= query.SecondaryEffectiveLength &&
+ _bloom.MightContain(target.SecondaryBloom, query.SecondaryBloom) &&
+ CanMatchSubsequence(qSecFold, target.SecondaryFoldedSpan, skipWordSeparators))
+ {
+ var score = ScoreNonContiguous(
+ qRaw: query.SecondaryOriginalSpan,
+ qFold: qSecFold,
+ qEffectiveLen: query.SecondaryEffectiveLength,
+ tRaw: target.SecondaryOriginalSpan,
+ tFold: target.SecondaryFoldedSpan,
+ ignoreSameCaseBonusForThisQuery: _options.IgnoreSameCaseBonusIfQueryIsAllLowercase && query.SecondaryIsAllLowercaseAsciiOrNonLetter);
+
+ if (score > bestScore)
+ {
+ bestScore = score;
+ }
+ }
+ }
+
+ // 3. Primary query → Secondary target
+ if (target.HasSecondary &&
+ target.SecondaryLength >= query.EffectiveLength &&
+ _bloom.MightContain(target.SecondaryBloom, query.Bloom))
+ {
+ if (CanMatchSubsequence(qFold, target.SecondaryFoldedSpan, skipWordSeparators))
+ {
+ var score = ScoreNonContiguous(
+ qRaw: query.OriginalSpan,
+ qFold: qFold,
+ qEffectiveLen: query.EffectiveLength,
+ tRaw: target.SecondaryOriginalSpan,
+ tFold: target.SecondaryFoldedSpan,
+ ignoreSameCaseBonusForThisQuery: _options.IgnoreSameCaseBonusIfQueryIsAllLowercase && query.IsAllLowercaseAsciiOrNonLetter);
+
+ if (score > bestScore)
+ {
+ bestScore = score;
+ }
+ }
+ }
+
+ // 4. Secondary query → Primary target
+ if (query.HasSecondary &&
+ tLen >= query.SecondaryEffectiveLength &&
+ _bloom.MightContain(target.Bloom, query.SecondaryBloom))
+ {
+ var qSecFold = query.SecondaryFoldedSpan;
+
+ if (CanMatchSubsequence(qSecFold, target.FoldedSpan, skipWordSeparators))
+ {
+ var score = ScoreNonContiguous(
+ qRaw: query.SecondaryOriginalSpan,
+ qFold: qSecFold,
+ qEffectiveLen: query.SecondaryEffectiveLength,
+ tRaw: target.OriginalSpan,
+ tFold: target.FoldedSpan,
+ ignoreSameCaseBonusForThisQuery: _options.IgnoreSameCaseBonusIfQueryIsAllLowercase && query.SecondaryIsAllLowercaseAsciiOrNonLetter);
+
+ if (score > bestScore)
+ {
+ bestScore = score;
+ }
+ }
+ }
+
+ return bestScore;
+ }
+
+ private FuzzyQuery PrecomputeQuery(string? input, string? secondaryInput)
+ {
+ input ??= string.Empty;
+
+ var folded = _stringFolder.Fold(input, _options.RemoveDiacritics);
+ var bloom = _bloom.Compute(folded);
+ var effectiveLength = _options.SkipWordSeparators
+ ? folded.Length - CountWordSeparators(folded)
+ : folded.Length;
+
+ var isAllLowercase = IsAllLowercaseAsciiOrNonLetter(input);
+
+ string? secondaryOriginal = null;
+ string? secondaryFolded = null;
+ ulong secondaryBloom = 0;
+ var secondaryEffectiveLength = 0;
+ var secondaryIsAllLowercase = true;
+
+ if (!string.IsNullOrEmpty(secondaryInput))
+ {
+ secondaryOriginal = secondaryInput;
+ secondaryFolded = _stringFolder.Fold(secondaryInput, _options.RemoveDiacritics);
+ secondaryBloom = _bloom.Compute(secondaryFolded);
+ secondaryEffectiveLength = _options.SkipWordSeparators
+ ? secondaryFolded.Length - CountWordSeparators(secondaryFolded)
+ : secondaryFolded.Length;
+
+ secondaryIsAllLowercase = IsAllLowercaseAsciiOrNonLetter(secondaryInput);
+ }
+
+ return new FuzzyQuery(
+ original: input,
+ folded: folded,
+ bloom: bloom,
+ effectiveLength: effectiveLength,
+ isAllLowercaseAsciiOrNonLetter: isAllLowercase,
+ secondaryOriginal: secondaryOriginal,
+ secondaryFolded: secondaryFolded,
+ secondaryBloom: secondaryBloom,
+ secondaryEffectiveLength: secondaryEffectiveLength,
+ secondaryIsAllLowercaseAsciiOrNonLetter: secondaryIsAllLowercase);
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ static int CountWordSeparators(string s)
+ {
+ var count = 0;
+ foreach (var c in s)
+ {
+ if (SymbolClassifier.Classify(c) == SymbolKind.WordSeparator)
+ {
+ count++;
+ }
+ }
+
+ return count;
+ }
+ }
+
+ internal FuzzyTarget PrecomputeTarget(string? input, string? secondaryInput)
+ {
+ input ??= string.Empty;
+
+ var folded = _stringFolder.Fold(input, _options.RemoveDiacritics);
+ var bloom = _bloom.Compute(folded);
+
+ string? secondaryFolded = null;
+ ulong secondaryBloom = 0;
+
+ if (!string.IsNullOrEmpty(secondaryInput))
+ {
+ secondaryFolded = _stringFolder.Fold(secondaryInput, _options.RemoveDiacritics);
+ secondaryBloom = _bloom.Compute(secondaryFolded);
+ }
+
+ return new FuzzyTarget(
+ input,
+ folded,
+ bloom,
+ secondaryInput,
+ secondaryFolded,
+ secondaryBloom);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static bool IsAllLowercaseAsciiOrNonLetter(string s)
+ {
+ foreach (var c in s)
+ {
+ if ((uint)(c - 'A') <= ('Z' - 'A'))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static bool CanMatchSubsequence(
+ ReadOnlySpan qFold,
+ ReadOnlySpan tFold,
+ bool skipWordSeparators)
+ {
+ var qi = 0;
+ var ti = 0;
+
+ while (qi < qFold.Length && ti < tFold.Length)
+ {
+ var qChar = qFold[qi];
+
+ if (skipWordSeparators && SymbolClassifier.Classify(qChar) == SymbolKind.WordSeparator)
+ {
+ qi++;
+ continue;
+ }
+
+ if (qChar == tFold[ti])
+ {
+ qi++;
+ }
+
+ ti++;
+ }
+
+ // Skip trailing word separators in query
+ if (skipWordSeparators)
+ {
+ while (qi < qFold.Length && SymbolClassifier.Classify(qFold[qi]) == SymbolKind.WordSeparator)
+ {
+ qi++;
+ }
+ }
+
+ return qi == qFold.Length;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveOptimization)]
+ [SkipLocalsInit]
+ private int ScoreNonContiguous(
+ scoped in ReadOnlySpan qRaw,
+ scoped in ReadOnlySpan qFold,
+ int qEffectiveLen,
+ scoped in ReadOnlySpan tRaw,
+ scoped in ReadOnlySpan tFold,
+ bool ignoreSameCaseBonusForThisQuery)
+ {
+ Debug.Assert(qRaw.Length == qFold.Length, "Original and folded spans are traversed in lockstep: requires qRaw.Length == qFold.Length");
+ Debug.Assert(tRaw.Length == tFold.Length, "Original and folded spans are traversed in lockstep: requires tRaw.Length == tFold.Length");
+ Debug.Assert(qEffectiveLen <= qFold.Length, "Effective length must be less than or equal to folded length");
+
+ var qLen = qFold.Length;
+ var tLen = tFold.Length;
+
+ // Copy options to local variables to avoid repeated field accesses
+ var charMatchBonus = _options.CharMatchBonus;
+ var sameCaseBonus = ignoreSameCaseBonusForThisQuery ? 0 : _options.SameCaseBonus;
+ var consecutiveMultiplier = _options.ConsecutiveMultiplier;
+ var camelCaseBonus = _options.CamelCaseBonus;
+ var startOfWordBonus = _options.StartOfWordBonus;
+ var pathSeparatorBonus = _options.PathSeparatorBonus;
+ var wordSeparatorBonus = _options.WordSeparatorBonus;
+ var separatorAlignmentBonus = _options.SeparatorAlignmentBonus;
+ var exactSeparatorBonus = _options.ExactSeparatorBonus;
+ var skipWordSeparators = _options.SkipWordSeparators;
+
+ // DP buffer: two rows of length tLen
+ var bufferSize = tLen * 2;
+ int[]? rented = null;
+
+ try
+ {
+ scoped Span buffer;
+ if (bufferSize <= StackallocThresholdChars)
+ {
+ buffer = stackalloc int[bufferSize];
+ }
+ else
+ {
+ rented = ArrayPool.Shared.Rent(bufferSize);
+ buffer = rented.AsSpan(0, bufferSize);
+ }
+
+ var scores = buffer[..tLen];
+ var seqLens = buffer.Slice(tLen, tLen);
+
+ scores.Clear();
+ seqLens.Clear();
+
+ ref var scores0 = ref MemoryMarshal.GetReference(scores);
+ ref var seqLens0 = ref MemoryMarshal.GetReference(seqLens);
+ ref var qRaw0 = ref MemoryMarshal.GetReference(qRaw);
+ ref var qFold0 = ref MemoryMarshal.GetReference(qFold);
+ ref var tRaw0 = ref MemoryMarshal.GetReference(tRaw);
+ ref var tFold0 = ref MemoryMarshal.GetReference(tFold);
+
+ var qiEffective = 0;
+
+ for (var qi = 0; qi < qLen; qi++)
+ {
+ var qCharFold = Unsafe.Add(ref qFold0, qi);
+ var qCharKind = SymbolClassifier.Classify(qCharFold);
+
+ if (skipWordSeparators && qCharKind == SymbolKind.WordSeparator)
+ {
+ continue;
+ }
+
+ // Hoisted values
+ var qRawIsUpper = char.IsUpper(Unsafe.Add(ref qRaw0, qi));
+
+ // row computation
+ var leftScore = 0;
+ var diagScore = 0;
+ var diagSeqLen = 0;
+
+ // limit ti to ensure enough remaining characters to match the rest of the query
+ var tiMax = tLen - qEffectiveLen + qiEffective;
+
+ for (var ti = 0; ti <= tiMax; ti++)
+ {
+ var upScore = Unsafe.Add(ref scores0, ti);
+ var upSeqLen = Unsafe.Add(ref seqLens0, ti);
+
+ var charScore = 0;
+ if (diagScore != 0 || qiEffective == 0)
+ {
+ charScore = ComputeCharScore(
+ qi,
+ ti,
+ qCharFold,
+ qCharKind,
+ diagSeqLen,
+ qRawIsUpper,
+ ref tRaw0,
+ ref qFold0,
+ ref tFold0);
+ }
+
+ var candidateScore = diagScore + charScore;
+ if (charScore != 0 && candidateScore >= leftScore)
+ {
+ Unsafe.Add(ref scores0, ti) = candidateScore;
+ Unsafe.Add(ref seqLens0, ti) = diagSeqLen + 1;
+ leftScore = candidateScore;
+ }
+ else
+ {
+ Unsafe.Add(ref scores0, ti) = leftScore;
+ Unsafe.Add(ref seqLens0, ti) = 0;
+ /* leftScore remains unchanged */
+ }
+
+ diagScore = upScore;
+ diagSeqLen = upSeqLen;
+ }
+
+ // Early exit: no match possible
+ if (leftScore == 0)
+ {
+ return NoMatchScore;
+ }
+
+ // Advance effective query index
+ // Only counts non-separator characters if skipWordSeparators is enabled
+ qiEffective++;
+
+ if (qiEffective == qEffectiveLen)
+ {
+ return leftScore;
+ }
+ }
+
+ return scores[tLen - 1];
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
+ int ComputeCharScore(
+ int qi,
+ int ti,
+ char qCharFold,
+ SymbolKind qCharKind,
+ int seqLen,
+ bool qCharRawCurrIsUpper,
+ ref char tRaw0,
+ ref char qFold0,
+ ref char tFold0)
+ {
+ // Match check:
+ // - exact folded char match always ok
+ // - otherwise, allow equivalence only for word separators (e.g. '_' matches '-')
+ var tCharFold = Unsafe.Add(ref tFold0, ti);
+ if (qCharFold != tCharFold)
+ {
+ if (!skipWordSeparators)
+ {
+ return 0;
+ }
+
+ if (qCharKind != SymbolKind.WordSeparator ||
+ SymbolClassifier.Classify(tCharFold) != SymbolKind.WordSeparator)
+ {
+ return 0;
+ }
+ }
+
+ // 0. Base char match bonus
+ var score = charMatchBonus;
+
+ // 1. Consecutive match bonus
+ if (seqLen > 0)
+ {
+ score += seqLen * consecutiveMultiplier;
+ }
+
+ // 2. Same case bonus
+ // Early outs to appease the branch predictor
+ if (sameCaseBonus != 0)
+ {
+ var tCharRawCurr = Unsafe.Add(ref tRaw0, ti);
+ var tCharRawCurrIsUpper = char.IsUpper(tCharRawCurr);
+ if (qCharRawCurrIsUpper == tCharRawCurrIsUpper)
+ {
+ score += sameCaseBonus;
+ }
+
+ if (ti == 0)
+ {
+ score += startOfWordBonus;
+ return score;
+ }
+
+ var tPrevFold = Unsafe.Add(ref tFold0, ti - 1);
+ var tPrevKind = SymbolClassifier.Classify(tPrevFold);
+ if (tPrevKind != SymbolKind.Other)
+ {
+ score += tPrevKind == SymbolKind.PathSeparator
+ ? pathSeparatorBonus
+ : wordSeparatorBonus;
+
+ if (skipWordSeparators && seqLen == 0 && qi > 0)
+ {
+ var qPrevFold = Unsafe.Add(ref qFold0, qi - 1);
+ var qPrevKind = SymbolClassifier.Classify(qPrevFold);
+
+ if (qPrevKind == SymbolKind.WordSeparator)
+ {
+ score += separatorAlignmentBonus;
+
+ if (tPrevKind == SymbolKind.WordSeparator && qPrevFold == tPrevFold)
+ {
+ score += exactSeparatorBonus;
+ }
+ }
+ }
+
+ return score;
+ }
+
+ if (tCharRawCurrIsUpper && seqLen == 0)
+ {
+ score += camelCaseBonus;
+ return score;
+ }
+
+ return score;
+ }
+ else
+ {
+ if (ti == 0)
+ {
+ score += startOfWordBonus;
+ return score;
+ }
+
+ var tPrevFold = Unsafe.Add(ref tFold0, ti - 1);
+ var tPrevKind = SymbolClassifier.Classify(tPrevFold);
+ if (tPrevKind != SymbolKind.Other)
+ {
+ score += tPrevKind == SymbolKind.PathSeparator
+ ? pathSeparatorBonus
+ : wordSeparatorBonus;
+
+ if (skipWordSeparators && seqLen == 0 && qi > 0)
+ {
+ var qPrevFold = Unsafe.Add(ref qFold0, qi - 1);
+ var qPrevKind = SymbolClassifier.Classify(qPrevFold);
+
+ if (qPrevKind == SymbolKind.WordSeparator)
+ {
+ score += separatorAlignmentBonus;
+
+ if (tPrevKind == SymbolKind.WordSeparator && qPrevFold == tPrevFold)
+ {
+ score += exactSeparatorBonus;
+ }
+ }
+ }
+
+ return score;
+ }
+
+ if (camelCaseBonus != 0 && seqLen == 0 && char.IsUpper(Unsafe.Add(ref tRaw0, ti)))
+ {
+ score += camelCaseBonus;
+ return score;
+ }
+
+ return score;
+ }
+ }
+ }
+ finally
+ {
+ if (rented is not null)
+ {
+ ArrayPool.Shared.Return(rented);
+ }
+ }
+ }
+
+ // Schema ID is for cache invalidation of precomputed targets.
+ // Only includes options that affect folding/bloom, not scoring.
+ private static uint ComputeSchemaId(PrecomputedFuzzyMatcherOptions o)
+ {
+ const uint fnvOffset = 2166136261;
+ const uint fnvPrime = 16777619;
+
+ var h = fnvOffset;
+ h = unchecked((h ^ FolderSchemaVersion) * fnvPrime);
+ h = unchecked((h ^ BloomSchemaVersion) * fnvPrime);
+ h = unchecked((h ^ (uint)(o.RemoveDiacritics ? 1 : 0)) * fnvPrime);
+
+ return h;
+ }
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PrecomputedFuzzyMatcherOptions.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PrecomputedFuzzyMatcherOptions.cs
new file mode 100644
index 0000000000..b1b01d60f1
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PrecomputedFuzzyMatcherOptions.cs
@@ -0,0 +1,40 @@
+// 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.CmdPal.Core.Common.Text;
+
+public sealed class PrecomputedFuzzyMatcherOptions
+{
+ public static PrecomputedFuzzyMatcherOptions Default { get; } = new();
+
+ /*
+ * Bonuses
+ */
+ public int CharMatchBonus { get; init; } = 1;
+
+ public int SameCaseBonus { get; init; } = 1;
+
+ public int ConsecutiveMultiplier { get; init; } = 5;
+
+ public int CamelCaseBonus { get; init; } = 2;
+
+ public int StartOfWordBonus { get; init; } = 8;
+
+ public int PathSeparatorBonus { get; init; } = 5;
+
+ public int WordSeparatorBonus { get; init; } = 4;
+
+ public int SeparatorAlignmentBonus { get; init; } = 2;
+
+ public int ExactSeparatorBonus { get; init; } = 1;
+
+ /*
+ * Settings
+ */
+ public bool RemoveDiacritics { get; init; } = true;
+
+ public bool SkipWordSeparators { get; init; } = true;
+
+ public bool IgnoreSameCaseBonusIfQueryIsAllLowercase { get; init; } = true;
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PrecomputedFuzzyMatcherWithPinyin.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PrecomputedFuzzyMatcherWithPinyin.cs
new file mode 100644
index 0000000000..026328f2c5
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PrecomputedFuzzyMatcherWithPinyin.cs
@@ -0,0 +1,177 @@
+// 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 System.Runtime.CompilerServices;
+using ToolGood.Words.Pinyin;
+
+namespace Microsoft.CmdPal.Core.Common.Text;
+
+public sealed class PrecomputedFuzzyMatcherWithPinyin : IPrecomputedFuzzyMatcher
+{
+ private readonly IBloomFilter _bloom;
+ private readonly PrecomputedFuzzyMatcher _core;
+
+ private readonly IStringFolder _stringFolder;
+ private readonly PinyinFuzzyMatcherOptions _pinyin;
+
+ public PrecomputedFuzzyMatcherWithPinyin(
+ PrecomputedFuzzyMatcherOptions coreOptions,
+ PinyinFuzzyMatcherOptions pinyinOptions,
+ IStringFolder stringFolder,
+ IBloomFilter bloom)
+ {
+ _pinyin = pinyinOptions;
+ _stringFolder = stringFolder;
+ _bloom = bloom;
+
+ _core = new PrecomputedFuzzyMatcher(coreOptions, stringFolder, bloom);
+
+ SchemaId = CombineSchema(_core.SchemaId, _pinyin);
+ }
+
+ public uint SchemaId { get; }
+
+ public FuzzyQuery PrecomputeQuery(string? input)
+ {
+ input ??= string.Empty;
+
+ var primary = _core.PrecomputeQuery(input);
+
+ // Fast exit if effectively off (provider should already filter, but keep robust)
+ if (!IsPinyinEnabled(_pinyin))
+ {
+ return primary;
+ }
+
+ // Match legacy: remove apostrophes for query secondary
+ var queryForPinyin = _pinyin.RemoveApostrophesForQuery ? RemoveApostrophesIfAny(input) : input;
+
+ var pinyin = WordsHelper.GetPinyin(queryForPinyin);
+ if (string.IsNullOrEmpty(pinyin))
+ {
+ return primary;
+ }
+
+ var secondary = _core.PrecomputeQuery(pinyin);
+ return new FuzzyQuery(
+ primary.Original,
+ primary.Folded,
+ primary.Bloom,
+ primary.EffectiveLength,
+ primary.IsAllLowercaseAsciiOrNonLetter,
+ secondary.Original,
+ secondary.Folded,
+ secondary.Bloom,
+ secondary.EffectiveLength,
+ secondary.SecondaryIsAllLowercaseAsciiOrNonLetter);
+ }
+
+ public FuzzyTarget PrecomputeTarget(string? input)
+ {
+ input ??= string.Empty;
+
+ var primary = _core.PrecomputeTarget(input);
+
+ if (!IsPinyinEnabled(_pinyin))
+ {
+ return primary;
+ }
+
+ // Match legacy: only compute target pinyin when target contains Chinese
+ if (!ContainsToolGoodChinese(input))
+ {
+ return primary;
+ }
+
+ var pinyin = WordsHelper.GetPinyin(input);
+ if (string.IsNullOrEmpty(pinyin))
+ {
+ return primary;
+ }
+
+ var secondary = _core.PrecomputeTarget(pinyin);
+ return new FuzzyTarget(
+ primary.Original,
+ primary.Folded,
+ primary.Bloom,
+ secondary.Original,
+ secondary.Folded,
+ secondary.Bloom);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public int Score(scoped in FuzzyQuery query, scoped in FuzzyTarget target)
+ => _core.Score(in query, in target);
+
+ private static bool IsPinyinEnabled(PinyinFuzzyMatcherOptions o) => o.Mode switch
+ {
+ PinyinMode.Off => false,
+ PinyinMode.On => true,
+ PinyinMode.AutoSimplifiedChineseUi => IsSimplifiedChineseUi(),
+ _ => false,
+ };
+
+ private static bool IsSimplifiedChineseUi()
+ {
+ var culture = CultureInfo.CurrentUICulture;
+ return culture.Name.StartsWith("zh-CN", StringComparison.OrdinalIgnoreCase)
+ || culture.Name.StartsWith("zh-Hans", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool ContainsToolGoodChinese(string s)
+ {
+ return WordsHelper.HasChinese(s);
+ }
+
+ private static string RemoveApostrophesIfAny(string input)
+ {
+ var first = input.IndexOf('\'');
+ if (first < 0)
+ {
+ return input;
+ }
+
+ var removeCount = 1;
+ for (var i = first + 1; i < input.Length; i++)
+ {
+ if (input[i] == '\'')
+ {
+ removeCount++;
+ }
+ }
+
+ return string.Create(input.Length - removeCount, input, static (dst, src) =>
+ {
+ var di = 0;
+ for (var i = 0; i < src.Length; i++)
+ {
+ var c = src[i];
+ if (c == '\'')
+ {
+ continue;
+ }
+
+ dst[di++] = c;
+ }
+ });
+ }
+
+ private static uint CombineSchema(uint coreSchemaId, PinyinFuzzyMatcherOptions p)
+ {
+ const uint fnvOffset = 2166136261;
+ const uint fnvPrime = 16777619;
+
+ var h = fnvOffset;
+ h = unchecked((h ^ coreSchemaId) * fnvPrime);
+ h = unchecked((h ^ (uint)p.Mode) * fnvPrime);
+ h = unchecked((h ^ (p.RemoveApostrophesForQuery ? 1u : 0u)) * fnvPrime);
+
+ // bump if you change formatting/conversion behavior
+ const uint pinyinAlgoVersion = 1;
+ h = unchecked((h ^ pinyinAlgoVersion) * fnvPrime);
+
+ return h;
+ }
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/StringFolder.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/StringFolder.cs
new file mode 100644
index 0000000000..2d814be553
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/StringFolder.cs
@@ -0,0 +1,163 @@
+// 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 System.Runtime.CompilerServices;
+using System.Text;
+
+namespace Microsoft.CmdPal.Core.Common.Text;
+
+public sealed class StringFolder : IStringFolder
+{
+ // Cache for diacritic-stripped uppercase characters.
+ // Benign race: worst case is redundant computation writing the same value.
+ // 0 = uncached, else cachedChar + 1
+ private static readonly ushort[] StripCacheUpper = new ushort[char.MaxValue + 1];
+
+ public string Fold(string input, bool removeDiacritics)
+ {
+ if (string.IsNullOrEmpty(input))
+ {
+ return string.Empty;
+ }
+
+ if (!removeDiacritics || Ascii.IsValid(input))
+ {
+ if (IsAlreadyFoldedAndSlashNormalized(input))
+ {
+ return input;
+ }
+
+ return string.Create(input.Length, input, static (dst, src) =>
+ {
+ for (var i = 0; i < src.Length; i++)
+ {
+ var c = src[i];
+ dst[i] = c == '\\' ? '/' : char.ToUpperInvariant(c);
+ }
+ });
+ }
+
+ return string.Create(input.Length, input, static (dst, src) =>
+ {
+ for (var i = 0; i < src.Length; i++)
+ {
+ var c = src[i];
+ var upper = c == '\\' ? '/' : char.ToUpperInvariant(c);
+ dst[i] = StripDiacriticsFromUpper(upper);
+ }
+ });
+ }
+
+ private static bool IsAlreadyFoldedAndSlashNormalized(string input)
+ {
+ var sawNonAscii = false;
+
+ for (var i = 0; i < input.Length; i++)
+ {
+ var c = input[i];
+
+ if (c == '\\')
+ {
+ return false;
+ }
+
+ if ((uint)(c - 'a') <= 'z' - 'a')
+ {
+ return false;
+ }
+
+ if (c > 0x7F)
+ {
+ sawNonAscii = true;
+ }
+ }
+
+ if (sawNonAscii)
+ {
+ for (var i = 0; i < input.Length; i++)
+ {
+ var c = input[i];
+ if (c <= 0x7F)
+ {
+ continue;
+ }
+
+ var cat = CharUnicodeInfo.GetUnicodeCategory(c);
+ if (cat is UnicodeCategory.LowercaseLetter or UnicodeCategory.TitlecaseLetter)
+ {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static char StripDiacriticsFromUpper(char upper)
+ {
+ if (upper <= 0x7F)
+ {
+ return upper;
+ }
+
+ // Never attempt normalization on lone UTF-16 surrogates.
+ if (char.IsSurrogate(upper))
+ {
+ return upper;
+ }
+
+ var cachedPlus1 = StripCacheUpper[upper];
+ if (cachedPlus1 != 0)
+ {
+ return (char)(cachedPlus1 - 1);
+ }
+
+ var mapped = StripDiacriticsSlow(upper);
+ StripCacheUpper[upper] = (ushort)(mapped + 1);
+ return mapped;
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private static char StripDiacriticsSlow(char upper)
+ {
+ try
+ {
+ var baseChar = FirstNonMark(upper, NormalizationForm.FormD);
+ if (baseChar == '\0' || baseChar == upper)
+ {
+ var kd = FirstNonMark(upper, NormalizationForm.FormKD);
+ if (kd != '\0')
+ {
+ baseChar = kd;
+ }
+ }
+
+ return char.ToUpperInvariant(baseChar == '\0' ? upper : baseChar);
+ }
+ catch
+ {
+ // Absolute safety: if globalization tables ever throw for some reason,
+ // degrade gracefully rather than failing hard.
+ return upper;
+ }
+
+ static char FirstNonMark(char c, NormalizationForm form)
+ {
+ var normalized = c.ToString().Normalize(form);
+
+ foreach (var ch in normalized)
+ {
+ var cat = CharUnicodeInfo.GetUnicodeCategory(ch);
+ if (cat is not (UnicodeCategory.NonSpacingMark or UnicodeCategory.SpacingCombiningMark or UnicodeCategory.EnclosingMark))
+ {
+ return ch;
+ }
+ }
+
+ return '\0';
+ }
+ }
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/SymbolClassifier.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/SymbolClassifier.cs
new file mode 100644
index 0000000000..e1be786646
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/SymbolClassifier.cs
@@ -0,0 +1,29 @@
+// 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.Runtime.CompilerServices;
+
+namespace Microsoft.CmdPal.Core.Common.Text;
+
+internal static class SymbolClassifier
+{
+ // Embedded in .data section - no allocation, no static constructor
+ private static ReadOnlySpan Lookup =>
+ [
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0-15
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16-31
+ 2, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 2, 2, 1, // 32-47: space=2, "=2, '=2, -=2, .=2, /=1
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, // 48-63: :=2
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 64-79
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 2, // 80-95: _=2
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 96-111
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 // 112-127
+ ];
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static SymbolKind Classify(char c)
+ {
+ return c > 0x7F ? SymbolKind.Other : (SymbolKind)Lookup[c];
+ }
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/SymbolKind.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/SymbolKind.cs
new file mode 100644
index 0000000000..d2644be420
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/SymbolKind.cs
@@ -0,0 +1,12 @@
+// 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.CmdPal.Core.Common.Text;
+
+internal enum SymbolKind : byte
+{
+ Other = 0,
+ PathSeparator = 1,
+ WordSeparator = 2,
+}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs
index f8e9478023..af10995cf9 100644
--- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs
@@ -4,6 +4,8 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.CmdPal.Core.Common;
+using Microsoft.CmdPal.Core.Common.Helpers;
+using Microsoft.CmdPal.Core.Common.Text;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.Core.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
@@ -13,7 +15,7 @@ using Windows.ApplicationModel.DataTransfer;
namespace Microsoft.CmdPal.Core.ViewModels;
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
-public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBarContext
+public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBarContext, IPrecomputedListItem
{
public ExtensionObject Model => _commandItemModel;
@@ -22,6 +24,9 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
private readonly ExtensionObject _commandItemModel = new(null);
private CommandContextItemViewModel? _defaultCommandContextItemViewModel;
+ private FuzzyTargetCache _titleCache;
+ private FuzzyTargetCache _subtitleCache;
+
internal InitializedState Initialized { get; private set; } = InitializedState.Uninitialized;
protected bool IsFastInitialized => IsInErrorState || Initialized.HasFlag(InitializedState.FastInitialized);
@@ -116,6 +121,8 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
_itemTitle = model.Title;
Subtitle = model.Subtitle;
+ _titleCache.Invalidate();
+ _subtitleCache.Invalidate();
Initialized |= InitializedState.FastInitialized;
}
@@ -249,6 +256,8 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
Subtitle = "Item failed to load";
MoreCommands = [];
_icon = _errorIcon;
+ _titleCache.Invalidate();
+ _subtitleCache.Invalidate();
Initialized |= InitializedState.Error;
}
@@ -286,6 +295,8 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
Subtitle = "Item failed to load";
MoreCommands = [];
_icon = _errorIcon;
+ _titleCache.Invalidate();
+ _subtitleCache.Invalidate();
Initialized |= InitializedState.Error;
}
@@ -335,12 +346,14 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
case nameof(Title):
_itemTitle = model.Title;
+ _titleCache.Invalidate();
break;
case nameof(Subtitle):
var modelSubtitle = model.Subtitle;
this.Subtitle = modelSubtitle;
_defaultCommandContextItemViewModel?.Subtitle = modelSubtitle;
+ _subtitleCache.Invalidate();
break;
case nameof(Icon):
@@ -415,6 +428,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
// Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command
// or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK.
_itemTitle = model.Title;
+ _titleCache.Invalidate();
UpdateProperty(nameof(Title), nameof(Name));
_defaultCommandContextItemViewModel?.UpdateTitle(model.Command.Name);
@@ -436,6 +450,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
private void UpdateTitle(string? title)
{
_itemTitle = title ?? string.Empty;
+ _titleCache.Invalidate();
UpdateProperty(nameof(Title));
}
@@ -456,6 +471,12 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
UpdateProperty(nameof(DataPackage));
}
+ public FuzzyTarget GetTitleTarget(IPrecomputedFuzzyMatcher matcher)
+ => _titleCache.GetOrUpdate(matcher, Title);
+
+ public FuzzyTarget GetSubtitleTarget(IPrecomputedFuzzyMatcher matcher)
+ => _subtitleCache.GetOrUpdate(matcher, Subtitle);
+
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs
index 07c238ab42..83f314a11f 100644
--- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs
@@ -3,9 +3,12 @@
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
+using System.Runtime.CompilerServices;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Core.Common;
+using Microsoft.CmdPal.Core.Common.Helpers;
+using Microsoft.CmdPal.Core.Common.Text;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -16,6 +19,8 @@ namespace Microsoft.CmdPal.Core.ViewModels;
public partial class ContextMenuViewModel : ObservableObject,
IRecipient
{
+ private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider;
+
public ICommandBarContext? SelectedItem
{
get => field;
@@ -39,8 +44,9 @@ public partial class ContextMenuViewModel : ObservableObject,
private string _lastSearchText = string.Empty;
- public ContextMenuViewModel()
+ public ContextMenuViewModel(IFuzzyMatcherProvider fuzzyMatcherProvider)
{
+ _fuzzyMatcherProvider = fuzzyMatcherProvider;
WeakReferenceMessenger.Default.Register(this);
}
@@ -91,13 +97,14 @@ public partial class ContextMenuViewModel : ObservableObject,
.OfType()
.Where(c => c.ShouldBeVisible);
- var newResults = ListHelpers.FilterList(commands, searchText, ScoreContextCommand);
+ var query = _fuzzyMatcherProvider.Current.PrecomputeQuery(searchText);
+ var newResults = InternalListHelpers.FilterList(commands, in query, ScoreFunction);
ListHelpers.InPlaceUpdateList(FilteredItems, newResults);
}
- private static int ScoreContextCommand(string query, CommandContextItemViewModel item)
+ private int ScoreFunction(in FuzzyQuery query, CommandContextItemViewModel item)
{
- if (string.IsNullOrEmpty(query) || string.IsNullOrWhiteSpace(query))
+ if (string.IsNullOrWhiteSpace(query.Original))
{
return 1;
}
@@ -107,11 +114,21 @@ public partial class ContextMenuViewModel : ObservableObject,
return 0;
}
- var nameMatch = FuzzyStringMatcher.ScoreFuzzy(query, item.Title);
+ var fuzzyMatcher = _fuzzyMatcherProvider.Current;
+ var title = item.GetTitleTarget(fuzzyMatcher);
+ var subtitle = item.GetSubtitleTarget(fuzzyMatcher);
- var descriptionMatch = FuzzyStringMatcher.ScoreFuzzy(query, item.Subtitle);
+ var titleScore = fuzzyMatcher.Score(query, title);
+ var subtitleScore = (fuzzyMatcher.Score(query, subtitle) - 4) / 2;
- return new[] { nameMatch, (descriptionMatch - 4) / 2, 0 }.Max();
+ return Max3(titleScore, subtitleScore, 0);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static int Max3(int a, int b, int c)
+ {
+ var m = a > b ? a : b;
+ return m > c ? m : c;
}
///
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 2ee8f1e357..325f9b5ff8 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs
@@ -8,6 +8,7 @@ using System.Diagnostics;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Helpers;
+using Microsoft.CmdPal.Core.Common.Text;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.Ext.Apps;
using Microsoft.CmdPal.Ext.Apps.Programs;
@@ -24,7 +25,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.MainPage;
/// This class encapsulates the data we load from built-in providers and extensions to use within the same extension-UI system for a .
/// TODO: Need to think about how we structure/interop for the page -> section -> item between the main setup, the extensions, and our viewmodels.
///
-public partial class MainListPage : DynamicListPage,
+public sealed partial class MainListPage : DynamicListPage,
IRecipient,
IRecipient, IDisposable
{
@@ -32,13 +33,18 @@ public partial class MainListPage : DynamicListPage,
private readonly AliasManager _aliasManager;
private readonly SettingsModel _settings;
private readonly AppStateModel _appStateModel;
- private List>? _filteredItems;
- private List>? _filteredApps;
+ private readonly ScoringFunction _scoringFunction;
+ private readonly ScoringFunction _fallbackScoringFunction;
+ private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider;
+
+ private RoScored[]? _filteredItems;
+ private RoScored[]? _filteredApps;
// Keep as IEnumerable for deferred execution. Fallback item titles are updated
// asynchronously, so scoring must happen lazily when GetItems is called.
- private IEnumerable>? _scoredFallbackItems;
- private IEnumerable>? _fallbackItems;
+ private IEnumerable>? _scoredFallbackItems;
+ private IEnumerable>? _fallbackItems;
+
private bool _includeApps;
private bool _filteredItemsIncludesApps;
private int _appResultLimit = 10;
@@ -48,7 +54,12 @@ public partial class MainListPage : DynamicListPage,
private CancellationTokenSource? _cancellationTokenSource;
- public MainListPage(TopLevelCommandManager topLevelCommandManager, SettingsModel settings, AliasManager aliasManager, AppStateModel appStateModel)
+ public MainListPage(
+ TopLevelCommandManager topLevelCommandManager,
+ SettingsModel settings,
+ AliasManager aliasManager,
+ AppStateModel appStateModel,
+ IFuzzyMatcherProvider fuzzyMatcherProvider)
{
Title = Resources.builtin_home_name;
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png");
@@ -58,6 +69,10 @@ public partial class MainListPage : DynamicListPage,
_aliasManager = aliasManager;
_appStateModel = appStateModel;
_tlcManager = topLevelCommandManager;
+ _fuzzyMatcherProvider = fuzzyMatcherProvider;
+ _scoringFunction = (in query, item) => ScoreTopLevelItem(in query, item, _appStateModel.RecentCommands, _fuzzyMatcherProvider.Current);
+ _fallbackScoringFunction = (in _, item) => ScoreFallbackItem(item, _settings.FallbackRanks);
+
_tlcManager.PropertyChanged += TlcManager_PropertyChanged;
_tlcManager.TopLevelCommands.CollectionChanged += Commands_CollectionChanged;
@@ -190,8 +205,7 @@ public partial class MainListPage : DynamicListPage,
public override void UpdateSearchText(string oldSearch, string newSearch)
{
- var timer = new Stopwatch();
- timer.Start();
+ var stopwatch = Stopwatch.StartNew();
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
@@ -354,15 +368,14 @@ public partial class MainListPage : DynamicListPage,
if (_includeApps)
{
- var allNewApps = AllAppsCommandProvider.Page.GetItems().ToList();
+ var allNewApps = AllAppsCommandProvider.Page.GetItems().Cast().ToList();
// We need to remove pinned apps from allNewApps so they don't show twice.
var pinnedApps = PinnedAppsManager.Instance.GetPinnedAppIdentifiers();
if (pinnedApps.Length > 0)
{
- newApps = allNewApps.Where(w =>
- pinnedApps.IndexOf(((AppListItem)w).AppIdentifier) < 0);
+ newApps = allNewApps.Where(w => pinnedApps.IndexOf(w.AppIdentifier) < 0);
}
else
{
@@ -376,11 +389,10 @@ public partial class MainListPage : DynamicListPage,
}
}
- var history = _appStateModel.RecentCommands!;
- Func scoreItem = (a, b) => { return ScoreTopLevelItem(a, b, history); };
+ var searchQuery = _fuzzyMatcherProvider.Current.PrecomputeQuery(SearchText);
// Produce a list of everything that matches the current filter.
- _filteredItems = [.. ListHelpers.FilterListWithScores(newFilteredItems ?? [], SearchText, scoreItem)];
+ _filteredItems = InternalListHelpers.FilterListWithScores(newFilteredItems, searchQuery, _scoringFunction);
if (token.IsCancellationRequested)
{
@@ -388,21 +400,14 @@ public partial class MainListPage : DynamicListPage,
}
IEnumerable newFallbacksForScoring = commands.Where(s => s.IsFallback && globalFallbacks.Contains(s.Id));
+ _scoredFallbackItems = InternalListHelpers.FilterListWithScores(newFallbacksForScoring, searchQuery, _scoringFunction);
if (token.IsCancellationRequested)
{
return;
}
- _scoredFallbackItems = ListHelpers.FilterListWithScores(newFallbacksForScoring ?? [], SearchText, scoreItem);
-
- if (token.IsCancellationRequested)
- {
- return;
- }
-
- Func scoreFallbackItem = (a, b) => { return ScoreFallbackItem(a, b, _settings.FallbackRanks); };
- _fallbackItems = [.. ListHelpers.FilterListWithScores(newFallbacks ?? [], SearchText, scoreFallbackItem)];
+ _fallbackItems = InternalListHelpers.FilterListWithScores(newFallbacks ?? [], searchQuery, _fallbackScoringFunction);
if (token.IsCancellationRequested)
{
@@ -412,18 +417,7 @@ public partial class MainListPage : DynamicListPage,
// Produce a list of filtered apps with the appropriate limit
if (newApps.Any())
{
- var scoredApps = ListHelpers.FilterListWithScores(newApps, SearchText, scoreItem);
-
- if (token.IsCancellationRequested)
- {
- return;
- }
-
- // We'll apply this limit in the GetItems method after merging with commands
- // but we need to know the limit now to avoid re-scoring apps
- var appLimit = AllAppsCommandProvider.TopLevelResultLimit;
-
- _filteredApps = [.. scoredApps];
+ _filteredApps = InternalListHelpers.FilterListWithScores(newApps, searchQuery, _scoringFunction);
if (token.IsCancellationRequested)
{
@@ -431,10 +425,15 @@ public partial class MainListPage : DynamicListPage,
}
}
+ var filterDoneTimestamp = stopwatch.ElapsedMilliseconds;
+ Logger.LogDebug($"Filter with '{newSearch}' in {filterDoneTimestamp}ms");
+
RaiseItemsChanged();
- timer.Stop();
- Logger.LogDebug($"Filter with '{newSearch}' in {timer.ElapsedMilliseconds}ms");
+ var listPageUpdatedTimestamp = stopwatch.ElapsedMilliseconds;
+ Logger.LogDebug($"Render items with '{newSearch}' in {listPageUpdatedTimestamp}ms /d {listPageUpdatedTimestamp - filterDoneTimestamp}ms");
+
+ stopwatch.Stop();
}
}
@@ -478,7 +477,11 @@ public partial class MainListPage : DynamicListPage,
// Almost verbatim ListHelpers.ScoreListItem, but also accounting for the
// fact that we want fallback handlers down-weighted, so that they don't
// _always_ show up first.
- internal static int ScoreTopLevelItem(string query, IListItem topLevelOrAppItem, IRecentCommandsManager history)
+ internal static int ScoreTopLevelItem(
+ in FuzzyQuery query,
+ IListItem topLevelOrAppItem,
+ IRecentCommandsManager history,
+ IPrecomputedFuzzyMatcher precomputedFuzzyMatcher)
{
var title = topLevelOrAppItem.Title;
if (string.IsNullOrWhiteSpace(title))
@@ -486,94 +489,80 @@ public partial class MainListPage : DynamicListPage,
return 0;
}
- var isWhiteSpace = string.IsNullOrWhiteSpace(query);
-
var isFallback = false;
var isAliasSubstringMatch = false;
var isAliasMatch = false;
var id = IdForTopLevelOrAppItem(topLevelOrAppItem);
- var extensionDisplayName = string.Empty;
+ FuzzyTarget? extensionDisplayNameTarget = null;
if (topLevelOrAppItem is TopLevelViewModel topLevel)
{
isFallback = topLevel.IsFallback;
+ extensionDisplayNameTarget = topLevel.GetExtensionNameTarget(precomputedFuzzyMatcher);
+
if (topLevel.HasAlias)
{
var alias = topLevel.AliasText;
- isAliasMatch = alias == query;
- isAliasSubstringMatch = isAliasMatch || alias.StartsWith(query, StringComparison.CurrentCultureIgnoreCase);
+ isAliasMatch = alias == query.Original;
+ isAliasSubstringMatch = isAliasMatch || alias.StartsWith(query.Original, StringComparison.CurrentCultureIgnoreCase);
}
-
- extensionDisplayName = topLevel.ExtensionHost?.Extension?.PackageDisplayName ?? string.Empty;
}
- // StringMatcher.FuzzySearch will absolutely BEEF IT if you give it a
- // whitespace-only query.
- //
- // in that scenario, we'll just use a simple string contains for the
- // query. Maybe someone is really looking for things with a space in
- // them, I don't know.
-
- // Title:
- // * whitespace query: 1 point
- // * otherwise full weight match
- var nameMatch = isWhiteSpace ?
- (title.Contains(query) ? 1 : 0) :
- 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) :
- (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 : FuzzyStringMatcher.ScoreFuzzy(query, extensionDisplayName) / 1.5;
-
- var scores = new[]
+ // Handle whitespace query separately - FuzzySearch doesn't handle it well
+ if (string.IsNullOrWhiteSpace(query.Original))
{
- nameMatch,
- descriptionMatch,
- isFallback ? 1 : 0, // Always give fallbacks a chance
- };
- var max = scores.Max();
+ return ScoreWhitespaceQuery(query.Original, title, topLevelOrAppItem.Subtitle, isFallback);
+ }
- // _Add_ the extension name. This will bubble items that match both
- // title and extension name up above ones that just match title.
- // e.g. "git" will up-weight "GitHub searches" from the GitHub extension
- // above "git" from "whatever"
- max = max + extensionTitleMatch;
+ // Get precomputed targets
+ var (titleTarget, subtitleTarget) = topLevelOrAppItem is IPrecomputedListItem precomputedItem
+ ? (precomputedItem.GetTitleTarget(precomputedFuzzyMatcher), precomputedItem.GetSubtitleTarget(precomputedFuzzyMatcher))
+ : (precomputedFuzzyMatcher.PrecomputeTarget(title), precomputedFuzzyMatcher.PrecomputeTarget(topLevelOrAppItem.Subtitle));
+
+ // Score components
+ var nameScore = precomputedFuzzyMatcher.Score(query, titleTarget);
+ var descriptionScore = (precomputedFuzzyMatcher.Score(query, subtitleTarget) - 4) / 2.0;
+ var extensionScore = extensionDisplayNameTarget is { } extTarget ? precomputedFuzzyMatcher.Score(query, extTarget) / 1.5 : 0;
+
+ // Take best match from title/description/fallback, then add extension score
+ // Extension adds to max so items matching both title AND extension bubble up
+ var baseScore = Math.Max(Math.Max(nameScore, descriptionScore), isFallback ? 1 : 0);
+ var matchScore = baseScore + extensionScore;
// Apply a penalty to fallback items so they rank below direct matches.
// Fallbacks that dynamically match queries (like RDP connections) should
// appear after apps and direct command matches.
- if (isFallback && max > 1)
+ if (isFallback && matchScore > 1)
{
// Reduce fallback scores by 50% to prioritize direct matches
- max = max * 0.5;
+ matchScore = matchScore * 0.5;
}
- var matchSomething = max
- + (isAliasMatch ? 9001 : (isAliasSubstringMatch ? 1 : 0));
+ // Alias matching: exact match is overwhelming priority, substring match adds a small boost
+ var aliasBoost = isAliasMatch ? 9001 : (isAliasSubstringMatch ? 1 : 0);
+ var totalMatch = matchScore + aliasBoost;
- // If we matched title, subtitle, or alias (something real), then
- // here we add the recent command weight boost
- //
- // Otherwise something like `x` will still match everything you've run before
- var finalScore = matchSomething * 10;
- if (matchSomething > 0)
+ // Apply scaling and history boost only if we matched something real
+ var finalScore = totalMatch * 10;
+ if (totalMatch > 0)
{
- var recentWeightBoost = history.GetCommandHistoryWeight(id);
- finalScore += recentWeightBoost;
+ finalScore += history.GetCommandHistoryWeight(id);
}
return (int)finalScore;
}
- internal static int ScoreFallbackItem(string query, IListItem topLevelOrAppItem, string[] fallbackRanks)
+ private static int ScoreWhitespaceQuery(string query, string title, string subtitle, bool isFallback)
+ {
+ // Simple contains check for whitespace queries
+ var nameMatch = title.Contains(query, StringComparison.Ordinal) ? 1.0 : 0;
+ var descriptionMatch = subtitle.Contains(query, StringComparison.Ordinal) ? 0.5 : 0;
+ var baseScore = Math.Max(Math.Max(nameMatch, descriptionMatch), isFallback ? 1 : 0);
+
+ return (int)(baseScore * 10);
+ }
+
+ private static int ScoreFallbackItem(IListItem topLevelOrAppItem, string[] fallbackRanks)
{
// Default to 1 so it always shows in list.
var finalScore = 1;
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs
index d63c0e4f90..0c0d876179 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs
@@ -4,6 +4,7 @@
#pragma warning disable IDE0007 // Use implicit type
+using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -16,10 +17,10 @@ internal static class MainListPageResultFactory
/// applying an application result limit and filtering fallback items as needed.
///
public static IListItem[] Create(
- IList>? filteredItems,
- IList>? scoredFallbackItems,
- IList>? filteredApps,
- IList>? fallbackItems,
+ IList>? filteredItems,
+ IList>? scoredFallbackItems,
+ IList>? filteredApps,
+ IList>? fallbackItems,
int appResultLimit)
{
if (appResultLimit < 0)
@@ -147,7 +148,7 @@ internal static class MainListPageResultFactory
return result;
}
- private static int GetNonEmptyFallbackItemsCount(IList>? fallbackItems)
+ private static int GetNonEmptyFallbackItemsCount(IList>? fallbackItems)
{
int fallbackItemsCount = 0;
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs
index cc863fe362..13b9423119 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs
@@ -3,8 +3,11 @@
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
+using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel;
using ManagedCommon;
+using Microsoft.CmdPal.Core.Common.Helpers;
+using Microsoft.CmdPal.Core.Common.Text;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Settings;
@@ -16,7 +19,8 @@ using WyHash;
namespace Microsoft.CmdPal.UI.ViewModels;
-public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IExtendedAttributesProvider
+[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
+public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IExtendedAttributesProvider, IPrecomputedListItem
{
private readonly SettingsModel _settings;
private readonly ProviderSettings _providerSettings;
@@ -34,6 +38,10 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
private HotkeySettings? _hotkey;
private IIconInfo? _initialIcon;
+ private FuzzyTargetCache _titleCache;
+ private FuzzyTargetCache _subtitleCache;
+ private FuzzyTargetCache _extensionNameCache;
+
private CommandAlias? Alias { get; set; }
public bool IsFallback { get; private set; }
@@ -176,6 +184,8 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
}
}
+ public string ExtensionName => ExtensionHost.GetExtensionDisplayName() ?? string.Empty;
+
public TopLevelViewModel(
CommandItemViewModel item,
bool isFallback,
@@ -230,6 +240,15 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
{
PropChanged?.Invoke(this, new PropChangedEventArgs(e.PropertyName));
+ if (e.PropertyName is nameof(CommandItemViewModel.Title) or nameof(CommandItemViewModel.Name))
+ {
+ _titleCache.Invalidate();
+ }
+ else if (e.PropertyName is nameof(CommandItemViewModel.Subtitle))
+ {
+ _subtitleCache.Invalidate();
+ }
+
if (e.PropertyName is "IsInitialized" or nameof(CommandItemViewModel.Command))
{
GenerateId();
@@ -420,4 +439,18 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
[WellKnownExtensionAttributes.DataPackage] = _commandItemViewModel?.DataPackage,
};
}
+
+ public FuzzyTarget GetTitleTarget(IPrecomputedFuzzyMatcher matcher)
+ => _titleCache.GetOrUpdate(matcher, Title);
+
+ public FuzzyTarget GetSubtitleTarget(IPrecomputedFuzzyMatcher matcher)
+ => _subtitleCache.GetOrUpdate(matcher, Subtitle);
+
+ public FuzzyTarget GetExtensionNameTarget(IPrecomputedFuzzyMatcher matcher)
+ => _extensionNameCache.GetOrUpdate(matcher, ExtensionName);
+
+ private string GetDebuggerDisplay()
+ {
+ return ToString();
+ }
}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs
index eb103d3157..152bf95a62 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs
@@ -6,6 +6,7 @@ using ManagedCommon;
using Microsoft.CmdPal.Core.Common;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.Core.Common.Services;
+using Microsoft.CmdPal.Core.Common.Text;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Ext.Apps;
using Microsoft.CmdPal.Ext.Bookmarks;
@@ -206,6 +207,9 @@ public partial class App : Application, IDisposable
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton(
+ _ => new FuzzyMatcherProvider(new PrecomputedFuzzyMatcherOptions(), new PinyinFuzzyMatcherOptions()));
+
// ViewModels
services.AddSingleton();
services.AddSingleton();
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml.cs
index afc2d190ef..58f7c6318f 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml.cs
@@ -4,9 +4,11 @@
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
+using Microsoft.CmdPal.Core.Common.Text;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.UI.Messages;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -21,21 +23,19 @@ public sealed partial class ContextMenu : UserControl,
IRecipient,
IRecipient
{
- public ContextMenuViewModel ViewModel { get; } = new();
+ public ContextMenuViewModel ViewModel { get; }
public ContextMenu()
{
this.InitializeComponent();
+ ViewModel = new ContextMenuViewModel(App.Current.Services.GetRequiredService());
+ ViewModel.PropertyChanged += ViewModel_PropertyChanged;
+
// RegisterAll isn't AOT compatible
WeakReferenceMessenger.Default.Register(this);
WeakReferenceMessenger.Default.Register(this);
WeakReferenceMessenger.Default.Register(this);
-
- if (ViewModel is not null)
- {
- ViewModel.PropertyChanged += ViewModel_PropertyChanged;
- }
}
public void Receive(OpenContextMenuMessage message)
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs
index 9a877358f0..23d8b413e0 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs
@@ -6,6 +6,7 @@ using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Services;
+using Microsoft.CmdPal.Core.Common.Text;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
@@ -23,13 +24,13 @@ internal sealed class PowerToysRootPageService : IRootPageService
private IExtensionWrapper? _activeExtension;
private Lazy _mainListPage;
- public PowerToysRootPageService(TopLevelCommandManager topLevelCommandManager, SettingsModel settings, AliasManager aliasManager, AppStateModel appStateModel)
+ public PowerToysRootPageService(TopLevelCommandManager topLevelCommandManager, SettingsModel settings, AliasManager aliasManager, AppStateModel appStateModel, IFuzzyMatcherProvider fuzzyMatcherProvider)
{
_tlcManager = topLevelCommandManager;
_mainListPage = new Lazy(() =>
{
- return new MainListPage(_tlcManager, settings, aliasManager, appStateModel);
+ return new MainListPage(_tlcManager, settings, aliasManager, appStateModel, fuzzyMatcherProvider);
});
}
diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherEmojiTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherEmojiTests.cs
new file mode 100644
index 0000000000..fc85834b2e
--- /dev/null
+++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherEmojiTests.cs
@@ -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");
+ }
+}
diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherOptionsTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherOptionsTests.cs
new file mode 100644
index 0000000000..b5798986ff
--- /dev/null
+++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherOptionsTests.cs
@@ -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.");
+ }
+}
diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherSecondaryInputTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherSecondaryInputTests.cs
new file mode 100644
index 0000000000..70c86a4598
--- /dev/null
+++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherSecondaryInputTests.cs
@@ -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;
+ }
+}
diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherTests.cs
new file mode 100644
index 0000000000..bdd3898ac9
--- /dev/null
+++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherTests.cs
@@ -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