diff --git a/docs/PLAN.md b/docs/PLAN.md index c95ae50..03e78f7 100644 --- a/docs/PLAN.md +++ b/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 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 906521f..528e4b2 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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() diff --git a/src/plugin/CMakeLists.txt b/src/plugin/CMakeLists.txt index 366fa95..b571cd0 100644 --- a/src/plugin/CMakeLists.txt +++ b/src/plugin/CMakeLists.txt @@ -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 diff --git a/src/plugin/ollieplugin.cpp b/src/plugin/ollieplugin.cpp index 5376aad..d65176c 100644 --- a/src/plugin/ollieplugin.cpp +++ b/src/plugin/ollieplugin.cpp @@ -11,6 +11,8 @@ #include "projectindex.h" #include "symbolindex.h" #include "plumbresolve.h" +#include "sampanel.h" +#include "samengine.h" #include #include @@ -33,13 +35,16 @@ #include #include #include +#include #include #include #include +#include #include #include #include #include +#include #include #include @@ -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::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(); diff --git a/src/plugin/ollieplugin.h b/src/plugin/ollieplugin.h index 3cf822f..39c4766 100644 --- a/src/plugin/ollieplugin.h +++ b/src/plugin/ollieplugin.h @@ -18,6 +18,7 @@ #include "projectindexer.h" #include "palettemodel.h" // PaletteItem #include "plumber.h" +#include "sampanel.h" #include "waylandcursorwarp.h" #include @@ -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 diff --git a/src/plugin/sampanel.cpp b/src/plugin/sampanel.cpp new file mode 100644 index 0000000..1bb60cc --- /dev/null +++ b/src/plugin/sampanel.cpp @@ -0,0 +1,105 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "sampanel.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +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 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 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. ,s/foo/bar/g or " + "X/\\.cpp$/ ,s/old/new/g (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("
%1
") + .arg(text.toHtmlEscaped())); +} + +void SamPanel::showError(const QString &text) +{ + m_output->append(QStringLiteral("%1") + .arg(text.toHtmlEscaped())); +} + +void SamPanel::clearOutput() +{ + m_output->clear(); +} + +} // namespace katecustom diff --git a/src/plugin/sampanel.h b/src/plugin/sampanel.h new file mode 100644 index 0000000..917f923 --- /dev/null +++ b/src/plugin/sampanel.h @@ -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 + +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 diff --git a/src/sam/CMakeLists.txt b/src/sam/CMakeLists.txt new file mode 100644 index 0000000..797bacd --- /dev/null +++ b/src/sam/CMakeLists.txt @@ -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() diff --git a/src/sam/samengine.cpp b/src/sam/samengine.cpp new file mode 100644 index 0000000..468d3b9 --- /dev/null +++ b/src/sam/samengine.cpp @@ -0,0 +1,1295 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * SamEngine implementation. + * + * The engine runs a sam program against an immutable snapshot (`m_text`). It + * evaluates every address against that original text and records edits as + * (start,end,text) in original offsets. Because sam computes all of a command's + * change addresses in the original file (see sam(1) "Grouping and multiple + * changes"), never observing its own edits mid-command, this snapshot model is + * faithful and composes with a single Kate EditingTransaction. + * + * Structure: + * Parser — hand-written recursive descent over the program Runes, mirroring + * plan9port src/cmd/sam/cmd.c (command table) and address.c. + * Evaluator— walks the parsed command tree, resolving ranges and emitting + * edits; loops (x y g v) re-run their sub-command per match with + * dot bound to each match, exactly as xec.c looper()/linelooper(). + */ +#include "samengine.h" + +#include +#include +#include + +namespace katecustom +{ + +namespace +{ + +// ---- parsed representation ------------------------------------------------- + +// A single address element. `kind` is one of the sam address atoms; `num` is +// the operand for line/char addresses; `re` is the pattern for /?"; children +// are used by the compound operators , ; + -. +struct Addr { + QChar kind; // 'l' line, '#' char, '.', '$', '\'', '/', '?', ',', + // ';', '+', '-', 0 (none) + int num = 0; // operand for 'l' and '#' + QString re; // pattern for '/', '?' + Addr *left = nullptr; // for ',' ';' (a1) and operand chains + Addr *next = nullptr; // chained element (sam Addr->next), or a2 for , ; + Addr(QChar k = QChar()) : kind(k) {} +}; + +// A parsed command. `addr` is its (optional) leading address. For loops/groups +// the sub-command(s) hang off `child`/`children`. +struct Cmd { + QChar c; // command character ('a','c','i','d','s','p','=', + // 'm','t','k','x','y','g','v','{', '\n', 0) + Addr *addr = nullptr; // leading address, or null -> default (dot) + QString text; // a/c/i body; s replacement; filenames; shell line + QString re; // s/x/y/g/v pattern + int num = 0; // s count (sN); 0 means 1 + bool global = false; // s///g + Addr *dest = nullptr; // m/t destination address + Cmd *child = nullptr; // x y g v sub-command + QList children; // { } group members + Cmd(QChar cc = QChar()) : c(cc) {} +}; + +// ---- parser ---------------------------------------------------------------- + +class Parser +{ +public: + Parser(const QString &prog) : m_s(prog), m_n(prog.size()) {} + + // Parse a whole program (a sequence of newline-separated commands at top + // level is treated as a group). Returns null and sets m_error on failure. + Cmd *parseProgram() + { + auto *group = new Cmd(QLatin1Char('{')); + skipBlankAndSep(); + while (!atEnd()) { + Cmd *c = parseCmd(); + if (!c) { + return nullptr; + } + group->children.append(c); + skipBlankAndSep(); + } + if (group->children.size() == 1) { + Cmd *only = group->children.first(); + group->children.clear(); + return only; + } + return group; + } + + QString error() const { return m_error; } + +private: + bool atEnd() const { return m_i >= m_n; } + QChar peek() const { return m_i < m_n ? m_s[m_i] : QChar(); } + QChar get() { return m_i < m_n ? m_s[m_i++] : QChar(); } + + void skipSpace() + { + while (m_i < m_n && (m_s[m_i] == QLatin1Char(' ') || m_s[m_i] == QLatin1Char('\t'))) { + m_i++; + } + } + void skipBlankAndSep() + { + while (m_i < m_n) { + const QChar c = m_s[m_i]; + if (c == QLatin1Char(' ') || c == QLatin1Char('\t') || c == QLatin1Char('\n')) { + m_i++; + } else { + break; + } + } + } + + void fail(const QString &msg) + { + if (m_error.isEmpty()) { + m_error = msg; + } + } + + // --- address parsing (mirrors cmd.c simpleaddr/compoundaddr) ---- + + // A simple address atom, possibly with a leading sign chain handled by + // the caller. Returns null if there is no address here (not an error). + Addr *parseSimpleAddr() + { + skipSpace(); + const QChar c = peek(); + if (c == QLatin1Char('#')) { + get(); + int n = parseNumber(/*def*/ 0); + Addr *a = new Addr(QLatin1Char('#')); + a->num = n; + return a; + } + if (c.isDigit()) { + int n = parseNumber(0); + Addr *a = new Addr(QLatin1Char('l')); + a->num = n; + return a; + } + if (c == QLatin1Char('/') || c == QLatin1Char('?')) { + get(); + Addr *a = new Addr(c); + a->re = parseDelimited(c); + return a; + } + if (c == QLatin1Char('.') || c == QLatin1Char('$') || c == QLatin1Char('\'')) { + get(); + return new Addr(c); + } + return nullptr; + } + + // A compound address: handles + - (high precedence) then , ; (low). Returns + // null when no address is present. + Addr *parseCompoundAddr() + { + // Parse the additive chain (left side). + Addr *left = parseAdditive(); + + skipSpace(); + const QChar c = peek(); + if (c == QLatin1Char(',') || c == QLatin1Char(';')) { + get(); + Addr *op = new Addr(c); + op->left = left; // may be null -> defaults to 0 + op->next = parseAdditive(); // may be null -> defaults to $ + return op; + } + return left; + } + + // Additive chain: simpleaddr ( (+|-) simpleaddr )*. Builds a left-linked + // Addr list via `next`, matching address.c's iterative evaluation. + Addr *parseAdditive() + { + skipSpace(); + Addr *head = parseSimpleAddr(); + Addr *tail = head; + while (true) { + skipSpace(); + const QChar c = peek(); + if (c != QLatin1Char('+') && c != QLatin1Char('-')) { + break; + } + get(); + Addr *op = new Addr(c); + // Operand after the sign (optional; defaults to line 1). + Addr *operand = parseSimpleAddr(); + if (!head) { + head = op; + tail = op; + } else { + tail->next = op; + tail = op; + } + if (operand) { + tail->next = operand; + tail = operand; + } + } + return head; + } + + int parseNumber(int def) + { + skipSpace(); + if (m_i >= m_n || !m_s[m_i].isDigit()) { + return def; + } + int n = 0; + while (m_i < m_n && m_s[m_i].isDigit()) { + n = n * 10 + (m_s[m_i].unicode() - '0'); + m_i++; + } + return n; + } + + // Read text delimited by `delim` (the char that opened it). Handles \n -> \n + // and \ -> literal delim. Consumes the trailing delimiter if present + // (sam allows eliding trailing delimiters). + QString parseDelimited(QChar delim) + { + QString out; + while (m_i < m_n) { + const QChar c = m_s[m_i]; + if (c == QLatin1Char('\n')) { + break; // delimited text may not contain a literal newline + } + if (c == delim) { + m_i++; // consume closing delimiter + return out; + } + if (c == QLatin1Char('\\') && m_i + 1 < m_n) { + const QChar d = m_s[m_i + 1]; + if (d == QLatin1Char('n')) { + out.append(QLatin1Char('\n')); + m_i += 2; + continue; + } + if (d == delim) { + out.append(delim); + m_i += 2; + continue; + } + // keep the backslash (regex escapes etc.) + out.append(c); + out.append(d); + m_i += 2; + continue; + } + out.append(c); + m_i++; + } + return out; // trailing delimiter elided + } + + // The replacement text of an s command keeps backslashes verbatim (so \1 + // and \n reach the evaluator) but still honours \ and ends at the + // delimiter. + QString parseReplacement(QChar delim) + { + QString out; + while (m_i < m_n) { + const QChar c = m_s[m_i]; + if (c == QLatin1Char('\n')) { + break; + } + if (c == delim) { + m_i++; + return out; + } + if (c == QLatin1Char('\\') && m_i + 1 < m_n) { + const QChar d = m_s[m_i + 1]; + if (d == delim) { + out.append(delim); + m_i += 2; + continue; + } + out.append(c); + out.append(d); + m_i += 2; + continue; + } + out.append(c); + m_i++; + } + return out; + } + + // Read to end of line (for a/c/i single-line form, filenames, shell cmds). + QString parseToEol() + { + QString out; + while (m_i < m_n && m_s[m_i] != QLatin1Char('\n')) { + out.append(m_s[m_i]); + m_i++; + } + return out; + } + + // Parse the body of a/c/i: either `/text/` inline or a dot-terminated block + // of following lines (lines until a line that is exactly "."). + QString parseTextBody() + { + skipSpace(); + if (m_i < m_n && m_s[m_i] != QLatin1Char('\n')) { + const QChar delim = m_s[m_i]; + // Any non-alphanumeric printable is a valid delimiter. + if (!delim.isLetterOrNumber()) { + m_i++; + return parseDelimited(delim); + } + } + // Dot-terminated block form. + if (m_i < m_n && m_s[m_i] == QLatin1Char('\n')) { + m_i++; + } + QString out; + while (m_i < m_n) { + // Read one line. + int lineStart = m_i; + while (m_i < m_n && m_s[m_i] != QLatin1Char('\n')) { + m_i++; + } + const QString ln = m_s.mid(lineStart, m_i - lineStart); + if (m_i < m_n) { + m_i++; // consume newline + } + if (ln == QLatin1String(".")) { + break; + } + if (!out.isEmpty()) { + out.append(QLatin1Char('\n')); + } + out.append(ln); + } + return out; + } + + // Parse one command (with optional leading address). Returns null on error. + Cmd *parseCmd() + { + skipSpace(); + Addr *addr = parseCompoundAddr(); + skipSpace(); + + if (atEnd()) { + // A bare address with no command: a "move dot" command. + Cmd *c = new Cmd(QLatin1Char('\n')); + c->addr = addr; + return c; + } + + const QChar cc = get(); + Cmd *cmd = new Cmd(cc); + cmd->addr = addr; + + switch (cc.unicode()) { + case 'a': + case 'c': + case 'i': + cmd->text = parseTextBody(); + return cmd; + case 'd': + case 'p': + case 'k': + return cmd; + case '=': { + // '=' or '=#' + if (peek() == QLatin1Char('#')) { + get(); + cmd->text = QStringLiteral("#"); + } + return cmd; + } + case 's': { + cmd->num = parseNumber(1); + if (cmd->num == 0) { + cmd->num = 1; + } + skipSpace(); + if (atEnd()) { + fail(QStringLiteral("s: missing delimiter")); + return nullptr; + } + const QChar delim = get(); + cmd->re = parseDelimited(delim); + cmd->text = parseReplacement(delim); + // optional trailing g + if (peek() == QLatin1Char('g')) { + get(); + cmd->global = true; + } + return cmd; + } + case 'm': + case 't': { + skipSpace(); + cmd->dest = parseCompoundAddr(); + if (!cmd->dest) { + fail(QStringLiteral("m/t: missing destination address")); + return nullptr; + } + return cmd; + } + case 'x': + case 'y': + case 'g': + case 'v': { + skipSpace(); + if (m_i < m_n && !m_s[m_i].isLetterOrNumber() && m_s[m_i] != QLatin1Char('\n') + && peek() != QLatin1Char('{')) { + const QChar delim = get(); + cmd->re = parseDelimited(delim); + } + skipSpace(); + cmd->child = parseLoopBody(); + if (!cmd->child && !m_error.isEmpty()) { + return nullptr; + } + return cmd; + } + case 'X': + case 'Y': { + // Recognised but handled by the Kate layer via peelFileLoop(). Parse + // the regexp and inner so a stray X/Y here still parses. + skipSpace(); + if (m_i < m_n && !m_s[m_i].isLetterOrNumber() && m_s[m_i] != QLatin1Char('\n')) { + const QChar delim = get(); + cmd->re = parseDelimited(delim); + } + skipSpace(); + cmd->child = parseLoopBody(); + return cmd; + } + case '{': + return parseGroupBody(cmd); + case '!': + case '>': + case '<': + case '|': + cmd->text = parseToEol(); + return cmd; + case '\n': + cmd->c = QLatin1Char('\n'); + return cmd; + default: + fail(QStringLiteral("unknown command '%1'").arg(cc)); + return nullptr; + } + } + + // The body of x/y/g/v: either a brace group or a single command (which may + // itself be empty -> defaults applied by the evaluator). + Cmd *parseLoopBody() + { + skipSpace(); + if (peek() == QLatin1Char('{')) { + get(); + Cmd *grp = new Cmd(QLatin1Char('{')); + return parseGroupBody(grp); + } + if (atEnd() || peek() == QLatin1Char('\n')) { + return nullptr; // empty command; evaluator supplies the default + } + return parseCmd(); + } + + // Parse the members of a { } group until the matching '}'. + Cmd *parseGroupBody(Cmd *grp) + { + skipBlankAndSep(); + while (!atEnd() && peek() != QLatin1Char('}')) { + Cmd *c = parseCmd(); + if (!c) { + return nullptr; + } + grp->children.append(c); + skipBlankAndSep(); + } + if (peek() == QLatin1Char('}')) { + get(); + } + return grp; + } + + QString m_s; + int m_i = 0; + int m_n = 0; + QString m_error; +}; + +// ---- evaluator ------------------------------------------------------------- + +struct Range { + int p1 = 0; + int p2 = 0; +}; + +class Eval +{ +public: + Eval(const QString &text) : m_text(text), m_nc(text.size()) {} + + QString error() const { return m_error; } + bool failed() const { return !m_error.isEmpty(); } + + const QList &edits() const { return m_edits; } + QString output() const { return m_out; } + Range dot() const { return m_dot; } + void setDot(Range r) { m_dot = r; } + + // Execute a command tree against the snapshot. `dot` is the current dot. + void exec(Cmd *cmd, Range dot) + { + if (failed() || !cmd) { + return; + } + m_dot = dot; + + // Resolve the command's operating range (its address, or dot). + Range r = dot; + if (cmd->addr) { + r = evalAddr(cmd->addr, dot); + if (failed()) { + return; + } + } + + switch (cmd->c.unicode()) { + case '{': { + // Group: each child runs with dot = the group's range, changes not + // visible to siblings (all against the snapshot). + for (Cmd *ch : cmd->children) { + exec(ch, r); + if (failed()) { + return; + } + } + m_dot = r; + return; + } + case '\n': { + // Bare address: set dot to the range. + m_dot = r; + return; + } + case 'a': { + addEdit(r.p2, r.p2, cmd->text); + m_dot = {r.p2, r.p2 + cmd->text.size()}; + return; + } + case 'i': { + addEdit(r.p1, r.p1, cmd->text); + m_dot = {r.p1, r.p1 + cmd->text.size()}; + return; + } + case 'c': { + addEdit(r.p1, r.p2, cmd->text); + m_dot = {r.p1, r.p1 + cmd->text.size()}; + return; + } + case 'd': { + addEdit(r.p1, r.p2, QString()); + m_dot = {r.p1, r.p1}; + return; + } + case 'p': { + if (!m_out.isEmpty()) { + m_out.append(QLatin1Char('\n')); + } + m_out.append(m_text.mid(r.p1, r.p2 - r.p1)); + m_dot = r; + return; + } + case '=': { + if (!m_out.isEmpty()) { + m_out.append(QLatin1Char('\n')); + } + if (cmd->text == QLatin1String("#")) { + m_out.append(QStringLiteral("#%1,#%2").arg(r.p1).arg(r.p2)); + } else { + const int l1 = lineOf(r.p1); + const int l2 = lineOf(r.p2); + m_out.append(QStringLiteral("%1,%2").arg(l1).arg(l2)); + } + m_dot = r; + return; + } + case 'k': { + m_mark = r; + m_haveMark = true; + // k does not set dot. + return; + } + case 's': { + execSubstitute(cmd, r); + return; + } + case 'm': + case 't': { + execMoveCopy(cmd, r); + return; + } + case 'x': + case 'y': + case 'g': + case 'v': { + execLoop(cmd, r); + return; + } + case '!': + case '>': + case '<': + case '|': { + execShell(cmd, r); + return; + } + default: + fail(QStringLiteral("unimplemented command '%1'").arg(cmd->c)); + return; + } + } + +private: + void fail(const QString &m) + { + if (m_error.isEmpty()) { + m_error = m; + } + } + + void addEdit(int start, int end, const QString &text) + { + if (start < 0 || end > m_nc || start > end) { + fail(QStringLiteral("edit out of range")); + return; + } + m_edits.append(SamEdit{start, end, text}); + } + + int lineOf(int offset) const + { + // 1-based line number of the character offset. + int line = 1; + for (int i = 0; i < offset && i < m_nc; i++) { + if (m_text[i] == QLatin1Char('\n')) { + line++; + } + } + return line; + } + + // Build a QRegularExpression from a sam pattern. The empty pattern reuses + // the last compiled pattern (sam behaviour). + QRegularExpression compile(const QString &pat) + { + QString p = pat; + if (p.isEmpty()) { + p = m_lastRe; + } else { + m_lastRe = p; + } + QRegularExpression re(p); + if (!re.isValid()) { + fail(QStringLiteral("bad regexp: %1").arg(re.errorString())); + } + return re; + } + + // --- address evaluation (mirrors address.c address()) ---- + Range evalAddr(Addr *ap, Range a) + { + Range cur = a; + for (Addr *p = ap; p; p = p->next) { + switch (p->kind.unicode()) { + case 'l': + cur = lineAddr(p->num, cur, m_sign); + m_sign = 0; + break; + case '#': + cur = charAddr(p->num, cur, m_sign); + m_sign = 0; + break; + case '.': + cur = m_dot; + break; + case '$': + cur = {m_nc, m_nc}; + break; + case '\'': + if (!m_haveMark) { + fail(QStringLiteral("no mark set")); + return cur; + } + cur = m_mark; + break; + case '?': + case '/': { + int sign = (p->kind == QLatin1Char('?')) ? -1 : 1; + cur = search(p->re, cur, sign); + if (failed()) { + return cur; + } + break; + } + case ',': + case ';': { + Range a1; + if (p->left) { + a1 = evalAddr(p->left, cur); + } else { + a1 = {0, 0}; + } + if (failed()) { + return cur; + } + Range startForA2 = cur; + if (p->kind == QLatin1Char(';')) { + m_dot = a1; + startForA2 = a1; + } + Range a2; + if (p->next) { + a2 = evalAddr(p->next, startForA2); + } else { + a2 = {m_nc, m_nc}; + } + if (failed()) { + return cur; + } + Range out{a1.p1, a2.p2}; + if (out.p2 < out.p1) { + fail(QStringLiteral("addresses out of order")); + return cur; + } + return out; // , and ; are terminal (return) + } + case '+': + case '-': { + m_sign = (p->kind == QLatin1Char('+')) ? 1 : -1; + // If no operand follows (or another sign), default to line 1. + if (!p->next || p->next->kind == QLatin1Char('+') + || p->next->kind == QLatin1Char('-')) { + cur = lineAddr(1, cur, m_sign); + m_sign = 0; + } + break; + } + default: + break; + } + } + return cur; + } + + // charaddr: #n relative to the sign (address.c charaddr()). + Range charAddr(int l, Range addr, int sign) + { + Range a = addr; + if (sign == 0) { + a.p1 = a.p2 = l; + } else if (sign < 0) { + a.p2 = a.p1 = addr.p1 - l; + } else { + a.p1 = a.p2 = addr.p2 + l; + } + if (a.p1 < 0 || a.p2 > m_nc) { + fail(QStringLiteral("char address out of range")); + } + return a; + } + + // lineaddr: line l relative to sign (address.c lineaddr()). + Range lineAddr(int l, Range addr, int sign) + { + Range a; + if (sign >= 0) { + int p; + if (l == 0) { + if (sign == 0 || addr.p2 == 0) { + return {0, 0}; + } + a.p1 = addr.p2; + p = addr.p2 - 1; + } else { + int n; + if (sign == 0 || addr.p2 == 0) { + p = 0; + n = 1; + } else { + p = addr.p2 - 1; + n = (charAt(p) == QLatin1Char('\n')) ? 1 : 0; + p++; + } + while (n < l) { + if (p >= m_nc) { + fail(QStringLiteral("line address out of range")); + return addr; + } + if (charAt(p++) == QLatin1Char('\n')) { + n++; + } + } + a.p1 = p; + } + // extend to end of this line (through the newline) + int p2 = a.p1; + while (p2 < m_nc && charAt(p2++) != QLatin1Char('\n')) { + // advance + } + a.p2 = p2; + return a; + } else { + int p = addr.p1; + if (l == 0) { + a.p2 = addr.p1; + } else { + int n = 0; + while (n < l) { + if (p == 0) { + if (++n != l) { + fail(QStringLiteral("line address out of range")); + return addr; + } + } else { + const QChar c = charAt(p - 1); + if (c != QLatin1Char('\n') || ++n != l) { + p--; + } + } + } + a.p2 = p; + } + // back up to beginning of the line + while (p > 0 && charAt(p - 1) != QLatin1Char('\n')) { + p--; + } + a.p1 = p; + return a; + } + } + + QChar charAt(int i) const + { + return (i >= 0 && i < m_nc) ? m_text[i] : QChar(QLatin1Char('\n')); + } + + // Forward (sign>0) / backward (sign<0) search, wrapping, like nextmatch(). + Range search(const QString &pat, Range a, int sign) + { + QRegularExpression re = compile(pat); + if (failed()) { + return a; + } + if (sign >= 0) { + int from = a.p2; + QRegularExpressionMatch m = re.match(m_text, from); + if (!m.hasMatch()) { + m = re.match(m_text, 0); // wrap + } + if (!m.hasMatch()) { + fail(QStringLiteral("search failed: %1").arg(pat.isEmpty() ? m_lastRe : pat)); + return a; + } + return {int(m.capturedStart()), int(m.capturedEnd())}; + } else { + // Backward: find the last match that ends at or before a.p1, + // else wrap to the last match in the text. + int limit = a.p1; + Range best{-1, -1}; + int from = 0; + while (true) { + QRegularExpressionMatch m = re.match(m_text, from); + if (!m.hasMatch()) { + break; + } + const int s = int(m.capturedStart()); + const int e = int(m.capturedEnd()); + if (e <= limit) { + best = {s, e}; + } else { + break; + } + from = (e > s) ? e : e + 1; + } + if (best.p1 < 0) { + // wrap: take the very last match in the whole text + from = 0; + while (true) { + QRegularExpressionMatch m = re.match(m_text, from); + if (!m.hasMatch()) { + break; + } + best = {int(m.capturedStart()), int(m.capturedEnd())}; + const int s = best.p1; + const int e = best.p2; + from = (e > s) ? e : e + 1; + } + } + if (best.p1 < 0) { + fail(QStringLiteral("search failed: %1").arg(pat.isEmpty() ? m_lastRe : pat)); + return a; + } + return best; + } + } + + // s/re/text/ with count and g flag (xec.c s_cmd()). + void execSubstitute(Cmd *cmd, Range r) + { + QRegularExpression re = compile(cmd->re); + if (failed()) { + return; + } + int n = cmd->num; + int op = -1; + bool didSub = false; + int lastEnd = r.p1; + int p1 = r.p1; + while (p1 <= r.p2) { + QRegularExpressionMatch m = re.match(m_text, p1); + if (!m.hasMatch() || int(m.capturedStart()) > r.p2) { + break; + } + int ms = int(m.capturedStart()); + int me = int(m.capturedEnd()); + if (me > r.p2) { + break; + } + // advance logic (empty match handling) + if (ms == me) { + if (ms == op) { + p1++; + continue; + } + p1 = me + 1; + } else { + p1 = me; + } + op = me; + + if (--n > 0) { + continue; + } + + const QString repl = expandReplacement(cmd->text, m); + addEdit(ms, me, repl); + if (failed()) { + return; + } + didSub = true; + lastEnd = me; + if (!cmd->global) { + break; + } + } + if (!didSub && m_nest == 0) { + fail(QStringLiteral("no match for substitution")); + return; + } + m_dot = {r.p1, lastEnd}; + } + + // Expand & and \1..\9 in an s replacement using the match captures. + QString expandReplacement(const QString &tmpl, const QRegularExpressionMatch &m) + { + QString out; + for (int i = 0; i < tmpl.size(); i++) { + const QChar c = tmpl[i]; + if (c == QLatin1Char('\\') && i + 1 < tmpl.size()) { + const QChar d = tmpl[i + 1]; + if (d.isDigit()) { + const int g = d.unicode() - '0'; + out.append(m.captured(g)); + i++; + continue; + } + if (d == QLatin1Char('n')) { + out.append(QLatin1Char('\n')); + i++; + continue; + } + if (d == QLatin1Char('\\')) { + out.append(QLatin1Char('\\')); + i++; + continue; + } + out.append(d); + i++; + continue; + } + if (c == QLatin1Char('&')) { + out.append(m.captured(0)); + continue; + } + out.append(c); + } + return out; + } + + // m/t: move or copy the range to after the destination (xec.c m_cmd()). + void execMoveCopy(Cmd *cmd, Range r) + { + Range dest = evalAddr(cmd->dest, m_dot); + if (failed()) { + return; + } + const QString moved = m_text.mid(r.p1, r.p2 - r.p1); + const int at = dest.p2; + // Copy (t): just insert at dest. Move (m): insert at dest and delete + // the source. Both addresses are in original offsets; the Kate layer + // applies all edits against the snapshot, so order does not matter as + // long as source and insertion do not overlap. + if (cmd->c == QLatin1Char('t')) { + addEdit(at, at, moved); + m_dot = {at, at + moved.size()}; + return; + } + // move: forbid destination inside the source + if (at > r.p1 && at < r.p2) { + fail(QStringLiteral("m: destination inside source")); + return; + } + addEdit(r.p1, r.p2, QString()); // delete source + addEdit(at, at, moved); // insert at dest + m_dot = {at, at + moved.size()}; + } + + // x/y/g/v loops (xec.c looper()). + void execLoop(Cmd *cmd, Range r) + { + const QChar c = cmd->c; + m_nest++; + + if (c == QLatin1Char('g') || c == QLatin1Char('v')) { + QRegularExpression re = compile(cmd->re); + if (failed()) { + m_nest--; + return; + } + QRegularExpressionMatch m = re.match(m_text, r.p1); + const bool contains = m.hasMatch() && int(m.capturedStart()) <= r.p2 + && int(m.capturedStart()) >= r.p1; + const bool run = (c == QLatin1Char('g')) ? contains : !contains; + if (run) { + runLoopBody(cmd, r); + } + m_nest--; + return; + } + + // x and y. + if (c == QLatin1Char('y')) { + // y: run on the gaps between matches of re within r. + QRegularExpression re = compile(cmd->re); + if (failed()) { + m_nest--; + return; + } + int prev = r.p1; + int from = r.p1; + while (from <= r.p2) { + QRegularExpressionMatch m = re.match(m_text, from); + if (!m.hasMatch() || int(m.capturedEnd()) > r.p2) { + break; + } + int ms = int(m.capturedStart()); + int me = int(m.capturedEnd()); + runLoopBody(cmd, {prev, ms}); + if (failed()) { + m_nest--; + return; + } + prev = me; + from = (me > ms) ? me : me + 1; + } + runLoopBody(cmd, {prev, r.p2}); + m_nest--; + return; + } + + // x: run on each match of re within r (default re handled by compile). + QRegularExpression re = compile(cmd->re); + if (failed()) { + m_nest--; + return; + } + int op = -1; + int p = r.p1; + while (p <= r.p2) { + QRegularExpressionMatch m = re.match(m_text, p); + if (!m.hasMatch() || int(m.capturedEnd()) > r.p2) { + break; + } + int ms = int(m.capturedStart()); + int me = int(m.capturedEnd()); + if (ms == me) { + if (ms == op) { + p++; + continue; + } + p = me + 1; + } else { + p = me; + } + op = me; + runLoopBody(cmd, {ms, me}); + if (failed()) { + m_nest--; + return; + } + } + m_nest--; + } + + // Run a loop's sub-command with dot bound to `sub`. An empty x/y body + // defaults to p. + void runLoopBody(Cmd *cmd, Range sub) + { + if (!cmd->child) { + // default: p + Cmd pcmd(QLatin1Char('p')); + exec(&pcmd, sub); + return; + } + exec(cmd->child, sub); + } + + // Shell filters: < replace range with cmd stdout; > feed range to stdin; + // | filter range through cmd; ! run cmd (ignore output). + void execShell(Cmd *cmd, Range r) + { + const QString sh = cmd->text; + if (cmd->c == QLatin1Char('!')) { + runProcess(sh, QString(), nullptr); + return; + } + const QString input = m_text.mid(r.p1, r.p2 - r.p1); + if (cmd->c == QLatin1Char('>')) { + runProcess(sh, input, nullptr); + m_dot = r; + return; + } + QString out; + if (cmd->c == QLatin1Char('<')) { + runProcess(sh, QString(), &out); + addEdit(r.p1, r.p2, out); + m_dot = {r.p1, r.p1 + out.size()}; + return; + } + // '|' + runProcess(sh, input, &out); + addEdit(r.p1, r.p2, out); + m_dot = {r.p1, r.p1 + out.size()}; + } + + void runProcess(const QString &shellLine, const QString &input, QString *out) + { + QProcess proc; + proc.start(QStringLiteral("sh"), {QStringLiteral("-c"), shellLine}); + if (!proc.waitForStarted(3000)) { + fail(QStringLiteral("shell: failed to start")); + return; + } + if (!input.isEmpty()) { + proc.write(input.toUtf8()); + } + proc.closeWriteChannel(); + if (!proc.waitForFinished(15000)) { + proc.kill(); + fail(QStringLiteral("shell: timed out")); + return; + } + if (out) { + *out = QString::fromUtf8(proc.readAllStandardOutput()); + } + } + + QString m_text; + int m_nc = 0; + Range m_dot; + Range m_mark; + bool m_haveMark = false; + int m_sign = 0; // pending +/- sign for the next line/char atom + int m_nest = 0; // loop nesting (suppresses "no match" errors inside loops) + QString m_lastRe; // last compiled pattern (empty-regexp reuse) + QList m_edits; + QString m_out; + QString m_error; +}; + +// Apply a set of (snapshot-offset) edits to produce the resulting text. Edits +// must be non-overlapping; applied right-to-left so earlier offsets stay valid. +QString applyEdits(const QString &text, QList edits) +{ + std::stable_sort(edits.begin(), edits.end(), + [](const SamEdit &a, const SamEdit &b) { return a.start < b.start; }); + QString out = text; + for (int i = edits.size() - 1; i >= 0; i--) { + const SamEdit &e = edits[i]; + out.replace(e.start, e.end - e.start, e.text); + } + return out; +} + +} // namespace + +SamResult SamEngine::run(const QString &program, const QString &text, int dotStart, int dotEnd) +{ + SamResult res; + + Parser parser(program); + Cmd *root = parser.parseProgram(); + if (!root) { + res.ok = false; + res.error = parser.error().isEmpty() ? QStringLiteral("parse error") : parser.error(); + return res; + } + + Eval ev(text); + Range dot{qBound(0, dotStart, text.size()), qBound(0, dotEnd, text.size())}; + ev.exec(root, dot); + + if (ev.failed()) { + res.ok = false; + res.error = ev.error(); + return res; + } + + res.edits = ev.edits(); + std::stable_sort(res.edits.begin(), res.edits.end(), + [](const SamEdit &a, const SamEdit &b) { return a.start < b.start; }); + const Range nd = ev.dot(); + res.dotStart = nd.p1; + res.dotEnd = nd.p2; + res.output = ev.output(); + res.applied = applyEdits(text, res.edits); + return res; +} + +SamEngine::FileLoop SamEngine::peelFileLoop(const QString &program) +{ + FileLoop fl; + int i = 0; + const int n = program.size(); + // Skip leading whitespace/newlines. + while (i < n && (program[i] == QLatin1Char(' ') || program[i] == QLatin1Char('\t') + || program[i] == QLatin1Char('\n'))) { + i++; + } + if (i >= n || (program[i] != QLatin1Char('X') && program[i] != QLatin1Char('Y'))) { + return fl; // not a file loop + } + fl.matched = true; + fl.negate = (program[i] == QLatin1Char('Y')); + i++; + // Skip spaces before the delimiter. + while (i < n && (program[i] == QLatin1Char(' ') || program[i] == QLatin1Char('\t'))) { + i++; + } + // Optional delimited file regex (any non-alphanumeric printable delimiter). + if (i < n && !program[i].isLetterOrNumber() && program[i] != QLatin1Char('\n')) { + const QChar delim = program[i++]; + QString re; + while (i < n && program[i] != QLatin1Char('\n')) { + const QChar c = program[i]; + if (c == delim) { + i++; + break; + } + if (c == QLatin1Char('\\') && i + 1 < n && program[i + 1] == delim) { + re.append(delim); + i += 2; + continue; + } + re.append(c); + i++; + } + fl.fileRegex = re; + } + // Skip spaces before the inner command. + while (i < n && (program[i] == QLatin1Char(' ') || program[i] == QLatin1Char('\t'))) { + i++; + } + fl.inner = program.mid(i); + return fl; +} + +} // namespace katecustom diff --git a/src/sam/samengine.h b/src/sam/samengine.h new file mode 100644 index 0000000..c39f003 --- /dev/null +++ b/src/sam/samengine.h @@ -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 +#include + +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 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 diff --git a/src/sam/test_samengine.cpp b/src/sam/test_samengine.cpp new file mode 100644 index 0000000..222edec --- /dev/null +++ b/src/sam/test_samengine.cpp @@ -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 +#include + +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/ 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"