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