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:
parent
bd3a58e246
commit
25a0276e8e
36
docs/PLAN.md
36
docs/PLAN.md
|
|
@ -354,6 +354,42 @@ Kate flow through the real plumber rules.
|
|||
confirmed passing. The GUI open/cursor path (`openUrl`/`setCursorPosition`)
|
||||
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
|
||||
|
||||
```sh
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ add_subdirectory(palette)
|
|||
add_subdirectory(radial)
|
||||
add_subdirectory(project)
|
||||
add_subdirectory(plumb)
|
||||
add_subdirectory(sam)
|
||||
if(KF6TextEditor_FOUND)
|
||||
add_subdirectory(commands)
|
||||
endif()
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ if(PkgConfig_FOUND)
|
|||
pkg_check_modules(WAYLAND_PROTOCOLS QUIET wayland-protocols)
|
||||
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 "")
|
||||
|
||||
if(WaylandScanner_FOUND AND WAYLAND_CLIENT_FOUND AND Qt6GuiPrivate_FOUND)
|
||||
|
|
@ -73,6 +73,7 @@ target_link_libraries(olliepalette PRIVATE
|
|||
olliecommands_lib
|
||||
project_lib
|
||||
plumb_lib
|
||||
sam_lib
|
||||
KF6::TextEditor
|
||||
KF6::XmlGui
|
||||
KF6::CoreAddons
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
#include "projectindex.h"
|
||||
#include "symbolindex.h"
|
||||
#include "plumbresolve.h"
|
||||
#include "sampanel.h"
|
||||
#include "samengine.h"
|
||||
|
||||
#include <KTextEditor/MainWindow>
|
||||
#include <KTextEditor/View>
|
||||
|
|
@ -33,13 +35,16 @@
|
|||
#include <QEvent>
|
||||
#include <QFileInfo>
|
||||
#include <QHash>
|
||||
#include <QIcon>
|
||||
#include <QKeyEvent>
|
||||
#include <QKeySequence>
|
||||
#include <QMouseEvent>
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
#include <QStandardPaths>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
#include <QWindow>
|
||||
|
||||
|
|
@ -292,6 +297,20 @@ 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);
|
||||
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
|
||||
// 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.
|
||||
|
|
@ -330,6 +349,7 @@ 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
|
||||
|
|
@ -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
|
||||
{
|
||||
KXMLGUIFactory *factory = m_mainWindow->guiFactory();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
#include "projectindexer.h"
|
||||
#include "palettemodel.h" // PaletteItem
|
||||
#include "plumber.h"
|
||||
#include "sampanel.h"
|
||||
#include "waylandcursorwarp.h"
|
||||
|
||||
#include <QObject>
|
||||
|
|
@ -32,6 +33,7 @@ namespace KTextEditor
|
|||
{
|
||||
class MainWindow;
|
||||
class View;
|
||||
class Document;
|
||||
}
|
||||
|
||||
namespace katecustom
|
||||
|
|
@ -79,6 +81,10 @@ 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.
|
||||
|
|
@ -143,6 +149,16 @@ 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
|
||||
|
|
@ -162,6 +178,8 @@ 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 katecustom
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
Loading…
Reference in New Issue