Modules
module MyModule
def hello
println("Hello from MyModule!")
end
end
Import a module.
import MyModule from "./my_module.sl"
MyModule.hello()
Import specific members.
import { hello } from "./my_module.sl"
hello()
Organize code with exports, imports, mixin modules, classes, and package structure.
Mixin modules
module Name … end is a mixin (and a namespace).
include copies instance methods onto a class;
extend copies them as class methods.
Methods are also callable on the module itself. This is separate from file
import / export.
module Greetable
def greet
"hello, #{self.name}"
end
end
class User
include Greetable
new(name)
@name = name
end
end
User.new("Ada").greet # "hello, Ada"
Greetable.greet # also works — like Ruby `extend self`
class Factory
extend Greetable
end
A class's own methods win over included ones. new Greetable() raises.
Nested classes work as a namespace: Admin::User.
Not yet: prepend, or super into the module.
Concern hooks
The ActiveSupport::Concern shape: an
included do block is replayed as class-body DSL
on the host (so validates / has_many / scope work),
and class_methods do installs class methods.
def self.included(base) is also called.
module Publishable
included do
validates("published_at", { "presence": true })
end
class_methods do
def published
this.where("published_at != null")
end
end
def publish
self.published_at = DateTime.utc()
end
end
class Post < Model
include Publishable
end
Export Declarations
export
Makes a function, class, or constant available to other modules.
# math.sl
# Private function (not exported)
def validate_number(n: Int) -> Bool
n >= 0
end
# Exported functions
export def add(a: Int, b: Int) -> Int
a + b
end
export def subtract(a: Int, b: Int) -> Int
a - b
end
export def multiply(a: Int, b: Int) -> Int
a * b
end
export def divide(a: Int, b: Int) -> Float
if (b == 0)
panic("Division by zero")
end
float(a) / float(b)
end
export def factorial(n: Int) -> Int
if (n <= 1)
return 1
end
n * factorial(n - 1)
end
Classes in Modules
Modules can contain classes, enabling organized domain-driven code structures.
# shapes.sl
module Shapes
class Circle
radius: Float
new(radius: Float)
this.radius = radius
end
def area -> Float
3.14159 * this.radius * this.radius
end
def perimeter -> Float
2 * 3.14159 * this.radius
end
end
class Rectangle
width: Float
height: Float
new(width: Float, height: Float)
this.width = width
this.height = height
end
def area -> Float
this.width * this.height
end
def perimeter -> Float
2 * (this.width + this.height)
end
end
end
Nested Classes in Modules
Modules can also contain nested classes using the :: pattern for domain organization.
# commerce.sl
module Commerce
class Product
name: String
price: Float
quantity: Int
new(name: String, price: Float, quantity: Int)
this.name = name
this.price = price
this.quantity = quantity
end
def in_stock -> Bool
this.quantity > 0
end
def discounted(percent: Float) -> Float
this.price * (1 - percent / 100)
end
end
class Cart
items: Product[]
new
this.items = []
end
def add(product: Product)
this.items.push(product)
end
def total -> Float
sum = 0.0
for item in this.items
sum = sum + item.price
end
sum
end
end
end
Importing Classes
Import classes from modules using named imports or module namespace.
# Import the Commerce module from commerce.sl
import Commerce from "./commerce.sl"
# Use classes from the module
book = new Commerce.Product("Soli Guide", 29.99, 10)
shirt = new Commerce.Product("T-Shirt", 19.99, 3)
println(book.in_stock()) # true
println(book.discounted(20.0)) # 23.992
cart = new Commerce.Cart()
cart.add(book)
cart.add(shirt)
println(cart.total()) # 49.98
Import Statements
import
Import functions and classes from other modules.
# Import all exports
import "./math.sl"
print(add(2, 3)); # 5
print(factorial(5)); # 120
# Named imports
import { add, multiply } from "./math.sl"
sum = add(1, 2); # 3
product = multiply(3, 4); # 12
# Aliased imports
import { add as sum, multiply as times } from "./math.sl"
result = sum(10, 20); # 30
doubled = times(5, 6); # 30
# Import everything with a namespace
import "./utils.sl" as utils
formatted = utils.format_date(DateTime.utc());
Project Structure
my-project/
├── soli.toml
├── src/
│ ├── main.sl
│ ├── config.sl
│ └── utils/
│ ├── mod.sl
│ ├── string.sl
│ ├── array.sl
│ └── datetime.sl
└── lib/
└── math/
├── mod.sl
├── basic.sl
└── advanced.sl
Package Configuration
[package]
name = "my-app"
version = "1.0.0"
description = "My awesome soli application"
main = "src/main.sl"
soli_version = "1.16.0" # minimum Soli version required to run this project
# soli_version = "=1.16.0" # or pin exactly: soli switches to this version here
[dependencies]
# Local dependency
utils = { path = "./lib/utils" }
[dev-dependencies]
test-utils = { path = "./tests/test-utils" }
[scripts]
dev = "soli serve"
build = "soli build --release"
test = "soli test"
The optional soli_version field declares which Soli interpreter the project needs, in
one of two forms.
A minimum
soli_version = "1.16.0" is a floor — the same
idea as Cargo's rust-version (MSRV). soli serve,
soli test and running a script inside the project refuse to start on an older
soli and print an upgrade message. A running version equal to or newer than the declared
one passes. Omit the field to accept any Soli version.
An exact pin
Prefix the version with = and the field stops being a floor and becomes a
pin: soli run inside the project switches to that
exact version.
[package]
soli_version = "=2.0.3" # this project runs on soli 2.0.3, whatever you typed
The first command in a pinned project fetches that version, verifies its published checksum, and caches it
under ~/.cache/soli/runtimes/. Every command after that runs from the cache and says
nothing.
$ soli test
Fetching soli 2.0.3 pinned by /home/me/app/soli.toml ...
Downloading linux-amd64 runtime v2.0.3 ...
Checksum verified.
167 passed, 0 failed
The pin is found by walking up from the current directory, the way
.nvmrc and rustup toolchain files work. So cd-ing into the project is
what activates it; soli serve ./other-app from outside does not adopt that project's pin.
soli which reports what will run, and why.
$ soli which
soli 2.0.3
binary /home/me/.cache/soli/runtimes/v2.0.3/soli-linux-amd64
pinned /home/me/app/soli.toml
A pin is exact in both directions — =2.0.3 is not satisfied by 2.0.4, and a
pre-release does not satisfy the release it precedes. Pinning an older version than the one you have
installed is fine, and is often the point.
- Commands that ignore the pin, because they must act on the soli you
actually invoked:
soli update,soli new,soli which,--versionand--help. - Escape hatch.
SOLI_NO_PIN=1skips the switch entirely — for CI, an air-gapped machine, or bisecting a version-dependent bug. - Older soli binaries ignore the pin rather than failing on it: they
read
"=2.0.3"as an unrecognised minimum, conclude it is satisfied, and carry on. So adding a pin does not break collaborators who have not upgraded — it just does not switch for them.
What a pin means for trust
A pinned toolchain is downloaded over TLS from the release host and checked against the
.sha256 that host publishes. That proves the bytes arrived intact; it does not prove
who made them, exactly like soli update. And there is a consequence worth naming:
after cloning an unfamiliar repository, soli test in it runs an interpreter
that repository chose. Soli therefore always prints the version and the manifest before
fetching, and never fetches for --version or --help.
Lockfile Integrity
soli install, soli add and
soli update record, in soli.lock, a
SHA-256 hash of each git or registry dependency's extracted file tree. It sits on a
comment line after the package's own line:
math|https://github.com/user/math.sl|3f2a9c…|/home/me/.soli/packages/math/3f2a9c…|main
#@integrity math|3f2a9c…|sha256-<64 hex characters>
Commit it with the rest of soli.lock. The model is
trust on first use: the hash is recorded the first time a given resolved revision is
installed and checked on every install after that. A new commit — soli update, or a
branch/tag that moved — is a new revision and records a new hash. Path dependencies are not hashed, and resolving an
import at run time does not re-check; the check happens at install.
When the installed files do not match, the install is refused:
Integrity check failed for module 'math' at 3f2a9c…: soli.lock expects
sha256-…, but the installed files hash to sha256-…. Refusing to use it.
- A fresh download that does not match is deleted. If the new content is expected
(the upstream rewrote history, say), remove the
#@integrity mathline fromsoli.lockand install again to accept it. - An existing cache that does not match is kept, so you can inspect it. Delete that
cache directory (under
~/.soli/packages/) to re-download. - A failed download or extraction now removes the half-written cache directory; it used to be taken as installed on the next run.
- Older
soliversions ignore#lines, so the lockfile stays readable by them. If one rewrites the lockfile, the integrity lines are dropped and recorded again by the next install with a currentsoli.
Use Keyword
use
Alternative syntax for importing (alias for import).
use "./math.sl"
use { add, multiply } from "./math.sl"
use "./utils.sl" as utils;