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.
Top-Level Declarations
Module scope is declaration-only.
Valid top-level forms:
tecλtest
Invalid at top level:
l
Canonical declaration ordering is:
t => e => c => λ => 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:
λfactorial(n:Int)=>Int match n{
0=>1|
1=>1|
value=>value*factorial(value-1)
}
For function declarations:
=is required before a non-matchbody=is forbidden before amatchbody- the canonical printer keeps the full signature on one physical line
- a direct
matchbody begins on that same line
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:
ClockFsHttpLogProcessRandomTcpTimer
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.
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 onlytdeclarations- 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
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
- current Sigil uses constraints to carry more type meaning and reject obvious literal contradictions
- constraints do not imply automatic runtime validation
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.
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:
¶...¤...•...§...※...†...
Project-defined named types and project sum constructors use:
µ...
There are no selective imports, import aliases, or separate import declarations.
Externs
Extern declarations use e:
e console:{log:λ(String)=>!Log Unit}
e axios:{get:λ(String)=>!Http String}
Unused extern declarations are non-canonical.
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);
()
}
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
- 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, and nominal sum constructors participate in exhaustiveness checking- guards participate in coverage only through a small proof fragment:
- true / false - equality and order comparisons between a bound pattern variable and a literal - boolean and / or / not over those supported facts
- guards outside that fragment remain valid source, but they do not count as full coverage and do not make later arms dead by themselves
- record patterns are not part of the current supported checker surface
Examples:
λ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"
}
Lists, Maps, and Records
List type:
t IntList=[Int]
List literal:
[1,2,3]
Map type:
t StringIntMap={String↦Int}
Map literals use ↦:
{"a"↦1,"b"↦2}
({↦}:{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 and must 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
Canonical References
For canonical formatting and validator-enforced rules, see:
language/docs/CANONICAL_FORMS.mdlanguage/docs/CANONICAL_ENFORCEMENT.md