From 217d042a1b5cf33949808b4efdba2cddb93c1056 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 7 Oct 2026 17:54:20 +0200 Subject: [PATCH] M5: :-verb command pack (sort/case/base64/rot13/uuid/pipe) Add src/commands/: texttransforms (pure, unit-tested string transforms) and olliecommands (a KTextEditor::Command registering sort, rsort, case, b64enc, b64dec, rot13, uuid, pipe). Verbs act on the selection in place, or the whole document when nothing is selected. 'pipe ' runs sh -c with the selection on stdin and replaces it with stdout (the user types the command, so shell execution is intended). The command object is created once by OlliePlugin and auto-registers with the global editor. 15 transform unit tests; 6 ctest suites green overall. Verbs are reachable from Kate's command line; surfacing them in the M-x palette is a follow-up (they are Commands, not QActions). --- CMakeLists.txt | 1 + README.md | 4 + docs/PLAN.md | 20 ++- src/CMakeLists.txt | 3 + src/commands/CMakeLists.txt | 18 +++ src/commands/olliecommands.cpp | 191 +++++++++++++++++++++++++++ src/commands/olliecommands.h | 42 ++++++ src/commands/test_texttransforms.cpp | 106 +++++++++++++++ src/commands/texttransforms.cpp | 173 ++++++++++++++++++++++++ src/commands/texttransforms.h | 54 ++++++++ src/plugin/CMakeLists.txt | 1 + src/plugin/ollieplugin.cpp | 3 + src/plugin/ollieplugin.h | 4 + 13 files changed, 613 insertions(+), 7 deletions(-) create mode 100644 src/commands/CMakeLists.txt create mode 100644 src/commands/olliecommands.cpp create mode 100644 src/commands/olliecommands.h create mode 100644 src/commands/test_texttransforms.cpp create mode 100644 src/commands/texttransforms.cpp create mode 100644 src/commands/texttransforms.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 9a375dd..79eb2b6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,6 +20,7 @@ set(CMAKE_POSITION_INDEPENDENT_CODE ON) find_package(Qt6 ${QT_MIN_VERSION} REQUIRED COMPONENTS Core Gui Widgets) find_package(Qt6 ${QT_MIN_VERSION} COMPONENTS Test) +find_package(KF6 ${KF_MIN_VERSION} COMPONENTS TextEditor XmlGui CoreAddons I18n) enable_testing() diff --git a/README.md b/README.md index ffa29a2..388ed1d 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,10 @@ src/ radialconfig.{h,cpp} JSON → radial tree + built-in default test_radialmodel.cpp QTest suite test_radialconfig.cpp QTest suite + commands/ + texttransforms.{h,cpp} pure text transforms (headless) + olliecommands.{h,cpp} ":"-verb KTextEditor::Command pack + test_texttransforms.cpp QTest suite plugin/ ollieplugin.{h,cpp} KTextEditor::Plugin + M-x + radial wiring ollieplugin.json plugin metadata diff --git a/docs/PLAN.md b/docs/PLAN.md index ae5a922..880ea03 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -170,13 +170,19 @@ Therefore the palette is replaced, not extended, and the replacement is built on these combos (so Kate's shortcut never fires) and acts on the `KeyPress`, consuming it — no registered shortcut, so no ambiguity, no per-view sweeping. -### M5 — Command vocabulary + project model + switchers — NEXT -- `:`-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. +### M5 — Command vocabulary + project model + switchers — IN PROGRESS +- `:`-verb pack — DONE. `src/commands/`: `texttransforms.{h,cpp}` (pure, + unit-tested: sort/rsort, case upper|lower|title|snake|camel|kebab, base64 + enc/dec, rot13, uuid) + `olliecommands.{h,cpp}` (a `KTextEditor::Command` + registering `sort`, `rsort`, `case`, `b64enc`, `b64dec`, `rot13`, `uuid`, + `pipe`). Verbs transform the selection in place (or the whole document when + nothing is selected); `pipe ` runs `sh -c ` with the selection + on stdin and replaces it with stdout. 15 transform tests. The command object + is created once by `OlliePlugin` (auto-registers globally). + - Reachable from Kate's command line today; surfacing the verbs in the M-x + palette (they are `Command`s, not `QAction`s) is a follow-up. +- Project = directory ("open folder == open project"): TODO. +- Fuzzy file / symbol switchers scoped to the folder: TODO. ## Build and test diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b641fa2..f4f2215 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,4 +1,7 @@ add_subdirectory(fuzzy) add_subdirectory(palette) add_subdirectory(radial) +if(KF6TextEditor_FOUND) + add_subdirectory(commands) +endif() add_subdirectory(plugin) diff --git a/src/commands/CMakeLists.txt b/src/commands/CMakeLists.txt new file mode 100644 index 0000000..a7ef504 --- /dev/null +++ b/src/commands/CMakeLists.txt @@ -0,0 +1,18 @@ +add_library(olliecommands_lib STATIC + texttransforms.cpp + texttransforms.h + olliecommands.cpp + olliecommands.h +) +target_link_libraries(olliecommands_lib PUBLIC + Qt6::Core + KF6::TextEditor + KF6::I18n +) +target_include_directories(olliecommands_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + +if(Qt6Test_FOUND) + add_executable(test_texttransforms test_texttransforms.cpp texttransforms.cpp texttransforms.h) + target_link_libraries(test_texttransforms PRIVATE Qt6::Core Qt6::Test) + add_test(NAME texttransforms COMMAND test_texttransforms) +endif() diff --git a/src/commands/olliecommands.cpp b/src/commands/olliecommands.cpp new file mode 100644 index 0000000..834d421 --- /dev/null +++ b/src/commands/olliecommands.cpp @@ -0,0 +1,191 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "olliecommands.h" + +#include "texttransforms.h" + +#include +#include + +#include + +#include +#include + +namespace katecustom +{ + +using namespace texttransforms; + +QStringList OllieCommands::supportedCmds() +{ + return { + QStringLiteral("sort"), + QStringLiteral("rsort"), + QStringLiteral("case"), + QStringLiteral("b64enc"), + QStringLiteral("b64dec"), + QStringLiteral("rot13"), + QStringLiteral("uuid"), + QStringLiteral("pipe"), + }; +} + +OllieCommands::OllieCommands(QObject *parent) + : KTextEditor::Command(supportedCmds(), parent) +{ +} + +namespace +{ +// The text the verb should operate on, and the range to replace. With a +// selection, that is the selection; otherwise the whole document. +struct Target { + QString text; + KTextEditor::Range range; +}; + +Target currentTarget(KTextEditor::View *view) +{ + if (view->selection()) { + return {view->selectionText(), view->selectionRange()}; + } + KTextEditor::Document *doc = view->document(); + return {doc->text(), doc->documentRange()}; +} + +void applyResult(KTextEditor::View *view, const Target &t, const QString &result) +{ + view->document()->replaceText(t.range, result); +} +} // namespace + +bool OllieCommands::exec(KTextEditor::View *view, const QString &cmd, QString &msg, + const KTextEditor::Range &) +{ + if (!view) { + return false; + } + + const QStringList parts = cmd.split(QLatin1Char(' '), Qt::SkipEmptyParts); + if (parts.isEmpty()) { + return false; + } + const QString verb = parts.at(0); + const QStringList args = parts.mid(1); + + // uuid inserts at the cursor; it does not need a target selection. + if (verb == QLatin1String("uuid")) { + view->document()->insertText(view->cursorPosition(), texttransforms::uuid()); + return true; + } + + const Target t = currentTarget(view); + + if (verb == QLatin1String("sort")) { + const bool unique = args.contains(QLatin1String("u")); + const bool ci = args.contains(QLatin1String("i")); + applyResult(view, t, sortLines(t.text, unique, ci)); + return true; + } + if (verb == QLatin1String("rsort")) { + applyResult(view, t, reverseLines(t.text)); + return true; + } + if (verb == QLatin1String("case")) { + if (args.isEmpty()) { + msg = i18n("case: need a style (upper|lower|title|snake|camel|kebab)"); + return false; + } + const QString s = args.at(0).toLower(); + CaseStyle style; + if (s == QLatin1String("upper")) { + style = CaseStyle::Upper; + } else if (s == QLatin1String("lower")) { + style = CaseStyle::Lower; + } else if (s == QLatin1String("title")) { + style = CaseStyle::Title; + } else if (s == QLatin1String("snake")) { + style = CaseStyle::Snake; + } else if (s == QLatin1String("camel")) { + style = CaseStyle::Camel; + } else if (s == QLatin1String("kebab")) { + style = CaseStyle::Kebab; + } else { + msg = i18n("case: unknown style '%1'", s); + return false; + } + applyResult(view, t, convertCase(t.text, style)); + return true; + } + if (verb == QLatin1String("b64enc")) { + applyResult(view, t, base64Encode(t.text)); + return true; + } + if (verb == QLatin1String("b64dec")) { + applyResult(view, t, base64Decode(t.text)); + return true; + } + if (verb == QLatin1String("rot13")) { + applyResult(view, t, rot13(t.text)); + return true; + } + if (verb == QLatin1String("pipe")) { + if (args.isEmpty()) { + msg = i18n("pipe: need a shell command"); + return false; + } + // The user typed the shell command explicitly, so running it through + // the shell is the intended behaviour. Feed the target text on stdin + // and replace it with the command's stdout. + const QString shellCmd = cmd.section(QLatin1Char(' '), 1); // keep spaces/quotes + QProcess proc; + proc.start(QStringLiteral("sh"), {QStringLiteral("-c"), shellCmd}); + if (!proc.waitForStarted(3000)) { + msg = i18n("pipe: failed to start shell"); + return false; + } + proc.write(t.text.toUtf8()); + proc.closeWriteChannel(); + if (!proc.waitForFinished(15000)) { + proc.kill(); + msg = i18n("pipe: command timed out"); + return false; + } + const QByteArray out = proc.readAllStandardOutput(); + applyResult(view, t, QString::fromUtf8(out)); + if (proc.exitStatus() != QProcess::NormalExit || proc.exitCode() != 0) { + msg = i18n("pipe: command exited with status %1", proc.exitCode()); + // Output already applied; report but do not fail the edit. + } + return true; + } + + return false; +} + +bool OllieCommands::help(KTextEditor::View *, const QString &cmd, QString &msg) +{ + const QString verb = cmd.section(QLatin1Char(' '), 0, 0); + if (verb == QLatin1String("sort")) { + msg = i18n("sort [u] [i] — sort selected lines; u=unique, i=case-insensitive"); + } else if (verb == QLatin1String("rsort")) { + msg = i18n("rsort — reverse the order of selected lines"); + } else if (verb == QLatin1String("case")) { + msg = i18n("case — convert selection case"); + } else if (verb == QLatin1String("b64enc") || verb == QLatin1String("b64dec")) { + msg = i18n("b64enc / b64dec — base64 encode or decode the selection"); + } else if (verb == QLatin1String("rot13")) { + msg = i18n("rot13 — ROT13 the selection"); + } else if (verb == QLatin1String("uuid")) { + msg = i18n("uuid — insert a random UUID at the cursor"); + } else if (verb == QLatin1String("pipe")) { + msg = i18n("pipe — replace selection with output of sh -c (selection on stdin)"); + } else { + return false; + } + return true; +} + +} // namespace katecustom diff --git a/src/commands/olliecommands.h b/src/commands/olliecommands.h new file mode 100644 index 0000000..43c4bcb --- /dev/null +++ b/src/commands/olliecommands.h @@ -0,0 +1,42 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * OllieCommands registers a small vocabulary of ":" command-line verbs with + * the editor. Each verb transforms the current selection in place (or the whole + * document when nothing is selected). Verbs: + * + * sort [u] [i] sort selected lines (u = unique, i = case-insensitive) + * rsort reverse the order of selected lines + * case