diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs index 95c227662d..8104da306d 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs @@ -1093,7 +1093,7 @@ private void DrawChangelogList(bool displayDalamud, bool displayPlugins) return; } - IEnumerable changelogs = null; + IEnumerable? changelogs = null; if (displayDalamud && displayPlugins && this.dalamudChangelogManager.Changelogs != null) { changelogs = this.dalamudChangelogManager.Changelogs; @@ -1107,10 +1107,15 @@ private void DrawChangelogList(bool displayDalamud, bool displayPlugins) 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.Any()) + if (!sortedChangelogs.Any()) { ImGui.TextColored( ImGuiColors.DalamudGrey2, @@ -3185,21 +3190,25 @@ private bool DrawPluginImages(LocalPlugin? plugin, IPluginManifest manifest, boo private bool IsManifestFiltered(IPluginManifest manifest) { - var searchString = this.searchText.ToLowerInvariant(); - var matcher = new FuzzyMatcher(searchString, MatchMode.FuzzyParts); var hasSearchString = !string.IsNullOrWhiteSpace(this.searchText); var oldApi = manifest.DalamudApiLevel < PluginManager.DalamudApiLevel; var installed = this.IsManifestInstalled(manifest).IsInstalled; - if (oldApi && !hasSearchString && !installed) - return true; + // Are we not searching at all? Hide plugins of older API levels that aren't installed. + if (!hasSearchString) + return oldApi && !installed; - return hasSearchString && !( - (!manifest.Name.IsNullOrEmpty() && matcher.Matches(manifest.Name.ToLowerInvariant()) > 0) || - (!manifest.InternalName.IsNullOrEmpty() && matcher.Matches(manifest.InternalName.ToLowerInvariant()) > 0) || - (!manifest.Author.IsNullOrEmpty() && matcher.Matches(manifest.Author.ToLowerInvariant()) > 0) || - (!manifest.Punchline.IsNullOrEmpty() && manifest.Punchline.ToLowerInvariant().Contains(searchString)) || - (manifest.Tags != null && matcher.MatchesAny(manifest.Tags.Select(term => term.ToLowerInvariant()).ToArray()) > 0)); + if (manifest.Name.FuzzyMatchesParts(this.searchText)) + return false; + if (manifest.InternalName.FuzzyMatchesParts(this.searchText)) + return false; + if (manifest.Author.FuzzyMatchesParts(this.searchText)) + return false; + if (manifest.Punchline?.Contains(this.searchText, StringComparison.InvariantCultureIgnoreCase) is true) + return false; + if (manifest.Tags?.Any(x => x.FuzzyMatchesParts(this.searchText)) is true) + return false; + return true; } private (bool IsInstalled, LocalPlugin Plugin) IsManifestInstalled(IPluginManifest? manifest) diff --git a/Dalamud/Utility/FuzzyMatcher.cs b/Dalamud/Utility/FuzzyMatcher.cs index 9ac71d8bbe..f4be0441d8 100644 --- a/Dalamud/Utility/FuzzyMatcher.cs +++ b/Dalamud/Utility/FuzzyMatcher.cs @@ -1,162 +1,122 @@ #define BORDER_MATCHING +using System.Globalization; + namespace Dalamud.Utility; using System; -using System.Collections.Generic; using System.Runtime.CompilerServices; -#pragma warning disable SA1600 -#pragma warning disable SA1602 - -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; - - switch (matchMode) - { - case MatchMode.FuzzyParts: - this.needleSegments = FindNeedleSegments(this.needleSpan); - break; - case MatchMode.Fuzzy: - case MatchMode.Simple: - this.needleSegments = EmptySegArray; - break; - default: - throw new ArgumentOutOfRangeException(nameof(matchMode), matchMode, null); - } - } - - private static (int Start, int End)[] FindNeedleSegments(ReadOnlySpan span) - { - 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(); + /// + /// 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, } -#pragma warning disable SA1202 - public int Matches(string value) -#pragma warning restore SA1202 + /// + /// 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) { - if (this.needleFinalPosition < 0) + score = 0; + switch (mode) { - 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); - } + case var _ when needle.Length == 0: + score = 0; + break; - if (this.mode == MatchMode.FuzzyParts) - { - if (this.needleSegments.Length < 2) - { - return this.GetRawScore(haystack, 0, this.needleFinalPosition); - } + case Mode.Fuzzy: + score = GetRawScore(haystack, needle, cultureInfo); + break; - 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) + case Mode.FuzzyParts: + foreach (var needleSegment in new WordEnumerator(needle)) { - return 0; - } - - total += cur; - } + var cur = GetRawScore(haystack, needleSegment, cultureInfo); + if (cur == 0) + { + score = 0; + break; + } - return total; - } + score += cur; + } - return 8; - } + break; - 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; - } + default: + throw new ArgumentOutOfRangeException(nameof(mode), mode, null); } - return max; + return score > 0; } - private int GetRawScore(ReadOnlySpan haystack, int needleStart, int needleEnd) + /// + 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 (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 @@ -169,10 +129,12 @@ private static int CalculateRawScore(int needleSize, int startPos, int gaps, int 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; @@ -182,7 +144,7 @@ private static int CalculateRawScore(int needleSize, int startPos, int gaps, int 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) @@ -201,7 +163,7 @@ private static int CalculateRawScore(int needleSize, int startPos, int gaps, int consecutive++; } - if (needleIndex > needleEnd) + if (needleIndex >= needle.Length) { return (startPos, gaps, consecutive, borderMatches, haystackIndex); } @@ -210,7 +172,7 @@ private static int CalculateRawScore(int needleSize, int startPos, int gaps, int } else { - if (needleIndex > needleStart) + if (needleIndex > 0) { gaps++; } @@ -224,19 +186,21 @@ private static int CalculateRawScore(int needleSize, int startPos, int gaps, int 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) @@ -255,7 +219,7 @@ private static int CalculateRawScore(int needleSize, int startPos, int gaps, int consecutive++; } - if (needleIndex < needleStart) + if (needleIndex < 0) { return (haystackIndex, gaps, consecutive, borderMatches); } @@ -270,14 +234,44 @@ private static int CalculateRawScore(int needleSize, int startPos, int gaps, int return (-1, 0, 0, 0); } -} -internal enum MatchMode -{ - Simple, - Fuzzy, - FuzzyParts, -} + 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]; + } -#pragma warning restore SA1600 -#pragma warning restore SA1602 + [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; + } +}