Paredit for Racket
Structural editing for Racket, Scheme and Lisp in VS Code — slurp, barf, splice, raise, wrap,
split, join — with the canonical paredit.el keymap.
Keymap
The canonical paredit.el bindings, scoped to the racket, scheme, lisp and commonlisp
language ids. Everything is also on the command palette under Paredit.
Run Paredit: Cheat Sheet from the palette for the same table in an editor tab, alongside a guide
that shows each command's effect on a real snippet. Those before/after pairs are generated by
running the commands, not written by hand, so the page cannot describe a transform this build does
not perform — src/cheatsheet/catalog.ts is the source of truth for the manifest, the cheat sheet
and this table alike, and pnpm sync:contributions regenerates package.json from it.
|
Key |
Command |
| Navigate |
ctrl+alt+f / ctrl+alt+b |
forward / backward over a datum |
|
ctrl+alt+u / ctrl+alt+n |
out of the list, backward / forward |
|
ctrl+alt+d / ctrl+alt+p |
into a list, forward / backward |
| Depth |
ctrl+right or ctrl+shift+0 |
forward slurp |
|
ctrl+left or ctrl+shift+] |
forward barf |
|
ctrl+alt+left or ctrl+shift+9 |
backward slurp |
|
ctrl+alt+right or ctrl+shift+[ |
backward barf |
|
alt+s / alt+r |
splice / raise |
|
alt+shift+9 / alt+[ / alt+shift+[ |
wrap in () / [] / {} |
| Kill |
ctrl+alt+k / ctrl+alt+backspace |
kill / backward-kill a datum |
|
alt+up / alt+down |
splice, killing backward / forward |
| Rearrange |
alt+shift+s / alt+shift+j |
split / join |
|
ctrl+alt+t |
transpose |
|
ctrl+alt+shift+up / ctrl+alt+shift+down |
drag a datum earlier / later |
Two things worth knowing before you turn this on.
It takes keys VS Code already uses, inside Lisp files only: ctrl+left/ctrl+right normally
move by word, and alt+up/alt+down normally move a line. paredit.el claims all four, so this
does too. Rebind them in keybindings.json if you would rather keep the defaults.
The bracket chords assume a US layout. C-) is written ctrl+shift+0 because that is where )
lives on a US keyboard; on a German layout ) is shift+9, and the chord will not be what your
fingers expect. The ctrl+left/ctrl+right alternates — which paredit.el also defines — are
layout-independent, so slurp and barf work regardless; wrap (alt+shift+9) is the one that needs
remapping.
wrap-square and wrap-curly are bound here even though upstream paredit leaves them unbound:
Racket code is full of cond, let and match clauses in [], and racket-mode's own docs tell
Emacs users to hand-bind them for exactly that reason. The drag commands have no paredit equivalent
at all — they come from Calva, where they are the most-used part of the package.
Status
Early. See CHANGELOG.md for what actually works today.
Design
Three layers, strictly separated:
| Layer |
Contents |
Depends on vscode? |
src/reader/ |
resumable line lexer, incremental line-state store, token cursor |
no |
src/ops/ |
paredit command semantics as pure (doc, selections) -> Edit[] |
no |
src/vscode/ |
document store, command registration, applying edits |
yes |
Keeping reader and ops free of vscode imports is what makes the behaviour testable as plain
unit tests rather than through an editor harness.
Why a client-side lexer
racket-langserver does compute full bracket structure internally, but it is discarded at the LSP
boundary: the published semantic-token legend is a six-element enum
(variable function string number regexp comment) with no punctuation category, and the tokens are
derived from the check-syntax expansion trace — which walks syntax objects, and so has no
parenthesis nodes at all. Bracket structure is not recoverable from the protocol.
Lexing locally also keeps structural commands off the IPC path, which matters when they are bound
to keys you hold down.
The reader features that matter
Naive bracket matching breaks on Racket in specific, enumerable ways, all of which the lexer
handles:
#\(, #\), #\;, #\" — character literals that look like delimiters
#| ... |# — block comments, which nest
#; — datum comments, which delete the next datum from the stream
|bar symbols| — pipes quote everything, including brackets, and may appear mid-symbol
#rx"...", #px"...", #"..." — prefixed string flavours
#(, #hash(, #s( — prefixed opens, where the delimiter lexeme is longer than one character
#<<TAG here strings, whose body runs to a line equal to TAG and whose brackets are text
#! script lines, which are comments rather than data
Known gap: @-expressions (Scribble text bodies), where {...} delimits text rather than a
datum, are lexed as ordinary data rather than understood.
Correctness
The lexer is checked four ways, in increasing order of how hard they are to
satisfy.
Examples. Hand-written tables covering each reader construct, run by
pnpm test.
Committed fixtures. Sources diffed token-for-token against Racket's own
syntax-color/racket-lexer, with the reference output committed so CI needs no
Racket. pnpm test:fixtures regenerates them.
A corpus sweep. pnpm test:oracle runs the same comparison over every
.rkt file in the local Racket installation — 5290 of them, about 25 seconds —
and currently reports no divergence.
A fuzzer. Also under pnpm test:oracle. The corpus is broad but biased:
every file in it is valid, committed Racket, and real code essentially never puts
#| immediately before #\" or opens a here string whose tag is (. Those
adjacencies are where a hand-written state machine and the real reader come
apart, and they are also the normal condition of a buffer being typed into, so
the fuzzer generates them on purpose — 30 000 sources per run, drawn from a pool
of reader fragments, half of them deliberately unbalanced.
Failures shrink to a minimal counterexample, which is the difference between a
bug report and a bug fix: the run that found the bar-continuation bug below
reported it as #\#|(, five characters. Shrinking needs hundreds of lexings of
ever-smaller inputs, so the oracle is a warm racket process rather than a fresh
one per call. Every find is then added to test/oracle/cases.json, becoming a
fixture that CI can run without Racket.
Between them these caught, among others:
- a symbol resuming at the top-level dispatch after a bar closed on a later line,
so
|a|#( grew a bracket that was not there;
#\𝔸 split across its surrogate pair, because the character rule matched one
UTF-16 code unit rather than one code point;
#\u3BBabc taking eight hex digits where Racket takes four;
#lang racket( and a backslash-escaped newline both ending a token too early.
A fifth layer, lexer-invariants.test.ts, needs no oracle and so runs on every
push. It asserts what can be stated without a second opinion — that the tokens of
a line tile it exactly, that they reconstruct it, that lexing is deterministic,
and that incremental relexing matches a full one — over inputs the oracle path
cannot reach anyway: lone surrogates, control characters, CRLF. That is how the
zero-length token emitted for a blank line inside a multi-line string was found;
the differential test could not see it, because coalescing absorbed it.
Two divergences remain, both documented with evidence in lexer-fuzz.test.ts and
both confined to malformed # forms — quoting inside #t/#f atoms, and a #!
that is not a shebang. Racket's own behaviour there follows no rule this lexer
could adopt, and no real program is affected. They are excluded on the generated
input rather than in the comparison, so they cannot mask anything else.
Keymap
The canonical paredit.el bindings, scoped to the racket, scheme, lisp and commonlisp
language ids. Everything is also on the command palette under Paredit.
Run Paredit: Cheat Sheet from the palette for the same table in an editor tab, alongside a guide
that shows each command's effect on a real snippet. Those before/after pairs are generated by
running the commands, not written by hand, so the page cannot describe a transform this build does
not perform — src/cheatsheet/catalog.ts is the source of truth for the manifest, the cheat sheet
and this table alike, and pnpm sync:contributions regenerates package.json from it.
|
Key |
Command |
| Navigate |
ctrl+alt+f / ctrl+alt+b |
forward / backward over a datum |
|
ctrl+alt+u / ctrl+alt+n |
out of the list, backward / forward |
|
ctrl+alt+d / ctrl+alt+p |
into a list, forward / backward |
| Depth |
ctrl+right or ctrl+shift+0 |
forward slurp |
|
ctrl+left or ctrl+shift+] |
forward barf |
|
ctrl+alt+left or ctrl+shift+9 |
backward slurp |
|
ctrl+alt+right or ctrl+shift+[ |
backward barf |
|
alt+s / alt+r |
splice / raise |
|
alt+shift+9 / alt+[ / alt+shift+[ |
wrap in () / [] / {} |
| Kill |
ctrl+alt+k / ctrl+alt+backspace |
kill / backward-kill a datum |
|
alt+up / alt+down |
splice, killing backward / forward |
| Rearrange |
alt+shift+s / alt+shift+j |
split / join |
|
ctrl+alt+t |
transpose |
|
ctrl+alt+shift+up / ctrl+alt+shift+down |
drag a datum earlier / later |
Two things worth knowing before you turn this on.
It takes keys VS Code already uses, inside Lisp files only: ctrl+left/ctrl+right normally
move by word, and alt+up/alt+down normally move a line. paredit.el claims all four, so this
does too. Rebind them in keybindings.json if you would rather keep the defaults.
The bracket chords assume a US layout. C-) is written ctrl+shift+0 because that is where )
lives on a US keyboard; on a German layout ) is shift+9, and the chord will not be what your
fingers expect. The ctrl+left/ctrl+right alternates — which paredit.el also defines — are
layout-independent, so slurp and barf work regardless; wrap (alt+shift+9) is the one that needs
remapping.
wrap-square and wrap-curly are bound here even though upstream paredit leaves them unbound:
Racket code is full of cond, let and match clauses in [], and racket-mode's own docs tell
Emacs users to hand-bind them for exactly that reason. The drag commands have no paredit equivalent
at all — they come from Calva, where they are the most-used part of the package.
Status
Early. See CHANGELOG.md for what actually works today.
Design
Three layers, strictly separated:
| Layer |
Contents |
Depends on vscode? |
src/reader/ |
resumable line lexer, incremental line-state store, token cursor |
no |
src/ops/ |
paredit command semantics as pure (doc, selections) -> Edit[] |
no |
src/vscode/ |
document store, command registration, applying edits |
yes |
Keeping reader and ops free of vscode imports is what makes the behaviour testable as plain
unit tests rather than through an editor harness.
Why a client-side lexer
racket-langserver does compute full bracket structure internally, but it is discarded at the LSP
boundary: the published semantic-token legend is a six-element enum
(variable function string number regexp comment) with no punctuation category, and the tokens are
derived from the check-syntax expansion trace — which walks syntax objects, and so has no
parenthesis nodes at all. Bracket structure is not recoverable from the protocol.
Lexing locally also keeps structural commands off the IPC path, which matters when they are bound
to keys you hold down.
The reader features that matter
Naive bracket matching breaks on Racket in specific, enumerable ways, all of which the lexer
handles:
#\(, #\), #\;, #\" — character literals that look like delimiters
#| ... |# — block comments, which nest
#; — datum comments, which delete the next datum from the stream
|bar symbols| — pipes quote everything, including brackets, and may appear mid-symbol
#rx"...", #px"...", #"..." — prefixed string flavours
#(, #hash(, #s( — prefixed opens, where the delimiter lexeme is longer than one character
#<<TAG here strings, whose body runs to a line equal to TAG and whose brackets are text
#! script lines, which are comments rather than data
Known gap: @-expressions (Scribble text bodies), where {...} delimits text rather than a
datum, are lexed as ordinary data rather than understood.
Correctness
The lexer is differentially tested against Racket's own syntax-color/racket-lexer. An oracle
script drives the real lexer and both streams are projected onto a common set of structural classes,
then compared token for token — every boundary must agree.
pnpm test:oracle runs that comparison over every .rkt file in the local Racket installation
(5290 of them, ~25s) and currently reports no divergence. pnpm test runs it over committed
fixtures instead, so neither CI nor a contributor needs Racket installed; pnpm test:fixtures
regenerates those from test/oracle/cases.json.
The comparison is deliberately not an identity check, because the two token models are not the same
one. Four differences are reconciled rather than treated as failures, each for a stated reason:
| Difference |
Why |
| adjacent same-class tokens are coalesced |
this lexer is line-at-a-time; Racket reports a multi-line string, comment or whitespace run as one token |
| Racket's atom flavours collapse to one class |
symbol, constant, other, hash-colon-keyword are distinctions paredit has no use for — and Racket reports the quoting prefixes among them |
| offsets are remapped to UTF-16 |
Racket counts code points, JavaScript and VS Code count code units, so any file with an emoji has two offset systems |
spans Racket calls error compare on boundaries only |
error recovery is where two independent lexers are entitled to differ |
Requirements
An extension providing the racket language id — Magic Racket is the usual one.
Regenerating the lexer fixtures (just oracle) additionally needs racket on PATH; using the
extension does not.
Development
just check # lint, test, build
just install-local # package a .vsix and install it into VS Code
just oracle # regenerate lexer fixtures from Racket's own lexer