mirror of
https://github.com/zealdocs/zeal.git
synced 2026-08-29 16:40:51 +08:00
236 lines
7.8 KiB
C++
236 lines
7.8 KiB
C++
// Copyright (C) Oleg Shparber, et al. <https://zealdocs.org>
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
#include "fuzzy.h"
|
|
|
|
#include <algorithm>
|
|
#include <limits>
|
|
|
|
namespace Zeal::Util::Fuzzy {
|
|
|
|
namespace {
|
|
constexpr double SCORE_GAP_LEADING = -0.005;
|
|
constexpr double SCORE_GAP_TRAILING = -0.005;
|
|
constexpr double SCORE_GAP_INNER = -0.01;
|
|
constexpr double SCORE_MATCH_CONSECUTIVE = 1.0;
|
|
constexpr double SCORE_MATCH_SLASH = 0.9;
|
|
constexpr double SCORE_MATCH_WORD = 0.8;
|
|
constexpr double SCORE_MATCH_CAPITAL = 0.7;
|
|
constexpr double SCORE_MATCH_DOT = 0.6;
|
|
constexpr int FZY_MAX_LEN = 1024;
|
|
|
|
void precomputeBonus(const QString &haystack, double *matchBonus)
|
|
{
|
|
// Initialize to '/' so the first character of the haystack always receives
|
|
// SCORE_MATCH_SLASH (0.9), the highest boundary bonus. This mirrors fzy's
|
|
// original file-path design where every path component starts after a '/'.
|
|
// For Zeal's symbol names the first character is conceptually a word
|
|
// boundary, but the high bonus is kept intentionally: it strongly rewards
|
|
// prefix matches.
|
|
QChar lastCh = '/';
|
|
for (int i = 0; i < haystack.length(); ++i) {
|
|
const QChar ch = haystack[i];
|
|
|
|
if (lastCh == '/' || lastCh == '\\') {
|
|
matchBonus[i] = SCORE_MATCH_SLASH;
|
|
} else if (lastCh == '-' || lastCh == '_' || lastCh == ' ') {
|
|
matchBonus[i] = SCORE_MATCH_WORD;
|
|
} else if (lastCh == '.' || lastCh == ':') {
|
|
matchBonus[i] = SCORE_MATCH_DOT;
|
|
} else if (lastCh.isLower() && ch.isUpper()) {
|
|
matchBonus[i] = SCORE_MATCH_CAPITAL;
|
|
} else {
|
|
matchBonus[i] = 0.0;
|
|
}
|
|
|
|
lastCh = ch;
|
|
}
|
|
}
|
|
|
|
// Check if all characters in needle exist in haystack (in order, case-insensitive)
|
|
// This is a pre-filter before running the expensive DP algorithm
|
|
bool hasMatch(const QString &needle, const QString &haystack)
|
|
{
|
|
int haystackPos = 0;
|
|
const int haystackLen = haystack.length();
|
|
|
|
for (int i = 0; i < needle.length(); ++i) {
|
|
const QChar needleCh = needle[i].toLower();
|
|
bool found = false;
|
|
|
|
while (haystackPos < haystackLen) {
|
|
if (haystack[haystackPos].toLower() == needleCh) {
|
|
found = true;
|
|
++haystackPos;
|
|
break;
|
|
}
|
|
++haystackPos;
|
|
}
|
|
|
|
if (!found) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
} // anonymous namespace
|
|
|
|
// ============================================================================
|
|
// High-level Qt convenience API implementation
|
|
// ============================================================================
|
|
|
|
double score(const QString &needle, const QString &haystack, QVector<int> *positions)
|
|
{
|
|
// Pre-filter: check if all needle characters exist in haystack (performance optimization)
|
|
// This avoids expensive DP computation on unmatchable strings
|
|
if (!needle.isEmpty() && !haystack.isEmpty() && hasMatch(needle, haystack)) {
|
|
return computeScore(needle, haystack, positions);
|
|
}
|
|
|
|
// No match - return -infinity (SQL will filter with WHERE score > 0)
|
|
if (positions) {
|
|
positions->clear();
|
|
}
|
|
|
|
return -std::numeric_limits<double>::infinity();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Low-level API implementation
|
|
// ============================================================================
|
|
|
|
double computeScore(const QString &needle, const QString &haystack, QVector<int> *positions)
|
|
{
|
|
const int needleLen = needle.length();
|
|
const int haystackLen = haystack.length();
|
|
|
|
if (needleLen == 0 || haystackLen == 0 || needleLen > haystackLen) {
|
|
if (positions) {
|
|
positions->clear();
|
|
}
|
|
|
|
return -std::numeric_limits<double>::infinity();
|
|
}
|
|
|
|
if (needleLen == haystackLen) {
|
|
// Equal length strings get infinity score only if they actually match (case-insensitive)
|
|
if (needle.compare(haystack, Qt::CaseInsensitive) == 0) {
|
|
if (positions) {
|
|
// Fill positions for exact match: [0, 1, 2, ..., n-1]
|
|
positions->resize(needleLen);
|
|
for (int i = 0; i < needleLen; ++i) {
|
|
(*positions)[i] = i;
|
|
}
|
|
}
|
|
|
|
return std::numeric_limits<double>::infinity();
|
|
}
|
|
|
|
// Otherwise return no match - equal length non-matching strings can't fuzzy match
|
|
if (positions) {
|
|
positions->clear();
|
|
}
|
|
|
|
return -std::numeric_limits<double>::infinity();
|
|
}
|
|
|
|
if (haystackLen > FZY_MAX_LEN || needleLen > FZY_MAX_LEN) {
|
|
if (positions) {
|
|
positions->clear();
|
|
}
|
|
|
|
return -std::numeric_limits<double>::infinity();
|
|
}
|
|
|
|
double matchBonus[FZY_MAX_LEN] = {}; // Zero-initialize to satisfy static analyzer
|
|
precomputeBonus(haystack, matchBonus);
|
|
|
|
const double SCORE_MIN = -std::numeric_limits<double>::infinity();
|
|
|
|
// Always allocate 2D tables on heap (simpler, memory overhead negligible for typical searches)
|
|
double **D = new double *[needleLen];
|
|
double **M = new double *[needleLen];
|
|
for (int i = 0; i < needleLen; ++i) {
|
|
D[i] = new double[haystackLen];
|
|
M[i] = new double[haystackLen];
|
|
}
|
|
|
|
// Forward pass: compute scores
|
|
for (int i = 0; i < needleLen; ++i) {
|
|
double prevScore = SCORE_MIN;
|
|
const double gapScore = (i == needleLen - 1) ? SCORE_GAP_TRAILING : SCORE_GAP_INNER;
|
|
|
|
const QChar needleCh = needle[i].toLower();
|
|
|
|
for (int j = 0; j < haystackLen; ++j) {
|
|
if (needleCh == haystack[j].toLower()) {
|
|
double score = SCORE_MIN;
|
|
|
|
if (i == 0) {
|
|
score = (j * SCORE_GAP_LEADING) + matchBonus[j];
|
|
} else if (j > 0) {
|
|
const double prevM = M[i - 1][j - 1];
|
|
const double prevD = D[i - 1][j - 1];
|
|
score = std::max(prevM + matchBonus[j], prevD + SCORE_MATCH_CONSECUTIVE);
|
|
}
|
|
|
|
D[i][j] = score;
|
|
M[i][j] = prevScore = std::max(score, prevScore + gapScore);
|
|
} else {
|
|
D[i][j] = SCORE_MIN;
|
|
M[i][j] = prevScore = prevScore + gapScore;
|
|
}
|
|
}
|
|
}
|
|
|
|
const double result = M[needleLen - 1][haystackLen - 1];
|
|
|
|
// Backtrack to find positions if requested (fzy algorithm)
|
|
// Only backtrack if we have a valid match (not SCORE_MIN)
|
|
if (positions != nullptr && result != SCORE_MIN) {
|
|
positions->resize(needleLen);
|
|
bool matchRequired = false;
|
|
|
|
for (int i = needleLen - 1, j = haystackLen - 1; i >= 0; --i) {
|
|
for (; j >= 0; --j) {
|
|
// Check if this is a valid match position on the optimal path
|
|
if (D[i][j] != SCORE_MIN && (matchRequired || D[i][j] == M[i][j])) {
|
|
// Check if we used consecutive match bonus to get here.
|
|
// Use D[i][j] (score at this specific position), not M[i][j]
|
|
// (global prefix optimum), which may reflect a different path entirely.
|
|
matchRequired = (i > 0 && j > 0 && D[i][j] == D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE);
|
|
(*positions)[i] = j;
|
|
--j;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up
|
|
for (int i = 0; i < needleLen; ++i) {
|
|
delete[] D[i];
|
|
delete[] M[i];
|
|
}
|
|
|
|
delete[] D;
|
|
delete[] M;
|
|
|
|
return result;
|
|
}
|
|
|
|
double scoreFunction(const QString &needle, const QString &haystack)
|
|
{
|
|
return score(needle, haystack, nullptr);
|
|
}
|
|
|
|
// Legacy C-string wrapper for SQLite callback
|
|
double scoreFunction(const char *needle, const char *haystack)
|
|
{
|
|
return scoreFunction(QString::fromUtf8(needle), QString::fromUtf8(haystack));
|
|
}
|
|
|
|
} // namespace Zeal::Util::Fuzzy
|