SFEP-0007
sfn fmt — Canonical Formatter
- Status
- Implemented
- Type
- tooling
- Created
- Updated
- Author
- agent:compiler-architect
Architecture: sfn fmt — Canonical Formatter
Status: In Progress (Steps 1-2 complete)
Date: April 15, 2026
Parent: docs/proposals/0003-tooling.md
Overview
sfn fmt is a zero-configuration source formatter for .sfn files. One
canonical style, no options. This document covers the internal architecture,
data structures, algorithm, formatting rules, phased implementation plan,
and test strategy.
CLI Interface
sfn fmt [--check] [--write] [path...]| Invocation | Behavior |
|---|---|
sfn fmt file.sfn |
Print formatted output to stdout |
sfn fmt --write file.sfn |
Format in place (overwrite) |
sfn fmt --write . |
Recursively format all .sfn files in place |
sfn fmt --check . |
Exit 1 if any file differs from canonical format |
sfn fmt --check --write |
Error — mutually exclusive flags |
Exit codes: 0 = success (or all files already formatted), 1 = files would
change (--check) or error occurred.
--check mode prints the paths of files that differ, one per line — no diffs.
This keeps CI output clean and lets the developer run sfn fmt --write . to
fix everything at once.
Architectural Approach: Token-Stream Formatter
The formatter operates on the token stream, not the AST. This is the
central design decision and follows gofmt’s proven model.
Why Not AST-Based?
The Sailfin parser discards comments — AST nodes have no comment attachment
points. An AST-based formatter would lose all comments, making it useless.
Adding comment attachment to the AST is a large, invasive change to
parser.sfn and ast.sfn that would need to pass through the entire
pipeline (typecheck, effects, emit, LLVM lowering). It’s the wrong trade-off
for a formatter.
Why Not CST-Based?
A Concrete Syntax Tree preserves exact structure including comments and whitespace. This is the “right” architecture for advanced formatters (Prettier, rust-analyzer). But it requires building a second parser that produces CST nodes instead of AST nodes. That’s ~2000-3000 lines of new parser code, a separate maintenance burden, and parser drift risk. Not worth it for a v1 formatter that needs to ship quickly.
Token-Stream: The Sweet Spot
The lexer already produces Whitespace and Comment tokens alongside the
structural tokens. The formatter:
- Lexes the source into the full token stream (all 8 kinds preserved)
- Strips existing whitespace tokens
- Classifies structural tokens to determine nesting and context
- Attaches comments to adjacent structural tokens
- Emits tokens with canonical whitespace inserted between them
This approach:
- Preserves all comments (they’re tokens, not AST metadata)
- Reuses the existing lexer with zero modifications
- Produces correct output for any syntactically valid input
- Handles malformed/partial files gracefully (no parse errors to block formatting)
- Is ~800-1200 lines vs ~3000+ for a CST approach
Limitation: No Semantic Formatting
A token-stream formatter cannot make decisions that require semantic
understanding. For example, it cannot reorder match arms, inline small
function bodies, or break long expressions at semantically meaningful
boundaries. These are explicitly out of scope — the formatter handles
layout (indentation, spacing, blank lines, alignment) not
restructuring.
Data Structures
Token (existing — no changes needed)
struct Token { kind: TokenKind; // Identifier, NumberLiteral, StringLiteral, // BooleanLiteral, Symbol, Whitespace, Comment, EndOfFile lexeme: string; // Raw source text line: number; // 1-based line number column: number; // 1-based column number}FmtToken — Enriched Token for Formatting
The formatter wraps each non-whitespace token with formatting metadata:
struct FmtToken { token: Token; // The original token leading_comments: Token[]; // Comments that precede this token (on prior lines) trailing_comment: Token?; // Comment on the same line after this token blank_lines_before: number; // Count of blank lines preceding this token role: string; // Structural role (see below)}Roles classify how a token participates in formatting decisions:
| Role | Examples | Effect |
|---|---|---|
"block_open" |
{ after fn/struct/enum/if/loop/for/match |
Increases indent |
"block_close" |
} that closes a block |
Decreases indent |
"paren_open" |
( |
Increases indent (for wrapped params) |
"paren_close" |
) |
Decreases indent |
"bracket_open" |
[ |
Increases indent (for wrapped arrays) |
"bracket_close" |
] |
Decreases indent |
"separator" |
,, ; |
Controls spacing |
"operator" |
+, -, *, /, ==, !=, &&, ||, =>, .. |
Surrounded by spaces |
"assign" |
=, +=, -= |
Surrounded by spaces |
"colon" |
: in type annotations |
Space after, not before |
"arrow" |
-> return type separator |
Space before and after |
"bang_bracket" |
![ effect list opener |
Space before, no space after |
"dot" |
. member access |
No space before or after |
"keyword" |
fn, let, if, for, loop, match, return, import, struct, enum, … |
Space after |
"value" |
Identifiers, literals | Default spacing |
FmtContext — Formatting State
struct FmtContext { indent: number; // Current nesting depth (0-based) line_pos: number; // Current column position in output line context_stack: string[];// Stack of enclosing constructs ("fn", "struct", "if", ...) last_emitted: string; // Role of the last emitted token in_import: boolean; // Inside an import { ... } block in_effect_list: boolean;// Inside a ![ ... ] effect annotation}Algorithm
Phase 1: Lex
Call the existing lex(source) function from lexer.sfn. This
produces a Token[] containing all token kinds including Whitespace
and Comment.
Phase 2: Strip & Classify
Walk the token stream and:
- Discard all
Whitespacetokens (we’ll regenerate canonical whitespace) - Count blank lines between tokens (a blank line = 2+ consecutive newlines in discarded whitespace) to preserve intentional paragraph breaks
- Assign a
roleto each non-whitespace, non-comment token - Attach comment tokens to their nearest structural token:
- A comment on the same line as a preceding token →
trailing_comment - A comment on a line by itself →
leading_commentsof the next token - A comment at EOF →
trailing_commentof the last structural token
- A comment on the same line as a preceding token →
This produces a FmtToken[] — the enriched token list with no whitespace
tokens but with blank line counts and comment attachments preserved.
Blank line detection: When processing a Whitespace token, count \n
characters in its lexeme. If the count is >= 2, there was at least one blank
line. Cap at 1 (normalize multiple blank lines to exactly one).
Comment attachment heuristic:
code_token // trailing comment → trailing_comment of code_token// leading comment → leading_comments[n] of next_tokencode_token → no comment attachment
// Section header → leading_comments[0] of next_token// More description → leading_comments[1] of next_tokenfn foo() { ... } → token for "fn"Phase 3: Emit
Walk the FmtToken[] array and build the output string. For each token:
-
Emit leading blank lines. If
blank_lines_before > 0and we’re not at the start of the file, emit exactly one blank line (two\nchars). -
Emit leading comments. For each comment in
leading_comments:- Emit indent (4 spaces ×
indentdepth) - Emit the comment lexeme
- Emit
\n
- Emit indent (4 spaces ×
-
Determine pre-token whitespace. Based on the token’s
roleandlast_emitted, decide whether to emit:- Nothing (no space)
- A single space
- A newline + indent (line break before this token)
-
Emit the token lexeme.
-
Update state. Adjust
indentfor block/paren/bracket opens/closes. Updateline_pos,last_emitted,context_stack. -
Emit trailing comment. If present:
- Emit two spaces + comment lexeme
-
Emit newline after statement-ending tokens (
;,{,}).
Indent adjustment timing: block_close tokens (and paren_close,
bracket_close) decrement indent before emitting, so the closing brace
aligns with its opening construct. block_open tokens increment indent
after emitting, so the next line is indented.
Formatting Rules
These rules are the canonical Sailfin style. They are not configurable.
Indentation
- 4 spaces per nesting level. No tabs.
- Nesting increases after
{,(,[. - Nesting decreases before
},),]. - Continuation lines (wrapped parameters, long expressions) get +4 from the declaration indent (8 total from the block indent in most cases).
Brace Placement (K&R)
// Opening brace on same line as declarationfn foo(x: number) -> number { return x + 1;}
struct Point { x: number; y: number;}
if condition { body();}
for item in list { process(item);}
loop { if done { break; }}Spacing
| Context | Rule | Example |
|---|---|---|
| Binary operators | Space before and after | x + y, a == b |
| Assignment | Space before and after | x = 1, x += 1 |
| Commas | No space before, one space after | foo(a, b, c) |
| Semicolons | No space before, newline after | let x = 1; |
| Colons (type annotation) | No space before, one space after | x: number |
| Arrow (return type) | Space before and after | -> number |
Effect ![ |
Space before, no space after | -> number ![io] |
Effect ] |
No space before | ![io, net] |
| Dot (member access) | No space before or after | obj.field |
| Unary operators | No space between operator and operand | !flag, -x |
| Parentheses (call) | No space before (, no space inside |
foo(x) |
| Parentheses (grouping) | No space inside | (x + y) |
| Brackets (index/type) | No space before, no space inside | arr[0], string[] |
| Keywords | One space after | if cond, let x, return val |
| Function params | One space after :, comma-separated |
fn f(a: number, b: string) |
Blank Lines
| Context | Rule |
|---|---|
| Between top-level declarations | Exactly 1 blank line |
| After import block (before first declaration) | Exactly 1 blank line |
| Within function bodies | Preserve 0 or 1 blank lines (normalize >1 to 1) |
| At start/end of blocks | No blank lines |
| Between struct/enum fields | No blank lines |
| Before/after comment groups | Preserve existing blank line if present |
A “top-level declaration” is any fn, struct, enum, interface, test,
const, or type at indent depth 0.
Import Formatting
Imports are the one area where the formatter applies ordering logic:
- Sort by module path (alphabetical, case-sensitive)
- Group by category with a blank line between groups:
- Group 1: Standard library (
"sfn/...") - Group 2: Relative imports (
"./...","../...")
- Group 1: Standard library (
- Single-item imports: Keep on one line
- Multi-item imports (>3 items): One item per line, trailing comma,
closing
}on its own line - Within multi-line imports: Sort items alphabetically
import { parse } from "sfn/json";import { join, resolve } from "sfn/path";
import { Token, TokenKind } from "./token";import { Block, Expression, FunctionSignature, Parameter, Program, Statement, TypeAnnotation,} from "./ast";import { parse_program } from "./parser/mod";Import wrapping threshold: A multi-specifier import wraps to one-per-line when the single-line form would exceed 80 characters or when it has more than 3 items — whichever triggers first.
Trailing Whitespace & EOF
- No trailing whitespace on any line.
- File ends with exactly one
\n(no trailing blank lines).
Comments
- Preserved exactly as written. The formatter does not modify comment content — no wrapping, no alignment, no reformatting.
- Line comments (
//): Maintained with their text. Leading whitespace before//is replaced with canonical indent. - Inline comments: Placed 2 spaces after the preceding code token on the same line.
- Block comments (
/* */): Maintained with their internal formatting. Only the indent of the opening/*is adjusted to canonical depth. - Section dividers (
// ═══,// ───): Preserved as-is. - Doc comments (
///): Treated the same as//comments. The formatter does not distinguish them (consistent with the lexer, which doesn’t either).
Struct & Enum Literals
// Short literal (fits on one line) — keep inlinelet tok = Token { kind: TokenKind.Identifier(), lexeme: name, line: 1, column: 1 };
// Long literal (exceeds 80 chars) — wrap to one field per linelet state = LexerState { source: source, source_len: length, index: 0, line: 1, column: 1,};Wrap threshold: if the single-line form exceeds 80 characters, wrap to
one-field-per-line with trailing comma and closing } on its own line.
String Literals
- Never modified. The formatter does not wrap, reindent, or alter string content in any way.
- Multi-line strings (strings containing
\n) are emitted as-is.
Effect Annotations
// Canonical format: space before ![ , comma+space between effectsfn fetch(id: number) -> Order ![io, net] {File Layout: Where the Code Lives
compiler/ src/ tools/ fmt.sfn # Core formatting logic (~800-1000 lines) fmt_rules.sfn # Spacing / blank-line rule tables (~200-300 lines) cli_main.sfn # Add `fmt` dispatch (minor edit) cli_commands.sfn # Add handle_fmt_command (minor edit)Module Breakdown
compiler/src/tools/fmt.sfn — Core formatter module
| Function | Responsibility | ~Lines |
|---|---|---|
format_source(source: string) -> string |
Public entry point: lex → classify → emit | 20 |
strip_and_classify(tokens: Token[]) -> FmtToken[] |
Phase 2: discard whitespace, assign roles, attach comments | 200 |
classify_token_role(token: Token, prev: Token?, next: Token?) -> string |
Determine a single token’s structural role | 150 |
attach_comments(tokens: Token[]) -> FmtToken[] |
Comment attachment subroutine of strip_and_classify | 100 |
count_blank_lines(whitespace_lexeme: string) -> number |
Count newlines in a whitespace token to detect blank lines | 15 |
emit_formatted(fmt_tokens: FmtToken[]) -> string |
Phase 3: walk enriched tokens, emit with canonical whitespace | 250 |
emit_indent(depth: number) -> string |
Generate indent string (4 spaces × depth) | 8 |
should_break_before(token: FmtToken, ctx: FmtContext) -> boolean |
Decide whether a newline+indent precedes this token | 60 |
spacing_between(prev_role: string, next_role: string) -> string |
Look up inter-token spacing (space, none, newline) | 60 |
sort_imports(fmt_tokens: FmtToken[]) -> FmtToken[] |
Reorder import statements by path; group stdlib/relative | 100 |
is_keyword(lexeme: string) -> boolean |
Check if an identifier is a language keyword | 30 |
compiler/src/tools/fmt_rules.sfn — Formatting rules as data
| Function | Responsibility | ~Lines |
|---|---|---|
get_spacing_rule(left_role: string, right_role: string) -> string |
Lookup table: given two adjacent token roles, return spacing | 120 |
get_blank_line_rule(context: string) -> number |
Return required blank lines for a given context transition | 40 |
opens_block(lexeme: string) -> boolean |
Is this token a block-opening keyword? | 20 |
closes_statement(lexeme: string) -> boolean |
Does this token end a statement? (;, a } at statement level) |
15 |
wraps_at_threshold(items: number, line_length: number) -> boolean |
Should a list wrap to one-per-line? | 10 |
is_binary_operator(lexeme: string) -> boolean |
Is this symbol a binary operator needing surrounding spaces? | 30 |
is_unary_prefix(lexeme: string, prev_role: string) -> boolean |
Is this - or ! a unary prefix? (context-dependent) |
20 |
import_group(path: string) -> number |
Return 0 for stdlib, 1 for relative — used in sort | 15 |
compiler/src/cli_commands.sfn — Command handler (addition)
fn handle_fmt_command(args: string[]) -> number ![io] { // Parse --check, --write, path arguments // Collect .sfn files (reuse _collect_sfn_files_cmd) // For each file: read → format_source → write/check/print // Return exit code}~80-120 lines including flag parsing and file I/O.
Implementation Plan
The formatter is built in 5 incremental steps. Each step produces a working (if incomplete) formatter that can be tested independently.
Step 1: Scaffold & CLI Wiring ✅
Goal: sfn fmt file.sfn runs and prints the file unchanged (identity pass).
- Create
compiler/src/tools/fmt.sfnwithformat_source()that returns input unchanged - Create
compiler/src/tools/fmt_rules.sfnwith stub functions - Add
fmtdispatch tocli_main.sfn - Add
handle_fmt_commandtocli_commands.sfnwith flag parsing and file collection - Verify
make compilesucceeds (self-hosting invariant)
Test: sfn fmt compiler/src/token.sfn prints the file to stdout.
Deliverable: Working CLI plumbing; format logic is a no-op.
Status: Complete. Committed in 7df95ec.
Step 2: Token Stripping & Basic Indentation ✅
Goal: Strip existing whitespace and re-emit with canonical indentation. Comments are preserved but may not be perfectly placed yet.
- Implement
strip_and_classify()— discardWhitespacetokens, compute blank line counts, assign basic roles (block_open,block_close,separator,value) - Implement
emit_formatted()— walk tokens, emit indent on new lines, emit newlines after;and{and}, track nesting depth - Implement
emit_indent(),count_blank_lines() - Tests: unit tests comparing formatted output against expected strings for small snippets (single function, single struct, nested if/loop)
Test: Format a 10-line function and verify 4-space indentation.
Deliverable: Correct indentation for braces and statement terminators. Spacing between tokens may still be wrong.
Status: Complete. Committed in 8db565b. Implementation went beyond the
minimum — includes full role classification (23 roles), spacing rules,
paren/bracket depth tracking for statement vs expression commas, compound
assignment handling, and block-close attachment rules.
Known issues in current Step 2 output:
- Single-field struct literals
{ x: 1 }expand to multi-line - Import specifier lists expand vertically
- No line-length wrapping heuristic
fmt_rules.sfntables are stubs (return defaults)
Step 3: Spacing Rules & Comment Attachment
Goal: Correct inter-token spacing (spaces around operators, after commas, after keywords, etc.) and correct comment placement.
- Implement
classify_token_role()with full role assignment including operator/keyword/colon/arrow/dot detection - Implement
attach_comments()— trailing vs leading comment attachment - Implement
spacing_between()and the rule tables infmt_rules.sfn - Implement
is_keyword(),is_binary_operator(),is_unary_prefix() - Implement short-form wrapping: struct literals and import specifier lists that fit on one line (≤80 chars, ≤3 items) stay inline
- Implement
_measure_inline_block()lookahead to compute single-line length - Implement
_is_inline_block_context()to detect struct literals and imports
Test: Format expressions like x+y*z → x + y * z; verify comments
on the same line as code stay on that line. Short struct literals stay inline.
Deliverable: Fully correct token-level formatting (spacing, comments), short-form wrapping for struct literals and imports.
Status: Complete. fmt_rules.sfn stubs filled in with full spacing
rule table, classification helpers (opens_block, closes_statement,
is_binary_operator, is_unary_prefix, import_group), and wrapping
threshold logic. fmt.sfn emit phase extended with inline block detection
(_measure_inline_block, _is_inline_block_context) and inline emit mode
that suppresses newlines/indent changes for short struct literals and import
specifier lists.
Step 4: Blank Line Normalization & Import Sorting
Goal: Canonical blank lines between declarations and sorted imports.
- Implement blank line rules: exactly 1 between top-level declarations, normalize >1 to 1, no blank lines at block start/end
- Implement
sort_imports()— parse import statements from the token stream, sort by path, group by category, re-emit with canonical wrapping - Implement
wraps_at_threshold()for import and struct literal wrapping
Test: Multiple blank lines between functions → exactly one. Unsorted imports → sorted and grouped.
Deliverable: Production-ready formatter for standard Sailfin code.
Status: Complete. Import sorting implemented with _find_import_spans,
_sort_import_spans (insertion sort by group then path), and
_sort_import_specifiers (alphabetical within a single import). Stdlib
imports (sfn/...) sort before relative imports, with a blank line between
groups. Blank line normalization: suppress blank lines at block start/end,
enforce exactly 1 between top-level declarations and before decorators at
indent 0. Fixed inline block indent tracking (opening { no longer
increments indent when inlined).
Step 5: Edge Cases, Self-Hosting Validation & CI ✅
Goal: Handle all edge cases in the compiler source and wire into CI.
- ✅ Format all 123 compiler source files +
runtime/prelude.sfn; fixed formatter bugs discovered during the pass - ✅ Verify
sfn fmt --write compiler/src/ && make rebuildsucceeds (formatted code self-hosts) - ✅ Idempotency verified: second
sfn fmtpass produces identical output across all files - ✅
--checkvalidation added to CI (GitHub Actionsci.yml) - ✅ Handle edge cases:
- Empty files (returns empty string)
- Files with only comments (emits comments directly)
- Deeply nested constructs (6+ levels)
- Very long string literals
- Adjacent comments with no code between them
- Effect annotations
![io, net] - Enum variants with payloads
- Match expressions with
=> - Decorator syntax (
@logExecution) - Unary operators
!and-(no space after via_reclassify_unary_opspost-pass) - Optional type suffix
?(no space before viaoptional_suffixrole) - Struct/enum declaration inlining guard (blocks with both colons and semicolons rejected)
- ✅ E2E test suite:
compiler/tests/e2e/test_fmt.sh(17 tests)
Test: sfn fmt --check compiler/src/ exits 0 after running
sfn fmt --write compiler/src/.
Deliverable: CI-enforced canonical formatting for the entire project.
Known limitations (v1):
- Bulk
sfn fmt --write <directory>with many files may OOM due tostring_concataccumulation; CI uses a per-file loop as workaround - No expression wrapping — long expressions stay on one line regardless of length
- Blank lines between section-divider comments and declarations may be collapsed
- No semantic formatting (cannot reorder match arms, inline function bodies, etc.)
- No
--diffmode (compare formatted vs original inline)
Key Design Decisions & Rationale
1. No Configuration
No .sfnfmt.toml, no CLI width flags, no style options. One style.
gofmt proved this is the right call. Configuration creates:
- Dialect fragmentation (every project looks different)
- Merge conflicts in config files
- Debates about which style is “best”
- LLM confusion (which style to generate?)
The formatter defines the style. End of discussion.
2. Import Sorting Is Formatting
Some formatters (Prettier) explicitly avoid import sorting. We include it because:
- Sailfin imports are simple (
import { names } from "path") — no side effects - Import order has no semantic meaning in Sailfin
- Consistent import ordering reduces diff noise significantly
- The compiler’s own 120 files have inconsistent import ordering today
3. 80-Character Wrap Threshold for Collections
Imports and struct literals wrap to multi-line at 80 chars. This is not a line-length limit — the formatter does NOT wrap arbitrary expressions. 80 is only the threshold for structured collections where wrapping is clean.
General expression lines can be any length. The formatter does not break
let x = very_long_function_name(arg1, arg2, arg3, arg4); — that’s a
readability decision for the programmer, not the formatter. A future version
could add expression wrapping, but it requires semantic understanding of
precedence and readability that a token-stream formatter can’t provide.
4. Semicolons Are Required
The formatter does not insert or remove semicolons. Sailfin requires them as statement terminators. If a semicolon is missing, the formatter emits the tokens as-is and the parser will report the error.
5. Lexer-Only Dependency
The formatter depends only on lexer.sfn and token.sfn. It does not
import the parser, AST, type checker, or any other compiler pass. This
means:
- Format errors never produce parse errors
- Partially valid files can still be formatted
- The formatter has no dependency on anything that might change
- Build time impact is minimal
Testing Strategy
Unit Tests (compiler/tests/unit/fmt_tool_test.sfn)
Small, focused tests that verify individual formatting rules:
test "fmt: 4-space indentation" { let input = "fn foo() {\nreturn 1;\n}"; let expected = "fn foo() {\n return 1;\n}\n"; assert format_source(input) == expected;}
test "fmt: space around binary operators" { let input = "let x=1+2;"; let expected = "let x = 1 + 2;\n"; assert format_source(input) == expected;}
test "fmt: preserve trailing comment" { let input = "let x = 1; // important\n"; let expected = "let x = 1; // important\n"; assert format_source(input) == expected;}
test "fmt: normalize blank lines" { let input = "fn a() {}\n\n\n\nfn b() {}"; let expected = "fn a() {}\n\nfn b() {}\n"; assert format_source(input) == expected;}Integration Tests (compiler/tests/integration/fmt_integration_test.sfn)
Format real compiler source files and verify:
- Output is valid Sailfin (re-lexing produces no errors)
- Formatting is idempotent (format twice = same result)
- Token count is preserved (no tokens lost or added)
Self-Hosting Test
The ultimate integration test:
# Format all compiler sourcesfn fmt --write compiler/src/
# Verify the compiler still compiles itselfmake compile
# Verify tests still passmake test
# Verify formatting is stablesfn fmt --check compiler/src/ # Should exit 0Golden File Tests
For complex formatting scenarios, use golden files:
compiler/tests/fixtures/fmt/ input/ # Unformatted .sfn snippets expected/ # Expected formatted outputEach test reads input/foo.sfn, formats it, and compares against
expected/foo.sfn. This makes it easy to add edge cases and review
formatting decisions visually.
Edge Cases & Known Challenges
1. Unary vs Binary Minus
- is both a unary prefix operator (-x) and a binary operator (x - y).
The formatter must distinguish them based on context:
- After
=,(,[,,,return,if, or start of expression → unary - After a value token (identifier, literal,
),]) → binary
The is_unary_prefix() function in fmt_rules.sfn handles this by
checking the role of the preceding token.
2. Generic Type Angle Brackets
< and > are both comparison operators and generic type delimiters:
Array<string>— generic typex < y— comparison
The formatter treats < and > as operators (spaces around them) by
default. When preceded by an identifier and followed by an identifier/type
with no space in the original source, treat as generics (no spaces). This
heuristic works because the lexer preserves original spacing context through
adjacent token positions.
3. Effect Annotation ![
The ! + [ sequence in effect annotations (![io, net]) must be treated as
a single unit. The classifier checks: if ! is followed immediately by [
(no whitespace between them in the original token positions), assign the !
role as bang_bracket and treat [ as the effect list opener with no
space after !.
4. Decorator Syntax
Decorators (@logExecution, @deprecated) appear on the line before a
declaration:
@logExecutionfn process() ![io] {The formatter ensures decorators are on their own line at the same indent as the declaration they annotate, with no blank line between decorator and declaration.
5. Arrow in Different Contexts
-> appears in two contexts:
- Return type:
fn foo() -> number - Legacy type annotation:
let x -> number(deprecated, being migrated to:)
Both get space before and after. The formatter is context-agnostic here — it applies the same spacing regardless of context, which is correct for both uses.
6. Match Expression Arms
match value { Pattern1 => { body(); } Pattern2 => { longer_body(); more_statements(); } _ => { default(); }}Each arm gets its own line. => gets space before and after. The body
follows normal brace/indent rules. Single-expression arms may stay on
one line if they fit within 80 characters.
7. Empty Blocks
// Kept on one linefn noop() {}
// Struct with no fieldsstruct Unit {}An empty block ({ immediately followed by } with nothing between them)
stays on one line with no space inside.
Dependencies
| Dependency | Status | Notes |
|---|---|---|
compiler/src/lexer.sfn |
Stable | lex() function |
compiler/src/token.sfn |
Stable | Token, TokenKind types |
compiler/src/cli_main.sfn |
Stable | Subcommand dispatch |
compiler/src/cli_commands.sfn |
Stable | Command handler pattern |
compiler/src/cli_commands_utils.sfn |
Stable | _collect_sfn_files_cmd(), file I/O helpers |
No new external dependencies. No changes to existing modules except minor additions to CLI dispatch.
Future Considerations (Out of Scope for v1)
- Expression wrapping: Break long expressions at operator boundaries. Requires precedence-aware formatting → needs parser integration.
- Align struct field types: Vertically align
:in struct fields. Controversial (Go explicitly doesn’t do this forgofmt). Defer. - Format-on-save in LSP: Once
sfn lspexists, wiresfn fmtas atextDocument/formattinghandler. - Partial formatting: Format only a selected range (LSP
textDocument/rangeFormatting). Requires tracking which FmtTokens correspond to the selected source range. - Pre-commit hook:
sfn fmt --checkas a git pre-commit hook. Trivial to wire once the formatter exists.