From bb7a2201f8481c0d008a3860260dcf7eac3df705 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 20:29:42 +0000 Subject: [PATCH 1/2] Keep case-sensitive globs off DotNet.Glob's global default [patch] ResolveGlob passed its own options only on the case-insensitive path. Glob.Parse(pattern) without options reads DotNet.Glob's static, mutable GlobOptions.Default, so a host that set GlobOptions.Default.Evaluation.CaseInsensitive = true quietly turned the default CaseSensitive filters, and explicit CaseSensitive ones, case-insensitive: `*.jpg` matched `IMG.JPG` and `-*.jpg` hid it. Which patterns were affected depended on whether they were cached before the flip. Parse the case-sensitive branch with a private CaseSensitiveGlobOptions, so TextFilter never reads GlobOptions.Default. Fixes ktsu-dev/TextFilter#188 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3SsCYipEZieVhB1tG --- TextFilter.Test/GlobDefaultOptionsTests.cs | 42 ++++++++++++++++++++++ TextFilter/TextFilter.cs | 6 +++- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 TextFilter.Test/GlobDefaultOptionsTests.cs diff --git a/TextFilter.Test/GlobDefaultOptionsTests.cs b/TextFilter.Test/GlobDefaultOptionsTests.cs new file mode 100644 index 0000000..136ee36 --- /dev/null +++ b/TextFilter.Test/GlobDefaultOptionsTests.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace TextFilter.Test; + +using DotNet.Globbing; +using ktsu.TextFilter; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// TextFilter's case-sensitive globs stay case-sensitive when the host flips DotNet.Glob's +/// process-wide (ktsu-dev/TextFilter#188). +/// +[TestClass] +public class GlobDefaultOptionsTests +{ + // Patterns no other test uses, so none of them is already in TextFilter's glob cache when the + // default is flipped and each is parsed for the first time under it. + [TestMethod] + [DoNotParallelize] + public void CaseSensitiveGlobIgnoresCaseInsensitiveGlobalDefault() + { + bool original = GlobOptions.Default.Evaluation.CaseInsensitive; + GlobOptions.Default.Evaluation.CaseInsensitive = true; + try + { + Assert.IsFalse(TextFilter.IsMatch("IMG.JPG188", "*.jpg188"), + "the default sensitivity should still be case-sensitive"); + Assert.IsFalse(TextFilter.IsMatch("IMG.PNG188", "*.png188", TextFilterType.Glob, + TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity.CaseSensitive), + "an explicit CaseSensitive should still be case-sensitive"); + Assert.IsTrue(TextFilter.IsMatch("IMG.GIF188", "-*.gif188"), + "an exclusion should not hide an item that differs only in case"); + Assert.IsTrue(TextFilter.IsMatch("IMG.BMP188", "*.bmp188", TextFilterType.Glob, + TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity.CaseInsensitive), + "CaseInsensitive should still ignore case"); + } + finally + { + GlobOptions.Default.Evaluation.CaseInsensitive = original; + } + } +} diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 16701d8..058e4bc 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -117,6 +117,10 @@ private static void AddBounded(ConcurrentDictionary cach private static string CacheKey(string pattern, TextFilterCaseSensitivity caseSensitivity) => caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive ? "i:" + pattern : "s:" + pattern; + // Both sensitivities pass their own options. Glob.Parse without options reads DotNet.Glob's static, + // mutable GlobOptions.Default, so a host that sets GlobOptions.Default.Evaluation.CaseInsensitive + // would otherwise turn TextFilter's case-sensitive globs case-insensitive. + private static readonly GlobOptions CaseSensitiveGlobOptions = new() { Evaluation = { CaseInsensitive = false } }; private static readonly GlobOptions CaseInsensitiveGlobOptions = new() { Evaluation = { CaseInsensitive = true } }; // Filter patterns are caller-supplied text, so a pattern with catastrophic backtracking would @@ -672,7 +676,7 @@ private static bool SpansPathSeparator(char first, char last) => .Where(pattern => pattern.Length != 0) .Select(pattern => caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive ? Glob.Parse(pattern, CaseInsensitiveGlobOptions) - : Glob.Parse(pattern))]; + : Glob.Parse(pattern, CaseSensitiveGlobOptions))]; globs = parsed is { Length: > 0 } ? parsed : null; } catch (Exception ex) when (ex is not OutOfMemoryException) From 73ce9a054ba7c0e289e5825fa8aa665b62e92f8e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 20:32:36 +0000 Subject: [PATCH 2/2] Hold only the visible overloads to the README's documented defaults (from #186) ReadmeTests fails on main because the EditorBrowsable(Never) binary-compatibility overloads carry no defaults by design. Ported from open PR #186 so this PR's CI shows its own result. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3SsCYipEZieVhB1tG --- TextFilter.Test/ReadmeTests.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/TextFilter.Test/ReadmeTests.cs b/TextFilter.Test/ReadmeTests.cs index 7ced909..17fc9c2 100644 --- a/TextFilter.Test/ReadmeTests.cs +++ b/TextFilter.Test/ReadmeTests.cs @@ -4,6 +4,7 @@ namespace TextFilter.Test; using System; using System.Collections.Generic; +using System.ComponentModel; using System.IO; using System.Linq; using System.Reflection; @@ -89,7 +90,10 @@ public void EveryMethodInTheReadmeApiReferenceExistsWithItsDocumentedDefaults() foreach (Match row in rows) { string methodName = row.Groups["method"].Value; - MethodInfo[] overloads = [.. methods.Where(m => m.Name == methodName)]; + // The binary-compatibility overloads are hidden from IntelliSense and deliberately carry no + // defaults, since defaults on both sets would make calls ambiguous. The README documents the + // visible overloads, so only those are held to its defaults. + MethodInfo[] overloads = [.. methods.Where(m => m.Name == methodName && !IsHiddenFromCallers(m))]; if (overloads.Length == 0) { problems.Add($"{methodName} does not exist"); @@ -115,6 +119,9 @@ public void EveryMethodInTheReadmeApiReferenceExistsWithItsDocumentedDefaults() Assert.IsEmpty(problems, string.Join("; ", problems)); } + private static bool IsHiddenFromCallers(MethodInfo method) => + method.GetCustomAttribute()?.State == EditorBrowsableState.Never; + [TestMethod] public void ReadmeMatchOptionsSampleGivesTheDocumentedResults() {