A practical reference for Plan 9 concepts, the acme editor, structural regular expressions, and how to apply these ideas on Linux.
Acme is a mouse-centric editor built around three buttons and a uniform interaction model. Every window is text. Every command is text. The editor is a file server.
| Button | Name | Action |
|---|---|---|
| 1 | Left | Select text (click to place, drag to select) |
| 2 | Middle | Execute: run the selected/clicked text as a command |
| 3 | Right | Plumb or search: opens file, jumps to line, or searches |
Chording: hold button 1 then click 2 to cut, hold button 1 then click 3 to paste.
Each window has a tag bar at the top containing the filename and a set of built-in commands: Del Snarf Get Put Look. Clicking any of these with button 2 executes them. You can type additional commands into the tag bar.
Any text in any window can be executed by middle-clicking. Acme looks up the text as a command in $PATH. Output appears in a new window or the scratch column.
middle-click "ls -la" -> runs ls -la, output in new window
middle-click "go build" -> runs go build in the current directory
middle-click "Edit ,d" -> deletes all text in current window
Right-clicking text sends it to the plumber. The plumber inspects the text and decides what to do: open a file, jump to a line, load a URL, open a man page. If no plumber rule matches, acme searches for the text in the current window.
right-click "main.go:42" -> opens main.go, jumps to line 42
right-click "http://example.com" -> opens browser
right-click "open" -> searches for "open" in current file
right-click "fork(2)" -> opens man page for fork section 2
These are typed into the tag bar or any window and executed with button 2.
:42 jump to line 42 in current window
:/pattern/ jump to next match of pattern
Edit = show current filename and line number
win open a new shell window inside acme
Dump save the current acme session layout to a file
Load restore a saved session layout
New open a new empty window
Del close current window
Font /lib/font/bit/lucsans/unicode.8.font set font
Font /usr/local/plan9/font/fixed/unicode.6x13.font
Get reload file from disk (button 2 on "Get" in tag)
Put write file to disk (button 2 on "Put" in tag)
Acme uses mostly standard Unix line-editing keys plus a few additions.
| Shortcut | Action |
|---|---|
| Ctrl-U | Delete from cursor to start of line |
| Ctrl-W | Delete previous word |
| Ctrl-A | Move to start of line |
| Ctrl-E | Move to end of line |
| Ctrl-F | Fetch/complete filename |
| Ctrl-H | Backspace |
| Ctrl-I | Tab |
| Ctrl-J | Newline |
| Escape | Select text typed since last mouse click (press again to delete it) |
Escape is the undo primitive in acme. Typing then pressing Escape selects what you typed. Pressing Escape again deletes it.
There is no conventional undo stack. The workflow is: type text, verify it, keep or Escape-delete it. For larger operations, use Edit commands which are composable and reversible by design.
Acme exposes itself as a 9P file server. Every window is a directory under the mount point. This is how shell scripts and programs interact with acme.
9 ls acme # list all acme window directories
ls $NAMESPACE/acme # direct mount point access
Each window directory contains:
acme/
<id>/
addr current address (read/write)
body window content (read/write)
ctl control messages (write)
data window data at addr (read/write)
errors error output destination
event mouse/keyboard events (read)
tag tag bar content (read/write)
xdata extended data
# read window content
cat acme/3/body
# write to a window
echo 'hello world' > acme/3/body
# set address then read at that address
echo '0,.' > acme/3/addr
cat acme/3/data
# append text to body
echo ', a/new line/' | 9p write acme/3/ctl
Write these strings to the ctl file:
clean mark file as unmodified
dirty mark file as modified
del delete window
get reload from file
put write to file
name foo rename window
show bring window to front
Example:
echo 'name /tmp/scratch' > acme/$winid/ctl
When acme runs a command, it sets $winid to the ID of the window that executed the command. Scripts use this to target the correct window.
# append text to the window that ran this script
echo 'done' >> acme/$winid/body
# show current file and line
cat acme/$winid/ctl | head -1
Programs can read from acme/<id>/event to receive keyboard and mouse events. This is how tools like acmego, acme-lsp, and language servers attach to acme.
Event format: <origin><type><q0> <q1> <flag> <n> <text>
Mx 10 20 0 4 test # mouse execution of "test" from position 10 to 20
Edit is acme's structural editor, derived from sam. Type Edit <commands> and execute it with button 2.
An address specifies a region of text to operate on.
| Address | Meaning |
|---|---|
0 |
before first character |
$ |
after last character |
. |
current selection |
1 |
line 1 |
42 |
line 42 |
1,42 |
lines 1 through 42 |
, |
entire file (shorthand for 0,$) |
/pattern/ |
next match of pattern after . |
?pattern? |
previous match of pattern before . |
+/pattern/ |
next match after . (explicit forward) |
-/pattern/ |
previous match before . |
/foo/,/bar/ |
from next "foo" to next "bar" |
2,/end/ |
from line 2 to next "end" |
Edit s/old/new/ substitute first match in .
Edit s/old/new/g substitute all matches in .
Edit ,s/old/new/g substitute all matches in file
Edit d delete .
Edit ,d delete entire file
Edit a/text/ append text after .
Edit i/text/ insert text before .
Edit c/text/ change . to text
Edit p print . to acme output window
Edit = print filename and line number of .
Using external commands:
Edit ,<cat -n replace file with cat -n output (number lines)
Edit ,>wc -l pipe file to wc -l (count lines, no change)
Edit ,|sort replace file with sorted content
Edit ,|sort -u replace file with sorted unique content
Edit /TODO/+1,/DONE/-1|sort sort lines between TODO and DONE markers
The key insight: instead of matching characters, you match structure. x and y let you loop over matches and non-matches.
x/pattern/command -- for each match of pattern in the address, set . to that match and run command.
Edit ,x/TODO.*\n/p print all TODO lines
Edit ,x/TODO.*\n/d delete all TODO lines
Edit ,x/^/a/> / prepend "> " to every line (quote a file)
Edit ,x/\n/a/,/ replace newlines with commas
Edit ,x/[A-Z][a-z]+/ select each capitalized word (. cycles through them)
y/pattern/command -- for each region between matches, set . to that region and run command.
Edit ,y/\t/s/ /\t/g in non-tab regions, replace spaces with tabs
g/pattern/command -- if . contains a match, run command.
v/pattern/command -- if . does not contain a match, run command.
Edit ,x/.*\n/g/TODO/p print lines containing TODO
Edit ,x/.*\n/v/TODO/p print lines not containing TODO
Edit ,x/.*\n/g/^$/d delete blank lines
Edit ,x/.*\n/v/^#/d delete lines that are not comments
Commands compose. The output address of one command feeds the next.
# Delete all blank lines in a function body
Edit /^func/,/^}/x/.*\n/g/^$/d
# In all struct definitions, sort the fields
Edit ,x/^type.*struct \{/,/^\}/x/^\t[A-Z]/,/\n/|sort
# Replace all occurrences of "foo" that appear on lines containing "bar"
Edit ,x/.*\n/g/bar/s/foo/baz/g
# Number only non-blank lines
Edit ,x/^.+$/=
# Uppercase all words in comments
Edit ,x/(#|\/\/).*\n/x/[a-z]+/|tr a-z A-Z
Use a temporary placeholder to avoid double-substitution:
Edit ,s/alpha/PLACEHOLDER/g
Edit ,s/beta/alpha/g
Edit ,s/PLACEHOLDER/beta/g
Or with braces (compound command -- all applied to same address):
Edit ,x/alpha|beta/{
s/alpha/PLACEHOLDER/
s/beta/alpha/
s/PLACEHOLDER/beta/
}
The brace form applies each sub-command to . sequentially within the same address context.
X/pattern/command -- for each open window whose filename matches pattern, run command.
Y/pattern/command -- for each open window whose filename does not match, run command.
Edit X/\.go$/,s/fmt\.Print\b/fmt.Println/g replace in all open .go files
Edit X/\.md$/,x/.*\n/g/TODO/p print TODO lines from all open markdown files
Edit Y/\.go$/Del close all windows that are not .go files
The plumber is Plan 9's dispatcher. It reads rules from $HOME/lib/plumbing and decides what to open or do based on text you right-click.
# compiler errors: file.go:42:5: message
type is text
data matches '([a-zA-Z0-9_\-./]+):([0-9]+).*'
arg isfile $1
plumb to edit
plumb client window acme
data set $file
attr add addr=$2
# URLs
type is text
data matches 'https?://[a-zA-Z0-9_\-./~:?#&=+%@,;]+'
plumb to web
plumb start chrome $0
# man pages: fork(2), open(2)
type is text
data matches '([a-zA-Z0-9_]+)\(([0-9])\)'
plumb to man
plumb start sh -c 'man '$2' '$1
# git SHA: 40-char hex
type is text
data matches '[0-9a-f]{40}'
plumb to git
plumb start sh -c 'git show '$0' | acme -'
# image files
type is text
data matches '.*\.(png|jpg|jpeg|gif|bmp)'
arg isfile $0
plumb to image
plumb start display $0
To open .json files in a formatter:
type is text
data matches '.*\.json'
arg isfile $0
plumb to edit
plumb client window acme
data set $0
plumb start window jq . $0
cat $HOME/lib/plumbing | 9p write plumb/rules
These are conventional shell scripts used inside acme. Middle-click to execute.
#!/usr/bin/env rc
# gl: show git log, clickable SHAs
git log --oneline | sed 's/^/ /' | acme -
#!/usr/bin/env rc
# glo: full log
git log --format='%H %ad %s' --date=short | acme -
#!/usr/bin/env rc
# gv: git show $1
git show $1 | acme -
#!/usr/bin/env rc
# gbl: blame current file
git blame $% | acme -
#!/usr/bin/env rc
# gd: diff working tree
git diff | acme -
EDITOR=E git rebase -i HEAD~5
E is the plan9port editor wrapper that opens a file in the current acme instance and waits for it to close. This lets you edit the rebase todo list inside acme.
# create commit message file, edit in acme, then commit
echo '' > /tmp/commitmsg
E /tmp/commitmsg
git commit -F /tmp/commitmsg
sam is the predecessor to acme. sam -d runs without any display, reading commands from stdin. This is ideal for scripted file editing.
sam -d file.go <<'EOF'
,s/oldname/newname/g
w
EOF
echo ',s/http:/https:/g
w' | sam -d *.md
# delete all blank lines from file.txt
sam -d file.txt <<'EOF'
,x/^$/d
w
EOF
# prepend shebang to all .sh files missing one
for f in *.sh; do
sam -d $f <<'EOF'
0g/^#!/ i/#!/usr/bin/env bash\n/
w
EOF
done
ssam is a wrapper that applies sam commands to stdin, outputting to stdout. Useful in pipelines.
# remove trailing whitespace from all lines
cat file.go | ssam ',x/[ \t]+$/d'
# add semicolons to end of lines not already ending in one
cat file.go | ssam ',x/[^;]\n/a/;/'
rc is the Plan 9 shell. It is simpler, more consistent, and more composable than sh or bash.
Variables hold lists natively. No word splitting, no glob rescan.
files = (main.go util.go server.go)
for (f in $files) echo $f
# append to a list
files = ($files new.go)
Compare with bash where files="main.go util.go" is just a string and requires careful quoting.
In bash, $var undergoes glob expansion after assignment. In rc, it does not.
pattern = '*.go'
echo $pattern # prints *.go literally, not expanded
ls $pattern # expands here, where you want it
Single quotes quote literally. Double quotes do not exist as a quoting mechanism. '' inside single quotes produces a literal single quote.
echo 'it''s fine' # prints: it's fine
echo 'no $expansion' # prints: no $expansion
The ^ operator concatenates strings and lists.
base = /usr/local
echo $base^/bin # /usr/local/bin
prefix = foo
names = (bar baz)
echo $prefix^$names # foobar foobaz (list expansion)
cmd >[2=1] # redirect stderr to stdout (equivalent of 2>&1)
cmd >[2]/dev/null # discard stderr
cmd <[0]/dev/null # read stdin from /dev/null
fn greet {
echo hello $1
}
greet world # prints: hello world
path = (/usr/local/bin /usr/bin /bin) # path is a list
home = /home/kris
| Feature | sh/bash | rc |
|---|---|---|
| Lists | Strings with splitting | Native first-class |
| Glob rescan | Yes (dangerous) | No |
| Quoting | Complex | Simple |
| Concatenation | Juxtaposition | ^ operator |
| stderr redirect | 2>&1 |
>[2=1] |
| Functions | foo() { } |
fn foo { } |
These rules appear in Pike's notes and talks. They are about systems, not just code.
You can't tell where a program is going to spend its time. Bottlenecks occur in surprising places. Don't guess. Measure.
Measure. Don't tune for speed until you've measured, and even then don't unless one part of the code overwhelms the rest. Premature optimization is wasted effort on the wrong problem.
Fancy algorithms are slow when n is small, and n is usually small. Simple algorithms win in practice. Fancy algorithms have big constants.
Fancy algorithms are buggier than simple ones, and they're much harder to implement. Use simple algorithms and simple data structures. A hash table or a linked list is almost always enough.
Data dominates. If you've chosen the right data structures and organized things well, the algorithms will almost always be self-evident. Program in terms of your data structures. Code follows data.
Rule 5 is the deepest. When the data structure is right, the program writes itself.
This is the title of a 1983 paper by Rob Pike and Brian Kernighan. The principle applies far beyond cat.
cat -v makes non-printing characters visible by converting them to printable representations. It seems helpful. It is harmful because:
cat -v is not the same bytes as the inputcat -v in a pipeline corrupts the data for downstream programsTools that silently transform data violate composability. Unix pipes work because tools pass data through unchanged. When a tool modifies data "for display", it breaks the pipeline.
Applied more broadly: do not add smarts to transport layers. The file should contain what the file contains. The display layer can render it however it wants. Do not conflate storage and display.
cat -A, cat -e, cat -t in scripts. Use xxd or od for binary inspection.Factotum is the Plan 9 credential manager and authentication agent. It embodies a specific philosophy about how credentials should work.
Factotum is a file server (/mnt/factotum). Programs authenticate by reading from and writing to factotum's files. The kernel mediates all access. No program ever sees a raw password; they see tokens that factotum provides.
Credentials belong to the agent, not the process. A program does not hold a password. It asks factotum "can I authenticate as kris to this host?" and factotum says yes or no, or asks the user.
Authentication is a protocol. Programs speak a challenge-response protocol with factotum. This works for passwords, keys, tickets, and anything else that can be plugged in.
Single point of trust. You type your password once to factotum at login. Everything else delegates to it. No password ever appears in environment variables, files, or process arguments.
Delegation, not copying. A process can be granted the ability to authenticate on your behalf without being given the credential itself. Factotum can grant limited, scoped authority.
ssh-agent follows the same philosophy for SSH keys. You add your key once (ssh-add), and then all SSH connections negotiate through the agent. The private key never leaves the agent.
The difference is that ssh-agent only does SSH. Factotum is general: it handles any protocol you plug in.
ssh-agent and ssh-add. Never put private keys in environment variables.pass or gopass for passwords. They never appear in shell history.gpg-agent for GPG operations. Keys live in the agent.--password flags on the command line (they appear in ps output).Plan 9 is not widely deployed, but its ideas are available on Linux through plan9port and disciplined practice.
plan9port is a port of Plan 9 userland tools to Unix.
# install on Arch Linux
paru -S plan9port
# or from source
git clone https://github.com/9fans/plan9port
cd plan9port && ./INSTALL
Key tools it provides: acme, sam, 9p, plumber, rc, mk, 9, factotum (partial).
# set PLAN9 environment variable
export PLAN9=/usr/local/plan9
export PATH=$PLAN9/bin:$PATH
# start acme
acme
Available immediately after installing plan9port. Excellent for scripted edits.
# edit multiple files with structural regex
for f in src/*.go; do
sam -d $f <<'EOF'
,x/.*\n/g/FIXME/s/FIXME/TODO/g
w
EOF
done
Also available via plan9port. Use it for stream processing:
git diff --stat | ssam ',x/[0-9]+/p' | sort -n
The plumber requires a running plumb daemon.
# start plumber
plumber
# reload rules
cat $HOME/lib/plumbing | 9p write plumb/rules
Acme sends right-click events to the plumber automatically when both are running.
Even without acme, you can apply structural regex thinking:
With ripgrep:
# find all lines with TODO in .go files, then process
rg 'TODO' --files-with-matches | xargs sam -d <<'EOF'
,x/.*\n/g/TODO/s/TODO(\([^)]+\))?/FIXME/g
w
EOF
With awk:
# awk can do g/v logic: print blocks between markers
awk '/BEGIN/,/END/' file.txt
awk '!/^#/ && NF > 0' file.txt # non-comment, non-blank lines
With perl -0777:
# perl with -0777 slurps whole file: enables cross-line regex
perl -0777 -i -pe 's/foo.*?bar/REPLACED/gs' file.txt
sed with ranges:
sed -n '/^func /,/^}/p' main.go # print function bodies
sed '/^func /,/^}/s/TODO/FIXME/g' main.go
mk is the Plan 9 build tool. It is simpler than make: no implicit rules, no magic variables, consistent behavior.
plan9port ships mk. Using it on Linux:
# Mkfile (note: capital M)
all:V:
go build ./...
test:V:
go test ./...
clean:V:
rm -f bin/*
The :V: suffix marks a target as a virtual target (like .PHONY in make).
Plan 9 has per-process namespaces. On Linux, containers and unshare provide a rough equivalent.
# run a command in a new mount namespace
unshare --mount sh
# Linux namespaces are Plan 9 namespaces, implemented later and less cleanly
The Plan 9 approach applied to Linux+dwm:
,p print whole file
,d delete whole file
,s/a/b/g replace a with b globally
,x/pat/cmd for each match of pat, run cmd
,x/.*\n/g/pat/p print lines matching pat
,x/.*\n/v/pat/d delete lines not matching pat
,|cmd pipe whole file through cmd
,<cmd replace file with cmd output
,>cmd pipe file to cmd (no replace)
/pat/+1 line after next match of pat
/pat/-1 line before next match of pat
X/\.go$/cmd run cmd in all open .go windows
sam -d file <<'EOF'
,s/old/new/g
w
q
EOF
echo ',s/old/new/g
w' | sam -d file
files = (a b c) # list
$#files # count: 3
$files(2) # second element: b
fn f { echo $* } # function
cmd >[2=1] # stderr to stdout
path = (/usr/bin /bin) # set path
type is text
data matches 'regex'
arg isfile $0 # optional: verify it's a file
plumb to destination
plumb start command $0