sam X/Y: target open buffers, not the whole project

Scope X/Y to the set of documents Kate currently has open
(Application::documents()) — acme's open-window set — instead of the project
index on disk. The regex matches each buffer's local file path (or display
name for an unsaved scratch buffer). Nothing is opened or read from disk; the
live Documents are edited in place, each its own undo step.

Smaller blast radius than a project-wide sweep and closer to acme's X/Y, which
iterate open windows. Drops the ProjectIndexer dependency from the X/Y path.
Docs (SAM.md, PLAN.md) updated. 18/18 ctest green.
This commit is contained in:
Levi Neely 2026-10-08 15:11:52 +02:00
parent b9a539d99c
commit df287b3a7f
3 changed files with 42 additions and 41 deletions

View File

@ -382,13 +382,15 @@ program, click **Run** (or Ctrl+Return); each Run is one undo step.
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/re/ cmd` runs `cmd` on every **open buffer** whose path matches `re` (`Y` =
non-match). The set is the documents Kate currently has open
(`Application::documents()`) — acme's open-window set — matched by local file
path, or display name for an unsaved scratch buffer. Nothing is opened or read
from disk; the live `Document`s are edited in place. 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).
`X`/`Y` is undoable **per buffer** (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`).

View File

@ -80,15 +80,18 @@ matches with `x`/`y`, guard with `g`/`v`, group with `{}`:
0/start/,/end/ x/[a-z]+/ s/.*/[&]/ bracket every lowercase word in a region
```
Multi-file `X`/`Y` applies an inner program across the project:
Multi-file `X`/`Y` applies an inner program across the **open buffers**:
```
X/\.cpp$/ ,s/old_api/new_api/g rewrite every .cpp in the project
Y/_test\./ ,x/TODO/ d drop TODO lines in non-test files
X/\.cpp$/ ,s/old_api/new_api/g rewrite every open .cpp buffer
Y/_test\./ ,x/TODO/ d drop TODO lines in non-test buffers
```
(The file set is the project index, matched by path. Edits land in buffers, not
on disk — review and save deliberately. Undo is per file for `X`/`Y`.)
(`X`/`Y` iterate the set of documents Kate currently has open — acme's
open-window set — not the project on disk. The regex matches each buffer's file
path, or its display name for an unsaved scratch buffer. Nothing is opened or
read from disk; edits land in the live buffers, each its own undo step. Review
and save deliberately.)
## Practical guidance

View File

@ -14,11 +14,12 @@
#include "sampanel.h"
#include "samengine.h"
#include <KTextEditor/MainWindow>
#include <KTextEditor/View>
#include <KTextEditor/Application>
#include <KTextEditor/Command>
#include <KTextEditor/Document>
#include <KTextEditor/Editor>
#include <KTextEditor/Command>
#include <KTextEditor/MainWindow>
#include <KTextEditor/View>
#include <KActionCollection>
#include <KXMLGUIClient>
@ -990,13 +991,8 @@ void OllieView::runSamFileLoop(bool negate, const QString &fileRegex, const QStr
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."));
m_samPanel->showError(i18n("X/Y: no command to run on matched buffers."));
return;
}
QRegularExpression fileRe(fileRegex);
@ -1005,50 +1001,50 @@ void OllieView::runSamFileLoop(bool negate, const QString &fileRegex, const QStr
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);
// X/Y iterate the set of OPEN buffers (like acme's open-window set), not the
// whole project on disk. Nothing is opened or read from disk: we edit the
// documents Kate already has loaded, in place, each as its own undo step.
KTextEditor::Editor *editor = KTextEditor::Editor::instance();
KTextEditor::Application *app = editor ? editor->application() : nullptr;
const QList<KTextEditor::Document *> docs =
app ? app->documents() : QList<KTextEditor::Document *>{};
if (docs.isEmpty()) {
m_samPanel->showError(i18n("X/Y: no open buffers."));
return;
}
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();
for (KTextEditor::Document *doc : docs) {
if (!doc) {
continue;
}
// Match the regex against the buffer's path (its local file path, or its
// display name for an unsaved scratch buffer).
const QUrl url = doc->url();
const QString target =
url.isLocalFile() ? url.toLocalFile() : doc->documentName();
const bool hit = fileRe.match(target).hasMatch();
if (hit == negate) {
continue; // X runs on hits; Y runs on misses
}
matched++;
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));
m_samPanel->showOutput(QStringLiteral("%1:\n%2").arg(target, output));
}
} else {
// A "no match" in a given file is normal during a sweep; report it
// A "no match" in a given buffer is normal during a sweep; report it
// quietly rather than as a hard error.
m_samPanel->showOutput(QStringLiteral("%1: %2").arg(rel, error));
m_samPanel->showOutput(QStringLiteral("%1: %2").arg(target, error));
}
}
m_samPanel->showOutput(
i18n("X/Y done: %1 file(s) matched, %2 changed, %3 failed.", matched, changed, failed));
i18n("X/Y done: %1 open buffer(s) matched, %2 changed.", matched, changed));
}
QAction *OllieView::findActionByName(const QString &nameOrText) const