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