Initial commit: FuzzyRanker keystone + project plan
Kate plugin suite for menu-free, mouse-first, one-handed UX. - FuzzyRanker: orderless + layered fuzzy matcher (exact/substring/ initials/subsequence/bounded-typo) with merged highlight ranges. Fixes Kate's KFuzzyMatcher gaps (no orderless, 'gti' fails 'git'). - 14 QTest cases, all green. - README + docs/PLAN.md: goal, design constraints, 5-milestone roadmap.
This commit is contained in:
commit
95bc9f7ed6
|
|
@ -0,0 +1,14 @@
|
|||
# Build artifacts
|
||||
/build/
|
||||
/prefix.sh
|
||||
|
||||
# Editor / OS cruft
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
*.moc
|
||||
moc_*.cpp
|
||||
*~
|
||||
.DS_Store
|
||||
compile_commands.json
|
||||
.cache/
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
cmake_minimum_required(VERSION 3.21)
|
||||
|
||||
project(kate-custom VERSION 0.1.0 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
|
||||
set(QT_MIN_VERSION 6.5.0)
|
||||
set(KF_MIN_VERSION 6.0.0)
|
||||
|
||||
find_package(ECM ${KF_MIN_VERSION} REQUIRED NO_MODULE)
|
||||
list(APPEND CMAKE_MODULE_PATH ${ECM_MODULE_PATH})
|
||||
|
||||
include(KDEInstallDirs)
|
||||
include(KDECMakeSettings)
|
||||
include(KDECompilerSettings NO_POLICY_SCOPE)
|
||||
|
||||
find_package(Qt6 ${QT_MIN_VERSION} REQUIRED COMPONENTS Core)
|
||||
|
||||
enable_testing()
|
||||
|
||||
add_subdirectory(src)
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
# kate-custom
|
||||
|
||||
A suite of Kate plugins for people who despise menus. The aim is a text editor
|
||||
that is driven by the mouse at the cursor and by plain-letter keyboard input —
|
||||
never by hunting through nested menubars or memorizing complex chords.
|
||||
|
||||
## Design constraints
|
||||
|
||||
These are hard constraints that shape every decision:
|
||||
|
||||
- **Mouse-heavy.** The fastest input is the mouse, used *locally* at the caret
|
||||
(radial / floating surfaces), not a trek to the menubar.
|
||||
- **One-handed typing.** Keyboard input must work comfortably with one hand.
|
||||
- **No complex chords.** Single modifier at most (`Ctrl`+letter). No leader-key
|
||||
chord trees, no multi-cursor keybinds.
|
||||
- **Compact keyboard.** F-keys and nav clusters live on a layer toggle, so they
|
||||
carry extra cognitive cost and are avoided as primary triggers.
|
||||
- **Emacs/Sublime fluency.** `M-x` (`Alt+x`) is comfortable and welcome as a
|
||||
primary keyboard entry point, as is a Sublime-style type-to-filter palette.
|
||||
|
||||
### Unifying principle
|
||||
|
||||
> **One action registry. Three doors onto it:**
|
||||
> **`M-x` (keyboard), a radial caret menu (mouse), and the `:` command line (optional).**
|
||||
> **No action requires an F-key or a chord.**
|
||||
|
||||
## Why not just use Kate's command bar?
|
||||
|
||||
Kate's `KCommandBar` is a sealed widget: it accepts a list of actions via
|
||||
`setActions()` and exposes **no hook** to customize its matcher. Its matcher,
|
||||
`KFuzzyMatcher`, is single-needle, strictly in-order subsequence, with **no typo
|
||||
tolerance** — by KDE's own documentation, `"gti"` will not match `"git"`. There
|
||||
is no "orderless" (space-separated tokens in any order) and no company-style
|
||||
live completion.
|
||||
|
||||
So the project replaces the palette rather than extending it, built on a custom
|
||||
matcher (`FuzzyRanker`).
|
||||
|
||||
## Environment
|
||||
|
||||
- Kate 25.12.3, KDE Frameworks 6, Qt 6
|
||||
- C++20, CMake, extra-cmake-modules
|
||||
- C++ is the only first-class plugin path on this install (no Python/Pâté binding present)
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
cmake -B build -S .
|
||||
cmake --build build
|
||||
QT_QPA_PLATFORM=offscreen ./build/bin/test_fuzzyranker # run the matcher tests
|
||||
ctest --test-dir build # or via ctest
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
See [docs/PLAN.md](docs/PLAN.md) for the full roadmap.
|
||||
|
||||
- **Milestone 1 — FuzzyRanker (keystone): DONE.** Orderless, layered scoring
|
||||
(exact / substring / word-initials / subsequence / bounded typo), merged
|
||||
highlight ranges. 14 unit tests, all green.
|
||||
- Milestone 2 — custom palette widget: next.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
CMakeLists.txt top-level KF6/Qt6/ECM project
|
||||
src/
|
||||
fuzzy/
|
||||
fuzzyranker.{h,cpp} orderless + layered fuzzy matcher
|
||||
test_fuzzyranker.cpp QTest suite
|
||||
docs/
|
||||
PLAN.md goal, constraints, roadmap
|
||||
```
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
# Plan — kate-custom
|
||||
|
||||
## Goal
|
||||
|
||||
Build a suite of Kate plugins that make the editor usable without menus, tuned
|
||||
to one specific operator profile:
|
||||
|
||||
- mouse-heavy, with the mouse used at the caret rather than at the menubar
|
||||
- one-handed keyboard typing
|
||||
- no complex chords; single modifier at most
|
||||
- compact keyboard where F-keys/nav clusters sit behind a layer toggle
|
||||
- long Emacs history and some Sublime Text; `M-x` and type-to-filter palettes
|
||||
are familiar and welcome
|
||||
|
||||
The spine of the suite is a single **action registry** reached through three
|
||||
interchangeable doors: `M-x` (keyboard), a radial caret menu (mouse), and the
|
||||
`:` command line (optional). Every feature registers its commands once and
|
||||
becomes reachable from all three. No action depends on an F-key or a chord.
|
||||
|
||||
## Why a custom matcher is the keystone
|
||||
|
||||
Kate's built-in command bar cannot be fixed in place:
|
||||
|
||||
- `KCommandBar` (KConfigWidgets) exposes only `setActions()`. There is no hook
|
||||
to replace or configure its matcher.
|
||||
- `KFuzzyMatcher` (KCoreAddons) is single-needle, strictly in-order
|
||||
subsequence, and not typo tolerant. KDE's own docs state `"gti"` will not
|
||||
match `"git"`. No orderless, no company-style completion.
|
||||
|
||||
Therefore the palette is replaced, not extended, and the replacement is built on
|
||||
`FuzzyRanker`, a matcher with:
|
||||
|
||||
- **orderless** tokenization (space-separated needles matched in any order)
|
||||
- **layered scoring**, strongest tier first:
|
||||
1. exact
|
||||
2. substring
|
||||
3. word-boundary initials (`rs` -> **R**ename **S**ymbol)
|
||||
4. in-order subsequence
|
||||
5. bounded typo (one deletion; recovers transpositions like `gti` -> `git`)
|
||||
- **merged highlight ranges** for the UI
|
||||
- AND semantics across needles, case-folded, shorter-candidate preference
|
||||
|
||||
## Milestones
|
||||
|
||||
### M1 — FuzzyRanker (keystone) — DONE
|
||||
- Repo scaffold: top-level CMake (KF6/Qt6/ECM), `src/`, `src/fuzzy/`.
|
||||
- `src/fuzzy/fuzzyranker.{h,cpp}`: tokenize + layered scoring + ranges.
|
||||
- `src/fuzzy/test_fuzzyranker.cpp`: QTest suite, 14 cases, all green,
|
||||
including `gti -> git` and `ren sym -> Rename Symbol`.
|
||||
|
||||
### M2 — Palette widget (replaces KCommandBar) — NEXT
|
||||
- Custom `QFrame`: filter `QLineEdit` + results list (model/view).
|
||||
- Live, company-style updates on every keystroke; top hit preselected; `Enter`
|
||||
executes; matched characters highlighted via `MatchResult::ranges`.
|
||||
- Consumes `FuzzyRanker`. Pure Qt widget, independent of Kate for testability.
|
||||
|
||||
### M3 — KTextEditor plugin + M-x + action registry
|
||||
- `KTextEditor::Plugin` skeleton producing a loadable `.so`.
|
||||
- Central action registry aggregating Kate built-in actions + plugin actions.
|
||||
- `M-x` (`Alt+x`) opens the M2 palette over the registry.
|
||||
- Frecency ranking: usage history feeds a score bonus.
|
||||
|
||||
### M4 — Radial caret menu (mouse door)
|
||||
- Mouse-triggered radial menu positioned at the caret
|
||||
(`View::cursorToCoordinate`), flick-to-select by angle.
|
||||
- Slices invoke registry actions; one slice opens the palette / command line, so
|
||||
the keyboard-expensive paths are never required.
|
||||
|
||||
### M5 — Command vocabulary + project model + switchers
|
||||
- `:`-verb pack via `KTextEditor::Command`: `sort`, `align`, `json`, `b64`,
|
||||
`uuid`, `case`, `pipe <shell>`, etc. These populate M-x and the radial.
|
||||
- **Project = directory.** "Open folder == open project" (VSCode/Sublime model);
|
||||
no `.kateproject` ceremony.
|
||||
- File / symbol switchers scoped to the opened folder (e.g. `git ls-files` / `fd`
|
||||
for files; LSP or ctags for symbols), all through the M2 palette.
|
||||
|
||||
## Build and test
|
||||
|
||||
```sh
|
||||
cmake -B build -S .
|
||||
cmake --build build
|
||||
ctest --test-dir build
|
||||
# or directly:
|
||||
QT_QPA_PLATFORM=offscreen ./build/bin/test_fuzzyranker
|
||||
```
|
||||
|
||||
## Environment facts (verified)
|
||||
|
||||
- Kate 25.12.3, KDE Frameworks 6, Qt 6.10, cmake 4.2.3, g++, extra-cmake-modules.
|
||||
- KTextEditor + KCoreAddons + KConfigWidgets + KXmlGui + KRunner dev headers present.
|
||||
- No Python/Pâté binding installed; C++ is the only first-class plugin path.
|
||||
|
|
@ -0,0 +1 @@
|
|||
add_subdirectory(fuzzy)
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
add_library(fuzzyranker STATIC
|
||||
fuzzyranker.cpp
|
||||
fuzzyranker.h
|
||||
)
|
||||
target_link_libraries(fuzzyranker PUBLIC Qt6::Core)
|
||||
target_include_directories(fuzzyranker PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
find_package(Qt6 ${QT_MIN_VERSION} COMPONENTS Test)
|
||||
if(Qt6Test_FOUND)
|
||||
add_executable(test_fuzzyranker test_fuzzyranker.cpp)
|
||||
target_link_libraries(test_fuzzyranker PRIVATE fuzzyranker Qt6::Test)
|
||||
add_test(NAME fuzzyranker COMMAND test_fuzzyranker)
|
||||
endif()
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
/*
|
||||
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||
*/
|
||||
#include "fuzzyranker.h"
|
||||
|
||||
#include <QChar>
|
||||
|
||||
#include <algorithm>
|
||||
#include <set>
|
||||
|
||||
namespace katecustom
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// --- character helpers -----------------------------------------------------
|
||||
|
||||
QString foldString(QStringView s)
|
||||
{
|
||||
return s.toString().toCaseFolded();
|
||||
}
|
||||
|
||||
bool isWordStart(const QString &s, int i)
|
||||
{
|
||||
if (i == 0) {
|
||||
return true;
|
||||
}
|
||||
const QChar prev = s.at(i - 1);
|
||||
const QChar cur = s.at(i);
|
||||
// Boundary if previous is a separator, or camelCase hump (lower -> Upper).
|
||||
if (prev == QLatin1Char(' ') || prev == QLatin1Char('-') || prev == QLatin1Char('_')
|
||||
|| prev == QLatin1Char('.') || prev == QLatin1Char('/') || prev == QLatin1Char(':')) {
|
||||
return true;
|
||||
}
|
||||
if (prev.isLower() && cur.isUpper()) {
|
||||
return true;
|
||||
}
|
||||
if (!prev.isLetterOrNumber() && cur.isLetterOrNumber()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Per-needle outcome: which original-string indices were consumed, a raw
|
||||
// quality score, and the tier reached.
|
||||
struct NeedleMatch {
|
||||
bool matched = false;
|
||||
MatchTier tier = MatchTier::None;
|
||||
int score = 0;
|
||||
std::vector<int> indices; // indices into the ORIGINAL candidate
|
||||
};
|
||||
|
||||
// Tier base weight so a stronger match kind always outranks a weaker one.
|
||||
int tierWeight(MatchTier t)
|
||||
{
|
||||
switch (t) {
|
||||
case MatchTier::Exact:
|
||||
return 1000;
|
||||
case MatchTier::Substring:
|
||||
return 400;
|
||||
case MatchTier::Initials:
|
||||
return 300;
|
||||
case MatchTier::Subsequence:
|
||||
return 150;
|
||||
case MatchTier::Typo:
|
||||
return 60;
|
||||
case MatchTier::None:
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- strategy 1: exact / substring ----------------------------------------
|
||||
|
||||
NeedleMatch trySubstring(const QString &cand, const QString &needle)
|
||||
{
|
||||
NeedleMatch m;
|
||||
const int at = cand.indexOf(needle);
|
||||
if (at < 0) {
|
||||
return m;
|
||||
}
|
||||
m.matched = true;
|
||||
m.tier = (needle.size() == cand.size()) ? MatchTier::Exact : MatchTier::Substring;
|
||||
for (int i = 0; i < needle.size(); ++i) {
|
||||
m.indices.push_back(at + i);
|
||||
}
|
||||
int bonus = 0;
|
||||
if (isWordStart(cand, at)) {
|
||||
bonus += 30;
|
||||
}
|
||||
if (at == 0) {
|
||||
bonus += 20;
|
||||
}
|
||||
m.score = tierWeight(m.tier) + needle.size() * 10 + bonus;
|
||||
return m;
|
||||
}
|
||||
|
||||
// --- strategy 2: word-boundary initials ------------------------------------
|
||||
// Each needle char must land on a word-start, consumed in order.
|
||||
|
||||
NeedleMatch tryInitials(const QString &cand, const QString &needle)
|
||||
{
|
||||
NeedleMatch m;
|
||||
std::vector<int> hits;
|
||||
int ni = 0;
|
||||
for (int i = 0; i < cand.size() && ni < needle.size(); ++i) {
|
||||
if (isWordStart(cand, i) && cand.at(i) == needle.at(ni)) {
|
||||
hits.push_back(i);
|
||||
++ni;
|
||||
}
|
||||
}
|
||||
if (ni != needle.size()) {
|
||||
return m;
|
||||
}
|
||||
m.matched = true;
|
||||
m.tier = MatchTier::Initials;
|
||||
m.indices = hits;
|
||||
m.score = tierWeight(m.tier) + needle.size() * 12;
|
||||
return m;
|
||||
}
|
||||
|
||||
// --- strategy 3: in-order subsequence (greedy, contiguity-bonused) ---------
|
||||
|
||||
NeedleMatch trySubsequence(const QString &cand, const QString &needle)
|
||||
{
|
||||
NeedleMatch m;
|
||||
std::vector<int> hits;
|
||||
int ni = 0;
|
||||
int contiguous = 0;
|
||||
int prev = -2;
|
||||
int bonus = 0;
|
||||
for (int i = 0; i < cand.size() && ni < needle.size(); ++i) {
|
||||
if (cand.at(i) == needle.at(ni)) {
|
||||
hits.push_back(i);
|
||||
if (i == prev + 1) {
|
||||
++contiguous;
|
||||
bonus += 5;
|
||||
}
|
||||
if (isWordStart(cand, i)) {
|
||||
bonus += 8;
|
||||
}
|
||||
prev = i;
|
||||
++ni;
|
||||
}
|
||||
}
|
||||
if (ni != needle.size()) {
|
||||
return m;
|
||||
}
|
||||
m.matched = true;
|
||||
m.tier = MatchTier::Subsequence;
|
||||
m.indices = hits;
|
||||
m.score = tierWeight(m.tier) + needle.size() * 6 + bonus - (hits.empty() ? 0 : hits.front());
|
||||
return m;
|
||||
}
|
||||
|
||||
// --- strategy 4: bounded typo tolerance ------------------------------------
|
||||
// Allow up to one deletion in the needle (handles transposition/insert typos
|
||||
// like "gti" -> "git"): if dropping one needle char yields a subsequence, it
|
||||
// matches at the lowest tier. We try each single-char deletion.
|
||||
|
||||
NeedleMatch tryTypo(const QString &cand, const QString &needle)
|
||||
{
|
||||
NeedleMatch m;
|
||||
if (needle.size() < 2) {
|
||||
return m;
|
||||
}
|
||||
for (int drop = 0; drop < needle.size(); ++drop) {
|
||||
QString reduced = needle;
|
||||
reduced.remove(drop, 1);
|
||||
NeedleMatch sub = trySubsequence(cand, reduced);
|
||||
if (sub.matched) {
|
||||
m.matched = true;
|
||||
m.tier = MatchTier::Typo;
|
||||
m.indices = sub.indices;
|
||||
m.score = tierWeight(m.tier) + reduced.size() * 4;
|
||||
return m;
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
NeedleMatch bestNeedleMatch(const QString &cand, const QString &needle)
|
||||
{
|
||||
// Escalate: stop at the first (strongest) strategy that matches.
|
||||
NeedleMatch m = trySubstring(cand, needle);
|
||||
if (m.matched) {
|
||||
return m;
|
||||
}
|
||||
m = tryInitials(cand, needle);
|
||||
if (m.matched) {
|
||||
return m;
|
||||
}
|
||||
m = trySubsequence(cand, needle);
|
||||
if (m.matched) {
|
||||
return m;
|
||||
}
|
||||
return tryTypo(cand, needle);
|
||||
}
|
||||
|
||||
std::vector<MatchRange> indicesToRanges(const std::set<int> &idx)
|
||||
{
|
||||
std::vector<MatchRange> ranges;
|
||||
int start = -1;
|
||||
int prev = -2;
|
||||
for (int i : idx) {
|
||||
if (i == prev + 1) {
|
||||
prev = i;
|
||||
continue;
|
||||
}
|
||||
if (start >= 0) {
|
||||
ranges.push_back({start, prev - start + 1});
|
||||
}
|
||||
start = i;
|
||||
prev = i;
|
||||
}
|
||||
if (start >= 0) {
|
||||
ranges.push_back({start, prev - start + 1});
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QList<QString> FuzzyRanker::tokenize(QStringView query)
|
||||
{
|
||||
QList<QString> needles;
|
||||
const QList<QStringView> parts = query.split(QLatin1Char(' '), Qt::SkipEmptyParts);
|
||||
for (const QStringView &p : parts) {
|
||||
const QString folded = foldString(p);
|
||||
if (!folded.isEmpty()) {
|
||||
needles.push_back(folded);
|
||||
}
|
||||
}
|
||||
return needles;
|
||||
}
|
||||
|
||||
MatchResult FuzzyRanker::score(QStringView candidate, const QList<QString> &needles)
|
||||
{
|
||||
MatchResult result;
|
||||
|
||||
// Empty query: everything matches, neutral score, shorter candidates win.
|
||||
if (needles.isEmpty()) {
|
||||
result.matched = true;
|
||||
result.score = -candidate.size();
|
||||
return result;
|
||||
}
|
||||
|
||||
const QString cand = foldString(candidate);
|
||||
|
||||
std::set<int> allIndices;
|
||||
int total = 0;
|
||||
for (const QString &needle : needles) {
|
||||
const NeedleMatch m = bestNeedleMatch(cand, needle);
|
||||
if (!m.matched) {
|
||||
return MatchResult{}; // AND semantics: any miss rejects.
|
||||
}
|
||||
total += m.score;
|
||||
for (int i : m.indices) {
|
||||
allIndices.insert(i);
|
||||
}
|
||||
}
|
||||
|
||||
result.matched = true;
|
||||
// Prefer shorter candidates and reward coverage of the candidate.
|
||||
const int coverage = static_cast<int>(allIndices.size());
|
||||
result.score = total + coverage * 2 - candidate.size();
|
||||
result.ranges = indicesToRanges(allIndices);
|
||||
return result;
|
||||
}
|
||||
|
||||
MatchResult FuzzyRanker::score(QStringView candidate, QStringView query)
|
||||
{
|
||||
return score(candidate, tokenize(query));
|
||||
}
|
||||
|
||||
} // namespace katecustom
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||
*
|
||||
* FuzzyRanker — orderless, layered fuzzy matching for command palettes.
|
||||
*
|
||||
* Unlike KFuzzyMatcher (single-needle, strictly in-order subsequence, no typo
|
||||
* tolerance: "gti" fails to match "git"), FuzzyRanker:
|
||||
* - splits the query into whitespace-separated needles matched in ANY order
|
||||
* (Emacs `orderless` semantics)
|
||||
* - scores each needle by an escalating strategy (exact substring >
|
||||
* word-boundary initials > in-order subsequence > bounded typo)
|
||||
* - returns highlight ranges for every matched character
|
||||
* - exposes a frecency bonus hook for usage-aware ranking
|
||||
*/
|
||||
#ifndef KATECUSTOM_FUZZYRANKER_H
|
||||
#define KATECUSTOM_FUZZYRANKER_H
|
||||
|
||||
#include <QString>
|
||||
#include <QList>
|
||||
#include <vector>
|
||||
|
||||
namespace katecustom
|
||||
{
|
||||
|
||||
/*! A contiguous span of matched characters in the candidate string. */
|
||||
struct MatchRange {
|
||||
int start = 0;
|
||||
int length = 0;
|
||||
bool operator==(const MatchRange &o) const
|
||||
{
|
||||
return start == o.start && length == o.length;
|
||||
}
|
||||
};
|
||||
|
||||
/*! Result of scoring one candidate against a query. */
|
||||
struct MatchResult {
|
||||
bool matched = false;
|
||||
int score = 0;
|
||||
std::vector<MatchRange> ranges; // sorted by start, non-overlapping
|
||||
|
||||
bool operator<(const MatchResult &o) const { return score < o.score; }
|
||||
};
|
||||
|
||||
/*!
|
||||
* How a single needle matched a candidate. Higher is better; the strategy
|
||||
* tier contributes a base weight so that stronger match kinds rank first even
|
||||
* when character counts are equal.
|
||||
*/
|
||||
enum class MatchTier {
|
||||
None = 0,
|
||||
Typo = 1, // bounded edit distance (<=1) subsequence
|
||||
Subsequence = 2, // in-order subsequence, like KFuzzyMatcher
|
||||
Initials = 3, // matched word-start characters (R S -> Rename Symbol)
|
||||
Substring = 4, // contiguous substring
|
||||
Exact = 5, // candidate equals needle
|
||||
};
|
||||
|
||||
class FuzzyRanker
|
||||
{
|
||||
public:
|
||||
/*!
|
||||
* Split \a query into lowercased needles on whitespace. Empty query yields
|
||||
* no needles (every candidate then matches with score 0).
|
||||
*/
|
||||
static QList<QString> tokenize(QStringView query);
|
||||
|
||||
/*!
|
||||
* Score \a candidate against the already-tokenized \a needles. All needles
|
||||
* must match (AND semantics) for \c matched to be true. Order of needles
|
||||
* relative to the candidate is irrelevant (orderless).
|
||||
*/
|
||||
static MatchResult score(QStringView candidate, const QList<QString> &needles);
|
||||
|
||||
/*! Convenience: tokenize \a query then score. */
|
||||
static MatchResult score(QStringView candidate, QStringView query);
|
||||
};
|
||||
|
||||
} // namespace katecustom
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
/*
|
||||
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||
*/
|
||||
#include "fuzzyranker.h"
|
||||
|
||||
#include <QTest>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
using namespace katecustom;
|
||||
|
||||
namespace
|
||||
{
|
||||
// Rank a list of candidates against a query, return the matching names
|
||||
// highest-score-first.
|
||||
QStringList rank(const QStringList &candidates, const QString &query)
|
||||
{
|
||||
const QList<QString> needles = FuzzyRanker::tokenize(query);
|
||||
struct Scored {
|
||||
QString name;
|
||||
int score;
|
||||
};
|
||||
QList<Scored> hits;
|
||||
for (const QString &c : candidates) {
|
||||
const MatchResult r = FuzzyRanker::score(c, needles);
|
||||
if (r.matched) {
|
||||
hits.push_back({c, r.score});
|
||||
}
|
||||
}
|
||||
std::stable_sort(hits.begin(), hits.end(),
|
||||
[](const Scored &a, const Scored &b) { return a.score > b.score; });
|
||||
QStringList out;
|
||||
for (const Scored &h : hits) {
|
||||
out << h.name;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
class TestFuzzyRanker : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private Q_SLOTS:
|
||||
|
||||
// --- orderless: space-separated tokens match in any order --------------
|
||||
void orderless()
|
||||
{
|
||||
const QStringList cmds = {
|
||||
QStringLiteral("Rename Symbol"),
|
||||
QStringLiteral("Symbol Rename Cursor"),
|
||||
QStringLiteral("Open File"),
|
||||
QStringLiteral("Reindent Lines"),
|
||||
};
|
||||
const QStringList got = rank(cmds, QStringLiteral("ren sym"));
|
||||
QVERIFY2(got.contains(QStringLiteral("Rename Symbol")),
|
||||
"orderless must match 'Rename Symbol'");
|
||||
QVERIFY2(got.contains(QStringLiteral("Symbol Rename Cursor")),
|
||||
"orderless must match tokens in reversed order too");
|
||||
QVERIFY2(!got.contains(QStringLiteral("Open File")), "non-matches rejected");
|
||||
}
|
||||
|
||||
void orderlessTokenOrderIrrelevant()
|
||||
{
|
||||
const QString cand = QStringLiteral("Toggle Comment");
|
||||
QVERIFY(FuzzyRanker::score(cand, QStringLiteral("tog com")).matched);
|
||||
QVERIFY(FuzzyRanker::score(cand, QStringLiteral("com tog")).matched);
|
||||
}
|
||||
|
||||
// --- typo tolerance: KFuzzyMatcher explicitly fails this ---------------
|
||||
void typoTolerance()
|
||||
{
|
||||
// "gti" is a transposition of "git"; strict subsequence fails it.
|
||||
QVERIFY2(FuzzyRanker::score(QStringLiteral("git"), QStringLiteral("gti")).matched,
|
||||
"typo 'gti' must match 'git'");
|
||||
QVERIFY2(FuzzyRanker::score(QStringLiteral("Git Blame"), QStringLiteral("gti")).matched,
|
||||
"typo 'gti' must match within 'Git Blame'");
|
||||
}
|
||||
|
||||
void inOrderStillWins()
|
||||
{
|
||||
// Exact-order "git" should outrank the typo "gti" against same target.
|
||||
const MatchResult good = FuzzyRanker::score(QStringLiteral("git"), QStringLiteral("git"));
|
||||
const MatchResult typo = FuzzyRanker::score(QStringLiteral("git"), QStringLiteral("gti"));
|
||||
QVERIFY(good.matched && typo.matched);
|
||||
QVERIFY2(good.score > typo.score, "exact order must score higher than typo");
|
||||
}
|
||||
|
||||
// --- ranking: better match kinds first ---------------------------------
|
||||
void substringBeatsSubsequence()
|
||||
{
|
||||
const QStringList cmds = {
|
||||
QStringLiteral("Reformat Document"), // 'r','e','f' subsequence
|
||||
QStringLiteral("Refactor"), // 'ref' substring at start
|
||||
};
|
||||
const QStringList got = rank(cmds, QStringLiteral("ref"));
|
||||
QCOMPARE(got.first(), QStringLiteral("Refactor"));
|
||||
}
|
||||
|
||||
void initialsMatch()
|
||||
{
|
||||
// "rs" should match the word initials of "Rename Symbol".
|
||||
const MatchResult r = FuzzyRanker::score(QStringLiteral("Rename Symbol"),
|
||||
QStringLiteral("rs"));
|
||||
QVERIFY(r.matched);
|
||||
}
|
||||
|
||||
void shorterCandidatePreferred()
|
||||
{
|
||||
const QStringList cmds = {
|
||||
QStringLiteral("sort lines by column descending"),
|
||||
QStringLiteral("sort"),
|
||||
};
|
||||
const QStringList got = rank(cmds, QStringLiteral("sort"));
|
||||
QCOMPARE(got.first(), QStringLiteral("sort"));
|
||||
}
|
||||
|
||||
// --- AND semantics: every needle must match ----------------------------
|
||||
void andSemantics()
|
||||
{
|
||||
QVERIFY(!FuzzyRanker::score(QStringLiteral("Rename Symbol"),
|
||||
QStringLiteral("ren xyz")).matched);
|
||||
}
|
||||
|
||||
// --- empty query matches everything ------------------------------------
|
||||
void emptyQueryMatchesAll()
|
||||
{
|
||||
const MatchResult r = FuzzyRanker::score(QStringLiteral("anything"), QStringLiteral(""));
|
||||
QVERIFY(r.matched);
|
||||
QVERIFY(r.ranges.empty());
|
||||
}
|
||||
|
||||
// --- highlight ranges are correct & merged -----------------------------
|
||||
void highlightRangesContiguous()
|
||||
{
|
||||
const MatchResult r = FuzzyRanker::score(QStringLiteral("Refactor"),
|
||||
QStringLiteral("ref"));
|
||||
QCOMPARE(r.ranges.size(), size_t(1));
|
||||
QCOMPARE(r.ranges.front().start, 0);
|
||||
QCOMPARE(r.ranges.front().length, 3);
|
||||
}
|
||||
|
||||
void highlightRangesSplit()
|
||||
{
|
||||
// Subsequence 'rf' in "Refactor": R(0), e(1), f(2) -> R at 0, f at 2,
|
||||
// i.e. two non-contiguous single-char ranges.
|
||||
const MatchResult r = FuzzyRanker::score(QStringLiteral("Refactor"),
|
||||
QStringLiteral("rf"));
|
||||
QVERIFY(r.matched);
|
||||
QCOMPARE(r.ranges.size(), size_t(2));
|
||||
QCOMPARE(r.ranges[0].start, 0);
|
||||
QCOMPARE(r.ranges[0].length, 1);
|
||||
QCOMPARE(r.ranges[1].start, 2);
|
||||
QCOMPARE(r.ranges[1].length, 1);
|
||||
}
|
||||
|
||||
// --- case insensitivity -------------------------------------------------
|
||||
void caseInsensitive()
|
||||
{
|
||||
QVERIFY(FuzzyRanker::score(QStringLiteral("Open File"),
|
||||
QStringLiteral("OPEN")).matched);
|
||||
QVERIFY(FuzzyRanker::score(QStringLiteral("OPEN FILE"),
|
||||
QStringLiteral("open")).matched);
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_APPLESS_MAIN(TestFuzzyRanker)
|
||||
#include "test_fuzzyranker.moc"
|
||||
Loading…
Reference in New Issue