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
46 changes: 27 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ using ktsu.TextFilter;
string text = "Hello, World!";
string pattern = "Hello*";

bool isMatch = TextFilter.Match(text, pattern);
bool isMatch = TextFilter.IsMatch(text, pattern);
```

### Filtering Collections
Expand Down Expand Up @@ -81,22 +81,26 @@ IEnumerable<string> ranked = TextFilter.Rank(texts, pattern);

### Match Options

You can customize how matching is performed using the `MatchOptions` enum:
You can customize how matching is performed using the `TextFilterMatchOptions` enum. The default is
`ByWordAny`:

```csharp
using ktsu.TextFilter;

string text = "Hello beautiful world";
string pattern = "hello world";

// Match by any word in the pattern
bool anyWordMatch = TextFilter.Match(text, pattern, MatchOptions.AnyWord);
// Match by any word in the pattern (the default)
bool anyWordMatch = TextFilter.IsMatch(text, pattern,
TextFilterType.Glob, TextFilterMatchOptions.ByWordAny); // true

// Match by all words in the pattern
bool allWordsMatch = TextFilter.Match(text, pattern, MatchOptions.AllWords);
bool allWordsMatch = TextFilter.IsMatch(text, pattern,
TextFilterType.Glob, TextFilterMatchOptions.ByWordAll); // false

// Match the entire string
bool wholeStringMatch = TextFilter.Match(text, pattern, MatchOptions.WholeString);
bool wholeStringMatch = TextFilter.IsMatch(text, pattern,
TextFilterType.Glob, TextFilterMatchOptions.ByWholeString); // false
```

### Case Sensitivity
Expand Down Expand Up @@ -141,13 +145,13 @@ string globPattern = "Hello*";
string regexPattern = "^Hello";

// Glob pattern matching
bool globMatch = TextFilter.Match(text, globPattern, filterType: FilterType.Glob);
bool globMatch = TextFilter.IsMatch(text, globPattern, filterType: TextFilterType.Glob);

// Regular expression matching
bool regexMatch = TextFilter.Match(text, regexPattern, filterType: FilterType.Regex);
bool regexMatch = TextFilter.IsMatch(text, regexPattern, filterType: TextFilterType.Regex);

// Fuzzy matching
bool fuzzyMatch = TextFilter.Match(text, "Helo", filterType: FilterType.Fuzzy);
bool fuzzyMatch = TextFilter.IsMatch(text, "Helo", filterType: TextFilterType.Fuzzy);
```

## API Reference
Expand All @@ -160,25 +164,29 @@ The primary class for text filtering operations.

| Name | Return Type | Description |
|------|-------------|-------------|
| `Match(string text, string pattern, MatchOptions options = MatchOptions.WholeString, FilterType filterType = FilterType.Glob)` | `bool` | Tests if the input text matches the specified pattern |
| `Filter(IEnumerable<string> texts, string pattern, MatchOptions options = MatchOptions.WholeString, FilterType filterType = FilterType.Glob)` | `IEnumerable<string>` | Returns all texts that match the pattern |
| `Rank(IEnumerable<string> texts, string pattern, MatchOptions options = MatchOptions.WholeString)` | `IEnumerable<string>` | Returns texts ranked by how well they match the pattern |
| `IsMatch(string text, string filter, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive)` | `bool` | Tests if the input text matches the specified filter |
| `IsMatch(string text, string filter, out int score, ...)` | `bool` | As above, and also returns the fuzzy score (`int.MinValue` for glob and regex) |
| `Filter(IEnumerable<string> strings, string filter, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive)` | `IEnumerable<string>` | Returns all strings that match the filter |
| `Filter<TItem>(IEnumerable<TItem> items, Func<TItem, string> keySelector, string filter, ...)` | `IEnumerable<TItem>` | Returns all items whose key matches the filter |
| `Rank(IEnumerable<string> strings, string fuzzyFilter)` | `IEnumerable<string>` | Returns the strings ranked by how well they fuzzy-match the filter |
| `Rank<TItem>(IEnumerable<TItem> items, Func<TItem, string> keySelector, string fuzzyFilter)` | `IEnumerable<TItem>` | Returns the items ranked by how well their key fuzzy-matches the filter |
| `GetHint(TextFilterType filterType)` | `string` | Returns a short description of the syntax a filter type accepts |

### Enums

#### `MatchOptions`
#### `TextFilterMatchOptions`

| Value | Description |
|-------|-------------|
| `WholeString` | Match the entire string against the pattern |
| `AllWords` | Match all words in the pattern against the string |
| `AnyWord` | Match any word in the pattern against the string |
| `ByWholeString` | Match the entire string against the pattern |
| `ByWordAll` | Match all words in the pattern against the string |
| `ByWordAny` | Match any word in the pattern against the string (the default) |

#### `FilterType`
#### `TextFilterType`

| Value | Description |
|-------|-------------|
| `Glob` | Use glob pattern matching |
| `Glob` | Use glob pattern matching (the default) |
| `Regex` | Use regular expression matching |
| `Fuzzy` | Use fuzzy matching |

Expand All @@ -200,4 +208,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE.md) f
## Acknowledgements

- [DotNet.Glob](https://github.com/dazinator/DotNet.Glob) for glob pattern matching.
- [ktsu.FuzzySearch](https://github.com/ktsu-io/ktsu.FuzzySearch) for fuzzy matching.
- [ktsu.FuzzySearch](https://github.com/ktsu-dev/FuzzySearch) for fuzzy matching.
139 changes: 139 additions & 0 deletions TextFilter.Test/ReadmeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace TextFilter.Test;

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using ktsu.TextFilter;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Keeps README.md, which is also the nuget.org package page, in step with the public API (#138).
/// </summary>
[TestClass]
public partial class ReadmeTests
{
private static readonly Assembly LibraryAssembly = typeof(TextFilterType).Assembly;
private const string LibraryNamespace = "ktsu.TextFilter";

[GeneratedRegex(@"```csharp\r?\n(?<code>.*?)```", RegexOptions.Singleline)]
private static partial Regex CSharpBlockRegex();

[GeneratedRegex(@"""(?:[^""\\]|\\.)*""")]
private static partial Regex StringLiteralRegex();

[GeneratedRegex(@"//[^\n]*")]
private static partial Regex LineCommentRegex();

[GeneratedRegex(@"\b(?<type>[A-Z]\w*)\.(?<member>[A-Za-z_]\w*)")]
private static partial Regex MemberAccessRegex();

[GeneratedRegex(@"^\| `(?<signature>(?<method>\w+)(?:<\w+>)?\((?<parameters>[^`]*)\))` \|", RegexOptions.Multiline)]
private static partial Regex ApiTableRowRegex();

[GeneratedRegex(@"(?<name>\w+) = (?<type>\w+)\.(?<value>\w+)")]
private static partial Regex DocumentedDefaultRegex();

private static string ReadReadme()
{
for (DirectoryInfo? directory = new(AppContext.BaseDirectory); directory is not null; directory = directory.Parent)
{
string candidate = Path.Join(directory.FullName, "README.md");
if (File.Exists(candidate) && File.Exists(Path.Join(directory.FullName, "TextFilter.sln")))
{
return File.ReadAllText(candidate);
}
}

throw new FileNotFoundException("README.md was not found above the test output directory.");
}

private static IEnumerable<string> CSharpCode() =>
CSharpBlockRegex().Matches(ReadReadme())
.Select(match => StringLiteralRegex().Replace(match.Groups["code"].Value, "\"\""))
.Select(code => LineCommentRegex().Replace(code, string.Empty));

[TestMethod]
public void ReadmeHasCSharpSamples() => Assert.IsNotEmpty(CSharpCode().ToList());

[TestMethod]
public void EveryTypeMemberUsedInReadmeSamplesExists()
{
List<string> missing = [];
foreach (Match access in CSharpCode().SelectMany(code => MemberAccessRegex().Matches(code)))
{
string typeName = access.Groups["type"].Value;
string memberName = access.Groups["member"].Value;
Type? type = LibraryAssembly.GetType($"{LibraryNamespace}.{typeName}");
if (type is null || type.GetMember(memberName, BindingFlags.Public | BindingFlags.Static).Length == 0)
{
missing.Add($"{typeName}.{memberName}");
}
}

Assert.IsEmpty(missing, $"README samples use names the library doesn't define: {string.Join(", ", missing.Distinct())}");
}

[TestMethod]
public void EveryMethodInTheReadmeApiReferenceExistsWithItsDocumentedDefaults()
{
List<Match> rows = [.. ApiTableRowRegex().Matches(ReadReadme()).Cast<Match>()];
Assert.IsNotEmpty(rows);

MethodInfo[] methods = typeof(ktsu.TextFilter.TextFilter).GetMethods(BindingFlags.Public | BindingFlags.Static);
List<string> problems = [];
foreach (Match row in rows)
{
string methodName = row.Groups["method"].Value;
MethodInfo[] overloads = [.. methods.Where(m => m.Name == methodName)];
if (overloads.Length == 0)
{
problems.Add($"{methodName} does not exist");
continue;
}

foreach (Match documented in DocumentedDefaultRegex().Matches(row.Groups["parameters"].Value))
{
string parameterName = documented.Groups["name"].Value;
string expected = $"{documented.Groups["type"].Value}.{documented.Groups["value"].Value}";
ParameterInfo[] parameters = [.. overloads.SelectMany(m => m.GetParameters()).Where(p => p.Name == parameterName)];
if (parameters.Length == 0)
{
problems.Add($"{methodName} has no parameter {parameterName}");
}
else if (parameters.Any(p => !p.HasDefaultValue || $"{p.ParameterType.Name}.{p.DefaultValue}" != expected))
{
problems.Add($"{methodName}({parameterName}) is documented as defaulting to {expected}");
}
}
}

Assert.IsEmpty(problems, string.Join("; ", problems));
}

[TestMethod]
public void ReadmeMatchOptionsSampleGivesTheDocumentedResults()
{
const string text = "Hello beautiful world";
const string pattern = "hello world";

Assert.IsTrue(ktsu.TextFilter.TextFilter.IsMatch(text, pattern, TextFilterType.Glob, TextFilterMatchOptions.ByWordAny));
Assert.IsFalse(ktsu.TextFilter.TextFilter.IsMatch(text, pattern, TextFilterType.Glob, TextFilterMatchOptions.ByWordAll));
Assert.IsFalse(ktsu.TextFilter.TextFilter.IsMatch(text, pattern, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString));
}

[TestMethod]
public void ReadmeBasicAndFilterTypeSamplesMatch()
{
const string text = "Hello, World!";

Assert.IsTrue(ktsu.TextFilter.TextFilter.IsMatch(text, "Hello*"));
Assert.IsTrue(ktsu.TextFilter.TextFilter.IsMatch(text, "Hello*", filterType: TextFilterType.Glob));
Assert.IsTrue(ktsu.TextFilter.TextFilter.IsMatch(text, "^Hello", filterType: TextFilterType.Regex));
Assert.IsTrue(ktsu.TextFilter.TextFilter.IsMatch(text, "Helo", filterType: TextFilterType.Fuzzy));
}
}
Loading