ESC
Type to search...
S
Soli Docs

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, --version and --help.
  • Escape hatch. SOLI_NO_PIN=1 skips 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 math line from soli.lock and 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 soli versions 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 current soli.

Use Keyword

use

Alternative syntax for importing (alias for import).

use "./math.sl"
use { add, multiply } from "./math.sl"
use "./utils.sl" as utils;