From 046f598ce0179e7dc253ff27892d62d68e877c18 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 17:27:10 +0000 Subject: [PATCH] docs: document only the API the library actually ships [patch] The README described Fuzzy.Match, Fuzzy.Filter, FuzzyOptions and FuzzyResult, none of which exist. Every sample except "Basic Matching" failed to compile against the shipped library, and this README is the NuGet.org landing page. Rewrite the usage examples and the API Reference around the two Contains overloads that do exist, and replace the fake Filter sections with the loop a caller actually writes to rank a collection. Document the behaviour the old text got wrong: matching is always case-insensitive, the score is an unbounded int rather than a 0-1 ratio, and it is reported even on a miss. Add ReadmeTests, which parses the shipped README and checks every Fuzzy member and library type it names against the assembly's public surface, so the documentation cannot drift from the API again. All three tests fail against the previous README. Fixes #74 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TGEcayWvpTKJ9S4gxu2XFD --- FuzzySearch.Test/FuzzySearch.Test.csproj | 5 + FuzzySearch.Test/ReadmeTests.cs | 133 +++++++++++++++++ README.md | 181 ++++++++++------------- 3 files changed, 213 insertions(+), 106 deletions(-) create mode 100644 FuzzySearch.Test/ReadmeTests.cs diff --git a/FuzzySearch.Test/FuzzySearch.Test.csproj b/FuzzySearch.Test/FuzzySearch.Test.csproj index 15bac1b..fdc3e61 100644 --- a/FuzzySearch.Test/FuzzySearch.Test.csproj +++ b/FuzzySearch.Test/FuzzySearch.Test.csproj @@ -14,4 +14,9 @@ + + + + + diff --git a/FuzzySearch.Test/ReadmeTests.cs b/FuzzySearch.Test/ReadmeTests.cs new file mode 100644 index 0000000..833904b --- /dev/null +++ b/FuzzySearch.Test/ReadmeTests.cs @@ -0,0 +1,133 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.FuzzySearch.Tests; + +using System.Reflection; +using System.Text.RegularExpressions; + +/// +/// Guards README.md against documenting an API the library does not ship. +/// +/// +/// The README is the NuGet.org landing page, so a member named there that does not exist is a +/// compile error for the first thing a new consumer copies. These tests read the shipped +/// README.md (copied beside the test assembly by the project file) and check every member +/// and type it names against the library's actual public surface. +/// +[TestClass] +public partial class ReadmeTests +{ + /// Matches a fenced C# sample, capturing its body. + [GeneratedRegex("^```csharp\r?\n(.*?)^```", RegexOptions.Multiline | RegexOptions.Singleline)] + private static partial Regex CSharpSample(); + + /// Matches a member accessed off the class, capturing the member name. + [GeneratedRegex(@"\bFuzzy\.(\w+)")] + private static partial Regex FuzzyMemberReference(); + + /// + /// Matches a library type named without a namespace qualifier, such as FuzzyOptions. The + /// lookbehind keeps it from firing on the namespace itself in using ktsu.FuzzySearch;. + /// + [GeneratedRegex(@"(?Matches a markdown table row, capturing the backticked name in its first cell. + [GeneratedRegex(@"^\|\s*`([^`]+)`", RegexOptions.Multiline)] + private static partial Regex TableRowName(); + + [TestMethod] + public void Readme_CSharpSamples_CallOnlyMembersThatExist() + { + // Arrange + HashSet declaredMembers = PublicMemberNamesOfFuzzy(); + + // Act + string[] documented = [.. CSharpSamples() + .SelectMany(sample => FuzzyMemberReference().Matches(sample)) + .Select(match => match.Groups[1].Value) + .Distinct()]; + + // Assert + Assert.IsGreaterThan(0, documented.Length, "No `Fuzzy.` call was found in any README sample, so this test is not checking anything."); + string[] missing = [.. documented.Where(name => !declaredMembers.Contains(name))]; + Assert.IsEmpty(missing, $"README.md calls Fuzzy members that do not exist: {string.Join(", ", missing)}."); + } + + [TestMethod] + public void Readme_CSharpSamples_NameOnlyTypesThatExist() + { + // Arrange + HashSet exportedTypes = PublicTypeNames(); + + // Act + string[] documented = [.. CSharpSamples() + .SelectMany(sample => LibraryTypeReference().Matches(sample)) + .Select(match => match.Value) + .Distinct()]; + + // Assert + string[] missing = [.. documented.Where(name => !exportedTypes.Contains(name))]; + Assert.IsEmpty(missing, $"README.md names library types that do not exist: {string.Join(", ", missing)}."); + } + + [TestMethod] + public void Readme_ApiReferenceTable_ListsOnlyMembersThatExist() + { + // Arrange + HashSet declaredMembers = PublicMemberNamesOfFuzzy(); + + // Act + string[] listed = [.. TableRowName().Matches(MethodsTable()) + .Select(match => match.Groups[1].Value) + .Distinct()]; + + // Assert + Assert.IsGreaterThan(0, listed.Length, "The API Reference methods table is empty, so this test is not checking anything."); + string[] missing = [.. listed.Where(name => !declaredMembers.Contains(name))]; + Assert.IsEmpty(missing, $"The API Reference table lists Fuzzy members that do not exist: {string.Join(", ", missing)}."); + } + + /// Reads the README.md copied beside the test assembly. + /// The full text of the README. + private static string ReadReadme() + { + string path = Path.Combine(AppContext.BaseDirectory, "README.md"); + Assert.IsTrue(File.Exists(path), $"README.md was not copied next to the test assembly; expected it at {path}."); + return File.ReadAllText(path); + } + + /// Gets the body of every fenced C# sample in the README. + /// One string per sample. + private static IEnumerable CSharpSamples() + { + string readme = ReadReadme(); + MatchCollection samples = CSharpSample().Matches(readme); + Assert.IsGreaterThan(0, samples.Count, "No ```csharp sample was found in README.md, so these tests are not checking anything."); + return samples.Select(match => match.Groups[1].Value); + } + + /// Gets the API Reference section's methods table. + /// The markdown between the methods heading and the heading that follows it. + private static string MethodsTable() + { + string readme = ReadReadme(); + int start = readme.IndexOf("#### Methods", StringComparison.Ordinal); + Assert.IsGreaterThanOrEqualTo(0, start, "README.md no longer has a '#### Methods' heading in its API Reference."); + + int end = readme.IndexOf("\n#### ", start + 1, StringComparison.Ordinal); + return end < 0 ? readme[start..] : readme[start..end]; + } + + /// Gets the names of the members actually declares publicly. + /// The set of public member names. + private static HashSet PublicMemberNamesOfFuzzy() => + [.. typeof(Fuzzy) + .GetMembers(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Select(member => member.Name)]; + + /// Gets the names of the types the library exports. + /// The set of public type names. + private static HashSet PublicTypeNames() => + [.. typeof(Fuzzy).Assembly.GetExportedTypes().Select(type => type.Name)]; +} diff --git a/README.md b/README.md index 871dd1a..ae379bd 100644 --- a/README.md +++ b/README.md @@ -12,15 +12,17 @@ ## Introduction -FuzzySearch is a .NET library that provides fuzzy string matching capabilities with intelligent scoring. It's perfect for implementing search-as-you-type features, command palettes, or any application requiring flexible string matching. This library offers both basic contains-style matching and more sophisticated algorithms that can rank multiple potential matches by relevance. +FuzzySearch is a .NET library that provides fuzzy string matching capabilities with intelligent scoring. It's perfect for implementing search-as-you-type features, command palettes, or any application requiring flexible string matching. + +The whole library is one static class, `Fuzzy`, with two `Contains` overloads: one that answers whether a subject matches a pattern, and one that also hands back a score you can rank by. ## Features -- **Fuzzy String Matching**: Match strings even when they contain typos or missing characters -- **Intelligent Scoring**: Rank matches by quality with a smart scoring algorithm -- **Case Insensitivity**: Optional case-insensitive matching -- **Filtering Collections**: Filter lists of strings and rank results -- **Customizable Parameters**: Adjust matching behavior to suit different needs +- **Subsequence Matching**: Match a subject against a pattern whose characters appear in order but not necessarily together +- **Intelligent Scoring**: Rank matches by quality with a scoring algorithm that rewards adjacent matches, matches after separators, and matches at camelCase boundaries +- **Case Insensitive**: Matching always ignores case; there is no case-sensitive mode +- **Unicode Aware**: Input is normalized to NFC and compared one codepoint at a time, so surrogate pairs match as a whole +- **Span Based**: Takes `ReadOnlySpan`, and allocates nothing for ASCII input - **Lightweight**: Minimal dependencies, focused on performance - **Well-tested**: Comprehensive test suite ensuring reliability @@ -67,7 +69,7 @@ class Program ### Matching with Scoring -To get both a match result and a score that indicates the quality of the match: +The second overload also reports a score, so you can tell a good match from a barely-there one: ```csharp using ktsu.FuzzySearch; @@ -78,19 +80,20 @@ class Program { string text = "Hello World"; string pattern = "hlo"; - - var result = Fuzzy.Match(text, pattern); - - Console.WriteLine($"Is match: {result.IsMatch}"); // True - Console.WriteLine($"Score: {result.Score}"); // A value between 0-1 - Console.WriteLine($"Character indices: {result.Indices}"); // Indices of matched characters + + bool isMatch = Fuzzy.Contains(text, pattern, out int score); + + Console.WriteLine($"Is match: {isMatch}"); // True + Console.WriteLine($"Score: {score}"); // Higher is better } } ``` -### Filtering a Collection +The score is an unbounded `int`, not a normalized ratio. It is only meaningful when comparing candidates against the *same* pattern, and it is reported whether or not the whole pattern was found — a near miss still scores. Use the return value to decide whether it matched at all, and the score to order the matches. -Filter a list of strings and sort them by match quality: +### Ranking a Collection + +There is no built-in filter method. Ranking a collection is a `Contains` call per candidate: ```csharp using ktsu.FuzzySearch; @@ -99,96 +102,70 @@ class Program { static void Main() { - var items = new List - { + string[] items = + [ "AppDataStorage", "Application Settings", "Data Store", "File System", - "Storage Provider" - }; - - string pattern = "appstor"; - - // Filter and rank by match quality - var results = Fuzzy.Filter(items, pattern); - - foreach (var result in results) - { - Console.WriteLine($"{result.Item} (Score: {result.Score})"); - } - - // Output might be: - // AppDataStorage (Score: 0.89) - // Application Settings (Score: 0.65) - // Storage Provider (Score: 0.52) - } -} -``` - -### Advanced Options + "Storage Provider", + ]; -Customize the matching behavior with options: + string pattern = "stor"; -```csharp -using ktsu.FuzzySearch; + List<(string Item, int Score)> matches = []; + foreach (string item in items) + { + if (Fuzzy.Contains(item, pattern, out int score)) + { + matches.Add((item, score)); + } + } -class Program -{ - static void Main() - { - var options = new FuzzyOptions + foreach ((string item, int score) in matches.OrderByDescending(match => match.Score)) { - CaseSensitive = true, // Default is false - ScoreThreshold = 0.4, // Minimum score to consider a match - BonusConsecutiveChars = 1.5, // Bonus for consecutive matched characters - BonusStartOfWord = 2.0, // Bonus for matches at word boundaries - PenaltyUnmatched = 0.1, // Penalty for unmatched characters - MaxPatternLength = 64 // Maximum pattern length to consider - }; - - string text = "FileSystemWatcher"; - string pattern = "FSW"; - - var result = Fuzzy.Match(text, pattern, options); - Console.WriteLine($"Score with custom options: {result.Score}"); + Console.WriteLine($"{item} (Score: {score})"); + } + + // Storage Provider (Score: 15) + // Data Store (Score: 14) + // AppDataStorage (Score: 10) } } ``` -### Object Collections +### Matching Against Objects -Filter and match against object collections by providing a selector function: +The same shape works for objects — project each one to the text you want matched: ```csharp using ktsu.FuzzySearch; class Program { - class FileItem + sealed class FileItem { - public string Name { get; set; } - public string Path { get; set; } - public long Size { get; set; } + public required string Name { get; init; } + public required string Path { get; init; } } - + static void Main() { - var files = new List - { - new FileItem { Name = "Document.pdf", Path = "/documents/", Size = 1024 }, - new FileItem { Name = "Presentation.pptx", Path = "/presentations/", Size = 2048 }, - new FileItem { Name = "Spreadsheet.xlsx", Path = "/spreadsheets/", Size = 512 } - }; - + FileItem[] files = + [ + new() { Name = "Document.pdf", Path = "/documents/" }, + new() { Name = "Presentation.pptx", Path = "/presentations/" }, + new() { Name = "Spreadsheet.xlsx", Path = "/spreadsheets/" }, + ]; + string pattern = "doc"; - - // Filter objects using a selector function - var results = Fuzzy.Filter(files, pattern, item => item.Name); - - foreach (var result in results) + + foreach (FileItem file in files) { - Console.WriteLine($"{result.Item.Name} (Score: {result.Score})"); + if (Fuzzy.Contains(file.Name, pattern, out int score)) + { + Console.WriteLine($"{file.Name} (Score: {score})"); + } } } } @@ -198,42 +175,34 @@ class Program ### `Fuzzy` Static Class -The main class providing fuzzy matching functionality. +`Fuzzy` is the library's entire public surface. It is a static class with two methods, both overloads of `Contains`. #### Methods | Name | Parameters | Return Type | Description | |------|------------|-------------|-------------| -| `Contains` | `string text, string pattern, bool caseSensitive = false` | `bool` | Checks if the text contains the pattern in sequence | -| `Match` | `string text, string pattern, FuzzyOptions options = null` | `FuzzyResult` | Matches text against pattern with scoring | -| `Filter` | `IEnumerable items, string pattern, FuzzyOptions options = null` | `IEnumerable>` | Filters and ranks a collection of strings | -| `Filter` | `IEnumerable items, string pattern, Func selector, FuzzyOptions options = null` | `IEnumerable>` | Filters and ranks a collection of objects using a selector function | - -### `FuzzyResult` Class - -Represents the result of a fuzzy match operation. - -#### Properties +| `Contains` | `ReadOnlySpan subject, ReadOnlySpan pattern` | `bool` | Whether `subject` contains every character of `pattern`, in order | +| `Contains` | `ReadOnlySpan subject, ReadOnlySpan pattern, out int outScore` | `bool` | The same answer, plus a match-quality score | -| Name | Type | Description | -|------|------|-------------| -| `IsMatch` | `bool` | Indicates if the pattern matches the text | -| `Score` | `double` | A value between 0 and 1 indicating match quality (1 is perfect) | -| `Indices` | `int[]` | The indices in the text where pattern characters were matched | +Both overloads take `ReadOnlySpan`, so a `string` argument is passed straight through by the compiler's implicit conversion — there is no separate `string` overload to look for. -### `FuzzyOptions` Class +#### Behaviour -Configuration options for fuzzy matching. +- **Case is always ignored.** There is no option to make matching case-sensitive. +- **An empty pattern matches any non-empty subject**, and scores `0`. An empty subject never matches. +- **Scores are unbounded `int`s**, comparable only within a single pattern. `outScore` is set even when the method returns `false`. +- **Input is normalized to NFC** before comparison, so precomposed and decomposed text match. This relies on the runtime's globalization data: under `InvariantGlobalization`, `string.Normalize` is a no-op and the two forms will not match. +- **Comparison advances one codepoint at a time**, so a surrogate pair matches only as a whole and a lone surrogate cannot match half of an unrelated character. -#### Properties +#### Scoring -| Name | Type | Default | Description | -|------|------|---------|-------------| -| `CaseSensitive` | `bool` | `false` | Whether matching should be case-sensitive | -| `ScoreThreshold` | `double` | `0.3` | Minimum score required to consider a match valid | -| `BonusConsecutiveChars` | `double` | `1.0` | Score bonus for consecutive matched characters | -| `BonusStartOfWord` | `double` | `1.5` | Score bonus for matches at word boundaries | -| `PenaltyUnmatched` | `double` | `0.1` | Score reduction for unmatched characters | +| Rule | Effect | +|------|--------| +| Match adjacent to the previous match | `+5` | +| Match after a `_` or space separator | `+10` | +| Match at a camelCase boundary | `+10` | +| Each unmatched character | `-1` | +| Unmatched characters before the first match | `-1` each, capped at `-5` | ## Contributing