| | | 1 | | using System; |
| | | 2 | | using System.Runtime.CompilerServices; |
| | | 3 | | |
| | | 4 | | namespace 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) |
| | 64 | 39 | | { |
| | 64 | 40 | | if (string.IsNullOrEmpty(query) || string.IsNullOrEmpty(title)) |
| | 4 | 41 | | return 0; |
| | | 42 | | |
| | | 43 | | // Fast path: exact contains check (common case) |
| | 60 | 44 | | if (title.Contains(query, StringComparison.OrdinalIgnoreCase)) |
| | 26 | 45 | | { |
| | 26 | 46 | | int exactScore = query.Length * (BaseMatchScore + ContiguityBonus); |
| | | 47 | | // Bonus if title starts with query |
| | 26 | 48 | | if (title.StartsWith(query, StringComparison.OrdinalIgnoreCase)) |
| | 21 | 49 | | exactScore += StartsWithBonus; |
| | 26 | 50 | | return exactScore; |
| | | 51 | | } |
| | | 52 | | |
| | | 53 | | // Normalize and perform fuzzy match |
| | 34 | 54 | | return ScoreNormalized(title.AsSpan(), query.AsSpan()); |
| | 64 | 55 | | } |
| | | 56 | | |
| | | 57 | | /// <summary> |
| | | 58 | | /// Performs fuzzy matching on normalized spans. |
| | | 59 | | /// </summary> |
| | | 60 | | private static int ScoreNormalized(ReadOnlySpan<char> title, ReadOnlySpan<char> query) |
| | 34 | 61 | | { |
| | | 62 | | // Allocate normalized buffers on stack for small strings |
| | 34 | 63 | | int titleLen = Math.Min(title.Length, MaxNormalizedLength); |
| | 34 | 64 | | int queryLen = Math.Min(query.Length, MaxNormalizedLength); |
| | | 65 | | |
| | 34 | 66 | | Span<char> normalizedTitle = titleLen <= 256 ? stackalloc char[titleLen] : new char[titleLen]; |
| | 34 | 67 | | Span<char> normalizedQuery = queryLen <= 64 ? stackalloc char[queryLen] : new char[queryLen]; |
| | | 68 | | |
| | 34 | 69 | | int actualTitleLen = Normalize(title.Slice(0, titleLen), normalizedTitle); |
| | 34 | 70 | | int actualQueryLen = Normalize(query.Slice(0, queryLen), normalizedQuery); |
| | | 71 | | |
| | 34 | 72 | | if (actualQueryLen == 0) |
| | 1 | 73 | | return 0; |
| | | 74 | | |
| | | 75 | | // Trim the spans to actual normalized lengths |
| | 33 | 76 | | normalizedTitle = normalizedTitle.Slice(0, actualTitleLen); |
| | 33 | 77 | | normalizedQuery = normalizedQuery.Slice(0, actualQueryLen); |
| | | 78 | | |
| | | 79 | | // Quick rejection: if query is longer than title, no match possible |
| | 33 | 80 | | if (actualQueryLen > actualTitleLen) |
| | 3 | 81 | | return 0; |
| | | 82 | | |
| | 30 | 83 | | return CalculateSubsequenceScore(normalizedTitle, normalizedQuery); |
| | 34 | 84 | | } |
| | | 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) |
| | 79 | 94 | | { |
| | 79 | 95 | | int writeIndex = 0; |
| | 2554 | 96 | | for (int i = 0; i < input.Length && writeIndex < output.Length; i++) |
| | 1198 | 97 | | { |
| | 1198 | 98 | | char c = input[i]; |
| | | 99 | | |
| | | 100 | | // Skip delimiters entirely (space, underscore, dash) |
| | 1198 | 101 | | if (c == ' ' || c == '_' || c == '-') |
| | 65 | 102 | | continue; |
| | | 103 | | |
| | | 104 | | // Convert to lowercase inline |
| | 1133 | 105 | | output[writeIndex++] = char.ToLowerInvariant(c); |
| | 1133 | 106 | | } |
| | 79 | 107 | | return writeIndex; |
| | 79 | 108 | | } |
| | | 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) |
| | 30 | 114 | | { |
| | 30 | 115 | | int queryIndex = 0; |
| | 30 | 116 | | int score = 0; |
| | 30 | 117 | | int lastMatchIndex = -1; |
| | 30 | 118 | | bool matchedAtStart = false; |
| | | 119 | | |
| | 1454 | 120 | | for (int titleIndex = 0; titleIndex < title.Length && queryIndex < query.Length; titleIndex++) |
| | 697 | 121 | | { |
| | 697 | 122 | | if (title[titleIndex] == query[queryIndex]) |
| | 194 | 123 | | { |
| | | 124 | | // Base score for matching character |
| | 194 | 125 | | score += BaseMatchScore; |
| | | 126 | | |
| | | 127 | | // Contiguity bonus: consecutive matches |
| | 194 | 128 | | if (lastMatchIndex == titleIndex - 1) |
| | 168 | 129 | | { |
| | 168 | 130 | | score += ContiguityBonus; |
| | 168 | 131 | | } |
| | | 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 |
| | 194 | 136 | | if (titleIndex == 0) |
| | 21 | 137 | | { |
| | 21 | 138 | | matchedAtStart = true; |
| | 21 | 139 | | score += WordBoundaryBonus; |
| | 21 | 140 | | } |
| | | 141 | | |
| | 194 | 142 | | lastMatchIndex = titleIndex; |
| | 194 | 143 | | queryIndex++; |
| | 194 | 144 | | } |
| | 697 | 145 | | } |
| | | 146 | | |
| | | 147 | | // Did we match all query characters? |
| | 30 | 148 | | if (queryIndex < query.Length) |
| | 9 | 149 | | return 0; // Incomplete match |
| | | 150 | | |
| | | 151 | | // Starts-with bonus: if matching started at the beginning |
| | 21 | 152 | | if (matchedAtStart) |
| | 20 | 153 | | { |
| | 20 | 154 | | score += StartsWithBonus; |
| | 20 | 155 | | } |
| | | 156 | | |
| | 21 | 157 | | return score; |
| | 30 | 158 | | } |
| | | 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) |
| | 3 | 168 | | { |
| | 3 | 169 | | return Score(title, query) > 0; |
| | 3 | 170 | | } |
| | | 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) |
| | 31 | 181 | | { |
| | 31 | 182 | | if (string.IsNullOrEmpty(query) || string.IsNullOrEmpty(title)) |
| | 4 | 183 | | return Array.Empty<int>(); |
| | | 184 | | |
| | 27 | 185 | | if (!useFuzzy) |
| | 2 | 186 | | return GetSubstringMatchIndices(title, query); |
| | | 187 | | |
| | | 188 | | // Fast path: exact contains check (use substring indices) |
| | 25 | 189 | | if (title.Contains(query, StringComparison.OrdinalIgnoreCase)) |
| | 14 | 190 | | return GetSubstringMatchIndices(title, query); |
| | | 191 | | |
| | | 192 | | // Fuzzy path: normalize both, match subsequence, then map back to original indices |
| | 11 | 193 | | return GetFuzzyMatchIndices(title, query); |
| | 31 | 194 | | } |
| | | 195 | | |
| | | 196 | | /// <summary> |
| | | 197 | | /// Gets indices for a case-insensitive substring match. |
| | | 198 | | /// </summary> |
| | | 199 | | private static int[] GetSubstringMatchIndices(string title, string query) |
| | 16 | 200 | | { |
| | 16 | 201 | | int start = title.IndexOf(query, StringComparison.OrdinalIgnoreCase); |
| | 16 | 202 | | if (start < 0) |
| | 1 | 203 | | return Array.Empty<int>(); |
| | | 204 | | |
| | 15 | 205 | | var indices = new int[query.Length]; |
| | 118 | 206 | | for (int i = 0; i < query.Length; i++) |
| | 44 | 207 | | indices[i] = start + i; |
| | 15 | 208 | | return indices; |
| | 16 | 209 | | } |
| | | 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) |
| | 11 | 215 | | { |
| | | 216 | | // Build a mapping from normalized index -> original index |
| | 11 | 217 | | int titleLen = Math.Min(title.Length, MaxNormalizedLength); |
| | 11 | 218 | | int queryLen = Math.Min(query.Length, MaxNormalizedLength); |
| | | 219 | | |
| | 11 | 220 | | Span<char> normalizedTitle = titleLen <= 256 ? stackalloc char[titleLen] : new char[titleLen]; |
| | 11 | 221 | | Span<int> indexMap = titleLen <= 256 ? stackalloc int[titleLen] : new int[titleLen]; |
| | 11 | 222 | | Span<char> normalizedQuery = queryLen <= 64 ? stackalloc char[queryLen] : new char[queryLen]; |
| | | 223 | | |
| | 11 | 224 | | int actualTitleLen = NormalizeWithMap(title.AsSpan(0, titleLen), normalizedTitle, indexMap); |
| | 11 | 225 | | int actualQueryLen = Normalize(query.AsSpan(0, queryLen), normalizedQuery); |
| | | 226 | | |
| | 11 | 227 | | if (actualQueryLen == 0 || actualQueryLen > actualTitleLen) |
| | 4 | 228 | | return Array.Empty<int>(); |
| | | 229 | | |
| | | 230 | | // Perform subsequence matching on normalized strings |
| | 7 | 231 | | var matchedNormalized = new System.Collections.Generic.List<int>(actualQueryLen); |
| | 7 | 232 | | int qi = 0; |
| | 708 | 233 | | for (int ti = 0; ti < actualTitleLen && qi < actualQueryLen; ti++) |
| | 347 | 234 | | { |
| | 347 | 235 | | if (normalizedTitle[ti] == normalizedQuery[qi]) |
| | 10 | 236 | | { |
| | 10 | 237 | | matchedNormalized.Add(ti); |
| | 10 | 238 | | qi++; |
| | 10 | 239 | | } |
| | 347 | 240 | | } |
| | | 241 | | |
| | 7 | 242 | | if (qi < actualQueryLen) |
| | 3 | 243 | | return Array.Empty<int>(); // Incomplete match |
| | | 244 | | |
| | | 245 | | // Map normalized indices back to original title indices |
| | 4 | 246 | | var result = new int[matchedNormalized.Count]; |
| | 24 | 247 | | for (int i = 0; i < matchedNormalized.Count; i++) |
| | 8 | 248 | | result[i] = indexMap[matchedNormalized[i]]; |
| | 4 | 249 | | return result; |
| | 11 | 250 | | } |
| | | 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) |
| | 11 | 257 | | { |
| | 11 | 258 | | int writeIndex = 0; |
| | 780 | 259 | | for (int i = 0; i < input.Length && writeIndex < output.Length; i++) |
| | 379 | 260 | | { |
| | 379 | 261 | | char c = input[i]; |
| | 379 | 262 | | if (c == ' ' || c == '_' || c == '-') |
| | 3 | 263 | | continue; |
| | 376 | 264 | | output[writeIndex] = char.ToLowerInvariant(c); |
| | 376 | 265 | | indexMap[writeIndex] = i; |
| | 376 | 266 | | writeIndex++; |
| | 376 | 267 | | } |
| | 11 | 268 | | return writeIndex; |
| | 11 | 269 | | } |
| | | 270 | | } |
| | | 271 | | } |