SFEP-0005
Colon Type Annotations
- Status
- Implemented
- Type
- language
- Created
- Updated
- Author
- agent:compiler-architect
Colon Type Annotations Migration Plan
Status: Phases 1–6 implemented (migration complete). Roadmap ref: sailfin.dev/roadmap — Syntax reform (pre-1.0) Date: 2026-04-15
Summary
Replace -> with : in parameter, variable, and struct/enum field type
annotation positions. Function return types keep ->.
// Beforefn add(x -> number, y -> number) -> number { let result -> number = x + y; return result;}
struct Point { x -> number; y -> number;}
// Afterfn add(x: number, y: number) -> number { let result: number = x + y; return result;}
struct Point { x: number; y: number;}Rationale
->for “has type” conflicts with its universal meaning of “returns/maps-to”- Every mainstream typed language (TypeScript, Rust, Python, Kotlin, Swift, Zig)
uses
:for annotations - The current syntax is the single most likely reason a new user bounces
- LLMs have zero
.sfntraining data; conventional syntax reduces generated code error rates
Current State
Parser enforces separator roles
consume_annotation_separator() in compiler/src/parser/declarations.sfn
accepts only : for parameter, variable, and field annotations.
consume_return_type_separator() in the same file accepts only -> for
function return types. expression_tokens_consume_type_separator() in
compiler/src/parser/expressions.sfn uses boolean flags accept_colon and
accept_arrow to select the correct separator per context.
The AST does not store which separator was parsed — it is discarded at parse time. Both annotation and return-type positions produce identical ASTs and the rest of the pipeline (type checker, effect checker, emitter, LLVM lowering) is agnostic to the separator.
Three files already migrated
17 function parameter annotations in compiler/src/llvm/lowering/ already use
: and compile successfully, confirming end-to-end compatibility:
lowering_phase_imports.sfn(3 functions)lowering_phase_sanitize.sfn(9 functions)lowering_phase_types.sfn(5 functions)
Seed compatibility confirmed
The current seed binary (v0.5.0-alpha.22+) uses the same dual-accepting parser. Migrated source files compile with the seed immediately — no bootstrap risk.
Migration Surface
| Category | Files | Annotations to change |
|---|---|---|
Compiler source (compiler/src/) |
120 .sfn |
~5,877 |
Tests (compiler/tests/) |
69 .sfn |
~1,055 |
Examples (examples/) |
51 .sfn |
~98 |
Runtime (runtime/) |
1 .sfn |
~67 |
| Docs (code samples) | 6 .md |
~30 samples |
| Total | ~241 files | ~7,100 annotations |
Three categories of -> that change
- Function parameters:
fn foo(param -> Type)becomesfn foo(param: Type) - Variable declarations:
let [mut] name -> Type = ...becomeslet [mut] name: Type = ... - Struct/enum fields:
field -> Type;becomesfield: Type;
What does NOT change
- Return types:
fn foo() -> Typestays as->(universal convention) ->inside string literals: ~35 occurrences (e.g.,"->"in parser code)->inside comments: ~12 occurrences (e.g., path descriptions).sfn-asmIR format: internal format, updated separately
Migration Script Design
The script (scripts/migrate_colon_annotations.py) performs a context-aware
find-and-replace on .sfn files. It must distinguish three -> contexts:
Patterns that change (annotation ->)
# Function parameters (inside parentheses)fn foo(name -> Type, other -> Type) -> ReturnType ^^ ^^ (change these)
# Variable declarationslet name -> Type = value;let mut name -> Type = value; ^^ (change these)
# Struct/enum fields (indented, inside braces)struct Foo { field -> Type; ^^ (change this)}Patterns that stay (return type ->)
fn foo() -> Type # after ) or effect listfn foo() ![io] -> Type # after effect annotationPatterns to skip entirely
"string containing -> arrow" # inside string literals// comment with -> arrow # inside line commentsAlgorithm
- Parse each line tracking string literal state (inside
"..."or not) - Skip lines that are pure comments
- For lines containing
->outside strings: a. If it matcheslet [mut] <name> -> <Type>— replace with:b. If it matches<name> -> <Type>inside a parameter list — replace with:c. If it matches) -> <Type>or] -> <Type>or effect list-> <Type>— keep as-> - Report a summary of changes per file
Emitter Updates (Phase 5 — Complete)
All emitters now output : for annotation positions. The IR parsers accept
both : (preferred) and -> (fallback) for backward compatibility with any
cached IR. The earliest valid separator is selected to handle function types
containing : in cached -> IR (e.g., handler -> fn(req: Request) -> Response).
Sailfin source emitter:
compiler/src/emitter_sailfin_expr.sfn—format_type_annotation()returns": " + text;format_return_type_annotation()returns" -> " + text.compiler/src/emitter_sailfin.sfn— field and parameter rendering uses": ".
Native IR emitter:
compiler/src/emit_native_format.sfn— parameter and field in.sfn-asm.compiler/src/emit_native.sfn—.parammetadata.compiler/src/llvm/rendering_helpers.sfn— interface parameter rendering.
Native IR parsers (accept both, earliest wins):
compiler/src/native_ir_utils_parse.sfn—parse_struct_field_line,parse_enum_variant_field,parse_parameter_entry.compiler/src/llvm/lowering/lowering_recovery.sfn— recovery parser for.paramand.fieldlines.
Parser Finalization (Phase 6 — Complete)
The shared consume_type_separator() was split into:
consume_annotation_separator()— accepts only:(parameters, variables, struct/enum fields)consume_return_type_separator()— accepts only->(function, pipeline, tool return types)
All 14 call sites in declarations.sfn and 2 in expressions.sfn were
updated. -> is now a parse error in annotation position and : is a parse
error in return-type position.
Execution Plan
Phase 1: Migration script
Write and test scripts/migrate_colon_annotations.py. Validate on a few files
manually before running on the full codebase.
Phase 2: Compiler source migration
- Run script on
compiler/src/ - Run
make compile(critical self-hosting gate) - Fix any edge cases
- Run
make test
Phase 3: Tests, examples, runtime
- Run script on
compiler/tests/,examples/,runtime/ - Run
make test
Phase 4: Documentation
- Update code samples in the language spec (
site/src/content/docs/docs/reference/spec/),docs/status.md,roadmap.astro,style-guide.md - Update EBNF in
site/src/content/docs/docs/reference/grammar.md - Remove “syntax reform” callout boxes that describe this as pending
- Mark checkbox in
roadmap.md
Phase 5: Emitter updates
- Update
format_type_annotation()to emit": " - Update
.sfn-asmemitter and parser atomically - Run
make compile && make test
Phase 6: Parser split (separate PR)
- Split
consume_type_separatorintoconsume_annotation_sep/consume_return_sep - Run
make compile && make test
Risk Assessment
| Risk | Likelihood | Mitigation |
|---|---|---|
| Script corrupts a file | Low | Git diff review before committing; dry-run mode |
Return type -> accidentally changed |
Medium | Pattern requires ) -> or ] -> or effect -> to stay |
-> inside string literal changed |
Low | String-aware parsing in script |
| Self-hosting break during migration | Very low | Parser accepts both; migrate incrementally |
| Seed can’t compile migrated source | None | Seed uses same dual-accepting parser |
Effort Estimate
| Phase | Effort |
|---|---|
| Migration script | 1-2 hours |
| Compiler source + validation | 1-2 hours |
| Tests/examples/runtime | 30 min |
| Docs | 30 min |
| Emitter updates | 30 min |
| Parser split (optional) | 1 hour |
| Total | 4-6 hours |
Lessons Learned (Phases 1–4)
Captured during execution to inform the deferred follow-ups.
Capsule directory was missing from the original migration scope
The proposal table listed compiler/src, compiler/tests, examples, and
runtime, but omitted capsules/ (18 files, 702 annotations). Always include
capsules/ in any tree-wide refactor; it is a first-class part of the code
under self-hosting.
Same-named struct + capsule resolution = SIGSEGV (no longer reproducible in capsule tests; underlying bug not fixed)
Originally compiler/tests/unit/layers_test.sfn was renamed to
nn_layers_test.sfn to work around a compiler crash: the test file shared
its base name with the capsule sfn/layers, both defined a Linear
struct, and all-colon annotations on both crashed the compiler.
The capsule-test reorganization (May 2026) makes the workaround
unnecessary in practice: all capsule-flavored tests now live under
capsules/<scope>/<name>/tests/, where the auto-resolve heuristic that
triggered the crash does not fire. Verified on 0.5.10-alpha.9 — a
same-name Linear struct in capsules/sfn/layers/tests/layers_test.sfn
compiles cleanly.
This is relocation, not a fix: the underlying compiler bug almost
certainly still exists, it just no longer has a live reproducer in tree.
If a future change re-introduces a compiler/tests/unit/<capsule-name>_test.sfn
file with a same-named struct, expect the SIGSEGV to come back — track it
down in the capsule auto-resolve / duplicate-symbol path rather than
renaming the file again.
Capsule-flavored unit tests have moved to their capsules
Done. compiler/tests/unit/{layers,nn,tensor,losses,math,path,toml,fmt, json_structs}_test.sfn were moved to capsules/sfn/<name>/tests/, run via
the new make test-capsules target. Helpers stay inlined for now — see the
header comment in each moved file for the follow-ups blocking a switch to
import { ... } from "../src/mod". Briefly:
- Importing capsule functions that internally iterate arrays surfaces a
duplicate-
declare roundLLVM error in the test module. - The runtime
floor/round/_truncatechain that array indexing relies on returns wrong values for some inputs.
Migration script is one-way and was deleted
scripts/migrate_colon_annotations.py was used once on each path and is not
needed again. It was deleted in this PR. If a future tree-wide refactor
needs a similar tool, write a fresh one — keeping single-use scripts in tree
just creates rot.