From 2220b170edf509166e8a8a65fc3821ef8269a7ce Mon Sep 17 00:00:00 2001 From: Matthew Edmondson Date: Sun, 1 Nov 2026 19:30:02 +0000 Subject: [PATCH 1/2] Use the real API names in the README samples and reference [patch] The README samples called TextFilter.Match, MatchOptions.* and FilterType.*, none of which exist, so every first sample a new user copied failed with CS0117/CS0103. The API reference documented WholeString as the default match option (it is ByWordAny) and a Rank overload that takes match options (there is none), and the FuzzySearch acknowledgement linked the old ktsu-io repo. The samples now use IsMatch, TextFilterType and TextFilterMatchOptions. The reference lists the real IsMatch, Filter, Rank and GetHint overloads with their defaults, and the enum tables mark which value is the default. ReadmeTests checks every Type.Member in the README's C# blocks, and every method and default in the API reference table, against the library by reflection, so the README can't drift from the API again unnoticed. Fixes ktsu-dev/TextFilter#138 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5rmwEcdN9nYifxAwK --- README.md | 46 ++++++----- TextFilter.Test/ReadmeTests.cs | 139 +++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 19 deletions(-) create mode 100644 TextFilter.Test/ReadmeTests.cs diff --git a/README.md b/README.md index 8509291..db5542c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -81,7 +81,8 @@ IEnumerable 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; @@ -89,14 +90,17 @@ 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 @@ -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 @@ -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 texts, string pattern, MatchOptions options = MatchOptions.WholeString, FilterType filterType = FilterType.Glob)` | `IEnumerable` | Returns all texts that match the pattern | -| `Rank(IEnumerable texts, string pattern, MatchOptions options = MatchOptions.WholeString)` | `IEnumerable` | 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 strings, string filter, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive)` | `IEnumerable` | Returns all strings that match the filter | +| `Filter(IEnumerable items, Func keySelector, string filter, ...)` | `IEnumerable` | Returns all items whose key matches the filter | +| `Rank(IEnumerable strings, string fuzzyFilter)` | `IEnumerable` | Returns the strings ranked by how well they fuzzy-match the filter | +| `Rank(IEnumerable items, Func keySelector, string fuzzyFilter)` | `IEnumerable` | 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 | @@ -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. diff --git a/TextFilter.Test/ReadmeTests.cs b/TextFilter.Test/ReadmeTests.cs new file mode 100644 index 0000000..02c3452 --- /dev/null +++ b/TextFilter.Test/ReadmeTests.cs @@ -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; + +/// +/// Keeps README.md, which is also the nuget.org package page, in step with the public API (#138). +/// +[TestClass] +public partial class ReadmeTests +{ + private static readonly Assembly LibraryAssembly = typeof(TextFilterType).Assembly; + private const string LibraryNamespace = "ktsu.TextFilter"; + + [GeneratedRegex(@"```csharp\r?\n(?.*?)```", RegexOptions.Singleline)] + private static partial Regex CSharpBlockRegex(); + + [GeneratedRegex(@"""(?:[^""\\]|\\.)*""")] + private static partial Regex StringLiteralRegex(); + + [GeneratedRegex(@"//[^\n]*")] + private static partial Regex LineCommentRegex(); + + [GeneratedRegex(@"\b(?[A-Z]\w*)\.(?[A-Za-z_]\w*)")] + private static partial Regex MemberAccessRegex(); + + [GeneratedRegex(@"^\| `(?(?\w+)(?:<\w+>)?\((?[^`]*)\))` \|", RegexOptions.Multiline)] + private static partial Regex ApiTableRowRegex(); + + [GeneratedRegex(@"(?\w+) = (?\w+)\.(?\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.Combine(directory.FullName, "README.md"); + if (File.Exists(candidate) && File.Exists(Path.Combine(directory.FullName, "TextFilter.sln"))) + { + return File.ReadAllText(candidate); + } + } + + throw new FileNotFoundException("README.md was not found above the test output directory."); + } + + private static IEnumerable 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 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 rows = [.. ApiTableRowRegex().Matches(ReadReadme()).Cast()]; + Assert.IsNotEmpty(rows); + + MethodInfo[] methods = typeof(ktsu.TextFilter.TextFilter).GetMethods(BindingFlags.Public | BindingFlags.Static); + List 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)); + } +} From 7ce1ae0c9d172afd51f084d43f4e19409fd5f20d Mon Sep 17 00:00:00 2001 From: Matthew Edmondson Date: Sun, 1 Nov 2026 19:33:02 +0000 Subject: [PATCH 2/2] Use Path.Join to locate the README in ReadmeTests Path.Combine drops earlier segments when a later one is rooted; Path.Join doesn't, and matches how the other ktsu test suites build these paths. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5rmwEcdN9nYifxAwK --- TextFilter.Test/ReadmeTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TextFilter.Test/ReadmeTests.cs b/TextFilter.Test/ReadmeTests.cs index 02c3452..7ced909 100644 --- a/TextFilter.Test/ReadmeTests.cs +++ b/TextFilter.Test/ReadmeTests.cs @@ -42,8 +42,8 @@ private static string ReadReadme() { for (DirectoryInfo? directory = new(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) { - string candidate = Path.Combine(directory.FullName, "README.md"); - if (File.Exists(candidate) && File.Exists(Path.Combine(directory.FullName, "TextFilter.sln"))) + string candidate = Path.Join(directory.FullName, "README.md"); + if (File.Exists(candidate) && File.Exists(Path.Join(directory.FullName, "TextFilter.sln"))) { return File.ReadAllText(candidate); }