From 361a0a95e9f1415014eab2ee8a25d2941f44de65 Mon Sep 17 00:00:00 2001 From: Soreepeong Date: Thu, 21 Dec 2023 23:13:05 +0900 Subject: [PATCH] Make FuzzyMatcher more convenient to use --- .../PluginInstaller/PluginInstallerWindow.cs | 43 +-- Dalamud/Utility/FuzzyMatcher.cs | 293 +++++++++--------- 2 files changed, 178 insertions(+), 158 deletions(-) diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs index 3536c9fe7..fcf9d4f6d 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs @@ -1252,7 +1252,7 @@ internal class PluginInstallerWindow : Window, IDisposable return; } - IEnumerable changelogs = null; + IEnumerable? changelogs = null; if (displayDalamud && displayPlugins && this.dalamudChangelogManager.Changelogs != null) { changelogs = this.dalamudChangelogManager.Changelogs; @@ -1266,10 +1266,15 @@ internal class PluginInstallerWindow : Window, IDisposable changelogs = this.dalamudChangelogManager.Changelogs.OfType(); } - var sortedChangelogs = changelogs?.Where(x => this.searchText.IsNullOrWhitespace() || new FuzzyMatcher(this.searchText.ToLowerInvariant(), MatchMode.FuzzyParts).Matches(x.Title.ToLowerInvariant()) > 0) - .OrderByDescending(x => x.Date).ToList(); + changelogs ??= Array.Empty(); + var sortedChangelogs = + this.searchText.IsNullOrWhitespace() + ? changelogs.ToList() + : changelogs.Where(x => x.Title.FuzzyMatchesParts(this.searchText)) + .OrderByDescending(x => x.Date) + .ToList(); - if (sortedChangelogs == null || sortedChangelogs.Count == 0) + if (sortedChangelogs.Count == 0) { ImGui.TextColored( ImGuiColors.DalamudGrey2, @@ -3793,22 +3798,24 @@ internal class PluginInstallerWindow : Window, IDisposable private int GetManifestSearchScore(IPluginManifest manifest) { - var searchString = this.searchText.ToLowerInvariant(); - var matcher = new FuzzyMatcher(searchString, MatchMode.FuzzyParts); - var scores = new List { 0 }; + var loc = Localization.GetCultureInfoFromLangCode(Service.Get().EffectiveLanguage); + var maxScore = 0; - if (!manifest.Name.IsNullOrEmpty()) - scores.Add(matcher.Matches(manifest.Name.ToLowerInvariant()) * 110); - if (!manifest.InternalName.IsNullOrEmpty()) - scores.Add(matcher.Matches(manifest.InternalName.ToLowerInvariant()) * 105); - if (!manifest.Author.IsNullOrEmpty()) - scores.Add(matcher.Matches(manifest.Author.ToLowerInvariant()) * 100); - if (!manifest.Punchline.IsNullOrEmpty()) - scores.Add(matcher.Matches(manifest.Punchline.ToLowerInvariant()) * 100); - if (manifest.Tags != null) - scores.Add(matcher.MatchesAny(manifest.Tags.ToArray()) * 100); + if (manifest.Name.FuzzyMatches(this.searchText, FuzzyMatcher.Mode.FuzzyParts, loc, out var score)) + maxScore = Math.Max(maxScore, score * 110); + if (manifest.InternalName.FuzzyMatches(this.searchText, FuzzyMatcher.Mode.FuzzyParts, loc, out score)) + maxScore = Math.Max(maxScore, score * 105); + if (manifest.Author.FuzzyMatches(this.searchText, FuzzyMatcher.Mode.FuzzyParts, loc, out score)) + maxScore = Math.Max(maxScore, score * 100); + if (manifest.Punchline.FuzzyMatches(this.searchText, FuzzyMatcher.Mode.FuzzyParts, loc, out score)) + maxScore = Math.Max(maxScore, score * 100); + foreach (var tag in manifest.Tags ?? []) + { + if (tag.FuzzyMatches(this.searchText, FuzzyMatcher.Mode.FuzzyParts, loc, out score)) + maxScore = Math.Max(maxScore, score * 100); + } - return scores.Max(); + return maxScore; } private (bool IsInstalled, LocalPlugin Plugin) IsManifestInstalled(IPluginManifest? manifest) diff --git a/Dalamud/Utility/FuzzyMatcher.cs b/Dalamud/Utility/FuzzyMatcher.cs index 03723da89..b58e1fe92 100644 --- a/Dalamud/Utility/FuzzyMatcher.cs +++ b/Dalamud/Utility/FuzzyMatcher.cs @@ -1,168 +1,140 @@ #define BORDER_MATCHING -using System.Collections.Generic; +using System.Globalization; using System.Runtime.CompilerServices; namespace Dalamud.Utility; - #pragma warning disable SA1600 #pragma warning disable SA1602 +/// +/// Specify fuzzy match mode. +/// internal enum MatchMode { Simple, + + /// + /// The string is considered for fuzzy matching as a whole. + /// Fuzzy, + + /// + /// Each part of the string, separated by whitespace, is considered for fuzzy matching; each part must match in a fuzzy way. + /// FuzzyParts, } -internal readonly ref struct FuzzyMatcher +/// +/// Matches a string in a fuzzy way. +/// +internal static class FuzzyMatcher { - private static readonly (int, int)[] EmptySegArray = Array.Empty<(int, int)>(); - - private readonly string needleString = string.Empty; - private readonly ReadOnlySpan needleSpan = ReadOnlySpan.Empty; - private readonly int needleFinalPosition = -1; - private readonly (int Start, int End)[] needleSegments = EmptySegArray; - private readonly MatchMode mode = MatchMode.Simple; - - public FuzzyMatcher(string term, MatchMode matchMode) + /// + /// Specify fuzzy match mode. + /// + internal enum Mode { - this.needleString = term; - this.needleSpan = this.needleString.AsSpan(); - this.needleFinalPosition = this.needleSpan.Length - 1; - this.mode = matchMode; + /// + /// The string is considered for fuzzy matching as a whole. + /// + Fuzzy, - switch (matchMode) + /// + /// Each part of the string, separated by whitespace, is considered for fuzzy matching; each part must match in a fuzzy way. + /// + FuzzyParts, + } + + /// + /// Determines if can be found in in a fuzzy way. + /// + /// The string to search from. + /// The substring to search for. + /// Fuzzy match mode. + /// Culture info for case insensitive matching. + /// The score. 0 means that the string did not match. The scores are meaningful only across matches using the same value. + /// true if matches. + public static bool FuzzyMatches( + this ReadOnlySpan haystack, + ReadOnlySpan needle, + Mode mode, + CultureInfo cultureInfo, + out int score) + { + score = 0; + switch (mode) { - case MatchMode.FuzzyParts: - this.needleSegments = FindNeedleSegments(this.needleSpan); + case var _ when needle.Length == 0: + score = 0; break; - case MatchMode.Fuzzy: - case MatchMode.Simple: - this.needleSegments = EmptySegArray; + + case Mode.Fuzzy: + score = GetRawScore(haystack, needle, cultureInfo); break; + + case Mode.FuzzyParts: + foreach (var needleSegment in new WordEnumerator(needle)) + { + var cur = GetRawScore(haystack, needleSegment, cultureInfo); + if (cur == 0) + { + score = 0; + break; + } + + score += cur; + } + + break; + default: - throw new ArgumentOutOfRangeException(nameof(matchMode), matchMode, null); + throw new ArgumentOutOfRangeException(nameof(mode), mode, null); } + + return score > 0; } - private static (int Start, int End)[] FindNeedleSegments(ReadOnlySpan span) + /// + public static bool FuzzyMatches( + this string? haystack, + ReadOnlySpan needle, + Mode mode, + CultureInfo cultureInfo, + out int score) => haystack.AsSpan().FuzzyMatches(needle, mode, cultureInfo, out score); + + /// + /// Determines if can be found in using the mode + /// . + /// + /// The string to search from. + /// The substring to search for. + /// true if matches. + public static bool FuzzyMatchesParts(this string? haystack, ReadOnlySpan needle) => + haystack.FuzzyMatches(needle, Mode.FuzzyParts, CultureInfo.InvariantCulture, out _); + + private static int GetRawScore(ReadOnlySpan haystack, ReadOnlySpan needle, CultureInfo cultureInfo) { - var segments = new List<(int, int)>(); - var wordStart = -1; - - for (var i = 0; i < span.Length; i++) - { - if (span[i] is not ' ' and not '\u3000') - { - if (wordStart < 0) - { - wordStart = i; - } - } - else if (wordStart >= 0) - { - segments.Add((wordStart, i - 1)); - wordStart = -1; - } - } - - if (wordStart >= 0) - { - segments.Add((wordStart, span.Length - 1)); - } - - return segments.ToArray(); - } - -#pragma warning disable SA1202 - public int Matches(string value) -#pragma warning restore SA1202 - { - if (this.needleFinalPosition < 0) - { - return 0; - } - - if (this.mode == MatchMode.Simple) - { - return value.Contains(this.needleString) ? 1 : 0; - } - - var haystack = value.AsSpan(); - - if (this.mode == MatchMode.Fuzzy) - { - return this.GetRawScore(haystack, 0, this.needleFinalPosition); - } - - if (this.mode == MatchMode.FuzzyParts) - { - if (this.needleSegments.Length < 2) - { - return this.GetRawScore(haystack, 0, this.needleFinalPosition); - } - - var total = 0; - for (var i = 0; i < this.needleSegments.Length; i++) - { - var (start, end) = this.needleSegments[i]; - var cur = this.GetRawScore(haystack, start, end); - if (cur == 0) - { - return 0; - } - - total += cur; - } - - return total; - } - - return 8; - } - - public int MatchesAny(params string[] values) - { - var max = 0; - for (var i = 0; i < values.Length; i++) - { - var cur = this.Matches(values[i]); - if (cur > max) - { - max = cur; - } - } - - return max; - } - - private int GetRawScore(ReadOnlySpan haystack, int needleStart, int needleEnd) - { - var (startPos, gaps, consecutive, borderMatches, endPos) = this.FindForward(haystack, needleStart, needleEnd); + var (startPos, gaps, consecutive, borderMatches, endPos) = FindForward(haystack, needle, cultureInfo); if (startPos < 0) { return 0; } - var needleSize = needleEnd - needleStart + 1; - - var score = CalculateRawScore(needleSize, startPos, gaps, consecutive, borderMatches); + var score = CalculateRawScore(needle.Length, startPos, gaps, consecutive, borderMatches); // PluginLog.Debug( - // $"['{needleString.Substring(needleStart, needleEnd - needleStart + 1)}' in '{haystack}'] fwd: needleSize={needleSize} startPos={startPos} gaps={gaps} consecutive={consecutive} borderMatches={borderMatches} score={score}"); + // $"['{needle.Substring(needleStart, needleEnd - needleStart + 1)}' in '{haystack}'] fwd: needleSize={needleSize} startPos={startPos} gaps={gaps} consecutive={consecutive} borderMatches={borderMatches} score={score}"); - (startPos, gaps, consecutive, borderMatches) = this.FindReverse(haystack, endPos, needleStart, needleEnd); - var revScore = CalculateRawScore(needleSize, startPos, gaps, consecutive, borderMatches); + (startPos, gaps, consecutive, borderMatches) = FindReverse(haystack[..(endPos + 1)], needle, cultureInfo); + var revScore = CalculateRawScore(needle.Length, startPos, gaps, consecutive, borderMatches); // PluginLog.Debug( - // $"['{needleString.Substring(needleStart, needleEnd - needleStart + 1)}' in '{haystack}'] rev: needleSize={needleSize} startPos={startPos} gaps={gaps} consecutive={consecutive} borderMatches={borderMatches} score={revScore}"); + // $"['{needle.Substring(needleStart, needleEnd - needleStart + 1)}' in '{haystack}'] rev: needleSize={needleSize} startPos={startPos} gaps={gaps} consecutive={consecutive} borderMatches={borderMatches} score={revScore}"); return int.Max(score, revScore); } [MethodImpl(MethodImplOptions.AggressiveInlining)] -#pragma warning disable SA1204 private static int CalculateRawScore(int needleSize, int startPos, int gaps, int consecutive, int borderMatches) -#pragma warning restore SA1204 { var score = 100 + needleSize * 3 @@ -175,10 +147,12 @@ internal readonly ref struct FuzzyMatcher return score < 1 ? 1 : score; } - private (int StartPos, int Gaps, int Consecutive, int BorderMatches, int HaystackIndex) FindForward( - ReadOnlySpan haystack, int needleStart, int needleEnd) + private static (int StartPos, int Gaps, int Consecutive, int BorderMatches, int HaystackIndex) FindForward( + ReadOnlySpan haystack, + ReadOnlySpan needle, + CultureInfo cultureInfo) { - var needleIndex = needleStart; + var needleIndex = 0; var lastMatchIndex = -10; var startPos = 0; @@ -188,7 +162,7 @@ internal readonly ref struct FuzzyMatcher for (var haystackIndex = 0; haystackIndex < haystack.Length; haystackIndex++) { - if (haystack[haystackIndex] == this.needleSpan[needleIndex]) + if (char.ToLower(haystack[haystackIndex], cultureInfo) == char.ToLower(needle[needleIndex], cultureInfo)) { #if BORDER_MATCHING if (haystackIndex > 0) @@ -207,7 +181,7 @@ internal readonly ref struct FuzzyMatcher consecutive++; } - if (needleIndex > needleEnd) + if (needleIndex >= needle.Length) { return (startPos, gaps, consecutive, borderMatches, haystackIndex); } @@ -216,7 +190,7 @@ internal readonly ref struct FuzzyMatcher } else { - if (needleIndex > needleStart) + if (needleIndex > 0) { gaps++; } @@ -230,19 +204,21 @@ internal readonly ref struct FuzzyMatcher return (-1, 0, 0, 0, 0); } - private (int StartPos, int Gaps, int Consecutive, int BorderMatches) FindReverse( - ReadOnlySpan haystack, int haystackLastMatchIndex, int needleStart, int needleEnd) + private static (int StartPos, int Gaps, int Consecutive, int BorderMatches) FindReverse( + ReadOnlySpan haystack, + ReadOnlySpan needle, + CultureInfo cultureInfo) { - var needleIndex = needleEnd; + var needleIndex = needle.Length - 1; var revLastMatchIndex = haystack.Length + 10; var gaps = 0; var consecutive = 0; var borderMatches = 0; - for (var haystackIndex = haystackLastMatchIndex; haystackIndex >= 0; haystackIndex--) + for (var haystackIndex = haystack.Length - 1; haystackIndex >= 0; haystackIndex--) { - if (haystack[haystackIndex] == this.needleSpan[needleIndex]) + if (char.ToLower(haystack[haystackIndex], cultureInfo) == char.ToLower(needle[needleIndex], cultureInfo)) { #if BORDER_MATCHING if (haystackIndex > 0) @@ -261,7 +237,7 @@ internal readonly ref struct FuzzyMatcher consecutive++; } - if (needleIndex < needleStart) + if (needleIndex < 0) { return (haystackIndex, gaps, consecutive, borderMatches); } @@ -276,7 +252,44 @@ internal readonly ref struct FuzzyMatcher return (-1, 0, 0, 0); } -} -#pragma warning restore SA1600 -#pragma warning restore SA1602 + private ref struct WordEnumerator + { + private readonly ReadOnlySpan fullNeedle; + private int start = -1; + private int end = 0; + + public WordEnumerator(ReadOnlySpan fullNeedle) + { + this.fullNeedle = fullNeedle; + } + + public ReadOnlySpan Current + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => this.fullNeedle[this.start..this.end]; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool MoveNext() + { + if (this.start >= this.fullNeedle.Length - 1) + return false; + + this.start = this.end; + + // Skip the spaces + while (this.start < this.fullNeedle.Length && char.IsWhiteSpace(this.fullNeedle[this.start])) + this.start++; + + this.end = this.start; + while (this.end < this.fullNeedle.Length && !char.IsWhiteSpace(this.fullNeedle[this.end])) + this.end++; + + return this.start != this.end; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public WordEnumerator GetEnumerator() => this; + } +}