HWW Portfolio

Software and Hardware Developer

The SOOT Module System: A Complete Guide

Table of Contents

Part 1: How Names Work in SOOT

In SOOT, it is important to distinguish two things: how the compiler finds definitions, and how the runtime stores their values.

1. Definitions in Files

Each .sc file is a separate module. Within a single file, you cannot declare the same name twice:

(defun greet () "hello")
(defun greet () "hi") ; error

To change a function, you must edit its definition, not add a second one.

At the same time, different files may define the same names.

2. How the Compiler Finds Definitions

When a file includes another one via (require "b.sc"), the compiler gains access to its definitions.

If several files define greet, that is not an error: each definition is tied to its own file. When resolving a name, the compiler first checks the current file, then the files it requires (recursively along the require graph).

Thus, identical names in different files can exist independently at the compilation level. The compiler does not merge them into a single table.

3. What Happens at Runtime

When a module is loaded, its global names are registered in the shared Globals table.

In this table, each name has exactly one current value. If several modules define greet, the last load replaces the previous value.

For example, bar.sc defines greet as "hello", and baz.sc defines it as "hi".

This rule applies to the runtime. It does not mean that the compiler mixes definitions from different files.

Modules

4. What Happens in the REPL

The REPL is an interactive console. Code entered there is treated by the compiler as a separate virtual file, such as <repl> or <repl-N>.

Therefore, defining greet in the REPL does not remove the definition from bar.sc in the compiler’s tables. But once the new definition is loaded, its value may replace the previous one in the shared Globals table.

5. Summary


Part 2: Using the Command Line

Compiling a Single File

sootc --no-repl --target build soot_src/tests/bar.sc

What happens:

  1. The compiler reads soot_src/tests/bar.sc.
  2. It builds an AST for it (FileNode named bar).
  3. It generates build/bar.bin and build/bar.h.
  4. It exits because of --no-repl.

--target build is the directory where results are written. If it does not exist, it is created. If omitted, build is used by default.

What is inside bar.bin:

Compiling a File with Dependencies

sootc --no-repl --target build soot_src/tests/foo.sc

foo.sc contains:

(require "bar.sc")

(defun foo-greet () (bar-greet))

(foo-greet)

What happens:

  1. The compiler reads foo.sc.
  2. It sees (require "bar.sc").
  3. It looks for bar.sc: first next to foo.sc (soot_src/tests/bar.sc), then in PROJECT, then in search_paths.
  4. It finds it, compiles bar.sc as a separate module in memory, and registers it in the runtime.
  5. It continues compiling foo.sc: foo-greet now sees bar-greet through the import graph.
  6. It generates build/foo.bin, which records: “this module requires bar”.

build/bar.bin is not written to disk at this point: require compiles bar.sc in memory, but only a separate sootc bar.sc run or (save-module "bar.sc") from the REPL creates the .bin.

Compiling with SIDBase

sootc --no-repl --target build --sidbase build/sidbase.bin soot_src/tests/foo.sc

SIDBase is a file that maps 64-bit hashes (SIDs) back to their strings. Inside .bin, all names are stored as SIDs (0xF9E6E6EF197C2B25), not as strings — this is more compact and faster. But without SIDBase, the disassembler and inspector show <unknown:0x...> instead of bar-greet.

Why --sidbase:

If --sidbase is not given:

Recommendation: always pass --sidbase build/sidbase.bin. One file, accumulating names between runs.

Starting the REPL with a Runtime

sootc --repl --runtime local --sidbase build/sidbase.bin

In the REPL you can:


Part 3: Using the Interactive Session

The compiler can be started with an interactive REPL session.

$ sootc --repl

The window that opens can be used for incremental compilation. To do so, type an expression and press Enter.

What Happens When You Type a Form

For example, the user types into the REPL:

sci> (defun bar-greet () (print "hello"))

The following happens:

  1. The reader parses the string into a SOOT object.
  2. The REPL dispatcher checks: is this a REPL command (from repl_commands()) or an ordinary form? defun is not a command, so it continues.
  3. The compiler compiles the form as a separate module named <repl-N> (N increments with each input).
  4. The runtime loads the module <repl-N>: bar-greet is added to Globals.
  5. The runtime runs the module’s top-level section (if any).
  6. The form is stored in m_session_forms — the session history.

Key point: each REPL input is a separate module <repl-0>, <repl-1>, and so on. This is done so that redefining a name in a later input does not shadow the previous one in the compiler. In the runtime, Globals is a single table, and the last load wins there.

The Problem of Faulty Input

If you type an incorrect defun or any other invalid expression, compilation fails, and the compiler state rolls back to its previous state:

sci> (defun bar-greet (lambda () "wrong"))
; ERROR: FunctionCompiler::parse_arguments ...

This form does not go into m_session_forms — it is not part of the session. Only successfully compiled forms are recorded. That is why (save-session ...) will not fail on earlier errors.

Compiling a Dependency Request

sci> (require "bar.sc")

This is a language form, not a REPL command. It is processed during compilation:

  1. The compiler looks for bar.sc (next to the current file, then PROJECT, then search_paths).
  2. It compiles bar.sc as a separate module (FileNode("bar")).
  3. It registers the module in the runtime.
  4. It adds bar.sc to the import graph of the current file (<repl-N>).

Result: bar-greet is visible to the compiler in the current form via FileNode::lookup, which walks the import graph recursively.

Loading a Module into the Runtime

sci> (load-module "./build/foo")

load-module is an ordinary function call that performs loading at program run time. It is not a compiler command and not a REPL dispatcher command. It:

  1. Looks for foo.bin (first as-is, then in PROJECT, then in search_paths).
  2. Loads it into Globals.
  3. Recursively reads requires: ["bar"] and loads bar.bin.
  4. Runs the top-level section of foo.bin (if any).

The key difference between require and load-module:

What follows from this: after (load-module "bar") you can call (sc-run bar-greet), but you cannot write (bar-greet) — the compiler does not know about bar-greet, because bar.bin did not go through require.

Saving the Console History

sci> (save-session "mysession")

What is saved:

What is not saved:

So mysession.bin saves the forms entered in the console, not copies of every module the console loaded. If the saved session should have a compile-time dependency on the source module foo, use require. load-module loads the finished binary module at run time but does not create such a compiler dependency.

Self-dependency: if you save a session as "bar" and the console had (require "bar.sc"), bar is removed from requires — otherwise bar.bin would require itself and could not be loaded.

Saving a Module

sci> (save-module "soot_src/tests/bar.sc")

What is saved:

When it is useful: when you have edited a .sc file and want to compile exactly that file without leaving the REPL, or when you are working with several files and want to rebuild one of them.

There is also (save-module-recursive "src/main") — it saves the module and every module it requires, in the correct order (dependencies first). One call — the whole dependency graph on disk.

Self-dependency: save-module "bar.sc" removes bar from requires — if bar.sc contains (require "bar.sc"), that is discarded.

The Difference Between save-session and save-module

  save-session "x" save-module "x.sc"
What is saved forms entered in the console the contents of the file x.sc
requires from require in the console from require in the file
top-level every form from the console the top-level from the file
Self-dependency removed removed
When to use it “save what I have built up” “rebuild a file without leaving the REPL”

Part 4: Loading a Module

sci> (load-module "./build/foo")
hello from bar
; => null

What happens:

  1. load-module looks for foo.bin (first as-is, then PROJECT/foo.bin, then PROJECT/search_path/foo.bin).
  2. It loads foo.bin into Globals:
    • foo-greet — registered.
    • top-level — not registered (it is the module’s entry point, not a symbol).
    • requires — not registered (it is metadata).
  3. It reads requires: ["bar"].
  4. It recursively loads bar.bin:
    • bar-greet — registered.
  5. It runs the top-level section of foo.bin: (foo-greet) → (bar-greet) → print "hello from bar".
  6. It prints hello from bar.

; => null — top-level returns nothing, which is normal.

Part 5: Calling a Function

sci> (sc-run foo-greet)
hello from bar
; foo-greet => 1

sc-run is a REPL command. It:

  1. Looks up the name foo-greet in Globals.
  2. Finds the ScriptLambda*.
  3. Calls it through the VM.
  4. Prints the result (1 — because print returns 1).

The difference between (sc-run foo-greet) and (foo-greet):

If you want (foo-greet) to work:

sci> (require "foo.sc")   ; ← not load-module!

require compiles foo.sc and adds foo.sc to the import graph of the current <repl-N>. After that (foo-greet) compiles and runs.


Part 6: Finding Project Files

Project Root

PROJECT is the project root. It is determined in the following order:

  1. --project-root <dir> (or the alias --project <dir>);
  2. the environment variable SOOT_ROOT;
  3. the project_root field in soot.json (a path relative to the directory containing soot.json);
  4. the directory that contains the found soot.json;
  5. the current working directory.

The project_root field in soot.json lets you place the config file anywhere and point to the real root relative to it. Without the field, the directory containing soot.json is the root. Example:

{
  "project_root": "..",
  "runtime": "local",
  "sidbase": "build/sidbase.bin",
  "search_paths": ["lib", "src", "examples"]
}

Here PROJECT is the directory one level above soot.json, and sidbase and search_paths are resolved relative to that PROJECT.

Directories

Files

Search Paths

When looking for require and load-module, the following are checked:

  1. Next to the current file (for require; for load-module — next to the .bin).
  2. CWD — current directory.
  3. EXE — directory of the executable.
  4. PROJECT — project root.
  5. PROJECT / search_path — each of the search_paths (by default ., scripts, src, examples, soot_src, or from soot.json).
  6. CONFIG — system config directory.
  7. SHARE — system shared directory.

Part 7: Common Scenarios

Compile a Single File

sootc --no-repl --target build --sidbase build/sidbase.bin soot_src/tests/bar.sc

Compile a File with Dependencies

sootc --no-repl --target build --sidbase build/sidbase.bin soot_src/tests/foo.sc

foo.bin will contain requires: ["bar"]. bar.bin is not written to disk: require compiles bar.sc only in memory. To get bar.bin, compile bar.sc separately (sootc --no-repl bar.sc) or use (save-module "bar.sc") / (save-module-recursive ...) from the REPL.

Build Several Modules in One Call

Create a script do.st:

; soot_src/tests/do.st
(require "./tests/bar.sc")
(require "./tests/foo.sc")
(save-module "tests/bar")
(save-module "tests/foo")

Run:

sootc --run soot_src/tests/do.st

--run <script.st> executes the script in a new session — equivalent to interactive input. --run does not require --no-repl: by default sootc exits after execution. The REPL is opened only if --repl is explicitly passed, or "repl": true is set in soot.json.

Open the REPL and Load a Module

sootc --repl --runtime local --sidbase build/sidbase.bin
sci> (load-module "./build/foo")
hello from bar
sci> (sc-run foo-greet)
hello from bar

Work in the REPL and Save the Session

sci> (require "bar.sc")
sci> (defun my-fn () (bar-greet))
sci> (save-session "mysession")
; saved mysession.bin, requires: ["bar"]
sci> (e)

Load the Saved Session

sootc --repl --runtime local --sidbase build/sidbase.bin
sci> (load-module "./mysession")
hello from bar
sci> (sc-run my-fn)
hello from bar

Recompile a File from the REPL

sci> (save-module "soot_src/tests/bar.sc")
; saved bar.bin
sci> (load-module "./build/bar")
sci> (sc-run bar-greet)
hello from bar

Part 8: Rules and Pitfalls

The following notes explain how the compiler works.

The following notes describe potential problems.


Conclusion

To navigate the system, it is enough to remember three main distinctions: definitions inside source files and the runtime name table are not the same thing; require works with source code at compile time, while load-module loads a finished binary module at run time; save-session saves console history, while save-module saves a specific source file.

require — compiles and registers. load-module — loads and registers. save-session — saves the console. save-module — saves a file.

Everything else is detail.


Project Status