Formatter Specification
This document defines the canonical formatting policy for Ashes source.
The formatter is expected to apply these rules consistently. Changes to formatter behavior should update this document and the formatter tests together.
Canonical Rules
Indentation
- Use 4 spaces per indentation level.
- Do not emit tabs in canonical repository formatting.
Line endings
- Canonical repository formatting uses
\nline endings. - Formatter tests normalize line endings for cross-platform stability.
- Programmatic callers may request a different newline style explicitly, but repo-owned
.ashfiles should remain canonical\n.
Top-level declarations
- A file is a sequence of imports, then top-level declarations, then an optional trailing expression (see Language Reference §1.1).
- Exactly one blank line separates adjacent top-level declarations, and one blank line separates the last declaration from the trailing expression.
- The block of
importlines at the top of the file is not blank-line separated internally; a single blank line separates the import block from the first declaration. - Each
importis preserved in its written form, including its shape and any alias. Whole-module imports render asimport M(optionallyimport M as X); selector imports keep the selected name asimport M.binding(optionallyimport M.binding as x). The selector's.bindingis never rewritten into a module alias, and anasalias is never dropped. - Top-level
let/let recursivedeclarations have no trailingin; they are formatted like aletbinding without theinline.
Example:
ash
import Ashes.IO
let name = "world"
let greeting = "hello " + name
Ashes.IO.print(greeting)let recursive ... and ... groups
- The
let recursivebinding starts the group. Eachandclause starts its own line at the same indentation aslet recursive(no blank line between members of the group). - Each binding's value follows the same multiline rules as any
letbinding.
Example:
ash
let recursive even = given (n) -> if n == 0 then true else odd(n - 1)
and odd = given (n) -> if n == 0 then false else even(n - 1)let ... in ...
letstarts the binding line.- Multiline values are indented one level.
instarts its own line when either the value or body is multiline.- Nested
let ... inexpressions are preserved as written; the formatter does not flatten a nestedlet ... inpyramid into top-level declarations, nor the reverse.
Example:
ash
let x =
let y = 1
in y
in xLambdas
given (...) ->stays on one line when the body fits on one line.- Multiline lambda bodies are indented one level after the arrow.
if / then / else
if,then, andelseeach start their own line in canonical multiline layout.- Multiline branches are indented one level.
Example:
ash
if cond
then
expr1
else
expr2Lists and cons
- Short list literals remain compact on one line.
- Cons expressions use spaces around
::. - Subexpressions are parenthesized when needed to preserve meaning.
match
match <expr> withstays on one line.- Each arm starts on its own line.
- Nested multiline expressions inside an arm are indented one level.
- Pattern guards are formatted inline:
| pattern when condition -> expr
Example:
ash
match xs with
| [] -> 0
| head :: tail ->
match tail with
| [] -> head
| _ -> headExample with pattern guard:
ash
match x with
| n when n >= 10 -> "big"
| _ -> "small"Type declarations
type Name =starts the declaration.- Each constructor appears on its own line, indented one level, prefixed with
|.
Example:
ash
type Color =
| Red
| Green
| BlueRecord types use the same one-field-per-line | layout, with each field rendered as | name: Type:
ash
type Point =
| x: Int
| y: IntRecords
- Record construction is rendered as a constructor call with named arguments:
Point(x = 1, y = 2). Fields keep their source order. - Record update is rendered brace-free:
p with x = 5, with comma-separated fields for multiple updates:p with x = 5, y = 6. Parentheses are added only where required by the surrounding precedence (withbinds looser than application and the binary operators).
Spacing
- Binary operators use spaces around the operator.
- Comparison operators use spaces around the operator.
- Function calls keep their existing canonical form:
f(x)for parenthesized calls andf xfor whitespace application. - No line ends in trailing whitespace.
Comments
- The leading
//comment block at the top of a file (before the first import or declaration) is preserved verbatim, in place. - Standalone
//comment lines elsewhere in the file are preserved: each is re-anchored to the surrounding significant lines (by a whitespace-insensitive token signature) and reinserted at the anchor's position in the formatted output. A comment whose anchor line no longer exists (for example, a line the formatter merged away) is placed after the nearest preceding anchor rather than dropped. - Trailing same-line comments (
let x = 1 // note) are not yet preserved; the reinsertion is line-based. Keep comments on their own line.
Enforcement
- Exact-output formatter tests lock down representative policy rules.
- Idempotence tests ensure formatting is stable across repeated runs.
- Repository
.ashfiles are expected to be formatted withashes fmtbefore changes are considered complete.
