Skip to content

Commit 737f463

Browse files
Let a backslash make a leading +, -, ! or ^ literal in a glob token [patch] (#200)
* Let a backslash make a leading +, -, ! or ^ literal in a glob token [patch] A glob token's first character was always read as a prefix, so an item named +page.svelte or !important.txt could not be found by typing its name. A backslash before a leading prefix character now makes it literal, also after a + or - prefix. GetHint and the README list every prefix the parser recognises and the escape. Fixes #198 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017vfJW3WKJyQMZaWCcyzKVK * Hold only the visible overloads to the README's documented defaults [patch] The README API test required every overload's same-named parameter to carry the documented default. The binary-compatibility overloads from #166 have no defaults by design, so main has been red since #178. They are marked [EditorBrowsable(Never)] and are not what the README documents, so the default check now skips them. A wrong default on a visible overload still fails. Fixes #183 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017vfJW6FzBtSrY5LKUuDYce (cherry picked from commit 9328e00) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4fdb0c3 commit 737f463

3 files changed

Lines changed: 92 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,31 @@ case pair, and a culture-sensitive fold would stop a filter of `img` matching `I
140140
machine whose locale happens to be `tr-TR`. A filter pattern is machine text rather than prose, so
141141
it folds the same way everywhere.
142142

143+
### Glob Tokens
144+
145+
A glob filter is split on whitespace into tokens, and the first character of each token says what
146+
kind it is:
147+
148+
| Token | Kind | The text must |
149+
|-------|------|---------------|
150+
| `word*` | optional | contain a word matching at least one optional token (all of them under `ByWordAll`) |
151+
| `+word*` | required | contain a word matching every required token |
152+
| `-word*`, `!word*` or `^word*` | excluded | contain no word matching any excluded token |
153+
154+
To match a word that itself starts with `+`, `-`, `!` or `^`, put a backslash before that character.
155+
`\+page.svelte` is the optional token `+page.svelte`, and `+\+page*` is the required token `+page*`.
156+
A one-character class works too: `[+]page.svelte`.
157+
158+
```csharp
159+
using ktsu.TextFilter;
160+
161+
string[] files = ["+page.svelte", "+layout.svelte", "!important.txt", "readme.md"];
162+
163+
IEnumerable<string> page = TextFilter.Filter(files, @"\+page.svelte"); // +page.svelte
164+
IEnumerable<string> important = TextFilter.Filter(files, @"\!important.txt"); // !important.txt
165+
IEnumerable<string> notPages = TextFilter.Filter(files, "-+*"); // !important.txt, readme.md
166+
```
167+
143168
### Filter Types
144169

145170
TextFilter supports different filter types:

‎TextFilter.Test/TextFilterTests.cs‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,7 @@ public void FilterWithKeySelectorNullFilterThrowsArgumentNullException()
420420
public void GetHintReturnsCorrectHintForGlob()
421421
{
422422
string result = TextFilter.GetHint(TextFilterType.Glob);
423-
Assert.AreEqual("glob pattern, 'optional1* opti?nal2 +required -excluded' etc, text must contain one of the optional tokens, all of the required tokens, and none of the excluded tokens", result);
423+
Assert.AreEqual("glob pattern, 'optional1* opti?nal2 +required -excluded' etc, text must contain one of the optional tokens, all of the required tokens, and none of the excluded tokens; a token starting with + is required, and one starting with -, ! or ^ is excluded; put \\ before a leading +, -, ! or ^ to match it literally, as in \\+page.svelte", result);
424424
}
425425

426426
[TestMethod]
@@ -1720,4 +1720,56 @@ public void SourceCallsWithAllPre17ArgumentsStillCompileAndMatchCaseSensitively(
17201720
Assert.HasCount(1, TextFilter.Filter(["Hello", "hello"], "Hello", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString).ToList());
17211721
Assert.HasCount(1, TextFilter.Filter([(1, "Hello"), (2, "hello")], t => t.Item2, "Hello", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString).ToList());
17221722
}
1723+
1724+
private static readonly string[] PrefixedItems = ["+page.svelte", "-draft.md", "!important.txt", "^notes.md", "readme.md"];
1725+
1726+
[TestMethod]
1727+
[DataRow(@"\+page.svelte", "+page.svelte")]
1728+
[DataRow(@"\-draft.md", "-draft.md")]
1729+
[DataRow(@"\!important.txt", "!important.txt")]
1730+
[DataRow(@"\^notes.md", "^notes.md")]
1731+
[DataRow(@"\+page*", "+page.svelte")]
1732+
[DataRow(@"[+]page.svelte", "+page.svelte")]
1733+
public void EscapedLeadingPrefixMatchesItLiterally(string filter, string expected)
1734+
{
1735+
CollectionAssert.AreEqual(new[] { expected }, TextFilter.Filter(PrefixedItems, filter).ToArray());
1736+
Assert.IsTrue(TextFilter.IsMatch(expected, filter));
1737+
}
1738+
1739+
[TestMethod]
1740+
[DataRow(@"+\+page*", "+page.svelte")]
1741+
[DataRow(@"+\!important*", "!important.txt")]
1742+
public void EscapedPrefixAfterRequiredPrefixIsARequiredLiteral(string filter, string expected)
1743+
{
1744+
CollectionAssert.AreEqual(new[] { expected }, TextFilter.Filter(PrefixedItems, filter).ToArray());
1745+
}
1746+
1747+
[TestMethod]
1748+
[DataRow(@"-\-draft*", "-draft.md")]
1749+
[DataRow(@"!\^notes*", "^notes.md")]
1750+
public void EscapedPrefixAfterExcludedPrefixIsAnExcludedLiteral(string filter, string excluded)
1751+
{
1752+
CollectionAssert.AreEqual(PrefixedItems.Where(item => item != excluded).ToArray(), TextFilter.Filter(PrefixedItems, filter).ToArray());
1753+
}
1754+
1755+
[TestMethod]
1756+
public void UnescapedPrefixesStillMeanRequiredAndExcluded()
1757+
{
1758+
string[] onlyReadme = [.. PrefixedItems.Where(item => item == "readme.md")];
1759+
string[] allButPage = [.. PrefixedItems.Where(item => item != "+page.svelte")];
1760+
string[] allButReadme = [.. PrefixedItems.Where(item => item != "readme.md")];
1761+
1762+
CollectionAssert.AreEqual(onlyReadme, TextFilter.Filter(PrefixedItems, "+readme*").ToArray());
1763+
CollectionAssert.AreEqual(allButPage, TextFilter.Filter(PrefixedItems, "-+*").ToArray());
1764+
CollectionAssert.AreEqual(allButReadme, TextFilter.Filter(PrefixedItems, "!readme*").ToArray());
1765+
CollectionAssert.AreEqual(allButReadme, TextFilter.Filter(PrefixedItems, "^readme*").ToArray());
1766+
}
1767+
1768+
[TestMethod]
1769+
public void BackslashBeforeAnOrdinaryCharacterIsStillAPathSeparator()
1770+
{
1771+
Assert.IsTrue(TextFilter.IsMatch(@"src\main.cs", @"src\main.cs", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString));
1772+
Assert.IsTrue(TextFilter.IsMatch("src/main.cs", @"src\main.cs", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString));
1773+
Assert.IsTrue(TextFilter.IsMatch(@"\server", @"\server", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString));
1774+
}
17231775
}

‎TextFilter/TextFilter.cs‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ public static string GetHint(TextFilterType filterType)
146146
{
147147
return filterType switch
148148
{
149-
TextFilterType.Glob => "glob pattern, 'optional1* opti?nal2 +required -excluded' etc, text must contain one of the optional tokens, all of the required tokens, and none of the excluded tokens",
149+
TextFilterType.Glob => "glob pattern, 'optional1* opti?nal2 +required -excluded' etc, text must contain one of the optional tokens, all of the required tokens, and none of the excluded tokens; a token starting with + is required, and one starting with -, ! or ^ is excluded; put \\ before a leading +, -, ! or ^ to match it literally, as in \\+page.svelte",
150150
TextFilterType.Regex => "regex pattern, text must match the regex pattern",
151151
TextFilterType.Fuzzy => "fuzzy pattern, text is ranked by how well it matches the pattern",
152152
_ => throw new NotImplementedException($"{nameof(TextFilterType)}.{filterType} has not been implemented"),
@@ -313,13 +313,25 @@ internal static Dictionary<TextFilterTokenType, HashSet<string>> ExtractGlobFilt
313313
return new
314314
{
315315
g.Key,
316-
Value = (removePrefix ? g.Select(t => t[1..]) : g).Where(t => !string.IsNullOrEmpty(t)).ToHashSet(),
316+
Value = (removePrefix ? g.Select(t => t[1..]) : g).Select(UnescapeLeadingPrefix).Where(t => !string.IsNullOrEmpty(t)).ToHashSet(),
317317
};
318318
})
319319
.Where(g => g.Value.Count != 0)
320320
.ToDictionary(g => g.Key, g => g.Value);
321321
}
322322

323+
// A token that starts with a prefix character is always read as a prefixed token, so without an
324+
// escape an item named "+page.svelte" or "!important.txt" could not be found by typing its name. A
325+
// backslash before a leading prefix character makes that character literal: "\+page.svelte" is the
326+
// optional token "+page.svelte", and "+\+page" is the required token "+page". A backslash anywhere
327+
// else is still a path separator.
328+
private static string UnescapeLeadingPrefix(string token) =>
329+
token.Length >= 2 && token[0] == GlobPrefixEscape && IsTokenPrefix(token[1]) ? token[1..] : token;
330+
331+
private const char GlobPrefixEscape = '\\';
332+
333+
private static bool IsTokenPrefix(char c) => ExcludedTokenPrefixes.Contains(c) || RequiredTokenPrefixes.Contains(c);
334+
323335
/// <summary>
324336
/// Determines whether the specified text matches the glob filter pattern.
325337
/// </summary>

0 commit comments

Comments
 (0)