Fuzzy string matching with scored matched indices
Fuzzy string matching that returns which characters matched and their score. Two algorithms: Smith-Waterman (local alignment) and Sahilm (gum-style scoring with separator/camel-case bonuses). The matched indices enable filter-highlighting UIs that were impossible with score-only matchers. Extracted from candy-forms; back-compat shims left in candy-forms and candy-lister for migration.
composer require sugarcraft/candy-fuzzy:@dev
use SugarCraft\Fuzzy\FuzzyMatcher;
use SugarCraft\Fuzzy\Matcher\SmithWatermanMatcher;
use SugarCraft\Fuzzy\MatchResult;
use SugarCraft\Fuzzy\Highlighter;
$matcher = new SmithWatermanMatcher();
// Single match — returns null when no match
$result = $matcher->match('foo', 'foobar');
// $result->score === 19 (3 × matchScore + adjacency bonuses)
// $result->indices() === [0, 1, 2] (0-based character positions)
// $result->haystack === 'foobar'
// Match all candidates, sorted by score desc then candidate asc
$results = $matcher->matchAll('ab', ['apple', 'banana', 'cabbage']);
// cabbage (11, the adjacent "ab") before apple (3) and banana (3)
// Opt in to full-query mode for type-to-filter pickers: every query
// character must land, in order
$picker = SmithWatermanMatcher::new()->withRequireFullQuery();
$picker->match('bxz', 'abcdef'); // null — "x" and "z" never land
$picker->match('gst', 'git status')->indices(); // [0, 4, 5]
// Use Highlighter to mark matched runs with ANSI codes
$highlighter = new Highlighter();
echo $highlighter->highlight($result, fn($s) => "\e[1;31m{$s}\e[0m");
// Output: 'foobar' with positions [0,1,2] highlighted in bold red
matchAllGenerator() streams the same results lazily.isMatched() is score > 0.ScoringProfile; ScoringProfile::canonical() (the default — default() is a deprecated alias) is bit-equivalent to the original candy-forms FuzzyMatcher, with strict() / lenient() presets. withRequireFullQuery() keeps only candidates that contain the whole query as an in-order subsequence, scoring the best alignment forced to cover every query character (floored at 1). Over-cap input is truncated, never handed to another algorithm: only the first maxQueryLength (128) query and maxCandidateLength (1000) candidate characters take part.SahilmScoring; SahilmScoring::canonical() is the historical sahilm/fuzzy constant set.Takes a MatchResult and a styler \Closure(string): string, returns the candidate string with matched runs passed through the styler. Non-matched runs are returned unchanged.
| Class | Method | Description |
|---|---|---|
| FuzzyMatcher | match(string $query, string $candidate): ?MatchResult | Single match or null |
| FuzzyMatcher | matchAll(string $query, iterable $candidates, ?int $limit = null, int $minScore = 1): list<MatchResult> | All matches sorted by score desc |
| FuzzyMatcher | matchAllGenerator(string $query, iterable $candidates, ?int $limit = null, int $minScore = 1): Generator | Lazy variant of matchAll() |
| MatchResult | $score / $needle / $haystack (readonly) | Match quality score (higher = better), the query and the candidate |
| MatchResult | indices(): list<int> | 0-based matched code-point offsets into the original haystack |
| MatchResult | isMatched(): bool / isEmpty(): bool | score > 0 / no matched indices |
| SmithWatermanMatcher | new(?ScoringProfile $profile = null, int $maxQueryLength = 128, int $maxCandidateLength = 1000, bool $requireFullQuery = false) | Local sequence alignment; over-cap input is truncated |
| SmithWatermanMatcher | withRequireFullQuery(bool $require = true): self / requiresFullQuery(): bool | Full-query mode: every query character must land, in order |
| SmithWatermanMatcher | profile() / maxQueryLength() / maxCandidateLength() | Configured weights and length caps |
| ScoringProfile | canonical() / strict() / lenient() | Smith-Waterman weight presets; default() is a deprecated alias of canonical() |
| ScoringProfile | withMatchScore() / withMismatchPenalty() / withGapOpen() / withGapExtend() / withAdjacentBonus() | Immutable weight setters |
| SahilmMatcher | new(bool $caseSensitive = false, ?SahilmScoring $scoring = null) | Separator/camelCase bonus algorithm; scoring() / caseSensitive() accessors |
| SahilmScoring | canonical() / withMatchScore() / withConsecutiveBonus() / withSeparatorBonus() / withCamelBonus() / withFirstCharBonus() / withLowerCaseBonus() | Immutable Sahilm weights (canonical 1 / 5 / 10 / 10 / 15 / 1) |
| FuzzyMatcherFactory | named(string $type, ScoringProfile|SahilmScoring|null $scoring = null): FuzzyMatcher | 'smith-waterman' or 'sahilm'; a mismatched weight type or unknown name throws. create(string $type, $profile = null) is a deprecated alias that forwards to named() and keeps its $profile parameter name for named-argument callers |
| Highlighter | highlight(MatchResult, \Closure(string): string): string | Apply styler to matched runs |