Sigil Syntax Reference
This document describes the current Sigil surface accepted by the compiler in this repository.
Sigil is canonical by design. This is not a style guide with alternatives. It documents the one surface form the parser, internal canonical printer, validator, and typechecker accept.
If source parses but does not exactly match the compiler's canonical printed form for that AST, compile, run, and test reject it. There is no public formatter.
Source Files
Sigil distinguishes file purpose with file extensions:
.lib.sigilfor libraries.sigilfor executables and tests
Canonical filename rules:
- basename must be
lowerCamelCase - no underscores
- no hyphens
- no spaces
- filename must end with
.sigilor.lib.sigil
Valid examples:
userService.lib.sigilfibonacci.sigilffiNodeConsole.lib.sigil
Invalid examples:
UserService.lib.sigiluser_service.lib.sigiluser-service.lib.sigil
Comments
Sigil uses one comment syntax:
⟦ This is a comment ⟧
#, //, and / ... / are not Sigil comments.
Comments are non-semantic trivia. They are allowed in canonical source, but they do not participate in canonical source comparison or code coverage extraction for checked docs.
Top-Level Declarations
Module scope is declaration-only.
Valid top-level forms:
labeleffectfeatureFlagruletransformtecλtest
Invalid at top level:
l
Canonical declaration ordering is:
label => t => effect => e => featureFlag => c => transform => λ => rule => test
There is no export keyword in current Sigil. Visibility is file-based:
- top-level declarations in
.lib.sigilfiles are referenceable from other modules .sigilfiles are executable-oriented- top-level functions, consts, and types in
.sigilfiles must be reachable
from main or tests
.lib.sigilfiles may still expose declarations that are unused locally
Function Declarations
Function declarations require:
- a name
- typed parameters
- a return type
Regular expression body:
λadd(x:Int,y:Int)=>Int=x+y
Match body:
total λfactorial(n:Int)=>Int
requires n≥0
decreases n
match n{
0=>1|
1=>1|
value=>value*factorial(value-1)
}
For function declarations:
=is required before a non-matchbody=is forbidden before amatchbody- delimited aggregate forms stay flat with
0or1item and print multiline with2+items, including type arguments inside signatures - a direct
matchbody begins on that same line
Function mode rules:
- functions are ordinary by default
mode totalmay appear once at the top of a file to maketotalthe default for later function declarationstotal λname...orordinary λname...overrides the file default for one declaration- only total self-recursive functions may declare
decreases - functions declared
totalmay not call declarations markedordinary
Effects, when present, appear between => and the return type:
e axios:{get:λ(String)=>!Http String}
e console:{log:λ(String)=>!Log Unit}
λfetchUser(id:Int)=>!Http String=axios.get("https://example.com/"+§string.intToString(id))
λmain()=>!Http!Log Unit={
l _=(fetchUser(1):String);
console.log("hello")
}
The built-in primitive effects are:
ClockFsFsWatchHttpLogProcessPtyRandomSqlStreamTaskTcpTerminalTimerWebSocket
Projects may define reusable multi-effect aliases only in src/effects.lib.sigil:
effect CliIo=!Fs!Log!Process
Those aliases are project-global and may be used directly in signatures.
Function contracts, when present, appear after the signature and before the body:
λnormalizeYear(raw:Int)=>Int
requires raw>0
ensures result>1800
match raw>1800{
true=>raw|
false=>1900
}
Contract rules:
- each function may declare at most one
requiresclause and at most oneensuresclause - if both are present,
requiresmust come beforeensures requiresmay reference only parametersensuresmay reference parameters plusresult- contracts must typecheck to
Bool - contracts must be pure and world-independent
- direct
matchbodies still begin withmatch, not=match, even after contract lines - effectful functions may carry contracts, but the contract surface talks only about parameters and the returned value
Lambda Expressions
Lambda expressions are fully typed and use the same body rule as top-level functions:
λ(x:Int)=>Int=x*2
λ(value:Int)=>Int match value{
0=>1|
n=>n+1
}
Lambda expressions require:
- parentheses around parameters
- typed parameters
- a return type
Generic lambdas are not part of Sigil's surface.
Type Declarations
Project-Defined Named Types
Inside a project with sigil.json, all project-defined named types live in:
src/types.lib.sigil
Rules:
src/types.lib.sigilmay contain onlytandlabeldeclarations- outside that file, project-defined types are referenced as
µTypeName - project sum constructors and patterns from
src/types.lib.sigilalso useµ... src/types.lib.sigilmay reference only§...and¶...inside type definitions and constraints
Labels
Projects and standalone files may declare labels:
label Brazil
label Paraguay
label Pii
label Mercosur combines [Brazil,Paraguay]
Types may attach one or more labels:
label Pii
label Usa
t Ssn=String label [Pii,Usa]
Rules:
whereandlabelare separate surfaceslabelclassifies the type; it does not replace value-level refinementlabel X combines Yadds implied labels during boundary checking- singleton label attachment prints as
label Pii - multiple labels print as
label [A,B]
Boundary Rules and Transforms
Projects use:
src/policies.lib.sigil
That file is the canonical home for:
ruletransform
Standalone .sigil and .lib.sigil files may also declare these forms locally for small examples and scripts.
Example:
transform λredactSsn(ssn:µSsn)=>String="***"
rule [µ.Pii,µ.Usa] for •topology.auditLog=Through(•policies.redactSsn)
rule targets exact named boundaries only in the current surface.
Product Types
t User={
active:Bool,
id:Int,
name:String
}
Record fields are canonical alphabetical order everywhere records appear.
Sum Types
t Color=Red()|Green()|Blue()
t ConcurrentOutcome[T,E]=Aborted()|Failure(E)|Success(T)
t Option[T]=Some(T)|None()
t Result[T,E]=Ok(T)|Err(E)
Project-defined constructors from src/types.lib.sigil use µ... in expressions and patterns:
t TopologicalSortResult=CycleDetected()|Ordering([Int])
λorderingResult()=>µTopologicalSortResult=µOrdering([
1,
2,
3
])
λorderingValues(result:µTopologicalSortResult)=>[Int] match result{
µOrdering(order)=>order|
µCycleDetected()=>[]
}
Constrained Types
Named types may carry a pure where clause:
t BirthYear=Int where value>1800 and value<10000
t DateRange={
end:Int,
start:Int
} where value.end≥value.start
Constraint rules:
- only
valueis in scope - the expression must typecheck to
Bool - constraints are pure and world-independent
- constrained aliases and constrained named product types act as compile-time refinements over their underlying type
- values flow into a constrained type only when the checker can prove the predicate in Sigil's canonical solver-backed refinement fragment
- constrained values widen to their underlying type automatically
- the current proof fragment covers Bool/Int literals,
value, field access,#over strings/lists/maps,+,-, comparisons,and,or, andnot match, exact record patterns, and internal branching propagate supported branch facts into that refinement proof- direct boolean local aliases of supported facts also narrow
- constraints do not imply automatic runtime validation
Example:
t BirthYear=Int where value>1800
λpromote(year:Int)=>BirthYear match year>1800{
true=>year|
false=>1900
}
Feature Flags
Projects and publishable packages may define first-class feature flags in:
src/flags.lib.sigil
That file may contain only featureFlag declarations.
Canonical declaration shape:
featureFlag NewCheckout:Bool
createdAt "2026-04-12T14-00-00Z"
default false
Variant-valued flags use named sum types:
t CheckoutColor=Citrus()|Control()|Ocean()
featureFlag CheckoutColorChoice:CheckoutColor
createdAt "2026-04-12T14-00-00Z"
default Control()
Rules:
- project/package feature flags must live in
src/flags.lib.sigil - flag names are
UpperCamel createdAtis required and uses canonical UTC timestamp formatYYYY-MM-DDTHH-mm-ssZdefaultis requireddefaultmust be a pure expression of the declared flag type- current flag types are
Booland named sum types
Project-local references use:
•flags.NewCheckout
Package consumers use the nested public module path:
☴featureFlagStorefrontFlags::flags.NewCheckout
Constants
Constants require a value ascription:
c answer=(42:Int)
c greeting=("hello":String)
Current parser behavior requires the typed form above. Untyped constants and the older c name:Type=value surface are not current Sigil.
This follows Sigil's general type-ascription rule:
- if you want to ascribe a type to an expression, write
(expr:Type) - the same parenthesized form is used everywhere instead of declaration-specific
variants
Sigil keeps that single rule even though it adds parentheses, because the language prefers one canonical annotation surface over multiple equivalent forms.
String Literals
Sigil uses one string literal surface:
"hello"
The same " form also allows multiline strings:
"hello
world"
String literal rules:
- the string value is exactly the raw contents between the quotes
- literal newlines inside the quotes are preserved as newline characters
- indentation spaces inside the quotes are preserved exactly
\\,\",\n,\r, and\tremain valid escapes- there is no heredoc, triple-quote, dedent, or trim-first-line variant
Canonical note:
- if a string value contains newline characters, canonical source prints it as a multiline
"string with literal line breaks rather than\nescapes
Rooted References
Sigil uses rooted module references directly at the use site. There are no top-level import declarations:
λtodoCount(todos:[µTodo])=>Int=•todoDomain.completedCount(todos)
Use rooted members through the namespace:
•todoDomain.completedCount(todos)
§list.last(items)
Canonical module roots include:
¶...¤...•...§...☴...※...†...
Current rooted surfaces are:
•...for project modules such as•todoDomain,•topology,•policies, and•flags¤...for config modules such as¤siteand¤release§...for standard library modules¶...for core helper modules†...for runtime/world modules※...for test observe/check surfaces☴...for external packages declared insigil.json
Project-defined named types and project sum constructors use:
µ...
The selected environment config module also exposes a rooted project surface:
•config.resolves a non-worldtop-level declaration from the selectedconfig/.lib.sigil - using
•config.requires--envoncompile,run,test, andinspect •configis project-only; standalone files use ordinary local declarations instead
Example:
•config.flags
There are no selective imports, import aliases, or separate import declarations.
Package references are direct-only:
☴router.resolve(
"GET",
"/health",
routes
)
☴namerequires a direct exact dependency insigil.json- transitive package imports are rejected
- publishable packages use
src/package.lib.sigil
Externs
Extern declarations use e:
e console:{log:λ(String)=>!Log Unit}
e axios:{get:λ(String)=>!Http String}
e nodePty:{onData: subscribes λ(Session)=>String}
e bridge::ptyAdapter:{onData: subscribes λ(SessionRef)=>String}
Unused extern declarations are non-canonical.
Typed extern member types may be either:
λ(...)=>...for ordinary foreign callssubscribes λ(...)=>...for foreign subscription ingress
subscribes is only valid inside typed extern member declarations.
Project-local foreign bridges use the reserved bridge::... namespace and map to files under bridges/ in the owning project root. Other extern module paths continue to compile as package-style imports.
Local Bindings
Local bindings use l inside expressions:
λdoubleAndAdd(x:Int,y:Int)=>Int={
l doubled=(x*2:Int);
doubled+doubled+y
}
Local names must not shadow names from the same or any enclosing lexical scope.
Named local bindings used zero times are non-canonical.
Pure local bindings used exactly once are non-canonical and must be inlined.
When a binding exists only to sequence effects, use the wildcard pattern:
{
l _=(§io.println("x"):Unit);
()
}
Discarding a pure expression with l _=(...) is non-canonical and rejected.
Owned resource scopes use using:
λmain()=>Unit={
using source=openSource(){
consume(source)
}
}
using rules:
- the initializer must have type
Owned[T] - the bound name is available only inside the
usingbody - leaving the scope disposes the owned resource
- the borrowed resource value must not escape the
usingbody - code after a guaranteed-terminating initializer in the same
usingis unreachable and rejected
Sequencing through l ...; body follows the same reachability rule:
- if the bound expression is guaranteed to terminate, the following body is unreachable and rejected
Pattern Matching
Sigil uses match for value-based branching:
λclassify(value:Int)=>String match value{
0=>"zero"|
1=>"one"|
_=>"many"
}
Canonical match shape comes from the internal printer:
- multi-arm
matchprints multiline - each arm begins as
pattern=> - nested branching may continue on following indented lines
- there is no alternate printed layout for the same
matchAST
Patterns include:
- literals
- identifiers
_- constructors
- list patterns
- record patterns
- tuple patterns
Current match rules:
matchis Sigil's only branching surface- matches over finite structural spaces must be exhaustive
- redundant and unreachable arms are rejected
Bool,Unit, tuples, list shapes, exact record patterns, and nominal sum constructors participate in exhaustiveness checking- coverage, contracts, and refinement narrowing share the same canonical proof fragment
- supported proof facts include Bool/Int literals, rooted or pattern-bound values, field access,
#over strings/lists/maps,+,-, comparisons,and,or,not, direct boolean local aliases of those supported facts, and shape facts from tuple/list/record/constructor patterns - unsupported guards remain valid source, but they stay opaque to coverage and refinement narrowing
- exact record patterns must mention every field of the matched exact record type
Examples:
t Point={
x:Int,
y:Int
}
λfromOption(option:Option[Int])=>Int match option{
Some(value)=>value|
None()=>0
}
λheadOrZero(list:[Int])=>Int match list{
[]=>0|
[
head,
.rest
]=>head
}
λpairLabel(left:Bool,right:Bool)=>String match (
left,
right
){
(
true,
true
)=>"tt"|
(
true,
false
)=>"tf"|
(
false,
true
)=>"ft"|
(
false,
false
)=>"ff"
}
λpointLabel(point:Point)=>String match point{
{
x:0,
y:0
}=>"origin"|
{
x:0,
y
}=>"y-axis"|
{
x,
y:0
}=>"x-axis"|
{
x,
y
}=>"plane"
}
Lists, Maps, and Records
List type:
t IntList=[Int]
List literal:
[
1,
2,
3
]
Map type:
t StringIntMap={String↦Int}
Map literals use ↦:
λsample1()=>{String↦Int}={
"a"↦1,
"b"↦2
}
λsample2()=>{String↦Int}=({↦}:{String↦Int})
Record types and literals use ::
t User={
id:Int,
name:String
}
λsampleUser()=>User={
id:1,
name:"Ana"
}
Built-In List Operators
Sigil includes canonical list operators:
mapprojectionfilterfilteringreduce ... from ...ordered reduction⧺concatenation
Examples:
λconcatenated()=>[Int]=[
1,
2
]⧺[
3,
4
]
λdoubled()=>[Int]=[
1,
2,
3
] map (λ(x:Int)=>Int=x*2)
λfiltered()=>[Int]=[
1,
2,
3
] filter (λ(x:Int)=>Bool=x>1)
λsummed()=>Int=[
1,
2,
3
] reduce (λ(acc:Int,x:Int)=>Int=acc+x) from 0
map and filter require pure callbacks.
Concurrent Regions
Sigil uses one explicit concurrency surface:
λmain()=>!Timer [ConcurrentOutcome[
Int,
String
]]=concurrent urlAudit@5:{
jitterMs:Some({
max:25,
min:1
}),
stopOn:shouldStop,
windowMs:Some(1000)
}{
spawn one()
spawnEach [
1,
2,
3
] process
}
λone()=>!Timer Result[
Int,
String
]={
l _=(§time.sleepMs(0):Unit);
Ok(1)
}
λprocess(value:Int)=>!Timer Result[
Int,
String
]={
l _=(§time.sleepMs(0):Unit);
Ok(value)
}
λshouldStop(err:String)=>Bool=false
Rules:
- regions are named:
concurrent name@width{...} - width is required after
@ - optional policy attaches as
:{...} - policy fields are canonical alphabetical order:
- jitterMs - stopOn - windowMs
- region bodies are spawn-only:
- spawn expr - spawnEach list fn
spawnrequires an effectful computation returningResult[T,E]spawnEachrequires a list and an effectful function returningResult[T,E]- regions return
[ConcurrentOutcome[T,E]]
Omitted policy defaults to no jitter, no early stop, and no windowing.
windowMs and jitterMs belong to the region policy, not to map or filter.
Sigil also treats these operators as the canonical surface for common list plumbing:
- do not hand-write recursive
allclones; use§list.all - do not hand-write recursive
anyclones; use§list.any - do not count with
#(xs filter pred); use§list.countIf - do not hand-write recursive
mapclones whenmapfits - do not hand-write recursive
filterclones whenfilterfits - do not hand-write recursive
findclones; use§list.find - do not hand-write recursive
flatMapclones; use§list.flatMap - do not hand-write recursive
foldclones whenreduce ... from ...fits - do not hand-write recursive
reverseclones; use§list.reverse - do not build recursive list results with
self(rest)⧺rhs - do not wrap canonical helpers just to rename them; exact wrappers like
λsum1(xs)=>Int=§list.sum(xs)are rejected - do not wrap
map,filter, orreduce ... from ...in trivial top-level aliases; use the operator surface directly
Tests
Tests are top-level declarations.
- in standalone files, they may live directly in the file
- in projects, they live under
tests/
λmain()=>Unit=()
test "adds numbers" {
1+1=2
}
Effectful tests use explicit effect annotations:
λmain()=>Unit=()
test "writes log" =>!Log {
l _=(§io.println("x"):Unit);
true
}
Tests may also derive the active world locally:
λmain()=>Unit=()
test "captured log contains line" =>!Log world {
c log=(†log.capture():†log.LogEntry)
} {
l _=(§io.println("captured"):Unit);
※check::log.contains("captured")
}
Rules:
world { ... }appears between the optional test effects and the body- world clauses are declaration-only and use
cbindings - world bindings must be pure entry values from
†... ※observeand※checkare test-only roots for reading the active test world- project-local test files still live under
tests/; standalone examples may embed tests directly
Canonical References
For canonical formatting and validator-enforced rules, see:
language/docs/CANONICAL_FORMS.mdlanguage/docs/CANONICAL_ENFORCEMENT.md