docs(sam): document PCRE deviations + implications for katesam usage

Add docs/SAM.md — a user-facing reference for the regex deviation, with
examples verified against the engine:
  - leftmost-greedy (PCRE) vs leftmost-longest (sam): diverges only on
    overlapping alternation (a|ab); order alternatives longest-first.
  - ^/$ anchor buffer-wide, not per-line, by default. Workarounds:
    structural ,x/.+/ … (preferred) or the (?m) inline flag.
  - identical behaviour table (. and newlines, empty-match advance, &/\N).
  - PCRE bonuses sam lacks: \d \w \b, lookahead, non-greedy, (?i)(?m)(?s).

Cross-link from PLAN.md and surface the two biggest gotchas (buffer-wide
anchors, alternation order) directly in the Sam panel hint label.
This commit is contained in:
Levi Neely 2026-10-08 14:03:55 +02:00
parent 25a0276e8e
commit 997055f0b5
3 changed files with 153 additions and 6 deletions

View File

@ -384,11 +384,16 @@ program, click **Run** (or Ctrl+Return); each Run is one undo step.
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.
plan9 `regexp(7)`. Everyday patterns match identically; the one semantic
divergence is leftmost-greedy vs sam's leftmost-longest (bites only on
overlapping alternation like `a|ab`), and `^`/`$` anchor buffer-wide not
per-line by default. katesam gains PCRE extras (`\d \w \b`, lookahead,
non-greedy, inline flags). Full user-facing treatment with verified examples
and workarounds in **`docs/SAM.md`**. 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

139
docs/SAM.md Normal file
View File

@ -0,0 +1,139 @@
# katesam — sam structural-regexp editing in Kate
The **Sam** tool view (left sidebar) runs the plan9
[sam](https://doc.cat-v.org/plan_9/4th_edition/papers/sam/) command language
against the active document, or project-wide via `X`/`Y`. Type a program, press
**Ctrl+Return** or click **Run**. Each Run is one undo step (Ctrl+Z reverts the
whole Run; multi-file `X`/`Y` undoes per file).
This document covers the **regex deviation** — katesam uses PCRE
(`QRegularExpression`), not plan9 `regexp(7)` — and what that means in practice.
Every example below is verified against the engine.
## TL;DR
- Everyday patterns behave **identically** to sam.
- One real semantic difference: **PCRE is leftmost-greedy, sam is
leftmost-longest.** It only bites on *overlapping alternation* (`a|ab`).
- `^` and `$` anchor to the **whole buffer, not each line**, by default. This is
the most common surprise. Use a structural loop or the `(?m)` flag.
- You *gain* PCRE features sam never had: `\d \w \s \b`, lookahead/lookbehind,
non-greedy `*?`, backreferences, inline flags `(?i)` `(?m)` `(?s)`.
---
## The one semantic divergence: greedy vs longest
sam's `regexp(7)` matches the **leftmost-longest** substring. PCRE matches
**leftmost**, then resolves alternation **left-to-right, first wins** (greedy
within a branch, but alternation order decides between branches).
| program | input | sam (leftmost-longest) | **katesam (PCRE)** |
|---|---|---|---|
| `,s/a\|ab/X/` | `ab` | `X` (matches `ab`) | **`Xb`** (matches `a`) |
| `,s/ab\|a/X/` | `ab` | `X` | `X` |
**Implication:** when alternatives overlap, **order them longest-first**
(`ab|a`, not `a|ab`). This is the only case where a working sam command can
silently produce a different edit in katesam. Non-overlapping alternation
(`cat|dog`) and all non-alternation patterns are unaffected.
Quantifier greediness is the same in both (`a.*c` on `axxcxxc` → whole string in
both). katesam additionally offers non-greedy `.*?`:
| program | input | result |
|---|---|---|
| `,s/a.*c/X/` | `axxcxxc` | `X` (greedy, same as sam) |
| `,s/a.*?c/X/` | `axxcxxc` | `Xxxc` (non-greedy, PCRE bonus) |
---
## Anchors `^` / `$` are buffer-wide, not per-line
By default PCRE anchors `^`/`$` to the **start/end of the whole buffer**, so a
global substitution touches only the first/last position — **not** every line:
| program | input | result |
|---|---|---|
| `,s/^/> /g` | `a⏎b⏎c` | `> a⏎b⏎c` (only line 1!) |
| `,s/$/;/g` | `a⏎b⏎c` | `a⏎b⏎c;` (only the end!) |
This differs from how people expect line-oriented edits to work. **Two correct
ways** to act per line:
**1. Structural (sam-idiomatic, preferred).** Loop over lines with `x`, then act
on each line's dot. `.+` matches the non-newline run of each line:
```
,x/.+/ i/> / prefix every (non-empty) line with "> " → > a⏎> b⏎> c
,x/.+/ a/;/ append ";" to every line → a;⏎b;⏎c;
```
**2. PCRE multiline flag `(?m)`** (a bonus sam lacks). Makes `^`/`$` match at
every line boundary:
```
,s/(?m)^/> /g → > a⏎> b⏎> c
,s/(?m)$/;/g → a;⏎b;⏎c;
```
Prefer the structural form — it is the real sam way and composes with other
loop/guard steps. Reach for `(?m)` for quick one-liners.
---
## What matches identically (no surprises)
| behaviour | katesam (PCRE) | sam | same? |
|---|---|---|---|
| `.` crosses newline | no | no | ✅ |
| `[^z]+` crosses newline | yes | yes | ✅ |
| empty-match advance `s/x*/-/g` on `axbx` → `-a-b-` | ✅ | ✅ | ✅ |
| greedy quantifiers `* + ?` | ✅ | ✅ | ✅ |
| character classes `[a-z]`, groups `( )`, `|` | ✅ | ✅ | ✅ |
| `&` whole-match, `\1..\9` groups in replacement | ✅ | ✅ | ✅ |
> `.` not crossing newlines matches sam, so line-structured descent
> (`,x/.*\n/ ...`) behaves as expected. Use `(?s)` if you *want* `.` to span
> newlines (PCRE bonus).
---
## PCRE features sam never had (free upgrades)
All verified working in katesam:
| feature | example | effect |
|---|---|---|
| shorthand classes | `,s/\d+/N/g` on `a12b345` | `aNbN` |
| `\w+` in a loop | `,x/\w+/ c/W/` on `foo bar` | `W W` |
| word boundary `\b` | `,s/\bfoo\b/X/g` on `foo foobar foo` | `X foobar X` |
| backreference | `,s/(\w)\1/D/g` on `aabbc` | `DDc` |
| lookahead | `,s/foo(?=bar)/X/g` on `foobar fooqux` | `Xbar fooqux` |
| inline flags | `(?i)` ignore-case, `(?m)` multiline, `(?s)` dotall | per-pattern |
These make many edits *easier* than in real sam. The tradeoff is that a pattern
written for katesam may not be portable back to sam.
---
## Practical guidance
- **Order overlapping alternatives longest-first** (`\.tar\.gz|\.gz`, not the
reverse). The only silent divergence.
- **For per-line edits, prefer `,x/.+/ …`** over `^`/`$`. If you use anchors
globally, remember they are buffer-wide, or add `(?m)`.
- **Lean on PCRE extras** (`\b`, `\d`, lookahead) freely — they are reliable;
just know they are not sam-portable.
- **Empty-match globals** (`s/x*/…/g`) advance one position per null match, as in
sam — safe, but as always with `*`, double-check the result.
## Why PCRE and not regexp(7)
`QRegularExpression` ships with Qt (already a dependency) and gives a richer,
familiar syntax for free. plan9 `regexp(7)` is a Rune-based NFA woven into sam's
own buffer types; using it would mean porting that engine. The tradeoff accepted
here: everyday fidelity plus extra power, at the cost of exact leftmost-longest
semantics on overlapping alternation. If that matters for your workflow, the
engine's regex calls are isolated in `SamEngine` and could be swapped for a
ported `regexp.c` later.

View File

@ -55,7 +55,10 @@ SamPanel::SamPanel(QWidget *parent)
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."),
"<tt>X/\\.cpp$/ ,s/old/new/g</tt> (project-wide). Ctrl+Return runs.<br/>"
"Regex is PCRE: <tt>^</tt>/<tt>$</tt> are buffer-wide (use "
"<tt>,x/.+/ …</tt> or <tt>(?m)</tt> per line); order overlapping "
"alternatives longest-first."),
this);
hint->setWordWrap(true);
hint->setTextFormat(Qt::RichText);