sam: extract into standalone [deft:nineify] sam plugin (deft_sam.so)

Stage 2a of the multi-.so split. sam becomes its own KTextEditor plugin:
new src/sam/samplugin.{h,cpp} (SamPlugin/SamView) + samplugin.json
("[deft:nineify] sam"), built as deft_sam.so. The sam tool-view wiring and
the runSamProgram/applySamToDocument/runSamFileLoop glue move out of
OllieView into SamView; sampanel.{h,cpp} move from src/plugin to src/sam.

The core deft plugin no longer links sam_lib or owns the sam panel.
This commit is contained in:
Levi Neely 2026-10-08 19:20:35 +02:00
parent d96cb63baf
commit debe1226f7
9 changed files with 327 additions and 183 deletions

View File

@ -24,7 +24,7 @@ if(PkgConfig_FOUND)
pkg_check_modules(WAYLAND_PROTOCOLS QUIET wayland-protocols)
endif()
set(OLLIE_PLUGIN_SOURCES ollieplugin.cpp ollieplugin.h sampanel.cpp sampanel.h waylandcursorwarp.h)
set(OLLIE_PLUGIN_SOURCES ollieplugin.cpp ollieplugin.h waylandcursorwarp.h)
set(OLLIE_WAYLAND_PROTO_SRCS "")
if(WaylandScanner_FOUND AND WAYLAND_CLIENT_FOUND AND Qt6GuiPrivate_FOUND)
@ -73,7 +73,6 @@ target_link_libraries(deft PRIVATE
olliecommands_lib
project_lib
plumb_lib
sam_lib
KF6::TextEditor
KF6::XmlGui
KF6::CoreAddons

View File

@ -11,8 +11,6 @@
#include "projectindex.h"
#include "symbolindex.h"
#include "plumbresolve.h"
#include "sampanel.h"
#include "samengine.h"
#include <KTextEditor/Application>
#include <KTextEditor/Command>
@ -47,7 +45,6 @@
#include <QStandardPaths>
#include <QTimer>
#include <QUrl>
#include <QVBoxLayout>
#include <QWidget>
#include <QWindow>
@ -417,27 +414,6 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow)
});
m_plumber.startReader();
// sam tool view: a dockable panel to run sam structural-regexp commands
// against the active document (or project-wide via X/Y). Created on the
// left sidebar; the host owns the returned widget.
m_samToolView = m_mainWindow->createToolView(
plugin, QStringLiteral("ollie_sam"), KTextEditor::MainWindow::Left,
QIcon::fromTheme(QStringLiteral("edit-find-replace")), i18n("Sam"));
if (m_samToolView) {
m_samPanel = new SamPanel(m_samToolView);
// The tool view returned by Kate (KateMDI::ToolView) already installs
// its own layout; creating a second one here is silently rejected by Qt
// and leaves SamPanel un-managed (tiny, pinned top-left). Add to the
// existing layout when there is one, otherwise create it ourselves.
QLayout *tvLayout = m_samToolView->layout();
if (!tvLayout) {
tvLayout = new QVBoxLayout(m_samToolView);
tvLayout->setContentsMargins(0, 0, 0, 0);
}
tvLayout->addWidget(m_samPanel);
connect(m_samPanel, &SamPanel::runRequested, this, &OllieView::runSamProgram);
}
// Build on Kate's project plugin: attach to its view now (if present) and
// follow it as plugins come and go. The bridge is read-only; the switchers
// scope to its project when one is loaded, else to VCS discovery.
@ -476,7 +452,6 @@ OllieView::~OllieView()
delete m_filePalette;
delete m_symbolPalette;
delete m_radial;
delete m_samToolView; // deletes the embedded SamPanel with it
}
QList<OllieView::ActionEntry> OllieView::collectActions() const
@ -1045,145 +1020,6 @@ void OllieView::onPlumbEdit(const QString &file, const QString &addr, const QStr
}
}
bool OllieView::applySamToDocument(KTextEditor::Document *doc, const QString &program,
int dotStart, int dotEnd, QString *output, QString *error)
{
const QString text = doc->text();
const SamResult res = SamEngine::run(program, text, dotStart, dotEnd);
if (!res.ok) {
if (error) {
*error = res.error;
}
return false;
}
if (output) {
*output = res.output;
}
// Apply all edits inside one transaction so a single undo reverts the whole
// Run. Edits are in ORIGINAL-text offsets, non-overlapping and sorted by
// start; apply back-to-front so earlier offsets remain valid.
if (!res.edits.isEmpty()) {
KTextEditor::Document::EditingTransaction transaction(doc);
for (int i = res.edits.size() - 1; i >= 0; i--) {
const SamEdit &e = res.edits[i];
const KTextEditor::Cursor from = doc->offsetToCursor(e.start);
const KTextEditor::Cursor to = doc->offsetToCursor(e.end);
doc->replaceText(KTextEditor::Range(from, to), e.text);
}
}
return true;
}
void OllieView::runSamProgram(const QString &program)
{
if (!m_samPanel) {
return;
}
const QString trimmed = program.trimmed();
if (trimmed.isEmpty()) {
return;
}
// Multi-file X/Y: drive the sweep over the project's files.
const SamEngine::FileLoop fl = SamEngine::peelFileLoop(trimmed);
if (fl.matched) {
runSamFileLoop(fl.negate, fl.fileRegex, fl.inner);
return;
}
// Single-file: run against the active document.
KTextEditor::View *view = m_mainWindow->activeView();
if (!view || !view->document()) {
m_samPanel->showError(i18n("No active document."));
return;
}
KTextEditor::Document *doc = view->document();
// Dot = current selection, else the whole document (sam's common idioms use
// explicit ranges like `,` anyway; whole-doc is a sensible default dot).
int dotStart = 0;
int dotEnd = doc->text().size();
if (view->selection()) {
const KTextEditor::Range sel = view->selectionRange();
dotStart = int(doc->cursorToOffset(sel.start()));
dotEnd = int(doc->cursorToOffset(sel.end()));
}
QString output;
QString error;
if (!applySamToDocument(doc, program, dotStart, dotEnd, &output, &error)) {
m_samPanel->showError(error);
return;
}
if (!output.isEmpty()) {
m_samPanel->showOutput(output);
} else {
m_samPanel->showOutput(i18n("(done)"));
}
}
void OllieView::runSamFileLoop(bool negate, const QString &fileRegex, const QString &inner)
{
if (!m_samPanel) {
return;
}
if (inner.trimmed().isEmpty()) {
m_samPanel->showError(i18n("X/Y: no command to run on matched buffers."));
return;
}
QRegularExpression fileRe(fileRegex);
if (!fileRe.isValid()) {
m_samPanel->showError(i18n("X/Y: bad file regexp: %1", fileRe.errorString()));
return;
}
// X/Y iterate the set of OPEN buffers (like acme's open-window set), not the
// whole project on disk. Nothing is opened or read from disk: we edit the
// documents Kate already has loaded, in place, each as its own undo step.
KTextEditor::Editor *editor = KTextEditor::Editor::instance();
KTextEditor::Application *app = editor ? editor->application() : nullptr;
const QList<KTextEditor::Document *> docs =
app ? app->documents() : QList<KTextEditor::Document *>{};
if (docs.isEmpty()) {
m_samPanel->showError(i18n("X/Y: no open buffers."));
return;
}
int matched = 0;
int changed = 0;
for (KTextEditor::Document *doc : docs) {
if (!doc) {
continue;
}
// Match the regex against the buffer's path (its local file path, or its
// display name for an unsaved scratch buffer).
const QUrl url = doc->url();
const QString target =
url.isLocalFile() ? url.toLocalFile() : doc->documentName();
const bool hit = fileRe.match(target).hasMatch();
if (hit == negate) {
continue; // X runs on hits; Y runs on misses
}
matched++;
QString output;
QString error;
const int dotEnd = doc->text().size();
if (applySamToDocument(doc, inner, 0, dotEnd, &output, &error)) {
changed++;
if (!output.isEmpty()) {
m_samPanel->showOutput(QStringLiteral("%1:\n%2").arg(target, output));
}
} else {
// A "no match" in a given buffer is normal during a sweep; report it
// quietly rather than as a hard error.
m_samPanel->showOutput(QStringLiteral("%1: %2").arg(target, error));
}
}
m_samPanel->showOutput(
i18n("X/Y done: %1 open buffer(s) matched, %2 changed.", matched, changed));
}
QAction *OllieView::findActionByName(const QString &nameOrText) const
{
KXMLGUIFactory *factory = m_mainWindow->guiFactory();

View File

@ -18,7 +18,6 @@
#include "projectindexer.h"
#include "palettemodel.h" // PaletteItem
#include "plumber.h"
#include "sampanel.h"
#include "waylandcursorwarp.h"
#include <QObject>
@ -81,10 +80,6 @@ private Q_SLOTS:
// addr (a plan9 edit-port address). Routed from the Plumber reader thread.
void onPlumbEdit(const QString &file, const QString &addr, const QString &wdir);
// Run a sam program from the panel. Dispatches to the multi-file X/Y driver
// when the program begins with X or Y, else runs against the active doc.
void runSamProgram(const QString &program);
private:
// Record one activation of \a id and persist. Feeds the frecency bonus so
// habitual choices float up in every palette.
@ -149,16 +144,6 @@ private:
// matching pair (multi-line aware). Returns true if a pair was selected.
bool selectBracketPairAt(KTextEditor::View *view, const QPoint &globalPos);
// Apply a sam program to a single document, returning the engine result.
// Edits are applied inside one EditingTransaction (a single undo step).
// \a doc must be non-null. Does not touch the panel.
bool applySamToDocument(KTextEditor::Document *doc, const QString &program,
int dotStart, int dotEnd, QString *output, QString *error);
// Drive a multi-file X/Y sweep over the project's files, opening each match
// and applying \a inner. Reports progress/errors to the panel.
void runSamFileLoop(bool negate, const QString &fileRegex, const QString &inner);
KTextEditor::MainWindow *m_mainWindow;
PaletteWidget *m_palette = nullptr;
PaletteWidget *m_filePalette = nullptr; // project "Go to File" switcher
@ -178,8 +163,6 @@ private:
ProjectIndexer m_indexer; // async, cached file/symbol indexing
WaylandCursorWarp m_cursorWarp; // pointer warp on Wayland (no-op on X11)
Plumber m_plumber; // plan9 plumbing client (send + edit-port reader)
SamPanel *m_samPanel = nullptr; // sam command tool view (owned by the host)
QWidget *m_samToolView = nullptr; // the dockable container from createToolView
};
} // namespace deft

View File

@ -24,3 +24,20 @@ if(Qt6Test_FOUND)
target_link_libraries(test_samregex PRIVATE sam_lib Qt6::Test)
add_test(NAME samregex COMMAND test_samregex)
endif()
# --- [deft:nineify] sam — the Kate plugin ----------------------------------
# The sam engine (sam_lib) is pure and always built. The plugin layer (tool
# view + active-document glue) needs KF6::TextEditor; build it only when found.
if(KF6TextEditor_FOUND)
kcoreaddons_add_plugin(deft_sam
SOURCES samplugin.cpp samplugin.h sampanel.cpp sampanel.h
INSTALL_NAMESPACE "kf6/ktexteditor"
)
target_link_libraries(deft_sam PRIVATE
sam_lib
KF6::TextEditor
KF6::CoreAddons
KF6::I18n
Qt6::Widgets
)
endif()

222
src/sam/samplugin.cpp Normal file
View File

@ -0,0 +1,222 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "samplugin.h"
#include "samengine.h"
#include "sampanel.h"
#include <KTextEditor/Application>
#include <KTextEditor/Document>
#include <KTextEditor/Editor>
#include <KTextEditor/MainWindow>
#include <KTextEditor/View>
#include <KLocalizedString>
#include <KPluginFactory>
#include <QIcon>
#include <QRegularExpression>
#include <QVBoxLayout>
#include <QWidget>
namespace deft
{
// ---------------------------------------------------------------------------
// SamPlugin
// ---------------------------------------------------------------------------
SamPlugin::SamPlugin(QObject *parent, const QVariantList &args)
: KTextEditor::Plugin(parent)
{
Q_UNUSED(args);
}
SamPlugin::~SamPlugin() = default;
QObject *SamPlugin::createView(KTextEditor::MainWindow *mainWindow)
{
return new SamView(this, mainWindow);
}
// ---------------------------------------------------------------------------
// SamView
// ---------------------------------------------------------------------------
SamView::SamView(SamPlugin *plugin, KTextEditor::MainWindow *mainWindow)
: QObject(plugin)
, m_mainWindow(mainWindow)
{
// sam tool view: a dockable panel to run sam structural-regexp commands
// against the active document (or project-wide via X/Y). Created on the
// left sidebar; the host owns the returned widget.
m_samToolView = m_mainWindow->createToolView(
plugin, QStringLiteral("deft_sam"), KTextEditor::MainWindow::Left,
QIcon::fromTheme(QStringLiteral("edit-find-replace")), i18n("Sam"));
if (m_samToolView) {
m_samPanel = new SamPanel(m_samToolView);
// The tool view returned by Kate (KateMDI::ToolView) already installs
// its own layout; creating a second one here is silently rejected by Qt
// and leaves SamPanel un-managed (tiny, pinned top-left). Add to the
// existing layout when there is one, otherwise create it ourselves.
QLayout *tvLayout = m_samToolView->layout();
if (!tvLayout) {
tvLayout = new QVBoxLayout(m_samToolView);
tvLayout->setContentsMargins(0, 0, 0, 0);
}
tvLayout->addWidget(m_samPanel);
connect(m_samPanel, &SamPanel::runRequested, this, &SamView::runSamProgram);
}
}
SamView::~SamView()
{
delete m_samToolView; // deletes the embedded SamPanel with it
}
bool SamView::applySamToDocument(KTextEditor::Document *doc, const QString &program,
int dotStart, int dotEnd, QString *output, QString *error)
{
const QString text = doc->text();
const SamResult res = SamEngine::run(program, text, dotStart, dotEnd);
if (!res.ok) {
if (error) {
*error = res.error;
}
return false;
}
if (output) {
*output = res.output;
}
// Apply all edits inside one transaction so a single undo reverts the whole
// Run. Edits are in ORIGINAL-text offsets, non-overlapping and sorted by
// start; apply back-to-front so earlier offsets remain valid.
if (!res.edits.isEmpty()) {
KTextEditor::Document::EditingTransaction transaction(doc);
for (int i = res.edits.size() - 1; i >= 0; i--) {
const SamEdit &e = res.edits[i];
const KTextEditor::Cursor from = doc->offsetToCursor(e.start);
const KTextEditor::Cursor to = doc->offsetToCursor(e.end);
doc->replaceText(KTextEditor::Range(from, to), e.text);
}
}
return true;
}
void SamView::runSamProgram(const QString &program)
{
if (!m_samPanel) {
return;
}
const QString trimmed = program.trimmed();
if (trimmed.isEmpty()) {
return;
}
// Multi-file X/Y: drive the sweep over the project's files.
const SamEngine::FileLoop fl = SamEngine::peelFileLoop(trimmed);
if (fl.matched) {
runSamFileLoop(fl.negate, fl.fileRegex, fl.inner);
return;
}
// Single-file: run against the active document.
KTextEditor::View *view = m_mainWindow->activeView();
if (!view || !view->document()) {
m_samPanel->showError(i18n("No active document."));
return;
}
KTextEditor::Document *doc = view->document();
// Dot = current selection, else the whole document (sam's common idioms use
// explicit ranges like `,` anyway; whole-doc is a sensible default dot).
int dotStart = 0;
int dotEnd = doc->text().size();
if (view->selection()) {
const KTextEditor::Range sel = view->selectionRange();
dotStart = int(doc->cursorToOffset(sel.start()));
dotEnd = int(doc->cursorToOffset(sel.end()));
}
QString output;
QString error;
if (!applySamToDocument(doc, program, dotStart, dotEnd, &output, &error)) {
m_samPanel->showError(error);
return;
}
if (!output.isEmpty()) {
m_samPanel->showOutput(output);
} else {
m_samPanel->showOutput(i18n("(done)"));
}
}
void SamView::runSamFileLoop(bool negate, const QString &fileRegex, const QString &inner)
{
if (!m_samPanel) {
return;
}
if (inner.trimmed().isEmpty()) {
m_samPanel->showError(i18n("X/Y: no command to run on matched buffers."));
return;
}
QRegularExpression fileRe(fileRegex);
if (!fileRe.isValid()) {
m_samPanel->showError(i18n("X/Y: bad file regexp: %1", fileRe.errorString()));
return;
}
// X/Y iterate the set of OPEN buffers (like acme's open-window set), not the
// whole project on disk. Nothing is opened or read from disk: we edit the
// documents Kate already has loaded, in place, each as its own undo step.
KTextEditor::Editor *editor = KTextEditor::Editor::instance();
KTextEditor::Application *app = editor ? editor->application() : nullptr;
const QList<KTextEditor::Document *> docs =
app ? app->documents() : QList<KTextEditor::Document *>{};
if (docs.isEmpty()) {
m_samPanel->showError(i18n("X/Y: no open buffers."));
return;
}
int matched = 0;
int changed = 0;
for (KTextEditor::Document *doc : docs) {
if (!doc) {
continue;
}
// Match the regex against the buffer's path (its local file path, or its
// display name for an unsaved scratch buffer).
const QUrl url = doc->url();
const QString target =
url.isLocalFile() ? url.toLocalFile() : doc->documentName();
const bool hit = fileRe.match(target).hasMatch();
if (hit == negate) {
continue; // X runs on hits; Y runs on misses
}
matched++;
QString output;
QString error;
const int dotEnd = doc->text().size();
if (applySamToDocument(doc, inner, 0, dotEnd, &output, &error)) {
changed++;
if (!output.isEmpty()) {
m_samPanel->showOutput(QStringLiteral("%1:\n%2").arg(target, output));
}
} else {
// A "no match" in a given buffer is normal during a sweep; report it
// quietly rather than as a hard error.
m_samPanel->showOutput(QStringLiteral("%1: %2").arg(target, error));
}
}
m_samPanel->showOutput(
i18n("X/Y done: %1 open buffer(s) matched, %2 changed.", matched, changed));
}
} // namespace deft
// K_PLUGIN_FACTORY generates the factory and the moc-level registration. The
// matching .moc include is required in this translation unit.
K_PLUGIN_FACTORY_WITH_JSON(SamPluginFactory, "samplugin.json",
registerPlugin<deft::SamPlugin>();)
#include "samplugin.moc"

73
src/sam/samplugin.h Normal file
View File

@ -0,0 +1,73 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* [deft:nineify] sam — the structural-regexp command plugin for Kate.
*
* SamPlugin is the KTextEditor::Plugin entry point. For every MainWindow it
* creates a SamView, which installs a dockable "Sam" tool view (a SamPanel) and
* runs sam programs against the active document, or project-wide via the X/Y
* multi-file loop. The sam engine (sam_lib) is pure; this layer is only the
* Kate glue (tool view, active document, EditingTransaction).
*/
#ifndef DEFT_SAMPLUGIN_H
#define DEFT_SAMPLUGIN_H
#include <KTextEditor/Plugin>
#include "sampanel.h"
#include <QObject>
#include <QPointer>
namespace KTextEditor
{
class Document;
class MainWindow;
}
namespace deft
{
class SamPlugin : public KTextEditor::Plugin
{
Q_OBJECT
public:
explicit SamPlugin(QObject *parent, const QVariantList &args = {});
~SamPlugin() override;
QObject *createView(KTextEditor::MainWindow *mainWindow) override;
};
class SamView : public QObject
{
Q_OBJECT
public:
SamView(SamPlugin *plugin, KTextEditor::MainWindow *mainWindow);
~SamView() override;
private Q_SLOTS:
// Run a sam program from the panel. Dispatches to the multi-file X/Y driver
// when the program begins with X or Y, else runs against the active doc.
void runSamProgram(const QString &program);
private:
// Apply a sam program to a single document, returning the engine result.
// Edits are applied inside one EditingTransaction (a single undo step).
// \a doc must be non-null. Does not touch the panel.
bool applySamToDocument(KTextEditor::Document *doc, const QString &program,
int dotStart, int dotEnd, QString *output, QString *error);
// Drive a multi-file X/Y sweep over the open buffers, applying \a inner.
// Reports progress/errors to the panel.
void runSamFileLoop(bool negate, const QString &fileRegex, const QString &inner);
KTextEditor::MainWindow *m_mainWindow;
SamPanel *m_samPanel = nullptr; // sam command tool view (owned by host)
QWidget *m_samToolView = nullptr; // the dockable container from createToolView
};
} // namespace deft
#endif

14
src/sam/samplugin.json Normal file
View File

@ -0,0 +1,14 @@
{
"KPlugin": {
"Authors": [
{
"Name": "Levi Neely"
}
],
"Description": "Run plan9 sam structural-regexp commands against the active document or across open buffers (X/Y).",
"Icon": "edit-find-replace",
"License": "LGPL-2.0-or-later",
"Name": "[deft:nineify] sam",
"Version": "0.1.0"
}
}