diff --git a/.gitignore b/.gitignore index dc0470a..e043c9f 100644 --- a/.gitignore +++ b/.gitignore @@ -203,6 +203,11 @@ PublishScripts/ **/[Pp]ackages/* # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ +# and except a Unity project's Packages/, which is source: Unity's package manifest and its +# resolved lock file are both meant to be committed, and a NuGet restore folder never contains +# a file by either name. +!**/[Pp]ackages/manifest.json +!**/[Pp]ackages/packages-lock.json # Uncomment if necessary however generally it will be regenerated when needed #!**/[Pp]ackages/repositories.config # NuGet v3's project.json files produces more ignorable files @@ -651,3 +656,16 @@ Temporary Items # ImGui.ini files imgui.ini + +# Game engine projects +# +# Godot: the import cache, and the mono/temp bin+obj a C# build writes. +.godot/ + +# Unity: .meta files are source, not the Visual Studio C++ build artifact that the `*.meta` rule +# further up targets. Unity generates one per asset and it carries the GUID that scenes, prefabs +# and serialized references point at, so ignoring them gives every clone fresh GUIDs and silently +# breaks those references - including for a plug-in whose .dll is itself a build output. This +# negation has to come after that rule to win, and is scoped to the asset tree so the Visual +# Studio artifact stays ignored everywhere else. +!**/[Aa]ssets/**/*.meta diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index 66011f5..e06fc20 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -858,6 +858,57 @@ public void RegexByWordAllFilterDropsBlankEntries() TextFilter.Filter(["", "hello"], "hel", TextFilterType.Regex, TextFilterMatchOptions.ByWordAll).ToList()); } + [TestMethod] + public void GlobStarMatchesAcrossSlash() + { + Assert.IsTrue(TextFilter.IsMatch("AC/DC", "AC*", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny)); + } + + [TestMethod] + public void GlobQuestionMarkMatchesSlash() + { + Assert.IsTrue(TextFilter.IsMatch("1/2", "1?2", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny)); + } + + [TestMethod] + public void GlobStarMatchesPath() + { + Assert.IsTrue(TextFilter.IsMatch("src/Foo.cs", "*.cs", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString)); + } + + [TestMethod] + public void GlobStarMatchesBackslashPath() + { + Assert.IsTrue(TextFilter.IsMatch(@"C:\temp\a.txt", "*.txt", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString)); + } + + [TestMethod] + public void GlobFilterKeepsPathItems() + { + List result = [.. TextFilter.Filter(["docs/readme.md", "readme.md"], "*readme*")]; + + Assert.AreEqual(2, result.Count); + } + + [TestMethod] + public void GlobStarMatchesAcrossSlashCaseInsensitively() + { + Assert.IsTrue(TextFilter.IsMatch("docs/README.md", "*readme*", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity.CaseInsensitive)); + } + + [TestMethod] + public void GlobExcludedTokenMatchesAcrossSlash() + { + Assert.IsFalse(TextFilter.IsMatch("docs/readme.md", "-*readme*", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny)); + } + + [TestMethod] + public void GlobLiteralSlashInPatternStillMatchesSlashInText() + { + Assert.IsTrue(TextFilter.IsMatch("docs/readme.md", "docs/*", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString)); + Assert.IsFalse(TextFilter.IsMatch("docsXreadme.md", "docs/*", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString)); + } + [TestMethod] [DataRow("foo \t")] [DataRow("foo \r\n")] diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 65c8a36..0d7f7ff 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -336,7 +336,7 @@ public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOpti // An unparseable excluded token is skipped rather than treated as match-anything, which here // would exclude every item while the user is still typing the token. - bool anyExcludedMatches = excludedTokens.Any(filterToken => ResolveGlob(filterToken, caseSensitivity) is Glob glob && textTokens.Any(glob.IsMatch)); + bool anyExcludedMatches = excludedTokens.Any(filterToken => ResolveGlob(filterToken, caseSensitivity) is Glob glob && textTokens.Any(token => IsGlobMatch(glob, token))); if (anyExcludedMatches) { @@ -382,7 +382,7 @@ public static bool AnyTokenMatchesGlobFilter(string filterToken, HashSet Glob? glob = ResolveGlob(filterToken, caseSensitivity); - return glob is null || textTokens.Any(glob.IsMatch); + return glob is null || textTokens.Any(token => IsGlobMatch(glob, token)); } /// @@ -399,9 +399,19 @@ public static bool AllTokensMatchGlobFilter(string filterToken, HashSet Glob? glob = ResolveGlob(filterToken, caseSensitivity); - return glob is null || textTokens.All(glob.IsMatch); + return glob is null || textTokens.All(token => IsGlobMatch(glob, token)); } + // DotNet.Glob is a file-path glob, so its * and ? stop at / and \. TextFilter filters arbitrary + // text, where a slash is an ordinary character, so both separators are swapped for a private-use + // character in the pattern and in the text before DotNet.Glob sees either. + private const char MaskedPathSeparator = '\uE000'; + + private static string MaskPathSeparators(string value) => + value.Replace('/', MaskedPathSeparator).Replace('\\', MaskedPathSeparator); + + private static bool IsGlobMatch(Glob glob, string textToken) => glob.IsMatch(MaskPathSeparators(textToken)); + // Returns null for a token that cannot be parsed, so each caller can decide what ignoring it means. private static Glob? ResolveGlob(string filterToken, TextFilterCaseSensitivity caseSensitivity) { @@ -411,9 +421,10 @@ public static bool AllTokensMatchGlobFilter(string filterToken, HashSet { try { + string maskedToken = MaskPathSeparators(filterToken); glob = caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive - ? Glob.Parse(filterToken, CaseInsensitiveGlobOptions) - : Glob.Parse(filterToken); + ? Glob.Parse(maskedToken, CaseInsensitiveGlobOptions) + : Glob.Parse(maskedToken); } catch (Exception ex) when (ex is not OutOfMemoryException) {