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.sigil for libraries
  • .sigil for executables and tests

Canonical filename rules:

  • basename must be lowerCamelCase
  • no underscores
  • no hyphens
  • no spaces
  • filename must end with .sigil or .lib.sigil

Valid examples:

  • userService.lib.sigil
  • fibonacci.sigil
  • ffiNodeConsole.lib.sigil

Invalid examples:

  • UserService.lib.sigil
  • user_service.lib.sigil
  • user-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:

  • label
  • effect
  • featureFlag
  • rule
  • transform
  • t
  • e
  • c
  • λ
  • 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.sigil files are referenceable from other modules
  • .sigil files are executable-oriented
  • top-level functions, consts, and types in .sigil files must be reachable

from main or tests

  • .lib.sigil files 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-match body
  • = is forbidden before a match body
  • delimited aggregate forms stay flat with 0 or 1 item and print multiline with 2+ items, including type arguments inside signatures
  • a direct match body begins on that same line

Function mode rules:

  • functions are ordinary by default
  • mode total may appear once at the top of a file to make total the default for later function declarations
  • total λname... or ordinary λname... overrides the file default for one declaration
  • only total self-recursive functions may declare decreases
  • functions declared total may not call declarations marked ordinary

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:

  • Clock
  • Fs
  • FsWatch
  • Http
  • Log
  • Process
  • Pty
  • Random
  • Sql
  • Stream
  • Task
  • Tcp
  • Terminal
  • Timer
  • WebSocket

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 requires clause and at most one ensures clause
  • if both are present, requires must come before ensures
  • requires may reference only parameters
  • ensures may reference parameters plus result
  • contracts must typecheck to Bool
  • contracts must be pure and world-independent
  • direct match bodies still begin with match, 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.sigil may contain only t and label declarations
  • outside that file, project-defined types are referenced as µTypeName
  • project sum constructors and patterns from src/types.lib.sigil also use µ...
  • src/types.lib.sigil may 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:

  • where and label are separate surfaces
  • label classifies the type; it does not replace value-level refinement
  • label X combines Y adds 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:

  • rule
  • transform

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 value is 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, and not
  • 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
  • createdAt is required and uses canonical UTC timestamp format YYYY-MM-DDTHH-mm-ssZ
  • default is required
  • default must be a pure expression of the declared flag type
  • current flag types are Bool and 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 \t remain 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 \n escapes

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 ¤site and ¤release
  • §... for standard library modules
  • ¶... for core helper modules
  • †... for runtime/world modules
  • ※... for test observe/check surfaces
  • ☴... for external packages declared in sigil.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-world top-level declaration from the selected config/.lib.sigil
  • using •config. requires --env on compile, run, test, and inspect
  • •config is 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
)
  • ☴name requires a direct exact dependency in sigil.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 calls
  • subscribes λ(...)=>... 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 using body
  • leaving the scope disposes the owned resource
  • the borrowed resource value must not escape the using body
  • code after a guaranteed-terminating initializer in the same using is 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 match prints multiline
  • each arm begins as pattern=>
  • nested branching may continue on following indented lines
  • there is no alternate printed layout for the same match AST

Patterns include:

  • literals
  • identifiers
  • _
  • constructors
  • list patterns
  • record patterns
  • tuple patterns

Current match rules:

  • match is 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:

  • map projection
  • filter filtering
  • reduce ... 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

  • spawn requires an effectful computation returning Result[T,E]
  • spawnEach requires a list and an effectful function returning Result[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 all clones; use §list.all
  • do not hand-write recursive any clones; use §list.any
  • do not count with #(xs filter pred); use §list.countIf
  • do not hand-write recursive map clones when map fits
  • do not hand-write recursive filter clones when filter fits
  • do not hand-write recursive find clones; use §list.find
  • do not hand-write recursive flatMap clones; use §list.flatMap
  • do not hand-write recursive fold clones when reduce ... from ... fits
  • do not hand-write recursive reverse clones; 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, or reduce ... 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 c bindings
  • world bindings must be pure entry values from †...
  • ※observe and ※check are 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.md
  • language/docs/CANONICAL_ENFORCEMENT.md