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 <shell>' runs sh -c <shell> 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).
This commit is contained in:
Levi Neely 2026-10-07 17:54:20 +02:00
parent bfbb32a95a
commit 217d042a1b
13 changed files with 613 additions and 7 deletions

View File

@ -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()

View File

@ -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

View File

@ -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 <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.
### 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 <shell>` runs `sh -c <shell>` 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

View File

@ -1,4 +1,7 @@
add_subdirectory(fuzzy)
add_subdirectory(palette)
add_subdirectory(radial)
if(KF6TextEditor_FOUND)
add_subdirectory(commands)
endif()
add_subdirectory(plugin)

View File

@ -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()

View File

@ -0,0 +1,191 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "olliecommands.h"
#include "texttransforms.h"
#include <KTextEditor/Document>
#include <KTextEditor/View>
#include <KLocalizedString>
#include <QProcess>
#include <QStringList>
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 <upper|lower|title|snake|camel|kebab> — 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 <shell> — replace selection with output of sh -c <shell> (selection on stdin)");
} else {
return false;
}
return true;
}
} // namespace katecustom

View File

@ -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 <style> upper|lower|title|snake|camel|kebab
* b64enc / b64dec base64 encode / decode
* rot13 ROT13
* uuid insert a random UUID at the cursor
* pipe <shell> replace the selection with the output of `sh -c <shell>`,
* feeding the selection on stdin
*/
#ifndef KATECUSTOM_OLLIECOMMANDS_H
#define KATECUSTOM_OLLIECOMMANDS_H
#include <KTextEditor/Command>
namespace katecustom
{
class OllieCommands : public KTextEditor::Command
{
Q_OBJECT
public:
explicit OllieCommands(QObject *parent = nullptr);
bool exec(KTextEditor::View *view, const QString &cmd, QString &msg,
const KTextEditor::Range &range = KTextEditor::Range::invalid()) override;
bool help(KTextEditor::View *view, const QString &cmd, QString &msg) override;
private:
static QStringList supportedCmds();
};
} // namespace katecustom
#endif

View File

@ -0,0 +1,106 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "texttransforms.h"
#include <QTest>
#include <QRegularExpression>
using namespace katecustom;
using namespace katecustom::texttransforms;
class TestTextTransforms : public QObject
{
Q_OBJECT
private Q_SLOTS:
void sortBasic()
{
QCOMPARE(sortLines(QStringLiteral("banana\napple\ncherry")),
QStringLiteral("apple\nbanana\ncherry"));
}
void sortPreservesTrailingNewline()
{
QCOMPARE(sortLines(QStringLiteral("b\na\n")), QStringLiteral("a\nb\n"));
}
void sortUnique()
{
QCOMPARE(sortLines(QStringLiteral("b\na\nb\na"), /*unique=*/true),
QStringLiteral("a\nb"));
}
void sortCaseInsensitive()
{
QCOMPARE(sortLines(QStringLiteral("B\na\nC"), false, /*ci=*/true),
QStringLiteral("a\nB\nC"));
}
void reverse()
{
QCOMPARE(reverseLines(QStringLiteral("one\ntwo\nthree")),
QStringLiteral("three\ntwo\none"));
}
void caseUpperLower()
{
QCOMPARE(convertCase(QStringLiteral("Hello"), CaseStyle::Upper), QStringLiteral("HELLO"));
QCOMPARE(convertCase(QStringLiteral("Hello"), CaseStyle::Lower), QStringLiteral("hello"));
}
void caseTitle()
{
QCOMPARE(convertCase(QStringLiteral("hello wORLD foo"), CaseStyle::Title),
QStringLiteral("Hello World Foo"));
}
void caseSnakeFromCamel()
{
QCOMPARE(convertCase(QStringLiteral("helloWorldFoo"), CaseStyle::Snake),
QStringLiteral("hello_world_foo"));
}
void caseKebabFromSpaces()
{
QCOMPARE(convertCase(QStringLiteral("Hello World Foo"), CaseStyle::Kebab),
QStringLiteral("hello-world-foo"));
}
void caseCamelFromSnake()
{
QCOMPARE(convertCase(QStringLiteral("hello_world_foo"), CaseStyle::Camel),
QStringLiteral("helloWorldFoo"));
}
void base64RoundTrip()
{
const QString s = QStringLiteral("Hello, 世界!");
QCOMPARE(base64Decode(base64Encode(s)), s);
}
void base64KnownVector()
{
QCOMPARE(base64Encode(QStringLiteral("hello")), QStringLiteral("aGVsbG8="));
QCOMPARE(base64Decode(QStringLiteral("aGVsbG8=")), QStringLiteral("hello"));
}
void rot13Involution()
{
const QString s = QStringLiteral("Why did the chicken? 123");
QCOMPARE(rot13(rot13(s)), s);
QCOMPARE(rot13(QStringLiteral("abc XYZ")), QStringLiteral("nop KLM"));
}
void uuidShape()
{
const QString u = uuid();
QRegularExpression re(QStringLiteral(
"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"));
QVERIFY2(re.match(u).hasMatch(), qPrintable(u));
}
};
QTEST_APPLESS_MAIN(TestTextTransforms)
#include "test_texttransforms.moc"

View File

@ -0,0 +1,173 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "texttransforms.h"
#include <QByteArray>
#include <QChar>
#include <QStringList>
#include <QUuid>
#include <algorithm>
namespace katecustom
{
namespace texttransforms
{
namespace
{
// Split into lines while remembering whether the text ended with a newline, so
// the transform can restore it. Uses '\n' as the separator (Kate normalises
// line endings to '\n' in document text).
QStringList splitLines(const QString &text, bool &trailingNewline)
{
trailingNewline = text.endsWith(QLatin1Char('\n'));
QString body = text;
if (trailingNewline) {
body.chop(1);
}
return body.split(QLatin1Char('\n'));
}
QString joinLines(const QStringList &lines, bool trailingNewline)
{
QString out = lines.join(QLatin1Char('\n'));
if (trailingNewline) {
out.append(QLatin1Char('\n'));
}
return out;
}
// Split an identifier-ish string into words for case conversion: a word is a
// run of letters/digits; camelCase humps split; everything else separates.
QStringList words(const QString &text)
{
QStringList out;
QString cur;
for (int i = 0; i < text.size(); ++i) {
const QChar c = text.at(i);
const bool isWordChar = c.isLetterOrNumber();
if (!isWordChar) {
if (!cur.isEmpty()) {
out << cur;
cur.clear();
}
continue;
}
// camelCase hump: lower/digit -> upper starts a new word.
if (!cur.isEmpty() && c.isUpper() && !cur.at(cur.size() - 1).isUpper()) {
out << cur;
cur.clear();
}
cur.append(c);
}
if (!cur.isEmpty()) {
out << cur;
}
return out;
}
} // namespace
QString sortLines(const QString &text, bool unique, bool caseInsensitive)
{
bool trailing = false;
QStringList lines = splitLines(text, trailing);
std::stable_sort(lines.begin(), lines.end(), [caseInsensitive](const QString &a, const QString &b) {
return a.compare(b, caseInsensitive ? Qt::CaseInsensitive : Qt::CaseSensitive) < 0;
});
if (unique) {
auto eq = [caseInsensitive](const QString &a, const QString &b) {
return a.compare(b, caseInsensitive ? Qt::CaseInsensitive : Qt::CaseSensitive) == 0;
};
lines.erase(std::unique(lines.begin(), lines.end(), eq), lines.end());
}
return joinLines(lines, trailing);
}
QString reverseLines(const QString &text)
{
bool trailing = false;
QStringList lines = splitLines(text, trailing);
std::reverse(lines.begin(), lines.end());
return joinLines(lines, trailing);
}
QString convertCase(const QString &text, CaseStyle style)
{
switch (style) {
case CaseStyle::Upper:
return text.toUpper();
case CaseStyle::Lower:
return text.toLower();
case CaseStyle::Title: {
QString out = text;
bool atWordStart = true;
for (int i = 0; i < out.size(); ++i) {
const QChar c = out.at(i);
if (c.isLetterOrNumber()) {
out[i] = atWordStart ? c.toUpper() : c.toLower();
atWordStart = false;
} else {
atWordStart = true;
}
}
return out;
}
case CaseStyle::Snake:
case CaseStyle::Kebab: {
const QChar sep = (style == CaseStyle::Snake) ? QLatin1Char('_') : QLatin1Char('-');
QStringList ws = words(text);
for (QString &w : ws) {
w = w.toLower();
}
return ws.join(sep);
}
case CaseStyle::Camel: {
const QStringList ws = words(text);
QString out;
for (int i = 0; i < ws.size(); ++i) {
QString w = ws.at(i).toLower();
if (i > 0 && !w.isEmpty()) {
w[0] = w.at(0).toUpper();
}
out += w;
}
return out;
}
}
return text;
}
QString base64Encode(const QString &text)
{
return QString::fromLatin1(text.toUtf8().toBase64());
}
QString base64Decode(const QString &text)
{
const QByteArray decoded = QByteArray::fromBase64(text.toLatin1());
return QString::fromUtf8(decoded);
}
QString rot13(const QString &text)
{
QString out = text;
for (int i = 0; i < out.size(); ++i) {
const QChar c = out.at(i);
if (c >= QLatin1Char('a') && c <= QLatin1Char('z')) {
out[i] = QChar(QLatin1Char('a').unicode() + (c.unicode() - QLatin1Char('a').unicode() + 13) % 26);
} else if (c >= QLatin1Char('A') && c <= QLatin1Char('Z')) {
out[i] = QChar(QLatin1Char('A').unicode() + (c.unicode() - QLatin1Char('A').unicode() + 13) % 26);
}
}
return out;
}
QString uuid()
{
return QUuid::createUuid().toString(QUuid::WithoutBraces);
}
} // namespace texttransforms
} // namespace katecustom

View File

@ -0,0 +1,54 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* Pure text transforms used by the Ollie ":" command verbs. Kept free of any
* KTextEditor dependency so they are unit testable in isolation; the command
* glue in olliecommands.cpp feeds them the selection and writes back the result.
*/
#ifndef KATECUSTOM_TEXTTRANSFORMS_H
#define KATECUSTOM_TEXTTRANSFORMS_H
#include <QString>
namespace katecustom
{
enum class CaseStyle {
Upper,
Lower,
Title, // Each Word Capitalised
Snake, // snake_case
Camel, // camelCase
Kebab, // kebab-case
};
namespace texttransforms
{
/*! Sort the lines of \a text. If \a unique, drop adjacent duplicates after
* sorting. A trailing newline in the input is preserved in the output. */
QString sortLines(const QString &text, bool unique = false, bool caseInsensitive = false);
/*! Reverse the order of the lines of \a text. */
QString reverseLines(const QString &text);
/*! Convert \a text to the requested case style. Word splitting for the
* programmer styles (snake/camel/kebab) treats any run of letters/digits as a
* word and everything else as a separator; Title/Upper/Lower keep separators. */
QString convertCase(const QString &text, CaseStyle style);
/*! Base64 encode / decode (UTF-8). Decode returns the original on invalid
* input only if it cannot be decoded at all; otherwise best-effort. */
QString base64Encode(const QString &text);
QString base64Decode(const QString &text);
/*! ROT13 letters, leave everything else untouched. */
QString rot13(const QString &text);
/*! A random version-4 UUID in 8-4-4-4-12 lowercase hex form, no braces. */
QString uuid();
} // namespace texttransforms
} // namespace katecustom
#endif

View File

@ -18,6 +18,7 @@ kcoreaddons_add_plugin(olliepalette
target_link_libraries(olliepalette PRIVATE
palette
radial
olliecommands_lib
KF6::TextEditor
KF6::XmlGui
KF6::CoreAddons

View File

@ -6,6 +6,7 @@
#include "palettewidget.h"
#include "radialmenu.h"
#include "radialconfig.h"
#include "olliecommands.h"
#include <KTextEditor/MainWindow>
#include <KTextEditor/View>
@ -130,6 +131,8 @@ OlliePlugin::OlliePlugin(QObject *parent, const QVariantList &args)
: KTextEditor::Plugin(parent)
{
Q_UNUSED(args);
// The ":"-verb pack registers itself with the global editor on construction.
m_commands = new OllieCommands(this);
}
OlliePlugin::~OlliePlugin() = default;

View File

@ -32,6 +32,7 @@ namespace katecustom
class PaletteWidget;
class RadialMenu;
class OllieCommands;
struct RadialDef;
class OlliePlugin : public KTextEditor::Plugin
@ -43,6 +44,9 @@ public:
~OlliePlugin() override;
QObject *createView(KTextEditor::MainWindow *mainWindow) override;
private:
OllieCommands *m_commands = nullptr; // ":"-verb pack, registered once globally
};
class OllieView : public QObject