GuideThe Silo HandbookProgram Structure

Chapter 20 Program Structure

Source

A Silo program isn't just a pile of :fns. It has an entry point, a lifecycle, a test surface, and — distinctively — a host that decides what effects are available and drives execution. This chapter covers the top-level plumbing: :main, :init / :deinit, :test, :cfg, annotations, and the sans-IO host model that everything runs under.

:main — the entry point

A binary's entry point is :main. It carries the effect row of the whole program — anything :main declares is something the host has to provide:

:main +Console +Env
  .args pop-> args
  "Hello from Silo!" print
:end

:main has no signature beyond its effect row. It runs, it returns, the program exits. The effect set it declares is the compact statement of what capabilities the host must install; everything downstream from :main can only use effects :main has claimed (or which come from a :with handler inside the program).

A library has no :main — its entry is src/lib.si (covered in chapter 16). A binary has both a lib.si and a main.si.

:init / :deinit

An :init block runs once per module load, in dependency order, before :main starts. An optional :deinit runs during shutdown. Together they handle setup/teardown and — because :init can produce a value — module-scoped runtime constants.

Every :init has a signature. The shape of that signature chooses one of three forms.

Named — a module-scoped constant word

An :init with an output type and a name creates a module-level word that holds the computed value. The body runs once; the value is memoised; any call to the name afterwards returns the same value.

:init my-pool ( -> Pool ) +Console
  pool-create
:deinit +Console
  pool-drain                    # Pool is on the stack
:end

After loading, my-pool is a word of type ( -> Pool ) you can call from anywhere in the module (or outside it, with the appropriate visibility on the :init). The value was constructed once, at load time, inside an +Console context.

:deinit receives the produced value on the stack and is responsible for tearing it down — in this case, draining the pool before the module unloads.

Anonymous — value for :deinit only

An :init without a name still produces its value, but the value is only reachable from its own :deinit. Good for resources that live for the module's lifetime but don't need to be named elsewhere:

:init ( -> Handle ) +FileSystem
  open-something
:deinit +FileSystem
  close-something               # Handle is on the stack
:end

Side-effect only

An :init with empty output ( -> ) is pure side-effect — no word, no stored value. Useful for registering default aspect handlers, installing tracing hooks, or pre-computing tables whose results live elsewhere:

:init setup-tracing ( -> ) +Console
  install-trace-handlers
:end

The name is optional when the output is empty; it just gives the block something to show up as in diagnostics.

Order

Across modules, :init blocks run in dependency order (parents before children). :deinit blocks run in the reverse order during shutdown. Within a module, multiple :init blocks run in source order.

:test

Tests are declared at top level with :test:

:test "fizzbuzz of 15"
  15 fizzbuzz "FizzBuzz" = assert
:end

A test has a name (a Str literal), an optional effect row, and a body. The body runs under whatever effects the test needs; the test host provides them:

:test "vec operations" +Console
  1 2 3 vec/3
  .first! 1 = assert
  "passed" print
:end

Tests live alongside the code they test — the convention is to put them in the same file inside a :cfg(test) block (see below), though a test runner can pick them up from anywhere.

Annotations

Annotations are @-prefixed metadata on a declaration. Two are relevant to everyday code:

  • @cfg(...) — conditional compilation. The annotated declaration is only compiled when the condition holds:

    @cfg(test)
    :test "addition works"
      2 3 + 5 = assert
    :end
    
    @cfg(effect: Http)
    :fn download ( Url -> Bytes ) +Http ... :end
    
    @cfg(not(test))
    :fn production-only ( -> ) ... :end

    Supported conditions:

    • test — active during silo test.
    • effect: Name — only if the host provides that effect. Lets the same source file compile against hosts with or without a given capability.
    • not(...) / all(...) / any(...) — boolean combinators over the above.

    @cfg is evaluated before type-checking, so an inactive branch doesn't have to be well-formed under the current configuration. An @cfg(effect: Http)-gated word can call +Http words without any fallback needed.

  • @override — suppresses the "shadowing a native impl" warning when you intentionally replace one. The compiler normally warns when a locally-defined impl covers a trait+type that already has a native impl from the trait's or type's home package; @override tells it you meant to do that.

    @override
    :impl (Display SomeStdType)
      .fmt ( SomeStdType FmtSpec -> Str ) ... ;
    :end

Other annotations exist for language-implementation and host-glue work (@lang, @host), but those are not routinely written in user code.

The REPL

Silo's REPL isn't a stripped-down scripting shell — it runs the same frontend and abstract machine the batch compiler does, and the full language surface works inside it. You can:

  • Declare types: :record Point .x F64 .y F64 :end
  • Declare and impl traits: :trait Printable self .display ( self -> Str ) :end
  • Write macros, use them on the next line.
  • Inspect types with :type-info(Point) and pattern-match on the result.
  • Define :init blocks that populate module-scoped constants, then reference them in subsequent expressions.
  • Run effectful words — the REPL host provides every standard effect.

There is no separate "interactive dialect" with fewer features. Compile-time reflection, macros, derive macros, dependent types, effect declarations — all of it works the same way interactively as it does in a file.

The practical upshot is a feedback loop that feels like a notebook or a Lisp REPL: sketch a type, implement a method, try a call, evolve the model incrementally, and paste the final forms into a .si file when you're happy. The handbook's display notation for stack output (⌊...⌉, top on right) is exactly what the REPL draws after each interaction.

The host

Silo is sans-IO: the compiler and the abstract machine never perform I/O themselves. Every side effect goes through an effect handler supplied by the host — usually a Rust program that embeds Silo:

frontend.compile_module(path, source)       # parse + type-check
workspace.link(...) -> ProgramImage         # link into runnable form
machine.start()
loop {
  match machine.poll() {
    RuntimeOutput::EffectRequest { .. } => host handles it, resumes
    RuntimeOutput::Finished => break
    ...
  }
}

The host decides which effects it provides. A CLI host publishes +Console, +FileSystem, +Env. A browser host might publish +Http, +DOM, +Storage. An embedded host on a microcontroller might publish +GPIO and +SPI and nothing else. Your Silo code declares the effects it needs via :main; the compiler rejects programs the host can't satisfy.

Because the machine is sans-IO, the same compiled program image is portable across hosts that provide the same effects. It's also what makes testing, sandboxing, and record-and-replay straightforward: none of those are features of the language, they fall out of the host being able to install whatever effect handler it likes.

The ecosystem layers

Silo is the middle of a three-layer stack:

  • Silo — this language. Concatenative core, dependent types, effects, traits. Everything in this book.
  • The host — the Rust runtime that embeds Silo's frontend and machine and provides effect handlers. The reference implementation is in silo-core / the silo CLI.
  • Tools — the CLI (silo binary) for compiling and running, the REPL for interactive use, silo doc for the handbook+API docs (what you're reading right now), and a growing set of language-server-style editor integrations.

Key points

  • :main is the binary entry point; its effect row is the program's capability surface. No :main for library packages.
  • :init runs once per module load in dependency order. Three forms: named creates a module-scoped constant word holding the produced value; anonymous produces a value available only to :deinit; side-effect-only (empty output) registers hooks without producing a value. An optional :deinit tears down the resource in reverse load order.
  • :test "name" +effects body :end declares a test; tests live alongside code, commonly inside :cfg(test) blocks.
  • @cfg(...) gates a declaration on build-time conditions: test, effect: Name, or boolean combinations. Inactive declarations aren't type-checked.
  • @override silences the warning when a local impl shadows a native one on purpose. Other annotations (@lang, @host) exist for language-implementation and host-glue work and don't appear in routine code.
  • The REPL runs the same frontend as the batch compiler. Everything that works at compile time — types, traits, impls, macros, :type-info, :init — works interactively. No reduced dialect.
  • The host embeds Silo and provides effect handlers. Silo itself is sans-IO; portability, sandboxing, and testing fall out of the host being the only party that touches the outside world.

Next: complete examples — putting it all together with fizzbuzz, binary search, a temperature-converter, and a logger-with-aspects.