Chapter 15 Modules and Imports
SourceSilo organises code into modules: one per source file, grouped into namespaces by directory, collected into packages that sit under a top-level namespace name. Every declaration carries a visibility mode that controls who can see it; every file controls what it sees via a single :use block at the top.
Namespaces must have a slash
External package paths follow the shape
namespace/sub:package.module.submodule.Name
The namespace part — everything before the : — must contain at least one /. This is how the compiler distinguishes an external import from an internal one: an internal module reference never has a / in it, while any external package reference always does.
Valid external namespaces:
foo-org/foo # a namespace with one slash
deep/ns/path # deeper nesting is fine
Invalid:
foo # no slash: not a valid external namespace
There is one reserved exception: silo. The silo namespace is reserved for the language itself. silo:std is the standard library; silo:lang is a conventional place for language-built-in items. No other no-slash namespace is allowed.
Putting the pieces together, a fully-qualified external path looks like:
silo:std.iter.CharsIter # namespace "silo", package "std",
# module "iter", name "CharsIter"
foo-org/foo:types.User # namespace "foo-org/foo",
# package "types", name "User"
deep/ns/path:pkg.mod.Thing # multi-segment namespace
Within a package you use short paths — just the module chain from the package root. The / rule is specifically for referring to other packages.
Distinctive features
Aside from the namespace rule, the module system's parts worth knowing up front are the lack of an orphan rule on trait impls, the way imports are explicitly scoped rather than globally visible, and the sealed visibility that lets a declarer allow use without allowing implementation. The rest of the chapter walks through the mechanics.
One file, one module
A .si file is a module. Directory structure forms namespaces. If a package has:
src/
├── lib.si # package root
├── geometry.si # module `geometry`
├── math/
│ ├── mod.si # module `math`
│ └── trig.si # module `math.trig`
└── net/
├── mod.si # module `net`
└── http.si # module `net.http`
then the qualified path math.trig.sin refers to the sin word in src/math/trig.si. The dots in the path are namespace separators.
Fully qualified external paths additionally include the package name: silo:std.iter.CharsIter refers to the CharsIter type in the iter module of the std package in the silo namespace. See the standard-library guide's silo:std.lib for the canonical shape.
Within a module, declaration order doesn't matter — every declaration is visible throughout the file. Circular dependencies between modules are allowed too; the compiler resolves them at link time.
The :use block
Imports live in a :use block at the top of the file. Paths inside a :use are always qualified the same way external references are elsewhere in the language — with the namespace prefix for other packages, or with the module chain for things inside the current package:
:use
:open silo :std .collections Vec HashMap
:open silo :std .traits Eq Ord
:open foo-org/geometry:shapes Point distance
:open my-app.internal.helpers Logger
:open silo :std .iter Iterator => Iter # aliased
:impl foo-org/logging:adapters ( Display Point ) # orphan impl
:aspect foo-org/telemetry:tracing Trace # aspect
:end
Four import directives:
:open <module-path> <names...>— bring specific words / types into scope from another module. The arrow formIterator => Iteraliases the import locally (useful when two modules export the same name or you want a shorter handle).:impl <package-path> (Trait Type)— bring an orphan impl into scope. See below.:aspect <package-path> Aspect— bring an aspect into scope so its operations are callable without qualification.
Every name a :use block brings in must be explicit — there is no glob import. If you need twenty things from a module you name twenty things, or you reach for an aggregation module that re-exports them in one place using :open(pub), and then :open from the aggregation module instead.
The one exception to the explicit-import rule is silo:std.prelude: it is automatically imported into every module (si[module.prelude+4]), so the common stdlib types and traits are always in scope without an explicit :use. The prelude itself is an ordinary module — the only special thing about it is the automatic import. It is not a mechanism packages can opt into for their own preludes; your own aggregation modules still need an explicit :use at each consumer.
Visibility modes
Every top-level declaration has a visibility, given in parens after the keyword:
| Mode | Meaning |
|---|---|
mod |
Module-private. The default. |
pkg |
Visible within the same package. |
pub |
Visible everywhere. |
sealed |
Visible everywhere, but impl-restricted to the declaring package. |
Examples:
:fn ( pub) public-api ( -> Int ) 42 :end
:fn ( pkg) internal-helper ( -> Int ) 7 :end
:fn private-detail ( -> Int ) 3 :end # defaults to mod
sealed
sealed is the one mode that doesn't just hide things. It applies to traits, effects, and aspects, and means "anyone can use me, but only my package can implement me."
:trait ( sealed) Error self { ( Display self ) }
.message ( self -> Str ) "{}" format ;
.source ( self -> ( Option Error ) ) drop None ;
:end
In the same package, impls of Error are fine:
:impl Error MyError
.message ( MyError -> Str ) .msg ;
:end
In a different package, the compiler rejects the attempt:
# In some other package:
:impl Error ForeignType .. . # compile error: Error is sealed
Same story for :effect(sealed) — callers can constrain on the effect, but only the declaring package can :with a handler. Useful for host-level effects where the host is the only valid implementer.
No orphan rule
Any package can implement any trait on any type — including standard-library traits on your own types, and your own traits on standard-library types, and third-party traits on fourth- party types. Coherence is handled per-import-scope rather than per-program: an impl is only in scope where an :impl import brings it in.
Rust forbids this — you can only implement a trait for a type if you own one of them — because Rust's resolution is global and overlapping impls from different crates would break that global view. Silo resolves impls from the import scope at the call site, so "crate A's impl of (Display T)" and "crate B's impl of (Display T)" can coexist in the world as long as no single file tries to bring both in at once.
- Automatic scope: an impl defined in the same package as the trait, or in the same package as the type, is in scope whenever the trait or the type is imported. That covers the common case and you rarely need explicit imports.
- Orphan impls: impls defined in a package that owns neither the trait nor the type must be brought in via
:impl package (Trait Type)in the:useblock.
:use
:open foo-org/geometry:shapes Point
:impl bar-org/logging:adapters ( Display Point )
# Display from silo:std, Point from foo-org/geometry,
# impl defined in bar-org/logging — orphan, explicit import
:end
If two orphan impls of the same (Trait Type) pair both try to be in scope at the same call site, it's a compile error and the importer disambiguates by dropping one of the :impl imports. Specialisation handles the cases where one impl is strictly more specific than another.
The benefit: third-party glue code is possible. You can add Display to someone else's type, add a custom From between two types you didn't write, without "newtype wrappers" everywhere. The cost: importers sometimes have to say which orphan impls they want.
Sealed vs orphan
The two mechanisms address different concerns:
sealedis about the author: "I don't want anyone implementing this trait because I need invariants." The author restricts downstream behaviour.- No orphan rule + scoped impls is about the ecosystem: "anyone can add impls but coherence is per-import-scope." The ecosystem gets mix-and-match flexibility.
Use sealed for traits where implementer control matters (error taxonomies, capability traits). Default to non-sealed for everything else.
Preludes
The standard library has a prelude module (silo:std.prelude) that re-exports the commonly-used names:
- Core types (
Int,Str,Bool,Vec,HashMap, etc.). - Common traits (
Eq,Ord,Hash,Display,Debug,From,Into,Foldable,Iterator, …). - Common aspects (
CurrentLocale, tracing, logging where appropriate).
Every package auto-imports the prelude. Your own package can define its own prelude module that extends or overrides the standard one for internal code.
Key points
- One module per
.sifile; directories form namespaces.mod.siunder a directory is the directory's own module. - Imports go in a single
:useblock at the top of the file::open,:impl,:aspectdirectives. - Visibility modes:
mod(default, module-private),pkg,pub,sealed.sealedrestricts implementation, not use. - No orphan rule. Instead,
:impl package (Trait Type)imports orphan impls explicitly, and automatic scope covers the common case.
ties when multiple impls match; ambiguous cases are compile errors.(named block placeholder leaked into renderer — pair_named_blocks not run)- External paths are
namespace:package.module.Name; the namespace part must contain a/(foo-org/foo,deep/ns/path) except for the reservedsilonamespace. Internal paths drop thenamespace:packageprefix.
Next: lifecycle — locality modes, :dispose, and how Silo handles memory without a garbage collector.