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