From 95bc9f7ed60d1063fb102a495bbec07968a66c84 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 7 Oct 2026 15:45:58 +0200 Subject: [PATCH] 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. --- .gitignore | 14 ++ CMakeLists.txt | 23 +++ README.md | 73 +++++++++ docs/PLAN.md | 91 +++++++++++ src/CMakeLists.txt | 1 + src/fuzzy/CMakeLists.txt | 13 ++ src/fuzzy/fuzzyranker.cpp | 277 +++++++++++++++++++++++++++++++++ src/fuzzy/fuzzyranker.h | 80 ++++++++++ src/fuzzy/test_fuzzyranker.cpp | 170 ++++++++++++++++++++ 9 files changed, 742 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 docs/PLAN.md create mode 100644 src/CMakeLists.txt create mode 100644 src/fuzzy/CMakeLists.txt create mode 100644 src/fuzzy/fuzzyranker.cpp create mode 100644 src/fuzzy/fuzzyranker.h create mode 100644 src/fuzzy/test_fuzzyranker.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..abb8202 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Build artifacts +/build/ +/prefix.sh + +# Editor / OS cruft +*.o +*.a +*.so +*.moc +moc_*.cpp +*~ +.DS_Store +compile_commands.json +.cache/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..6562ad8 --- /dev/null +++ b/CMakeLists.txt @@ -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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..64fb500 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..e156668 --- /dev/null +++ b/docs/PLAN.md @@ -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 `, 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. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt new file mode 100644 index 0000000..69f8470 --- /dev/null +++ b/src/CMakeLists.txt @@ -0,0 +1 @@ +add_subdirectory(fuzzy) diff --git a/src/fuzzy/CMakeLists.txt b/src/fuzzy/CMakeLists.txt new file mode 100644 index 0000000..46aad3a --- /dev/null +++ b/src/fuzzy/CMakeLists.txt @@ -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() diff --git a/src/fuzzy/fuzzyranker.cpp b/src/fuzzy/fuzzyranker.cpp new file mode 100644 index 0000000..451f28e --- /dev/null +++ b/src/fuzzy/fuzzyranker.cpp @@ -0,0 +1,277 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "fuzzyranker.h" + +#include + +#include +#include + +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 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 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 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 indicesToRanges(const std::set &idx) +{ + std::vector 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 FuzzyRanker::tokenize(QStringView query) +{ + QList needles; + const QList 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 &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 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(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 diff --git a/src/fuzzy/fuzzyranker.h b/src/fuzzy/fuzzyranker.h new file mode 100644 index 0000000..91de459 --- /dev/null +++ b/src/fuzzy/fuzzyranker.h @@ -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 +#include +#include + +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 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 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 &needles); + + /*! Convenience: tokenize \a query then score. */ + static MatchResult score(QStringView candidate, QStringView query); +}; + +} // namespace katecustom + +#endif diff --git a/src/fuzzy/test_fuzzyranker.cpp b/src/fuzzy/test_fuzzyranker.cpp new file mode 100644 index 0000000..9e5d8e2 --- /dev/null +++ b/src/fuzzy/test_fuzzyranker.cpp @@ -0,0 +1,170 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "fuzzyranker.h" + +#include +#include +#include + +#include + +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 needles = FuzzyRanker::tokenize(query); + struct Scored { + QString name; + int score; + }; + QList 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"