Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion FuzzySearch.Test/FuzzyTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2023-2026 ktsu-dev contributors
// Copyright (c) 2023-2026 ktsu-dev contributors

[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]

Expand Down Expand Up @@ -469,6 +469,41 @@ public void CalculateScore_LongUnmatchedPrefix_PenaltyFlattensAtTheCap()
Assert.AreEqual(atCap, wellPastCap, "The prefix penalty must stay flat however long the prefix grows.");
}

[TestMethod]
[DataRow(5)]
[DataRow(20)]
[DataRow(100)]
public void CalculateScore_ReplacedFirstLetter_KeepsThePrefixPenaltyCapped(int gapLength)
{
// Arrange: both subjects end in the same "_ab" alignment. The second also starts with a stray copy of the
// pattern's first letter, which is provisionally matched and then replaced by the better-placed "a" in
// "_ab", so everything before that "a" is prefix in both.
string gap = new('x', gapLength);
string pattern = "ab";

// Act
int withoutStrayLetter = Fuzzy.CalculateScore(gap + "_ab", pattern, out bool withoutPresent);
int withStrayLetter = Fuzzy.CalculateScore("a" + gap + "_ab", pattern, out bool withPresent);

// Assert
Assert.IsTrue(withoutPresent, "The pattern is present.");
Assert.IsTrue(withPresent, "The pattern is present.");
Assert.AreEqual(withoutStrayLetter, withStrayLetter,
"A replaced first letter is prefix, so it must cost no more than the capped prefix penalty.");
}

[TestMethod]
public void Score_StrayEarlyFirstLetter_DoesNotSinkAPathMatch()
{
// Act: the same "_test" match, with and without a 't' early in the path
int withStrayLetter = Fuzzy.CalculateScore("tools/build/scripts/unit_test", "test", out _);
int withoutStrayLetter = Fuzzy.CalculateScore("docs/build/scripts/unit_test", "test", out _);

// Assert
Assert.AreEqual(withoutStrayLetter, withStrayLetter,
"An early copy of the first letter that the match moves past must not cost more than the prefix cap.");
}

[TestMethod]
public void CalculateScore_PrefixPenalty_NeverExceedsTheDocumentedCap()
{
Expand Down
62 changes: 57 additions & 5 deletions FuzzySearch/Fuzzy.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2023-2026 ktsu-dev contributors
// Copyright (c) 2023-2026 ktsu-dev contributors

using System.Runtime.CompilerServices;
using System.Text;
Expand Down Expand Up @@ -165,7 +165,7 @@
/// or the pattern is empty and the subject is not; otherwise, <c>false</c>.
/// </param>
/// <returns>A score representing the quality of the match. Higher scores indicate better matches.</returns>
internal static int CalculateScoreCore(ReadOnlySpan<char> subject, ReadOnlySpan<char> pattern, out bool wholePatternIsPresent)

Check warning on line 168 in FuzzySearch/Fuzzy.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 38 to the 15 allowed.

Check warning on line 168 in FuzzySearch/Fuzzy.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 38 to the 15 allowed.

Check warning on line 168 in FuzzySearch/Fuzzy.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 38 to the 15 allowed.

Check warning on line 168 in FuzzySearch/Fuzzy.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 38 to the 15 allowed.

Check warning on line 168 in FuzzySearch/Fuzzy.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 38 to the 15 allowed.

Check warning on line 168 in FuzzySearch/Fuzzy.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 38 to the 15 allowed.
{
if (pattern.IsEmpty)
{
Expand Down Expand Up @@ -203,6 +203,15 @@
// of them, leaving the score falling without bound as the prefix grows.
int prefixPenaltyCharged = 0;

// While the best letter is still the first pattern codepoint and has not been committed, a better-placed
// copy of it can take its place, and then everything before the copy is prefix too. These record what was
// charged since the first match landed — the capped prefix penalty and the uncapped per-codepoint costs
// after it — so a replacement can hand them back and charge the longer prefix through the cap instead.
// Otherwise a stray early copy of the first letter puts the whole gap outside the cap.
bool firstLetterProvisional = false;
int provisionalPrefixPenalty = 0;
int provisionalCharges = 0;

// Loop over codepoints in subject
while (strIdx != strLength)
{
Expand All @@ -227,6 +236,7 @@
bestLetterIdx = null;
bestLetterLength = 0;
bestLetterScore = 0;
firstLetterProvisional = false;
}

if (nextMatch || rematch)
Expand All @@ -239,9 +249,13 @@
// so that the capped penalty applied next is all the prefix is charged.
score -= prefixPenaltyCharged;
prefixPenaltyCharged = 0;
}

score = PenalizeNonPatternCharacters(score, patternIdx, strCodepointIdx);
int scoreBeforePrefix = score;
score = PenalizeNonPatternCharacters(score, patternIdx, strCodepointIdx);
firstLetterProvisional = true;
provisionalPrefixPenalty = score - scoreBeforePrefix;
provisionalCharges = 0;
}

newScore = ApplyBonuses(prevMatched, prevLower, prevSeparator, strChar, strLower, strUpper, newScore);

Expand All @@ -255,10 +269,21 @@
bool displacesRun = rematch && !nextMatch && bestLetterContinuesRun;
if (newScore >= bestLetterScore && !displacesRun)
{
// Apply penalty for now skipped letter
if (bestLetterIdx is not null)
if (ReplacesProvisionalFirstLetter(firstLetterProvisional, rematch, nextMatch))
{
// A copy of the first letter replaces it, so the superseded letter and everything up to
// here is prefix: refund what was charged since the first match and charge the cap again.
score -= provisionalPrefixPenalty + provisionalCharges;
int scoreBeforePrefix = score;
score = PenalizeNonPatternCharacters(score, 0, strCodepointIdx);
provisionalPrefixPenalty = score - scoreBeforePrefix;
provisionalCharges = 0;
}
else if (bestLetterIdx is not null)
{
// Apply penalty for now skipped letter
score += unmatchedLetterPenalty;
ChargeProvisional(firstLetterProvisional, ref provisionalCharges);
}

bestLetterIdx = strIdx;
Expand All @@ -271,6 +296,7 @@
// A rematch that loses to the current best letter is skipped too, so charge it like one.
// Otherwise repeats of a well-scoring letter are free and can tie an exact match.
score += unmatchedLetterPenalty;
ChargeProvisional(firstLetterProvisional, ref provisionalCharges);
}

prevMatched = true;
Expand All @@ -287,6 +313,7 @@
prefixPenaltyCharged += unmatchedLetterPenalty;
}

ChargeProvisional(firstLetterProvisional, ref provisionalCharges);
prevMatched = false;
}

Expand All @@ -310,6 +337,31 @@
return score;
}

/// <summary>
/// Determines whether a match is a copy of the uncommitted first pattern codepoint taking that codepoint's
/// place, rather than a match of the next pattern codepoint.
/// </summary>
/// <param name="firstLetterProvisional">Whether the best letter is the uncommitted first pattern codepoint.</param>
/// <param name="rematch">Whether the subject codepoint repeats the best letter.</param>
/// <param name="nextMatch">Whether the subject codepoint matches the next pattern codepoint.</param>
/// <returns><c>true</c> if the match replaces the provisional first letter; otherwise, <c>false</c>.</returns>
private static bool ReplacesProvisionalFirstLetter(bool firstLetterProvisional, bool rematch, bool nextMatch) =>
firstLetterProvisional && rematch && !nextMatch;

/// <summary>
/// Records a per-codepoint charge made while the first matched letter may still be replaced, so that a
/// replacement can refund it.
/// </summary>
/// <param name="firstLetterProvisional">Whether the best letter is the uncommitted first pattern codepoint.</param>
/// <param name="provisionalCharges">The running total of such charges.</param>
private static void ChargeProvisional(bool firstLetterProvisional, ref int provisionalCharges)
{
if (firstLetterProvisional)
{
provisionalCharges += unmatchedLetterPenalty;
}
}

/// <summary>
/// Determines whether a character separates words, so that a match right after it earns
/// <see cref="matchAfterSeparatorBonus"/>.
Expand Down Expand Up @@ -471,7 +523,7 @@

if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1]))
{
i++;

Check warning on line 526 in FuzzySearch/Fuzzy.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 526 in FuzzySearch/Fuzzy.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 526 in FuzzySearch/Fuzzy.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 526 in FuzzySearch/Fuzzy.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.
continue;
}

Expand Down
Loading