TL;DR: sift is three tools built on one reader. sift format rewrites whitespace, sift lint has 92 rules, and sift lsp gives Emacs, Neovim, VS Code (and more) the same answers over the protocol. It handles Scheme, Emacs Lisp and Common Lisp as three separate languages. Formatting is a total function and a fixed point: format a formatted file and you get it back byte-identical, with comments, blank lines, #; datum comments and #. read-time forms exactly where they were. Guile 3.0, MIT, no C, no build step.
Repo: https://github.com/jjba23/sift Try it (WASM version): https://jointhefreeworld.org/sift/
Why
sift reads the file as a tree of forms with source positions, then prints it back. Everything else follows from that: diffs with hunks that are just the change, quickfixes that replace exactly one range, suppression comments that can't drift away from the code they silence.
The reader and printer are inverse operations. The test suite asserts byte-exact preservation and idempotence on every fixture.
Dialects
Square brackets are a vector in Emacs Lisp and a cond clause in Scheme. #{ ... }# is a Guile symbol and only Guile's reader knows it. So the dialect has to be decided before anything else, and sift decides in a fixed order, stopping at the first answer: --dialect, then the extension (.scm .ss .sld .sls / .el / .lisp .cl .asd .lsp), then the shebang, then a marker in the first 200 lines ((in-package ...), (define-module ...), lexical-binding: t).
If none of that answers, sift says so and names the three dialects. It doesn't guess. A guess will eventually rewrite a Common Lisp file as Scheme, and the diff will get blamed on your code.
Each dialect is read by its own rules: character literals, reader macros, #| ... |# block comments, package prefixes, LOOP keyword style, docstring conventions, whether ) goes on its own line.
doc/style.org has the long form for each choice: what it beat and why, including which of Riastradh's rules sift keeps and which it declines, and which "Emacs Lisp conventions" are really byte-compiler warnings.
The linter
92 rules. Each one is a record: a name, a stable code (SIFT012), a sentence, the dialects it applies to, a severity, a procedure, an example, and its own test file. The catalogue, docs and editor integrations are generated from those records, so they can't drift from the code.
$ sift lint small.scm
small.scm:2:4: warning: [if-without-else] `if` has no else branch; `when` says it once
That's the whole output format: file:line:column: severity: [rule] sentence. Vim's :make, Emacs's compilation-mode, grep and CI log viewers already parse it. Suggestions are findings too; a deeply nested body gets reported, not silently rewritten.
You can ask what a rule means instead of reading a wiki:
$ sift explain if-without-else
CODE SIFT012
NAME if-without-else
DIALECTS scheme
SEVERITY warning
FAILING EXAMPLE (if (ready?) (go))
EXPECTED FIX (when (ready?) (go))
Two decisions I'd defend in a code review:
A fix replaces exactly one range, and if the set of fixes in a run would introduce a parse error, all of them are discarded. No invented delimiters, no renamed identifiers.
Suppression is a comment next to the code: ;; sift:disable-next-line keyword-arguments for one line, disable for the rest of the form, enable to turn it back on. Not an exception list in a config file.
The language server
sift lsp speaks LSP over stdin/stdout. Findings are computed once. The same modules that print text on the command line produce the diagnostics, so editor and CI can't disagree.
It answers diagnostics, quickfixes, formatting, go-to-definition and references, outline, completion, inlay hints naming a call's arguments, hover (a symbol or the rule under your cursor), import organization (unused imports removed, specs sorted), and file rename that keeps the module in step. Definition and references reach into the standard library and installed packages, including the compressed sources most Emacs installations ship.
On open and on save, in a thread, on a temporary copy, it asks the dialect's own compiler (guild, emacs, sbcl) about the names, arity and types sift doesn't check, and publishes what the compiler says. If the compiler isn't installed, it isn't asked. compiler-diagnostics: false turns it off.
Two limits, stated not hidden: references are matched by name within one dialect (sift expands no macro and follows no import), and the project is read once per session unless the editor reports a change. Requests it can't answer get "method not found."
In Emacs
emacs/sift.el is a single file, Emacs 28.1+, no dependencies. Two ways in: a Flymake backend for the rules with its own keymap, and Eglot for the code actions. The docstring, lexical-binding, interactive and provide rules land in Flymake and in CI, from one program instead of three.
One thing to know: sift and TAB will disagree about a third-party macro, because sift doesn't expand macros and indent-region does. doc/emacs.org explains why and what to do about it.
Also in the repo: a Neovim vim.lsp.start setup with no plugin, a Vim plugin with :SiftLint/:SiftFormat/:SiftFix/:SiftExplain, a VS Code extension with no build step and no dependencies, and a Helix recipe.
In a pipeline
sift ci src/ tests/
One command asks both questions (is it formatted, does it lint), prints both reports, and exits 1 if either found something, 2 if a file couldn't be read or parsed. It never writes and never fixes.
Output is --format=json, --format=sarif (2.1.0), --format=github (workflow annotations on the lines of the diff), or null for a script that only cares about exit status. A run distributes files over the processors and prints in file order, so a parallel run writes exactly the bytes a sequential one writes.
sift ci over sift's own tree (151 files, ~34k lines of Scheme) takes about 2 seconds on my machine, interpreter startup included. sift check src/ on the sources it formats itself exits 0.
Ready-made: a reusable GitHub workflow (one file, no flags, uploads SARIF to code scanning), a composite action, sift install-hooks for a plain pre-commit hook, and three hooks for the pre-commit framework.
In the browser
Format and lint are compiled to WebAssembly with Guile Hoot, with an editor page around it. Same reader, same formatter, all 92 rules. The module is the modules in src/sift/.
It's not a port. Writing it again in JavaScript would be a second formatter, and therefore a second style: two tools that agree until the first file that exercises them.
The contract between the page and the Scheme is one procedure from string to string, and nothing in src/sift/ names the browser, WebAssembly or Hoot. Eight tests hold that dependency running one way. The hard part was SRFI-13, which Hoot doesn't ship. The stand-in is thirteen procedures written against (scheme base), plus a test that runs the browser's copy in a second process and fails unless its answers are byte-for-byte what Guile's library gives.
| Buffer | format | lint | | 300 chars | 17 ms | 56 ms | | 1.2 kB | 52 ms | 145 ms | | 6 kB | 780 ms | 1.2 s |
A screenful stays under the editor's 300 ms debounce, so the first two rows never reach you. Past ~2 kB it's perceptible. No JIT, a record per node, four records per character in the reader. That's the price of Scheme on a Wasm GC.
Live at https://jointhefreeworld.org/sift/ — your buffer never leaves the tab.
Install
guix install sift # guix-jointhefreeworld channel
nix run git+https://github.com/jjba23/sift -- version
docker run --rm --volume "$PWD:/src" sift ci . # Debian's Guile + the library
git clone https://github.com/jjba23/sift && cd sift && ./bin/sift version
Guile 3.0 is the only runtime requirement. gzip, curl and tar are for reading compressed sources and fetching stdlib archives during navigation; a missing one is reported and the feature is skipped, not a hard failure. sift doctor prints the Guile it runs on, the library it loaded, the rules it found, and which configuration layer won.
bin/sift is a POSIX shell launcher that puts src/ on Guile's load path and calls (sift cli). The program reduces to five lines of Scheme if you don't want the launcher.
What it doesn't do
No macro expansion, no type-checking, no knowledge of your project's conventions. It reports; you decide. Where an answer can be wrong, the rule says so: wrap-in-let is a suggestion, keyword-arguments counts, and the thresholds are settings.
Configuration is sift.yaml (data, never code) or a sift.scm the project has explicitly trusted. Four layers, system to command line, validated against one schema. A key sift doesn't have is refused with its position rather than ignored, and sift config show --verbose tells you which layer won.
Contributing
The most useful contribution is a rule, and the shape is fixed: one record, one test file, a name, the next free code, a sentence, the dialects, a severity, an example, and three cases — one that passes, one that fails at a known line and column, and when a fix can be stated safely, the fixed text plus a re-lint that must find nothing. The catalogue, docs and editor integrations follow from it.
A file sift refuses to parse is a bug even when the file is broken, and the message has to say where. A question the README doesn't answer is the most useful bug report.
The project's own style is the style it enforces, so it formats itself: maak fmt, maak test, maak self-check, maak fixture-check.