镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 40 additions & 7 deletions TextFilter.Test/TextFilterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1173,17 +1173,17 @@
}

[TestMethod]
[DataRow("[.-0]", "/ . 0", "a -")]
[DataRow("[!.-0]", "a -", "/ . 0")]
[DataRow("[A-z]", "\\ A _ z", "0 {")]
[DataRow("[!A-z]", "0 {", "\\ A _ z")]
[DataRow("[*-0]", "/ \\ * 0", "a")]
[DataRow("[.-0]", "/ . 0", "a - \\")]
[DataRow("[!.-0]", "a - \\", "/ . 0")]
[DataRow("[A-z]", "\\ A _ z", "0 { /")]
[DataRow("[!A-z]", "0 { /", "\\ A _ z")]
[DataRow("[*-0]", "/ * 0", "a \\")]
[DataRow("[a-z]", "a q z", "/ \\ A")]
public void GlobClassRangeSpanningAPathSeparatorMatchesIt(string pattern, string matching, string notMatching)
{
// Path separators are masked to a private-use character in the pattern and the text, so a range
// left for DotNet.Glob to match natively never contained a masked "/" or "\" (#157). The two
// separators share one mask (#132), so these rows don't try to tell "/" and "\" apart.
// left for DotNet.Glob to match natively never contained a masked "/" or "\" (#157). Each
// separator has its own mask (#132), so a range holding one of them doesn't match the other.
ArgumentNullException.ThrowIfNull(matching);
ArgumentNullException.ThrowIfNull(notMatching);
foreach (string text in matching.Split(' '))
Expand Down Expand Up @@ -1407,6 +1407,39 @@
Assert.IsTrue(TextFilter.IsMatch("Docs/README.md", "**/readme.md", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive));
}

[TestMethod]
[DataRow(TextFilterCaseSensitivity.CaseSensitive)]
[DataRow(TextFilterCaseSensitivity.CaseInsensitive)]
public void GlobTellsSlashAndBackslashApart(TextFilterCaseSensitivity caseSensitivity)
{
// Both separators used to be masked to the same character, so either one matched the other (#132).
CollectionAssert.AreEqual(
new List<string> { "1/2" },
TextFilter.Filter(["C:\\temp\\x", "1/2", "1\\2"], "1/2", TextFilterType.Glob, caseSensitivity: caseSensitivity).ToList());

Check warning on line 1418 in TextFilter.Test/TextFilterTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_TextFilter&issues=AaGTChs8Q4iSPc4VPba5&open=AaGTChs8Q4iSPc4VPba5&pullRequest=175
CollectionAssert.AreEqual(
new List<string> { "1\\2" },
TextFilter.Filter(["1/2", "1\\2"], "1\\2", TextFilterType.Glob, caseSensitivity: caseSensitivity).ToList());

Check warning on line 1421 in TextFilter.Test/TextFilterTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_TextFilter&issues=AaGTChs8Q4iSPc4VPba6&open=AaGTChs8Q4iSPc4VPba6&pullRequest=175
Assert.IsFalse(TextFilter.IsMatch("a\\b", "a/b", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, caseSensitivity));
Assert.IsFalse(TextFilter.IsMatch("a/b", "a\\b", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, caseSensitivity));

// Excluding URLs keeps Windows paths.
CollectionAssert.AreEqual(
new List<string> { "C:\\temp", "plain" },
TextFilter.Filter(["C:\\temp", "http://x", "plain"], "-*/*", TextFilterType.Glob, caseSensitivity: caseSensitivity).ToList());

Check warning on line 1428 in TextFilter.Test/TextFilterTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_TextFilter&issues=AaGTChs8Q4iSPc4VPba7&open=AaGTChs8Q4iSPc4VPba7&pullRequest=175

// Wildcards still cross either separator (#114).
Assert.IsTrue(TextFilter.IsMatch("a/b\\c", "a*c", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, caseSensitivity));
Assert.IsTrue(TextFilter.IsMatch("a\\b", "a?b", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, caseSensitivity));
}

[TestMethod]
[DataRow(@"src\x\a.cs", @"src/**/*.cs")]
[DataRow(@"src/x/a.cs", @"src\**\*.cs")]
public void GlobStarSegmentEndsAtTheSeparatorThePatternWrote(string text, string filter)
{
Assert.IsFalse(TextFilter.IsMatch(text, filter, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString));
}

[TestMethod]
[DataRow("**/")]
[DataRow("**/**/")]
Expand Down
46 changes: 35 additions & 11 deletions TextFilter/TextFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@

return ExcludedTokenPrefixes.Contains(prefix)
? TextFilterTokenType.Excluded
: RequiredTokenPrefixes.Contains(prefix)

Check warning on line 306 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 306 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.
? TextFilterTokenType.Required
: TextFilterTokenType.Optional;
})
Expand Down Expand Up @@ -334,7 +334,7 @@
/// matches nor rejects anything; as a required token it matches everything; and as an excluded
/// token it excludes nothing.
/// </remarks>
public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOptions textFilterMatchOptions, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive)

Check warning on line 337 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

This method signature overlaps the one defined on line 35 in file 'TextFilter.BinaryCompatibility.cs', the default parameter value can't be used.

Check warning on line 337 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

This method signature overlaps the one defined on line 35 in file 'TextFilter.BinaryCompatibility.cs', the default parameter value can't be used.

Check warning on line 337 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

This method signature overlaps the one defined on line 35 in file 'TextFilter.BinaryCompatibility.cs', the default parameter value can't be used.
{
Ensure.NotNull(text);
Ensure.NotNull(filter);
Expand Down Expand Up @@ -408,7 +408,7 @@
/// <param name="textTokens">The set of text tokens to match against.</param>
/// <param name="caseSensitivity">Whether the match distinguishes uppercase from lowercase.</param>
/// <returns><c>true</c> if any token matches the glob filter token; otherwise, <c>false</c>.</returns>
public static bool AnyTokenMatchesGlobFilter(string filterToken, HashSet<string> textTokens, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive)

Check warning on line 411 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

This method signature overlaps the one defined on line 45 in file 'TextFilter.BinaryCompatibility.cs', the default parameter value can't be used.

Check warning on line 411 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

This method signature overlaps the one defined on line 45 in file 'TextFilter.BinaryCompatibility.cs', the default parameter value can't be used.

Check warning on line 411 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

This method signature overlaps the one defined on line 45 in file 'TextFilter.BinaryCompatibility.cs', the default parameter value can't be used.
{
Ensure.NotNull(filterToken);
Ensure.NotNull(textTokens);
Expand All @@ -425,7 +425,7 @@
/// <param name="textTokens">The set of text tokens to match against.</param>
/// <param name="caseSensitivity">Whether the match distinguishes uppercase from lowercase.</param>
/// <returns><c>true</c> if all tokens match the glob filter token; otherwise, <c>false</c>.</returns>
public static bool AllTokensMatchGlobFilter(string filterToken, HashSet<string> textTokens, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive)

Check warning on line 428 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

This method signature overlaps the one defined on line 50 in file 'TextFilter.BinaryCompatibility.cs', the default parameter value can't be used.
{
Ensure.NotNull(filterToken);
Ensure.NotNull(textTokens);
Expand All @@ -436,12 +436,15 @@
}

// 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';
// text, where a slash is an ordinary character, so both separators are swapped for private-use
// characters in the pattern and in the text before DotNet.Glob sees either. Each separator gets
// its own character: sharing one would make them interchangeable, so "1/2" would also match
// "1\2", and excluding URLs with "-*/*" would hide every Windows path too.
private const char MaskedSlash = '\uE000';
private const char MaskedBackslash = '\uE001';

private static string MaskPathSeparators(string value) =>
value.Replace('/', MaskedPathSeparator).Replace('\\', MaskedPathSeparator);
value.Replace('/', MaskedSlash).Replace('\\', MaskedBackslash);

private static bool IsGlobMatch(Glob[] globs, string textToken)
{
Expand All @@ -452,9 +455,29 @@
// Masking also hides the separators DotNet.Glob needs to recognise "**/" as "zero or more path
// segments", leaving "**" followed by a literal character. Since * already crosses the masked
// separator, each "**/" is expanded here instead: dropped, for the zero-segment case, or kept as
// "*/", for "anything ending at a separator". A token matches if any expansion does. Only a "**/"
// that starts the token or follows a separator is dropped, since one joined to a literal is a "*".
private const string MaskedGlobStarSegment = "**\uE000";
// "*/", for "anything ending at a separator". A token matches if any expansion does. The kept form
// ends at the same separator the pattern wrote, so "a/**/b" still needs a "/" before the "b". Only
// a "**/" that starts the token or follows a separator is dropped, since one joined to a literal is a "*".
private const string MaskedGlobStar = "**";

private static bool IsMaskedPathSeparator(char value) => value is MaskedSlash or MaskedBackslash;

private static int IndexOfMaskedGlobStarSegment(string maskedToken, int start)
{
int index = start;
while ((index = maskedToken.IndexOf(MaskedGlobStar, index, StringComparison.Ordinal)) >= 0)
{
int separator = index + MaskedGlobStar.Length;
if (separator < maskedToken.Length && IsMaskedPathSeparator(maskedToken[separator]))
{
return index;
}

index++;
}

return -1;
}

// Each "**/" doubles the expansions, so only this many are expanded both ways; any beyond it keep
// only the "*/" form and so need at least one segment.
Expand All @@ -469,7 +492,7 @@
int expanded = 0;
int index;

while ((index = maskedToken.IndexOf(MaskedGlobStarSegment, searchFrom, StringComparison.Ordinal)) >= 0)
while ((index = IndexOfMaskedGlobStarSegment(maskedToken, searchFrom)) >= 0)
{
// Inside a bracket class "**/" is not a globstar but the members "*" and "/". Dropping them
// would shrink the class, which for a negated class lets through characters it excludes, so
Expand All @@ -482,25 +505,26 @@
}

string literal = maskedToken[start..index];
char separator = maskedToken[index + MaskedGlobStar.Length];
bool expandBothWays = expanded < MaxExpandedGlobStarSegments;
List<string> next = new(expansions.Count * 2);

foreach (string prefix in expansions)
{
string before = prefix + literal;
next.Add(before + "*" + MaskedPathSeparator);
next.Add(before + "*" + separator);

// Only a "**" that is a whole segment can match zero segments; joined to a literal, as
// in "foo**/bar", it is just "*", and dropping it would splice "foo" onto "bar".
if (expandBothWays && (before.Length == 0 || before[^1] == MaskedPathSeparator))
if (expandBothWays && (before.Length == 0 || IsMaskedPathSeparator(before[^1])))
{
next.Add(before);
}
}

expansions = next;
expanded++;
start = index + MaskedGlobStarSegment.Length;
start = index + MaskedGlobStar.Length + 1;
searchFrom = start;
}

Expand Down Expand Up @@ -816,7 +840,7 @@
/// <see cref="TextFilterMatchOptions.ByWholeString"/> for those.
/// </para>
/// </remarks>
public static bool DoesMatchRegex(string text, string filter, TextFilterMatchOptions textFilterMatchOptions, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive)

Check warning on line 843 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

This method signature overlaps the one defined on line 40 in file 'TextFilter.BinaryCompatibility.cs', the default parameter value can't be used.
{
Ensure.NotNull(text);
Ensure.NotNull(filter);
Expand Down
Loading