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..7ced909 --- /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.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 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)); + } +}