apex · updated October 8, 2026 · 7507b874a9b6

The Edit Language (apex-edit)

Relevant source files

apex-edit is apex's implementation of acme's Edit command: sam's structural-regular-expression command language (,x/foo/ c/bar/, /re/;/re2/p, s/a/b/g, { … } blocks and so on), plus the Plan 9 regular expressions it uses. It is a line-by-line port of plan9port's src/cmd/acme/{regx.c, edit.c, ecmd.c, elog.c}. It is also a pure library. It reads a rune-indexed text, runs a program, and returns a description of what should happen: a list of changes in original coordinates, the new dot, the printed output, any warnings, and intents for commands that touch the world (files, shell pipes, undo). The caller decides how to carry those out.

The crate has no dependencies on the rest of apex (only thiserror), so you can test it against the reference implementation, plan9port's sam. Inside apex the main caller is the node. Node::run_edit runs a program over a window's body and lowers the resulting changes into buffer entries in one undo group (see Buffers, Views and Undo for the rope text that implements the crate's Text trait). The apex command also uses it directly for apex text read -addr.

Architecture

The crate has four modules, one for each acme source file:

Module Ported from Responsibility
regx.rs regx.c Compile a pattern into forward and backward instruction programs; run a leftmost-longest Thompson simulation with wrap-around
parse.rs edit.c The command table, addresses, text and regexp arguments, { } blocks; builds a Cmd tree
exec.rs ecmd.c Edit::run, address evaluation, command semantics, loops, intents
elog.rs elog.c The change log: records changes against the original text, merges them as acme does, reverses them for application; adjust_dot

Everything is measured in runes (char), never in bytes. lib.rs defines the read-only Text trait that the engine reads through. It has implementations for Vec<char> and [char], and apex-core implements it for its rope (crates/apex-core/src/text.rs:107-117). lib.rs also defines the Error type, which carries acme's message verbatim, and apply, which splices a list of changes into a Vec<char>.

pub trait Text {
    fn len(&self) -> usize;
    fn char_at(&self, i: usize) -> char;
    fn read(&self, q0: usize, q1: usize) -> Vec<char> { … }
}
flowchart TD
    P["program (str)"] --> PA["Parser::parsecmd"]
    PA --> C["Cmd tree"]
    C --> EX["Exec::cmdexec"]
    EX --> AD["cmdaddress / lineaddr / charaddr"]
    AD --> RX["Regex::execute / bexecute"]
    EX --> RX
    EX --> EL["Elog: insert / delete / replace"]
    EX --> OUT["output (p, =)"]
    EX --> INT["intents (u w r e f b B D < | >)"]
    EL --> FIN["Elog::finish (merge, reverse)"]
    FIN --> CH["changes"]
    CH --> DOT["adjust_dot"]
    CH --> O["Outcome"]
    DOT --> O
    OUT --> O
    INT --> O

Sources: crates/apex-edit/README.md:1-27, crates/apex-edit/src/lib.rs:1-83, crates/apex-edit/Cargo.toml:1-16

The Edit::run API

Edit is the interpreter. Its only state is last_pat, the last regular expression seen. The empty pattern // refers to it, and it persists from one program to the next, as it does in acme. The node keeps one Edit per node for this reason (crates/apex-edit/src/exec.rs:97-137).

pub fn run(&mut self, text: &dyn Text, dot: (usize, usize),
           name: Option<&str>, program: &str) -> Result<Outcome>

dot is clamped to the text. name is the buffer's file name. = prints it as a prefix, and w, r and e use it as their default file. run parses one command at a time and executes it, then finishes the log and adjusts dot. Any error aborts the whole run with acme's message, and no changes are returned.

The Outcome has these fields (crates/apex-edit/src/exec.rs:71-95):

Field Meaning
changes: Vec<Change> Changes in application order (last recorded first). Each is "delete nd runes at q0, then insert text", in coordinates of the original text
dot: (usize, usize) Dot after the changes are applied
output: Vec<Printed> What p (Printed::Text) and = (Printed::Address, with a newline) printed, in order. output_string() concatenates them the way acme shows them in +Errors
intents: Vec<Intent> Effects for the caller to carry out
warnings: Vec<String> acme's warning: changes out of sequence, at most once

Because the changes are ordered from the end of the text backwards, applying them in sequence keeps every later change's coordinates valid. apex_edit::apply does exactly that (crates/apex-edit/src/lib.rs:76-83).

Intents

Commands with effects never act. They push an Intent (crates/apex-edit/src/exec.rs:39-60):

Command Intent Notes
u [n] Undo { n } Negative n redoes. A missing count defaults to 1
w [name] Write { name, q0, q1 } Defaults to the whole file. Errors with "can't write file with pending modifications" if the program has already changed the text
r [name] Read { name, q0, q1 } Replace the range with the file
e [name] Load { name } Replace the whole buffer
f [name] SetName { name }
b name Browse { name }
B list Open { list }
D [list] Delete { list } Empty means the current window
<, |, > cmd Pipe { kind, cmd, q0, q1 } PipeKind::From, Through and To. Dot is set to the range

X and Y (loops over files) fail with "X command is not supported". apex has no file menu.

How the node uses a run

Node::run_edit reads the body's buffer, dot and name and calls run. It then turns each change into an edit_op in a fresh undo group, appends a Select of the new dot, and carries out Undo intents itself by calling undo or redo |n| times. The remaining intents, the output string and the warnings go back to the caller (crates/apex-core/src/node.rs:1758-1791). The node page covers what happens to them next. The CLI's apex text read -addr A WIN runs the program Ap with a fresh Edit and prints output_string() (crates/apex-cli/src/main.rs:1459-1467).

Sources: crates/apex-edit/src/exec.rs:28-137, crates/apex-edit/src/exec.rs:260-336, crates/apex-core/src/node.rs:1758-1791, crates/apex-cli/src/main.rs:1447-1474

Regular expressions (regx.rs)

Compilation

Regex::compile takes a pattern in runes and compiles it twice into a single instruction vector. The first pass builds the forward program (start). The second sets backwards = true and builds the backward program (bstart), whose only difference is that the operands of each catenation are swapped. The backward machine can therefore read the text right to left (crates/apex-edit/src/regx.rs:420-442, crates/apex-edit/src/regx.rs:239-247).

The compiler is the C code's operator-precedence parser. It has an operand stack (andstack of {first, last} instruction fragments) and an operator stack (atorstack of (operator, subid)). The operators have the C's precedences: START 0, RBRA 1, LBRA 2, OR 3, CAT 4, STAR 5, PLUS 6, QUEST 7. Catenation is implicit: an operand that follows an operand first pushes CAT. evaluntil reduces the stack into instructions:

optimize then threads every next past Nops (crates/apex-edit/src/regx.rs:125-288).

The lexer understands \ escapes (\n is a newline; any other escaped rune is itself), * ? + | . ( ) ^ $ and […] classes (crates/apex-edit/src/regx.rs:294-322). These details follow acme:

The C code's fixed tables (NLIST, NPROG, NSTACK) are plain Vecs here, so there is no "regexp list overflow" error.

Execution: leftmost-longest with wrap-around

execute(t, startp, eof) searches forward. It is a Thompson simulation with two thread lists that are swapped at each rune. Each Thread carries an instruction index and a Rangeset of match and subexpression positions (crates/apex-edit/src/regx.rs:466-619). Two rules make it leftmost-longest:

A new thread at the program start is seeded at each position only while no match has been found. The search stops once a match exists and no threads are left. If the program's first instruction is a literal character, positions that cannot start a match are skipped quickly.

The eof argument controls wrap-around. At the end of the range (p >= eof or the end of the text) the machine runs one more step with a NUL character, so that pending $/End states resolve. If nothing has matched and eof == INFINITY, it then clears its lists and restarts at 0, scanning up to startp. Address searches (/re/) pass INFINITY and wrap. Commands (x, y, g, v, s) pass the end of their range and do not.

bexecute(t, startp) is the mirror image. It runs the backward program from startp down to 0 and wraps to the end of the text if nothing matched. It stores start positions negated so that addinst's "earlier start wins" comparison still prefers the match nearest startp. bnewmatch swaps q0/q1 back when it records a match (crates/apex-edit/src/regx.rs:408-416, crates/apex-edit/src/regx.rs:621-769). In the backward machine ^ tests the rune just read for \n, and $ looks at the rune after the position.

One consequence matters for the tests: forward $ (Eol) only matches when the current rune is \n. Past the end of the searched range the machine feeds NUL, so $ never matches at the end of a range, even if a newline follows it in the text. sam behaves differently here (see below).

Sources: crates/apex-edit/src/regx.rs:1-122, crates/apex-edit/src/regx.rs:125-442, crates/apex-edit/src/regx.rs:466-842

The parser (parse.rs)

The command table

CMDTAB is acme's cmdtab, copied verbatim. Each row says whether the command takes text, a regexp or a target address (m, t). It also gives the default sub-command (p for x y g v, f for X Y), the default address (DefAddr::No, Dot or All), whether the command takes a count (1 unsigned for s, 2 signed for u), and the characters that end a token argument (LINEX = newline, WORDX = blank or newline) (crates/apex-edit/src/parse.rs:10-92).

Commands Address default Argument
a i c dot text: /…/ with any non-alphanumeric delimiter, or lines ending at .
d p and newline dot none
s dot count, /re/rhs/, optional g
m t dot a target address
x y g v dot /re/ (optional for x), then a command or p
= dot token: empty, # or +
w whole file file name
r dot file name
e f b B D u X Y none (an address is an error) name, list or count
< | > dot shell command up to newline

Commands and arguments

Parser::new appends a newline if the program lacks one. parsecmd reads an optional compound address, then a command character, and fills in a Cmd: addr, re, arg (Arg::Cmd, Text or Addr), next (for {} chains), num and flag (crates/apex-edit/src/parse.rs:113-151, crates/apex-edit/src/parse.rs:324-430). It returns None at the end of input or at a }. A { parses sub-commands until then and links them through next.

Some small rules come from acme:

Addresses

An Addr is a linked list. typ is one of # l / ? . $ + - , ; " ', plus the internal * for "whole file". next chains simple addresses, and left/next hold the two sides of , and ; (crates/apex-edit/src/parse.rs:94-111).

simpleaddr parses one element and recurses. When two elements are juxtaposed, it inserts the + that sam implies, so ./x/ becomes . + /x/ and $-1 stays as written. It rejects juxtaposed forms that make no sense ("bad address syntax"). compoundaddr builds the right-recursive , and ; nodes (crates/apex-edit/src/parse.rs:466-533).

Sources: crates/apex-edit/src/parse.rs:1-533, crates/apex-edit/src/parse.rs:536-622

Command semantics (exec.rs)

The key idea: everything is relative to the original text

Every command, including commands inside loops and after earlier changes, reads the unmodified text. Changes go only into the Elog. Dot (Exec::dot) and the current command's address (Exec::addr, acme's global addr) are tracked in original coordinates. They are translated to final coordinates only at the end, by adjust_dot. So ,x/a/ c/X/ can address every match in the original text even though each c "changes" it, and a program like { a/X/ ; $a/Y/ } sees the original $ (crates/apex-edit/src/exec.rs:1-11, crates/apex-edit/src/exec.rs:139-152).

Dispatch

cmdexec resolves the command's address before dispatching. A missing address becomes ., or * for DefAddr::All. The result is evaluated into self.addr with cmdaddress. A { evaluates its own address once, then runs each sub-command with dot reset to that range. It fails with "dot extends past end of buffer during { command" if the range has gone out of bounds (crates/apex-edit/src/exec.rs:183-227). dispatch then does the following (crates/apex-edit/src/exec.rs:229-336):

Loops

x and y with a regexp go through looper. It first computes all ranges (matches for x, the gaps between matches for y), using the same empty-match rule as s. Then loopcmd runs the sub-command once per range with dot set to it. x without a regexp uses linelooper, which walks line ranges with lineaddr. Both increment nest around the loop (crates/apex-edit/src/exec.rs:555-637). Because the ranges are computed up front against the original text, changes made by the loop body cannot disturb the iteration. After the loop, dot is the last range, adjusted through the change log.

Address evaluation

cmdaddress walks the Addr list (crates/apex-edit/src/exec.rs:684-742):

Compiled regexps are cached by source in Exec::re, so a loop that applies the same pattern repeatedly does not recompile it (crates/apex-edit/src/exec.rs:172-181).

Sources: crates/apex-edit/src/exec.rs:139-840

The change log (elog.rs)

Elog holds one pending change (kind: Null, Insert, Delete or Replace, with q0, nd and the runes r) and a list of flushed changes. Every new change is first merged into the pending one if possible. Otherwise the pending change is flushed. The merging rules are acme's, and they matter because they decide where dot ends up (crates/apex-edit/src/elog.rs:1-158):

Operation Merges with a pending change when Result
replace(q0, q1, r) the pending change is a Replace that ends at or before q0, the gap is under MINSTRING (16) runes, and the total is under MAXSTRING One Replace spanning both. The gap's original runes are copied into the replacement text
insert(q0, r) the pending change is an Insert at the same q0 and the total is under MAXSTRING Text catenated. Long inserts are split into RBUFSIZE chunks
delete(q0, q1) the pending change is a Delete ending exactly at q0 nd grows

MAXSTRING is acme's RBUFSIZE, (32*1024+24)/4 runes. For example, replacing a and e in abcde yields one change, {q0: 0, nd: 5, text: "XbcdX"} (crates/apex-edit/src/elog.rs:208-216).

Out of sequence. acme expects each change to start at or after the previous one. The check is sequence(q0, limit): limit is the pending change's q0 for inserts and replaces, and its end for deletes. If a change starts before the limit, the log records warning: changes out of sequence (once), flushes the pending change and carries on. sam would refuse instead. The resulting addresses are what acme itself calls bogus. For example, { #4,#5d ; #1,#2d } on abcdef\n deletes b and then f rather than e. The crate reproduces this exactly, and apply clamps addresses to the text (crates/apex-edit/src/elog.rs:63-73, crates/apex-edit/tests/golden.rs:274-286).

finish flushes the pending change and reverses the log, so it is in application order (crates/apex-edit/src/elog.rs:152-158).

Dot. adjust_dot replays the changes against dot using acme's textdelete/textinsert rules: a deletion before an end pulls it back, and an insertion strictly before an end pushes it forward. One extra convention applies: if dot is the empty range exactly at a change's q0, dot grows to cover the inserted text. That is why a/X/ selects the X, and why a loop whose replacements merged into one change selects the merged span. The result is clamped to the final length (crates/apex-edit/src/elog.rs:161-198).

sequenceDiagram
    participant X as "Exec (x loop)"
    participant L as Elog
    participant R as "Edit::run"
    X->>L: "replace(1,2,N)"
    Note over L: pending Replace q0=1 nd=1
    X->>L: "replace(3,5,N)"
    Note over L: gap 1 lt MINSTRING, merge: q0=1 nd=4 text=NbN
    X->>R: commands done, dot = last range (3,5)
    R->>L: finish()
    L-->>R: changes reversed, warnings
    R->>R: "adjust_dot((3,5), changes)"
    Note over R: dot becomes (1,4), the merged span

Sources: crates/apex-edit/src/elog.rs:1-262, crates/apex-edit/tests/golden.rs:162-170

Where acme and sam differ

The crate follows acme wherever acme's and sam's implementations differ. The differential harness knows about each of these differences:

The crate also departs from acme in a few places: its tables are dynamic, X/Y are unsupported, and file addresses are errors.

Sources: crates/apex-edit/README.md:41-71, crates/apex-edit/tests/common/mod.rs:211-309

Tests and benchmarks

Unit tests live beside the code. regx.rs checks leftmost-longest matching (a|ab on xab matches ab), classes and anchors, subexpressions, the backward machine and wrap-around, and compile errors. parse.rs checks command shapes, address juxtaposition, loops and blocks, multi-line text and parse errors. elog.rs checks merging, the out-of-sequence warning, reversal and adjust_dot.

Golden tests (tests/golden.rs) are acceptance cases written from sam(1) and acme's semantics. Each case is (text, dot, program) → (text', dot', output), or an error with acme's exact message. They are grouped by feature: text commands, substitute, move and copy, addresses, loops, braces, =, the newline command, intents, errors, out-of-sequence behaviour, lastpat persisting across programs, and runes rather than bytes (日本語). The shared helper common::ours runs a program and applies its changes (crates/apex-edit/tests/golden.rs:1-306, crates/apex-edit/tests/common/mod.rs:26-64).

Differential tests (tests/sam_diff.rs) use plan9port's sam -d as the oracle. sam_binary looks for $SAM, then $PLAN9/bin/sam, then ~/.local/plan9/bin/sam. If none exists, the tests print a note and pass. common::sam writes the text to a temporary file, then pipes in the program followed by =# (to read back dot) and w (to read back the text). It separates p output (stdout) from = output and errors (stderr), and strips sam's ; #q0,#q1 suffix so = lines look like acme's. compare then matches errors by presence only (the messages differ), the text exactly, dot only for non-modifying programs, and output as a sorted multiset of lines (crates/apex-edit/tests/common/mod.rs:66-309). The suite has three tests:

tests/debug_one.rs is an ignored test that runs one case through both implementations from the T and P environment variables and prints both results. SAM_DEBUG dumps sam's raw streams (crates/apex-edit/tests/debug_one.rs:1-10).

Benchmarks (cargo bench, criterion) run over a text of about 1.1M runes (20,000 lines). They measure ,x/fox/ c/cat/, ,s/dog/cat/g, a line loop ,x/.*\n/ g/3$/ d, applying 20k changes, repeated regexp search with (quick|lazy) (brown|dog), and seeking to line 10000 with 10000=# (crates/apex-edit/benches/edit.rs:14-68). The README says sam builds on macOS with ./INSTALL -b and clang alone. Building sam and the rest of the workspace is covered on Building, Testing and Packaging.

Sources: crates/apex-edit/tests/sam_diff.rs:1-320, crates/apex-edit/tests/golden.rs:1-306, crates/apex-edit/tests/common/mod.rs:1-309, crates/apex-edit/benches/edit.rs:1-71, crates/apex-edit/README.md:29-39

Previous: Tiling and LayoutNext: The Server: Commands, Files and Processes