< Summary

Information
Class: SwitchBlade.Core.FuzzyMatcher
Assembly: SwitchBlade
File(s): D:\a\switchblade\switchblade\Core\FuzzyMatcher.cs
Tag: 203_23722840422
Line coverage
100%
Covered lines: 129
Uncovered lines: 0
Coverable lines: 129
Total lines: 271
Line coverage: 100%
Branch coverage
100%
Covered branches: 82
Total branches: 82
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Score(...)100%88100%
ScoreNormalized(...)100%88100%
Normalize(...)100%1010100%
CalculateSubsequenceScore(...)100%1414100%
IsMatch(...)100%11100%
GetMatchedIndices(...)100%88100%
GetSubstringMatchIndices(...)100%44100%
GetFuzzyMatchIndices(...)100%2020100%
NormalizeWithMap(...)100%1010100%

File(s)

D:\a\switchblade\switchblade\Core\FuzzyMatcher.cs

#LineLine coverage
 1using System;
 2using System.Runtime.CompilerServices;
 3
 4namespace SwitchBlade.Core
 5{
 6    /// <summary>
 7    /// High-performance fuzzy matching service for window title search.
 8    /// Implements subsequence matching with delimiter normalization and intelligent scoring.
 9    /// </summary>
 10    /// <remarks>
 11    /// Performance optimizations:
 12    /// - Uses <see cref="Span{T}"/> and stackalloc for zero-allocation string operations
 13    /// - Early termination when match is impossible
 14    /// - Static methods to avoid object allocations
 15    /// - Inline aggressive methods for hot paths
 16    /// </remarks>
 17    public static class FuzzyMatcher
 18    {
 19        // Scoring constants
 20        private const int BaseMatchScore = 1;
 21        private const int ContiguityBonus = 2;
 22        private const int WordBoundaryBonus = 3;
 23        private const int StartsWithBonus = 5;
 24
 25        // Maximum title length we'll process (longer titles are truncated for perf)
 26        private const int MaxNormalizedLength = 512;
 27
 28        /// <summary>
 29        /// Calculates a fuzzy match score between a search query and a window title.
 30        /// </summary>
 31        /// <param name="title">The window title to search in.</param>
 32        /// <param name="query">The user's search query.</param>
 33        /// <returns>
 34        /// A score >= 0. Higher scores indicate better matches.
 35        /// Returns 0 if there is no match.
 36        /// </returns>
 37        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 38        public static int Score(string title, string query)
 6439        {
 6440            if (string.IsNullOrEmpty(query) || string.IsNullOrEmpty(title))
 441                return 0;
 42
 43            // Fast path: exact contains check (common case)
 6044            if (title.Contains(query, StringComparison.OrdinalIgnoreCase))
 2645            {
 2646                int exactScore = query.Length * (BaseMatchScore + ContiguityBonus);
 47                // Bonus if title starts with query
 2648                if (title.StartsWith(query, StringComparison.OrdinalIgnoreCase))
 2149                    exactScore += StartsWithBonus;
 2650                return exactScore;
 51            }
 52
 53            // Normalize and perform fuzzy match
 3454            return ScoreNormalized(title.AsSpan(), query.AsSpan());
 6455        }
 56
 57        /// <summary>
 58        /// Performs fuzzy matching on normalized spans.
 59        /// </summary>
 60        private static int ScoreNormalized(ReadOnlySpan<char> title, ReadOnlySpan<char> query)
 3461        {
 62            // Allocate normalized buffers on stack for small strings
 3463            int titleLen = Math.Min(title.Length, MaxNormalizedLength);
 3464            int queryLen = Math.Min(query.Length, MaxNormalizedLength);
 65
 3466            Span<char> normalizedTitle = titleLen <= 256 ? stackalloc char[titleLen] : new char[titleLen];
 3467            Span<char> normalizedQuery = queryLen <= 64 ? stackalloc char[queryLen] : new char[queryLen];
 68
 3469            int actualTitleLen = Normalize(title.Slice(0, titleLen), normalizedTitle);
 3470            int actualQueryLen = Normalize(query.Slice(0, queryLen), normalizedQuery);
 71
 3472            if (actualQueryLen == 0)
 173                return 0;
 74
 75            // Trim the spans to actual normalized lengths
 3376            normalizedTitle = normalizedTitle.Slice(0, actualTitleLen);
 3377            normalizedQuery = normalizedQuery.Slice(0, actualQueryLen);
 78
 79            // Quick rejection: if query is longer than title, no match possible
 3380            if (actualQueryLen > actualTitleLen)
 381                return 0;
 82
 3083            return CalculateSubsequenceScore(normalizedTitle, normalizedQuery);
 3484        }
 85
 86        /// <summary>
 87        /// Normalizes a string for fuzzy matching:
 88        /// - Converts to lowercase
 89        /// - Treats spaces, underscores, and dashes as equivalent (normalized to empty/skip)
 90        /// </summary>
 91        /// <returns>The actual length of the normalized output (may be shorter due to delimiter removal).</returns>
 92        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 93        private static int Normalize(ReadOnlySpan<char> input, Span<char> output)
 7994        {
 7995            int writeIndex = 0;
 255496            for (int i = 0; i < input.Length && writeIndex < output.Length; i++)
 119897            {
 119898                char c = input[i];
 99
 100                // Skip delimiters entirely (space, underscore, dash)
 1198101                if (c == ' ' || c == '_' || c == '-')
 65102                    continue;
 103
 104                // Convert to lowercase inline
 1133105                output[writeIndex++] = char.ToLowerInvariant(c);
 1133106            }
 79107            return writeIndex;
 79108        }
 109
 110        /// <summary>
 111        /// Calculates the fuzzy match score using subsequence matching with bonuses.
 112        /// </summary>
 113        private static int CalculateSubsequenceScore(ReadOnlySpan<char> title, ReadOnlySpan<char> query)
 30114        {
 30115            int queryIndex = 0;
 30116            int score = 0;
 30117            int lastMatchIndex = -1;
 30118            bool matchedAtStart = false;
 119
 1454120            for (int titleIndex = 0; titleIndex < title.Length && queryIndex < query.Length; titleIndex++)
 697121            {
 697122                if (title[titleIndex] == query[queryIndex])
 194123                {
 124                    // Base score for matching character
 194125                    score += BaseMatchScore;
 126
 127                    // Contiguity bonus: consecutive matches
 194128                    if (lastMatchIndex == titleIndex - 1)
 168129                    {
 168130                        score += ContiguityBonus;
 168131                    }
 132
 133                    // Word boundary bonus: match at position 0 or after a word boundary
 134                    // Since we removed delimiters, position 0 in normalized string or
 135                    // the first character match gets a bonus
 194136                    if (titleIndex == 0)
 21137                    {
 21138                        matchedAtStart = true;
 21139                        score += WordBoundaryBonus;
 21140                    }
 141
 194142                    lastMatchIndex = titleIndex;
 194143                    queryIndex++;
 194144                }
 697145            }
 146
 147            // Did we match all query characters?
 30148            if (queryIndex < query.Length)
 9149                return 0; // Incomplete match
 150
 151            // Starts-with bonus: if matching started at the beginning
 21152            if (matchedAtStart)
 20153            {
 20154                score += StartsWithBonus;
 20155            }
 156
 21157            return score;
 30158        }
 159
 160        /// <summary>
 161        /// Checks if a query matches a title using fuzzy matching.
 162        /// </summary>
 163        /// <param name="title">The window title to search in.</param>
 164        /// <param name="query">The user's search query.</param>
 165        /// <returns>True if there is any match (score > 0).</returns>
 166        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 167        public static bool IsMatch(string title, string query)
 3168        {
 3169            return Score(title, query) > 0;
 3170        }
 171        /// <summary>
 172        /// Returns the indices in the original <paramref name="title"/> that match
 173        /// the <paramref name="query"/> using fuzzy subsequence matching.
 174        /// Indices refer to positions in the original title string, not the normalized version.
 175        /// </summary>
 176        /// <param name="title">The window title to search in.</param>
 177        /// <param name="query">The user's search query.</param>
 178        /// <param name="useFuzzy">When true, uses fuzzy subsequence matching; when false, uses substring matching.</par
 179        /// <returns>Sorted array of 0-based character indices that matched, or empty array if no match.</returns>
 180        public static int[] GetMatchedIndices(string title, string query, bool useFuzzy)
 31181        {
 31182            if (string.IsNullOrEmpty(query) || string.IsNullOrEmpty(title))
 4183                return Array.Empty<int>();
 184
 27185            if (!useFuzzy)
 2186                return GetSubstringMatchIndices(title, query);
 187
 188            // Fast path: exact contains check (use substring indices)
 25189            if (title.Contains(query, StringComparison.OrdinalIgnoreCase))
 14190                return GetSubstringMatchIndices(title, query);
 191
 192            // Fuzzy path: normalize both, match subsequence, then map back to original indices
 11193            return GetFuzzyMatchIndices(title, query);
 31194        }
 195
 196        /// <summary>
 197        /// Gets indices for a case-insensitive substring match.
 198        /// </summary>
 199        private static int[] GetSubstringMatchIndices(string title, string query)
 16200        {
 16201            int start = title.IndexOf(query, StringComparison.OrdinalIgnoreCase);
 16202            if (start < 0)
 1203                return Array.Empty<int>();
 204
 15205            var indices = new int[query.Length];
 118206            for (int i = 0; i < query.Length; i++)
 44207                indices[i] = start + i;
 15208            return indices;
 16209        }
 210
 211        /// <summary>
 212        /// Gets indices for fuzzy subsequence matching, mapped back to original title positions.
 213        /// </summary>
 214        private static int[] GetFuzzyMatchIndices(string title, string query)
 11215        {
 216            // Build a mapping from normalized index -> original index
 11217            int titleLen = Math.Min(title.Length, MaxNormalizedLength);
 11218            int queryLen = Math.Min(query.Length, MaxNormalizedLength);
 219
 11220            Span<char> normalizedTitle = titleLen <= 256 ? stackalloc char[titleLen] : new char[titleLen];
 11221            Span<int> indexMap = titleLen <= 256 ? stackalloc int[titleLen] : new int[titleLen];
 11222            Span<char> normalizedQuery = queryLen <= 64 ? stackalloc char[queryLen] : new char[queryLen];
 223
 11224            int actualTitleLen = NormalizeWithMap(title.AsSpan(0, titleLen), normalizedTitle, indexMap);
 11225            int actualQueryLen = Normalize(query.AsSpan(0, queryLen), normalizedQuery);
 226
 11227            if (actualQueryLen == 0 || actualQueryLen > actualTitleLen)
 4228                return Array.Empty<int>();
 229
 230            // Perform subsequence matching on normalized strings
 7231            var matchedNormalized = new System.Collections.Generic.List<int>(actualQueryLen);
 7232            int qi = 0;
 708233            for (int ti = 0; ti < actualTitleLen && qi < actualQueryLen; ti++)
 347234            {
 347235                if (normalizedTitle[ti] == normalizedQuery[qi])
 10236                {
 10237                    matchedNormalized.Add(ti);
 10238                    qi++;
 10239                }
 347240            }
 241
 7242            if (qi < actualQueryLen)
 3243                return Array.Empty<int>(); // Incomplete match
 244
 245            // Map normalized indices back to original title indices
 4246            var result = new int[matchedNormalized.Count];
 24247            for (int i = 0; i < matchedNormalized.Count; i++)
 8248                result[i] = indexMap[matchedNormalized[i]];
 4249            return result;
 11250        }
 251
 252        /// <summary>
 253        /// Normalizes input while building a mapping from normalized index to original index.
 254        /// </summary>
 255        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 256        private static int NormalizeWithMap(ReadOnlySpan<char> input, Span<char> output, Span<int> indexMap)
 11257        {
 11258            int writeIndex = 0;
 780259            for (int i = 0; i < input.Length && writeIndex < output.Length; i++)
 379260            {
 379261                char c = input[i];
 379262                if (c == ' ' || c == '_' || c == '-')
 3263                    continue;
 376264                output[writeIndex] = char.ToLowerInvariant(c);
 376265                indexMap[writeIndex] = i;
 376266                writeIndex++;
 376267            }
 11268            return writeIndex;
 11269        }
 270    }
 271}