Formatting
soli fmt rewrites your .sl files into Soli's canonical style — 2-space indent, Ruby-style end, normalized operator spacing. Run it on save, in CI, or by hand.
AST-driven, never lossy
The formatter parses your file, then re-emits the AST. Output is guaranteed to be the same program — only the whitespace and keyword spelling change.
Usage
# Format every .sl file under the cwd, in place
soli fmt
# Format a single file
soli fmt path/to/file.sl
# Format every .sl file under one or more paths
soli fmt app/ lib/
# Don't rewrite — exit non-zero if any file would change (CI-friendly)
soli fmt --check
# Filter mode for editor integration
soli fmt --stdin < in.sl > out.sl
- With no path argument, the current working directory is walked recursively.
- Already-formatted files are left untouched — no spurious
mtimebump. --checkprintswould reformat: <path>for each unformatted file and exits with status1if any are found.--stdinreads from stdin and writes to stdout — no file is touched. Use this for editor "format on save" hooks.
Style
| Rule | Detail |
|---|---|
| Indent | 2 spaces, never tabs |
| Functions | def name() ... end (parens kept even when empty) |
| Classes | class X < Y ... end |
| Control flow | if cond ... end, while cond ... end, etc. |
| Operators | Normalized spacing — a + b, a == b, a && b |
| Comments | Preserved at their original line positions |
| Comment marker | // line comments are normalized to # |
| Blank lines | Multiple consecutive blank lines collapse to one |
| Guard clauses | if cond return … end with no else gets a trailing blank line |
| Early returns | A return is followed by a blank line, unless the next statement is another return or the block's end follows |
| Raw strings | [[ … ]] and r"…" are copied from source verbatim — never re-escaped |
begin/rescue | Canonicalized to the try/catch synonym |
An early return gets breathing room from the body below it, while a run of guards stays grouped as one paragraph:
def publish(post)
return error("missing") if post.nil?
return error("draft") unless post.published?
post.notify_subscribers()
redirect("/posts/#{post._key}")
end
A raw literal keeps the form you wrote it in. Re-escaping one is semantics-preserving but defeats its purpose — a multi-line [[ … ]] SDBQL query would collapse into a single line of \n escapes long enough to trip style/line-length. The """ … """ form is not preserved — it is rewritten into an escaped single-line string, so use brackets for multi-line queries.
db.query([[
FOR post IN posts
FILTER post.published == true
RETURN post
]])
Generated code is already formatted
soli new and every soli generate run their .sl output through the formatter, so a freshly generated app passes soli fmt --check from its first commit — your first fmt run won't produce a diff of files you never touched.
Editor Integration
Hook soli fmt --stdin into your editor's "format document" command. The filter mode is the same shape as gofmt, rustfmt --emit=stdout, or black -, so most LSP and format-tool wrappers can drive it with no extra config.
soli fmt --stdin
See Editor Integration for language-server setup.
CI Usage
The recommended CI gate:
soli fmt --check
It exits non-zero on the first unformatted file it sees, listing each one as would reformat: <path>. Pair with soli lint for a complete style gate.
Coverage & Fallback
A handful of advanced grammar forms aren't canonicalized yet — @sdbql{ … } query blocks, list/hash comprehensions, and some complex match patterns. For these nodes the formatter copies the original source bytes verbatim via the AST span: semantics are preserved, the surrounding code is still formatted, and the body of the un-modeled node is left exactly as you wrote it.
Fixed point
Running soli fmt on an already-formatted file is a no-op. You can mix formatted code and un-modeled forms freely without churn.