Sam: structural-regexp editing panel (sam in Kate)

A dockable 'Sam' tool view runs the plan9 sam command language against the
active document, or project-wide via X/Y. Each Run is one undo step.

Engine (src/sam/, pure, 27 unit tests, no Kate dep):
  - SamEngine::run(program,text,dotStart,dotEnd) -> edits (original-offset,
    non-overlapping, sorted), dot, output, applied text. Addresses computed
    against the original snapshot per sam semantics.
  - Recursive-descent parser + evaluator mirroring plan9port src/cmd/sam
    (cmd.c table, address.c lineaddr/charaddr, xec.c s_cmd/looper).
  - Language: #n n 0 $ . ' /re/ ?re? and compound + - , ; ; commands
    a c i d, s (sN, g, &, \1..\9), p =, m t, k, loops x y g v (nestable),
    { } groups, shell < > | ! via sh -c.
  - peelFileLoop(): recognises a leading X/Y file loop (sam allows one per
    command) for the Kate layer to drive.

Kate layer:
  - SamPanel (src/plugin/sampanel): program editor + Run + output log.
  - OllieView creates the tool view (MainWindow::createToolView) and owns
    runSamProgram/applySamToDocument/runSamFileLoop. Single-doc edits apply
    inside one Document::EditingTransaction (atomic undo). X/Y enumerates the
    project index (ProjectIndexer::cachedFiles), opens each matching file, and
    applies the inner program; undo is per-file (no global multi-file undo).

Regexes use QRegularExpression (PCRE), not plan9 regexp(7); documented. Out of
scope: b B n D menu, e r w f file I/O, "re" file-addressing, sam u (Kate undo).
17/17 ctest green.
This commit is contained in:
Levi Neely 2026-10-08 13:54:56 +02:00
parent bd3a58e246
commit 25a0276e8e
11 changed files with 2046 additions and 1 deletions

View File

@ -354,6 +354,42 @@ Kate flow through the real plumber rules.
confirmed passing. The GUI open/cursor path (`openUrl`/`setCursorPosition`) confirmed passing. The GUI open/cursor path (`openUrl`/`setCursorPosition`)
is not exercisable headless and is unverified on a live display. is not exercisable headless and is unverified on a live display.
### Sam — structural-regexp editing panel — DONE
A dockable tool view ("Sam", left sidebar) runs the plan9 **sam** command
language against the active document, or project-wide via `X`/`Y`. Type a
program, click **Run** (or Ctrl+Return); each Run is one undo step.
- **Pure engine** (`src/sam/samengine.{h,cpp}`, unit-tested, no Kate dep):
`SamEngine::run(program, text, dotStart, dotEnd) -> SamResult{edits, dot,
output, applied}`. Edits are computed against the ORIGINAL snapshot in
character offsets (sam computes all of a command's change addresses in the
original file — see sam(1) "Grouping and multiple changes") and returned
non-overlapping and sorted. A hand-written recursive-descent parser +
evaluator mirrors plan9port `src/cmd/sam` (`cmd.c` command table, `address.c`
address eval incl. `lineaddr`/`charaddr`, `xec.c` `s_cmd`/`looper`).
- **Language**: addresses `#n`, `n`, `0`, `$`, `.`, `'` (k mark within a run),
`/re/`, `?re?`, compound `+ - , ;` with sam defaults/precedence; commands
`a c i d`, `s` (with `sN`, `g`, `&`, `\1..\9`), `p =`, `m t`, `k`,
loops `x y g v` (nestable, `{}` groups), and shell filters `< > | !` via
`sh -c`. 27 unit tests cover each.
- **Multi-file `X`/`Y`** (`SamEngine::peelFileLoop` + Kate driver): a leading
`X/re/ cmd` runs `cmd` on every project file whose path matches `re` (`Y` =
non-match). The file set is the project index (`ProjectIndexer::cachedFiles`);
each matched file is opened as a Document and edited. sam permits only one
`X`/`Y` per command, so this is leading-token recognition, not a nested parse.
- **Undo**: single-document Run applies all edits inside one
`Document::EditingTransaction` → one Ctrl+Z reverts the whole Run. Multi-file
`X`/`Y` is undoable **per file** (KTextEditor has no global multi-file undo).
- **Panel** (`src/plugin/sampanel.{h,cpp}`): program editor + Run button +
output log; `OllieView` creates the tool view and owns the apply logic
(`runSamProgram`, `applySamToDocument`, `runSamFileLoop`).
- **Deviations (documented)**: regexes use `QRegularExpression` (PCRE), not
plan9 `regexp(7)` — practical patterns match identically; `longest-leftmost`
edge cases differ. Out of scope: multi-file menu (`b B n D`), external file
I/O (`e r w f`), `"re"` file-addressing, and sam's own `u` (Kate's undo stack
is the undo mechanism). The live GUI panel (tool view + transaction apply) is
not exercisable headless and is unverified on a live display.
## Build and test ## Build and test
```sh ```sh

View File

@ -3,6 +3,7 @@ add_subdirectory(palette)
add_subdirectory(radial) add_subdirectory(radial)
add_subdirectory(project) add_subdirectory(project)
add_subdirectory(plumb) add_subdirectory(plumb)
add_subdirectory(sam)
if(KF6TextEditor_FOUND) if(KF6TextEditor_FOUND)
add_subdirectory(commands) add_subdirectory(commands)
endif() endif()

View File

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

View File

@ -11,6 +11,8 @@
#include "projectindex.h" #include "projectindex.h"
#include "symbolindex.h" #include "symbolindex.h"
#include "plumbresolve.h" #include "plumbresolve.h"
#include "sampanel.h"
#include "samengine.h"
#include <KTextEditor/MainWindow> #include <KTextEditor/MainWindow>
#include <KTextEditor/View> #include <KTextEditor/View>
@ -33,13 +35,16 @@
#include <QEvent> #include <QEvent>
#include <QFileInfo> #include <QFileInfo>
#include <QHash> #include <QHash>
#include <QIcon>
#include <QKeyEvent> #include <QKeyEvent>
#include <QKeySequence> #include <QKeySequence>
#include <QMouseEvent> #include <QMouseEvent>
#include <QRegularExpression>
#include <QSet> #include <QSet>
#include <QStandardPaths> #include <QStandardPaths>
#include <QTimer> #include <QTimer>
#include <QUrl> #include <QUrl>
#include <QVBoxLayout>
#include <QWidget> #include <QWidget>
#include <QWindow> #include <QWindow>
@ -292,6 +297,20 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow)
}); });
m_plumber.startReader(); 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);
auto *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 // 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 // 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. // scope to its project when one is loaded, else to VCS discovery.
@ -330,6 +349,7 @@ OllieView::~OllieView()
delete m_filePalette; delete m_filePalette;
delete m_symbolPalette; delete m_symbolPalette;
delete m_radial; delete m_radial;
delete m_samToolView; // deletes the embedded SamPanel with it
} }
QList<OllieView::ActionEntry> OllieView::collectActions() const QList<OllieView::ActionEntry> OllieView::collectActions() const
@ -880,6 +900,150 @@ 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;
}
const QString root = currentProjectRoot();
if (root.isEmpty()) {
m_samPanel->showError(i18n("X/Y needs a project; none is loaded."));
return;
}
if (inner.trimmed().isEmpty()) {
m_samPanel->showError(i18n("X/Y: no command to run on matched files."));
return;
}
QRegularExpression fileRe(fileRegex);
if (!fileRe.isValid()) {
m_samPanel->showError(i18n("X/Y: bad file regexp: %1", fileRe.errorString()));
return;
}
// Use the cached project file list; request a build if we have none yet.
if (!m_indexer.hasFiles(root)) {
m_samPanel->showOutput(i18n("Indexing project files… run X/Y again in a moment."));
m_indexer.requestFiles(root);
return;
}
const QStringList files = m_indexer.cachedFiles(root);
int matched = 0;
int changed = 0;
int failed = 0;
for (const QString &rel : files) {
const bool hit = fileRe.match(rel).hasMatch();
if (hit == negate) {
continue; // X runs on hits; Y runs on misses
}
matched++;
const QString absolute = QDir(root).absoluteFilePath(rel);
const QUrl url = QUrl::fromLocalFile(absolute);
// Open (or focus) the file so the edit is a normal, undoable Document
// change visible to the user.
KTextEditor::View *view = m_mainWindow->openUrl(url);
if (!view || !view->document()) {
failed++;
m_samPanel->showError(i18n("could not open %1", rel));
continue;
}
KTextEditor::Document *doc = view->document();
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(rel, output));
}
} else {
// A "no match" in a given file is normal during a sweep; report it
// quietly rather than as a hard error.
m_samPanel->showOutput(QStringLiteral("%1: %2").arg(rel, error));
}
}
m_samPanel->showOutput(
i18n("X/Y done: %1 file(s) matched, %2 changed, %3 failed.", matched, changed, failed));
}
QAction *OllieView::findActionByName(const QString &nameOrText) const QAction *OllieView::findActionByName(const QString &nameOrText) const
{ {
KXMLGUIFactory *factory = m_mainWindow->guiFactory(); KXMLGUIFactory *factory = m_mainWindow->guiFactory();

View File

@ -18,6 +18,7 @@
#include "projectindexer.h" #include "projectindexer.h"
#include "palettemodel.h" // PaletteItem #include "palettemodel.h" // PaletteItem
#include "plumber.h" #include "plumber.h"
#include "sampanel.h"
#include "waylandcursorwarp.h" #include "waylandcursorwarp.h"
#include <QObject> #include <QObject>
@ -32,6 +33,7 @@ namespace KTextEditor
{ {
class MainWindow; class MainWindow;
class View; class View;
class Document;
} }
namespace katecustom namespace katecustom
@ -79,6 +81,10 @@ private Q_SLOTS:
// addr (a plan9 edit-port address). Routed from the Plumber reader thread. // addr (a plan9 edit-port address). Routed from the Plumber reader thread.
void onPlumbEdit(const QString &file, const QString &addr, const QString &wdir); 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: private:
// Record one activation of \a id and persist. Feeds the frecency bonus so // Record one activation of \a id and persist. Feeds the frecency bonus so
// habitual choices float up in every palette. // habitual choices float up in every palette.
@ -143,6 +149,16 @@ private:
// matching pair (multi-line aware). Returns true if a pair was selected. // matching pair (multi-line aware). Returns true if a pair was selected.
bool selectBracketPairAt(KTextEditor::View *view, const QPoint &globalPos); 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; KTextEditor::MainWindow *m_mainWindow;
PaletteWidget *m_palette = nullptr; PaletteWidget *m_palette = nullptr;
PaletteWidget *m_filePalette = nullptr; // project "Go to File" switcher PaletteWidget *m_filePalette = nullptr; // project "Go to File" switcher
@ -162,6 +178,8 @@ private:
ProjectIndexer m_indexer; // async, cached file/symbol indexing ProjectIndexer m_indexer; // async, cached file/symbol indexing
WaylandCursorWarp m_cursorWarp; // pointer warp on Wayland (no-op on X11) WaylandCursorWarp m_cursorWarp; // pointer warp on Wayland (no-op on X11)
Plumber m_plumber; // plan9 plumbing client (send + edit-port reader) 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 katecustom } // namespace katecustom

105
src/plugin/sampanel.cpp Normal file
View File

@ -0,0 +1,105 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "sampanel.h"
#include <KLocalizedString>
#include <QFontDatabase>
#include <QKeyEvent>
#include <QLabel>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QTextEdit>
#include <QVBoxLayout>
namespace katecustom
{
// A small QPlainTextEdit that fires a signal on Ctrl+Return so the user can run
// without reaching for the mouse. Enter alone inserts a newline (programs are
// multi-line).
class SamProgramEdit : public QPlainTextEdit
{
public:
explicit SamProgramEdit(QWidget *parent, std::function<void()> onRun)
: QPlainTextEdit(parent)
, m_onRun(std::move(onRun))
{
}
protected:
void keyPressEvent(QKeyEvent *e) override
{
if ((e->key() == Qt::Key_Return || e->key() == Qt::Key_Enter)
&& (e->modifiers() & Qt::ControlModifier)) {
if (m_onRun) {
m_onRun();
}
e->accept();
return;
}
QPlainTextEdit::keyPressEvent(e);
}
private:
std::function<void()> m_onRun;
};
SamPanel::SamPanel(QWidget *parent)
: QWidget(parent)
{
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(4, 4, 4, 4);
layout->setSpacing(4);
auto *hint = new QLabel(
i18n("sam commands — e.g. <tt>,s/foo/bar/g</tt> or "
"<tt>X/\\.cpp$/ ,s/old/new/g</tt> (project-wide). Ctrl+Return runs."),
this);
hint->setWordWrap(true);
hint->setTextFormat(Qt::RichText);
layout->addWidget(hint);
m_editor = new SamProgramEdit(this, [this]() {
Q_EMIT runRequested(program());
});
m_editor->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
m_editor->setPlaceholderText(i18n("Enter sam command(s)…"));
layout->addWidget(m_editor, /*stretch*/ 2);
m_runButton = new QPushButton(i18n("Run"), this);
connect(m_runButton, &QPushButton::clicked, this,
[this]() { Q_EMIT runRequested(program()); });
layout->addWidget(m_runButton);
m_output = new QTextEdit(this);
m_output->setReadOnly(true);
m_output->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
layout->addWidget(m_output, /*stretch*/ 1);
}
QString SamPanel::program() const
{
return m_editor->toPlainText();
}
void SamPanel::showOutput(const QString &text)
{
// Render literally (output may contain <, &, etc.), preserving newlines.
m_output->append(QStringLiteral("<pre style='margin:0'>%1</pre>")
.arg(text.toHtmlEscaped()));
}
void SamPanel::showError(const QString &text)
{
m_output->append(QStringLiteral("<span style='color:#c0392b'>%1</span>")
.arg(text.toHtmlEscaped()));
}
void SamPanel::clearOutput()
{
m_output->clear();
}
} // namespace katecustom

51
src/plugin/sampanel.h Normal file
View File

@ -0,0 +1,51 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* SamPanel — the tool-view widget for running sam commands.
*
* Layout: a multi-line command editor on top, a Run button, and a read-only
* output/log area below. The widget is pure UI: it emits runRequested() with
* the program text; OllieView does the document work (snapshot, engine, apply
* in an EditingTransaction) and calls showOutput()/showError() to report back.
*/
#ifndef KATECUSTOM_SAMPANEL_H
#define KATECUSTOM_SAMPANEL_H
#include <QWidget>
class QPlainTextEdit;
class QPushButton;
class QTextEdit;
namespace katecustom
{
class SamPanel : public QWidget
{
Q_OBJECT
public:
explicit SamPanel(QWidget *parent = nullptr);
/*! The current program text. */
QString program() const;
/*! Append a normal (non-error) line to the output log. */
void showOutput(const QString &text);
/*! Append an error line (rendered emphasised) to the output log. */
void showError(const QString &text);
/*! Clear the output log. */
void clearOutput();
Q_SIGNALS:
/*! The user asked to run the program (Run button or Ctrl+Return). */
void runRequested(const QString &program);
private:
QPlainTextEdit *m_editor = nullptr;
QPushButton *m_runButton = nullptr;
QTextEdit *m_output = nullptr;
};
} // namespace katecustom
#endif

20
src/sam/CMakeLists.txt Normal file
View File

@ -0,0 +1,20 @@
# sam — the plan9 sam structural-regexp command language engine.
#
# sam_lib is pure (QString in, edits out) and fully unit tested. It needs
# Qt Core for QString/QRegularExpression and Qt Core's QProcess for the shell
# filter commands (< > | !); no GUI dependency.
find_package(Qt6 ${QT_MIN_VERSION} COMPONENTS Core REQUIRED)
add_library(sam_lib STATIC
samengine.cpp
samengine.h
)
target_link_libraries(sam_lib PUBLIC Qt6::Core)
target_include_directories(sam_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
find_package(Qt6 ${QT_MIN_VERSION} COMPONENTS Test)
if(Qt6Test_FOUND)
add_executable(test_samengine test_samengine.cpp)
target_link_libraries(test_samengine PRIVATE sam_lib Qt6::Test)
add_test(NAME samengine COMMAND test_samengine)
endif()

1295
src/sam/samengine.cpp Normal file

File diff suppressed because it is too large Load Diff

94
src/sam/samengine.h Normal file
View File

@ -0,0 +1,94 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* SamEngine — the plan9 sam structural-regexp command language, applied to a
* single text buffer.
*
* This is a faithful, self-contained reimplementation of sam's command and
* address language (see sam(1) and plan9port src/cmd/sam) over an immutable
* snapshot of the document text. It is pure (QString in, edits out) with no
* Kate or GUI dependency so the whole language can be unit tested.
*
* Semantics follow sam: "the addresses of all changes to the file are computed
* in the original file" — so run() evaluates every address against the
* ORIGINAL snapshot and emits a list of non-overlapping edits in original-text
* character offsets. The Kate layer then applies them inside one
* EditingTransaction, giving a single native undo per Run (sam's `u` is not
* reimplemented; Kate's undo stack is the undo mechanism).
*
* Addresses supported: #n, n (line), 0, $, ., ' (mark set by k within a run),
* /re/, ?re?, and compound + - , ; with sam's defaults and precedence.
* Commands supported: a c i d s (sN, g, & and \1..\9), p =, m t, k, x y g v
* (nestable), and { } grouping. Shell filters < > | ! run via `sh -c`.
*
* Deviations (documented, not hidden):
* - Regular expressions use QRegularExpression (PCRE), not plan9 regexp(7).
* Practical editing patterns behave identically; pathological
* longest-leftmost cases may differ.
* - Multi-file features (b B n D X Y, "re" file-addressing) and external file
* I/O (e r w f) are out of scope: Kate manages files and views.
*/
#ifndef KATECUSTOM_SAMENGINE_H
#define KATECUSTOM_SAMENGINE_H
#include <QList>
#include <QString>
namespace katecustom
{
/*! One edit against the ORIGINAL snapshot: replace [start,end) with text. */
struct SamEdit {
int start = 0; // character offset into the original text, inclusive
int end = 0; // character offset into the original text, exclusive
QString text; // replacement
bool operator==(const SamEdit &o) const
{
return start == o.start && end == o.end && text == o.text;
}
};
/*! Result of running a sam program against a text buffer. */
struct SamResult {
bool ok = true;
QString error; // human-readable message when ok == false
QList<SamEdit> edits; // non-overlapping, sorted by start (original offsets)
int dotStart = 0; // resulting dot in ORIGINAL offsets (pre-apply)
int dotEnd = 0;
QString output; // text produced by p / = commands, newline separated
QString applied; // convenience: original text with edits applied
};
/*!
* Run a sam \a program against \a text, with the initial dot set to the range
* [\a dotStart, \a dotEnd) (character offsets; pass the current selection, or
* an empty range at the caret). Returns the collected edits and the resulting
* dot. Pure: no I/O except explicit shell filters (< > | !), which run `sh -c`.
*/
class SamEngine
{
public:
static SamResult run(const QString &program, const QString &text,
int dotStart, int dotEnd);
/*! A leading X/Y file loop peeled off a program for the Kate layer. */
struct FileLoop {
bool matched = false; // true if the program starts with X or Y
bool negate = false; // Y (run on files NOT matching) vs X
QString fileRegex; // pattern matched against each file path
QString inner; // the remaining single-file program to run per file
};
/*!
* If \a program begins with an X or Y file loop, return it peeled apart so
* the caller can enumerate the project's files, filter by \c fileRegex
* (match for X, non-match for Y), and run run(\c inner, ...) on each. sam
* permits only one X/Y per command, so this is a simple leading-token
* recognition, not a nested parse. Returns {matched=false} otherwise.
*/
static FileLoop peelFileLoop(const QString &program);
};
} // namespace katecustom
#endif

260
src/sam/test_samengine.cpp Normal file
View File

@ -0,0 +1,260 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* Unit tests for the sam engine. Each test runs a sam program against a text
* snapshot and checks the resulting text (edits applied) and/or output. The
* expected behaviours are taken from sam(1) and plan9port src/cmd/sam.
*/
#include "samengine.h"
#include <QObject>
#include <QTest>
using namespace katecustom;
class TestSamEngine : public QObject
{
Q_OBJECT
// Run a program over the whole buffer (dot = entire text) and return the
// resulting text. Fails the test if the engine reports an error.
QString runAll(const QString &prog, const QString &text)
{
SamResult r = SamEngine::run(prog, text, 0, text.size());
if (!r.ok) {
qWarning("sam error: %s", qUtf8Printable(r.error));
}
return r.applied;
}
private Q_SLOTS:
void substituteFirst();
void substituteGlobal();
void substituteNth();
void substituteAmp();
void substituteGroup();
void deleteRange();
void appendInsertChange();
void lineAddress();
void charAddress();
void dollarAndZero();
void commaRange();
void xLoopSub();
void gGuard();
void vGuard();
void xWithExplicitRange();
void moveCommand();
void copyCommand();
void groupBraces();
void printOutput();
void equalsOutput();
void regexSearchAddr();
void peelFileLoopX();
void peelFileLoopY();
void peelFileLoopNone();
void errorOnNoMatch();
};
void TestSamEngine::substituteFirst()
{
// s/foo/bar/ replaces only the first match.
QCOMPARE(runAll(QStringLiteral(",s/foo/bar/"), QStringLiteral("foo foo foo")),
QStringLiteral("bar foo foo"));
}
void TestSamEngine::substituteGlobal()
{
QCOMPARE(runAll(QStringLiteral(",s/foo/bar/g"), QStringLiteral("foo foo foo")),
QStringLiteral("bar bar bar"));
}
void TestSamEngine::substituteNth()
{
// s2/x/y/ substitutes the second match only.
QCOMPARE(runAll(QStringLiteral(",s2/x/Y/"), QStringLiteral("x x x")),
QStringLiteral("x Y x"));
}
void TestSamEngine::substituteAmp()
{
// & is the whole match.
QCOMPARE(runAll(QStringLiteral(",s/foo/[&]/g"), QStringLiteral("foo bar foo")),
QStringLiteral("[foo] bar [foo]"));
}
void TestSamEngine::substituteGroup()
{
// \1 is the first capture group.
QCOMPARE(runAll(QStringLiteral(",s/(\\w+)=(\\w+)/\\2=\\1/"),
QStringLiteral("key=value")),
QStringLiteral("value=key"));
}
void TestSamEngine::deleteRange()
{
// Delete the first line (1).
QCOMPARE(runAll(QStringLiteral("1d"), QStringLiteral("one\ntwo\nthree\n")),
QStringLiteral("two\nthree\n"));
}
void TestSamEngine::appendInsertChange()
{
// a appends after the range, i inserts before, c replaces.
QCOMPARE(runAll(QStringLiteral("1a/X/"), QStringLiteral("ab\ncd\n")),
QStringLiteral("ab\nXcd\n"));
QCOMPARE(runAll(QStringLiteral("1i/X/"), QStringLiteral("ab\ncd\n")),
QStringLiteral("Xab\ncd\n"));
QCOMPARE(runAll(QStringLiteral("1c/X/"), QStringLiteral("ab\ncd\n")),
QStringLiteral("Xcd\n"));
}
void TestSamEngine::lineAddress()
{
// Line 2 is "two\n"; deleting it leaves lines 1 and 3.
QCOMPARE(runAll(QStringLiteral("2d"), QStringLiteral("one\ntwo\nthree\n")),
QStringLiteral("one\nthree\n"));
}
void TestSamEngine::charAddress()
{
// #0,#3 is the first three characters.
QCOMPARE(runAll(QStringLiteral("#0,#3 c/XYZ/"), QStringLiteral("abcdef")),
QStringLiteral("XYZdef"));
}
void TestSamEngine::dollarAndZero()
{
// 0 is the start; a after it inserts at the very beginning.
QCOMPARE(runAll(QStringLiteral("0a/>/"), QStringLiteral("abc")),
QStringLiteral(">abc"));
// $ is the end; a after it appends at the very end.
QCOMPARE(runAll(QStringLiteral("$a/</"), QStringLiteral("abc")),
QStringLiteral("abc<"));
}
void TestSamEngine::commaRange()
{
// , with no operands is 0,$ -> whole file; d empties it.
QCOMPARE(runAll(QStringLiteral(",d"), QStringLiteral("anything here")),
QStringLiteral(""));
}
void TestSamEngine::xLoopSub()
{
// x/word/ loops each match; the sub-command s/./upper?/ No — use c to wrap.
// For each run of letters, surround with <>.
QCOMPARE(runAll(QStringLiteral(",x/[a-z]+/ c/W/"), QStringLiteral("aa bb cc")),
QStringLiteral("W W W"));
}
void TestSamEngine::gGuard()
{
// g/re/ runs the command only if the range contains a match.
// Range is line 1; it contains "foo" so delete it.
QCOMPARE(runAll(QStringLiteral("1g/foo/ d"), QStringLiteral("foo\nbar\n")),
QStringLiteral("bar\n"));
// Line 2 has no "foo", so the guard blocks the delete.
QCOMPARE(runAll(QStringLiteral("2g/foo/ d"), QStringLiteral("foo\nbar\n")),
QStringLiteral("foo\nbar\n"));
}
void TestSamEngine::vGuard()
{
// v/re/ runs only if the range does NOT contain a match.
QCOMPARE(runAll(QStringLiteral("2v/foo/ d"), QStringLiteral("foo\nbar\n")),
QStringLiteral("foo\n"));
}
void TestSamEngine::xWithExplicitRange()
{
// x over the whole file replacing each digit run.
QCOMPARE(runAll(QStringLiteral(",x/[0-9]+/ c/#/"), QStringLiteral("a1b22c333")),
QStringLiteral("a#b#c#"));
}
void TestSamEngine::moveCommand()
{
// Move line 1 ("one\n") to after line 3 ($ end). m deletes source + inserts.
QCOMPARE(runAll(QStringLiteral("1m$"), QStringLiteral("one\ntwo\nthree\n")),
QStringLiteral("two\nthree\none\n"));
}
void TestSamEngine::copyCommand()
{
// Copy line 1 to the end.
QCOMPARE(runAll(QStringLiteral("1t$"), QStringLiteral("one\ntwo\n")),
QStringLiteral("one\ntwo\none\n"));
}
void TestSamEngine::groupBraces()
{
// A group: delete line 1 and substitute on the whole file. Both addresses
// computed against the original, applied in sequence.
const QString prog = QStringLiteral("{\n1d\n,s/two/TWO/\n}");
QCOMPARE(runAll(prog, QStringLiteral("one\ntwo\nthree\n")),
QStringLiteral("TWO\nthree\n"));
}
void TestSamEngine::printOutput()
{
SamResult r = SamEngine::run(QStringLiteral("2p"),
QStringLiteral("one\ntwo\nthree\n"), 0, 0);
QVERIFY(r.ok);
QCOMPARE(r.output, QStringLiteral("two\n"));
}
void TestSamEngine::equalsOutput()
{
SamResult r = SamEngine::run(QStringLiteral("#0,#3="),
QStringLiteral("abcdef"), 0, 0);
QVERIFY(r.ok);
QCOMPARE(r.output, QStringLiteral("1,1"));
SamResult r2 = SamEngine::run(QStringLiteral("#0,#3=#"),
QStringLiteral("abcdef"), 0, 0);
QVERIFY(r2.ok);
QCOMPARE(r2.output, QStringLiteral("#0,#3"));
}
void TestSamEngine::regexSearchAddr()
{
// /two/ addresses the first match; change it.
QCOMPARE(runAll(QStringLiteral("/two/ c/2/"), QStringLiteral("one two three")),
QStringLiteral("one 2 three"));
}
void TestSamEngine::peelFileLoopX()
{
auto fl = SamEngine::peelFileLoop(QStringLiteral("X/\\.go$/ ,s/foo/bar/g"));
QVERIFY(fl.matched);
QVERIFY(!fl.negate);
QCOMPARE(fl.fileRegex, QStringLiteral("\\.go$"));
QCOMPARE(fl.inner, QStringLiteral(",s/foo/bar/g"));
}
void TestSamEngine::peelFileLoopY()
{
auto fl = SamEngine::peelFileLoop(QStringLiteral("Y/test/ ,d"));
QVERIFY(fl.matched);
QVERIFY(fl.negate);
QCOMPARE(fl.fileRegex, QStringLiteral("test"));
QCOMPARE(fl.inner, QStringLiteral(",d"));
}
void TestSamEngine::peelFileLoopNone()
{
auto fl = SamEngine::peelFileLoop(QStringLiteral(",s/a/b/"));
QVERIFY(!fl.matched);
}
void TestSamEngine::errorOnNoMatch()
{
// A top-level substitution with no match is an error (sam Enosub).
SamResult r = SamEngine::run(QStringLiteral(",s/zzz/q/"),
QStringLiteral("abc"), 0, 3);
QVERIFY(!r.ok);
QVERIFY(!r.error.isEmpty());
}
QTEST_MAIN(TestSamEngine)
#include "test_samengine.moc"