The Suji Programming Language
Welcome to the Suji Programming Language documentation!
Suji is a dynamically-typed scripting language designed for data manipulation, scripting tasks, and general-purpose programming. It combines the expressiveness of modern scripting languages with powerful features like pattern matching, pipes, and native shell integration.
Why Suji?
Suji is built for developers who need:
- Reliable arithmetic - Decimal number semantics eliminate floating-point surprises
- Data transformation power - First-class pipes and functional programming patterns
- Shell integration - Execute commands naturally, without string escaping hell
- Rapid prototyping - Dynamic typing with strict runtime type checking
- Readable code - Clean syntax inspired by functional languages and modern scripting
Who Is Suji For?
Suji is ideal for:
- Data engineers processing JSON, CSV, YAML, and other formats
- DevOps engineers writing automation scripts and configuration tools
- Backend developers building CLI tools and data pipelines
- Anyone who wants a more expressive alternative to shell scripts
Key Features
One Number Type
There is no int versus float split: every number is a base-10 decimal with 28
digits of precision, so arithmetic behaves the way you would work it out on paper.
import std:println
println(0.1 + 0.2) # 0.3, not 0.30000000000000004
println(0.1 + 0.2 == 0.3) # true
Pipes and Method Chains
Transform data left to right, either by chaining methods with :: or by piping
values into functions with |>:
import std:println
numbers = [1, 2, 3, 4, 5]
println(numbers::map(|x| x * 2)::filter(|x| x > 5)::sum()) # 24
total = |xs| xs::sum()
println(numbers |> total) # 15
Pattern Matching
match is Suji’s only conditional construct, in a value form and a
condition form:
import std:println
describe = |value| match {
value == 0 => "zero",
value < 0 => "negative",
value < 10 => "single digit",
_ => "large",
}
println(describe(0)) # zero
println(describe(7)) # single digit
println(describe(900)) # large
Native Shell Integration
Run commands with backtick templates; the result is the command’s output:
import std:println
name = "Suji"
println(`echo Hello from ${name}`) # Hello from Suji
println(`uname -s`::length() > 0) # true
String Interpolation
Built-in ${expression} syntax, in every kind of string literal and in shell
templates:
import std:println
name = "Alice"
age = 30
println("Hello, ${name}! You are ${age} years old.")
println("Next year: ${age + 1}")
A Small Set of Types
Numbers, booleans, strings, lists, maps, tuples, regular expressions, streams,
functions and nil — that is the whole list:
import std:println
user = {
name: "Alice",
age: 30,
tags: ["admin", "developer"],
email: /^[^@]+@[^@]+$/,
}
println(user:name) # Alice
println(user:tags::length()) # 2
println("alice@example.com" ~ user:email) # true
Standard Library
Data formats, filesystem and process access, time, hashing, encoding and more — all explicitly imported:
import std:json
import std:yaml
import std:csv
import std:io
import std:path
import std:time
import std:crypto
Quick Example
Here’s what Suji code looks like:
import std:println
import std:json
# Define a data structure
users = [
{name: "Alice", age: 30, role: "admin"},
{name: "Bob", age: 25, role: "user"},
{name: "Charlie", age: 35, role: "user"},
]
# Transform it with a method chain
adult_admins = users
::filter(|u| u:age >= 30)
::filter(|u| u:role == "admin")
::map(|u| u:name)
println(adult_admins) # [Alice]
# Convert to JSON
output = json:generate({
admin_users: adult_admins,
count: adult_admins::length(),
})
println(output) # {"admin_users":["Alice"],"count":1}
Philosophy
Suji follows these design principles:
- Clarity over cleverness - Code should be easy to read and understand
- Explicit is better than implicit - Imports, types, and control flow are clear
- Composability - Small, focused functions that work together
- Practical defaults - Common cases should be easy, edge cases possible
- Progressive disclosure - Learn the basics quickly, discover advanced features gradually
Language Design covers the concrete decisions these principles led to, and what each one costs.
Language Comparison
Coming from another language? Here’s how Suji compares:
| Feature | Python | JavaScript | Bash | Suji |
|---|---|---|---|---|
| Dynamic typing | Yes | Yes | Yes | Yes |
| Default numbers are exact decimals | No | No | No | Yes |
| Shell command literals | No | No | Yes | Yes |
| Pipelines in the language | No | No | Yes | Yes (|, |>, <|) |
| Pattern matching | Yes (3.10+) | No | No | Yes (match) |
if/for/while statements | Yes | Yes | Yes | No — match and loop |
| Exceptions | Yes | Yes | No | No — errors are fatal |
| Truthiness | Yes | Yes | Yes | No — booleans only |
| JSON/YAML/TOML/CSV in the stdlib | Partly | JSON only | No | Yes |
Suji is pre-1.0 (currently version 0.1.22) and is installed by building from
source. The two most common surprises for newcomers are that there is no if
statement and that runtime errors cannot be caught — see
Conditional Logic and
Error Handling.
Getting Started
Ready to start? Follow these steps:
- Installation - Install Suji on your system
- Quick Start - Write your first programs
- Hello World - Detailed first program walkthrough
- CLI & REPL - Master the command-line tools
Documentation Structure
This book is organized into several main sections:
For Beginners
- Getting Started - Installation, first steps, and basic usage
- Language Fundamentals - Core language features and syntax
- Functions and Modules - Structure and organize your code
For Practitioners
- Standard Library Reference - Every module, function and signature
- Cookbook - Practical recipes for common tasks
- Examples - Complete example programs
For Deep Divers
- Advanced Topics - Error handling, pattern matching, shell integration, performance
- Development - Building, testing and contributing
- Appendices - Syntax reference, precedence, error codes, glossary
Community and Support
- GitHub: github.com/suji-lang/suji
- Issues: Report bugs and request features
License
Suji is open source, released under a 3-clause BSD license. See the LICENSE
file in the repository for the full text.
Enjoy exploring Suji! We hope you find it as expressive and productive as we do.
Installation
Suji is distributed as source code and built with Cargo. There is no published package yet, so every installation starts with a clone and a build.
Prerequisites
- A Unix-like system (macOS or Linux), or Windows with WSL
- The Rust toolchain, stable channel, 1.85 or newer (the workspace uses Rust edition 2024)
- Git
Suji has no C dependencies: every crate it uses is pure Rust, so you do not need OpenSSL or any other system library.
Build from source
1. Install Rust
If you don’t have Rust, install it from rustup.rs:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
If Rust is already installed, make sure it is current:
rustup update stable
rustc --version
2. Clone the repository
git clone https://github.com/suji-lang/suji.git
cd suji
3. Build the interpreter
cargo build --release
The workspace builds the suji-cli package by default, producing the binary at
target/release/suji. The Makefile wraps the same commands:
make build # debug build, faster to compile
make release # optimized build
4. Put suji on your PATH (optional)
cargo install --path crates/suji-cli
This installs the suji binary into ~/.cargo/bin, which rustup already adds to
your PATH. Alternatively, copy the binary yourself:
sudo cp target/release/suji /usr/local/bin/
# or, without sudo
mkdir -p ~/.local/bin && cp target/release/suji ~/.local/bin/
Verify the installation
Suji has no --version or --help flag, so verify it by running a program.
Create hello.si:
import std:println
println("Hello, World!")
Then run it:
suji hello.si
Hello, World!
Starting suji with no file argument opens the REPL:
suji
SUJI Language REPL
Type expressions to evaluate them, or :help for commands
Use Ctrl+C to cancel current input, Ctrl+D or :quit to exit
suji>
Type :quit or press Ctrl+D to leave.
Platform notes
macOS
Install the Xcode Command Line Tools if the linker is missing:
xcode-select --install
Apple Silicon builds natively with the ARM64 toolchain; no extra configuration is needed.
Linux
You need a working linker and C toolchain for Rust itself. On Debian/Ubuntu:
sudo apt update
sudo apt install build-essential
On Fedora/RHEL:
sudo dnf install gcc
Windows
Use WSL 2 and follow the Linux instructions. Suji’s own code is
platform-independent, but the shell integration (backtick command templates) and
parts of std:os assume a Unix shell, so WSL gives the most predictable
behaviour.
Troubleshooting
error: package requires rustc 1.85 or newer — run rustup update stable.
Linking errors — install the platform build tools listed above.
Out of memory while compiling — limit parallelism:
cargo build --release -j 2
suji: command not found — the binary is not on your PATH. Run it by path
(./target/release/suji program.si), add the directory to your PATH, or use
cargo install --path crates/suji-cli.
Working on Suji itself
cargo build # debug build
cargo run -- examples/hello.si
make test # Rust tests + spec suite + examples
make lint # clippy and formatting checks
See the Contributing chapter for the crate layout and testing conventions.
Uninstalling
cargo uninstall suji-cli # if installed with cargo install
sudo rm /usr/local/bin/suji # if copied manually
Then delete the cloned repository.
Next steps
- Work through the Quick Start
- Walk through a first program in Hello World
- Learn the tooling in CLI & REPL
- Read the Language Overview
See Also
Quick Start
Welcome to Suji! This guide will get you up and running in minutes.
What You’ll Learn
In this quick start, you’ll learn:
- How to write your first Suji program
- Basic syntax and structure
- Core language features
- How to run Suji programs
Prerequisites
Make sure you have Suji installed. If not, see the Installation guide.
Your First Program
Let’s start with the classic “Hello, World!”:
import std:println
"Hello, World!" |> println
Save this in a file called hello.si and run it:
suji hello.si
You should see:
Hello, World!
Variables and Functions
Suji makes it easy to work with variables and functions:
import std:println
# Variables are dynamically typed
name = "Alice"
age = 30
# Functions use the |params| syntax
greet = |person| {
"Hello, ${person}!"
}
# Call the function
message = greet(name)
println(message) # Hello, Alice!
Lists and Loops
Working with collections is straightforward:
import std:println
# Create a list
numbers = [1, 2, 3, 4, 5]
# Loop through items
loop through numbers with n {
println(n)
}
Pattern Matching
Suji has powerful pattern matching for control flow:
import std:println
classify = |n| {
match n {
0 => "zero",
1 | 2 | 3 => "small",
_ => match {
n < 10 => "medium",
_ => "large",
},
}
}
println(classify(0)) # zero
println(classify(2)) # small
println(classify(5)) # medium
println(classify(42)) # large
Method Chains and Pipes
Transformations read left to right. Chain methods with :::
import std:println
numbers = [1, 2, 3, 4, 5]
result = numbers::map(|x| x * 2)::filter(|x| x > 5)::sum()
println(result) # 24
Or feed a value into named functions with the pipe-apply operator |>, which
passes the value on the left as the single argument to the function on the right:
import std:println
double_all = |xs| xs::map(|x| x * 2)
big_only = |xs| xs::filter(|x| x > 5)
total = |xs| xs::sum()
result = [1, 2, 3, 4, 5]
|> double_all
|> big_only
|> total
println(result) # 24
Suji also has a shell-style | pipeline for streaming data between closures and
shell commands — see Pipe for the
difference between the two.
Data Transformation Example
Here’s a more realistic example showing data transformation:
import std:println
import std:json
# Sample data
users = [
{name: "Alice", age: 30, role: "admin"},
{name: "Bob", age: 25, role: "user"},
{name: "Charlie", age: 35, role: "user"},
{name: "Diana", age: 28, role: "admin"}
]
# Find adult admins
filtered = users::filter(|u| u:age >= 30)
admins = filtered::filter(|u| u:role == "admin")
adult_admins = admins::map(|u| u:name)
println(adult_admins) # ["Alice"]
Working with JSON
Suji has excellent support for JSON and other data formats:
import std:println
import std:json
# Parse JSON
data = json:parse("""
{
"name": "Suji",
"version": "0.1.0",
"features": ["pipes", "pattern matching", "shell integration"]
}
""")
println(data:name) # Suji
println(data:version) # 0.1.0
# Generate JSON
output = json:generate({
status: "success",
count: 42
})
println(output) # {"count":42,"status":"success"}
json:generate writes object keys in sorted order.
Shell Integration
Execute shell commands directly in your code:
import std:println
# A backtick template runs a command and evaluates to its output
host = `uname -s`
println(host)
# Interpolation works inside the command
name = "Suji"
greeting = `echo Hello from ${name}`
println(greeting) # Hello from Suji
The result is the command’s standard output with the trailing newline removed. A
command that exits non-zero raises a runtime error and stops the program, so
guard risky commands (for example `grep x file || true`).
Function Composition
Compose functions for cleaner code:
import std:println
double = |x| x * 2
increment = |x| x + 1
# Compose functions with >>
double_then_increment = double >> increment
result = double_then_increment(5)
println(result) # 11
Next Steps
Now that you’ve seen the basics, explore more:
- Hello World - Detailed breakdown of your first program
- CLI & REPL - Learn about the command-line interface and REPL
- Language Overview - Understand Suji’s design philosophy
- Data Types - Learn about all available types
- Operators - Master Suji’s operators
Try It Yourself
Experiment with these examples:
- Modify the greeting: Change the
greetfunction to include the age - Filter numbers: Use pipes to find all even numbers in a list
- Parse data: Load a JSON file and extract specific fields
- Combine patterns: Use pattern matching inside a loop
Common Beginner Mistakes
- Forgetting imports: nothing is available without an import, not even
println, and that applies in the REPL too - Leaving off the last comma in a
match: an arm whose body is a bare expression needs a trailing comma, including the final arm - Reaching for
if: Suji has noif,else,fororwhile—matchandloopcover their jobs - Missing colons in maps: use
key: value, notkey = value - Expecting truthiness:
&&,||and!require real booleans, sovalue || "default"is a type error
Getting Help
If you get stuck:
- Read the error message — diagnostics point at the offending span and carry a numeric code you can look up in Error Codes
- Use the REPL to experiment interactively
- Refer to the Standard Library reference
- Review the Examples section
Happy coding with Suji!
Hello World
Let’s write your first Suji program and understand it step by step.
Your First Program
Create a file called hello.si:
import std:println
println("Hello, World!")
Run it:
suji hello.si
Output:
Hello, World!
Congratulations! You’ve just written and run your first Suji program!
Breaking It Down
Let’s understand what each part does:
Line 1: Import Statement
import std:println
This line imports the println function from Suji’s standard library. In Suji:
stdis the standard library module- The colon
:is used to access nested modules or functions printlnis a function that prints text followed by a newline
Why do we need to import? Suji keeps the global namespace clean. You explicitly import only what you need.
Line 2: Print Statement
The second line, println("Hello, World!"), calls the println function with
the string "Hello, World!" as an argument.
- Strings in Suji are enclosed in double quotes
"or single quotes' - Function calls use parentheses:
function_name(arguments) printlnadds a newline character after printing
Variations
Using Single Quotes
import std:println
println('Hello, World!')
Single quotes and double quotes behave identically, including for interpolation.
Using print (No Newline)
import std:print
print("Hello, ")
print("World!")
Output:
Hello, World!
The print function doesn’t add a newline, so both calls print on the same line.
Multiple Imports
import std:print
import std:println
print("Hello, ")
println("World!")
You can import multiple functions with separate import statements.
Adding Variables
Let’s make it more interactive:
import std:println
name = "Alice"
println("Hello, ${name}!")
Output:
Hello, Alice!
What’s new?
name = "Alice"creates a variable calledname${name}is string interpolation - it inserts the value ofnameinto the string- Interpolation works in single-quoted, double-quoted and triple-quoted strings
alike, and
${...}can hold any expression
Creating a Function
Let’s turn our greeting into a reusable function:
import std:println
greet = |name| {
"Hello, ${name}!"
}
message = greet("Alice")
println(message)
Output:
Hello, Alice!
Understanding the function:
greet = |name| { ... }defines a function calledgreet|name|declares the parameter list (between pipe characters|)- The function body is in braces
{ } - The last expression in a function is automatically returned
greet("Alice")calls the function with “Alice” as the argument
A More Complete Example
Here’s a program that demonstrates multiple concepts:
import std:println
# Function to create a greeting
greet = |name, language| {
match language {
"english" => "Hello, ${name}!",
"spanish" => "¡Hola, ${name}!",
"french" => "Bonjour, ${name}!",
_ => "Hi, ${name}!",
}
}
# Use the function
names = ["Alice", "Bob", "Charlie"]
loop through names with name {
message = greet(name, "english")
println(message)
}
Output:
Hello, Alice!
Hello, Bob!
Hello, Charlie!
What’s happening?
- We define a
greetfunction that takes two parameters - We use
matchto choose a greeting based on the language - Every arm ends with a comma — including the last one, which is required whenever an arm’s body is a plain expression
- We create a list of names, loop through it, and print each greeting
Common Beginner Mistakes
Mistake 1: Forgetting to Import
Nothing at all is available without an import — there is no prelude:
# println("Hello")
#
# [401] Error: Undefined variable
# Variable 'println' is not defined
import std:println
println("Hello")
Mistake 2: Leaving Off the Last Comma in a match
import std:println
# This does NOT parse, because the final arm's body is a bare expression:
#
# label = match 1 {
# 1 => "one",
# _ => "many"
# }
label = match 1 {
1 => "one",
_ => "many",
}
println(label) # one
An arm whose body is a { ... } block may omit the comma; an arm whose body is
an expression may not.
Mistake 3: Missing Quotes
import std:println
# println(Hello)
#
# [401] Error: Undefined variable — `Hello` is read as a variable name
println("Hello")
Mistake 4: Reaching for if
Suji has no if, else, for or while keywords. Conditions are match
expressions and iteration is loop:
import std:println
temperature = 30
# if temperature > 25 { ... } <- not valid Suji
message = match {
temperature > 25 => "warm",
_ => "cool",
}
println(message) # warm
Semicolons Are Optional
Coming from JavaScript or Rust, you may reach for semicolons. They are accepted as statement separators but never required, and a newline does the same job:
import std:println
a = 1; b = 2
println(a + b) # 3
Try It Yourself
Exercise 1: Personalize It
Modify the program to print your own name:
import std:println
your_name = "YourName" # Change this
println("Hello, ${your_name}!")
Exercise 2: Multiple Greetings
Print three different greetings:
import std:println
name = "Your Name"
# Add code here to print:
# - Good morning, [name]!
# - Good afternoon, [name]!
# - Good evening, [name]!
Solution
import std:println
name = "Alice"
println("Good morning, ${name}!")
println("Good afternoon, ${name}!")
println("Good evening, ${name}!")
Exercise 3: Create a Function
Write a function that takes a name and returns a farewell message:
import std:println
farewell = |name| {
# Your code here — the last expression in the body is the return value
""
}
println(farewell("Alice")) # your version should print: Goodbye, Alice!
Solution
import std:println
farewell = |name| {
"Goodbye, ${name}!"
}
println(farewell("Alice"))
What’s Next?
Now that you’ve written your first program, learn more:
- CLI & REPL - Master the command-line interface and interactive REPL
- Language Overview - Understand Suji’s design philosophy
- Data Types - Learn about strings, numbers, lists, and more
- Functions - Deep dive into function syntax and features
Quick Reference
Here are the key concepts from this chapter:
| Concept | Syntax | Example |
|---|---|---|
| Import | import module:function | import std:println |
| String (double quotes) | "text" | "Hello" |
| String (single quotes) | 'text' | 'Hello' |
| String interpolation | "${variable}" | "Hello, ${name}!" |
| Variable | name = value | name = "Alice" |
| Function | name = |params| { body } | greet = |n| "Hi, ${n}!" |
| Function call | function(args) | println("Hello") |
Keep experimenting and have fun learning Suji!
CLI & REPL
The suji binary does two things: it runs a program file, or it opens an
interactive REPL. This chapter covers both, plus the handful of flags that exist.
Running a program
suji script.si
Create greet.si:
import std:println
println("Hello from Suji!")
Run it:
suji greet.si
Hello from Suji!
Relative imports inside the script resolve against the script’s own directory, so you can run it from anywhere.
Command-line flags
Suji’s CLI is deliberately tiny. There is no --help, --version, -e or
-c flag; the complete set of options is:
| Option | Description |
|---|---|
<file> | Execute a Suji program |
| (no arguments) | Start the REPL |
--print-ast | Parse the file and print the abstract syntax tree instead of running it |
--print-ast is a development aid rather than something you need day to day.
suji --print-ast greet.si # inspect the parse tree
Exit status
Suji exits with 0 on success and 1 for any diagnosed failure — lexer, parser
or runtime. There are no finer-grained diagnostic codes, and a runtime error
always terminates the program, since Suji has no error-handling construct. Two
failures skip the diagnostic entirely and abort with a Rust panic instead:
numeric overflow exits 101 and a stack overflow exits 134.
if suji script.si; then
echo "Success"
else
echo "Failed"
fi
Your program can choose its own status with os:exit, which takes any
non-negative integer:
import std:os
import std:println
println("done")
os:exit(0)
Script arguments
Arguments arrive through std:env as a map keyed by strings, where "0" is
meant to be the script path and "1" the first argument:
import std:println
import std:env
println(env:args::get("0", "(unknown)"))
println(env:args::get("1", "(none)"))
println(env:args::length())
Run with no arguments, that prints the script’s path, (none) and 1.
Known bug in 0.1.22: positional arguments never make it through. Every argument overwrites key
"0", sosuji args.si alpha betaleavesenv:argsas{0: beta}— the last argument, with the script path gone and"1"still missing. Pass input through an environment variable or stdin until this is fixed; seestd:env.
Arguments that begin with - are consumed by the interpreter itself and never
reach env:args at all, so a script cannot take --flag style options.
Reading standard input
A script can read whatever is piped into it through io:stdin:
import std:println
import std:io
lines = io:stdin::read_lines()
println(lines::length())
printf 'a\nb\nc\n' | suji count.si
3
read_all() returns the whole stream as one string and read_line() reads a
single line. All stream reads are eager and blocking.
Executable scripts
# starts a comment in Suji, so a shebang line is valid Suji source:
#!/usr/bin/env suji
import std:println
println("This is a Suji script!")
chmod +x script.si
./script.si
The REPL
Run suji with no arguments:
suji
SUJI Language REPL
Type expressions to evaluate them, or :help for commands
Use Ctrl+C to cancel current input, Ctrl+D or :quit to exit
suji>
How evaluation works
The REPL evaluates each complete input and echoes the value of the last
statement unless that value is nil. Bindings persist for the session.
suji> 1 + 1
2
suji> "Hello, " + "World!"
Hello, World!
suji> x = 42
42
suji> x * 2
84
Note that values are printed the way to_string() renders them, so strings
appear without surrounding quotes.
Imports are still required
There is no prelude and the REPL adds nothing implicitly — println has to be
imported here just as it does in a file:
suji> println("hi")
[401] Error: Undefined variable
suji> import std:println
suji> println("hi")
hi
3
That trailing 3 is not a surprise: println returns the number of bytes it
wrote, and the REPL echoes the value of the last statement.
Multi-line input
While braces, brackets or parentheses are unbalanced, the REPL switches to the
continuation prompt > and keeps reading:
suji> greet = |name| {
> "Hello, ${name}!"
> }
<function>
suji> greet("Alice")
Hello, Alice!
Input completeness is judged by bracket balance, so an expression that is
syntactically incomplete without unbalanced brackets (a trailing +, say) is
submitted immediately and reports a parse error.
REPL commands
| Command | Description |
|---|---|
:help | Show the built-in help |
:quit | Exit |
:exit | Exit |
| Ctrl+C | Discard the input being typed |
| Ctrl+D | Exit |
| Arrow Up/Down | Browse this session’s history |
Those three colon commands are the only ones; there is no :load, :type,
:vars or :reset. History lives in memory only and is not written to disk.
Things to try in the REPL
Explore data structures:
suji> users = [{name: "Alice", age: 30}, {name: "Bob", age: 25}]
[{name: Alice, age: 30}, {name: Bob, age: 25}]
suji> users[0]:name
Alice
suji> users::map(|u| u:name)
[Alice, Bob]
Build a pipeline one stage at a time:
suji> numbers = [1, 2, 3, 4, 5]
[1, 2, 3, 4, 5]
suji> numbers::map(|x| x * 2)
[2, 4, 6, 8, 10]
suji> numbers::map(|x| x * 2)::filter(|x| x > 5)
[6, 8, 10]
suji> numbers::map(|x| x * 2)::filter(|x| x > 5)::sum()
24
Check a regular expression:
suji> "user@example.com" ~ /^[^@]+@[^@]+\.[^@]+$/
true
suji> "invalid" ~ /^[^@]+@[^@]+\.[^@]+$/
false
Use it as an exact-decimal calculator:
suji> (42 + 8) * 2
100
suji> 100 / 3
33.333333333333333333333333333
suji> 10 ^ 3
1000
REPL limitations
- State is lost when you exit; nothing is saved between sessions.
- A runtime error aborts the current input only — the session survives — but there is still no way for your code to catch it.
- Long programs are easier to iterate on in a file; the REPL is best for checking one expression at a time.
A typical workflow
Sketch the logic in the REPL, then move it into a file once it works:
import std:println
import std:json
data = json:parse('{"users": [{"name": "Alice", "score": 95}, {"name": "Bob", "score": 87}]}')
high_scorers = data:users
::filter(|u| u:score >= 90)
::map(|u| u:name)
println(json:generate({
high_scorers: high_scorers,
count: high_scorers::length(),
}))
{"count":1,"high_scorers":["Alice"]}
(json:generate writes object keys in sorted order, not insertion order.)
Quick reference
suji # start the REPL
suji script.si # run a program
suji script.si a b # run with positional arguments
suji --print-ast script.si # print the parse tree
printf 'x\n' | suji script.si # feed standard input
Next steps
- Language Overview — how the language fits together
- Data Types — the eight value types
- Basic Functions — lambda syntax and returns
- Standard Library Overview — what you can import
See Also
Language Overview
Suji is a dynamically and strongly typed language designed for simplicity and expressiveness.
Key Features
- Dynamic typing: Variables can hold values of any type
- Strong typing: Type safety enforced at runtime
- Higher-order functions: Functions are first-class values
- Closures: Functions can capture variables from their lexical scope
- Pattern matching:
matchexpressions are the only conditional construct — there is noif,else,whileorfor - String interpolation: Built-in
${expression}syntax - Regular expressions: Native regex support with
/pattern/literals - Shell integration: Execute commands with backticks
- Decimal arithmetic: A single fixed-precision decimal number type (~28–29 significant digits), so
0.1 + 0.2 == 0.3 - Pipe operators: Both stream pipes (
|) and function pipes (|>and<|)
Design Philosophy
Suji is designed to be:
- Simple: Familiar syntax that’s easy to learn
- Expressive: Powerful features that reduce boilerplate
- Practical: Built-in support for common tasks (JSON, YAML, shell commands)
- Type-safe: Runtime type checking prevents common errors
- Functional: First-class functions and transformations (
map,filter,fold) that return new collections
Type System
Suji is dynamically typed but strongly typed:
- Variables don’t have type annotations
- Types are checked at runtime
- Type mismatches raise clear runtime errors
- Type checking methods available for runtime validation
Syntax Highlights
Functions
Functions are lambdas bound to a name; there is no fn or def declaration form.
# Function definition
add = |x, y| {
return x + y
}
# Implicit return (last expression)
multiply = |x, y| {
x * y
}
# Single expression (braces optional)
square = |x| x * x
Pattern Matching
match is Suji’s only conditional construct — there is no if/else. An arm
whose body is a bare expression must be followed by a comma, including the last
arm.
import std:println
x = 3
# Value matching
result = match x {
1 => "one",
2 | 3 => "small",
_ => "other",
}
# Conditional matching
status = match {
x > 10 => "large",
x > 0 => "positive",
_ => "zero or negative",
}
println(result) # small
println(status) # positive
String Interpolation
name = "Alice"
age = 30
message = "Hello, ${name}! You are ${age} years old."
# Works with both single and double quotes
message2 = 'Hello, ${name}!'
Method Calls
# Method syntax with ::
text = "hello"
length = text::length()
upper = text::upper()
# List methods
numbers = [1, 2, 3]
doubled = numbers::map(|x| x * 2)
sum = numbers::sum()
Pipe Operators
import std:println
import std:io
# Function pipe (|>)
result = 5 |> (|x| x * 2) |> (|x| x + 1)
println(result) # 11
# Stream pipe (|) — connects closures and shell templates
producer = || {
println("alpha")
println("beta")
}
consumer = || {
lines = io:stdin::read_lines()
println("kept ${lines::length()}")
}
producer() | `grep a` | consumer() # kept 2
Standard Library
Suji includes a comprehensive standard library:
- Data formats: JSON, YAML, TOML, CSV parsing and generation
- I/O: File operations, streams, standard input/output
- System: Environment variables, command-line arguments, OS utilities
- Math: Trigonometric functions, logarithms, constants
- Crypto: Hash functions (MD5, SHA-1, SHA-256, SHA-512), HMAC
- Time: Current time, sleep, ISO-8601 parsing and formatting
- UUID: Version 4 and 5 UUID generation
- Encoding: Base64, hex, percent encoding
- Random: Random numbers and string generation
- Path: Cross-platform path utilities
- Dotenv: Loading
.envfiles into the environment
Nothing is available without an import — there is no prelude, so even printing
needs import std:println.
File Extension
Suji programs use the .si file extension.
Version
Current version: 0.1.22
See Language Versions for detailed version history.
Next Steps
- Learn about Data Types
- Explore Operators
- Study Control Flow
- Check out Functions
Data Types
Suji provides a rich set of data types for representing different kinds of values.
Overview
Understanding data types is fundamental to writing effective Suji programs. This chapter covers all built-in types, their characteristics, operations, and best practices.
Type System
Suji uses dynamic typing - variables can hold any type of value, and types are checked at runtime. However, the type system is strong - operations between incompatible types raise errors rather than coercing values.
# Dynamic typing - no type declarations needed
name = "Alice" # String
age = 30 # Number
active = true # Boolean
# Strong typing - no automatic coercion
# result = "5" + 3 # Error: Can't add string and number
# Explicit conversion required
result = "5"::to_number() + 3 # Works: 8
There is also no truthiness. ! and the right-hand side of &&/|| require an
actual boolean — !nil and nil || "default" are type errors — and a
conditional match only takes an arm whose test is exactly true. nil, 0
and "" are not “falsy”; they simply are not booleans. See
Booleans for the details.
Core Types
Primitive Types
Simple, fundamental types:
- Numbers - A single fixed-precision decimal number type
- Booleans - Logical true/false values
- Strings - Unicode text with interpolation
- Nil - Represents absence of a value
Collection Types
Types for grouping multiple values:
- Lists - Ordered, indexed sequences
- Maps - Key-value dictionaries
- Tuples - Fixed-size, immutable collections
Special Types
Types with unique behaviors:
- Regular Expressions - Pattern matching
- Functions - First-class callable values
- Streams - I/O handles for files and standard streams
Type Characteristics
Immutability
Strings and tuples are immutable. Lists and maps are mutable (some methods modify the value in place).
import std:println
# Lists are mutable
list = [1, 2, 3]
list::push(4)
println(list) # [1, 2, 3, 4]
# Strings are immutable
text = "hello"
upper = text::upper()
println(text) # hello (unchanged)
println(upper) # HELLO
Type Checking
Check types at runtime:
import std:println
value = 42
println(value::is_number()) # true
println(value::is_string()) # false
println(value::is_bool()) # false
text = "hello"
println(text::is_string()) # true
Type Conversion
Explicit conversion between types:
import std:println
# Number to string
num = 42
str = num::to_string()
println(str) # 42
# String to number
text = "123"
num = text::to_number()
println(num) # 123
# Tuple to list
tuple = (1, 2, 3)
list = tuple::to_list()
println(list) # [1, 2, 3]
Choosing the Right Type
Use Numbers For:
- Counting, indexing
- Mathematical calculations
- Measurements, coordinates
Use Strings For:
- Text and messages
- User input/output
- File paths, URLs
Use Booleans For:
- Conditional logic
- Flags and toggles
- Validation results
Use Lists For:
- Ordered collections
- Variable-length sequences
- Data transformations
Use Maps For:
- Structured data
- Key-value associations
- JSON-like objects
Use Tuples For:
- Multiple return values
- Fixed-size groups
- Coordinate pairs
Use Functions For:
- Reusable logic
- Callbacks
- Higher-order operations
Use Regex For:
- Pattern matching
- Text validation
- Data extraction
Type Hierarchy
All Types
├── Primitive
│ ├── Number
│ ├── Boolean
│ ├── String
│ └── Nil
├── Collection
│ ├── List
│ ├── Map
│ └── Tuple
└── Special
├── Function
├── Regex
└── Stream
Common Patterns
Type Guards
import std:println
process_value = |value| {
match {
value::is_number() => "Number: ${value}",
value::is_string() => "String: ${value}",
value::is_bool() => "Boolean: ${value}",
_ => "Unknown type",
}
}
println(process_value(42)) # Number: 42
println(process_value("hello")) # String: hello
println(process_value(true)) # Boolean: true
Type-Safe Operations
import std:println
safe_add = |a, b| {
match { a::is_number() && b::is_number() => a + b, _ => nil, }
}
println(safe_add(5, 3)) # 8
println(safe_add("5", 3)) # nil
Polymorphic Functions
import std:println
length_of = |value| {
match {
value::is_string() => value::length(),
value::is_list() => value::length(),
value::is_map() => value::length(),
_ => 0,
}
}
println(length_of("hello")) # 5
println(length_of([1, 2, 3])) # 3
println(length_of({a: 1, b: 2})) # 2
Performance Considerations
Memory Usage
Different types have different memory characteristics:
- Numbers: Fixed size (a 128-bit decimal)
- Booleans: Fixed size
- Strings: Variable, proportional to length
- Lists: Variable, grows with elements
- Maps: Variable, based on number of keys
- Functions: Small closure overhead
Operation Costs
- Number operations: Decimal arithmetic is done in software, so it is slower than hardware floating point but exact in base 10
- String concatenation: Can be slow for many ops (build a list and
::join()instead) - String indexing: O(n) — indexes and
length()count characters, not bytes - List access: O(1) by index, O(n) by value
- Map access: O(1) average for lookups
- Function calls: Small overhead (closure capture); there are no tail calls, so deep recursion will overflow the stack
Best Practices
DO:
- Use the most appropriate type for your data
- Check types when accepting external input
- Convert types explicitly
- Document expected types in functions
- Use type-safe helper functions
DON’T:
- Expect implicit type coercion — there is none
- Mix types without validation
- Ignore nil possibilities
- Use strings when numbers are more appropriate
- Assume list and map methods copy:
push,pop,mergeanddeletemutate the value in place
Quick Reference
| Type | Literal | Example | Mutable? |
|---|---|---|---|
| Number | 42, 3.14 | age = 30 | Immutable |
| Boolean | true, false | active = true | Immutable |
| String | "text", 'text' | name = "Alice" | Immutable |
| Nil | nil | optional = nil | Immutable |
| List | [...] | nums = [1, 2, 3] | Mutable |
| Map | {...} | user = {name: "Alice"} | Mutable |
| Tuple | (...) | point = (10, 20) | Immutable |
| Function | |x| x * 2 | double = |x| x * 2 | Immutable |
| Regex | /pattern/ | email = /^.+@.+$/ | Immutable |
| Stream | (I/O handles) | s = io:open("file.txt") | I/O |
Mutable types are changed in place by a few methods (list::push, list::pop,
map::merge, map::delete) and by index assignment; every other method returns
a new value and leaves the receiver alone.
Next Steps
Start with the primitive types to build a solid foundation:
- Numbers - Learn arithmetic and math operations
- Strings - Master text manipulation
- Booleans - Understand logical operations
- Lists - Work with collections
- Maps - Handle structured data
Then explore special types for advanced use cases.
See Also
Numbers
Numbers in Suji are represented as fixed-precision decimal numbers (base-10 arithmetic).
Overview
Suji has a single number type: Decimal. All numbers are decimal numbers, whether they have a fractional part or not. Integers are simply decimal numbers with no fractional component.
Key Characteristics
- Single type - All numbers are decimals (no separate integer/float types)
- Decimal arithmetic - Base-10 arithmetic, so
0.1 + 0.2 == 0.3istrue - Fixed precision - About 28–29 significant digits, with a hard maximum of
79228162514264337593543950335. This is not arbitrary precision: exceeding the range aborts the program. - No NaN/Infinity - Invalid operations raise runtime errors
- Simple literals - Only decimal digits and an optional
.(see below)
When to Use Numbers
Use numbers for:
- Counting and indexing
- Mathematical calculations
- Measurements and quantities
- Coordinates and positions
- Financial calculations (decimal arithmetic provides exact precision)
Syntax
All numbers in Suji are decimal numbers. Whether a number has a fractional part or not, it’s still the same decimal type.
Number Literals
import std:println
# Whole numbers (decimals with no fractional part)
age = 30
count = 100
negative = -42
# Decimal numbers (with fractional part)
pi = 3.14159
temperature = -40.5
tiny = 0.000001
println(age) # 30
println(negative) # -42
println(temperature) # -40.5
A number literal is only a run of decimal digits with an optional . and
fractional part. The following forms that other languages accept do not
exist in Suji, and each one is a lex or parse error:
| Not supported | Write instead |
|---|---|
Hex 0xFF, octal 0o77, binary 0b1010 | the decimal value (255, 63, 10) |
Digit separators 1_000_000 | 1000000 |
Scientific notation 3e8 | 300000000 (write the digits out) |
Negative numbers are not part of the literal syntax either: -42 is unary minus
applied to the literal 42.
Very small magnitudes are also limited: a decimal keeps at most 28 digits after
the point, so a value like 6.626e-34 simply cannot be represented. Rescale your
units instead of trying to write it.
Note: There’s no distinction between “integers” and “floats” in Suji. Both 42 and 42.0 are decimal numbers. The difference is only whether they have a fractional component.
Trailing Zeros and Scale
Every number carries a scale (the number of digits it keeps after the decimal point), and arithmetic keeps the larger scale of its operands:
import std:println
println(1.50) # 1.50 (scale preserved)
println(2.50 + 1) # 3.50
println(1.0) # 1 (a literal with only zeros normalises)
println(42.0) # 42
Arithmetic Operations
Basic Operations
import std:println
# Addition
println(5 + 3) # 8
# Subtraction
println(10 - 4) # 6
# Multiplication
println(6 * 7) # 42
# Division (always returns decimal result)
println(15 / 3) # 5
println(10 / 3) # 3.3333333333333333333333333333 (rounded at 28 digits)
# Floor division (get integer part)
println((10 / 3)::floor()) # 3
# Modulo (remainder)
println(10 % 3) # 1
# Exponentiation (the exponent must be a whole number)
println(2 ^ 10) # 1024
# For roots use the sqrt() method, not a fractional exponent:
# 4 ^ 0.5 is a runtime error, "Power exponent must be an integer"
println(4::sqrt()) # 2
^ is right-associative and binds tighter than unary minus, so 2 ^ 3 ^ 2 is
512 (not 64) and -2 ^ 2 is -4 (not 4).
Compound Assignment
import std:println
x = 10
x += 5 # x is now 15
x -= 3 # x is now 12
x *= 2 # x is now 24
x /= 4 # x is now 6
x %= 4 # x is now 2
println(x) # 2
# Postfix increment and decrement are statements, not expressions
x++
println(x) # 3
x--
println(x) # 2
Operator Precedence
import std:println
# Standard precedence (PEMDAS)
result = 2 + 3 * 4 # 14, not 20
println(result)
# Use parentheses for clarity
result = (2 + 3) * 4 # 20
println(result)
# Exponentiation has highest precedence
result = 2 * 3 ^ 2 # 18 (2 * 9)
println(result)
Comparison Operations
import std:println
# Equality
println(5 == 5) # true
println(5 == 6) # false
println(5 != 6) # true
# Relational
println(5 < 10) # true
println(5 > 10) # false
println(5 <= 5) # true
println(10 >= 10) # true
Mathematical Functions
Arithmetic that works on a single number is exposed as methods on the number
itself. The std:math module contains only the transcendental functions and two
constants.
Basic Math (number methods)
import std:println
# Absolute value
println((-5)::abs()) # 5
# Rounding
println(3.7::floor()) # 3
println(3.2::ceil()) # 4
println(3.5::round()) # 4
println(3.4::round()) # 3
# Min/Max (a method on one of the two values)
println(5::min(10)) # 5
println(5::max(10)) # 10
# Power and square root
println(2::pow(10)) # 1024
println(16::sqrt()) # 4
There is no
math:abs,math:floor,math:ceil,math:round,math:min,math:max,math:pow,math:sqrtormath:cbrt. Use the methods above. Suji has no cube-root operation at all;27 ^ (1/3)will not work either because^requires a whole-number exponent. If you need one, write a Newton-iteration helper yourself.
sqrt() returns a decimal approximation for non-perfect squares:
import std:println
println(2::sqrt()) # 1.4142135623730950488016887242
pow() requires a non-negative whole-number exponent (2::pow(-1) fails with
Negative exponents not supported).
Both ^ and pow() overflow far earlier than plain multiplication does. They
work by repeated squaring and square the base once more than the result needs, so
the intermediate value exceeds the decimal range long before the answer would:
import std:println
println(10 ^ 15) # 1000000000000000
println(2 ^ 63) # 9223372036854775808
10 ^ 16 and 2 ^ 64 both abort the program, even though both results are
comfortably inside the decimal range — 1000000000000000 * 10 * 10 computes
100000000000000000 without complaint. Overflow is not a catchable Suji error
either: it is a panic that prints Multiplication overflowed and exits with
status 101 instead of a framed diagnostic.
Trigonometry
import std:math
import std:println
# Angles in radians
println(math:sin(math:PI / 2)) # 1
println(math:cos(0)) # 1
# Trig results are approximations, so don't expect exact values
println(math:tan(math:PI / 4)) # 0.9999999956815324130588099842
# Inverse functions
println(math:asin(1)) # 1.570796326794897 (π/2)
println(math:acos(1)) # 0
println(math:atan(1)) # 0.785398163397448 (π/4)
println(math:atan2(1, 1)) # 0.785398163397448 (two-argument arctangent)
There are no hyperbolic functions (sinh, cosh, tanh) and no inverse
hyperbolics.
Logarithms and Exponentials
import std:math
import std:println
# Natural logarithm
println(math:log(math:E)) # 0.9999999999999999999998942453
# Base 10 logarithm
println(math:log10(100)) # 2
# Exponential
println(math:exp(1)) # 2.7182818261984928651595318263
# Constants (uppercase)
println(math:PI) # 3.14159265358979323846
println(math:E) # 2.71828182845904523536
The complete contents of std:math are PI, E, sin, cos, tan, asin,
acos, atan, atan2, log, log10 and exp. There is no log2, hypot,
sign, clamp, trunc or random (for randomness see
std:random).
Number Methods
Type Checking
import std:println
# Check if a number has no fractional part (is an integer)
println(42::is_int()) # true (no fractional part)
println(3.14::is_int()) # false (has fractional part)
println(42.0::is_int()) # true (no fractional part, even with .0)
# Check if a value is a number
println(42::is_number()) # true
println("text"::is_number()) # false
# Note: Suji uses decimal arithmetic, so NaN and Infinity don't exist
# Division by zero raises a runtime error instead
Conversion
import std:println
# To string
println(42::to_string()) # 42
println(3.14::to_string()) # 3.14
# Parse from string
num = "42"::to_number()
println(num) # 42
decimal = "3.14"::to_number()
println(decimal) # 3.14
to_number() raises a runtime error on input that isn’t a number, and Suji has
no way to trap a runtime error, so validate the text before converting:
import std:println
parse_or_nil = |text| {
match {
text ~ /^-?[0-9]+(\.[0-9]+)?$/ => text::to_number(),
_ => nil,
}
}
println(parse_or_nil("42")) # 42
println(parse_or_nil("abc")) # nil
Formatting
import std:println
# Rounding methods
num = 3.14159
println(num::round()) # 3
println(num::floor()) # 3
println(num::ceil()) # 4
# Note: number::to_fixed(), to_exponential() and to_precision() do not exist.
# To show a fixed number of decimals, scale, round and divide yourself:
two_dp = |x| (x * 100)::round() / 100
println(two_dp(3.14159)) # 3.14
println(two_dp(2.5)) # 2.50
println(two_dp(2)) # 2
This rounds correctly, but how many decimals get printed still depends on the
input’s scale: two_dp(2) shows 2, not 2.00. Suji has no formatting
mini-language, so pad the string yourself when you need fixed-width output.
Number Ranges
import std:println
# Range literals (list of numbers)
range1 = 0..5 # [0, 1, 2, 3, 4]
range2 = 0..=5 # [0, 1, 2, 3, 4, 5] (inclusive)
range3 = 5..0 # [5, 4, 3, 2, 1] (descending)
println(range1)
# Note: Suji doesn't support step ranges directly
# Use filter to get evens/odds:
evens = (0..10)::filter(|x| x % 2 == 0) # [0, 2, 4, 6, 8]
odds = (1..10)::filter(|x| x % 2 == 1) # [1, 3, 5, 7, 9]
println(evens)
Special Values
Division by Zero
Suji uses decimal arithmetic and raises a runtime error on division by zero:
import std:println
# Division by zero raises a runtime error
# result = 1 / 0 # Runtime error
# Check before dividing
safe_divide = |a, b| {
match b {
0 => nil,
_ => a / b,
}
}
println(safe_divide(10, 2)) # 5
println(safe_divide(10, 0)) # nil
Note: Suji does not have Infinity or NaN values. Invalid operations raise runtime errors instead.
Common Patterns
Clamping
import std:println
clamp = |value, min_val, max_val| {
match {
value < min_val => min_val,
value > max_val => max_val,
_ => value,
}
}
println(clamp(5, 0, 10)) # 5
println(clamp(-5, 0, 10)) # 0
println(clamp(15, 0, 10)) # 10
Linear Interpolation
import std:println
lerp = |a, b, t| {
a + (b - a) * t
}
# Interpolate between 0 and 100
println(lerp(0, 100, 0.0)) # 0
println(lerp(0, 100, 0.5)) # 50
println(lerp(0, 100, 1.0)) # 100
Range Mapping
import std:println
# Map value from one range to another
map_range = |value, in_min, in_max, out_min, out_max| {
(value - in_min) * (out_max - out_min) / (in_max - in_min) + out_min
}
# Map 0-100 to 0-1
println(map_range(50, 0, 100, 0, 1)) # 0.50
# Map 0-255 to 0-100 (RGB to percentage)
println(map_range(128, 0, 255, 0, 100)) # 50.196078431372549019607843137
Note the 0.50 in the first result: division keeps the scale of its operands, so
round explicitly when the exact number of decimals matters.
Averaging
import std:println
numbers = [1, 2, 3, 4, 5]
# Mean (::average() does the same thing, and returns nil for an empty list)
mean = numbers::sum() / numbers::length()
println(mean) # 3
println(numbers::average()) # 3
# Median
sorted = numbers::sort()
median = match sorted::length() % 2 {
0 => {
mid = sorted::length() / 2
(sorted[mid - 1] + sorted[mid]) / 2
},
_ => {
mid = (sorted::length() / 2)::floor()
sorted[mid]
},
}
println(median) # 3
Common Pitfalls
Pitfall 1: Decimal Precision
import std:println
# Decimal arithmetic is exact for values that fit in base 10
println(0.1 + 0.2 == 0.3) # true
println(0.1 + 0.2) # 0.3
# But precision is finite: repeating divisions are rounded to 28 digits
println(1 / 3) # 0.3333333333333333333333333333
println((1 / 3) * 3 == 1) # false
So the familiar 0.1 + 0.2 surprise is gone, but you can still lose digits.
Anything that does not terminate in base 10 is rounded, and results larger than
79228162514264337593543950335 abort the program instead of wrapping or
becoming infinity.
Pitfall 2: Division by Zero
import std:println
# Division by zero raises a runtime error (not infinity)
# result = 10 / 0 # Runtime error: "Division by zero"
# Check before dividing
divide_safe = |a, b| {
match b {
0 => nil,
_ => a / b,
}
}
println(divide_safe(10, 2)) # 5
println(divide_safe(10, 0)) # nil
Pitfall 3: Whole Number vs Decimal Division
import std:println
# Suji uses decimal division (always returns decimal result)
println(10 / 3) # 3.3333333333333333333333333333 (exact decimal)
println((10 / 3)::floor()) # 3 (get whole number part using floor)
# All numbers are decimals, so division always returns a decimal
# Use floor() if you need the whole number part
Pitfall 4: Modulo with Negatives
import std:println
# Result has sign of dividend
println(10 % 3) # 1
println(-10 % 3) # -1
println(10 % -3) # 1
Performance Tips
All Numbers Are Decimals
# All numbers are decimals, so there's no "conversion" between types
count = 0
count = count + 1
# Adding 1.0 is the same as adding 1 (both are decimals)
count = count + 1.0 # Same result
# The only difference is whether the result has a fractional part
whole = 42 # Decimal with no fractional part
decimal = 42.5 # Decimal with fractional part
# Both are the same type: Decimal
Decimal arithmetic is implemented in software rather than by the CPU’s floating-point unit, so it is exact but not free. In hot loops, hoist work that does not depend on the loop variable.
Avoid Repeated Calculations
import std:println
items = [1, 2, 3, 4]
# Recalculates the square root on every iteration
total = 0
loop through items with item {
total += item / items::length()::sqrt()
}
println(total) # 5
# Calculate once, outside the loop
sqrt_len = items::length()::sqrt()
total = 0
loop through items with item {
total += item / sqrt_len
}
println(total) # 5
Best Practices
DO:
- Handle division by zero before it happens (it raises a runtime error that cannot be caught)
- Use exact decimal comparisons for terminating values (no epsilon needed)
- Round explicitly with
round()/floor()/ceil()when a result’s scale matters - Keep magnitudes well inside the 28–29 significant digit range
- Document units and ranges
DON’T:
- Think of numbers as separate integer/float types (they’re all decimals)
- Assume arbitrary precision — overflow aborts the program
- Ignore division by zero (it raises an error, not infinity)
- Expect NaN or Infinity (invalid operations raise runtime errors)
- Reach for
math:for abs/round/sqrt/pow/min/max — those are number methods
Next Steps
- Learn about Booleans for logical values
- Explore the Math Module for trigonometry and logarithms
- Study Operators in detail
See Also
Booleans
Booleans represent logical truth values: true or false.
Overview
Booleans are fundamental for control flow, conditionals, and logical operations.
Key Characteristics
- Two values - Only
trueorfalse - Logical operators -
&&,||,!(there are noand/or/notkeywords) - Comparison results - All comparisons return booleans
- Short-circuit evaluation - Efficient logical operations
- No truthiness - Other types are not “falsy”;
!nilandnil || "x"are type errors, and a conditionalmatchonly takes an arm that teststrue
When to Use Booleans
Use booleans for:
- Conditional logic (match)
- Flags and toggles
- Validation results
- State tracking (enabled/disabled, active/inactive)
- Filter conditions
Syntax
Boolean Literals
import std:println
# The two boolean values
is_valid = true
is_active = false
# In expressions
user_logged_in = true
debug_mode = false
println(is_valid) # true
println(debug_mode) # false
Logical Operators
AND (&&)
Both operands must be true:
import std:println
println(true && true) # true
println(true && false) # false
println(false && true) # false
println(false && false) # false
# Short-circuit: second operand not evaluated if first is false
result = false && (10 / 0) # No error - second part not evaluated
OR (||)
At least one operand must be true:
import std:println
println(true || true) # true
println(true || false) # true
println(false || true) # true
println(false || false) # false
# Short-circuit: second operand not evaluated if first is true
result = true || (10 / 0) # No error - second part not evaluated
NOT (!)
Negates a boolean value:
import std:println
println(!true) # false
println(!false) # true
println(!!true) # true (double negation)
Comparison Operators
All comparison operators return booleans:
Equality
import std:println
println(5 == 5) # true
println(5 == 6) # false
println(5 != 6) # true
println("hello" == "hello") # true
Relational
import std:println
println(5 < 10) # true
println(5 > 10) # false
println(5 <= 5) # true
println(10 >= 10) # true
String Comparison
import std:println
println("apple" < "banana") # true (lexicographic)
println("zebra" > "aardvark") # true
Compound Conditions
Combining Operators
import std:println
age = 25
has_license = true
# Can drive if adult with license
can_drive = age >= 18 && has_license
println(can_drive) # true
# Student or senior discount
age = 70
is_student = false
gets_discount = is_student || age >= 65
println(gets_discount) # true
Operator Precedence
import std:println
# NOT has highest precedence
println(!false && true) # true (parsed as (!false) && true)
# AND before OR
println(true || false && false) # true (parsed as true || (false && false))
# Use parentheses for clarity
println((true || false) && false) # false
Short-Circuit Evaluation
AND Short-Circuit
import std:println
# Second expression only evaluated if first is true
check_user = |user| {
user != nil && user:is_active
}
# Safe - doesn't error on nil user
println(check_user(nil)) # false
OR Short-Circuit
import std:println
# The right side is only evaluated when the left side is not `true`
is_weekend = |day| day == "sat" || day == "sun"
println(is_weekend("sat")) # true
println(is_weekend("mon")) # false
# Falling back to a default needs a match: `||` will not accept a non-boolean
get_name = |user| {
match user != nil && user:name != nil {
true => user:name,
false => "Guest",
}
}
println(get_name(nil)) # Guest
println(get_name({name: "Alice"})) # Alice
Truthiness
Suji has no truthiness. Only the value true is true, and non-boolean values
are never coerced:
true && exprevaluatesexpr, which must itself be a boolean- anything other than
trueon the left of&&behaves likefalseand short-circuits, without complaining about its type - a non-boolean on the right of
&&/||is a type error once it is reached:true && 5fails with Logical AND requires boolean operands, andnil || "default"fails with Logical OR requires boolean operands !always requires a boolean:!niland!5are type errors
import std:println
# Only boolean `true` counts as true on the left:
println(true && true) # true
println(5 && true) # false
println("hello" && true) # false
# Use comparisons so both sides are really booleans:
text = "hello"
println(text != "" && true) # true
Because || is strict about its right-hand side, the value || fallback idiom
from other languages does not exist in Suji. Use a match or, for maps,
map::get(key, default):
import std:println
settings = {"retries": 3}
# NOT: settings:timeout || 30 (type error, and a missing key raises anyway)
timeout = settings::get("timeout", 30)
println(timeout) # 30
Converting to Boolean
import std:println
# Nil check
value = nil
is_present = value != nil
println(is_present) # false
# Empty check
list = []
is_empty = list::length() == 0
println(is_empty) # true
# Non-empty
text = "hello"
has_text = text::length() > 0
println(has_text) # true
Common Patterns
Validation
import std:println
validate_user = |user| {
has_name = user:name != nil && user:name::length() > 0
has_email = user:email != nil && user:email ~ /^[^@]+@[^@]+$/
is_adult = user:age != nil && user:age >= 18
has_name && has_email && is_adult
}
user = {
name: "Alice",
email: "alice@example.com",
age: 30
}
println(validate_user(user)) # true
Note that user:name raises Key not found if the key is absent, so
user:name != nil only guards against an explicit nil value. To tolerate
missing keys, read them with user::get("name"), which returns nil instead.
Flags and Toggles
import std:println
# Feature flags
config = {
dark_mode: true,
notifications: false,
beta_features: true
}
# Toggle a flag
config:dark_mode = !config:dark_mode
println(config:dark_mode) # false (toggled)
Conditional Assignment
import std:println
# Using match
status = true
message = match status {
true => "Enabled",
false => "Disabled",
}
println(message) # Enabled
# Using ternary-style match (note the comma after the last arm)
is_admin = true
role = match { is_admin => "Administrator", _ => "User", }
println(role) # Administrator
Guard Clauses
import std:println
process_user = |user| {
# Early returns for validation
user == nil && return "Error: No user"
!user:is_active && return "Error: User not active"
user:age < 18 && return "Error: User must be adult"
# Main logic here
"User processed successfully"
}
println(process_user(nil)) # Error: No user
println(process_user({is_active: false, age: 25})) # Error: User not active
Boolean Methods
Type Checking
import std:println
println(true::is_bool()) # true
println(false::is_bool()) # true
println(1::is_bool()) # false
println("true"::is_bool()) # false
Conversion
import std:println
# To string
println(true::to_string()) # true
println(false::to_string()) # false
# Parse from string (simple example)
parse_bool = |s| {
match s {
"true" => true,
"false" => false,
_ => nil,
}
}
println(parse_bool("true")) # true
println(parse_bool("false")) # false
XOR (Exclusive OR)
XOR is true when operands differ:
import std:println
# Manual XOR
xor = |a, b| {
(a || b) && !(a && b)
}
println(xor(true, true)) # false
println(xor(true, false)) # true
println(xor(false, true)) # true
println(xor(false, false)) # false
# Simpler: != for booleans
println(true != true) # false
println(true != false) # true
Common Pitfalls
Pitfall 1: Comparing with True/False
import std:println
is_valid = true
# Unnecessary comparison
println(match { is_valid == true => "yes", _ => "no", }) # yes
# Use the boolean directly
println(match { is_valid => "yes", _ => "no", }) # yes
# Unnecessary comparison
println(match { is_valid == false => "yes", _ => "no", }) # no
# Use negation
println(match { !is_valid => "yes", _ => "no", }) # no
Pitfall 2: Confusing = and ==
import std:println
# Assignment, not comparison
x = 5
x = 10
# Comparison (use this in conditions)
println(match { x == 10 => "ten", _ => "something else", }) # ten
Pitfall 3: Non-Boolean in Conditionals
import std:println
# A conditional match only takes an arm whose test is exactly `true`.
# Any other value simply fails to match:
text = "hello"
println(match { text => "matched", _ => "fell through", }) # fell through
# Explicit check
println(match { text != "" => "matched", _ => "fell through", }) # matched
# Numbers are not truthy either
count = 0
println(match { count => "matched", _ => "fell through", }) # fell through
# Explicit comparison
println(match { count == 0 => "matched", _ => "fell through", }) # matched
Pitfall 4: Operator Precedence
import std:println
# `!` binds tighter than `&&`, so this is (!false) && true
result = !false && true
println(result) # true
# If you meant to negate the whole conjunction, use parentheses
result = !(false && true)
println(result) # true
Examples
All/Any for Lists
Lists have no built-in any() or all(), so write them yourself with a loop and
an early return:
import std:println
all = |list, predicate| {
loop through list with item {
!predicate(item) && return false
}
true
}
any = |list, predicate| {
loop through list with item {
predicate(item) && return true
}
false
}
numbers = [2, 4, 6, 8]
# All even?
all_even = all(numbers, |x| x % 2 == 0)
println(all_even) # true
# Any odd?
any_odd = any(numbers, |x| x % 2 == 1)
println(any_odd) # false
Boolean Algebra
import std:println
# De Morgan's Laws
a = true
b = false
# !(a && b) == !a || !b
println(!(a && b) == (!a || !b)) # true
# !(a || b) == !a && !b
println(!(a || b) == (!a && !b)) # true
State Machine
import std:println
# A map literal with bare identifier keys is parsed as a block when it is the
# whole body of a match arm, so quote the keys here.
traffic_light = |state| {
match state {
"red" => {"running": false, "warning": false},
"yellow" => {"running": true, "warning": true},
"green" => {"running": true, "warning": false},
_ => {"running": false, "warning": false},
}
}
state = traffic_light("yellow")
println("Running: ${state:running}") # true
println("Warning: ${state:warning}") # true
Best Practices
DO:
- Use boolean values directly in conditionals
- Use descriptive names (
is_active,has_permission) - Keep conditions simple and readable
- Use short-circuit evaluation for safety
- Prefer early returns for validation
DON’T:
- Compare booleans to
trueorfalse - Create complex nested conditions
- Use non-descriptive names (
flag,b,x) - Rely on implicit truthiness (Suji requires explicit booleans)
Next Steps
- Learn about Conditional Logic
- Explore Pattern Matching with booleans
- Study Logical Operators in depth
- Check out Relational Operators
See Also
Strings
Strings represent text in Suji. They support Unicode, interpolation, and rich manipulation methods.
Overview
Strings are sequences of Unicode characters used for text processing.
Key Characteristics
- UTF-8 encoding - Full Unicode support
- String interpolation - Embed expressions with
${...} - Immutable - Operations create new strings
- Two quote styles - Single
'or double" - Multiline support - Triple quotes
"""..."""
When to Use Strings
Use strings for:
- Text and messages
- User input/output
- File paths and URLs
- Configuration values
- Template generation
Syntax
Single and Double Quotes
import std:println
# Single quotes
name = 'Alice'
path = '/tmp/file.txt'
# Double quotes
greeting = "Hello"
message = "Welcome to Suji"
println("${greeting}, ${name}!") # Hello, Alice!
println(path) # /tmp/file.txt
Both work the same for simple strings. String interpolation works with both quote types.
String Interpolation
Works with both single and double quotes:
import std:println
name = "Alice"
age = 30
# Interpolate variables
println("Hello, ${name}!") # Hello, Alice!
println('Hello, ${name}!') # Hello, Alice! (also works with single quotes)
# Interpolate expressions
println("Age: ${age + 1}") # Age: 31
# Multiple interpolations
println("${name} is ${age} years old")
Multiline Strings
Use triple quotes for multiline text:
import std:println
text = """
This is a
multiline
string
"""
println(text)
Escape Sequences
The supported escapes are exactly \n, \t, \r, \", \', \`, \\
and \$:
import std:println
newline = "Line 1\nLine 2"
tab = "Column 1\tColumn 2"
quote = "He said \"Hello\""
backslash = "Path: C:\\Users\\Alice"
dollar = "Costs \${amount}" # escape $ to keep it literal
println(newline)
println(tab)
println(quote)
println(backslash)
println(dollar) # Costs ${amount}
Any other escape is a lex error, including the Unicode escapes you may know
from other languages: \u2764, \u{1F600}, \x41, \0 and \e are all
rejected with Invalid escape sequence. To put a non-ASCII character in a
string, type the character itself — Suji source is UTF-8:
import std:println
heart = "❤"
println(heart) # ❤
There are also no raw strings; use \\ for a literal backslash.
String Operations
Concatenation
import std:println
first = "Hello"
last = "World"
# Using +
result = first + " " + last
println(result) # Hello World
# Using interpolation
result = "${first} ${last}"
println(result) # Hello World
Length
import std:println
text = "Hello"
println(text::length()) # 5
# Counts characters, not bytes
accented = "café"
println(accented::length()) # 4
Indexing
Access characters by position (0-based):
import std:println
text = "Hello"
println(text[0]) # H
println(text[4]) # o
# Negative indices count from end
println(text[-1]) # o
println(text[-2]) # l
Slicing
Extract substrings using semicolon syntax:
import std:println
text = "Hello, World!"
# Range [start;end) - includes start, excludes end
println(text[0;5]) # Hello
println(text[7;12]) # World
# From start
println(text[;7]) # Hello,
# To end
println(text[7;]) # World!
String Methods
Case Conversion
import std:println
text = "Hello World"
println(text::upper()) # HELLO WORLD
println(text::lower()) # hello world
Trimming
import std:println
text = " hello "
println(text::trim()) # hello
# Trim specific characters
text = "***hello***"
println(text::trim("*")) # hello
trim() always trims both ends. There is no trim_start() or trim_end().
Searching
import std:println
text = "Hello, World!"
# Contains
println(text::contains("World")) # true
println(text::contains("xyz")) # false
# Starts with / ends with
println(text::starts_with("Hello")) # true
println(text::ends_with("!")) # true
# Find position
println(text::index_of("World")) # 7
println(text::index_of("xyz")) # -1 (not found)
Splitting
import std:println
text = "apple,banana,cherry"
# Split by delimiter
fruits = text::split(",")
println(fruits) # [apple, banana, cherry]
# Split by space (default separator is `" "`)
text = "one two three"
words = text::split()
println(words) # [one, two, three]
split() takes a plain string separator only — you cannot split on a regex.
Replacing
import std:println
text = "Hello, World!"
# Replace all occurrences
result = text::replace("World", "Suji")
println(result) # Hello, Suji!
# Replace in a string with multiple occurrences
text = "foo bar foo"
result = text::replace("foo", "baz")
println(result) # baz bar baz
Both arguments must be strings. text::replace(/foo/, "baz") is a type error —
there is no regex-based replacement in Suji.
Repeating
import std:println
println("*"::repeat(5)) # *****
println("ab"::repeat(3)) # ababab
Reversing
import std:println
text = "Hello"
println(text::reverse()) # olleH
Converting to List
import std:println
text = "hello"
chars = text::to_list()
println(chars) # [h, e, l, l, o]
to_list() is also how you iterate a string — a string is not directly
iterable (see Character Iteration below).
Methods That Do Not Exist
The complete string method list is length, split, to_number, to_list,
index_of, contains, starts_with, ends_with, replace, trim, upper,
lower, reverse, repeat, to_string (plus the is_* type predicates).
| You might reach for | Use instead |
|---|---|
trim_start() / trim_end() | trim() (both ends), or slice manually |
is_empty() | s::length() == 0 |
capitalize() / title() | slice and upper() the first character (see Title Case) |
pad_start() / pad_end() | build the padding with " "::repeat(n) |
slice() / substring() | slice syntax s[1;3] |
chars() | to_list() |
lines() | split("\n") |
find() | index_of() |
match() / captures() | the ~ operator (boolean only) |
format() | interpolation "${a} ${b}" |
Pattern Matching
With Regular Expressions
import std:println
email = "user@example.com"
# Match operator
is_valid = email ~ /^[^@]+@[^@]+\.[^@]+$/
println(is_valid) # true
text = "Call me at 555-1234"
words = text::split(" ")
phones = words::filter(|w| w ~ /^\d{3}-\d{4}$/)
match {
phones::length() > 0 => { println("Found phone: " + phones[0]) },
_ => println("No phone found"),
}
See Regular Expressions for more details.
Advanced Patterns
Template Strings
import std:println
template = |name, age| {
"""
Name: ${name}
Age: ${age}
Status: ${match { age >= 18 => "Adult", _ => "Minor", }}
"""
}
println(template("Alice", 30))
String Builder Pattern
For building strings efficiently:
import std:println
build_html = |items| {
html = "<ul>"
loop through items with item {
html = html + "<li>${item}</li>"
}
html = html + "</ul>"
html
}
items = ["Apple", "Banana", "Cherry"]
println(build_html(items))
Multi-line with Indentation
import std:println
query = """
SELECT *
FROM users
WHERE age >= 18
AND status = 'active'
ORDER BY name
"""
println(query)
Type Conversion
To String
import std:println
# Numbers
println(42::to_string()) # 42
# Booleans
println(true::to_string()) # true
# Lists/Maps
println([1, 2, 3]::to_string()) # [1, 2, 3]
to_string() is required whenever you mix types with +: "n = " + 1 is a type
error, while "n = " + 1::to_string() (or just "n = ${1}") works.
From String
import std:println
# Parse number
num = "42"::to_number()
println(num) # 42
# Parse boolean
parse_bool = |s| {
match s {
"true" => true,
"false" => false,
_ => nil,
}
}
println(parse_bool("true")) # true
Unicode Support
Unicode Length
String::length() counts Unicode scalar values (Rust chars), not grapheme clusters:
import std:println
text = "Hello World"
println(text::length()) # 11
# Diacritics: a precomposed é is one scalar value
text = "café"
println(text::length()) # 4
# An emoji is usually one scalar value too
text = "👍"
println(text::length()) # 1
Because the unit is the scalar value and not the grapheme cluster, a character
written as a base letter plus a combining mark counts as two, and a
multi-codepoint emoji (a flag, or a family with zero-width joiners) counts as
several. Indexing and length() are therefore O(n) walks over the string.
Character Iteration
A string is not iterable: loop through "Hello" fails with
Cannot iterate over string. Convert it to a list of characters first:
import std:println
text = "Hello"
loop through text::to_list() with char {
println(char)
}
# H
# e
# l
# l
# o
Common Pitfalls
Pitfall 1: Index Out of Bounds
There is no way to catch a runtime error, so check the length before indexing:
import std:println
text = "Hello"
# This would terminate the program:
# char = text[10] # Runtime error: Index out of bounds
char = match {
text::length() > 10 => text[10],
_ => nil,
}
println(char) # nil
Pitfall 2: Mutability Confusion
import std:println
text = "Hello"
result = text::upper() # Returns "HELLO", doesn't modify text
# Original unchanged
println(text) # Hello
println(result) # HELLO
# Rebind the variable to "modify" it
text = text::upper()
println(text) # HELLO
Pitfall 3: String Comparison
import std:println
# Case-sensitive by default
println("Hello" == "hello") # false
# Case-insensitive comparison
println("Hello"::lower() == "hello"::lower()) # true
# `<` and `>` compare lexicographically, but only between two strings —
# comparing a string with a number is a type error
println("apple" < "banana") # true
Performance Considerations
String Concatenation
For many concatenations, collect in list then join:
import std:println
items = ["a", "b", "c"]
# Slow for many items: every + allocates a new string
result = ""
loop through items with item {
result = result + item + ", "
}
println(result) # a, b, c,
# Faster, and no trailing separator
println(items::join(", ")) # a, b, c
String Building
import std:println
items = ["Apple", "Banana"]
# Efficient for complex building: collect the pieces, join once
parts = []
loop through items with item {
parts::push("<li>${item}</li>")
}
html = parts::join("")
println(html) # <li>Apple</li><li>Banana</li>
Examples
Email Validation
import std:println
validate_email = |email| {
email ~ /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
}
println(validate_email("user@example.com")) # true
println(validate_email("invalid.email")) # false
Slugify
replace() only accepts strings, so a slug has to be built character by
character rather than with a regex substitution:
import std:println
allowed = "abcdefghijklmnopqrstuvwxyz0123456789"
slugify = |text| {
out = []
loop through text::lower()::to_list() with ch {
out::push(match {
allowed::contains(ch) => ch,
_ => "-",
})
}
# Collapse runs of "-" and drop the empty leading/trailing pieces
words = out::join("")::split("-")::filter(|p| p::length() > 0)
words::join("-")
}
println(slugify("Hello World!")) # hello-world
println(slugify(" Foo & Bar ")) # foo-bar
Parse CSV Line
import std:println
parse_csv_line = |line| {
line::split(",")::map(|s| s::trim())
}
line = "Alice, 30, Engineer"
fields = parse_csv_line(line)
println(fields) # [Alice, 30, Engineer]
Title Case
import std:println
title_case = |text| {
words = text::lower()::split()
result = []
loop through words with word {
first = word[0]::upper()
rest = word[1;]::lower()
result::push(first + rest)
}
result::join(" ")
}
println(title_case("hello world")) # Hello World
println(title_case("the quick BROWN fox")) # The Quick Brown Fox
Password Strength
import std:println
check_password_strength = |password| {
has_length = password::length() >= 8
has_upper = password ~ /[A-Z]/
has_lower = password ~ /[a-z]/
has_digit = password ~ /[0-9]/
has_special = password ~ /[!@#$%^&*]/
checks = [has_length, has_upper, has_lower, has_digit, has_special]
score = checks::filter(|x| x)::length()
match score {
5 => "Strong",
_ => match score >= 3 {
true => "Medium",
false => "Weak",
},
}
}
println(check_password_strength("abc123")) # Weak
println(check_password_strength("Abc123")) # Medium
println(check_password_strength("Abc123!@")) # Strong
Best Practices
DO:
- Use string interpolation with
${...}(works with both single and double quotes) - Use triple quotes for multiline text
- Use method chaining with
::syntax - Use meaningful variable names
- Handle Unicode correctly
DON’T:
- Concatenate in loops (use join instead)
- Forget strings are immutable
- Ignore Unicode edge cases
- Hard-code strings that should be configurable
Next Steps
- Learn about Lists for collections of strings
- Explore Regular Expressions for pattern matching
- Study String Interpolation in depth
- Check out Text Encoding in the stdlib
See Also
Lists
Lists are ordered, indexed collections that can hold any type of values.
Overview
Lists are one of the most commonly used data types in Suji for storing sequences of items.
Key Characteristics
- Ordered - Items maintain insertion order
- Indexed - Zero-based integer indexing
- Heterogeneous - Can contain mixed types
- Growable - Can add/remove items
- Functional - Rich methods for transformation
When to Use Lists
Use lists for:
- Sequences of items
- Collections that need ordering
- Data pipelines and transformations
- Iterating over elements
- Stack/queue operations
Syntax
Creating Lists
import std:println
# Empty list
items = []
# With initial values
numbers = [1, 2, 3, 4, 5]
names = ["Alice", "Bob", "Charlie"]
# Mixed types
mixed = [1, "two", 3.0, true, nil]
# Nested lists
matrix = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
]
println(items) # []
println(names) # [Alice, Bob, Charlie]
println(mixed) # [1, two, 3, true, nil]
println(matrix) # [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
Note that printing a list shows its elements without quotes: strings inside a
list are not re-quoted by println.
Range Literals
Create lists using range syntax. A range is evaluated immediately into a real
list — it is not lazy, so 0..1000000 allocates a million elements.
import std:println
# Exclusive range (includes start, excludes end)
println(0..10) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# Inclusive range (includes both start and end)
println(0..=10) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Descending range
println(10..5) # [10, 9, 8, 7, 6]
# Descending inclusive
println(10..=5) # [10, 9, 8, 7, 6, 5]
# Negative numbers
println(-2..2) # [-2, -1, 0, 1]
There is no step syntax; use filter to skip elements.
Accessing Elements
import std:println
numbers = [10, 20, 30, 40, 50]
# By index (0-based)
println(numbers[0]) # 10
println(numbers[2]) # 30
# Negative indices (from end)
println(numbers[-1]) # 50 (last)
println(numbers[-2]) # 40 (second to last)
Slicing
Extract sublists using semicolon syntax:
import std:println
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# Range [start;end) - includes start, excludes end
println(numbers[1;3]) # [1, 2]
# From start
println(numbers[;2]) # [0, 1]
# To end
println(numbers[2;]) # [2, 3, 4, 5, 6, 7, 8, 9]
# Negative indices
println(numbers[-2;]) # [8, 9]
Assigning Elements
import std:println
xs = [10, 20, 30]
xs[1] = 99 # xs is now [10, 99, 30]
xs[-1] = 0 # xs is now [10, 99, 0]
println(xs) # [10, 99, 0]
List Methods
Length
import std:println
numbers = [1, 2, 3, 4, 5]
println(numbers::length()) # 5
empty = []
println(empty::length()) # 0
Adding Elements
import std:println
# Push (add to end)
list = [1, 2, 3]
list::push(4)
println(list) # [1, 2, 3, 4]
# Multiple pushes
list::push(5)
list::push(6)
println(list) # [1, 2, 3, 4, 5, 6]
# Concatenate lists
list1 = [1, 2]
list2 = [3, 4]
combined = list1 + list2
println(combined) # [1, 2, 3, 4]
Removing Elements
import std:println
# Pop (remove from end)
list = [1, 2, 3, 4]
last = list::pop()
println(list) # [1, 2, 3]
println(last) # 4
Joining
import std:println
# Join with default separator (space)
words = ["hello", "world"]
println(words::join()) # hello world
# Join with custom separator
numbers = [1, 2, 3]
println(numbers::join(",")) # 1,2,3
Functional Methods
Map
Transform each element:
import std:println
numbers = [1, 2, 3, 4, 5]
# Double each number
doubled = numbers::map(|x| x * 2)
println(doubled) # [2, 4, 6, 8, 10]
# Convert to strings (println shows them unquoted)
strings = numbers::map(|x| x::to_string())
println(strings) # [1, 2, 3, 4, 5]
Filter
Keep only elements that match a condition:
import std:println
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Only even numbers
evens = numbers::filter(|x| x % 2 == 0)
println(evens) # [2, 4, 6, 8, 10]
# Only numbers > 5
large = numbers::filter(|x| x > 5)
println(large) # [6, 7, 8, 9, 10]
Fold
Combine elements into a single value:
import std:println
numbers = [1, 2, 3, 4, 5]
# Sum
sum = numbers::fold(0, |acc, x| acc + x)
println(sum) # 15
# Product
product = numbers::fold(1, |acc, x| acc * x)
println(product) # 120
Sum and Product
Convenient shortcuts for fold:
import std:println
numbers = [1, 2, 3, 4, 5]
println(numbers::sum()) # 15
println(numbers::product()) # 120
Chain Operations
Combine multiple operations:
import std:println
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Filter evens, square them, filter > 20, sum
result = numbers
::filter(|x| x % 2 == 0) # [2, 4, 6, 8, 10]
::map(|x| x * x) # [4, 16, 36, 64, 100]
::filter(|x| x > 20) # [36, 64, 100]
::sum() # 200
println(result) # 200
Searching Methods
Contains
import std:println
fruits = ["apple", "banana", "cherry"]
println(fruits::contains("banana")) # true
println(fruits::contains("grape")) # false
Index Of
Find position of element:
import std:println
fruits = ["apple", "banana", "cherry"]
println(fruits::index_of("banana")) # 1
println(fruits::index_of("grape")) # -1 (not found)
Sorting and Reversing
Sort
import std:println
# Sort numbers
numbers = [3, 1, 4, 1, 5, 9, 2, 6]
sorted = numbers::sort()
println(sorted) # [1, 1, 2, 3, 4, 5, 6, 9]
# Sort strings
fruits = ["cherry", "apple", "banana"]
sorted = fruits::sort()
println(sorted) # [apple, banana, cherry]
sort() takes no arguments: there is no sort_by() and no comparator. To sort by
a derived key, build a list of "key|value" strings, sort that, and map back —
or sort a list of the keys and look values up in a map.
Reverse
import std:println
list = [1, 2, 3, 4, 5]
reversed = list::reverse()
println(reversed) # [5, 4, 3, 2, 1]
println(list) # [1, 2, 3, 4, 5] (original unchanged)
Min, Max, and Average
Min and Max
import std:println
scores = [85, 92, 78, 96, 88]
println(scores::min()) # 78
println(scores::max()) # 96
Average
import std:println
numbers = [1, 2, 3, 4]
println(numbers::average()) # 2.50
empty = []
println(empty::average()) # nil
min(), max(), sum(), product() and average() require numeric elements.
average() is the only one that returns nil for an empty list.
First and Last
import std:println
items = ["first", "middle", "last"]
println(items::first()) # first
println(items::last()) # last
# With default values
empty = []
println(empty::first("n/a")) # n/a
println(empty::last(0)) # 0
Methods That Do Not Exist
The complete list method set is push, pop, length, join, index_of,
filter, map, fold, sum, product, average, contains, reverse,
sort, min, max, first, last, to_string (plus the is_* predicates).
Familiar names from other languages are missing; here is what to use instead:
| You might reach for | Use instead |
|---|---|
each() / for_each() | loop through xs with x { … } |
reduce() | fold(initial, fn) |
any() / all() | a loop with an early return, or filter(...)::length() |
find() | filter(...)::first(nil) |
is_empty() | xs::length() == 0 |
slice() / take() / drop() | slice syntax xs[1;3], xs[;n], xs[n;] |
unique() | a seen map (see Remove Duplicates) |
zip() / enumerate() | (0..xs::length())::map(|i| …) |
flatten() / flat_map() | fold([], |acc, x| acc + x) |
sort_by() | sort() on a derived key list |
insert() / remove() | rebuild with slices and + |
count() | filter(...)::length() |
get() / set() | index syntax xs[i] and xs[i] = v |
Common Patterns
Building Lists
import std:println
# Range literals
numbers = 0..10 # [0, 1, 2, ..., 9]
inclusive = 0..=10 # [0, 1, 2, ..., 10]
# List comprehension style using map
squares = (0..10)::map(|x| x * x)
println(squares) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
Processing with Loops
import std:println
numbers = [1, 2, 3, 4, 5]
doubled = []
loop through numbers with n {
doubled::push(n * 2)
}
println(doubled) # [2, 4, 6, 8, 10]
Common Pitfalls
Pitfall 1: Index Out of Bounds
import std:println
list = [1, 2, 3]
# This would terminate the program:
# item = list[10] # Runtime error: Index out of bounds
# Check first
item = match {
list::length() > 10 => list[10],
_ => nil,
}
println(item) # nil
# Or use first/last with defaults
println(list::first(nil)) # 1
Pitfall 2: Modifying While Iterating
import std:println
# Don't grow a list while iterating it
list = [1, 2, 3, 4, 5]
loop through list with item {
# list::push(item) # Dangerous! Keeps extending what you iterate
continue
}
# Build a new list instead
new_list = []
loop through list with item {
new_list::push(item * 2)
}
println(new_list) # [2, 4, 6, 8, 10]
Pitfall 3: Methods Return New Lists
import std:println
list = [1, 2, 3]
reversed = list::reverse() # Returns a new list
# Original unchanged
println(list) # [1, 2, 3]
println(reversed) # [3, 2, 1]
# push() modifies the list
list::push(4)
println(list) # [1, 2, 3, 4]
Pitfall 4: Empty List Edge Cases
import std:println
empty = []
# Error on empty
# first = empty[0] # Runtime error
# Check first (note the comma after the final arm)
first = match empty::length() {
0 => nil,
_ => empty[0],
}
println(first) # nil
# The same check on one line
last = match empty::length() { 0 => nil, _ => empty[-1], }
println(last) # nil
# Or let first()/last() supply the default
println(empty::first("n/a")) # n/a
There is no is_empty() method — compare length() with 0.
Performance Considerations
Every one of map, filter and fold is eager and allocates a fresh list, so a
long chain walks the data once per stage. Ranges are materialised too: 0..1000000
really does build a list of a million numbers.
Method Chaining
import std:println
list = [3, -1, 4, -5, 9]
# Readable - methods can be chained across lines
result = list
::filter(|x| x > 0)
::map(|x| x * 2)
::sum()
println(result) # 32
Avoid Repeated Concatenation
import std:println
items = [1, 2, 3]
# Slow for many items (O(n²)) - builds a new list on every iteration
result = []
loop through items with item {
result = result + [item]
}
println(result) # [1, 2, 3]
# Use push (amortised O(1) per item)
result = []
loop through items with item {
result::push(item)
}
println(result) # [1, 2, 3]
Examples
Remove Duplicates Preserving Order
There is no unique() method, so track what you have seen in a map:
import std:println
remove_duplicates = |list| {
seen = {}
result = []
loop through list with item {
seen::contains(item) && continue
seen[item] = true
result::push(item)
}
result
}
numbers = [1, 2, 2, 3, 1, 4, 3, 5]
println(remove_duplicates(numbers)) # [1, 2, 3, 4, 5]
Moving Average
import std:println
moving_average = |values, window| {
result = []
loop through 0..(values::length() - window + 1) with i {
window_values = values[i;i + window]
avg = window_values::sum() / window
result::push(avg)
}
result
}
data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
averages = moving_average(data, 3)
println(averages) # [2, 3, 4, 5, 6, 7, 8, 9]
Transpose Matrix
import std:println
transpose = |matrix| {
match matrix::length() {
0 => [],
_ => {
cols = matrix[0]::length()
# Bind the range first: a line starting with "(" would otherwise be
# read as a call on the previous line's value.
indices = 0..cols
indices::map(|col| matrix::map(|row| row[col]))
}
}
}
matrix = [
[1, 2, 3],
[4, 5, 6]
]
transposed = transpose(matrix)
println(transposed) # [[1, 4], [2, 5], [3, 6]]
Best Practices
DO:
- Use functional methods (map, filter, fold)
- Use range literals (
0..10) for sequences - Check list length before accessing indices
- Use meaningful variable names
- Use
first()andlast()with defaults for safe access
DON’T:
- Modify lists while iterating
- Ignore index out of bounds errors
- Use repeated concatenation in loops
- Forget that most methods return new lists (except push/pop)
Next Steps
- Learn about Maps for key-value collections
- Explore Tuples for fixed-size collections
- Study Functional Programming patterns
- Check out Data Transformation recipes
See Also
Maps
Maps are insertion-ordered key-value collections, perfect for structured data and JSON-like objects.
Overview
Maps associate keys with values, providing fast lookups and flexible data structures.
Key Characteristics
- Key-value pairs - Associate data with unique keys
- Insertion-ordered -
keys(),values(),to_list()andloop throughall visit entries in the order they were inserted - Keys are strings, numbers or booleans - not lists, maps or nil
- Nested access - Convenient syntax for deep properties, and those chains are assignable
- Missing keys raise -
m:absentis a runtime error, notnil; usem::get(k, default)orm::contains(k) - JSON-compatible - Natural mapping to JSON objects
When to Use Maps
Use maps for:
- Structured data (user profiles, configuration)
- JSON-like data structures
- Key-based lookups
- Dictionaries and associations
- Optional/sparse data
Syntax
Creating Maps
import std:println
# Empty map
empty = {}
# With initial data
user = {
name: "Alice",
age: 30,
email: "alice@example.com"
}
# Nested maps
config = {
database: {
host: "localhost",
port: 5432
},
features: {
dark_mode: true,
notifications: false
}
}
println(empty) # {}
println(user) # {name: Alice, age: 30, email: alice@example.com}
println(config:database) # {host: localhost, port: 5432}
Bare identifier keys (name:) and quoted keys ("name":) mean the same thing.
Bare keys are only recognised where a map is expected, though: as the entire body
of a match arm or a lambda, { name: "x" } is parsed as a block and fails. Quote
the keys or wrap the literal in parentheses in those positions.
Accessing Values
import std:println
user = {name: "Alice", age: 30}
# Colon notation (preferred)
println(user:name) # Alice
println(user:age) # 30
# Bracket notation
println(user["name"]) # Alice
# Nested access
config = {db: {host: "localhost"}}
println(config:db:host) # localhost
# A missing key is a runtime error, not nil:
# println(user:phone) # Error: Key not found: Key 'phone' not found in map
Use ::get() (below) whenever a key might be absent — Suji has no way to recover
from the Key not found error once it happens.
Setting Values
import std:println
user = {name: "Alice"}
# Add/update with direct assignment
user["age"] = 30
user["email"] = "alice@example.com"
println(user)
# {name: Alice, age: 30, email: alice@example.com}
# Multiple updates
user["age"] = 31
user["city"] = "Boston"
println(user)
# {name: Alice, age: 31, email: alice@example.com, city: Boston}
# Nested chains are assignable too
profile = {user: {name: "Alice"}}
profile:user:name = "Bob"
println(profile) # {user: {name: Bob}}
There is no map::set(key, value) method — assignment is the only way to write a
key.
Map Methods
Get Value
import std:println
user = {name: "Alice", age: 30}
# Get with default
email = user::get("email", "no-email@example.com")
println(email) # no-email@example.com
# Returns nil if not found and no default
phone = user::get("phone")
println(phone) # nil
Check if Key Exists
import std:println
user = {name: "Alice", age: 30}
println(user::contains("name")) # true
println(user::contains("email")) # false
The method is contains(), not has().
Keys and Values
import std:println
user = {name: "Alice", age: 30, city: "Boston"}
# Get all keys, in insertion order
keys = user::keys()
println(keys) # [name, age, city]
# Get all values, in the same order
values = user::values()
println(values) # [Alice, 30, Boston]
# Get key-value pairs as a list of tuples
entries = user::to_list()
println(entries) # [(name, Alice), (age, 30), (city, Boston)]
to_list() is the pair-list accessor; there is no entries(). Remember that the
pairs are tuples, so unpack them by destructuring (k, v = pair) rather than
indexing — see Tuples.
Size
import std:println
user = {name: "Alice", age: 30}
println(user::length()) # 2
empty = {}
println(empty::length()) # 0
There is no is_empty(); compare length() with 0.
Remove Key
import std:println
user = {name: "Alice", age: 30, email: "alice@example.com"}
# Remove a key (mutates the map, returns true if the key was present)
println(user::delete("email")) # true
println(user) # {name: Alice, age: 30}
The method is delete(), not remove().
Merge Maps
import std:println
defaults = {theme: "light", lang: "en"}
user_prefs = {theme: "dark"}
# Merge (user_prefs overwrites defaults)
defaults::merge(user_prefs)
println(defaults) # {theme: dark, lang: en}
merge() mutates the receiver and returns nil, so merged = a::merge(b)
would leave merged as nil. Merge into the map you want to end up with.
Functional Operations
Maps have no map(), filter() or each() methods — those exist only on
lists. Iterate with loop through m with k, v and build a new map yourself.
Map Values
Transform all values:
import std:println
prices = {apple: 1.00, banana: 0.50, cherry: 2.00}
# Double all prices (manual transformation)
doubled = {}
loop through prices with key, value {
doubled[key] = value * 2
}
println(doubled) # {apple: 2, banana: 1, cherry: 4}
Two bindings after with only work for maps: loop through some_list with a, b
is a runtime error.
Filter
Keep only entries matching condition:
import std:println
users = {
alice: {age: 30, active: true},
bob: {age: 25, active: false},
charlie: {age: 35, active: true}
}
# Only active users (manual filtering)
active = {}
loop through users with key, val {
match { val:active => { active[key] = val } }
}
println(active)
# {alice: {age: 30, active: true}, charlie: {age: 35, active: true}}
A conditional match with no matching arm evaluates to nil and does nothing,
which is why the single-armed match above works as a filter.
Common Patterns
Default Values
import std:println
get_config = |key, default| {
config = {timeout: 30, retries: 3}
config::get(key, default)
}
println(get_config("timeout", 60)) # 30
println(get_config("max_size", 100)) # 100 (default)
Nested Access with Safety
import std:println
safe_get = |m, path| {
result = m
loop through path with key {
match {
result::is_map() => { result = result::get(key) }
_ => { result = nil }
}
}
result
}
data = {user: {profile: {name: "Alice"}}}
# Safe deep access
name = safe_get(data, ["user", "profile", "name"])
println(name) # Alice
# Returns nil for missing paths
missing = safe_get(data, ["user", "settings", "theme"])
println(missing) # nil
Building Maps
import std:println
# From lists of pairs
pairs = [["a", 1], ["b", 2], ["c", 3]]
map = {}
loop through pairs with pair {
map[pair[0]] = pair[1]
}
println(map) # {a: 1, b: 2, c: 3}
# From keys and values
keys = ["name", "age", "city"]
values = ["Alice", 30, "Boston"]
user = {}
limit = keys::length()
match { values::length() < limit => { limit = values::length() } }
loop through (0..limit) with i {
user[keys[i]] = values[i]
}
println(user) # {name: Alice, age: 30, city: Boston}
Grouping
import std:println
users = [
{name: "Alice", role: "admin"},
{name: "Bob", role: "user"},
{name: "Charlie", role: "admin"}
]
# Group by role
by_role = {}
loop through users with u {
role = u:role
group = by_role::get(role, [])
group::push(u)
by_role[role] = group
}
println(by_role)
# {admin: [{name: Alice, role: admin}, {name: Charlie, role: admin}], user: [{name: Bob, role: user}]}
get(role, []) gives a fresh default list, so the pattern above works even for
the first user in each group. Note that lists have no group_by() method — this
loop is the idiom.
JSON Integration
Maps work naturally with JSON:
import std:json
import std:println
# Map to JSON (json:generate sorts the keys)
user = {name: "Alice", age: 30, active: true}
json_str = json:generate(user)
println(json_str) # {"active":true,"age":30,"name":"Alice"}
# JSON to Map
data = json:parse("""
{
"users": [
{"name": "Alice", "age": 30},
{"name": "Bob", "age": 25}
]
}
""")
# Access like a map
println(data:users[0]:name) # Alice
Common Pitfalls
Pitfall 1: Missing Keys Raise an Error
This is the most common surprise for people coming from other languages: reading
an absent key does not give you nil, it terminates the program with
Key not found.
import std:println
user = {name: "Alice"}
# This would abort the script:
# age = user:age # Error: Key not found: Key 'age' not found in map
# Use get() with a default
println(user::get("age", 0)) # 0
# get() with no default yields nil
println(user::get("age")) # nil
# Or check first
age = match user::contains("age") {
true => user:age,
false => 0,
}
println(age) # 0
Pitfall 2: Key Types
import std:println
# Keys may be strings, numbers or booleans
map = {1: "one", 2: "two"}
println(map[1]) # one
# A number key and a string key are different keys
map["1"] = "string one"
println(map::length()) # 3
# Lists, maps and nil cannot be keys (Invalid key type)
Be consistent: mixing 1 and "1" in one map is legal and almost always a bug.
Pitfall 3: Nested Updates
import std:println
user = {profile: {name: "Alice"}}
# Access chains are assignable, so you can update in place
user:profile:name = "Bob"
println(user) # {profile: {name: Bob}}
# Bracket form works the same way
user["profile"]["name"] = "Carol"
println(user) # {profile: {name: Carol}}
Every key along the chain must already exist, though — assigning through a missing intermediate key raises Key not found.
Pitfall 4: Iteration Order
Maps preserve insertion order, so iteration is deterministic — but insertion order is rarely the order you want to display. Sort the keys when presentation matters:
import std:println
map = {c: 3, a: 1, b: 2}
# Insertion order
println(map::keys()) # [c, a, b]
# Sorted order
loop through map::keys()::sort() with key {
println("${key}: ${map[key]}")
}
# a: 1
# b: 2
# c: 3
Note that json:generate() sorts keys, so a round-trip through JSON does not
preserve insertion order.
Performance Considerations
Lookups are Fast
Map lookups are O(1) average case - very fast even for large maps:
import std:println
# Build a map with 1000 entries
large_map = {}
loop through 0..1000 with i {
large_map[i] = i * i
}
# Lookup cost does not grow with the map
println(large_map[999]) # 998001
println(large_map::get(5000, nil)) # nil
Choose Right Data Structure
import std:println
# Use a map for key-based lookup
user_by_id = {
123: {name: "Alice"},
456: {name: "Bob"}
}
println(user_by_id[456]:name) # Bob
# A list needs a scan for the same question
users_list = [{id: 123, name: "Alice"}, {id: 456, name: "Bob"}]
found = users_list::filter(|u| u:id == 456)::first(nil)
println(found:name) # Bob
Examples
Configuration Merging
import std:println
# merge() mutates `defaults`, so return it explicitly rather than the nil
# that merge() itself gives back.
merge_config = |defaults, user_config| {
defaults::merge(user_config)
defaults
}
defaults = {
theme: "light",
lang: "en",
notifications: true,
timeout: 30
}
user = {
theme: "dark",
lang: "es"
}
final = merge_config(defaults, user)
println(final)
# {theme: dark, lang: es, notifications: true, timeout: 30}
Object Transformation
import std:println
# Transform user object. The keys are quoted because a map literal that is the
# whole body of a lambda would otherwise be read as a block.
transform_user = |user| {
{
"full_name": "${user:first_name} ${user:last_name}",
"contact": user:email,
"is_adult": user:age >= 18,
}
}
user = {
first_name: "Alice",
last_name: "Smith",
age: 30,
email: "alice@example.com"
}
transformed = transform_user(user)
println(transformed)
# {full_name: Alice Smith, contact: alice@example.com, is_adult: true}
Counting Occurrences
import std:println
count_occurrences = |items| {
counts = {}
loop through items with item {
current = counts::get(item, 0)
counts[item] = current + 1
}
counts
}
words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
result = count_occurrences(words)
println(result) # {apple: 3, banana: 2, cherry: 1}
Index By Key
import std:println
index_by = |list, key| {
result = {}
loop through list with item {
id = item[key]
result[id] = item
}
result
}
users = [
{id: 1, name: "Alice"},
{id: 2, name: "Bob"},
{id: 3, name: "Charlie"}
]
by_id = index_by(users, "id")
println(by_id[2]) # {id: 2, name: Bob}
Methods That Do Not Exist
The complete map method set is keys, values, to_list, get, contains,
delete, length, merge, to_string (plus the is_* predicates).
| You might reach for | Use instead |
|---|---|
set(k, v) | assignment m[k] = v |
has(k) | contains(k) |
remove(k) | delete(k) |
entries() | to_list() |
is_empty() | m::length() == 0 |
each() | loop through m with k, v { … } |
map() / filter() | a loop that builds a new map |
Best Practices
DO:
- Use consistent key types (usually strings)
- Read possibly-absent keys with
get(key, default)— direct access raises - Use
contains(key)before writing through a nested chain - Use maps for structured data
- Leverage JSON integration
DON’T:
- Assume a missing key gives you
nil - Expect
merge()ordelete()to return a new map — they mutate in place - Rely on insertion order surviving a JSON round-trip
- Mix different conventions for key naming
Next Steps
- Learn about Tuples for fixed-size collections
- Explore JSON for data serialization
- Study Pattern Matching with maps
- Check out Data Transformation recipes
See Also
Tuples
Tuples are immutable, fixed-size collections that group heterogeneous values together.
Overview
Tuples let you package multiple values into a single compound value, perfect for returning multiple results or grouping related data.
Key Characteristics
- Fixed size - Cannot add/remove elements
- Immutable - Cannot change after creation
- Heterogeneous - Can contain different types
- Not indexable -
t[0]is a type error; there is noget,firstorsecondeither - Destructuring -
a, b = tis the way to get the elements out
When to Use Tuples
Use tuples for:
- Multiple return values from functions
- Grouping related but different types
- Temporary data bundling
- Coordinate pairs or triples
- Fixed-size records
Syntax
Creating Tuples
import std:println
# Empty tuple
empty = ()
# With values
pair = (1, 2)
triple = (1, "two", 3.0)
nested = (1, (2, 3), 4)
# Single element tuple (note the comma)
single = (42,)
println(empty) # ()
println(pair) # (1, 2)
println(triple) # (1, two, 3)
println(nested) # (1, (2, 3), 4)
println(single) # (42,)
Accessing Elements
Tuples are not indexable. t[0] fails with Cannot index tuple, and there
are no get(), first() or second() methods — the only tuple methods are
length(), to_list() and to_string(). Get at the elements by destructuring,
or convert to a list first:
import std:println
point = (10, 20, 30)
# This does NOT work:
# println(point[0]) # Error: Type error: Cannot index tuple
# Destructure instead
x, y, z = point
println(x) # 10
println(z) # 30
# Or convert to a list, which *is* indexable
coords = point::to_list()
println(coords[0]) # 10
println(coords[-1]) # 30
Destructuring
The most powerful feature of tuples. Write the targets without parentheses —
(x, y) = point is not destructuring syntax and will not work:
import std:println
# Unpack tuple into variables
point = (10, 20)
x, y = point
println("x: ${x}, y: ${y}") # x: 10, y: 20
# Ignore values with underscore
triple = (1, 2, 3)
first, _, third = triple
println("${first}, ${third}") # 1, 3
Destructuring is one level deep: a nested tuple comes out as a tuple that you unpack in a second step.
import std:println
data = (1, (2, 3), 4)
a, inner, d = data
println(inner) # (2, 3)
b, c = inner
println("${a}, ${b}, ${c}, ${d}") # 1, 2, 3, 4
The number of targets must match the tuple exactly — a, b = (1, 2, 3) fails
with Destructuring arity mismatch: expected 2, got 3.
Multiple Return Values
Tuples excel at returning multiple values:
import std:println
# Function returning tuple
# Use an explicit `return` for the tuple: a line that begins with "(" would
# otherwise be read as a call on the previous line's value.
divide_with_remainder = |a, b| {
quotient = (a / b)::floor()
remainder = a % b
return (quotient, remainder)
}
# Destructure the result
q, r = divide_with_remainder(17, 5)
println("17 ÷ 5 = ${q} remainder ${r}") # 17 ÷ 5 = 3 remainder 2
return a, b also builds a tuple, so an explicit early return can carry two
values:
import std:println
split_sign = |n| {
n < 0 && return ("negative", n::abs())
return ("non-negative", n)
}
label, magnitude = split_sign(-7)
println("${label} ${magnitude}") # negative 7
Real-World Example
import std:println
# Parse name into parts
parse_name = |full_name| {
parts = full_name::split()
match parts::length() {
0 => (nil, nil),
1 => (parts[0], nil),
_ => (parts[0], parts[1;]::join(" ")),
}
}
first, last = parse_name("Alice Smith")
println("First: ${first}, Last: ${last}")
# First: Alice, Last: Smith
first, last = parse_name("Bob")
println("First: ${first}, Last: ${last}")
# First: Bob, Last: nil
Tuple Methods
Length
import std:println
triple = (1, 2, 3)
println(triple::length()) # 3
Conversion
import std:println
# Tuple to list
triple = (1, 2, 3)
as_list = triple::to_list()
println(as_list) # [1, 2, 3]
# There is no automatic list-to-tuple conversion; construct a tuple explicitly
# when you know the length
values = [4, 5, 6]
rebuilt = (values[0], values[1], values[2])
println(rebuilt) # (4, 5, 6)
# to_string() renders the same text println shows
println(triple::to_string()) # (1, 2, 3)
Comparison
Tuples support equality, which compares element by element:
import std:println
println((1, 2) == (1, 2)) # true
println((1, 2) == (1, 3)) # false
println((1, 2) != (1, 3)) # true
There is no ordering for tuples: (1, 2) < (1, 3) fails with Cannot compare
tuple and tuple. If you need to sort pairs, destructure them and compare the
components yourself.
Common Patterns
Coordinate Pairs
import std:println
# 2D point
point = (10, 20)
x, y = point
# Distance from origin
distance = ((x ^ 2) + (y ^ 2))::sqrt()
println(distance) # 22.360679774997896964091736688
# 3D point
point3d = (10, 20, 30)
x, y, z = point3d
println("${x}, ${y}, ${z}") # 10, 20, 30
Min/Max with Index
import std:println
# `loop through list with a, b` is a runtime error - two bindings are for maps
# only - so iterate the indices when you need a counter.
find_min_with_index = |numbers| {
min_val = numbers[0]
min_idx = 0
loop through 0..numbers::length() with idx {
match { numbers[idx] < min_val => {
min_val = numbers[idx]
min_idx = idx
} }
}
(min_val, min_idx)
}
numbers = [5, 2, 8, 1, 9]
min_value, min_index = find_min_with_index(numbers)
println("Min ${min_value} at index ${min_index}")
# Min 1 at index 3
Success/Error Results
Suji has no exceptions and no Result type, so a (value, error) tuple is the
usual way to report failure from a function:
import std:println
safe_divide = |a, b| {
match b {
0 => (nil, "Division by zero"),
_ => (a / b, nil),
}
}
result, error = safe_divide(10, 2)
match error {
nil => println("Result: ${result}"),
_ => println("Error: ${error}"),
}
# Result: 5
result, error = safe_divide(10, 0)
match error {
nil => println("Result: ${result}"),
_ => println("Error: ${error}"),
}
# Error: Division by zero
Note the _ in the second arm. A bare identifier in a pattern is treated as a
string literal, so an arm written err => … would only match the literal
string "err"; it would not bind the error.
Swapping Values
import std:println
a = 10
b = 20
# Swap using tuple destructuring - the right side must be a real tuple,
# so the parentheses here are required
a, b = (b, a)
println("a: ${a}, b: ${b}") # a: 20, b: 10
Tuples vs Lists
When to Use Tuples
import std:println
# Fixed, known structure
rgb = (255, 0, 128)
# Different types
user = ("Alice", 30, true) # name, age, active
# Multiple returns
try_parse = |text| {
match {
text ~ /^[0-9]+$/ => (true, text::to_number()),
_ => (false, nil),
}
}
ok, value = try_parse("42")
println("${ok} ${value}") # true 42
println(rgb) # (255, 0, 128)
println(user) # (Alice, 30, true)
When to Use Lists
import std:println
# Variable number of items
numbers = [1, 2, 3, 4, 5] # Could be any length
# Same type collection
names = ["Alice", "Bob", "Charlie"]
# Need to add/remove items
items = [1, 2, 3]
items::push(4) # Grows dynamically
println(numbers::length()) # 5
println(names::join(", ")) # Alice, Bob, Charlie
println(items) # [1, 2, 3, 4]
Common Pitfalls
Pitfall 1: Forgetting Comma for Single Element
import std:println
# Not a tuple - just a number in parentheses
not_tuple = (42)
println(not_tuple::is_tuple()) # false
# Single-element tuple needs trailing comma
one_tuple = (42,)
println(one_tuple::is_tuple()) # true
println(one_tuple::length()) # 1
Pitfall 2: Trying to Modify or Index
import std:println
triple = (1, 2, 3)
# Neither of these works:
# triple[0] = 10 # Error: Type error: Cannot assign index on tuple
# println(triple[0]) # Error: Type error: Cannot index tuple
# Destructure, then build a new tuple
a, b, c = triple
updated = (10, b, c)
println(updated) # (10, 2, 3)
Pitfall 3: Wrong Number of Variables
import std:println
triple = (1, 2, 3)
# Too few targets:
# a, b = triple # Error: Destructuring arity mismatch: expected 2, got 3
# Match the count
a, b, c = triple
println("${a}${b}${c}") # 123
# Or use underscore for unwanted values
a, _, c = triple
println("${a}${c}") # 13
Pitfall 4: Confusion with Function Calls
import std:println
calculate = |a, b| a * b
# Parentheses for grouping
println((5 + 3) * 2) # 16
# Parentheses for a function call
println(calculate(5, 3)) # 15
# Tuple (note the comma!)
coords = (5, 3)
println(coords) # (5, 3)
Advanced Usage
Tuple Unpacking in Loops
A loop binds one variable per element. loop through points with x, y is a
runtime error — two bindings only work for maps — so destructure inside the body:
import std:println
# List of tuples
points = [(1, 2), (3, 4), (5, 6)]
loop through points with point {
x, y = point
println("Point: (${x}, ${y})")
}
# Point: (1, 2)
# Point: (3, 4)
# Point: (5, 6)
Maps are the exception, and map::to_list() gives you exactly this shape:
import std:println
scores = {alice: 3, bob: 5}
loop through scores with name, score {
println("${name} scored ${score}")
}
# alice scored 3
# bob scored 5
Nested Destructuring
Destructuring does not recurse. Unpack one level at a time:
import std:println
# Complex nested structure
data = (1, (2, 3, (4, 5)), 6)
a, middle, f = data
b, c, innermost = middle
d, e = innermost
println("${a}, ${b}, ${c}, ${d}, ${e}, ${f}")
# 1, 2, 3, 4, 5, 6
Pattern Matching with Tuples
Tuple patterns work, and _ is a wildcard inside them. What does not work is
binding: patterns cannot introduce variables, and a bare identifier in a pattern
is read as a string literal. So use _ for “anything” and fall through to a
conditional match when you need to look at the values.
import std:println
classify_point = |point| {
match point {
(0, 0) => "origin",
(0, _) => "on y-axis",
(_, 0) => "on x-axis",
_ => {
x, y = point
match { x == y => "on diagonal", _ => "elsewhere", }
}
}
}
println(classify_point((0, 0))) # origin
println(classify_point((0, 5))) # on y-axis
println(classify_point((3, 0))) # on x-axis
println(classify_point((4, 4))) # on diagonal
println(classify_point((2, 3))) # elsewhere
Examples
RGB to HSV Conversion
import std:println
rgb_to_hsv = |r, g, b| {
r = r / 255
g = g / 255
b = b / 255
max_val = match {
r >= g && r >= b => r,
g >= b => g,
_ => b,
}
min_val = match {
r <= g && r <= b => r,
g <= b => g,
_ => b,
}
delta = max_val - min_val
# Calculate hue
h = match {
delta == 0 => 0,
max_val == r => 60 * (((g - b) / delta) % 6),
max_val == g => 60 * (((b - r) / delta) + 2),
_ => 60 * (((r - g) / delta) + 4),
}
# Calculate saturation
s = match max_val {
0 => 0,
_ => delta / max_val,
}
v = max_val
return (h, s, v)
}
h, s, v = rgb_to_hsv(255, 0, 0) # Red
println("H: ${h}, S: ${s}, V: ${v}") # H: 0, S: 1, V: 1
Statistics
import std:println
calculate_stats = |numbers| {
total = numbers::sum()
count = numbers::length()
mean = total / count
sorted = numbers::sort()
mid = (count / 2)::floor()
median = match count % 2 {
0 => (sorted[mid - 1] + sorted[mid]) / 2,
_ => sorted[mid],
}
return (mean, median, total, count)
}
data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
mean, median, sum, count = calculate_stats(data)
println("Mean: ${mean}") # Mean: 5.50
println("Median: ${median}") # Median: 5.50
println("Sum: ${sum}") # Sum: 55
println("Count: ${count}") # Count: 10
Best Practices
DO:
- Use for multiple return values
- Destructure immediately when possible
- Use underscore for unused values
- Keep tuples small (2-4 elements ideal)
- Document tuple structure in function names
DON’T:
- Try to index a tuple (
t[0]) or reach forget/first/second— destructure, or callto_list()if you really need positional access - Wrap destructuring targets in parentheses (
(a, b) = tis not destructuring) - Order tuples with
</>— only==and!=are defined - Use tuples for large collections
- Create deeply nested tuples (use maps instead)
- Forget trailing comma for single-element tuples
Next Steps
- Learn about Pattern Matching with tuples
- Explore Functions returning tuples
- Study Destructuring patterns
- Check out Lists for variable-size collections
See Also
Functions
Functions are first-class values in Suji that can be passed around, stored, and returned.
Overview
Functions encapsulate reusable logic and are treated as values like numbers or strings. This page covers functions as a data type: how to create them, what a function value can do, and how they interact with the rest of the language. For deeper treatment of individual topics see Higher-Order Functions, Closures, Recursion and Multiple Return Values.
Key Characteristics
- Lambda syntax only -
name = |a, b| …. There is nofn,def,funcorfunctionkeyword; a function is just a value you bind to a variable. - First-class - Functions can be stored in lists and maps, passed as arguments, and returned from other functions
- Closures - Capture the enclosing scope by reference, and can mutate what they capture
- Default parameter values -
|a, b = 10| … - Multiple return values - Using tuples
- No variadics, no keyword arguments - Take a list or a map instead
When to Use Functions
Use functions for:
- Reusable logic
- Abstraction and composition
- Callbacks and event handlers
- Data transformations
- Building DSLs
Syntax
Function Definition
A function value is written with pipes around the parameter list, followed by
either a single expression or a { … } block.
import std:println
# Basic function
greet = |name| {
"Hello, ${name}!"
}
# Multiple parameters
add = |a, b| {
a + b
}
# No parameters
answer = || {
42
}
# Single expression (no braces needed)
double = |x| x * 2
println(greet("Alice")) # Hello, Alice!
println(add(2, 3)) # 5
println(answer()) # 42
println(double(21)) # 42
Because a function is an ordinary value, it has the usual type predicate and can be stored anywhere:
import std:println
double = |x| x * 2
println(double::is_function()) # true
# Functions in a map, used as a dispatch table
ops = {"double": double, "negate": |x| 0 - x}
println(ops:double(7)) # 14
println(ops:negate(7)) # -7
Function Calls
import std:println
greet = |name| "Hello, ${name}!"
# Call function
message = greet("Alice")
println(message) # Hello, Alice!
# Direct call
println(greet("Bob")) # Hello, Bob!
Arity is checked at call time: passing the wrong number of arguments raises Arity mismatch: Function expects N arguments, got M and stops the program.
There are two pipe-apply operators for feeding a value into a function, which often reads better than nesting calls — see Pipe Apply:
import std:println
double = |x| x * 2
println(5 |> double) # 10 (forward)
println(double <| 5) # 10 (backward)
Parameters
Required Parameters
import std:println
calculate = |x, y, operation| {
match operation {
"add" => x + y,
"subtract" => x - y,
"multiply" => x * y,
"divide" => x / y,
_ => 0,
}
}
println(calculate(10, 5, "add")) # 15
println(calculate(10, 5, "multiply")) # 50
Default Values
A parameter can declare a default, which applies when the argument is omitted:
import std:println
greet = |name, title = "Mr./Ms."| "${title} ${name}"
println(greet("Alice", "Dr.")) # Dr. Alice
println(greet("Bob")) # Mr./Ms. Bob
Defaults only fill in missing arguments. Passing nil explicitly passes nil;
if you want nil to mean “use the default”, check for it in the body.
Variable Arguments
Suji has no variadic parameters and no keyword arguments. Accept a list when the count varies, or a map when you want named options:
import std:println
# Using a list for a variable number of values
sum_all = |numbers| numbers::fold(0, |acc, x| acc + x)
println(sum_all([1, 2, 3, 4, 5])) # 15
# Using a map for named options, with get() supplying defaults
render = |text, options| {
prefix = options::get("prefix", "- ")
upper = options::get("upper", false)
body = match upper {
true => text::upper(),
_ => text,
}
"${prefix}${body}"
}
println(render("hello", {})) # - hello
println(render("hello", {"upper": true})) # - HELLO
Return Values
Explicit Return
import std:println
find_first = |list, predicate| {
loop through list with item {
predicate(item) && return item # Early return
}
nil # Default return
}
numbers = [1, 2, 3, 4, 5]
println(find_first(numbers, |x| x > 3)) # 4
Implicit Return
Last expression is returned:
import std:println
add = |a, b| {
a + b # Returned automatically
}
println(add(3, 4)) # 7
Multiple Return Values
Use tuples:
import std:println
divide_with_remainder = |a, b| {
quotient = (a / b)::floor()
remainder = a % b
return (quotient, remainder)
}
q, r = divide_with_remainder(17, 5)
println("${q} remainder ${r}") # 3 remainder 2
Two details worth remembering: destructuring targets are written without
parentheses (q, r = …), and the tuple is returned with an explicit return
because a line that starts with ( would otherwise be parsed as a call on the
previous line’s value. return a, b builds the tuple for you as well.
Closures
Functions capture variables from their surrounding scope:
import std:println
make_adder = |x| {
|y| x + y # Captures x
}
add_5 = make_adder(5)
add_10 = make_adder(10)
println(add_5(3)) # 8 (5 + 3)
println(add_10(3)) # 13 (10 + 3)
Closure Example
Captured variables are shared by reference, so a closure can keep mutable state.
Note the explicit return: a line beginning with || would otherwise be read as
a logical-or continuing the previous line.
import std:println
make_counter = || {
count = 0
return || {
count = count + 1
count
}
}
counter = make_counter()
println(counter()) # 1
println(counter()) # 2
println(counter()) # 3
Higher-Order Functions
Functions that accept or return functions:
Functions as Parameters
import std:println
apply_twice = |fn, x| {
fn(fn(x))
}
double = |x| x * 2
println(apply_twice(double, 3)) # 12 (double(double(3)))
increment = |x| x + 1
println(apply_twice(increment, 5)) # 7
Functions as Return Values
import std:println
multiplier = |factor| {
|x| x * factor
}
times_2 = multiplier(2)
times_10 = multiplier(10)
println(times_2(5)) # 10
println(times_10(5)) # 50
Common Patterns
Partial Application
There is no partial-application syntax; return a lambda from a lambda instead:
import std:println
greet_with = |greeting| {
|name| "${greeting}, ${name}!"
}
hello = greet_with("Hello")
hi = greet_with("Hi")
println(hello("Alice")) # Hello, Alice!
println(hi("Bob")) # Hi, Bob!
Function Composition
import std:println
compose = |f, g| {
|x| f(g(x))
}
add_1 = |x| x + 1
times_2 = |x| x * 2
# (x + 1) * 2
add_then_multiply = compose(times_2, add_1)
println(add_then_multiply(5)) # 12
# (x * 2) + 1
multiply_then_add = compose(add_1, times_2)
println(multiply_then_add(5)) # 11
Suji also has built-in composition operators, so you rarely need to write
compose yourself: f >> g is “f then g” and f << g is “g then f”. See
Composition Operators.
import std:println
add_1 = |x| x + 1
times_2 = |x| x * 2
println((add_1 >> times_2)(5)) # 12
println((add_1 << times_2)(5)) # 11
Currying
import std:println
# Curried function
curry_add = |a| |b| |c| a + b + c
# Partial application
add_1 = curry_add(1)
add_1_2 = add_1(2)
result = add_1_2(3)
println(result) # 6
# Or all at once
println(curry_add(1)(2)(3)) # 6
Memoization
import std:println
memoize = |fn| {
cache = {}
return |arg| {
match cache::contains(arg) {
true => cache::get(arg),
false => {
result = fn(arg)
cache[arg] = result
result
}
}
}
}
# Example: memoize a function and avoid repeating work
calls = 0
slow_square = |n| {
calls = calls + 1
n * n
}
fast_square = memoize(slow_square)
println(fast_square(5)) # 25
println(fast_square(5)) # 25
println(calls) # 1
Recursion
Functions can call themselves:
import std:println
# Factorial
factorial = |n| {
match n {
0 => 1,
_ => n * factorial(n - 1),
}
}
println(factorial(5)) # 120
# Fibonacci
fib = |n| {
match n {
0 => 0,
1 => 1,
_ => fib(n - 1) + fib(n - 2),
}
}
println(fib(10)) # 55
Recursion Depth
Suji does not optimise tail calls. Every recursive call is a real stack frame, and somewhere around 600–700 frames deep the process aborts with a stack overflow. Writing a call in tail position does not change that:
import std:println
# "Tail-recursive" in shape, but still one frame per call
factorial = |n| {
helper = |n, acc| {
match n {
0 => acc,
_ => helper(n - 1, n * acc),
}
}
helper(n, 1)
}
println(factorial(5)) # 120
For anything that could run deep, use a loop instead of recursion:
import std:println
sum_to = |n| {
total = 0
loop through 1..=n with i {
total += i
}
total
}
println(sum_to(100000)) # 5000050000
Lambda Expressions
Short anonymous functions:
import std:println
# In map
numbers = [1, 2, 3, 4, 5]
squares = numbers::map(|x| x * x)
println(squares) # [1, 4, 9, 16, 25]
# In filter
evens = numbers::filter(|x| x % 2 == 0)
println(evens) # [2, 4]
# In fold (the method is fold, not reduce)
sum = numbers::fold(0, |acc, x| acc + x)
println(sum) # 15
Common Pitfalls
Pitfall 1: Forgetting to Call Function
import std:println
greet = |name| "Hello, ${name}!"
# This is the function value, not the result
result = greet
println(result::is_function()) # true
# Call the function
result = greet("Alice")
println(result) # Hello, Alice!
Pitfall 2: Closure Variable Capture
Closures capture the enclosing scope by reference, so later changes to a captured variable are visible inside the closure:
import std:println
factor = 2
scale = |x| x * factor
println(scale(5)) # 10
# Reassigning the captured variable changes what the closure computes
factor = 10
println(scale(5)) # 50
Loop bindings, on the other hand, are fresh each iteration, so closures created in a loop each keep their own value — the “all my callbacks see the last index” bug from other languages does not happen here:
import std:println
functions = []
loop through [1, 2, 3] with i {
functions::push(|| i)
}
println(functions::first()()) # 1
println(functions::last()()) # 3
Pitfall 3: Missing Base Case
There is no way to catch a stack overflow, so a runaway recursion kills the process:
import std:println
# No base case - would abort with a stack overflow:
# countdown = |n| {
# println(n)
# countdown(n - 1)
# }
# Always have a base case
countdown = |n| {
n <= 0 && return nil
println(n)
countdown(n - 1)
}
countdown(3)
# 3
# 2
# 1
Best Practices
DO:
- Use descriptive function names (verbs)
- Keep functions small and focused
- Use lambdas for simple transformations
- Leverage closures when appropriate
- Document complex functions
DON’T:
- Create overly long functions
- Mix concerns in one function
- Forget base cases in recursion
- Capture mutable state carelessly
- Ignore function return values
Examples
Map-Filter-Fold Pipeline
import std:println
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
result = numbers
::filter(|x| x % 2 == 0) # [2, 4, 6, 8, 10]
::map(|x| x * x) # [4, 16, 36, 64, 100]
::fold(0, |acc, x| acc + x) # 220
println(result)
Function Builder
import std:println
operation = |op| {
match op {
"add" => |a, b| a + b,
"subtract" => |a, b| a - b,
"multiply" => |a, b| a * b,
"divide" => |a, b| a / b,
_ => |a, b| 0,
}
}
add = operation("add")
multiply = operation("multiply")
println(add(5, 3)) # 8
println(multiply(5, 3)) # 15
Retry Logic
import std:println
retry = |fn, max_attempts| {
attempt = 1
loop {
result = fn()
# Bind the condition first: a line starting with "(" would be parsed
# as a call on the previous line's value.
done = result != nil || attempt >= max_attempts
done && return result
attempt++
}
}
# Simulated flaky function: fails twice, then succeeds
tries = 0
flaky = || {
tries++
match {
tries < 3 => nil,
_ => "ok",
}
}
println(retry(flaky, 5)) # ok
println(tries) # 3
loop always evaluates to nil and break <value> is not supported, so an
explicit return from inside the loop is how you hand a result back.
Next Steps
- Learn about Higher-Order Functions
- Explore Closures in detail
- Study Composition Operators
- Check out Recursion patterns
See Also
- Function Basics
- Higher-Order Functions
- Closures
- Multiple Return Values
- Composition Operators
- Pipe Apply
- Pattern Matching
Regular Expressions
Regular expressions (regex) are patterns for matching text.
Overview
In this repository, regexes are used for:
- Validating formats (emails, URLs, IDs)
- Filtering text (pick only lines that match)
- Branching with
matchbased on whether a string matches a pattern
Matching is all a regex can do in Suji. There is no way to:
- Extract capture groups — there is no
::match(),::captures()or::find(), and parentheses in a pattern only group for the matcher’s own purposes - Replace with a regex —
"abc"::replace(/b/, "B")is a type error, becausereplace()takes two strings - Split with a regex —
split()also takes a plain string separator - Interpolate a pattern —
/${word}/is compiled literally and fails as an invalid pattern; build the check some other way (see Building patterns at runtime)
Syntax
Regex literals use slashes, and evaluate to a regex value you can store in a variable:
import std:println
email = /^[^@]+@[^@]+\.[^@]+$/
println(email::is_regex()) # true
println("user@example.com" ~ email) # true
The pattern is compiled by Rust’s regex engine, so you can use inline modifiers like (?i):
import std:println
case_insensitive = /(?i)hello/
println("HeLLo there" ~ case_insensitive) # true
Matching
Match operator (~)
import std:println
email = "user@example.com"
println(email ~ /^[^@]+@[^@]+\.[^@]+$/) # true
Negative match (!~)
import std:println
text = "hello world"
println(text !~ /goodbye/) # true
Using regex in match
You can use a regex literal as a match pattern:
import std:println
line = "[WARN] disk is almost full"
match line {
/^\[ERROR\]/ => println("error"),
/^\[WARN\]/ => println("warn"),
_ => println("other"),
}
Common patterns
Email validation
import std:println
is_email = |s| s ~ /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
println(is_email("user@example.com")) # true
println(is_email("nope")) # false
URL detection
import std:println
is_url = |s| s ~ /^https?:\/\/.+$/
println(is_url("https://example.com")) # true
println(is_url("example.com")) # false
Filtering lines
import std:println
lines = [
"INFO startup ok",
"WARN slow request",
"ERROR database down"
]
errors = lines::filter(|line| line ~ /^ERROR\b/)
println(errors::length()) # 1
Building patterns at runtime
A regex literal is fixed at parse time — /${prefix}/ does not interpolate. When
the thing you are looking for is only known at runtime, use the string methods
instead of a regex:
import std:println
word = "cat"
text = "the cat sat"
# Instead of /${word}/
println(text::contains(word)) # true
println(text::starts_with(word)) # false
println(text::index_of(word)) # 4
Extracting values without capture groups
Since there are no capture groups, validate with a regex and then pull the pieces out with string operations:
import std:println
parse_pair = |text| {
match {
text ~ /^[a-z_]+=[0-9]+$/ => {
parts = text::split("=")
return (parts[0], parts[1]::to_number())
}
_ => (nil, nil),
}
}
key, value = parse_pair("retries=3")
println("${key} -> ${value}") # retries -> 3
key, value = parse_pair("garbage")
println("${key} -> ${value}") # nil -> nil
See Also
Streams
Streams represent readable/writable handles (files, stdin/stdout/stderr).
All stream reads are eager and blocking: read_all() and read_lines()
return the whole contents as a string or a list of strings. There are no lazy
iterators, and a stream is not itself iterable — loop through some_stream fails
with a runtime error. Iterate stream::read_lines() instead.
Getting streams
import std:io
import std:println
stdin = io:stdin
stdout = io:stdout
stderr = io:stderr
println(stdout::is_stream()) # true
Open a file. io:open(path, create = false, truncate = false) raises a runtime
error if the file does not exist and create is false:
import std:io
import std:println
path = `mktemp`
# create = true, truncate = true
f = io:open(path, true, true)
f::close()
# Now it exists, so a plain open works
f = io:open(path)
println(f::read_all()) # (empty file)
f::close()
`rm -f ${path}`
Methods
Streams support:
stream::read(chunk_kb=8)→ String | nilstream::read_line()→ String | nilstream::read_all()→ Stringstream::read_lines()→ List[String]stream::write(text)→ Number (bytes written)stream::is_terminal()→ booleanstream::close()→ nilstream::to_string()→ String
That is the complete list. There is no seek, no flush, no each_line, and no
io:read_file / io:write_file convenience function.
Examples
Write a file
import std:io
import std:println
path = `mktemp`
out = io:open(path, true, true)
println(out::write("Report\n")) # 7 (bytes written)
out::write("======\n")
out::close()
`rm -f ${path}`
Read line by line
read_lines() reads the whole file up front and gives you a list, which you then
loop over:
import std:io
import std:println
path = `mktemp`
out = io:open(path, true, true)
out::write("first\n\nsecond\n")
out::close()
f = io:open(path)
loop through f::read_lines() with line {
match { line::trim()::length() > 0 => println(line), _ => nil, }
}
f::close()
# first
# second
`rm -f ${path}`
Note the comma after the final _ => nil arm: a match arm whose body is a bare
expression always needs a trailing comma, including the last one.
Writing to stderr
std:print and std:println take an optional stream as their second argument:
import std:io
import std:println
println("this goes to stderr", io:stderr)
println("this goes to stdout")
See Also
Nil
nil represents the absence of a value.
It is its own type with a single value. println(nil) prints nil, and it
interpolates as nil inside a string. The only methods it has are to_string()
and the is_* type predicates — and note that there is no is_nil(), so test
for nil with == nil.
Checking for nil
Use equality and conditional match / guards:
import std:println
value = nil
match { value == nil => { println("Value is nil") } }
match { value != nil => { println("Value exists") } }
# Value is nil
nil is not “falsy”: !nil is a type error (Cannot apply logical NOT to nil),
and nil || "default" is a type error too. Comparing is the only test.
Returning nil for “not found”
Because there is no exception mechanism, returning nil is the normal way to say
“nothing here”:
import std:println
# The keys are quoted: a map literal that is the whole body of a match arm would
# otherwise be parsed as a block.
get_user = |id| match id {
1 => {"name": "Alice", "age": 30},
2 => {"name": "Bob", "age": 25},
_ => nil,
}
user = get_user(1)
match { user != nil => println("Found: ${user:name}"), _ => println("User not found"), }
# Found: Alice
user = get_user(9)
match { user != nil => println("Found: ${user:name}"), _ => println("User not found"), }
# User not found
A match that has no matching arm also evaluates to nil, so an incomplete
match yields nil rather than raising.
Nil in collections
Lists
import std:println
items = [1, nil, 3, nil, 5]
non_nil = items::filter(|x| x != nil)
println(non_nil) # [1, 3, 5]
Maps
A missing key does not read as nil — it raises Key not found and
terminates the program. Use ::get() when the key may be absent:
import std:println
user = {name: "Alice", age: 30}
# This would abort the script:
# email = user:email # Error: Key not found: Key 'email' not found in map
email = user::get("email")
println(email) # nil
match { email != nil => println("Email: ${email}"), _ => println("No email provided"), }
# No email provided
get() accepts a default as its second argument, which is usually clearer than
checking for nil afterwards:
import std:println
user = {name: "Alice"}
println(user::get("email", "none")) # none
Note that a key whose stored value really is nil is indistinguishable from a
missing key when you use get() with no default — use ::contains() if that
distinction matters.
Default values
Use match for defaults. There is no || fallback idiom, because || requires
boolean operands:
import std:println
title = nil
title = match { title != nil => title, _ => "Mr./Ms.", }
println(title) # Mr./Ms.
See Also
Operators
Operators are special symbols that perform operations on values and expressions.
Overview
Suji provides a rich set of operators for arithmetic, comparison, logic, pattern matching, and data flow.
Operator Categories
Arithmetic Operators
Perform mathematical calculations:
import std:println
a = 10
b = 3
println(a + b) # 13 (addition)
println(a - b) # 7 (subtraction)
println(a * b) # 30 (multiplication)
println(a / b) # 3.3333333333333333333333333333 (division)
println((a / b)::floor()) # 3 (integer division via floor)
println(a % b) # 1 (modulo/remainder)
println(a ^ b) # 1000 (exponentiation)
println(-a) # -10 (negation)
Learn more about Arithmetic Operators →
Relational Operators
Compare values and return booleans:
import std:println
x = 5
y = 10
println(x == y) # false (equal to)
println(x != y) # true (not equal to)
println(x < y) # true (less than)
println(x <= y) # true (less than or equal)
println(x > y) # false (greater than)
println(x >= y) # false (greater than or equal)
Learn more about Relational Operators →
Logical Operators
Combine boolean expressions. Both operands must already be booleans — Suji has no
truthiness, and there are no and / or / not keywords:
import std:println
a = true
b = false
println(a && b) # false (AND)
println(a || b) # true (OR)
println(!a) # false (NOT)
Learn more about Logical Operators →
Assignment Operators
Assign and update variables:
import std:println
x = 10
# Basic assignment
x = 20
# Compound assignment
x = x + 5 # or: x += 5
x = x - 3 # or: x -= 3
x = x * 2 # or: x *= 2
x = x / 4 # or: x /= 4
x = x % 3 # or: x %= 3
println(x)
Learn more about Assignment Operators →
Matching Operators
Test patterns with regular expressions:
import std:println
text = "user@example.com"
# Match operator
is_email = text ~ /^[^@]+@[^@]+$/
println(is_email) # true
# Negative match
not_number = text !~ /^\d+$/
println(not_number) # true
Learn more about Matching Operators →
Pipe and Composition Operators
Suji has three distinct pipe-ish operators plus two composition operators. They are easy to confuse, so keep them straight:
| Operator | Name | Meaning |
|---|---|---|
| | pipeline | stdout of one stage becomes stdin of the next |
|> | forward apply | value |> f is f(value) |
<| | backward apply | f <| value is f(value) |
>> | compose | f >> g is “f then g” |
<< | compose (reverse) | f << g is “g then f” |
There is no |< operator.
import std:println
double = |x| x * 2
inc = |x| x + 1
# Pipe-apply passes a value into a function
println(5 |> double) # 10
println(double <| 5) # 10
println(5 |> double |> inc) # 11
# Composition builds a new function instead of calling one
then_inc = double >> inc
println(then_inc(5)) # 11
inc_first = double << inc
println(inc_first(5)) # 12
Learn more about the Pipeline Operator → · Pipe Apply → · Function Composition →
Operator Precedence
Operators are evaluated in a specific order. The full table, lowest to highest precedence:
- Assignment (
=, including destructuringa, b = …) - Compound assignment (
+=,-=,*=,/=,%=) - Backward pipe-apply (
<|, right-associative) - Forward pipe-apply (
|>) - Pipeline (
|) - Function composition (
>>,<<) - Logical OR (
||) - Logical AND (
&&) - Regex match (
~,!~) - Equality (
==,!=) - Ordering (
<,<=,>,>=) - Range (
..,..=) - Addition, Subtraction (
+,-) - Multiplication, Division, Modulo (
*,/,%) - Unary (
-,!) - Exponentiation (
^, right-associative) - Postfix (
(),[],::,:,++,--)
Note that || binds less tightly than &&, and both bind less tightly than the
regex operators — which in turn bind less tightly than ==. There is no . member
access operator: :: calls a method and : reads a map key.
Precedence Examples
import std:println
# Without parentheses
result = 2 + 3 * 4
println(result) # 14 (multiplication first)
# With parentheses
result = (2 + 3) * 4
println(result) # 20 (addition first)
# Exponentiation before multiplication
result = 2 * 3 ^ 2
println(result) # 18 (3^2 = 9, then 2*9)
# Comparison before logical
result = 5 > 3 && 10 < 20
println(result) # true (comparisons first, then &&)
Associativity
When operators have the same precedence, associativity determines evaluation order:
Left-Associative
Most operators evaluate left to right:
import std:println
# Left to right: (10 - 3) - 2
result = 10 - 3 - 2
println(result) # 5
# Left to right: (12 / 3) / 2
result = 12 / 3 / 2
println(result) # 2
Right-Associative
Exponentiation and assignment evaluate right to left:
import std:println
# Right to left: 2 ^ (3 ^ 2)
result = 2 ^ 3 ^ 2
println(result) # 512 (2^9)
# Assignment chains (right to left)
a = b = c = 10
println(a) # 10
Overloading
Some operators work differently based on operand types:
Addition (+)
+ works on two numbers, two strings, or two lists. It never mixes types:
import std:println
# Numbers: arithmetic addition
println(5 + 3) # 8
# Strings: concatenation
println("Hello" + " " + "World") # Hello World
# Lists: concatenation
println([1, 2] + [3, 4]) # [1, 2, 3, 4]
# Mixing types is a type error:
# "a" + 1 -> Type error: Cannot add string and number
# Convert or interpolate instead:
println("a" + 1::to_string()) # a1
println("a${1}") # a1
Multiplication (*)
* is numbers-only. Unlike some scripting languages, Suji has no string or list
repetition operator:
import std:println
# Numbers: arithmetic multiplication
println(5 * 3) # 15
# "Ha" * 3 -> Type error: Cannot multiply string and number
# [1, 2] * 3 -> Type error: Cannot multiply list and number
# Use the repeat() method for strings:
println("Ha"::repeat(3)) # HaHaHa
Short-Circuit Evaluation
Logical operators use short-circuit evaluation:
AND (&&)
If left side is false, right side is not evaluated:
import std:println
# Right side not evaluated, so no division-by-zero error
result = false && (10 / 0)
println(result) # false
OR (||)
If left side is true, right side is not evaluated:
import std:println
# Right side not evaluated
result = true || (10 / 0)
println(result) # true
This is useful for safe operations:
import std:println
# Safe property access
user = nil
name_present = user != nil && user:name != nil
println(name_present) # false (no error accessing nil)
Because && and || require boolean operands, there is no x || "default"
idiom in Suji — nil || "default" is a type error. Use match (or
map::get(key, default)) instead:
import std:println
user_setting = nil
config_value = match user_setting {
nil => "default",
_ => user_setting,
}
println(config_value) # default
# For maps, get() already takes a default
settings = {theme: "dark"}
println(settings::get("lang", "en")) # en
Best Practices
DO:
- Use parentheses for clarity in complex expressions
- Understand precedence to avoid bugs
- Leverage short-circuit evaluation for safety
- Use pipes for readable data transformations
- Keep expressions simple and readable
DON’T:
- Write deeply nested expressions without parentheses
- Rely on obscure precedence rules
- Ignore short-circuit evaluation opportunities
- Chain too many operations in one expression
- Sacrifice readability for brevity
Quick Reference
| Category | Operators | Example |
|---|---|---|
| Arithmetic | +, -, *, /, %, ^, - (unary) | a + b, a ^ 2 |
| Relational | ==, !=, <, <=, >, >= | x < y |
| Logical | &&, ||, ! | a && b |
| Assignment | =, +=, -=, *=, /=, %= | x += 5 |
| Matching | ~, !~ | text ~ /pattern/ |
| Range | .., ..= | 0..5 |
| Pipeline | | | `cat f` | stage() |
| Pipe apply | |>, <| | data |> transform |
| Composition | >>, << | f >> g |
| Postfix | (), [], ::, :, ++, -- | xs::length(), m:key |
Next Steps
Explore each operator category in detail:
- Arithmetic Operators - Mathematical operations
- Relational Operators - Equality and ordering
- Logical Operators - Boolean logic
- Assignment Operators - Variable assignment
- Matching Operators - Regex matching
- Pipeline Operator - stdin/stdout pipelines
- Pipe Apply - Applying a value to a function
- Function Composition - Building functions from functions
See Also
Assignment Operators
Assignment operators set or update variable values.
Overview
Assignment is how you store values in variables and update them over time.
Basic Assignment (=)
The simplest and most common operator:
import std:println
# Assign a value to a variable
x = 42
name = "Alice"
active = true
println(x) # 42
println(name) # Alice
Compound Assignment
Combine an operation with assignment for concise updates:
Addition Assignment (+=)
import std:println
x = 10
x += 5 # same as: x = x + 5
println(x) # 15
Subtraction Assignment (-=)
import std:println
x = 20
x -= 3 # same as: x = x - 3
println(x) # 17
Multiplication Assignment (*=)
import std:println
x = 5
x *= 4 # same as: x = x * 4
println(x) # 20
Division Assignment (/=)
import std:println
x = 100
x /= 4 # same as: x = x / 4
println(x) # 25
Modulo Assignment (%=)
import std:println
x = 17
x %= 5 # same as: x = x % 5
println(x) # 2
Increment and Decrement (++, --)
++ and -- are postfix operators that mutate the variable in place. There are
no prefix forms — ++x fails with Increment (++) can only be applied to variables.
Note that unlike C, x++ evaluates to the value after the increment:
import std:println
count = 0
count++
count++
println(count) # 2
count--
println(count) # 1
x = 1
y = x++
println(y) # 2 (the incremented value, not 1)
Assignment Semantics
Rebinding
Assignment in Suji rebinds the variable to a new value:
import std:println
x = 10
println(x) # 10
x = 20 # Rebind x to new value
println(x) # 20
x = "hello" # Can change type
println(x) # hello
Immutability
Strings and tuples are immutable. Lists and maps are mutable (some methods modify the value in place):
import std:println
list = [1, 2, 3]
list::push(4)
println(list) # [1, 2, 3, 4]
Destructuring Assignment
Tuple Destructuring
Unpack tuple values into variables. The target list is a bare comma-separated list
of names — do not wrap it in parentheses. (x, y) = (10, 20) fails with
Invalid assignment target:
import std:println
# Basic destructuring
x, y = (10, 20)
println("x: ${x}, y: ${y}") # x: 10, y: 20
# Any number of targets, as long as the arity matches
a, b, c, d = (1, 2, 3, 4)
println("${a}, ${b}, ${c}, ${d}") # 1, 2, 3, 4
# Ignore values with underscore
first, _, third = (1, 2, 3)
println("${first}, ${third}") # 1, 3
Nested destructuring is not supported. a, (b, c), d = … is a parse error;
unpack in two steps instead:
import std:println
outer, inner = (1, (2, 3))
p, q = inner
println("${outer}, ${p}, ${q}") # 1, 2, 3
Multiple Return Values
return a, b returns a tuple, which the caller can destructure:
import std:println
divide_with_remainder = |a, b| {
return (a / b)::floor(), a % b
}
quotient, remainder = divide_with_remainder(17, 5)
println("${quotient} remainder ${remainder}") # 3 remainder 2
Assignment vs Equality
Don’t confuse assignment (=) with equality (==):
import std:println
x = 5 # Assignment: set x to 5
# Equality check
match x == 5 {
true => println("x is 5"),
false => {},
}
# Assignment in condition would be an error
# match x = 5 { # Error: Assignment not allowed in condition
# ...
# }
Chained Assignment
Assign the same value to multiple variables:
import std:println
# Right-to-left evaluation
a = b = c = 10
println(a) # 10
println(b) # 10
println(c) # 10
Common Patterns
Accumulator
import std:println
numbers = [1, 2, 3, 4, 5]
sum = 0
loop through numbers with num {
sum += num
}
println(sum) # 15
Counter
import std:println
items = [3, -1, 7, 0, 12]
count = 0
loop through items with item {
match { item > 0 => { count++ } }
}
println("${count} valid items") # 3 valid items
Swap Values
import std:println
a = 10
b = 20
# Swap using tuple destructuring
a, b = (b, a)
println("a: ${a}, b: ${b}") # a: 20, b: 10
Update Configuration
import std:println
config = {
theme: "light",
lang: "en",
notifications: true
}
# Update single value
config["theme"] = "dark"
# Update multiple values
config["lang"] = "es"
config["notifications"] = false
println(config) # {theme: dark, lang: es, notifications: false}
Common Pitfalls
Pitfall 1: Forgetting Immutability
list = [1, 2, 3]
# push() modifies the list, but returns nil
result = list::push(4)
# list is now [1, 2, 3, 4], and result is nil
Pitfall 2: Assignment in Conditions
x = 5
# Typo: assignment instead of comparison
# match x = 10 { # Error
# ...
# }
# Correct: equality check
match x == 10 {
true => {
# ...
},
false => {},
}
Pitfall 3: Shadowing vs Mutation
import std:println
x = 10
# This creates a new binding in inner scope
{
x = 20 # Rebinds x
println(x) # 20
}
# x is now 20 (not shadowing in Suji, actual rebinding)
println(x) # 20
Pitfall 4: Compound Assignment Type Changes
import std:println
x = "10"
# This would be an error, so it is commented out:
# x += 5 -> Type error: Cannot add string and number
# Convert first
x = x::to_number()
x += 5
println(x) # 15
Best Practices
DO:
- Use compound assignment for concise updates
- Use destructuring for multiple return values
- Choose descriptive variable names
- Initialize variables before use
- Keep assignments simple and clear
DON’T:
- Confuse
=with== - Forget that operations return new values
- Use overly complex chained assignments
- Mutate external state unexpectedly
- Reuse variable names for different purposes
Examples
Running Total
import std:println
prices = [10.50, 25.00, 15.75, 30.00]
total = 0.0
loop through prices with price {
total = total + price
println("Subtotal: $${total}")
}
println("Final total: $${total}")
State Machine
import std:println
state = "idle"
process_event = |event| {
state = match (state, event) {
("idle", "start") => "running",
("running", "pause") => "paused",
("paused", "resume") => "running",
("running", "stop") => "idle",
_ => state,
}
}
process_event("start")
println(state) # running
Note the wildcard arm: a bare identifier in a pattern position is treated as a
string literal, not a binding, so (s, _) => s would try to match the literal
tuple ("s", "_"). Use _ and refer to the outer variable instead.
Fibonacci Sequence
import std:println
a = 0
b = 1
loop through (0..10) {
println(a)
a, b = (b, a + b) # Tuple swap and update
}
Build Configuration
import std:println
config = {}
# Build up configuration
config["env"] = "production"
config["debug"] = false
config["port"] = 8080
config["host"] = "0.0.0.0"
println(config) # {env: production, debug: false, port: 8080, host: 0.0.0.0}
Next Steps
- Learn about Arithmetic Operators
- Explore Language Overview
- Study Destructuring
See Also
Arithmetic Operators
Arithmetic operators perform mathematical calculations on numeric values.
Overview
Suji provides all standard arithmetic operators for working with numbers.
Basic Operations
Addition (+)
Add two numbers:
import std:println
println(5 + 3) # 8
println(10.5 + 2.3) # 12.8
println(-5 + 10) # 5
+ also concatenates two strings or two lists, but it never mixes types:
"a" + 1 is a type error. Use ::to_string() or string interpolation instead.
import std:println
# String concatenation
println("Hello" + " " + "World") # Hello World
# List concatenation
println([1, 2] + [3, 4]) # [1, 2, 3, 4]
# Mixing types is a type error, so convert or interpolate:
println("count: " + 3::to_string()) # count: 3
println("count: ${3}") # count: 3
Subtraction (-)
Subtract one number from another:
import std:println
println(10 - 3) # 7
println(20.5 - 5.5) # 15
println(5 - 10) # -5
Multiplication (*)
Multiply two numbers:
import std:println
println(5 * 3) # 15
println(2.5 * 4) # 10
println(-3 * 4) # -12
* is numbers only. There is no repetition operator for strings or lists —
"Ha" * 3 and [1, 2] * 3 are both type errors. Use ::repeat(n) for strings:
import std:println
println("Ha"::repeat(3)) # HaHaHa
# "Ha" * 3 -> Type error: Cannot multiply string and number
# [1, 2] * 3 -> Type error: Cannot multiply list and number
Lists have no repeat method; build one with a loop if you need it:
import std:println
repeated = []
loop through 0..3 {
repeated = repeated + [1, 2]
}
println(repeated) # [1, 2, 1, 2, 1, 2]
Division (/)
Divide one number by another. Suji has a single decimal number type, so division is always decimal division. Non-terminating results are rounded to 28 significant digits:
import std:println
println(10 / 2) # 5
println(10 / 3) # 3.3333333333333333333333333333
println(15.0 / 4.0) # 3.75
Dividing by zero is a runtime error that terminates the program — there is no
Infinity and no way to catch it, so check the divisor first.
Integer Division
Suji uses decimal division. To get integer division, use the floor() method:
import std:println
println((10 / 3)::floor()) # 3
println((17 / 5)::floor()) # 3
println((-10 / 3)::floor()) # -4 (rounds down)
There is no // integer-division operator; floor() is the idiom.
Modulo (%)
Get remainder after division:
import std:println
println(10 % 3) # 1
println(17 % 5) # 2
println(20 % 4) # 0 (evenly divisible)
# Useful for checking even/odd
println(7 % 2 == 0) # false (odd)
println(8 % 2 == 0) # true (even)
Exponentiation (^)
Raise a number to a power. The exponent must be a non-negative integer:
import std:println
println(2 ^ 3) # 8 (2³)
println(10 ^ 2) # 100 (10²)
println(2 ^ 10) # 1024
Fractional and negative exponents are rejected:
4 ^ 0.5→Invalid operation: Power exponent must be an integer2 ^ (0 - 1)→Invalid operation: Negative exponents not supported
Use ::sqrt() for square roots and division for reciprocals:
import std:println
println(16::sqrt()) # 4
println(1 / 2 ^ 1) # 0.50
Also note that ^ binds tighter than unary minus, so -2 ^ 2 is -(2 ^ 2):
import std:println
println(-2 ^ 2) # -4
println((-2) ^ 2) # 4
Negation (-)
Negate a number (unary operator):
import std:println
x = 5
println(-x) # -5
println(-(-x)) # 5 (double negation)
y = -10
println(-y) # 10
Compound Assignment
Arithmetic operations combined with assignment:
import std:println
x = 10
x += 5 # same as: x = x + 5
println(x) # 15
x -= 3 # same as: x = x - 3
println(x) # 12
x *= 2 # same as: x = x * 2
println(x) # 24
x /= 4 # same as: x = x / 4
println(x) # 6
x %= 5 # same as: x = x % 5
println(x) # 1
Operator Precedence
The arithmetic slice of the precedence table, lowest to highest:
- Addition, Subtraction (
+,-) - Multiplication, Division, Modulo (
*,/,%) - Unary negation and not (
-,!) - Exponentiation (
^, right-associative)
Parentheses override all of it. Note that ^ binds tighter than unary -, which
differs from the usual PEMDAS reading — see the -2 ^ 2 example above. The full
table for all operators is in the operators overview.
Examples
import std:println
# Multiplication before addition
println(2 + 3 * 4) # 14 (not 20)
# Exponentiation before multiplication
println(2 * 3 ^ 2) # 18 (2 * 9, not 6²)
# Use parentheses to override
println((2 + 3) * 4) # 20
println((2 * 3) ^ 2) # 36
# Complex expression
println(10 + 2 * 3 ^ 2 - 4 / 2) # 26
# Breakdown: 10 + 2*9 - 2 = 10 + 18 - 2 = 26
Associativity
Most arithmetic operators are left-associative:
import std:println
# Left to right: (10 - 3) - 2
println(10 - 3 - 2) # 5
# Left to right: (20 / 4) / 2
println(20 / 4 / 2) # 2.50
(Scale is preserved through division, which is why this prints 2.50 rather than
2.5.)
Exponentiation is right-associative:
import std:println
# Right to left: 2 ^ (3 ^ 2)
println(2 ^ 3 ^ 2) # 512 (2^9, not 8^2)
Common Patterns
Increment/Decrement
import std:println
count = 0
count++ # Increment (also: count += 1)
println(count) # 1
count-- # Decrement (also: count -= 1)
println(count) # 0
Averaging
import std:println
numbers = [10, 20, 30, 40, 50]
sum = numbers::fold(0, |acc, x| acc + x)
average = sum / numbers::length()
println(average) # 30
Scaling
import std:println
# Scale value to percentage
value = 75
max_value = 200
percentage = (value / max_value) * 100
println("${percentage}%") # 37.500%
Rounding
Rounding lives on the number type, not in std:math — there is no math:round,
math:floor, math:ceil, math:abs or math:sqrt:
import std:println
value = 3.14159
# Round to 2 decimal places
rounded = (value * 100)::round() / 100
println(rounded) # 3.14
Wrapping (Circular)
import std:println
# Wrap index in circular buffer
index = 15
buffer_size = 10
wrapped = index % buffer_size
println(wrapped) # 5
Common Pitfalls
Pitfall 1: Division by Zero
Division by zero terminates the program, and there is no try/catch, so the only
option is to check first:
import std:println
# result = 10 / 0 -> Runtime error: Division by zero (process exits)
# Check before dividing
safe_divide = |a, b| {
match b {
0 => nil,
_ => a / b,
}
}
println(safe_divide(10, 0)) # nil
println(safe_divide(10, 4)) # 2.50
Pitfall 2: Integer Division Confusion
import std:println
# Regular division always returns decimal
println(10 / 3) # 3.3333333333333333333333333333
# Integer division using floor method
println((10 / 3)::floor()) # 3
# Be explicit about intent
total = 10
count = 4
println(total / count) # 2.50 - when you want decimal
println((total / count)::floor()) # 2 - when you want an integer
Suji has no // operator. Writing a // b starts a regex literal and produces a
lexer error, not integer division.
Pitfall 3: Modulo with Negatives
import std:println
# Result has sign of dividend (left operand)
println(10 % 3) # 1
println(-10 % 3) # -1 (not 2)
println(10 % -3) # 1
Pitfall 4: Assuming Binary Floating Point
Suji’s single number type is a fixed-precision decimal, not a binary float, so
the classic 0.1 + 0.2 surprise does not happen here:
import std:println
println(0.1 + 0.2) # 0.3
println(0.1 + 0.2 == 0.3) # true
What does bite is the precision limit: results are rounded to about 28 significant
digits, and exceeding the maximum value (79228162514264337593543950335) aborts the
process rather than producing an approximation. Decimals are not big integers.
Pitfall 5: Operator Precedence Confusion
import std:println
# Unclear intent
result = 2 + 3 * 4 ^ 2 - 5
# Use parentheses for clarity
result = 2 + (3 * (4 ^ 2)) - 5
println(result) # 45
Best Practices
DO:
- Use parentheses for clarity in complex expressions
- Check for division by zero before dividing — there is no way to recover after
- Be aware of the 28-significant-digit precision limit
- Use
(a / b)::floor()when you need integer division - Consider modulo sign behavior with negatives
DON’T:
- Rely on obscure precedence rules (
^binds tighter than unary-) - Ignore division by zero possibilities
- Reach for
//,**,"x" * 3ormath:sqrt— none of them exist - Assume modulo always returns positive
Examples
Distance Between Points
std:math has no sqrt — square roots are a number method:
import std:println
distance = |x1, y1, x2, y2| {
dx = x2 - x1
dy = y2 - y1
sum_of_squares = (dx ^ 2) + (dy ^ 2)
sum_of_squares::sqrt()
}
println(distance(0, 0, 3, 4)) # 5
Temperature Conversion
import std:println
celsius_to_fahrenheit = |c| {
(c * 9 / 5) + 32
}
fahrenheit_to_celsius = |f| {
(f - 32) * 5 / 9
}
println(celsius_to_fahrenheit(0)) # 32
println(celsius_to_fahrenheit(100)) # 212
println(fahrenheit_to_celsius(32)) # 0
Compound Interest
import std:println
compound_interest = |principal, rate, time| {
principal * ((1 + rate) ^ time)
}
# $1000 at 5% for 10 years
final = compound_interest(1000, 0.05, 10)
println("$${final::round()}") # $1629
Digit Sum
import std:println
digit_sum = |n| {
sum = 0
num = n::abs()
loop {
num == 0 && break
sum = sum + (num % 10)
num = (num / 10)::floor()
}
sum
}
println(digit_sum(12345)) # 15 (1+2+3+4+5)
Next Steps
- Learn about Relational Operators
- Explore Math Module for trigonometry, logs and
PI/E - Study Operator Precedence in detail
- Check out Number Data Type
See Also
Relational Operators
Relational operators compare values and return boolean results.
Overview
Relational (comparison) operators test relationships between values. They’re essential for conditional logic, sorting, and filtering operations.
This page covers both equality (==, !=) and ordering (<, <=, >, >=). The two behave differently across types: equality between mismatched types simply returns false, while ordering between mismatched types is a runtime type error.
Operators
Equal To (==)
Tests if two values are equal.
import std:println
println(5 == 5) # true
println(5 == 3) # false
println("a" == "a") # true
println("a" == "A") # false
println(true == true) # true
Works with: every type. Lists, maps and tuples compare by value.
Type-strict: 5 == "5" is false (number ≠ string) — comparing different types
is never an error, it is just false.
Not Equal To (!=)
Tests if two values are not equal.
import std:println
println(5 != 3) # true
println(5 != 5) # false
println("a" != "b") # true
println("a" != "a") # false
Equivalent to: !(a == b)
Less Than (<)
Tests if left value is less than right value.
import std:println
println(3 < 5) # true
println(5 < 3) # false
println(5 < 5) # false
println("a" < "b") # true (lexicographic)
Works with: Numbers, strings (lexicographic comparison). Anything else — including two booleans, two lists, or a number and a string — is a runtime type error.
Less Than or Equal (<=)
Tests if left value is less than or equal to right value.
import std:println
println(3 <= 5) # true
println(5 <= 5) # true
println(7 <= 5) # false
println("a" <= "a") # true
Greater Than (>)
Tests if left value is greater than right value.
import std:println
println(5 > 3) # true
println(3 > 5) # false
println(5 > 5) # false
println("b" > "a") # true (lexicographic)
Works with: Numbers, strings (lexicographic comparison)
Greater Than or Equal (>=)
Tests if left value is greater than or equal to right value.
import std:println
println(5 >= 3) # true
println(5 >= 5) # true
println(3 >= 5) # false
println("b" >= "b") # true
Type Compatibility
Numbers
All relational operators work with numbers:
import std:println
println(3.14 < 3.15) # true
println(100 >= 50) # true
println(42 == 42.0) # true (there is only one number type)
println(0 != -0) # false (both zero)
Strings
Strings use lexicographic (dictionary) ordering:
import std:println
println("a" < "b") # true
println("apple" < "banana") # true
println("A" < "a") # true (uppercase before lowercase)
println("10" < "2") # true (string comparison, not numeric)
Note: String comparison is case-sensitive and uses Unicode code points.
Booleans
Booleans can be compared for equality:
import std:println
println(true == true) # true
println(true == false) # false
println(true != false) # true
Note: Order comparisons are not supported for booleans — true < false fails with
Type error: Cannot compare boolean and boolean.
Nil
Nil can be compared for equality:
import std:println
println(nil == nil) # true
println(nil != nil) # false
println(5 == nil) # false
println("text" != nil) # true
Collections
Lists, maps and tuples support equality only. They are compared element by element (maps by key/value pairs):
import std:println
println([1, 2] == [1, 2]) # true
println([1, 2] == [2, 1]) # false
println([1, 2] == [1, 2, 3]) # false
println((1, 2) == (1, 2)) # true
println({a: 1} == {a: 1}) # true
There is no ordering for collections: [1, 2] < [1, 3] fails with
Type error: Cannot compare list and list. To sort a list of lists you would have to
compare a derived scalar (e.g. an element or a length) yourself.
Common Patterns
Range Checking
import std:println
age = 25
match { age >= 18 && age < 65 => { println("Working age") } }
# Numeric ranges
score = 85
match { score >= 80 && score < 90 => { println("Grade: B") } }
Boundary Validation
import std:println
validate_percentage = |value| {
match { value < 0 || value > 100 => {
println("Error: Must be between 0 and 100")
return false
} }
true
}
println(validate_percentage(50)) # true
println(validate_percentage(150)) # prints the error, then false
Sorting
import std:println
numbers = [5, 2, 8, 1, 9]
sorted = numbers::sort()
println(sorted) # [1, 2, 5, 8, 9]
Finding Min/Max
import std:println
find_min = |list| {
list::length() == 0 && return nil
min = list[0]
loop through list with item {
match { item < min => { min = item } }
}
min
}
numbers = [5, 2, 8, 1, 9]
println(find_min(numbers)) # 1
Filtering
import std:println
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Filter by comparison
evens = numbers::filter(|x| x % 2 == 0)
large = numbers::filter(|x| x > 5)
range = numbers::filter(|x| x >= 3 && x <= 7)
println(evens) # [2, 4, 6, 8, 10]
println(large) # [6, 7, 8, 9, 10]
println(range) # [3, 4, 5, 6, 7]
Conditional Logic
import std:println
classify = |temp| {
match {
temp < 0 => "Freezing",
temp < 15 => "Cold",
temp < 25 => "Comfortable",
_ => "Hot",
}
}
println(classify(-5)) # Freezing
println(classify(20)) # Comfortable
println(classify(30)) # Hot
String Comparison Details
Lexicographic Order
Strings are compared character by character using Unicode values:
import std:println
# Character-by-character comparison
println("abc" < "abd") # true ('c' < 'd')
println("abc" < "abcd") # true (shorter is less if prefix matches)
# Case sensitivity
println("ABC" < "abc") # true (uppercase < lowercase in Unicode)
println("apple" < "Apple") # false
# Numbers as strings
println("10" < "2") # true ("1" < "2" as strings)
println("10" < "9") # true ("1" < "9" as strings)
Case-Insensitive Comparison
For case-insensitive comparison, normalize first:
import std:println
compare_ignore_case = |a, b| {
a::lower() == b::lower()
}
println(compare_ignore_case("Hello", "hello")) # true
println(compare_ignore_case("Apple", "APPLE")) # true
Chaining Comparisons
You can chain comparisons with logical operators:
import std:println
x = 5
# Multiple conditions
match { x > 0 && x < 10 => { println("x is between 0 and 10") } }
# Range check
in_range = x >= 1 && x <= 100
println(in_range) # true
Best Practices
DO:
- Use
==for equality, not=(assignment) - Compare same types (number with number, string with string)
- Use parentheses for clarity in complex conditions
- Normalize strings before case-insensitive comparison
- Check for nil before comparing
DON’T:
- Compare different types (5 == “5” is always false)
- Use
=in conditions (syntax error) - Forget that string comparison is case-sensitive
- Chain operators without logical connectors (
a < b < cdoesn’t work) - Assume order comparison works for all types
Examples
Temperature Converter with Validation
import std:println
celsius_to_fahrenheit = |c| {
below_absolute_zero = c < -273.15
match below_absolute_zero {
true => {
println("Error: Below absolute zero")
return nil
}
_ => nil,
}
result = (c * 9 / 5) + 32
result
}
f = celsius_to_fahrenheit(100)
match f {
nil => {},
_ => println("${f}°F"),
}
Note the local variable before (c * 9 / 5): a line that starts with ( continues
the previous expression, so it would otherwise be parsed as a call on the match
result.
Grade Calculator
import std:println
get_grade = |score| {
(score < 0 || score > 100) && return "Invalid score"
match {
score >= 90 => "A",
score >= 80 => "B",
score >= 70 => "C",
score >= 60 => "D",
_ => "F",
}
}
println(get_grade(85)) # B
println(get_grade(92)) # A
println(get_grade(105)) # Invalid score
List Deduplication
import std:println
deduplicate = |list| {
unique = []
loop through list with item {
found = false
loop through unique with existing {
match { existing == item => {
found = true
break
} }
}
match { !found => { unique::push(item) } }
}
unique
}
numbers = [1, 2, 2, 3, 3, 3, 4, 5, 5]
result = deduplicate(numbers)
println(result) # [1, 2, 3, 4, 5]
Binary Search
import std:println
binary_search = |sorted_list, target| {
left = 0
right = sorted_list::length() - 1
loop {
left > right && return nil # Not found
mid = ((left + right) / 2)::floor()
mid_val = sorted_list[mid]
match {
mid_val == target => return mid,
mid_val < target => left = mid + 1,
_ => right = mid - 1,
}
}
}
numbers = [1, 3, 5, 7, 9, 11, 13]
index = binary_search(numbers, 7)
println(index) # 3
Operator Summary
| Operator | Name | Example | Result |
|---|---|---|---|
== | Equal | 5 == 5 | true |
!= | Not equal | 5 != 3 | true |
< | Less than | 3 < 5 | true |
<= | Less or equal | 5 <= 5 | true |
> | Greater than | 5 > 3 | true |
>= | Greater or equal | 5 >= 5 | true |
See Also
- Logical Operators - AND, OR, NOT
- Arithmetic Operators - Math operations
- Matching Operators - Regex matching
- Control Flow - conditionals, loops, match
Logical Operators
Logical operators combine boolean expressions and short-circuit.
Suji does not have general truthiness: only true counts as true in boolean
contexts. The operators are &&, || and ! — the words and, or and not are
not keywords in Suji and using them produces an Undefined variable error.
Crucially, && and || always evaluate to a boolean, never to one of their
operands. That means the x || "default" idiom from other languages does not exist
here: nil || "default" fails with
Type error: Logical OR requires boolean operands. See
Default Values below for what to write instead.
Overview
Logical operators are essential for building complex conditions in match expressions, loops, and filters.
The Three Logical Operators
AND (&&)
Returns true only if both operands are true:
import std:println
println(true && true) # true
println(true && false) # false
println(false && true) # false
println(false && false) # false
OR (||)
Returns true if at least one operand is true:
import std:println
println(true || true) # true
println(true || false) # true
println(false || true) # true
println(false || false) # false
NOT (!)
Negates a boolean value:
import std:println
println(!true) # false
println(!false) # true
println(!!true) # true (double negation)
Short-Circuit Evaluation
Logical operators use short-circuit evaluation for efficiency and safety.
AND Short-Circuit
If the left side is false, the right side is NOT evaluated:
import std:println
# Right side never evaluated (no error!)
result = false && (10 / 0)
println(result) # false
# This is useful for safe operations
user = nil
is_admin = user != nil && user:is_admin == true
println(is_admin) # false - no error accessing nil, because user != nil is false
OR Short-Circuit
If the left side is true, the right side is NOT evaluated:
import std:println
# Right side never evaluated
result = true || (10 / 0)
println(result) # true
# `||` short-circuits on boolean `true`, and its result is always a boolean.
#
# If you want a default when a value may be `nil`, use `match`:
maybe_timeout_ms = nil
timeout_ms = match maybe_timeout_ms {
nil => 1000,
_ => maybe_timeout_ms,
}
println(timeout_ms) # 1000
Note the _ arm rather than a binding: patterns cannot bind variables in Suji, and a
bare identifier in a pattern is treated as a string literal.
Combining Conditions
Multiple AND
All conditions must be true:
import std:println
age = 25
has_license = true
has_insurance = true
can_drive = age >= 18 && has_license && has_insurance
println(can_drive) # true
Multiple OR
At least one condition must be true:
import std:println
day = "Sunday"
is_national_holiday = false
is_regional_holiday = false
is_weekend = day == "Saturday" || day == "Sunday"
is_holiday = is_national_holiday || is_regional_holiday
day_off = is_weekend || is_holiday
println(day_off) # true
Mixed AND/OR
import std:println
# Must be adult AND (have license OR have permit)
age = 17
has_license = false
has_permit = true
can_drive = age >= 18 && (has_license || has_permit)
println(can_drive) # false (not adult)
age = 18
can_drive = age >= 18 && (has_license || has_permit)
println(can_drive) # true (adult with permit)
Operator Precedence
Lowest to highest:
- OR (
||) - lowest - AND (
&&) - NOT (
!) - highest (it sits with the other unary operators, above*//and below^)
All three bind less tightly than the comparison and regex operators, so
a == b && c > d groups the way you would expect without parentheses.
import std:println
# NOT first, then AND, then OR
result = true || false && !false
# Parsed as: true || (false && (!false))
println(result) # true
# Use parentheses for clarity
result = (true || false) && (!false)
println(result) # true
Common Patterns
Validation
Reading a missing map key raises Key not found, so user:name != nil is only
safe when the key is known to exist. Use ::get(key, default) to read defensively:
import std:println
validate_user = |user| {
name = user::get("name", "")
email = user::get("email", "")
age = user::get("age", 0)
has_name = name::length() > 0
has_email = email ~ /^[^@]+@[^@]+$/
is_adult = age >= 18
has_name && has_email && is_adult
}
println(validate_user({name: "Alice", email: "alice@example.com", age: 30})) # true
println(validate_user({name: "Bob"})) # false
Range Checking
import std:println
in_range = |value, min_val, max_val| {
value >= min_val && value <= max_val
}
println(in_range(5, 0, 10)) # true
println(in_range(15, 0, 10)) # false
Default Values
There is no || fallback and no ?? operator. Use map::get(key, default) for maps,
and match for anything else:
import std:println
get_name = |user| {
preferred = user::get("preferred_name", nil)
first = user::get("first_name", nil)
match {
preferred != nil => preferred,
first != nil => first,
_ => "Anonymous",
}
}
println(get_name({preferred_name: "Ada"})) # Ada
println(get_name({first_name: "Grace"})) # Grace
println(get_name({})) # Anonymous
Or, when a single default suffices, let get do the work:
import std:println
user = {}
println(user::get("first_name", "Anonymous")) # Anonymous
Guard Clauses
import std:println
process_user = |user| {
# Early returns with logical checks (guard clauses)
invalid = user == nil || !user::get("is_active", false)
invalid && return "Invalid user"
unauthorized = user::get("age", 0) < 18 || !user::get("has_consent", false)
unauthorized && return "Unauthorized"
"Processing user..."
}
println(process_user(nil)) # Invalid user
println(process_user({is_active: true, age: 12})) # Unauthorized
println(process_user({is_active: true, age: 30, has_consent: true})) # Processing user...
Access Control
import std:println
can_edit = |user, document| {
is_owner = user:id == document:owner_id
is_admin = user:role == "admin"
is_collaborator = document:collaborators::contains(user:id)
is_owner || is_admin || is_collaborator
}
alice = {id: 1, role: "member"}
doc = {owner_id: 2, collaborators: [1, 5]}
println(can_edit(alice, doc)) # true (collaborator)
De Morgan’s Laws
Useful equivalences for simplifying logical expressions:
NOT (A AND B) = (NOT A) OR (NOT B)
import std:println
a = true
b = false
# These are equivalent:
result1 = !(a && b)
result2 = !a || !b
println(result1 == result2) # true
NOT (A OR B) = (NOT A) AND (NOT B)
import std:println
a = true
b = false
# These are equivalent:
result1 = !(a || b)
result2 = !a && !b
println(result1 == result2) # true
Truth Tables
AND (&&)
| A | B | A && B |
|---|---|---|
| T | T | T |
| T | F | F |
| F | T | F |
| F | F | F |
OR (||)
| A | B | A || B |
|---|---|---|
| T | T | T |
| T | F | T |
| F | T | T |
| F | F | F |
NOT (!)
| A | !A |
|---|---|
| T | F |
| F | T |
Common Pitfalls
Pitfall 1: Confusing && with ,
import std:println
age = 20
has_license = true
# Wrong - this is a tuple of two booleans, not a logical AND
conditions = (age >= 18, has_license)
println(conditions) # (true, true)
# Correct - logical AND
can_drive = age >= 18 && has_license
println(can_drive) # true
Pitfall 2: Unnecessary Comparisons
is_valid = true
# Redundant comparison
match is_valid == true {
true => {
# ...
},
false => {},
}
# Use directly
match is_valid {
true => {
# ...
},
false => {},
}
Pitfall 3: Incorrect Negation
is_valid = true
# Wrong (redundant comparison)
match is_valid == false {
true => {
# ...
},
false => {},
}
# Correct (use negation)
match !is_valid {
true => {
# ...
},
false => {},
}
Pitfall 4: Complex Conditions Without Parentheses
import std:println
a = true
b = false
c = true
d = true
e = false
# Hard to read
match (a && b) || (c && d) || e {
true => println("matched"),
false => {},
}
# Clear with parentheses (same as above, but explicit)
match ((a && b) || (c && d) || e) {
true => println("matched"),
false => {},
}
Pitfall 5: Side Effects in Conditions
import std:println
# Don't rely on side effects: with short-circuiting, whether the right-hand side
# runs at all depends on the left-hand side.
# Separate side effects from conditions
counter = 0
limit = 5
counter++
match counter <= limit {
true => println("within limit"),
false => {},
}
Best Practices
DO:
- Use parentheses for complex conditions
- Leverage short-circuit evaluation for safety
- Keep conditions simple and readable
- Use meaningful boolean variable names
- Extract complex logic into named variables
DON’T:
- Compare booleans to
true/false - Create deeply nested logical expressions
- Put side effects in conditional expressions
- Forget operator precedence
- Mix too many conditions without structure
Examples
Permission System
import std:println
has_permission = |user, resource, action| {
# Admin can do anything
user:role == "admin" && return true
# Owner can do anything with their resource
resource:owner_id == user:id && return true
# Check specific permissions
permission_key = "${resource:type}:${action}"
user:permissions::contains(permission_key)
}
editor = {role: "editor", id: 7, permissions: ["post:read", "post:write"]}
post = {type: "post", owner_id: 99}
println(has_permission(editor, post, "write")) # true
println(has_permission(editor, post, "delete")) # false
Input Validation
import std:println
validate_password = |password| {
long_enough = password::length() >= 8
has_upper = password ~ /[A-Z]/
has_lower = password ~ /[a-z]/
has_digit = password ~ /[0-9]/
has_special = password ~ /[!@#$%^&*]/
# All conditions must be true
long_enough && has_upper && has_lower && has_digit && has_special
}
println(validate_password("Passw0rd!")) # true
println(validate_password("password")) # false
Feature Flags
import std:println
global_features = {dark_mode: true, beta_search: false}
is_feature_enabled = |feature_name, user| {
# Check global flag, defaulting to off
global_enabled = global_features::get(feature_name, false)
# Check user-specific override
user_enabled = user:feature_flags::get(feature_name, nil)
# User override takes precedence
match user_enabled {
nil => global_enabled,
_ => user_enabled,
}
}
user = {feature_flags: {beta_search: true}}
println(is_feature_enabled("dark_mode", user)) # true (global)
println(is_feature_enabled("beta_search", user)) # true (user override)
println(is_feature_enabled("unknown", user)) # false (default)
Eligibility Check
import std:println
is_eligible_for_loan = |applicant| {
# Must meet all criteria
age_ok = applicant:age >= 21 && applicant:age <= 65
income_ok = applicant:annual_income >= 30000
credit_ok = applicant:credit_score >= 650
employed = applicant:employment_status == "employed"
# No disqualifying factors
no_bankruptcy = !applicant:has_bankruptcy
no_defaults = !applicant:has_loan_defaults
# All positive criteria AND no negative factors
age_ok && income_ok && credit_ok && employed && no_bankruptcy && no_defaults
}
applicant = {
age: 34,
annual_income: 52000,
credit_score: 710,
employment_status: "employed",
has_bankruptcy: false,
has_loan_defaults: false,
}
println(is_eligible_for_loan(applicant)) # true
Next Steps
- Learn about Relational Operators to create conditions
- Explore Conditional Logic
- Study Boolean Data Type
- Check out Pattern Matching
See Also
Matching Operators
Matching operators test if strings match regular expression patterns.
Overview
Suji provides two operators for testing strings against regular expressions, making text validation concise and readable.
The operators are ~ and !~ — there is no =~. Both yield only a boolean.
Suji’s regex support is deliberately match-only:
- no capture groups (
::captures(),::match(),::find()do not exist) - no regex replace —
::replace(old, new)takes strings only - no regex split —
::split(sep)takes a string separator - regex literals are not interpolated:
/${var}/is passed to the engine literally and fails to compile
Regex values are first class: you can store one in a variable and use it as a match
arm pattern.
Match Operator (~)
Tests if a string matches a regex pattern:
import std:println
text = "user@example.com"
pattern = /^[^@]+@[^@]+\.[^@]+$/
# Returns true if matches
is_email = text ~ pattern
println(is_email) # true
Basic Matching
import std:println
# Check if string contains pattern
println("hello world" ~ /world/) # true
println("hello world" ~ /goodbye/) # false
# Case-sensitive by default
println("Hello" ~ /hello/) # false
println("Hello" ~ /(?i)hello/) # true (case-insensitive)
Negative Match Operator (!~)
Tests if a string does NOT match a pattern:
import std:println
text = "hello123"
println(text !~ /world/) # true (doesn't contain "world")
println(text !~ /hello/) # false (contains "hello")
# Useful for validation
username = "alice_123"
is_valid_username = username !~ /[^a-zA-Z0-9_]/
println(is_valid_username) # true - contains NO invalid characters
Common Patterns
Email Validation
import std:println
validate_email = |email| {
email ~ /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
}
println(validate_email("user@example.com")) # true
println(validate_email("invalid.email")) # false
URL Validation
import std:println
validate_url = |url| {
url ~ /^https?:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(\/.*)?$/
}
println(validate_url("https://example.com")) # true
println(validate_url("http://site.co.uk/path")) # true
println(validate_url("not-a-url")) # false
Phone Number
import std:println
validate_phone = |phone| {
phone ~ /^\d{3}-\d{3}-\d{4}$/
}
println(validate_phone("555-123-4567")) # true
println(validate_phone("555-1234")) # false
println(validate_phone("5551234567")) # false
Contains Digits
import std:println
has_digits = |text| {
text ~ /\d/
}
println(has_digits("hello123")) # true
println(has_digits("hello")) # false
Starts/Ends With
import std:println
# Starts with "http"
println("http://example.com" ~ /^http/) # true
println("https://example.com" ~ /^http/) # true
println("ftp://example.com" ~ /^http/) # false
# Ends with ".com"
println("example.com" ~ /\.com$/) # true
println("example.org" ~ /\.com$/) # false
In Conditionals
Matching operators work well with match expressions:
import std:println
username = "alice_123"
match username ~ /^[a-zA-Z0-9_]+$/ {
true => println("Valid username"),
false => println("Invalid username: contains special characters"),
}
In Filters
Use matching to filter lists:
import std:println
emails = [
"valid@example.com",
"invalid.email",
"another@test.org",
"bad@"
]
pattern = /^[^@]+@[^@]+\.[^@]+$/
valid_emails = emails::filter(|e| e ~ pattern)
println(valid_emails)
# [valid@example.com, another@test.org]
Pattern Matching with Match
Combine with match for branching on patterns:
import std:println
text = "Call me at 555-1234"
words = text::split(" ")
phones = words::filter(|w| w ~ /^\d{3}-\d{4}$/)
match {
phones::length() > 0 => println("Phone: " + phones[0]),
_ => println("No phone found"),
}
A regex literal can also be used directly as a match arm pattern, which is often
tidier than a chain of ~ tests:
import std:println
classify = |token| {
match token {
/^\d+$/ => "number",
/^[a-z]+$/ => "word",
_ => "mixed",
}
}
println(classify("42")) # number
println(classify("hello")) # word
println(classify("h3llo")) # mixed
Because regex values are ordinary values, you can name them and reuse them:
import std:println
digits_only = /^\d+$/
println("123" ~ digits_only) # true
println("12a" ~ digits_only) # false
What you cannot do is build a pattern dynamically. Regex literals are not
interpolated, so /${some_var}/ is handed to the engine verbatim and fails with a
Regex error, and there is no function that turns a string into a regex. Patterns
must be written out literally in the source.
Negation Patterns
Use !~ to check absence of patterns:
import std:println
# Check password doesn't contain spaces
password = "MyP@ssw0rd"
no_spaces = password !~ /\s/
println(no_spaces) # true
# Check username has no special chars
username = "alice_123"
only_alphanumeric = username !~ /[^a-zA-Z0-9_]/
println(only_alphanumeric) # true
Combining with Logical Operators
import std:println
validate_password = |password| {
# All these conditions must be true
long_enough = password::length() >= 8
has_upper = password ~ /[A-Z]/
has_lower = password ~ /[a-z]/
has_digit = password ~ /[0-9]/
has_special = password ~ /[!@#$%^&*]/
long_enough && has_upper && has_lower && has_digit && has_special
}
println(validate_password("Passw0rd!")) # true
println(validate_password("password")) # false
Inline Regex Modes
The regex engine supports inline mode modifiers like (?i) (case-insensitive) and (?m) (multiline).
import std:println
text = "Hello World"
# Case-insensitive
println(text ~ /(?i)hello/) # true
# Multiline
multiline = "line1\nline2"
println(multiline ~ /(?m)^line2/) # true
Common Pitfalls
Pitfall 1: Not Escaping Special Characters
import std:println
# Unescaped dot matches any character
println("axb" ~ /a.b/) # true - the dot matched "x"
println("a.b" ~ /a.b/) # true - and it matches a literal dot too
# Escape the dot to match only a literal dot
println("axb" ~ /a\.b/) # false
println("a.b" ~ /a\.b/) # true
Pitfall 2: Forgetting Anchors
import std:println
# Partial match (finds "123" anywhere)
println("hello123world" ~ /\d+/) # true
# Anchored (entire string must be digits)
println("hello123world" ~ /^\d+$/) # false
println("123" ~ /^\d+$/) # true
Pitfall 3: Case Sensitivity
import std:println
# Case-sensitive by default
println("Hello" ~ /hello/) # false
# Use inline case-insensitive mode
println("Hello" ~ /(?i)hello/) # true
Pitfall 4: Expecting Greediness to Matter
Greedy and non-greedy quantifiers are both accepted, but since ~ returns only
“did it match”, the difference is invisible — you never get to see what matched:
import std:println
text = "<tag>content</tag>"
println(text ~ /<.*>/) # true
println(text ~ /<.*?>/) # true - same answer
If you need the matched text, regex will not help you: use string methods such as
::index_of(), ::split() and slicing (s[1;4]) to carve the value out yourself.
Best Practices
DO:
- Use anchors (
^,$) for exact matches - Escape special regex characters
- Reach for
::contains(),::starts_with()and::ends_with()when a plain substring test will do - Test patterns thoroughly
- Add comments for complex regex
DON’T:
- Expect captured groups, regex replace or regex split — none exist
- Write
=~; the operator is~ - Try to interpolate a pattern (
/${var}/is a regex error) - Forget case sensitivity
- Use regex to parse HTML/XML
Examples
Username Validation
import std:println
validate_username = |username| {
# 3-20 characters, alphanumeric and underscore only
valid_format = username ~ /^[a-zA-Z0-9_]{3,20}$/
no_double_underscore = username !~ /__/
not_starts_with_number = username !~ /^[0-9]/
valid_format && no_double_underscore && not_starts_with_number
}
println(validate_username("alice_123")) # true
println(validate_username("ab")) # false (too short)
println(validate_username("alice__bob")) # false (double underscore)
println(validate_username("123alice")) # false (starts with number)
Extract Domain from URL
import std:println
get_domain = |url| {
url !~ /^https?:\/\// && return nil
parts = url::split("://")
parts::length() < 2 && return nil
host_and_path = parts[1]
host_parts = host_and_path::split("/")
host_parts[0]
}
println(get_domain("https://example.com/path")) # example.com
println(get_domain("http://site.org")) # site.org
println(get_domain("ftp://site.org")) # nil
Sanitize Input
There is no regex replace, so filtering has to be done character by character. Note
that strings are not iterable — loop through text is a runtime error, so convert
with ::to_list() first:
import std:println
sanitize = |text| {
# Keep only alphanumerics, spaces, hyphens and underscores
result = ""
loop through text::to_list() with char {
match { char ~ /[a-zA-Z0-9 _-]/ => { result = result + char } }
}
result
}
println(sanitize("Hello, World!")) # Hello World
println(sanitize("Test@#$%123")) # Test123
Password Strength Checker
import std:println
check_password_strength = |password| {
score = 0
match { password::length() >= 8 => { score = score + 1 } }
match { password::length() >= 12 => { score = score + 1 } }
match { password ~ /[A-Z]/ => { score = score + 1 } }
match { password ~ /[a-z]/ => { score = score + 1 } }
match { password ~ /[0-9]/ => { score = score + 1 } }
match { password ~ /[!@#$%^&*]/ => { score = score + 1 } }
match {
score >= 5 => "Strong",
score >= 3 => "Medium",
_ => "Weak",
}
}
println(check_password_strength("abc123")) # Weak
println(check_password_strength("Abc123")) # Medium
println(check_password_strength("Abc123!@")) # Strong
File Extension Check
import std:println
is_image = |filename| {
filename ~ /(?i)\.(jpg|jpeg|png|gif|webp)$/
}
is_document = |filename| {
filename ~ /(?i)\.(pdf|doc|docx|txt)$/
}
println(is_image("photo.jpg")) # true
println(is_image("document.pdf")) # false
println(is_document("report.pdf")) # true
Next Steps
- Learn about Regular Expressions in detail
- Explore String Methods
- Study Pattern Matching
- Check out Text Processing
See Also
Pipe (|)
The pipe operator | builds pipelines where the stdout of one stage becomes the stdin of the next.
This is different from pipe-apply (|> / <|), which passes values as function arguments. Note that |< is not an operator in Suji; the backward pipe-apply is spelled <|.
What can appear in a pipeline?
In this repository, each stage must be either:
- A function invocation (e.g.
stage()orstage(arg)), or - A backtick command template (e.g.
`cat file.txt`)
Stages communicate via stdin/stdout (not via return values). A bare function name is not a valid stage — you must call it.
Example: shell → Suji → shell
import std:io
# Read all input from stdin, uppercase it, write to stdout
upper = || {
text = io:stdin::read_all()
io:stdout::write(text::upper())
}
`printf "hello\n"` | upper() | `wc -c`
The value of a pipeline
A pipeline expression evaluates to the captured stdout of its last stage, with the trailing newline trimmed (the same rule as a plain backtick template). Nothing is printed automatically when the last stage is a backtick command, so assign it if you want to see it:
import std:io
import std:println
upper = || {
text = io:stdin::read_all()
io:stdout::write(text::upper())
}
bytes = `printf "hello\n"` | upper() | `wc -c`
println("bytes: ${bytes::trim()}") # bytes: 6
Example: processing lines
Reading a stream is eager: read_lines() returns the whole input as a list of lines.
This example creates its own input file so it runs anywhere:
import std:io
import std:println
path = `mktemp`
`printf 'alpha\n\nbeta\n\ngamma\n' > ${path}`
only_non_empty = || {
lines = io:stdin::read_lines()
loop through lines with line {
match { line::trim()::length() > 0 => { io:stdout::write(line + "\n") } }
}
}
kept = `cat ${path}` | only_non_empty() | `head -n 2`
println(kept) # alpha, then beta on the next line
`rm ${path}`
Notes
|is implemented by redirectingstd:iostreams for each stage and capturing stdout between stages.- A stage whose command exits non-zero raises a runtime error that terminates the
script — there is no way to trap it. Defensive tricks such as
`cmd || true`keep the shell happy. |sits between|>and>>in the precedence table: lower than composition, higher than the pipe-apply operators.- If you want to pass values through a chain (not stdin/stdout), use list/string methods (
::map,::filter, …) or pipe-apply (|>/<|).
See Also
Pipe Apply (|> and <|)
Pipe-apply operators call functions with one argument, but let you choose a convenient reading order.
Overview
value |> fis equivalent tof(value)f <| valueis equivalent tof(value)
Both operators require the function side to evaluate to a function value; anything
else raises Pipe apply (|>) requires a function on the right-hand side (or the
matching left-hand-side error for <|).
There is no |< operator — the backward form is <|.
Forward apply (|>)
Read left-to-right:
import std:println
trim = |s| s::trim()
upper = |s| s::upper()
result = " hello " |> trim |> upper
println(result) # HELLO
Backward apply (<|)
Read right-to-left. <| is right-associative, so the parentheses are optional:
import std:println
trim = |s| s::trim()
upper = |s| s::upper()
println <| upper <| trim <| " hello " # HELLO
Precedence
<| has the lowest precedence of the two, just above assignment; |> binds slightly
tighter, and both bind looser than arithmetic. That means the arithmetic on the left
is evaluated before the value is piped:
import std:println
inc = |x| x + 1
println(1 + 2 |> inc) # 4 - the same as (1 + 2) |> inc
Notes
|>/<|always apply one argument. They do not “insert into a parameter list”, and there is no partial application syntax — write a lambda returning a lambda if you need that.- If you want to transform collections, prefer list methods:
import std:println
numbers = [1, 2, 3, 4, 5]
result = numbers::filter(|x| x % 2 == 0)::map(|x| x * x)::sum()
println(result) # 20
- If you want to build a reusable function instead of calling one right away, use the
composition operators
>>/<<(see Function Composition). - If you want stdin/stdout piping between stages, use the
|operator (see Pipe).
See Also
Function Composition
Function composition operators combine functions to create new functions.
Overview
Composition creates a new function by chaining two functions together, where the output of one becomes the input of the next. Suji provides two composition operators: >> (compose right) and << (compose left).
Both sit at the same precedence level, between the pipeline operator | and ||. They
are distinct from the pipe-apply operators |> / <|, which call a function
immediately rather than building a new one.
Operators
Compose Right (>>)
Chains functions left-to-right: f >> g means “first apply f, then apply g to the result”.
import std:println
# Define simple functions
add2 = |x| x + 2
mul3 = |x| x * 3
# Compose: add 2, then multiply by 3
f = add2 >> mul3
println(f(5)) # (5 + 2) * 3 = 21
Syntax: function1 >> function2
Evaluation: (f >> g)(x) is equivalent to g(f(x))
Compose Left (<<)
Chains functions right-to-left: f << g means “first apply g, then apply f to the result”.
import std:println
# Define simple functions
add2 = |x| x + 2
mul3 = |x| x * 3
# Compose: multiply by 3, then add 2
f = add2 << mul3
println(f(5)) # (5 * 3) + 2 = 17
Syntax: function1 << function2
Evaluation: (f << g)(x) is equivalent to f(g(x))
Comparison
import std:println
add2 = |x| x + 2
mul3 = |x| x * 3
# Left-to-right (>>)
f1 = add2 >> mul3
println(f1(5)) # (5 + 2) * 3 = 21
# Right-to-left (<<)
f2 = add2 << mul3
println(f2(5)) # (5 * 3) + 2 = 17
# Equivalence
f3 = mul3 << add2
println(f3(5)) # Same as f1: (5 + 2) * 3 = 21
Relationship: f >> g is equivalent to g << f
Common Use Cases
Data Transformation Pipelines
import std:println
# Processing functions
trim = |s| s::trim()
upper = |s| s::upper()
exclaim = |s| "${s}!"
# Compose processing pipeline
process = trim >> upper >> exclaim
println(process(" hello ")) # HELLO!
Number Processing
import std:println
# Math operations
square = |x| x * x
negate = |x| -x
add10 = |x| x + 10
# Complex transformation
transform = square >> negate >> add10
println(transform(3)) # -(3^2) + 10 = -9 + 10 = 1
Validation and Transformation
import std:println
# Validators
is_positive = |x| x > 0
is_even = |x| x % 2 == 0
# Transformers
double = |x| x * 2
add5 = |x| x + 5
# Combined pipeline
safe_transform = |x| {
match (is_positive(x) && is_even(x)) {
true => {
pipeline = double >> add5
pipeline(x)
},
false => nil,
}
}
println(safe_transform(4)) # (4 * 2) + 5 = 13
println(safe_transform(3)) # nil (not even)
println(safe_transform(-2)) # nil (not positive)
Function Factories
import std:println
# Create composable operations
make_adder = |n| { |x| x + n }
make_multiplier = |n| { |x| x * n }
# Build complex functions
add10 = make_adder(10)
mul2 = make_multiplier(2)
# Compose dynamically
transform = add10 >> mul2
println(transform(5)) # (5 + 10) * 2 = 30
Chaining Compositions
Composition is associative, allowing natural chaining:
import std:println
f = |x| x + 1
g = |x| x * 2
h = |x| x * x
# All equivalent:
pipeline1 = (f >> g) >> h
pipeline2 = f >> (g >> h)
pipeline3 = f >> g >> h
println(pipeline1(3)) # 64
println(pipeline2(3)) # 64
println(pipeline3(3)) # 64
# Calculation: ((3 + 1) * 2)^2 = (8)^2 = 64
Composition vs Pipe Apply
Composition creates a new function, while pipe apply executes immediately:
import std:println
add2 = |x| x + 2
mul3 = |x| x * 3
# Composition: creates a function
composed = add2 >> mul3
result1 = composed(5) # Call the composed function
println(result1) # 21
# Pipe apply: executes immediately
result2 = 5 |> add2 |> mul3
println(result2) # 21
# Key difference: composition is reusable
println(composed(10)) # 36
println(composed(15)) # 51
With Higher-Order Functions
Composition works elegantly with map, filter, etc.:
import std:println
add1 = |x| x + 1
double = |x| x * 2
# Compose transformation
transform = add1 >> double
# Apply to list
numbers = [1, 2, 3, 4, 5]
result = numbers::map(transform)
println(result) # [4, 6, 8, 10, 12]
Pattern: Method Chain Composition
import std:println
# String processing pipeline
process_text =
(|s| s::trim()) >>
(|s| s::lower()) >>
(|s| s::split(" "))
text = " HELLO WORLD "
words = process_text(text)
println(words) # [hello, world]
Advanced Patterns
Conditional Composition
import std:println
safe_divide = |divisor| {
|x| {
match divisor != 0 {
true => x / divisor,
false => nil,
}
}
}
# Compose with safe operations
add10 = |x| x + 10
divide_by_2 = safe_divide(2)
pipeline = add10 >> divide_by_2
println(pipeline(5)) # 7.50 - (5 + 10) / 2
N-ary Function Composition
import std:println
# Compose functions that take multiple arguments
add = |a, b| a + b
multiply_by_3 = |x| x * 3
# Suji has no partial-application syntax, so wrap the call in a one-argument lambda
add5 = |x| add(x, 5)
transform = add5 >> multiply_by_3
println(transform(10)) # (10 + 5) * 3 = 45
Best Practices
DO:
- Use
>>for left-to-right reading (more intuitive) - Compose pure functions (no side effects)
- Create reusable function pipelines
- Name composed functions descriptively
- Keep composed functions simple and focused
DON’T:
- Compose functions with side effects (unpredictable)
- Create overly complex compositions (hard to debug)
- Forget that composition creates new functions
- Mix composition with imperative code
- Ignore function signatures (ensure types match)
Composition Equivalences
These are identities, not runnable Suji (≡ is not an operator):
f >> g ≡ |x| g(f(x))
f << g ≡ |x| f(g(x))
Associativity:
(f >> g) >> h ≡ f >> (g >> h)
(f << g) << h ≡ f << (g << h)
Relationship:
f >> g ≡ g << f
And here they are as executable checks:
import std:println
f = |x| x + 1
g = |x| x * 2
println((f >> g)(5) == g(f(5))) # true
println((f << g)(5) == f(g(5))) # true
println((f >> g)(5) == (g << f)(5)) # true
Examples
URL Builder
import std:println
# Component functions
add_protocol = |url| "https://${url}"
add_path = |url| "${url}/api"
add_version = |url| "${url}/v1"
# Compose URL builder
build_api_url = add_protocol >> add_path >> add_version
url = build_api_url("example.com")
println(url) # https://example.com/api/v1
Data Sanitization
import std:println
# Sanitization steps. There is no regex replace, so stripping unwanted characters
# means filtering them out one at a time (strings need ::to_list() to be iterable).
remove_whitespace = |s| s::trim()
remove_special_chars = |s| {
kept = ""
loop through s::to_list() with ch {
match { ch ~ /[A-Za-z0-9]/ => { kept = kept + ch } }
}
kept
}
to_lowercase = |s| s::lower()
# Compose sanitizer
sanitize = remove_whitespace >> remove_special_chars >> to_lowercase
raw = " Hello@World! "
clean = sanitize(raw)
println(clean) # helloworld
Numeric Transformation
import std:println
# Math operations. Note `^` requires an integer exponent, so `x ^ 0.5` is an error —
# square roots use the number method `::sqrt()`.
abs_val = |x| x::abs()
square = |x| x * x
root = |x| x::sqrt()
# Compose distance calculation
distance = abs_val >> square >> root
println(distance(-5)) # 5 - abs(-5) = 5, 5^2 = 25, sqrt(25) = 5
See Also
- Pipe Apply Operators - Execute pipelines immediately
- Higher-Order Functions - Functions that work with functions
- Function Basics - Function fundamentals
- Pipe Operator - Stream composition
Control Flow
Control flow determines the order in which code executes based on conditions and iteration needs.
Overview
Suji provides two control flow constructs: loop (with break and continue) and match expressions. There is no if, else, while or for, and no try/catch. Guard clauses using logical operators enable early returns.
One rule catches almost everybody: in a match, an arm whose body is a bare
expression must be followed by a comma, including the final arm. Only arms
with { … } block bodies may omit it.
Control Flow Constructs
Conditional Logic
Suji does not have if or else statements. Use match expressions for all conditional logic:
Match for Conditionals
import std:println
age = 25
# Match on boolean condition
match age >= 18 {
true => println("Adult"),
false => println("Minor"),
}
Multiple Conditions
import std:println
score = 85
# Use conditional match for ranges
match {
score >= 90 => println("A"),
score >= 80 => println("B"),
score >= 70 => println("C"),
score >= 60 => println("D"),
_ => println("F"),
}
Learn more about Conditional Logic →
Loops
Repeat code multiple times:
Infinite Loop
import std:println
count = 0
loop {
count = count + 1
count > 5 && break
println(count)
}
Loop Through Lists
import std:println
fruits = ["apple", "banana", "cherry"]
loop through fruits with fruit {
println(fruit)
}
# Lists have no index binding — keep a counter or iterate the indices
loop through 0..fruits::length() with index {
println("${index}: ${fruits[index]}")
}
Loop Through Maps
Two bindings are for maps only:
import std:println
config = {port: 8080, host: "localhost"}
loop through config with key, value {
println("${key} = ${value}")
}
Pattern Matching
Match values against patterns:
import std:println
grade = |score| {
match {
score >= 90 => "A",
score >= 80 => "B",
score >= 70 => "C",
score >= 60 => "D",
_ => "F",
}
}
println(grade(85)) # B
With Tuple Patterns
Tuple patterns compare element by element. Elements are literals or _ — they do
not bind, so use a destructuring assignment when you need the parts:
import std:println
point = (10, 20)
match point {
(0, 0) => println("Origin"),
(0, _) => println("On the Y-axis"),
(_, 0) => println("On the X-axis"),
_ => println("Elsewhere"),
}
x, y = point
println("Point at ${x}, ${y}")
With Regex
import std:println
text = "Call 555-1234"
match text {
/\d{3}-\d{4}/ => {
words = text::split(" ")
phones = words::filter(|w| w ~ /^\d{3}-\d{4}$/)
match { phones::length() > 0 => { println("Phone: " + phones[0]) } }
},
_ => println("No phone found"),
}
Learn more about Pattern Matching →
Guard Clauses
Early returns using logical operators:
import std:println
process_user = |user| {
# Guard: return early if nil
user == nil && return "Error: No user"
# Guard: return early if inactive (::get avoids a "Key not found" error)
user::get("is_active", false) == false && return "Error: Inactive user"
# Main logic
"User ${user::get("name", "?")} processed"
}
println(process_user(nil)) # Error: No user
println(process_user({name: "Ada"})) # Error: Inactive user
println(process_user({name: "Ada", is_active: true})) # User Ada processed
Guards are the only way to deal with failure: a runtime error terminates the program and cannot be caught, so validate before acting.
Learn more about Guard Clauses →
Control Flow Comparison
When to Use Each Construct
| Construct | Use When | Example |
|---|---|---|
| Match | All conditional logic | Check if user is admin, grade calculator |
| Loop | Repeat until condition | Read until EOF |
| Loop Through | Iterate collections | Process each item in list |
| Guards | Validate inputs, early exits | Check preconditions |
Common Patterns
Early Return Pattern
import std:println
transform = |xs| xs::map(|n| n * 2)
process_data = |data| {
# Validate and return early on failure
data == nil && return nil
data::length() == 0 && return nil
# Main processing logic
transform(data)
}
println(process_data(nil)) # nil
println(process_data([])) # nil
println(process_data([1, 2, 3])) # [2, 4, 6]
Switch-Style Match
import std:println
handle_command = |cmd| {
match cmd {
"start" => println("Starting..."),
"stop" => println("Stopping..."),
"restart" => println("Restarting..."),
"status" => println("Running"),
_ => println("Unknown command"),
}
}
handle_command("stop") # Stopping...
handle_command("deploy") # Unknown command
Iterator Pattern
import std:println
items = ["skip me", "a", "b", "c"]
index = 0
loop through items with item {
index = index + 1
index == 1 && continue # Skip first
index > 3 && break # Stop after the third
println(item)
}
State Machine
import std:println
state = "idle"
process_event = |event| {
state = match (state, event) {
("idle", "start") => "running",
("running", "pause") => "paused",
("paused", "resume") => "running",
("running", "stop") => "idle",
_ => state, # No change
}
}
process_event("start")
println(state) # running
process_event("bogus")
println(state) # running
Nested Control Flow
Nested Conditions
import std:println
check_access = |user, resource| {
# Use guards for early returns
user == nil && return false
user::get("is_active", false) == false && return false
user::get("permissions", [])::contains(resource)
}
println(check_access(nil, "reports")) # false
println(check_access({is_active: true, permissions: ["logs"]}, "reports")) # false
println(check_access({is_active: true, permissions: ["reports"]}, "reports")) # true
Nested Loops
import std:println
matrix = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
]
loop through matrix with row {
loop through row with cell {
println(cell)
}
}
Match with Nested Conditions
import std:println
categorize = |value| {
match {
value::is_number() => {
match {
value < 0 => "negative",
value == 0 => "zero",
_ => "positive",
}
},
value::is_string() => "text",
_ => "unknown",
}
}
println(categorize(-4)) # negative
println(categorize("hi")) # text
println(categorize(nil)) # unknown
Best Practices
DO:
- Use guard clauses for early validation
- Prefer
matchfor all conditional logic - Use
loop throughfor collections - Keep nesting shallow (max 2-3 levels)
- Use meaningful condition names
DON’T:
- Try to use
ifstatements (they don’t exist in Suji) - Forget the trailing comma after the last expression arm of a
match - Assume a bare identifier pattern binds — it is a string literal
- Create deeply nested match expressions
- Use loops where functional methods work better
- Ignore break/continue for complex loop logic
- Write long match expressions (extract to functions)
- Forget the default case (
_) in match
Common Pitfalls
Pitfall 1: Missing Comma After the Last Arm
This is the most common parse error in Suji code. Expression arms need commas, last arm included:
match value { 1 => "one", 2 => "two" } # [201] Error: Unexpected token
import std:println
value = 2
println(match value { 1 => "one", 2 => "two", }) # two
Pitfall 2: Missing Default Case
A match that matches nothing evaluates to nil rather than raising, so a
missing _ arm shows up as an unexpected nil later:
import std:println
value = 3
# No default case
println(match value {
1 => "one",
2 => "two",
}) # nil
# Always have a default when nil is not the answer you want
println(match value {
1 => "one",
2 => "two",
_ => "other",
}) # other
Pitfall 3: Infinite Loops
import std:println
count = 0
done = || count >= 3
# No exit condition
# loop {
# println("forever") # Never breaks!
# }
# Have clear exit
loop {
done() && break
count = count + 1
println(count)
}
Pitfall 4: Binding Patterns That Do Not Exist
Patterns cannot introduce variables. n below is the string "n", so nothing
matches and the result is nil:
import std:println
println(match 5 { n => n * 2, }) # nil
n = 5
println(match { n > 0 => n * 2, _ => 0, }) # 10
Performance Considerations
Match Arms Are Tested in Order
There is no jump table: the interpreter walks the arms top to bottom, so put the cheapest and most likely conditions first.
import std:println
handle = |key| {
match key {
"a" => "handled a",
"b" => "handled b",
"c" => "handled c",
_ => "handled default",
}
}
println(handle("b")) # handled b
println(handle("z")) # handled default
Loop vs Functional Methods
import std:println
numbers = [1, 2, 3, 4, 5, 6]
# Loop with state mutation
total = 0
loop through numbers with n {
match { n % 2 == 0 => { total = total + n } }
}
println(total) # 12
# Functional pipeline (shorter, though it allocates an intermediate list)
total = numbers::filter(|n| n % 2 == 0)::sum()
println(total) # 12
Next Steps
Explore each control flow construct in detail:
- Conditional Logic - Using match for conditionals
- Loops - Iteration and repetition
- Match - Pattern matching
- Guards - Early returns and validation
See Also
Conditional Logic
Suji has no if, else or elif, no ternary operator, and no and/or/not
keywords. match is the only conditional construct, in two forms; logical
operators (&&, ||, !) combine boolean conditions and drive guard clauses for
early returns.
Using Match for Conditionals
match is Suji’s only conditional construct, and it comes in two forms:
- the subject form,
match value { pattern => body, … }, which compares patterns against a value - the condition-only form,
match { condition => body, … }, where every arm is a boolean expression evaluated in order
Both are expressions: they evaluate to the body of the first matching arm, or to
nil if no arm matches. Every arm whose body is a bare expression needs a
trailing comma, including the last one — see
Match Expressions for the full rule.
Basic Conditional
import std:println
age = 25
# Match on boolean condition
match age >= 18 {
true => println("Adult"),
false => println("Minor"),
}
Multiple Conditions
import std:println
score = 85
# Use conditional match for ranges
status = match {
score >= 90 => "A",
score >= 80 => "B",
score >= 70 => "C",
score >= 60 => "D",
_ => "F",
}
println(status) # B
Match as Expression
Match expressions return values:
import std:println
age = 25
status = match age >= 18 {
true => "adult",
false => "minor",
}
println(status) # adult
Nested Conditions
import std:println
user = {is_active: true, is_admin: false}
# Use nested match or combine conditions
match user:is_active {
true => match user:is_admin {
true => println("Active admin"),
false => println("Active user"),
},
false => println("Inactive user"),
}
# Or combine conditions
match {
user:is_active && user:is_admin => println("Active admin"),
user:is_active => println("Active user"),
_ => println("Inactive user"),
}
Guard Clauses
Use logical operators (&& and ||) for early returns. Read a possibly-missing
map key with ::get(key, default) — a plain user:name on a map that lacks the
key raises Key not found and terminates the program:
import std:println
validate_user = |user| {
# Guard: return early if nil
user == nil && return "User is required"
# Guard: return early if name is missing or empty
name = user::get("name", "")
name::length() == 0 && return "Name is required"
# Guard: return early if too young
user::get("age", 0) < 18 && return "Must be 18 or older"
# All guards passed
"Valid"
}
println(validate_user({name: "Alice", age: 30})) # Valid
println(validate_user({age: 30})) # Name is required
println(validate_user(nil)) # User is required
Guard Pattern Explained
Guard clauses use short-circuit evaluation:
condition && return value- returns if condition is truecondition || return value- returns if condition is false
The condition must be a real boolean. There is no truthiness in Suji, so write
x == nil or name::length() == 0 rather than relying on the value itself, and
note that x || "default" is a type error rather than a defaulting idiom.
import std:println
process = |x| {
# Return early if x is nil
x == nil && return "nil"
# Return early if x is negative
x < 0 && return "negative"
# Main logic
"positive: ${x}"
}
println(process(5)) # positive: 5
println(process(-1)) # negative
println(process(nil)) # nil
Common Patterns
Range Checking
import std:println
temperature = 25
status = match {
temperature < 0 => "Freezing",
temperature < 20 => "Cold",
temperature < 30 => "Comfortable",
_ => "Hot",
}
println(status) # Comfortable
Checking Before Acting
Runtime errors cannot be caught, so a conditional is how you avoid them. Test the dangerous condition first and pick a safe branch:
import std:println
divide = |a, b| {
match {
b == 0 => nil,
_ => a / b,
}
}
println(divide(10, 2)) # 5
println(divide(10, 0)) # nil
Type Checking
import std:println
process_value = |value| {
match {
value::is_number() => println("Number: ${value}"),
value::is_string() => println("String: ${value}"),
value::is_bool() => println("Boolean: ${value}"),
_ => println("Unknown type"),
}
}
process_value(42) # Number: 42
process_value("hello") # String: hello
process_value(true) # Boolean: true
Ternary-Style
For simple true/false conditionals:
import std:println
age = 25
status = match age >= 18 {
true => "adult",
false => "minor",
}
println(status) # adult
Best Practices
DO:
- Use
matchfor all conditional logic - Use guard clauses (
&& return) for early validation - Use conditional match (
match { condition => ... }) for multiple conditions - Keep match expressions readable
- Use pattern alternation (
|) for multiple matching values
DON’T:
- Try to use
if,else,elifor a ternary operator (none of them exist in Suji) - Write
and,orornot— the operators are&&,||and! - Rely on truthiness:
x || "default"is a type error, not a default - Nest match expressions too deeply (extract to functions)
- Forget the default case (
_) in match, unlessnilis the answer you want - Omit the trailing comma after the last expression arm
- Use complex boolean expressions in guards (extract to variables)
Comparison with Other Languages
If you’re coming from languages with if statements:
| Other Language | Suji Equivalent |
|---|---|
if (condition) { ... } | match { condition => { ... } } |
if (condition) { ... } else { ... } | match { condition => { ... } _ => { ... } } |
if (x) return y | x && return y (guard clause; x must be boolean) |
if (!x) return y | x || return y (guard clause; x must be boolean) |
if-else if-else chain | match { condition1 => ..., condition2 => ..., _ => ..., } |
condition ? a : b | match { condition => a, _ => b, } |
x = y ?? "default" | m::get("y", "default"), or match { y == nil => "default", _ => y, } |
See Also
- Match Expressions - Complete guide to pattern matching
- Guard Clauses - Using logical operators for early returns
- Logical Operators - Understanding
&&and|| - Loops - Iteration and repetition
Loops
Loops repeat code multiple times until a condition is met. loop is the only
looping keyword — there is no while and no for. The five forms are:
loop { … } # infinite; needs break
loop as outer { … } # labeled infinite loop
loop through xs { … } # iterate without a binding
loop through xs with x { … } # bind each element (or each map key)
loop through m with k, v { … } # bind key and value — maps only
A loop is a statement, never a value: break takes no operand (there is no
break <value>) and a loop expression always evaluates to nil.
Infinite Loop
import std:println
count = 0
loop {
count = count + 1
println(count)
match count >= 5 {
true => break,
false => {},
}
}
Loop Through Lists
import std:println
fruits = ["apple", "banana", "cherry"]
# Basic iteration
loop through fruits with fruit {
println(fruit)
}
# Iterate a fixed number of times without binding anything
count = 0
loop through fruits {
count = count + 1
}
println(count) # 3
There Is No Index Binding for Lists
Two bindings are for maps only. Writing loop through fruits with fruit, index
fails at runtime with Type error: Cannot iterate over list. Keep your own
counter, or iterate an index range:
import std:println
fruits = ["apple", "banana", "cherry"]
# Manual counter
index = 0
loop through fruits with fruit {
println("${index}: ${fruit}")
index = index + 1
}
# Or iterate the indices directly
loop through 0..fruits::length() with i {
println("${i} -> ${fruits[i]}")
}
Loop Through Maps
Only maps support two bindings, one for the key and one for the value. Iterating a
map with k alone binds just the key.
import std:println
config = {
host: "localhost",
port: 8080,
debug: true
}
loop through config with key, value {
println("${key} = ${value}")
}
Loop Through Ranges
A range is not a lazy iterator — a..b evaluates immediately to a plain list, so
loop through 1..11 is exactly loop through [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].
Very large ranges therefore allocate very large lists.
import std:println
# Using range literal (exclusive)
loop through 1..11 with n {
println(n) # 1 to 10
}
# Inclusive range
loop through 1..=10 with n {
println(n) # 1 to 10
}
# Descending range
loop through 10..5 with n {
println(n) # 10, 9, 8, 7, 6
}
What You Cannot Iterate
Only lists (including ranges) and maps are iterable. A string or a stream must be
converted first, otherwise you get Type error: Cannot iterate over string /
over stream:
import std:println
word = "hi"
# loop through word with c { … } # runtime error
loop through word::to_list() with c {
println(c)
}
Streams are the same story: read them into a list with read_lines() first, then
loop through that list.
Labeled Loops
Label an infinite loop with as name to break or continue an outer loop from
inside a nested one. The label goes on the plain loop form; loop as name through …
is a parse error.
import std:println
i = 0
loop as outer {
i = i + 1
i > 3 && break outer
loop through [1, 2, 3] with j {
j == 2 && continue outer
println("${i},${j}")
}
}
Break and Continue
Break
Exit the loop immediately:
import std:println
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
loop through numbers with n {
n > 5 && break # Stop when n > 5 (short-circuit)
println(n)
}
# Prints: 1, 2, 3, 4, 5
Continue
Skip to next iteration:
import std:println
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
loop through numbers with n {
n % 2 == 0 && continue # Skip even numbers (short-circuit)
println(n)
}
# Prints: 1, 3, 5, 7, 9
Return from Loop
Since break cannot carry a value, a loop that needs to produce a result lives
inside a function and uses return:
import std:println
find_first = |list, predicate| {
loop through list with item {
predicate(item) && return item # Return from function (short-circuit)
}
nil # Not found
}
println(find_first([1, 3, 8, 5], |n| n % 2 == 0)) # 8
println(find_first([1, 3, 5], |n| n % 2 == 0)) # nil
Common Patterns
Accumulator
import std:println
numbers = [1, 2, 3, 4, 5]
sum = 0
loop through numbers with n {
sum = sum + n
}
println(sum) # 15
Search
import std:println
names = ["Alice", "Bob", "Charlie"]
target = "Bob"
found = false
loop through names with name {
match { name == target => {
found = true
break
} }
}
println(found) # true
Transformation
import std:println
numbers = [1, 2, 3, 4, 5]
doubled = []
loop through numbers with n {
doubled::push(n * 2)
}
println(doubled) # [2, 4, 6, 8, 10]
Nested Loops
import std:println
# Multiplication table
loop through 1..11 with i {
loop through 1..11 with j {
println("${i} x ${j} = ${i * j}")
}
}
Loops vs Functional Methods
Often functional methods are more concise:
import std:println
numbers = [1, 2, 3, 4, 5]
# Loop (verbose)
doubled = []
loop through numbers with n {
doubled::push(n * 2)
}
# Map (concise)
doubled = numbers::map(|n| n * 2)
println(doubled) # [2, 4, 6, 8, 10]
When to use each:
- Use loops for: complex logic, early exits, state management
- Use functional methods for: transformations, filtering, aggregation
Best Practices
DO:
- Use
loop throughfor collections - Provide clear exit conditions
- Use
breakfor early exit - Use
continueto skip iterations - Label the outer
loopwhen a nested loop needs to break out of it - Consider functional methods for simple transformations
DON’T:
- Create infinite loops without break
- Expect
breakto return a value, or a loop to evaluate to anything butnil - Use two bindings on a list — that form is for maps only
- Iterate a string or stream directly; convert with
to_list()/read_lines() - Modify collection while iterating
- Nest loops more than 2-3 levels
- Use loops where functional methods are clearer
See Also
Match Expressions
Pattern matching allows you to match values against patterns and execute corresponding code.
Basic Match
import std:println
day = "Monday"
match day {
"Monday" => println("Start of week"),
"Friday" => println("End of week"),
"Saturday" | "Sunday" => println("Weekend"),
_ => println("Midweek"),
}
The Comma Rule
This is the single easiest thing to get wrong. An arm whose body is a bare
expression must be followed by a comma — including the final arm. Only an arm
whose body is a { … } block may omit the comma.
Correct — every expression arm ends with a comma:
import std:println
x = 1
println(match x { 1 => "one", _ => "other", })
Incorrect — the last arm has no comma, so the program fails to parse with
[201] Error: Unexpected token pointing at the closing brace:
match x { 1 => "one", _ => "other" }
Block bodies are the exception, and they may be written with or without commas:
import std:println
x = 1
match x {
1 => { println("one") }
_ => { println("other") }
}
Match as Expression
Match expressions return values:
import std:println
grade = |score| {
match {
score >= 90 => "A",
score >= 80 => "B",
score >= 70 => "C",
score >= 60 => "D",
_ => "F",
}
}
println(grade(85)) # B
Pattern Types
Patterns are deliberately limited. The complete list is:
- number literals, including negative ones (
-3) - string literals (
"Monday") - boolean literals (
true,false) andnil - regex literals (
/\d+/) - tuple patterns whose elements are themselves patterns (
(0, 1),(10, _)) - alternations with
|(1 | 2 | 3) - the wildcard
_
There are no variable-binding patterns, no list patterns ([a, b]), no map
patterns, no range patterns (1..10 =>) and no if guards. For anything that
needs a computed test, use the condition-only form of match described below.
Literal Matching
import std:println
value = 2
result = match value {
0 => "zero",
1 => "one",
2 => "two",
_ => "other",
}
println(result) # two
Bare Identifiers Are String Literals — Not Bindings
A bare identifier on the left of => is not a binding; it is parsed as a
string literal. So this looks like it doubles the subject but actually compares
5 against the string "n", matches nothing, and evaluates to nil:
import std:println
println(match 5 { n => n * 2, }) # nil
If you need the subject inside an arm body, refer to the original variable instead:
import std:println
n = 5
println(match { n > 0 => n * 2, _ => 0, }) # 10
No Match Means nil
A match with no arm that matches is not an error — it evaluates to nil:
import std:println
println(match 99 { 1 => "one", 2 => "two", }) # nil
That is why an explicit _ arm matters whenever nil would be a surprising
result.
Conditional Match (No Scrutinee)
Use match { ... } without an expression for conditional matching:
import std:println
categorize = |n| {
match {
n < 0 => "negative",
n == 0 => "zero",
n < 10 => "single digit",
n < 100 => "double digit",
_ => "large",
}
}
println(categorize(42)) # double digit
Tuple Patterns
A tuple pattern matches element by element, and each element is itself a pattern
— a literal or _, never a binding:
import std:println
point = (10, 0)
match point {
(0, 0) => println("Origin"),
(0, _) => println("On the Y-axis"),
(_, 0) => println("On the X-axis"),
_ => println("Somewhere else"),
}
To use the components of a tuple, destructure it with an assignment first — the
match arms can then test the parts:
import std:println
point = (10, 20)
x, y = point
quadrant = match {
x > 0 && y > 0 => "I",
x < 0 && y > 0 => "II",
x < 0 && y < 0 => "III",
x > 0 && y < 0 => "IV",
_ => "on an axis",
}
println("(${x}, ${y}) is in quadrant ${quadrant}") # (10, 20) is in quadrant I
Regex Patterns
A regex literal as a pattern matches when the subject string matches it. Regex matching is boolean only — there are no capture groups, so an arm body cannot pull pieces out of the subject:
import std:println
text = "Call me at 555-1234"
match text {
/\d{3}-\d{4}/ => {
words = text::split(" ")
phones = words::filter(|w| w ~ /^\d{3}-\d{4}$/)
match { phones::length() > 0 => { println("Phone: " + phones[0]) } }
}
/[a-z]+@[a-z]+\.[a-z]+/ => println("Email found"),
_ => println("No pattern matched"),
}
Type Matching
import std:println
process = |value| {
match {
value::is_number() => "Number: ${value}",
value::is_string() => "String: ${value}",
value::is_bool() => "Boolean: ${value}",
value::is_list() => "List with ${value::length()} items",
_ => "Unknown type",
}
}
println(process(42)) # Number: 42
println(process("hello")) # String: hello
println(process([1, 2, 3])) # List with 3 items
Multiple Patterns (Alternation)
Use | to match multiple patterns:
import std:println
value = 5
match value {
1 | 2 | 3 => println("Small"),
4 | 5 | 6 => println("Medium"),
7 | 8 | 9 => println("Large"),
_ => println("Out of range"),
}
Wildcard Pattern (_)
Matches anything (catch-all):
import std:println
value = "something"
match value {
"specific" => println("Matched specific"),
_ => println("Matched anything else"),
}
Nested Match
import std:println
process = |value| {
match {
value::is_number() => {
match {
value < 0 => "Negative",
value == 0 => "Zero",
value < 10 => "Small positive",
_ => "Large positive",
}
},
value::is_string() => "Text",
_ => "Other",
}
}
println(process(-5)) # Negative
println(process(5)) # Small positive
println(process("hi")) # Text
Common Patterns
Command Handler
import std:println
start_service = || println("starting")
stop_service = || println("stopping")
check_status = || println("running")
handle_command = |cmd| {
match cmd {
"start" => start_service(),
"stop" => stop_service(),
"status" => check_status(),
_ => println("Unknown command: ${cmd}"),
}
}
handle_command("start") # starting
handle_command("deploy") # Unknown command: deploy
State Machine
import std:println
next_state = |current, event| {
match (current, event) {
("idle", "start") => "running",
("running", "pause") => "paused",
("paused", "resume") => "running",
("running", "stop") => "idle",
("paused", "stop") => "idle",
_ => current, # No transition
}
}
println(next_state("idle", "start")) # running
println(next_state("idle", "pause")) # idle
Note the last arm: it is _ (a wildcard pattern) with the body current (an
ordinary variable reference). Writing (state, _) => state would not work,
because state in a pattern means the string "state".
Value-and-Error Tuples
Suji has no Result or Option type and no exceptions, so functions that can
fail conventionally return a (value, error) tuple. Destructure the tuple first,
then branch on the error with the condition form:
import std:println
parse_port = |text| {
match text {
/^\d+$/ => (text::to_number(), nil),
_ => (nil, "not a number"),
}
}
report = |text| {
value, error = parse_port(text)
match {
error == nil => println("Port: ${value}"),
_ => println("Error: ${error}"),
}
}
report("8080") # Port: 8080
report("http") # Error: not a number
Ranges Are Not Patterns
1..10 is a range expression that evaluates to a list; it cannot appear on the
left of =>. Use the condition form for interval logic:
import std:println
categorize_age = |age| {
match {
age < 0 => "Invalid",
age < 13 => "Child",
age < 20 => "Teenager",
age < 65 => "Adult",
_ => "Senior",
}
}
println(categorize_age(25)) # Adult
Match vs Conditional Logic
When to Use Match:
- Multiple distinct literal values
- Regex or tuple shape testing
- Type-based dispatch (via
is_number(),is_string()and friends) - Interval and comparison logic, using the condition form
- Anywhere another language would use
if/else
When to Use Short-Circuit Operators:
- Simple boolean conditions with early returns
- Guard clauses:
condition && return value - Early exits:
condition || break
Both operands of && and || must be real booleans — there is no truthiness, so
write x == nil or xs::length() == 0 rather than relying on a bare value.
Best Practices
DO:
- Always include a wildcard case (
_) unlessnilis the result you want - Remember the trailing comma on every expression arm, last one included
- Use the condition form (
match { … }) for ranges and computed tests - Destructure a tuple before matching when you need its parts
- Keep match arms simple (extract complex logic)
- Order patterns from specific to general
DON’T:
- Forget the wildcard/default case
- Expect a bare identifier pattern to bind — it is a string literal
- Reach for list, map, range or
if-guard patterns; they do not exist - Have unreachable patterns
- Mix unrelated match criteria
See Also
Guard Clauses
Guard clauses use logical operators (&& and ||) for early returns and short-circuit control flow.
Overview
Guard clauses improve code readability by handling edge cases and validation upfront, avoiding deep nesting. Suji uses logical operators for short-circuit evaluation to enable early returns.
They matter more in Suji than in most languages, because there is no error
handling construct at all — no try, no catch, no throw, no Result type.
A runtime error prints a diagnostic and terminates the process with exit code 1,
and nothing can intercept it. The only way to survive a bad input is to check for
it before performing the operation that would fail.
Both operands of && and || must be real booleans. There is no truthiness, so a
guard condition is always an explicit comparison such as x == nil,
xs::length() == 0 or m::contains("k").
Basic Guard Clause
import std:println
process_user = |user| {
# Guard: return early if nil
user == nil && return "Error: No user provided"
# Main logic
"Processing ${user:name}"
}
println(process_user(nil)) # Error: No user provided
Multiple Guards
Read possibly-missing map keys with ::get(key, default). A plain order:items
on a map without that key raises Key not found and ends the program, which
defeats the purpose of a guard:
import std:println
validate_order = |order| {
# Guard 1: nil check
order == nil && return "Order is required"
# Guard 2: empty items
order::get("items", [])::length() == 0 && return "Order must have items"
# Guard 3: invalid total
order::get("total", 0) <= 0 && return "Order total must be positive"
# All guards passed - main logic
"Order ${order::get("id", "?")} is valid"
}
println(validate_order(nil)) # Order is required
println(validate_order({items: []})) # Order must have items
println(validate_order({id: 7, items: ["book"], total: 0})) # Order total must be positive
println(validate_order({id: 7, items: ["book"], total: 12.50})) # Order 7 is valid
Guard Pattern Explained
Instead of nested conditions, use guard clauses for flat, readable code:
import std:println
# Guards (clear and flat)
process = |user| {
# Guard clauses - return early on failure
user == nil && return "No user"
user::get("is_active", false) == false && return "User inactive"
user::get("permissions", [])::contains("edit") == false && return "No permission"
# Main logic (not nested)
"Success"
}
println(process(nil)) # No user
println(process({is_active: false})) # User inactive
println(process({is_active: true, permissions: ["read"]})) # No permission
println(process({is_active: true, permissions: ["read", "edit"]})) # Success
Guards in Loops
import std:println
items = [
{name: "a", valid: true},
{name: "b", valid: false},
{name: "c", valid: true},
]
loop through items with item {
# Skip invalid items
item:valid == false && continue
# Process valid item
println("processing ${item:name}")
}
break and continue also work with a label on an enclosing loop as name { … },
so a guard deep inside nested loops can abandon the outer one.
Logical Operators for Guards
Short-Circuit AND (&&)
Use && to return early when condition is true:
import std:println
# Return early if condition is true
validate = |x| {
x == nil && return "nil"
x < 0 && return "negative"
"valid: ${x}"
}
println(validate(5)) # valid: 5
println(validate(-1)) # negative
println(validate(nil)) # nil
Short-Circuit OR (||)
Use || to return early when condition is false:
import std:println
# Return early if condition is false
process = |x| {
x != nil || return "nil required"
x > 0 || return "must be positive"
"processing ${x}"
}
println(process(5)) # processing 5
println(process(0)) # must be positive
println(process(nil)) # nil required
Watch the Precedence
&& binds tighter than ||, so an alternative-condition guard needs parentheses.
Written without them, i < 0 || i >= xs::length() && return nil groups as
i < 0 || (i >= … && return nil) and never returns when i is negative:
import std:println
at = |xs, i| {
(i < 0 || i >= xs::length()) && return nil
xs[i]
}
println(at([1, 2, 3], 1)) # 2
println(at([1, 2, 3], 7)) # nil
Guarding Against Runtime Errors
These are the operations that terminate a program, and the check that prevents each one.
import std:println
# Division by zero: test the divisor
safe_divide = |a, b| {
b == 0 && return nil
a / b
}
println(safe_divide(10, 2)) # 5
println(safe_divide(10, 0)) # nil
# Missing map key: ::get with a default, or ::contains to test
config = {host: "localhost"}
println(config::get("port", 8080)) # 8080
println(config::contains("port")) # false
# Empty collection: check length before averaging
scores = []
println(match { scores::length() == 0 => "no scores", _ => scores::average(), }) # no scores
# Unparseable number: validate with a regex before converting
to_number_or = |text, default| {
!(text ~ /^-?\d+(\.\d+)?$/) && return default
text::to_number()
}
println(to_number_or("42", 0)) # 42
println(to_number_or("abc", 0)) # 0
Index access is the same idea: xs[5] on a two-element list raises
Index out of bounds, so compare against xs::length() first, as in the
precedence example above.
Default Values Pattern
There is no x || default idiom — || requires booleans, so nil || "Guest" is a
type error. Use ::get on maps, or a match when the value is already in hand:
import std:println
get_name = |user| {
preferred = user::get("preferred_name", nil)
preferred != nil && return preferred
user::get("first_name", "Guest")
}
user1 = {preferred_name: "Alice"}
user2 = {first_name: "Bob"}
user3 = {}
println(get_name(user1)) # Alice
println(get_name(user2)) # Bob
println(get_name(user3)) # Guest
Common Patterns
Validation Function
Because a failure cannot be raised, report it as a value. A (ok, error) tuple is
the usual shape; return a, b returns a tuple and a, b = f() destructures it:
import std:println
validate_user = |user| {
user::get("name", "")::length() == 0 && return false, "Name is required"
!(user::get("email", "") ~ /^[^@]+@[^@]+$/) && return false, "Valid email is required"
user::get("age", 0) < 18 && return false, "Must be 18 or older"
return true, nil
}
valid, error = validate_user({name: "Alice", email: "alice@example.com", age: 30})
match {
error == nil => println("Valid"),
_ => println("Error: ${error}"),
}
valid2, error2 = validate_user({name: "Bob", email: "nope", age: 30})
println("${valid2}: ${error2}") # false: Valid email is required
Resource Access
import std:println
can_access = |user, resource| {
# Guard: no user
user == nil && return false
# Guard: inactive user
user::get("is_active", false) == false && return false
# Guard: no resource
resource == nil && return false
# Check permissions
user::get("permissions", [])::contains(resource::get("type", ""))
}
alice = {is_active: true, permissions: ["report"]}
println(can_access(nil, nil)) # false
println(can_access(alice, {type: "invoice"})) # false
println(can_access(alice, {type: "report"})) # true
Data Processing
import std:println
process_data = |data| {
# Guard: nil
data == nil && return []
# Guard: wrong type
data::is_list() == false && return []
# Guard: empty
data::length() == 0 && return []
# Process valid data
data::filter(|n| n > 0)::map(|n| n * 10)
}
println(process_data(nil)) # []
println(process_data("not a list")) # []
println(process_data([1, -2, 3])) # [10, 30]
Command Handler
import std:println
known = ["start", "stop", "status"]
handle_command = |cmd, user| {
# Guard: no command
cmd == nil && return "Command required"
cmd::length() == 0 && return "Command required"
# Guard: unauthorized
user == nil && return "Not authenticated"
user::get("is_authenticated", false) == false && return "Not authenticated"
# Guard: unknown command
known::contains(cmd) == false && return "Unknown command: ${cmd}"
"Running ${cmd}"
}
session = {is_authenticated: true}
println(handle_command("", session)) # Command required
println(handle_command("start", nil)) # Not authenticated
println(handle_command("deploy", session)) # Unknown command: deploy
println(handle_command("start", session)) # Running start
Guards vs Match
Use guards for early returns, match for branching:
import std:println
# Guards for validation
process = |x| {
x == nil && return "nil"
x < 0 && return "negative"
# Match for branching logic
match {
x > 100 => "very large",
x > 10 => "large",
_ => "small",
}
}
println(process(nil)) # nil
println(process(-1)) # negative
println(process(500)) # very large
Guards Instead of Exceptions
Suji has no exceptions to catch, so validation has to happen up front and failures travel back as ordinary return values:
import std:println
safe_divide = |a, b| {
b == 0 && return nil, "Division by zero"
return a / b, nil
}
result, error = safe_divide(10, 0)
match {
error == nil => println("Result: ${result}"),
_ => println("Error: ${error}"),
}
result2, error2 = safe_divide(10, 2)
println("${result2} ${error2}") # 5 nil
Note what is not possible: you cannot write a / b first and recover
afterwards. Once Division by zero is raised the process is gone.
Best Practices
DO:
- Put guards at the start of functions
- Return early on failure cases
- Check for nil before accessing properties
- Prefer
m::get(k, default)andm::contains(k)over a directm:kthat can raise - Use guards to avoid deep nesting
- Make error messages descriptive
- Use
&& returnfor positive conditions (return if true) - Use
|| returnfor negative conditions (return if false) - Parenthesize mixed
&&/||conditions
DON’T:
- Put guards after main logic
- Expect to recover from a runtime error — there is no
try/catch - Rely on truthiness; compare explicitly (
x == nil,flag == false) - Use
x || "default"as a defaulting idiom — it is a type error - Create overly defensive guards
- Use guards for normal branching logic (use match instead)
- Forget to return from guards
- Have guards with complex conditions (extract to variables)
- Mix guard patterns inconsistently
When to Use Guards
Use Guards For:
- Input validation
- Nil checks
- Permission checks
- Precondition verification
- Preventing operations that would raise (division by zero, missing keys, out-of-range indices)
- Early returns
Don’t Use Guards For:
- Normal branching logic (use
matchinstead) - Complex decision trees (use
matchinstead) - Multiple related conditions (use conditional
match)
Examples
API Handler
Map literals with bare identifier keys are only recognised where a map is
expected, so quote the keys when a map is the whole body of a return:
import std:println
handle_request = |request| {
# Validate request with guards
request == nil && return {"status": 400, "error": "No request"}
!(request::get("method", "") ~ /^(GET|POST|PUT|DELETE)$/) && return {"status": 405, "error": "Invalid method"}
request::contains("path") == false && return {"status": 400, "error": "Path required"}
# Process request
{"status": 200, "path": request:path}
}
println(handle_request(nil)) # {status: 400, error: No request}
println(handle_request({method: "TRACE", path: "/"})) # {status: 405, error: Invalid method}
println(handle_request({method: "GET"})) # {status: 400, error: Path required}
println(handle_request({method: "GET", path: "/ok"})) # {status: 200, path: /ok}
Form Validation
Collect every problem instead of returning on the first one:
import std:println
validate_form = |form| {
errors = []
username = form::get("username", "")
match { username::length() < 3 => { errors::push("Username too short") } }
password = form::get("password", "")
match { password::length() < 8 => { errors::push("Password too short") } }
email = form::get("email", "")
match { !(email ~ /^[^@]+@[^@]+$/) => { errors::push("Invalid email") } }
errors
}
println(validate_form({username: "al", password: "secret", email: "nope"}))
println(validate_form({username: "alice", password: "supersecret", email: "alice@example.com"}))
See Also
- Match Expressions - Pattern matching for branching logic
- Logical Operators - Understanding
&&and|| - Loops - Using guards in loops
- Functions - Guard clauses in functions
Functions
Functions are first-class values in Suji that encapsulate reusable logic and enable functional programming patterns.
Overview
In Suji, functions are treated as values just like numbers or strings. You can pass them as arguments, return them from other functions, and store them in data structures.
Function Basics
Defining Functions
Every function in Suji is a lambda assigned to a name — there is no fn, def or
function declaration keyword:
import std:println
# Basic function
greet = |name| {
"Hello, ${name}!"
}
# Multiple parameters
add = |a, b| {
a + b
}
# No parameters
get_pi = || {
3.14159
}
# Single expression (implicit return)
double = |x| x * 2
# Default parameter value
increment = |x, step = 1| x + step
Calling Functions
import std:println
greet = |name| "Hello, ${name}!"
# Call the function
message = greet("Alice")
println(message) # Hello, Alice!
# Direct call
println(greet("Bob")) # Hello, Bob!
Learn more about Function Basics →
Closures
Functions capture variables from their surrounding scope:
import std:println
make_counter = || {
count = 0
increment = || {
count = count + 1
count
}
increment
}
counter = make_counter()
println(counter()) # 1
println(counter()) # 2
println(counter()) # 3
The returned closure is bound to a name (increment) before it is returned. A line
that begins with | would otherwise be read as the pipeline operator continuing the
previous statement.
Higher-Order Functions
Functions that take functions as parameters or return functions:
import std:println
# Takes a function as parameter
apply_twice = |fn, x| {
fn(fn(x))
}
double = |x| x * 2
println(apply_twice(double, 5)) # 20
# Returns a function
multiplier = |factor| {
|x| x * factor
}
times_3 = multiplier(3)
println(times_3(5)) # 15
Learn more about Higher-Order Functions →
Function Composition
Combine functions to create new functions:
import std:println
add_1 = |x| x + 1
times_2 = |x| x * 2
# Compose: (x + 1) * 2
composed = add_1 >> times_2
println(composed(5)) # 12
# Reverse: (x * 2) + 1
reversed = add_1 << times_2
println(reversed(5)) # 11
Learn more about Function Composition →
Recursion
Functions that call themselves. Note that every match arm whose body is a bare
expression needs a trailing comma, including the last one:
import std:println
# Factorial
factorial = |n| {
match n {
0 => 1,
_ => n * factorial(n - 1),
}
}
println(factorial(5)) # 120
# Accumulator version — the same depth of recursion, just a different shape
factorial_acc = |n| {
helper = |n, acc| {
match n {
0 => acc,
_ => helper(n - 1, n * acc),
}
}
helper(n, 1)
}
println(factorial_acc(5)) # 120
Suji has no tail-call optimisation, so recursion is limited by the native stack — see Recursion for the depth you can rely on.
Multiple Return Values
Use tuples to return multiple values:
import std:println
divide_with_remainder = |a, b| {
quotient = (a / b)::floor()
return quotient, a % b
}
q, r = divide_with_remainder(17, 5)
println("${q} remainder ${r}") # 3 remainder 2
Destructuring targets are written without parentheses (q, r = …); a line that starts
with ( is read as a call on the previous expression.
Learn more about Multiple Returns →
Common Patterns
Partial Application
There is no partial-application operator; return a lambda from a lambda instead:
import std:println
greet_with = |greeting| {
|name| "${greeting}, ${name}!"
}
hello = greet_with("Hello")
hi = greet_with("Hi")
println(hello("Alice")) # Hello, Alice!
println(hi("Bob")) # Hi, Bob!
Currying
import std:println
# Curried function
curry_add = |a| |b| |c| a + b + c
# Partial application
add_1 = curry_add(1)
add_1_2 = add_1(2)
result = add_1_2(3)
println(result) # 6
Function Pipeline
import std:println
validate = |data| data::filter(|x| x != nil)
transform = |data| data::map(|x| x * 2)
enrich = |data| data + [0]
process_data = |data| {
validated = validate(data)
transformed = transform(validated)
enrich(transformed)
}
println(process_data([1, nil, 3])) # [2, 6, 0]
Memoization
A closure over a cache map turns an expensive recursive function into a fast one:
import std:println
fib_cache = {0: 0, 1: 1}
fib = |n| {
fib_cache::contains(n) && return fib_cache[n]
result = fib(n - 1) + fib(n - 2)
fib_cache[n] = result
result
}
println(fib(60)) # 1548008755920
Function Types
Pure Functions
No side effects, same input always produces same output:
# Pure: depends only on parameters
add = |a, b| a + b
# Pure: deterministic
double = |x| x * 2
Impure Functions
Have side effects or depend on external state:
import std:println
# Impure: side effect (printing)
log = |message| {
println(message)
}
# Impure: depends on external state
counter = 0
increment = || {
counter = counter + 1
counter
}
Anonymous Functions (Lambdas)
Inline functions without names:
import std:println
numbers = [1, 2, 3, 4, 5]
# Anonymous function in map
doubled = numbers::map(|x| x * 2)
# Anonymous function in filter
evens = numbers::filter(|x| x % 2 == 0)
Best Practices
DO:
- Keep functions small and focused (single responsibility)
- Use descriptive names (verbs for actions)
- Prefer pure functions when possible
- Use closures for encapsulation
- Leverage higher-order functions for abstraction
DON’T:
- Create functions with too many parameters (>4)
- Mix concerns in a single function
- Forget to handle edge cases
- Create deeply nested functions
- Ignore function return values
Performance Considerations
Function Call Overhead
Every call still walks the interpreter, so inlining simple arithmetic is cheaper than routing it through two closures:
import std:println
double = |x| x * 2
add_1 = |x| x + 1
x = 5
result1 = x * 2 + 1
result2 = add_1(double(x))
println(result1 == result2) # true
Closure Capture
A closure keeps a reference to the scope it was created in, so captured variables stay live (and stay writable) for as long as the closure does:
make_adder = |x| {
|y| x + y
}
Recursion Depth
There is no tail-call optimisation: a tail-recursive shape uses exactly as much stack as any other recursion, and a few hundred nested calls will overflow the native stack and abort the process. Use a loop when the depth depends on input size:
import std:println
# Recursive: one stack frame per element
sum_recursive = |n| {
match n {
0 => 0,
_ => n + sum_recursive(n - 1),
}
}
# Iterative: constant stack, any size
sum_iterative = |n| {
total = 0
loop through 1..=n with i {
total = total + i
}
total
}
println(sum_recursive(100)) # 5050
println(sum_iterative(10000)) # 50005000
Common Use Cases
Callbacks
import std:println
read_config = |path, callback| {
contents = `cat ${path}`
callback(contents)
}
path = `mktemp`
`printf 'debug=true' > ${path}`
read_config(path, |data| {
println("Received: ${data}") # Received: debug=true
})
Event Handlers
Store handlers in a map and call them when the event happens:
import std:println
handlers = {}
on = |event, handler| {
handlers[event] = handler
}
emit = |event| {
handler = handlers::get(event)
match { handler != nil => { handler() } }
}
on("click", || println("Button clicked!"))
emit("click") # Button clicked!
emit("scroll") # nothing registered, nothing happens
Factory Functions
import std:println
create_user = |name, age| {
user = {
name: name,
age: age,
greet: || "Hello, I'm ${name}",
is_adult: || age >= 18,
}
user
}
user = create_user("Alice", 30)
println(user:greet()) # Hello, I'm Alice
println(user:is_adult()) # true
The map is bound to a name before being returned: a { … } in statement position is
parsed as a block, not as a map literal.
Middleware Pattern
import std:println
# Middleware function
with_logging = |handler| {
|request| {
println("Request: ${request}")
result = handler(request)
println("Response: ${result}")
result
}
}
# Base handler
handle_request = |request| {
"Processed: ${request}"
}
# Wrapped handler
logged_handler = with_logging(handle_request)
println(logged_handler("GET /"))
# Request: GET /
# Response: Processed: GET /
# Processed: GET /
Quick Reference
| Concept | Syntax | Example |
|---|---|---|
| Basic function | name = |params| { body } | add = |a, b| a + b |
| Default value | |param = expr| | inc = |x, step = 1| x + step |
| Call function | name(args) | add(5, 3) |
| Closure | Captures outer scope | make_counter = || { ... } |
| Higher-order | Takes/returns a function | xs::map(|x| x * 2) |
| Composition | >>, << | f >> g |
| Recursion | Calls itself by name | fib = |n| ... fib(n - 1) |
| Multiple returns | return a, b then a, b = f() | return result, nil |
| Anonymous | Lambda | |x| x * 2 |
Next Steps
Dive deep into each function concept:
- Function Basics - Parameters, returns, calling
- Closures - Variable capture, scope
- Higher-Order Functions - Functions as values
- Recursion - Self-referential functions
- Multiple Returns - Returning tuples
- Function Composition - Combining functions with
>>and<<
See Also
Function Basics
Learn the fundamentals of defining, calling, and using functions in Suji.
Defining Functions
Functions are created with the pipe syntax |parameters| { body } and bound to a name
with =. There is no separate declaration keyword:
import std:println
import std:time
# Basic function
greet = |name| {
"Hello, ${name}!"
}
# Multiple parameters
add = |a, b| {
a + b
}
# No parameters
get_timestamp = || {
time:now():epoch_ms
}
# Single expression (no braces needed)
double = |x| x * 2
square = |x| x * x
Calling Functions
import std:println
greet = |name| "Hello, ${name}!"
add = |a, b| a + b
double = |x| x * 2
# Direct call
println(greet("Alice")) # Hello, Alice!
# Store result
message = greet("Bob")
println(message) # Hello, Bob!
# Chain calls
result = double(add(3, 4))
println(result) # 14
Parameters
Required Parameters
Every parameter without a default must be provided. Arity is checked at call time, so
a missing argument is a runtime error, not nil:
import std:println
calculate = |x, y, operation| {
match operation {
"add" => x + y,
"subtract" => x - y,
"multiply" => x * y,
"divide" => x / y,
_ => 0,
}
}
println(calculate(10, 5, "add")) # 15
# calculate(10, 5)
# => Arity mismatch: Function expects 3 arguments, got 2
Default Values
Give a parameter a default with = in the parameter list. The default is used when the
argument is omitted:
import std:println
greet = |name, title = "Mr./Ms."| "${title} ${name}"
println(greet("Alice", "Dr.")) # Dr. Alice
println(greet("Bob")) # Mr./Ms. Bob
Passing nil explicitly passes nil — it does not fall back to the default. Handle
that case with match if callers may supply nil:
import std:println
greet = |name, title| {
label = match title {
nil => "Mr./Ms.",
_ => title,
}
"${label} ${name}"
}
println(greet("Alice", "Dr.")) # Dr. Alice
println(greet("Bob", nil)) # Mr./Ms. Bob
A bare identifier in a match pattern is a string literal, not a binding, so the
fallback arm has to be _ and read the parameter directly.
Variable Arguments Pattern
There are no variadic parameters. Accept a list instead:
import std:println
sum_all = |numbers| {
numbers::fold(0, |acc, x| acc + x)
}
println(sum_all([1, 2, 3])) # 6
println(sum_all([1, 2, 3, 4, 5])) # 15
Return Values
Implicit Return
Last expression is automatically returned:
import std:println
add = |a, b| {
a + b # Returned automatically
}
println(add(3, 4)) # 7
Explicit Return
Use return for early exit:
import std:println
find_first = |list, predicate| {
loop through list with item {
predicate(item) && return item # Early return
}
nil # Not found
}
numbers = [1, 2, 3, 4, 5]
println(find_first(numbers, |x| x > 3)) # 4
Multiple Return Values
Return several values with return a, b, and destructure them without parentheses:
import std:println
divide_with_remainder = |a, b| {
return (a / b)::floor(), a % b
}
quotient, remainder = divide_with_remainder(17, 5)
println("${quotient} remainder ${remainder}") # 3 remainder 2
No Return Value
Functions can perform side effects without returning:
import std:println
log_message = |message| {
println("[LOG] ${message}")
# No explicit return (returns nil implicitly)
}
log_message("Application started")
Function Scope
Local Variables
Variables defined in functions are local:
import std:println
calculate = |x| {
temp = x * 2
result = temp + 10
result
}
println(calculate(5)) # 20
# temp and result are not accessible here
Capturing Outer Variables
Functions can access variables from outer scope:
import std:println
multiplier = 10
scale = |x| {
x * multiplier # Accesses outer variable
}
println(scale(5)) # 50
Function as Values
Functions are first-class values:
import std:println
# Store in variable
double = |x| x * 2
# Store in list
operations = [
|x| x + 1,
|x| x * 2,
|x| x ^ 2,
]
times_2 = operations[1]
println(times_2(5)) # 10 (second function)
# Store in map
math_ops = {
add: |a, b| a + b,
sub: |a, b| a - b,
mul: |a, b| a * b,
}
println(math_ops:add(3, 4)) # 7
Common Patterns
Predicate Functions
Return boolean:
import std:println
is_even = |x| x % 2 == 0
is_positive = |x| x > 0
is_adult = |age| age >= 18
println(is_even(4)) # true
println(is_positive(-5)) # false
println(is_adult(25)) # true
Transformer Functions
Transform input to output:
import std:println
to_upper = |text| text::upper()
trim_and_lower = |text| text::trim()::lower()
add_prefix = |text| "PREFIX_${text}"
println(to_upper("hello")) # HELLO
Validator Functions
Validate and return result:
import std:println
validate_email = |email| {
missing = email == nil || email::length() == 0
missing && return false, "Email is required"
!(email ~ /^[^@]+@[^@]+$/) && return false, "Invalid email format"
return true, nil
}
valid, error = validate_email("test@example.com")
println(valid) # true
ok, why = validate_email("not-an-email")
println(why) # Invalid email format
Note the explicit return true, nil at the end: a line starting with ( would be
parsed as a call on the previous line’s value.
Best Practices
DO:
- Use descriptive verb names (calculate, validate, transform)
- Keep functions small and focused
- Document complex behavior
- Handle edge cases
- Return consistent types
DON’T:
- Create functions with too many parameters
- Mix different concerns
- Use cryptic abbreviations
- Ignore error cases
- Create side effects unexpectedly
See Also
Closures
Closures are functions that capture and remember variables from their surrounding scope.
Overview
A closure “closes over” variables from its outer scope, allowing the function to access those variables even after the outer scope has finished executing.
Basic Closure
import std:println
outer = || {
message = "Hello"
# Inner function captures 'message'
inner = || {
println(message)
}
inner
}
fn = outer()
fn() # Prints: Hello
Capturing Variables
import std:println
make_greeter = |greeting| {
# Returns function that captures 'greeting'
|name| "${greeting}, ${name}!"
}
hello = make_greeter("Hello")
hi = make_greeter("Hi")
println(hello("Alice")) # Hello, Alice!
println(hi("Bob")) # Hi, Bob!
Counter Example
Classic closure use case:
import std:println
make_counter = || {
count = 0
increment = || {
count = count + 1
count
}
increment
}
counter1 = make_counter()
counter2 = make_counter()
println(counter1()) # 1
println(counter1()) # 2
println(counter2()) # 1 (separate counter)
println(counter1()) # 3
The inner closure is bound to increment before being returned. A statement that
begins with | is read as the pipeline operator continuing the previous statement, so
a returned lambda always needs a name (or has to be the first thing in the body).
Multiple Closures Sharing State
import std:println
make_account = |initial_balance| {
balance = initial_balance
account = {
deposit: |amount| {
balance = balance + amount
balance
},
withdraw: |amount| {
match {
amount <= balance => {
balance = balance - amount
balance
},
_ => nil,
}
},
get_balance: || balance,
}
account
}
account = make_account(100)
println(account:deposit(50)) # 150
println(account:withdraw(30)) # 120
println(account:get_balance()) # 120
All three closures share the same balance, and the map has to be bound to a name
before it is returned — a { … } in statement position is parsed as a block.
Partial Application
Use closures for partial application:
import std:println
multiply = |a, b| a * b
# Create specialized function
double = |x| multiply(2, x)
triple = |x| multiply(3, x)
println(double(5)) # 10
println(triple(5)) # 15
# Or with closure
make_multiplier = |factor| {
|x| multiply(factor, x)
}
times_10 = make_multiplier(10)
println(times_10(5)) # 50
Configuration Pattern
import std:println
create_formatter = |config| {
prefix = config::get("prefix", "")
suffix = config::get("suffix", "")
uppercase = config::get("uppercase", false)
format = |text| {
result = text
match { uppercase => { result = result::upper() } }
"${prefix}${result}${suffix}"
}
format
}
formatter = create_formatter({
prefix: "[",
suffix: "]",
uppercase: true,
})
println(formatter("hello")) # [HELLO]
Closure Scope Chain
Closures can access multiple levels of scope:
import std:println
outer = |x| {
middle = |y| {
inner = |z| {
# Accesses all three scopes
x + y + z
}
inner
}
middle
}
fn = outer(1)(2)
println(fn(3)) # 6
Memoization with Closures
import std:println
memoize = |fn| {
cache = {}
cached = |arg| {
cache::contains(arg) && return cache[arg]
println("Computing for ${arg}")
result = fn(arg)
cache[arg] = result
result
}
cached
}
expensive = |n| {
# Simulate expensive computation
n * n
}
fast = memoize(expensive)
println(fast(5)) # Computing for 5, then 25
println(fast(5)) # 25 straight from the cache
Event Handlers
import std:println
create_button = |label| {
click_count = 0
button = {
label: label,
on_click: || {
click_count = click_count + 1
println("${label} clicked ${click_count} times")
},
}
button
}
button = create_button("Submit")
button:on_click() # Submit clicked 1 times
button:on_click() # Submit clicked 2 times
Common Patterns
Factory Function
import std:println
create_validator = |rules| {
check = |value| {
loop through rules with rule {
!rule(value) && return false
}
true
}
check
}
is_valid_password = create_validator([
|p| p::length() >= 8,
|p| p ~ /[A-Z]/,
|p| p ~ /[0-9]/,
])
println(is_valid_password("Abc123")) # false
println(is_valid_password("Abc12345")) # true
Module Pattern
import std:println
create_module = || {
# Private state
private_data = "secret"
# Public interface
api = {
get_public: || "public data",
process: |input| {
# Can access private_data
"${input} + ${private_data}"
},
}
api
}
module = create_module()
println(module:get_public()) # public data
println(module:process("test")) # test + secret
# private_data is not accessible
Performance Considerations
Closure Creation Cost
Creating a closure only records the current scope, so factories are cheap to call:
make_adder = |x| {
|y| x + y
}
A lambda that is the first thing in the body does not need a name — it is only a statement after another expression that would be misread as a pipeline.
Shared Scope
A closure holds a reference to the whole scope it was created in, not a copy of individual variables. Everything defined in that scope stays alive as long as the closure does, and writes are visible in both directions:
import std:println
outer = || {
shared = "first"
read = || shared
shared = "second"
read
}
read = outer()
println(read()) # second
Best Practices
DO:
- Use closures for encapsulation
- Return closures for configuration
- Use for event handlers and callbacks
- Create factory functions with closures
- Bind a returned closure to a name before returning it
DON’T:
- Keep large values alive in a captured scope longer than needed
- Create deeply nested closures
- Use closures for simple operations
- Forget captured variables are shared
- Mutate captured state unexpectedly
See Also
Higher-Order Functions
Higher-order functions are functions that take other functions as parameters or return functions as results.
Overview
Higher-order functions enable powerful abstraction and code reuse by treating functions as first-class values.
Functions as Parameters
Basic Example
import std:println
apply_twice = |fn, x| {
fn(fn(x))
}
double = |x| x * 2
increment = |x| x + 1
println(apply_twice(double, 3)) # 12 (double(double(3)))
println(apply_twice(increment, 5)) # 7 (increment(increment(5)))
Map
Transform each element in a collection:
import std:println
numbers = [1, 2, 3, 4, 5]
squared = numbers::map(|x| x * x)
println(squared) # [1, 4, 9, 16, 25]
Filter
Keep only elements that match a predicate:
import std:println
numbers = [1, 2, 3, 4, 5, 6]
evens = numbers::filter(|x| x % 2 == 0)
println(evens) # [2, 4, 6]
Fold
Combine elements into a single value with fold(initial, fn). This is the only
reducing method — there is no list::reduce():
import std:println
numbers = [1, 2, 3, 4, 5]
sum = numbers::fold(0, |acc, x| acc + x)
product = numbers::fold(1, |acc, x| acc * x)
println(sum) # 15
println(product) # 120
map, filter and fold are the built-in higher-order list methods. Anything else
(each, find, any, all, sort_by, group_by, …) you write yourself with a
loop through, as shown below.
Functions as Return Values
Function Factories
import std:println
make_multiplier = |factor| {
|x| x * factor
}
times_2 = make_multiplier(2)
times_10 = make_multiplier(10)
println(times_2(5)) # 10
println(times_10(5)) # 50
Configurable Functions
import std:println
create_validator = |min_length, pattern| {
check = |input| {
long_enough = input::length() >= min_length
matches = input ~ pattern
long_enough && matches
}
check
}
validate_username = create_validator(3, /^[a-zA-Z0-9_]+$/)
validate_password = create_validator(8, /^.*[A-Z].*[0-9].*$/)
println(validate_username("abc")) # true
println(validate_password("Pass1")) # false (too short)
Common Higher-Order Functions
ForEach
There is no list::each(); loop through is the way to run a function for every
element, and it wraps up neatly as a helper:
import std:println
for_each = |list, fn| {
loop through list with item {
fn(item)
}
}
names = ["Alice", "Bob", "Charlie"]
for_each(names, |name| println("Hello, ${name}!"))
Find
list::find() does not exist either. Write it with an early return:
import std:println
find = |list, predicate| {
loop through list with item {
predicate(item) && return item
}
nil
}
numbers = [1, 3, 5, 8, 10]
first_even = find(numbers, |x| x % 2 == 0)
println(first_even) # 8
Any / All
Same again — any and all are helpers you define, not methods:
import std:println
any = |list, predicate| {
loop through list with item {
predicate(item) && return true
}
false
}
all = |list, predicate| {
loop through list with item {
!predicate(item) && return false
}
true
}
numbers = [2, 4, 6, 8]
println(any(numbers, |x| x > 5)) # true
println(all(numbers, |x| x % 2 == 0)) # true
Sort By
There is no built-in list::sort_by(). If you need ordering by a key, write a small helper.
import std:println
insert_sorted = |list, item, key_fn| {
out = []
inserted = false
loop through list with existing {
match { !inserted && key_fn(item) < key_fn(existing) => {
out::push(item)
inserted = true
} }
out::push(existing)
}
match { inserted == false => { out::push(item) } }
out
}
sort_by = |list, key_fn| {
result = []
loop through list with item {
result = insert_sorted(result, item, key_fn)
}
result
}
users = [
{name: "Alice", age: 30},
{name: "Bob", age: 25},
{name: "Charlie", age: 35},
]
by_age = sort_by(users, |u| u:age)
println(by_age[0]:name) # Bob (youngest)
list::sort() sorts numbers and strings directly; the helper above is only needed when
you sort by a derived key.
Group By
Group elements by key function:
import std:println
group_by = |list, key_fn| {
result = {}
loop through list with item {
key = key_fn(item)
group = result::get(key, [])
group::push(item)
result[key] = group
}
result
}
users = [
{name: "Alice", role: "admin"},
{name: "Bob", role: "user"},
{name: "Charlie", role: "admin"},
]
by_role = group_by(users, |u| u:role)
println(by_role:admin::length()) # 2
Function Combinators
Compose
Combine two functions. Suji has built-in composition operators (>> and <<), but
writing compose by hand shows what they do:
import std:println
compose = |f, g| {
|x| f(g(x))
}
add_1 = |x| x + 1
times_2 = |x| x * 2
# (x + 1) * 2
add_then_multiply = compose(times_2, add_1)
println(add_then_multiply(5)) # 12
# The same thing with the operator
println((add_1 >> times_2)(5)) # 12
Pipe
Apply value through functions:
import std:println
pipe = |x, functions| {
result = x
loop through functions with fn {
result = fn(result)
}
result
}
result = pipe(5, [
|x| x + 1,
|x| x * 2,
|x| x ^ 2,
])
println(result) # 144
Partial
There are no variadic parameters, so a generic partial is not expressible. Bind the
known arguments in a closure with the arity you actually need:
import std:println
add_three = |a, b, c| a + b + c
partial_1 = |fn, first| {
rest = |b, c| fn(first, b, c)
rest
}
add_5_and = partial_1(add_three, 5)
println(add_5_and(3, 7)) # 15
Practical Examples
Data Pipeline
import std:println
process_data = |data, transformers| {
result = data
loop through transformers with transform {
result = transform(result)
}
result
}
data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
result = process_data(data, [
|nums| nums::filter(|x| x % 2 == 0),
|nums| nums::map(|x| x * x),
|nums| nums::fold(0, |acc, x| acc + x)
])
println(result) # 220
Validation Pipeline
import std:println
validate_all = |value, validators| {
loop through validators with validator {
valid, error = validator(value)
!valid && return false, error
}
return true, nil
}
valid, error = validate_all("test@example.com", [
|v| match { v != nil => (true, nil), _ => (false, "Required"), },
|v| match { v::length() > 0 => (true, nil), _ => (false, "Not empty"), },
|v| match { v ~ /@/ => (true, nil), _ => (false, "Invalid email"), },
])
println(valid) # true
println(error) # nil
Retry Logic
A function that returns a (value, error) pair can be retried by a higher-order
wrapper. Suji has no exceptions, so the wrapped function reports failure in its return
value rather than raising:
import std:println
retry = |fn, max_attempts| {
attempt = 1
loop {
result, error = fn()
done = error == nil || attempt >= max_attempts
done && return result, error
attempt = attempt + 1
}
}
# A call that fails the first two times
attempts = 0
flaky = || {
attempts = attempts + 1
match {
attempts < 3 => (nil, "temporary failure"),
_ => ("payload", nil),
}
}
result, error = retry(flaky, 5)
println(result) # payload
println(attempts) # 3
Best Practices
DO:
- Use higher-order functions for abstraction
- Prefer the built-in methods (
map,filter,fold) over hand-written loops - Name function parameters descriptively
- Keep functions pure when possible
- Use lambdas for simple transformations
DON’T:
- Overuse higher-order functions
- Create deeply nested function calls
- Ignore performance implications
- Make functions too abstract
- Forget about readability
See Also
Recursion
Recursion occurs when a function calls itself to solve a problem by breaking it into smaller sub-problems.
A function recurses through the name it is assigned to, and every match arm whose
body is a bare expression needs a trailing comma — including the last arm.
Depth limit: Suji has no tail-call optimisation. Each call consumes a native stack frame, and a few hundred nested calls overflow the stack and abort the process. The exact ceiling depends on how much each frame holds — a simple
1 + f(n - 1)recursion survives 600 levels but not 700, and a two-argument accumulator survives 700 but not 900. Recursion is for bounded, shallow work; use aloopwhen the depth grows with the size of the input.
Basic Recursion
import std:println
# Factorial
factorial = |n| {
match n {
0 => 1,
_ => n * factorial(n - 1),
}
}
println(factorial(5)) # 120
Fibonacci Sequence
import std:println
fib = |n| {
match n {
0 => 0,
1 => 1,
_ => fib(n - 1) + fib(n - 2),
}
}
println(fib(10)) # 55
Accumulator Recursion
Passing an accumulator keeps the intermediate result in a parameter instead of in the pending multiplications:
import std:println
factorial = |n| {
helper = |n, acc| {
match n {
0 => acc,
_ => helper(n - 1, n * acc),
}
}
helper(n, 1)
}
println(factorial(5)) # 120
This is often called tail recursion, but Suji does not optimise it: the accumulator version uses exactly as many stack frames as the version above. Its only advantage here is that the result is complete as soon as the base case is reached.
Recursion vs Iteration
Recursive Sum
import std:println
sum_recursive = |list| {
match list::length() {
0 => 0,
_ => list[0] + sum_recursive(list[1;]),
}
}
println(sum_recursive([1, 2, 3, 4, 5])) # 15
Iterative Sum
import std:println
sum_iterative = |list| {
total = 0
loop through list with n {
total = total + n
}
total
}
println(sum_iterative([1, 2, 3, 4, 5])) # 15
Both print the same answer, but only the iterative version survives a long list:
sum_recursive needs one stack frame per element, so it aborts somewhere past a few
hundred elements.
Common Recursive Patterns
List Processing
import std:println
# Recursive map
map_recursive = |list, fn| {
match list::length() {
0 => [],
_ => [fn(list[0])] + map_recursive(list[1;], fn),
}
}
numbers = [1, 2, 3, 4, 5]
doubled = map_recursive(numbers, |x| x * 2)
println(doubled) # [2, 4, 6, 8, 10]
Tree Traversal
import std:println
# Sum all values in tree
sum_tree = |node| {
match node {
nil => 0,
_ => node:value + sum_tree(node:left) + sum_tree(node:right),
}
}
tree = {
value: 10,
left: {value: 5, left: nil, right: nil},
right: {value: 15, left: nil, right: nil},
}
println(sum_tree(tree)) # 30
Patterns cannot bind values: a bare identifier in a match pattern is a string literal,
so the recursive arm is _ and reads node directly.
Path Finding
import std:println
find_path = |graph, start, goal, visited| {
seen = match visited {
nil => [],
_ => visited,
}
start == goal && return [goal]
seen::contains(start) && return nil
seen::push(start)
neighbors = graph::get(start, [])
loop through neighbors with neighbor {
path = find_path(graph, neighbor, goal, seen)
path != nil && return [start] + path
}
nil
}
graph = {
a: ["b", "c"],
b: ["d"],
c: ["d"],
d: [],
}
println(find_path(graph, "a", "d", nil)) # [a, b, d]
println(find_path(graph, "d", "a", nil)) # nil
Recursive Data Structures
Linked List
import std:println
# Count elements
count_list = |node| {
match node {
nil => 0,
_ => 1 + count_list(node:next),
}
}
list = {value: 1, next: {value: 2, next: {value: 3, next: nil}}}
println(count_list(list)) # 3
Directory Tree
import std:println
# Count all files
count_files = |entry| {
match entry:type {
"file" => 1,
"directory" => {
total = 0
loop through entry:children with child {
total = total + count_files(child)
}
total
}
_ => 0,
}
}
tree = {
type: "directory",
children: [
{type: "file"},
{type: "directory", children: [{type: "file"}, {type: "file"}]},
],
}
println(count_files(tree)) # 3
The _ => 0 arm matters: a match with no matching arm evaluates to nil, which would
break the total + … addition on the next level up.
Mutual Recursion
Functions that call each other:
import std:println
is_even = |n| {
match n {
0 => true,
_ => is_odd(n - 1),
}
}
is_odd = |n| {
match n {
0 => false,
_ => is_even(n - 1),
}
}
println(is_even(4)) # true
println(is_odd(5)) # true
is_even may refer to is_odd before it is defined, because the name is looked up when
the call happens, not when the lambda is created.
Performance Optimization
Memoization
Cache results to avoid recomputation. The cache has to be consulted inside the recursive function, so that every sub-call benefits:
import std:println
fib_cache = {0: 0, 1: 1}
fib = |n| {
fib_cache::contains(n) && return fib_cache[n]
result = fib(n - 1) + fib(n - 2)
fib_cache[n] = result
result
}
println(fib(60)) # 1548008755920
Without the cache, fib(60) would make more calls than you have patience for; with it,
each value is computed once. Note that the recursion still goes 60 frames deep, so this
trick makes the function faster, not deeper.
A generic wrapper is also possible, but it only caches repeated calls at the top level —
the recursive calls inside fib_slow still go to the unmemoized function:
import std:println
memoize = |fn| {
cache = {}
cached = |arg| {
cache::contains(arg) && return cache[arg]
result = fn(arg)
cache[arg] = result
result
}
cached
}
fib_slow = |n| {
match n {
0 => 0,
1 => 1,
_ => fib_slow(n - 1) + fib_slow(n - 2),
}
}
fib_fast = memoize(fib_slow)
println(fib_fast(20)) # 6765 (computed)
println(fib_fast(20)) # 6765 (from the cache)
No Tail-Call Optimization
Suji does not optimise tail calls. Both functions below use one stack frame per element, so both overflow on a long list — the accumulator version is not a way to recurse deeper:
import std:println
# Tail-recursive in shape, but not optimised
sum_tail = |list, acc| {
match list::length() {
0 => acc,
_ => sum_tail(list[1;], acc + list[0]),
}
}
# Plainly recursive
sum_regular = |list| {
match list::length() {
0 => 0,
_ => list[0] + sum_regular(list[1;]),
}
}
small = 1..=50
println(sum_tail(small, 0)) # 1275
println(sum_regular(small)) # 1275
# sum_tail(1..=5000, 0)
# => aborts with "thread 'main' has overflowed its stack"
When the depth follows the size of the input, use list::fold() or a loop:
import std:println
big = 1..=10000
println(big::fold(0, |acc, x| acc + x)) # 50005000
When to Use Recursion
Good Use Cases:
- Tree/graph traversal
- Divide-and-conquer algorithms
- Parsing nested structures
- Mathematical sequences
- Backtracking algorithms
Avoid Recursion For:
- Simple list iteration (use loops)
- Deep recursion (stack overflow risk)
- When iterative solution is clearer
- Performance-critical hot paths
Best Practices
DO:
- Always have base case
- Keep the depth bounded and small (a few hundred frames at most)
- Consider memoization for expensive recursion
- Document recursive logic
- Test with edge cases (empty, single element)
DON’T:
- Forget the base case
- Create infinite recursion — there is no trampolining, it aborts the process
- Use recursion for simple loops
- Expect tail calls to be optimised
- Over-complicate with recursion
Examples
Flatten Nested Lists
import std:println
flatten = |list| {
result = []
loop through list with item {
match {
item::is_list() => { result = result + flatten(item) },
_ => result::push(item),
}
}
result
}
nested = [1, [2, 3], [4, [5, 6]], 7]
println(flatten(nested)) # [1, 2, 3, 4, 5, 6, 7]
Quick Sort
import std:println
quicksort = |list| {
match list::length() {
0 => [],
1 => list,
_ => {
pivot = list[0]
rest = list[1;]
less = rest::filter(|x| x < pivot)
greater = rest::filter(|x| x >= pivot)
quicksort(less) + [pivot] + quicksort(greater)
},
}
}
unsorted = [3, 1, 4, 1, 5, 9, 2, 6, 5]
println(quicksort(unsorted)) # [1, 1, 2, 3, 4, 5, 5, 6, 9]
Generate Permutations
import std:println
permutations = |list| {
match list::length() {
0 => [[]],
_ => {
result = []
i = 0
loop through list with item {
rest = list[0;i] + list[i + 1;]
loop through permutations(rest) with perm {
result::push([item] + perm)
}
i = i + 1
}
result
}
}
}
println(permutations([1, 2, 3]))
# [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
loop through over a list binds one variable, the element. Track the index with a
counter, as above; the two-binding form (with k, v) is for maps only.
See Also
Multiple Return Values
Functions can return multiple values using tuples, enabling elegant error handling and complex result patterns.
Two rules apply throughout this chapter:
- Return several values with
return a, b, which builds a tuple. - Destructure without parentheses:
a, b = f(). A line that begins with(is parsed as a call on the previous expression, so(a, b) = f()does not destructure.
Basic Multiple Returns
import std:println
divide_with_remainder = |a, b| {
quotient = (a / b)::floor()
return quotient, a % b
}
q, r = divide_with_remainder(17, 5)
println("${q} remainder ${r}") # 3 remainder 2
Destructuring Returns
Basic Destructuring
import std:println
get_coordinates = || (10, 20, 30)
x, y, z = get_coordinates()
println("x:${x}, y:${y}, z:${z}") # x:10, y:20, z:30
Ignoring Values
Use underscore to ignore unwanted values:
import std:println
# total, average, min, max
get_stats = || (100, 50, 25, 10)
total, avg, _, _ = get_stats()
println("Total: ${total}, Average: ${avg}") # Total: 100, Average: 50
Result/Error Pattern
Common pattern for error handling:
import std:println
safe_divide = |a, b| {
match { b == 0 => (nil, "Division by zero"), _ => (a / b, nil), }
}
result, error = safe_divide(10, 2)
match error {
nil => println("Result: ${result}"), # Result: 5
_ => println("Error: ${error}"),
}
failed, why = safe_divide(10, 0)
println(why) # Division by zero
Patterns do not bind, so the second arm is _ and reads error directly. This is the
only way to react to a failure: a real division by zero terminates the program, so the
check has to happen before the operation.
Validation Results
import std:println
validate_user = |user| {
# Check multiple conditions
user:name == nil && return false, "Name is required"
user:name::length() < 3 && return false, "Name too short"
user:age < 18 && return false, "Must be 18+"
return true, nil
}
valid, error = validate_user({name: "Alice", age: 30})
match { valid => println("User is valid"), _ => println("Validation error: ${error}"), }
# User is valid
ok, problem = validate_user({name: "Al", age: 30})
println(problem) # Name too short
Parsing Results
import std:println
parse_int = |text| {
# Validate before converting: to_number() on junk is a runtime error
match { text ~ /^\d+$/ => (text::to_number(), nil), _ => (nil, "Invalid integer format"), }
}
value, error = parse_int("123")
match error {
nil => println("Parsed: ${value}"), # Parsed: 123
_ => println("Error: ${error}"),
}
bad, why = parse_int("12x")
println(why) # Invalid integer format
Multiple Validation Results
import std:println
validate_form = |form| {
errors = []
warnings = []
# Collect errors — get() avoids the "Key not found" error on absent keys
email = form::get("email")
phone = form::get("phone")
match { email == nil => errors::push("Email required"), _ => nil, }
match { email != nil && !(email ~ /@/) => errors::push("Invalid email"), _ => nil, }
# Collect warnings
match { phone == nil => warnings::push("Phone recommended"), _ => nil, }
return errors, warnings
}
errors, warnings = validate_form({email: "test@example.com"})
println("Errors: ${errors::length()}") # Errors: 0
println("Warnings: ${warnings::length()}") # Warnings: 1
Complex State Returns
import std:println
process_transaction = |account, amount| {
old_balance = account:balance
new_balance = old_balance + amount
status = match {
new_balance < 0 => "overdrawn",
new_balance < 100 => "low",
_ => "ok",
}
return new_balance, old_balance, status
}
new_bal, old_bal, status = process_transaction({balance: 150}, -75)
println("Old: ${old_bal}, New: ${new_bal}, Status: ${status}")
# Old: 150, New: 75, Status: low
Option Pattern
Represent optional values:
import std:println
find_user = |id| {
users = {
1: {name: "Alice", age: 30},
2: {name: "Bob", age: 25},
}
user = users::get(id)
match user {
nil => (nil, false),
_ => (user, true),
}
}
user, found = find_user(1)
match { found => println("Found: ${user:name}"), _ => println("User not found"), }
# Found: Alice
missing, was_found = find_user(99)
println(was_found) # false
Suji has no Option type; a (value, found) pair is the idiom, and map::get(key)
returns nil instead of raising when the key is absent.
Tuples of Structures
Tuple elements can be any value, including maps:
import std:println
get_user_with_address = || {
person = {name: "Alice", age: 30}
address = {city: "Boston", zip: "02101"}
return person, address
}
user, address = get_user_with_address()
println("${user:name} lives in ${address:city}") # Alice lives in Boston
Common Patterns
Success/Failure
import std:println
try_operation = |input| {
success = input != nil
match {
success => (input::upper(), nil),
_ => (nil, "no input given"),
}
}
data, error = try_operation("payload")
println(data) # PAYLOAD
nothing, problem = try_operation(nil)
println(problem) # no input given
Before/After State
import std:println
update_counter = |current| {
before = current
after = current + 1
return before, after
}
old, new = update_counter(5)
println("Changed from ${old} to ${new}") # Changed from 5 to 6
Min/Max Pair
import std:println
get_range = |list| {
match list::length() {
0 => (nil, nil),
_ => (list::min(), list::max()),
}
}
lowest, highest = get_range([3, 1, 4, 1, 5, 9])
println("Min: ${lowest}, Max: ${highest}") # Min: 1, Max: 9
Split Result
import std:println
partition_by_predicate = |list, pred| {
matching = []
non_matching = []
loop through list with item {
match pred(item) {
true => { matching::push(item) },
false => { non_matching::push(item) },
}
}
return matching, non_matching
}
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
evens, odds = partition_by_predicate(numbers, |x| x % 2 == 0)
println("Evens: ${evens}") # [2, 4, 6, 8, 10]
println("Odds: ${odds}") # [1, 3, 5, 7, 9]
Best Practices
DO:
- Use tuples for multiple related values
- Use (value, error) pattern for operations that can fail
- Destructure immediately when possible
- Use underscore for unwanted values
- Keep tuple size reasonable (2-4 elements)
DON’T:
- Return large tuples (use maps instead)
- Mix unrelated values in tuples
- Forget to check error values
- Overuse tuples when single value suffices
- Create deeply nested tuples
Tuples Are Not Indexable
Destructuring is the only cheap way into a tuple. t[0] is a type error, and there is
no t::get(0) or t::first(); the fallback is to_list(), which copies:
import std:println
function_returning_tuple = || (1, 2)
# Preferred
a, b = function_returning_tuple()
println("${a} ${b}") # 1 2
# Works, but allocates a list
values = function_returning_tuple()::to_list()
println(values[0]) # 1
# values = function_returning_tuple()
# values[0]
# => Type error: Cannot index tuple
Tuples also support length() and to_string().
Examples
HTTP Response Pattern
import std:println
fetch_data = |url| {
# Simulated HTTP response
status = 200
data = {users: ["Alice", "Bob"]}
return status, data, nil
}
status, data, error = fetch_data("https://api.example.com")
match status {
200 => println("Success: ${data}"),
_ => println("Error: ${error}"),
}
# Success: {users: [Alice, Bob]}
Database Query Result
import std:println
query_users = |conditions| {
# Simulated query
rows = [{id: 1, name: "Alice"}, {id: 2, name: "Bob"}]
return rows, rows::length(), false
}
users, total, has_more = query_users({active: true})
println("Found ${total} users") # Found 2 users
Parser Result
import std:println
parse_command = |input| {
parts = input::split()
match parts::length() {
0 => (nil, nil, "Empty command"),
1 => (parts[0], [], nil),
_ => (parts[0], parts[1;], nil),
}
}
cmd, args, error = parse_command("git commit -m message")
println(cmd) # git
println(args) # [commit, -m, message]
See Also
Modules
Modules in Suji are values loaded from files and directories. Most modules are maps (key/value objects), and importing a module brings that exported value into scope.
Quick example
Two files in the same directory. math.si:
export {
PI: 3.14159,
add: |a, b| a + b,
}
main.si:
import std:println
import math
println(math:add(math:PI, 2)) # 5.14159
Run it with suji main.si.
Imports
Import a module (the value exported by a file or directory). The path is a bare
identifier path, never a string — import "./my_module.si" is a parse error:
import my_module
Import a specific item from a module (colon-separated path):
import std:json
import std:json:parse
Alias an imported item:
import std:json:parse as parse_json
Notes:
- Imports are one per statement (no
import a:b, c). - Aliases apply to item imports (paths with at least one
:);import my_module as mis a parse error.
Exports
Each .si file may contain exactly one export statement:
- Map export (module):
export { key: expr, ... } - Expression export (leaf):
export <expr>
See:
Imports
Imports bring modules (and items inside module maps) into the current scope.
Syntax
import <name>
import <name>:<segment>:...:<item>
import <name>:<segment>:...:<item> as <alias>
Where each <name>/<segment>/<item>/<alias> is an identifier. Paths are never quoted:
import "./math.si" and import "math" are parse errors.
Examples
Import a module value (the file math.si next to the importing file):
import math
Import a nested item:
import std:json:parse
import std:json:generate
Alias an imported item:
import std:json:parse as parse_json
The name that is bound is always the last segment of the path (or the alias), so
import std:json:parse gives you parse, not json:parse.
Notes
- Imports are one per statement (write multiple
importlines). - There is no grouped import syntax like
import std:json:parse, generate. - An alias needs a path of two or more segments.
import math as mis a parse error; writeimport lib:math as m, or import normally and rebind withm = math. - Importing something that does not resolve is an error at import time, not at first use.
Exports
Exports define what a .si file provides to importers.
Rules
- Each file may contain exactly one
exportstatement. A second one is a parse error: Multiple export statements found. - A file with no
exportcan still be run directly; it just has nothing to import. - Exports are either:
- Map export:
export { name: expr, ... } - Expression export:
export <expr>
- Map export:
Map export (module)
export {
add: |a, b| a + b,
PI: 3.14159,
}
This file exports a map. Importers can take the whole module (import util, then
util:add(…)) or a single key (import util:add, then add(…)).
Expression export (leaf)
answer = 42
export answer
If this file is answer.si, then import answer binds the number 42 itself. Only map
exports can be indexed with : or imported key by key.
Exporting computed modules
make = || {
return {
inc: |x| x + 1,
double: |x| x * 2,
}
}
export make()
The export expression is evaluated once, when the module is first imported, and the
resulting value is cached for every later import.
See Also
Module Resolution
This chapter describes how import paths are resolved in this repository.
Standard library (std)
std is a special, built-in module root. For example:
import std:println
import std:json
Files and directories
Non-std imports are resolved relative to the importing file’s directory. Path segments
are identifiers separated by :, and the .si extension is always omitted: the file
lib/util.si is imported as lib:util.
Importing a single segment: import one
Resolution attempts:
one.si(file)one/(directory)
If a file is found, the value exported by that file is imported.
If a directory is found, it is imported as a module map built from the .si files within it (recursively).
Importing multiple segments: import one:two:three
Let the path segments be [one, two, three]. Resolution attempts:
- File-as-module, then key lookup:
- Load
one.si. - It must export a map.
- Look up
twoin that map, thenthree, etc.
- Load
- Nested file:
- Load
one/two/three.si(and import its exported value).
- Load
- Nested directory:
- Load
one/two/three/as a directory module map.
- Load
This allows both “module files” (map exports) and “nested file modules” to work naturally.
Examples
File module exporting a map
math.si:
export {
add: |a, b| a + b,
}
main.si:
import std:println
import math:add
println(add(1, 2)) # 3
Nested file module
utils/slug.si:
export |s| s::lower()::replace(" ", "-")
main.si:
import std:println
import utils:slug
println(slug("Hello World")) # hello-world
Directory module
Importing the directory itself yields a map keyed by the basenames it contains. With
utils/ holding format.si and slug.si:
import std:println
import utils
println(utils::keys()) # [format, slug]
println(utils:slug("A B")) # a-b
Module Organization
Organize code using files and directories that mirror your import paths.
File modules
Use a .si file for a small module:
project/
main.si
math.si
From main.si, import it with:
import math
Directory modules
Use directories for larger modules:
project/
main.si
utils/
slug.si
format.si
Import nested modules with colon paths:
import utils:slug
import utils:format
Each directory level is one path segment, so utils/text/slug.si is utils:text:slug.
Imports are resolved relative to the importing file, and there is no way to reach
upwards out of that directory — keep entry points at or above the modules they use.
Guidelines
- Keep modules small and focused.
- Prefer map exports (
export { ... }) for modules you want to index into with:. - Give a module the name you want at the call site: the binding is the last path segment,
and only multi-segment paths can be aliased with
as. - Avoid circular dependencies; split shared helpers into a separate module.
Module System
Modules are loaded when their import statement runs, and each module is evaluated once.
Loading and caching
The first import of a module loads and evaluates it; later imports of the same module —
including imports of individual keys, and imports from other files — reuse the cached
value. Top-level side effects in a module therefore happen exactly once.
import std:println
import std:random
# The import above loaded std:random once; both calls use that same module
println(random:integer(1, 10))
println(random:integer(1, 10))
A local module that prints while loading shows this clearly. Given lib/util.si:
import std:println
println("loading lib/util")
export {
value: 1,
}
importing it twice from the same file still prints once:
import std:println
import lib:util
import lib:util:value
println(value)
# loading lib/util
# 1
Directory modules are maps
Importing a directory produces a map-like module where keys correspond to contained .si files (and subdirectories) by basename.
See: Module Resolution
Standard Library Structure
The standard library is imported under the std module root:
import std:println
import std:json
import std:time
There is no top-level module for these names: import json looks for json.si next to
your file and fails. There is also no prelude — even printing needs
import std:println in every file that prints.
The available modules are print, println, math, os, path, env, io, time,
uuid, encoding, crypto, random, json, yaml, toml, csv and dotenv.
Virtual std
In this repository, std is provided by embedded Suji source files (see crates/suji-stdlib/src/std/*.si). At runtime, the module system resolves std without reading from the filesystem.
__builtins__
There is a special virtual module named __builtins__ that exposes builtin functions implemented by the runtime. The std modules are thin wrappers that delegate to these builtins.
For example, the std:json module delegates to __builtins__:json_parse and __builtins__:json_generate.
import std:json
data = json:parse('{"ok":true}')
text = json:generate(data)
Error Handling Deep Dive
Suji has no error handling construct. There is no try, catch, throw or rescue; there are no Result or Option types, no error values, and no way to trap or recover from a runtime error. Almost every runtime failure prints a diagnostic to stderr and terminates the process with exit status 1. The two exceptions are numeric overflow and stack overflow, which abort even more abruptly, with a Rust panic message and exit status 101 or 134 instead of a diagnostic.
That single fact shapes every technique on this page. Since you cannot recover after a failure, all the work happens before it: you validate, you check, and you design your own functions so that a “soft” failure is an ordinary value your caller can branch on.
What a failure looks like
A failing program stops at the point of the error. Nothing after it runs:
import std:println
println("this line runs")
If the next line were println(10 / 0), the program would print the first line and then die with:
[402] Error: Invalid operation
╭─[ script.si:4:9 ]
│
4 │ println(10 / 0)
│ ───┬──
│ ╰──── Division by zero
│
│ Note: The SUJI language is strongly typed. Check that you're using compatible types
───╯
Error: Invalid operation: Division by zero
The [402] is the numeric error code (see Error taxonomy below). The shell sees exit status 1, which is what makes Suji scripts safe to use in a set -e pipeline: they fail loudly rather than silently.
Validate before you act
The whole discipline is: never perform an operation whose preconditions you have not checked.
Missing map keys
Reading an absent key raises Key not found. Use ::contains(key) to test and ::get(key, default) to read safely:
import std:println
config = { host: "localhost" }
# Safe read with a fallback
println(config::get("port", 5432)) # 5432
# Explicit branch on presence
port = match config::contains("port") {
true => config:port,
false => 5432,
}
println(port) # 5432
config:port on its own would abort the script.
Division by zero
There is no NaN and no Infinity — 1 / 0 is a fatal error. Check the divisor:
import std:println
divide = |a, b| match {
b == 0 => nil,
_ => a / b,
}
println(divide(10, 4)) # 2.50
println(divide(10, 0)) # nil
List indices
An out-of-range index raises Index out of bounds. Compare against length() first:
import std:println
at = |xs, i| match {
i < 0 => nil,
i < xs::length() => xs[i],
_ => nil,
}
xs = [10, 20]
println(at(xs, 1)) # 20
println(at(xs, 5)) # nil
For the common cases, lists already have safe accessors: xs::first(default) and xs::last(default) never fail.
import std:println
println([]::first("none")) # none
println([1, 2]::last(0)) # 2
Strings that must be numbers
"abc"::to_number() is fatal. Validate with a regex first:
import std:println
parse_port = |text| {
!(text ~ /^[0-9]+$/) && return nil
text::to_number()
}
println(parse_port("8080")) # 8080
println(parse_port("80x")) # nil
Files
io:open(path) fails if the file does not exist. Either create it (io:open(path, true)) or check first with os:stat, which is itself only safe on a path you know exists — in practice, ask the shell:
import std:println
path = `mktemp`
exists = `test -f ${path} && echo yes || echo no`
println(match exists {
"yes" => "found",
_ => "missing",
}) # found
Returning nil for soft failures
The simplest convention: a function that can fail returns nil, and the caller matches on it. This works because nil is a first-class value and nil is a valid match pattern.
import std:println
lookup = |users, name| match users::contains(name) {
true => users::get(name),
false => nil,
}
users = { alice: 30 }
report = |name| {
age = lookup(users, name)
return match age {
nil => "${name}: unknown",
_ => "${name}: ${age}",
}
}
println(report("alice")) # alice: 30
println(report("bob")) # bob: unknown
Note that there is no || idiom for defaults: nil || "default" is a type error, because && and || require boolean operands. Use match or ::get(key, default).
The (ok, value) tuple convention
When nil is itself a legitimate result, return a two-element tuple and destructure it at the call site:
import std:println
safe_divide = |a, b| {
b == 0 && return (false, nil)
return (true, a / b)
}
ok, value = safe_divide(10, 4)
println(match ok {
true => "result ${value}",
false => "undefined",
}) # result 2.50
ok2, value2 = safe_divide(1, 0)
println(match ok2 {
true => "result ${value2}",
false => "undefined",
}) # undefined
Two things to watch:
- Write
return (true, a / b)explicitly. A line that begins with(is parsed as a call applied to the previous expression, so a bare(true, a / b)on its own line after another statement is a bug. - Tuples are not indexable. Destructure with
ok, value = f()or uset::to_list().
Guard clauses
&& and || short-circuit, and return, break and continue are usable on their right-hand side. That gives you compact preconditions at the top of a function:
import std:println
validate_user = |user| {
user == nil && return "user is required"
!user::contains("email") && return "email is required"
!(user:email ~ /^[^@]+@[^@]+$/) && return "invalid email"
return "ok"
}
println(validate_user(nil)) # user is required
println(validate_user({ "name": "Alice" })) # email is required
println(validate_user({ "email": "not-an-email" })) # invalid email
println(validate_user({ "email": "a@b.com" })) # ok
Shell commands abort your script
This deserves its own warning, because it is the failure mode that surprises people most.
A backtick command that exits non-zero is a fatal runtime error. There is no way to read the exit status, and no way to recover:
[406] Error: Shell command failed
╭─[ script.si:2:9 ]
│
2 │ println(`false`)
│ ┬
│ ╰── Shell command 'false' failed with exit code 1:
───╯
Error: Shell command failed: Shell command 'false' failed with exit code 1:
Since the command runs through a shell, do the recovery in the shell, and turn the outcome into a string you can match on.
Force success with || true
import std:println
out = `grep nonexistent-pattern /etc/hosts || true`
println("[" + out + "]") # []
grep exits 1 when it finds nothing; || true makes the whole command succeed and yields an empty string.
Turn a status into a value with && / ||
import std:println
path = `mktemp`
status = `test -s ${path} && echo nonempty || echo empty`
println(match status {
"nonempty" => "file has content",
"empty" => "file is empty",
_ => "unknown",
}) # file is empty
Capture stderr if you need it
Only stdout is captured. Redirect if the message matters:
import std:println
out = `ls /definitely/not/here 2>&1 || true`
println(out::length() > 0) # true
Error taxonomy
Diagnostics are printed as [code] Error: Title. The code ranges reflect the phase that failed:
| Range | Phase | Examples |
|---|---|---|
1xx | Lexing | 101 unterminated string, 104 invalid escape, 105 invalid number |
2xx | Parsing | 201 unexpected token, 202 unexpected EOF, 205 expected token |
4xx | Runtime | 400 type error, 401 undefined variable, 402 invalid operation, 404 key not found, 406 shell error, 408 arity mismatch |
Lexer and parser codes mean the file never ran at all — those are typos, and no amount of defensive coding helps. Runtime codes (4xx) are the ones the techniques on this page are designed to prevent. The most common are:
- 400 type error — mixing types, e.g.
"a" + 1ornil || "x" - 401 undefined variable — a name used before assignment, or a missing
import - 402 invalid operation — including division by zero
- 403 index out of bounds / 412 string index error — check
length()first - 404 key not found — use
::get()or::contains() - 406 shell error — a non-zero exit status from a backtick command
- 408 arity mismatch — calling
|a, b|with the wrong number of arguments
The complete list, with every code and its meaning, is in Error Codes.
Common Patterns
Chain of validations, one exit point:
import std:println
check = |record| {
!record::contains("name") && return (false, "missing name")
!record::contains("age") && return (false, "missing age")
!record:age::is_number() && return (false, "age must be a number")
record:age < 0 && return (false, "age must not be negative")
return (true, record:name)
}
ok, detail = check({ "name": "Ada", "age": 36 })
println("${ok} ${detail}") # true Ada
ok2, detail2 = check({ "name": "Ada", "age": "old" })
println("${ok2} ${detail2}") # false age must be a number
Collect failures instead of stopping at the first:
import std:println
rows = ["1", "two", "3", ""]
good = []
bad = []
loop through rows with row {
match { row ~ /^[0-9]+$/ => { good::push(row::to_number()) } _ => { bad::push(row) } }
}
println(good::sum()) # 4
println(bad::length()) # 2
Fail fast on purpose: when the input really is invalid and there is nothing sensible to do, let the error happen, or call os:exit(1) with your own message on stderr.
import std:println
import std:io
report_and_stop = |message| {
println("fatal: ${message}", io:stderr)
# os:exit(1) would end the script here
}
report_and_stop("configuration missing")
println("only reached because exit is commented out")
See Also
- Error Codes — the complete numeric code list
- Match Expressions — the branching construct used throughout this page
- Guards —
&&/||withreturn,breakandcontinue - Maps —
::get()and::contains() - Shell Integration — running commands safely
Pattern Matching Deep Dive
Advanced patterns and best practices for match expressions.
Suji’s match is deliberately small. Patterns are literal shapes, not a destructuring mini-language: there are no binding patterns, no list or map patterns, no ranges and no if guards. Almost everything people reach for those features to do is instead done with the conditional form match { … }, or with plain destructuring assignment before the match. This page covers what patterns really are, the two traps that catch everyone, and the idioms that replace the features Suji does not have.
What a Pattern Can Be
A pattern in the subject form match value { … } is one of:
| Pattern | Example | Matches |
|---|---|---|
| Number literal | 200, -1, 3.5 | that exact number |
| String literal | "admin" | that exact string |
| Boolean literal | true, false | that boolean |
nil | nil | nil |
| Regex literal | /^[0-9]+$/ | a string matching the regex |
| Tuple pattern | (0, 0), ("GET", _) | a tuple whose elements match |
| Alternatives | 200 | 201 | 204 | any one of the alternatives |
| Wildcard | _ | anything |
That is the complete list.
What a Pattern Cannot Be
None of the following exist in Suji, and most of them fail silently rather than loudly:
- Binding patterns.
n => n * 2does not bindn. - List patterns.
[a, b] => …is a parse error. - Map patterns.
{role: "admin"} => …is a parse error. - Range patterns.
1..10 => …does not match a range of values. - Guards. There is no
pattern if condition =>form. - Interpolated strings.
"${prefix}-x" => …is a parse error. - Variables. A name in pattern position is not read as a variable.
The bare-identifier trap
A bare identifier in a pattern is a string literal. This is the single most common misunderstanding, because it produces no error at all — just a silent nil:
import std:println
# Looks like a binding pattern. Is not.
println(match 5 { n => n * 2, }) # nil
# It is really a string literal, so it matches the string "n":
println(match "n" { n => "matched the string n", _ => "no match", }) # matched the string n
The same applies to variables holding regexes: pattern = /^h/ followed by match text { pattern => … } compares against the string "pattern", never the regex.
The comma rule
An arm whose body is a bare expression must be followed by a comma, including the final arm. An arm whose body is a { … } block may omit it.
import std:println
# Correct: trailing comma on every expression arm
println(match 1 { 1 => "one", _ => "other", })
# Correct: block bodies need no commas
println(match 2 { 1 => { "one" } _ => { "other" } })
Writing match x { 1 => "one", _ => "other" } without the final comma is a parse error.
The Two Forms
Subject form
Compares patterns against a value:
import std:println
status_code = 200
println(match status_code {
200 => "OK",
404 => "Not Found",
500 => "Server Error",
_ => "Unknown",
}) # OK
Conditional form
No subject; each arm is a boolean expression, evaluated top to bottom. This is where every “guard” goes:
import std:println
classify = |value| match {
value < 0 => "Negative",
value == 0 => "Zero",
value < 10 => "Small positive",
_ => "Large positive",
}
println(classify(-3)) # Negative
println(classify(0)) # Zero
println(classify(7)) # Small positive
println(classify(99)) # Large positive
The conditional form is strictly more powerful than pattern guards would be: any expression that evaluates to a boolean is allowed, including method calls, regex tests and type predicates. The only rule is that the arm conditions must be booleans — there is no truthiness in Suji, so match { name => … } is a type error.
A match with no matching arm is nil
Non-exhaustive matches are not an error; they evaluate to nil:
import std:println
println(match 42 { 1 => "one", }) # nil
Always add a _ arm unless nil is the answer you want.
Tuple Patterns
Tuple patterns are the one structural pattern Suji has. Elements are themselves patterns, so literals and _ both work:
import std:println
describe = |point| match point {
(0, 0) => "Origin",
(0, _) => "On the Y axis",
(_, 0) => "On the X axis",
_ => "Somewhere else",
}
println(describe((0, 0))) # Origin
println(describe((0, 7))) # On the Y axis
println(describe((3, 0))) # On the X axis
println(describe((3, 4))) # Somewhere else
Note that (x, 0) would not bind x — it would require the first element to be the string "x".
Getting Values Out: Destructure First, Match Second
Since patterns cannot bind, the way to work with the parts of a value is to destructure it before the match, with an ordinary assignment, and then use the conditional form:
import std:println
describe = |point| {
x, y = point
return match {
x == 0 && y == 0 => "Origin",
x == 0 => "On the Y axis at ${y}",
y == 0 => "On the X axis at ${x}",
_ => "Point at (${x}, ${y})",
}
}
println(describe((0, 7))) # On the Y axis at 7
println(describe((3, 4))) # Point at (3, 4)
The same technique replaces list and map patterns. Pull the pieces out with indexing or key access, then branch:
import std:println
user = { "role": "admin", "active": true }
role = user::get("role", "guest")
active = user::get("active", false)
println(match {
role == "admin" && active => "Active admin",
role == "admin" => "Inactive admin",
role == "user" && active => "Active user",
_ => "Unknown user type",
}) # Active admin
Regex Patterns
Regex literals are real patterns and are tested against the subject string:
import std:println
classify = |input| match input {
/^[0-9]+$/ => "Digits only",
/^[a-zA-Z]+$/ => "Letters only",
/^[a-zA-Z0-9]+$/ => "Alphanumeric",
_ => "Mixed characters",
}
println(classify("12345")) # Digits only
println(classify("hello")) # Letters only
println(classify("abc123")) # Alphanumeric
println(classify("a b!")) # Mixed characters
Order matters: /^[a-zA-Z0-9]+$/ would also match "12345", so the narrower patterns come first.
No capture groups
Suji’s regex support is match-only. There are no capture groups, no ::match(), no ::captures() and no regex-based replace or split. To extract the interesting part of a string, match to classify it and then use string methods to slice it:
import std:println
log_lines = ["ERROR: disk full", "WARN: low memory", "hello"]
loop through log_lines with line {
message = match line {
/^ERROR: / => "error -> " + line[7;],
/^WARN: / => "warn -> " + line[6;],
_ => "unrecognised",
}
println(message)
}
# error -> disk full
# warn -> low memory
# unrecognised
Note line[7;] — string and list slices use ;, not :.
Pattern Alternation
| matches any one of several patterns and works with every pattern kind:
import std:println
label = |status| match status {
200 | 201 | 202 => "Success",
400 | 401 | 403 => "Client error",
500 | 502 | 503 => "Server error",
_ => "Other",
}
println(label(201)) # Success
println(label(403)) # Client error
println(label(302)) # Other
For a range of values, which alternation cannot express, use the conditional form:
import std:println
label = |status| match {
status >= 200 && status < 300 => "Success",
status >= 400 && status < 500 => "Client error",
status >= 500 => "Server error",
_ => "Other",
}
println(label(204)) # Success
println(label(451)) # Client error
Ordering
Arms are tried top to bottom and the first match wins, so specific patterns must come before general ones:
import std:println
# Good: specific first
good = |value| match {
value == 0 => "Zero",
value < 10 => "Small",
value < 100 => "Medium",
_ => "Large",
}
# Bad: the general arm swallows the specific one
bad = |value| match {
value < 100 => "Less than 100",
value == 0 => "Zero",
_ => "Other",
}
println(good(0)) # Zero
println(bad(0)) # Less than 100
Complete Example: A Request Router
This is the shape a router actually takes in Suji: exact routes as tuple patterns, everything variable extracted with string methods, and the conditional form for prefix matching.
import std:println
get_user = |id| "user ${id}"
route = |method, path| {
exact = match (method, path) {
("GET", "/") => "home page",
("GET", "/health") => "ok",
("POST", "/users") => "created",
_ => nil,
}
exact != nil && return exact
parts = path::split("/")
is_user_path = method == "GET" && parts::length() == 3 && parts[1] == "users"
return match {
is_user_path && parts[2] ~ /^[0-9]+$/ => get_user(parts[2]),
_ => "404 not found",
}
}
println(route("GET", "/")) # home page
println(route("POST", "/users")) # created
println(route("GET", "/users/42")) # user 42
println(route("GET", "/nope")) # 404 not found
Map Literals as Arm Bodies
A map literal with bare identifier keys is ambiguous with a block, so as a match arm body it is parsed as a block and fails. Quote the keys or wrap the literal in parentheses:
import std:println
response = match 200 {
200 => { "status": 200, "body": "ok" },
_ => ({ status: 500, body: "error" }),
}
println(response:status) # 200
println(response:body) # ok
Best Practices
DO:
- Add a
_arm unless you genuinely wantnilfor unmatched input - Put a trailing comma after every expression-bodied arm, including the last one
- Order arms from specific to general
- Destructure with
a, b = valuebefore the match when you need the parts - Use the conditional form
match { … }for anything involving comparisons, ranges or type predicates - Use tuple patterns for fixed combinations like
(method, path)
DON’T:
- Expect a bare identifier to bind a value — it is a string literal
- Reach for list, map or range patterns; they do not exist
- Put a variable holding a regex in pattern position
- Rely on capture groups; use
split,index_ofand slices instead - Write unreachable arms after a broad one
See Also
- Match Expressions — the basics
- Guards —
&&/||withreturn - Regular Expressions
- Tuples — including destructuring
- Type Checking — predicates for type-based dispatch
Type Checking Methods
Suji provides type checking methods that allow you to determine the type of a value at runtime. These methods are available on all values and return boolean results.
Overview
All types support a set of type checking methods that return true if the value is of the specified type, and false otherwise. These methods are useful for runtime type validation and conditional processing based on type.
Available Methods
All values support these type checking methods:
value::is_number()- returnstrueif value is a numbervalue::is_bool()- returnstrueif value is a booleanvalue::is_string()- returnstrueif value is a stringvalue::is_list()- returnstrueif value is a listvalue::is_map()- returnstrueif value is a mapvalue::is_stream()- returnstrueif value is a streamvalue::is_function()- returnstrueif value is a functionvalue::is_tuple()- returnstrueif value is a tuplevalue::is_regex()- returnstrueif value is a regex
Every type has all of these methods available, and each method returns true only when called on its corresponding type. The nil type also supports all these methods and returns false for all of them.
Numbers have one additional predicate:
number::is_int()- returnstrueif the number has no fractional part
is_int() is only available on numbers; calling it on a string or any other type raises a Method error and terminates the program.
What does not exist
This list is complete. In particular, there is:
- No
is_nil()- test for nil withvalue == nil - No
is_boolean()- the method is calledis_bool() - No
type(),type_of()ortypeof- there is no way to obtain a type as a value; you ask a yes/no question with a predicate instead
Testing for Nil
Because there is no is_nil(), comparisons are how you detect nil. Every predicate returns false for nil, so a chain of predicates falls through to the catch-all arm:
import std:println
describe = |value| match {
value == nil => "nothing",
value::is_number() => "number",
value::is_string() => "string",
_ => "something else",
}
println(describe(nil)) # nothing
println(describe(42)) # number
println(describe("hi")) # string
println(describe([1, 2])) # something else
nil is also a valid pattern in the subject form of match:
import std:println
value = nil
println(match value {
nil => "missing",
_ => "present",
}) # missing
Integer Checks
import std:println
println((42)::is_int()) # true
println((3.5)::is_int()) # false
println((42.0)::is_int()) # true
println((10 / 2)::is_int()) # true
# Guard the check so it only runs on numbers
is_whole = |v| v::is_number() && v::is_int()
println(is_whole(7)) # true
println(is_whole("7")) # false
Basic Usage
import std:println
# Number type checking
x = 42
println(x::is_number()) # true
println(x::is_string()) # false
println(x::is_list()) # false
# String type checking
s = "hello"
println(s::is_string()) # true
println(s::is_number()) # false
println(s::is_map()) # false
# Boolean type checking
b = true
println(b::is_bool()) # true
println(b::is_number()) # false
# List type checking
lst = [1, 2, 3]
println(lst::is_list()) # true
println(lst::is_tuple()) # false
println(lst::is_map()) # false
# Map type checking
m = { a: 1, b: 2 }
println(m::is_map()) # true
println(m::is_list()) # false
# Tuple type checking
t = (1, 2, 3)
println(t::is_tuple()) # true
println(t::is_list()) # false
# Function type checking
f = |x| x + 1
println(f::is_function()) # true
println(f::is_number()) # false
# Stream type checking
import std:io
stream = io:stdout
println(stream::is_stream()) # true
println(stream::is_string()) # false
# Regex type checking
pattern = /^[a-z]+$/
println(pattern::is_regex()) # true
println(pattern::is_string()) # false
println(pattern::is_number()) # false
# Nil type checking
n = nil
println(n::is_number()) # false
println(n::is_string()) # false
println(n::is_list()) # false
println(n::is_map()) # false
println(n::is_tuple()) # false
println(n::is_bool()) # false
println(n::is_function()) # false
println(n::is_stream()) # false
println(n::is_regex()) # false
Common Use Cases
Runtime Type Validation
import std:println
process = |value| {
match {
value::is_number() => value * 2,
value::is_string() => value + " processed",
value::is_list() => value::length(),
_ => nil,
}
}
println(process(10)) # 20
println(process("data")) # data processed
println(process([1, 2, 3])) # 3
Type-Safe Function Parameters
import std:println
safe_divide = |a, b| {
match {
!a::is_number() || !b::is_number() => return nil,
b == 0 => return nil,
_ => a / b,
}
}
println(safe_divide(10, 2)) # 5
println(safe_divide(10, 0)) # nil
println(safe_divide("10", 2)) # nil
Conditional Type Handling
import std:println
format_value = |v| {
match {
v::is_string() => '"' + v + '"',
v::is_number() => v::to_string(),
v::is_bool() => match v {
true => "true",
false => "false",
},
v::is_list() => "[list]",
v::is_map() => "{map}",
v::is_function() => "<function>",
v::is_tuple() => "(tuple)",
v::is_stream() => "<stream>",
v::is_regex() => "<regex>",
_ => "nil",
}
}
println(format_value("hello")) # "hello"
println(format_value(42)) # 42
println(format_value(true)) # true
println(format_value([1, 2])) # [list]
println(format_value(nil)) # nil
Type-Based Dispatch
import std:println
handle = |data| {
match {
data::is_string() => {
println("Processing string: ${data}")
data::upper()
},
data::is_list() => {
println("Processing list with ${data::length()} items")
data::sum()
},
data::is_map() => {
println("Processing map with ${data::length()} keys")
data::keys()::join(", ")
},
_ => {
println("Unknown type")
nil
},
}
}
println(handle("hello")) # Processing string: hello\nHELLO
println(handle([1, 2, 3])) # Processing list with 3 items\n6
println(handle({ a: 1, b: 2 })) # Processing map with 2 keys\na, b
Input Validation
import std:println
validate_input = |input| {
match {
input::is_string() && input::length() > 0 => "Valid string",
input::is_number() && input > 0 => "Valid positive number",
input::is_list() && input::length() > 0 => "Valid non-empty list",
_ => "Invalid input",
}
}
println(validate_input("hello")) # Valid string
println(validate_input("")) # Invalid input
println(validate_input(42)) # Valid positive number
println(validate_input(-5)) # Invalid input
println(validate_input([1, 2])) # Valid non-empty list
println(validate_input([])) # Invalid input
Best Practices
DO:
- Use type checking methods for runtime validation
- Combine with match expressions for type-based dispatch
- Check types before performing type-specific operations
- Use type checks for input validation
DON’T:
- Overuse type checking (Suji is dynamically typed)
- Check types unnecessarily when types are already known
- Look for
is_nil(),is_boolean()ortype()- they do not exist - Call
is_int()on a value you have not already confirmed is a number
Implementation Notes
- Type checking methods are available on all values, including
nil - Each method performs a runtime type check and returns a boolean result
is_int()is the only predicate restricted to a single type (numbers)- Because there is no way to recover from a runtime error, predicates are the main tool for keeping a program on a valid path - check first, then operate
See Also
- Data Types - Overview of all Suji types
- Match Expressions - Pattern matching with type checks
- Nil Type - Handling nil values
- Error Handling - Validating before you act
String Interpolation
Embedding values and expressions directly inside string literals with ${…}.
Overview
Suji has exactly one interpolation form: ${expression}. It works in every kind of string literal and inside backtick shell templates, it accepts any expression (not just a variable name), and it converts the result with that value’s to_string() behaviour.
Key Characteristics
- One syntax -
${expr};$varwithout braces is plain text - Any expression - arithmetic, method calls, indexing, even a nested
match - Everywhere strings are -
"…",'…',"""…""",'''…'''and`…` - Automatic conversion - values are rendered with their
to_string()form - Escapable -
\$produces a literal dollar sign
Syntax
import std:println
name = "Ada"
year = 1843
println("Hello, ${name}!") # Hello, Ada!
println("Published in ${year}.") # Published in 1843.
println("${name} in ${year + 100}") # Ada in 1943
Single quotes behave identically — unlike some languages, '…' is not a raw string:
import std:println
name = "Ada"
println('Single quotes interpolate too: ${name}') # Single quotes interpolate too: Ada
$var is not interpolation
Only the braced form is recognised. A bare $ followed by a name is ordinary text:
import std:println
name = "Ada"
println("$name") # $name
println("${name}") # Ada
Escaping a literal $
Use \$ when the character before { really should be a dollar sign:
import std:println
amount = 42
println("Total: \$${amount}") # Total: $42
println("A literal \${not interpolated}") # A literal ${not interpolated}
Any Expression Works
The contents of ${…} are parsed as a full expression, so method calls, indexing, key access and function calls are all fair game:
import std:println
name = "ada lovelace"
scores = [90, 80, 70]
user = { profile: { city: "London" } }
double = |x| x * 2
println("Name: ${name::upper()}") # Name: ADA LOVELACE
println("Best: ${scores::max()}") # Best: 90
println("Second: ${scores[1]}") # Second: 80
println("Average: ${scores::average()}") # Average: 80
println("City: ${user:profile:city}") # City: London
println("Doubled: ${double(21)}") # Doubled: 42
println("Length: ${name::length()} characters") # Length: 12 characters
Even a match expression can be interpolated, which is the closest Suji gets to a conditional inside a template:
import std:println
count = 3
println("You have ${count} ${match { count == 1 => "item", _ => "items", }}")
# You have 3 items
Nested interpolation
An interpolated expression may itself contain a string with interpolation:
import std:println
name = "Ada"
println("outer: ${"inner: ${name}"}") # outer: inner: Ada
This is legal but hard to read; prefer building the inner string in a variable first.
How Values Are Rendered
Interpolation uses the same conversion as ::to_string(). There is no formatting mini-language — no field widths, no precision specifiers, no alignment.
import std:println
println("number: ${42}") # number: 42
println("scaled: ${1.50}") # scaled: 1.50
println("bool: ${true}") # bool: true
println("nil: ${nil}") # nil: nil
println("list: ${[1, 2, 3]}") # list: [1, 2, 3]
println("map: ${{a: 1}}") # map: {a: 1}
println("tuple: ${(1, 2)}") # tuple: (1, 2)
Note that numbers keep their scale: 1.50 renders as 1.50, not 1.5.
Since strings and numbers cannot be added ("a" + 1 is a type error), interpolation is usually the shortest way to join them. The explicit alternative is ::to_string():
import std:println
count = 7
println("Count: ${count}") # Count: 7
println("Count: " + count::to_string()) # Count: 7
Rounding and padding by hand
Because there is no format specifier, numeric presentation is done with methods:
import std:println
ratio = 22 / 7
println("rounded: ${ratio::round()}") # rounded: 3
println("floored: ${ratio::floor()}") # floored: 3
println("padded: ${"0"::repeat(3)}${7}") # padded: 0007
println("percent: ${(0.256 * 100)::round()}%") # percent: 26%
Escape Sequences
Interpolation shares the string lexer, so the same escape rules apply everywhere.
| Escape | Produces |
|---|---|
\n | newline |
\t | tab |
\r | carriage return |
\" | double quote |
\' | single quote |
\` | backtick |
\\ | backslash |
\$ | dollar sign |
That is the complete list. Any other escape — notably \u0041, \u{1F600}, \0 and \e — is a lex error ([104] Invalid escape sequence) and the file will not even parse. There are no Unicode escapes and no raw strings; to put a non-ASCII character in a string, type the character itself.
import std:println
println("tab:\tdone") # tab: done
println("quote: \" and \\") # quote: " and \
println("emoji: ✨ é 日本") # emoji: ✨ é 日本
Multi-Line Templates
Triple-quoted strings ("""…""" and '''…''') span lines and interpolate the same way, which makes them the natural fit for reports, messages and generated files:
import std:println
name = "Ada"
items = 3
price = 12.50
receipt = """Dear ${name},
You ordered ${items} item(s).
Total: ${(items * price)::to_string()}
Thank you!"""
println(receipt)
That prints:
Dear Ada,
You ordered 3 item(s).
Total: 37.50
Thank you!
Everything between the delimiters is preserved literally, so a newline right after the opening """ becomes a leading blank line. Start the content on the same line as the delimiter when you do not want one.
Interpolation in Shell Templates
Backtick shell templates interpolate too, and this is by far the most common way to build a command:
import std:println
word = "hello"
println(`echo ${word}`) # hello
println(`printf '%s-%s\n' a ${word}`) # a-hello
Quote what you interpolate
Interpolation is plain text substitution into the command line, performed before the shell parses it. An interpolated value containing shell metacharacters therefore becomes shell syntax:
import std:println
untrusted = "safe; echo INJECTED"
println(`echo ${untrusted}`)
# safe
# INJECTED
The ; was interpreted by the shell and a second command ran. Wrapping the interpolation in double quotes prevents that:
import std:println
untrusted = "safe; echo INJECTED"
println(`echo "${untrusted}"`) # safe; echo INJECTED
Rules of thumb:
- Always put
"${…}"in double quotes inside a shell template - Prefer values you produced yourself over values from
env:varor file input - For paths, quoting also handles spaces:
`ls "${dir}"` - Values containing a double quote still need care; validate with a regex first when the value is untrusted
import std:println
path = `mktemp`
label = "my report"
`printf '%s\n' "${label}" > "${path}"`
println(`cat "${path}"`) # my report
Where Interpolation Does Not Work
Two places look like they should interpolate and do not:
- Regex literals.
/${var}/is not interpolated; the regex engine sees the literal characters${var}and raises[407] Regex error. Regex patterns must be written out in full. - Match patterns.
match x { "${prefix}-1" => … }is a parse error. Patterns must be plain literals; compare with the conditional formmatch { x == "${prefix}-1" => … }instead.
import std:println
prefix = "job"
id = "job-1"
# Interpolate in the comparison, not in the pattern
println(match {
id == "${prefix}-1" => "first job",
_ => "other",
}) # first job
Common Patterns
Building a log line:
import std:println
import std:time
level = "WARN"
message = "disk almost full"
stamp = time:now():iso
line = "[${level}] ${message}"
println(line) # [WARN] disk almost full
println(stamp::length() > 0) # true
Assembling a key from parts:
import std:println
user_id = 42
resource = "invoice"
key = "user:${user_id}:${resource}"
println(key) # user:42:invoice
Rendering a list of rows:
import std:println
rows = [{ name: "a", n: 1 }, { name: "b", n: 2 }]
lines = rows::map(|r| "${r:name} = ${r:n}")
println(lines::join("\n"))
# a = 1
# b = 2
Writing a generated file:
import std:println
import std:io
path = `mktemp`
host = "localhost"
port = 5432
config = """host = "${host}"
port = ${port}
"""
f = io:open(path, true, true)
f::write(config)
f::close()
println(`cat "${path}"`)
# host = "localhost"
# port = 5432
See Also
- Strings - literals, escapes and string methods
- Shell Integration - running commands built with interpolation
- Regular Expressions - why patterns cannot interpolate
- Match Expressions
Deep Nesting
Reading, writing and walking maps and lists that are many levels deep.
Overview
Configuration files, JSON documents and API responses all arrive as maps containing lists containing maps. Suji handles these with two postfix operators that chain freely:
:reads a map key written as a bare identifier —config:server:port[]indexes a list, or reads a map key given by any expression —rows[0],m["a b"]
Key Characteristics
- Chains mix both operators -
data[0]:users[1]:config:preferences:email - Chains are assignable - the same expression works on the left of
= - Depth is not a problem - ten levels behave exactly like two
- Missing keys are fatal - reading an absent key aborts the program
::get(key, default)is the safe alternative
Reading Nested Values
Maps
import std:println
config = {
user: {
profile: {
settings: {
display: {
theme: "light",
layout: "grid"
},
notifications: true
},
avatar: "user.png"
},
name: "Alice"
},
version: "1.0"
}
println(config:user:profile:settings:display:theme) # light
println(config:user:name) # Alice
println(config:version) # 1.0
Bracket notation does the same job and is required when a key is not a bare identifier — because it contains a space or a dash, or because it is computed:
import std:println
m = { "content-type": "text/plain", "max size": 1024 }
key = "content-type"
println(m["content-type"]) # text/plain
println(m[key]) # text/plain
println(m["max size"]) # 1024
Lists
Indices chain the same way:
import std:println
matrix = [[[[1, 2, 3, 4], [5, 6, 7, 8]], [[9, 10, 11, 12], [13, 14, 15, 16]]]]
println(matrix[0][1][1][3]) # 16
println(matrix[0][0][0][0]) # 1
Mixed chains
Real data alternates between the two. Read it in one expression:
import std:println
data = [{
users: [
{ name: "Alice", config: { preferences: { notifications: { email: false } } } },
{ name: "Bob", config: { preferences: { notifications: { email: true } } } }
],
count: 2
}]
println(data[0]:users[1]:name) # Bob
println(data[0]:users[1]:config:preferences:notifications:email) # true
println(data[0]:count) # 2
Depth really is unlimited in practice:
import std:println
deep = { l1: { l2: { l3: { l4: { l5: { l6: { l7: { l8: { l9: { l10: "bottom" } } } } } } } } } }
println(deep:l1:l2:l3:l4:l5:l6:l7:l8:l9:l10) # bottom
Assigning Through a Chain
Any chain you can read, you can also assign to. The intermediate containers must already exist; only the final key may be new.
import std:println
config = { server: { http: { port: 8080 } } }
# Update an existing leaf
config:server:http:port = 9090
println(config:server:http:port) # 9090
# Add a new leaf to an existing map
config:server:http:tls = true
println(config:server:http:tls) # true
# Add a whole new subtree
config:server:grpc = { port: 50051 }
println(config:server:grpc:port) # 50051
Lists work identically, including nested ones:
import std:println
matrix = [[[[1, 2, 3, 4], [5, 6, 7, 8]], [[9, 10, 11, 12], [13, 14, 15, 16]]]]
matrix[0][1][1][3] = 99
println(matrix[0][1][1][3]) # 99
And mixed chains:
import std:println
data = [{ users: [{ name: "Alice", tags: ["a"] }] }]
data[0]:users[0]:name = "Ada"
data[0]:users[0]:tags[0] = "b"
println(data[0]:users[0]:name) # Ada
println(data[0]:users[0]:tags[0]) # b
Creating a missing level
Assigning through a level that does not exist yet fails, so create it first:
import std:println
config = { server: {} }
match config:server::contains("http") {
false => { config:server:http = {} }
_ => { nil }
}
config:server:http:port = 8080
println(config:server:http:port) # 8080
Missing Keys Are Fatal
Reading a key that is not present raises [404] Key not found and terminates the program — and in a long chain, the failure happens at the first missing link:
[404] Error: Key not found
╭─[ script.si:4:9 ]
│
4 │ println(config:server:grpc:port)
│ ─────────┬────────
│ ╰────────── Key 'grpc' not found in map
│
│ Note 1: Check array/map bounds and key existence
───╯
Error: Key not found: Key 'grpc' not found in map
::get(key, default) never fails, so it is the right tool at every level where the key is optional:
import std:println
config = { server: { http: { port: 8080 } } }
println(config:server::get("http")::get("port", 80)) # 8080
println(config:server::get("grpc", {})::get("port", 0)) # 0
Note the {} default in the second call: it keeps the chain going by supplying an empty map to the next ::get().
A reusable safe reader
For deep or variable paths, walk a list of keys and bail out at the first miss:
import std:println
get_path = |root, path| {
node = root
loop through path with key {
!node::is_map() && return nil
!node::contains(key) && return nil
node = node::get(key)
}
return node
}
config = { server: { http: { port: 8080 } } }
println(get_path(config, ["server", "http", "port"])) # 8080
println(get_path(config, ["server", "grpc", "port"])) # nil
println(get_path(config, ["nope"])) # nil
Building Nested Structures
The literal form is usually clearest:
import std:println
app = {
name: "demo",
services: [
{ name: "api", port: 8080 },
{ name: "worker", port: 0 }
],
limits: { memory_mb: 512, cpu: 2 }
}
println(app:services[0]:port) # 8080
println(app:limits:memory_mb) # 512
Building incrementally works too — assign an empty container, then fill it:
import std:println
report = {}
report:totals = {}
report:totals:count = 0
rows = []
loop through [3, 5, 7] with n {
rows::push({ "value": n })
report:totals:count = report:totals:count + n
}
report:rows = rows
println(report:totals:count) # 15
println(report:rows::length()) # 3
println(report:rows[1]:value) # 5
Note that rows is built as a plain variable and attached at the end. A mutating method cannot be called through a chain — report:rows::push(x) raises Cannot call mutating method on immutable value — so build the list first, or reassign the whole value:
import std:println
report = { rows: [1, 2] }
report:rows = report:rows + [3]
println(report:rows::length()) # 3
Maps and lists are copied into function parameters
This surprises people building nested data in helpers: arguments are passed by value. Mutating a parameter does not change the caller’s structure, but a variable captured from an enclosing scope is shared.
import std:println
m = { a: 1 }
# Parameter: the mutation is lost
by_param = |target| { target["b"] = 2 }
by_param(m)
println(m::contains("b")) # false
# Capture: the mutation sticks
by_capture = || { m["c"] = 3 }
by_capture()
println(m::contains("c")) # true
So a helper that adds to a nested structure should return the new value rather than mutate its argument.
Walking Nested Structures
Maps
loop through map with k, v gives you both halves of each entry — two bindings are allowed for maps only:
import std:println
settings = { theme: "dark", layout: "grid", zoom: 2 }
loop through settings with k, v {
println("${k} = ${v}")
}
# theme = dark
# layout = grid
# zoom = 2
keys(), values() and to_list() give the same data as ordinary lists. to_list() yields (key, value) tuples, which you destructure:
import std:println
settings = { theme: "dark", zoom: 2 }
println(settings::keys()::join(", ")) # theme, zoom
println(settings::length()) # 2
loop through settings::to_list() with pair {
k, v = pair
println("${k} -> ${v}")
}
# theme -> dark
# zoom -> 2
Nested loops
Iterating a structure that alternates lists and maps is just nested loops:
import std:println
teams = [
{ name: "red", members: ["ann", "bo"] },
{ name: "blue", members: ["cy"] }
]
loop through teams with team {
loop through team:members with member {
println("${team:name}/${member}")
}
}
# red/ann
# red/bo
# blue/cy
Recursive walks
A recursive helper flattens an arbitrarily nested map into path = value lines. Return a list and concatenate rather than accumulating into a parameter:
import std:println
flatten = |root, prefix| {
out = []
loop through root with k, v {
full = match {
prefix == "" => "${k}",
_ => "${prefix}.${k}",
}
match v::is_map() {
true => { out = out + flatten(v, full) }
_ => { out::push("${full} = ${v}") }
}
}
return out
}
config = { server: { http: { port: 8080, host: "localhost" } }, debug: false }
println(flatten(config, "")::join("\n"))
# server.http.port = 8080
# server.http.host = localhost
# debug = false
Recursion depth is limited — around 600–700 frames before the interpreter aborts — which is far more than any realistic document nesting, but it does rule out walking cyclic structures.
Common Patterns
Reading a parsed document. json:parse returns exactly the nested maps and lists described above:
import std:println
import std:json
text = '{"meta": {"page": 1}, "items": [{"id": 7, "tags": ["a", "b"]}]}'
doc = json:parse(text)
println(doc:meta:page) # 1
println(doc:items[0]:id) # 7
println(doc:items[0]:tags[1]) # b
Round-tripping a config file:
import std:println
import std:json
import std:io
path = `mktemp`
f = io:open(path, true, true)
f::write('{"server": {"http": {"port": 8080}}}')
f::close()
g = io:open(path)
config = json:parse(g::read_all())
g::close()
config:server:http:port = 9090
println(json:generate(config)) # {"server":{"http":{"port":9090}}}
Collecting a field from every record:
import std:println
users = [
{ profile: { email: "a@x" } },
{ profile: { email: "b@x" } }
]
emails = users::map(|u| u:profile:email)
println(emails::join(", ")) # a@x, b@x
Defaults for an optional subtree:
import std:println
settings = { display: { theme: "dark" } }
display = settings::get("display", {})
println(display::get("theme", "light")) # dark
println(display::get("density", "cosy")) # cosy
audio = settings::get("audio", {})
println(audio::get("volume", 50)) # 50
See Also
- Maps - key access,
::get(),::contains() - Lists - indexing and slices
- Loops -
loop through … with k, v - JSON - producing nested structures from text
- Error Handling - why a missing key ends the program
Shell Integration Best Practices
Suji can execute shell commands using backticks (`...`). This is powerful, but you should treat shell execution as an unsafe boundary: validate inputs, quote carefully, and prefer stdlib modules when they exist.
Basics
A backtick template is sent to the shell and evaluates to the command’s standard output with one trailing newline trimmed:
import std:println
name = "world"
out = `echo hello ${name}`
println(out) # hello world
println(out::length()) # 11
The length of 11 is the point: echo emitted hello world\n, and the newline was trimmed. Only one trailing newline is removed, so a command that emits blank lines at the end keeps all but the last:
import std:println
out = `printf 'x\n\n\n'`
println(out::length()) # 3
stderr is not captured
The value is stdout only. Anything the command writes to stderr goes straight to your terminal. Redirect it into stdout when you want it:
import std:println
out = `ls /definitely/not/here 2>&1 || true`
println(out::contains("No such file")) # true
Interpolation
${expr} works inside backticks exactly as it does in a string:
import std:println
dir = `mktemp -d`
`printf 'alpha\nbeta\n' > "${dir}/data.txt"`
println(`wc -l < "${dir}/data.txt"`::trim()) # 2
println(`cat "${dir}/data.txt"`)
# alpha
# beta
Always quote what you interpolate
Interpolation is textual substitution performed before the shell parses the command, so an interpolated value containing shell metacharacters becomes shell syntax:
import std:println
untrusted = "safe; echo INJECTED"
println(`echo ${untrusted}`)
# safe
# INJECTED
The ; started a second command. Double quotes around the interpolation stop that, and also handle spaces in filenames:
import std:println
untrusted = "safe; echo INJECTED"
println(`echo "${untrusted}"`) # safe; echo INJECTED
Guidelines for anything you did not produce yourself — command-line arguments, environment variables, file contents, parsed data:
- Wrap every interpolation in double quotes:
`ls "${dir}"` - Validate first when the value must have a known shape (
value ~ /^[a-zA-Z0-9_-]+$/) - Remember that a value containing a
"can still break out of double quotes - Prefer a stdlib function over a shell command whenever one exists
Failure Behavior
A command that exits non-zero is a fatal runtime error. The script prints a [406] Shell command failed diagnostic and exits with status 1. There is no way to read the exit status, and no way to recover — Suji has no try/catch.
[406] Error: Shell command failed
╭─[ script.si:2:9 ]
│
2 │ println(`false`)
│ ┬
│ ╰── Shell command 'false' failed with exit code 1:
│
│ Note 1: Shell commands use backticks: `command`. Check the command syntax and permissions
───╯
Error: Shell command failed: Shell command 'false' failed with exit code 1:
That default is often what you want in a script: a failed step stops everything. When it is not, handle the failure in the shell and turn the outcome into a string you can match on.
|| true to ignore a failure
import std:println
out = `grep nonexistent-pattern /etc/hosts || true`
println("[" + out + "]") # []
grep exits 1 when it matches nothing; || true makes the whole command succeed and yields an empty string.
&& echo / || echo to capture a status
import std:println
path = `mktemp`
status = `test -s "${path}" && echo nonempty || echo empty`
println(match status {
"nonempty" => "has content",
"empty" => "is empty",
_ => "unknown",
}) # is empty
This is the standard way to ask a yes/no question of the filesystem, since io:open on a missing file is itself fatal:
import std:println
path = `mktemp`
exists = `test -f "${path}" && echo yes || echo no`
println(match exists {
"yes" => "found",
_ => "missing",
}) # found
Choose your failure mode deliberately
- Want a failed command to stop the script? Do nothing — that is the default.
- Want to continue regardless? Append
|| true. - Want to branch? Append
&& echo ok || echo failandmatchon the result.
Trailing newline behavior
- Standalone backticks trim one trailing newline from stdout.
- Backticks inside a
|pipeline do not trim trailing newlines; pipe stages operate on raw bytes.
import std:println
standalone = `echo hi`
piped = `echo hi` | `cat`
println(standalone::length()) # 2
println(piped::length()) # 3
Use ::trim() when a pipeline result feeds into string comparisons or to_number().
Streaming pipelines (|)
Use | to connect stdout of one stage to stdin of the next. Each stage must be either:
- an invocation (e.g.
producer()/sink()), or - a backtick command (e.g.
`grep foo`)
Passing a bare function name is an error: `echo hi` | up raises Pipe requires function invocations. Write up().
import std:io
import std:println
producer = || {
println("alpha")
println("beta")
println("gamma")
}
collector = || {
lines = io:stdin::read_lines()
lines::join(",")
}
out = producer() | `grep beta` | collector()
println(out) # beta
A closure stage reads its input from io:stdin — read_lines() for a list of lines, read_all() for the whole text — and whatever it prints becomes the next stage’s input.
import std:io
import std:println
shout = || io:stdin::read_all()::trim()::upper()
println(`printf 'a\nb\n'` | shout())
# A
# B
Shell-only pipelines work too, and are often the clearest way to express a text transformation:
import std:println
words = `echo "the quick brown fox"` | `tr ' ' '\n'` | `sort`
println(words::trim())
# brown
# fox
# quick
# the
A non-zero exit anywhere in the pipeline is still fatal, so add || true to the stage that may legitimately find nothing.
Prefer stdlib for “local” tasks
Reaching for the shell has real costs: a process spawn per command, quoting hazards, and platform differences between macOS and Linux. Use the standard library when it covers the job:
- files/streams:
std:io(io:open,io:stdin,io:stdout,io:stderr) - filesystem metadata + ops:
std:os(os:stat,os:mkdir,os:rm,os:rmdir) - paths:
std:path(path:join,path:dirname,path:basename,path:extname) - environment:
std:env(env:var,env:args) - parsing structured data:
std:json,std:yaml,std:toml,std:csv
import std:println
import std:io
import std:path
file = `mktemp`
# Shell round-trip
`printf 'from shell\n' > "${file}"`
# Same job, no subprocess
f = io:open(file, true, true)
f::write("from stdlib\n")
f::close()
g = io:open(file)
println(g::read_all()::trim()) # from stdlib
g::close()
# Path handling without calling out to dirname/basename
neighbour = path:join([path:dirname(file), "next"])
println(file::starts_with("/")) # true
println(neighbour::ends_with("/next")) # true
For HTTP requests in this repo, use curl:
Common Patterns
Capture a single value:
import std:println
host = `hostname`
println(host::length() > 0) # true
Count something without a subprocess loop:
import std:println
lines = `printf 'a\nb\nc\n'`::split("\n")
println(lines::length()) # 3
Write a temp file, process it, clean up:
import std:println
import std:os
path = `mktemp`
`printf '3\n1\n2\n' > "${path}"`
sorted = `sort -n "${path}"`
println(sorted::split("\n")::join(",")) # 1,2,3
os:rm(path)
println(`test -f "${path}" && echo yes || echo no`) # no
See Also
- String Interpolation - building commands safely
- Error Handling - why a non-zero exit ends the script
- Streams -
io:stdinin pipeline stages - Pipe Operator
- OS Module and Path Module
Performance Considerations
Best practices and optimization tips for writing efficient Suji code.
How Suji Executes Your Code
Knowing the execution model tells you which optimisations are worth attempting:
- Tree-walking AST interpreter. Every expression is re-walked each time it is evaluated, so per-operation constant factors are high compared with a compiled language.
- No JIT, no parallelism, no lazy sequences, no tail-call optimisation.
The practical consequence: algorithmic choices — how many times you touch each element, how much you allocate — dominate. Micro-tuning individual expressions rarely pays off. Measure before and after any change.
Measuring
There are two timing primitives: os:uptime_ms() and time:now():epoch_ms. Both are millisecond-resolution, so a single fast operation will measure as 0 — repeat the work enough times to get a signal.
import std:println
import std:os
benchmark = |work| {
start = os:uptime_ms()
work()
return os:uptime_ms() - start
}
elapsed = benchmark(|| (1..=100000)::sum())
println(elapsed::is_number()) # true
println(elapsed >= 0) # true
time:now() returns a map, so subtract the epoch_ms field rather than the maps themselves:
import std:println
import std:time
start = time:now():epoch_ms
total = (1..=1000)::sum()
elapsed = time:now():epoch_ms - start
println(elapsed >= 0) # true
Compare two implementations by running both and reporting the ratio at runtime. Do not trust numbers written into documentation, including this page — measure on your own machine and your own data.
Everything Is Eager
map, filter and fold allocate
List methods are not lazy. Each stage walks the whole list and builds a brand-new list, so a three-stage chain over a million elements allocates three million-element lists.
import std:println
data = 1..=10
# Three passes, two intermediate lists
result = data
::filter(|x| x > 2)
::map(|x| x * 2)
::fold(0, |acc, x| acc + x)
println(result) # 104
That chain is perfectly good style for small and medium lists — clarity is worth an allocation. When the list is large, collapse the passes into one:
import std:println
data = 1..=10
# One pass, no intermediate lists
result = data::fold(0, |acc, x| match {
x > 2 => acc + x * 2,
_ => acc,
})
println(result) # 104
Filtering before mapping is also worth doing when the filter is selective, since it shrinks the input to the more expensive stage:
import std:println
data = 1..=10
cheap_first = data::filter(|x| x % 2 == 0)::map(|x| x * x)
println(cheap_first::sum()) # 220
Ranges materialise complete lists
a..b and a..=b are not lazy iterators — they build the whole list immediately. 0..1000000 allocates a million elements before the loop body runs even once.
import std:println
r = 0..1000
println(r::is_list()) # true
println(r::length()) # 1000
For a large counted loop where you do not need the list, use an explicit counter:
import std:println
i = 0
total = 0
loop {
i >= 1000 && break
total = total + i
i++
}
println(total) # 499500
For moderate sizes, loop through 0..n with i is clearer and the allocation is not worth worrying about.
Strings Are Character-Indexed
Strings are UTF-8, and length() and indexing count characters, not bytes. That means indexing is a scan from the start of the string, so indexing in a loop is quadratic.
import std:println
s = "hello world"
println(s::length()) # 11
println(s[0]) # h
println(s[6;]) # world
Convert once instead of indexing repeatedly:
import std:println
s = "hello"
# Good: one conversion, then cheap list access
chars = s::to_list()
loop through chars with c {
println(c)
}
# h
# e
# l
# l
# o
Concatenation in a loop
Each + builds a new string. Collect the pieces and join once:
import std:println
items = ["a", "b", "c"]
# Slower: a new string per iteration
result = ""
loop through items with item {
result = result + item
}
println(result) # abc
# Faster: one allocation at the end
println(items::join("")) # abc
Interpolation is a single build step, so prefer it over a chain of +:
import std:println
name = "Ada"
count = 3
println("Hello ${name}, you have ${count} messages")
# Hello Ada, you have 3 messages
Recursion Has a Hard Limit
There is no tail-call optimisation. Every call consumes a native stack frame, and a few hundred frames deep the interpreter aborts with a stack overflow that you cannot catch. The ceiling depends on the function: a simple 1 + f(n - 1) recursion survives 600 levels but not 700, and an accumulator-passing version survives 700 but not 900. Recursion is fine for shallow, tree-shaped work; it is not a substitute for a loop.
import std:println
# Fine: depth is bounded and small
depth = |n| match {
n == 0 => 0,
_ => 1 + depth(n - 1),
}
println(depth(500)) # 500
Rewrite deep recursion as iteration:
import std:println
# Recursive sum would overflow for large n
total = 0
loop through 1..=10000 with i {
total = total + i
}
println(total) # 50005000
Memoise repeated subproblems
A map makes exponential recursion linear, and keeps the depth low enough to be safe:
import std:println
memo = {}
fib = |n| {
memo::contains(n) && return memo::get(n)
value = match {
n < 2 => n,
_ => fib(n - 1) + fib(n - 2),
}
memo[n] = value
return value
}
println(fib(30)) # 832040
Note that memo is a captured variable, not a parameter — that matters, as the next section explains.
Arguments Are Copied
Maps and lists are passed by value. Handing a large structure to a function copies it, and mutations inside the function are lost. Variables captured from an enclosing scope are shared instead.
import std:println
m = { a: 1 }
by_param = |target| { target["b"] = 2 }
by_param(m)
println(m::contains("b")) # false
by_capture = || { m["c"] = 3 }
by_capture()
println(m::contains("c")) # true
So for a hot loop over a big structure, prefer a closure over the structure, or pass only the slice you need — not the whole document.
Streams Read Eagerly
read_all() and read_lines() load the entire file into memory. They are convenient and fine for configuration files and modest data, but they are not streaming.
import std:println
import std:io
path = `mktemp`
f = io:open(path, true, true)
loop through 1..=5 with i {
f::write("line ${i}\n")
}
f::close()
g = io:open(path)
lines = g::read_lines() # whole file in memory
g::close()
println(lines::length()) # 5
println(lines[0]) # line 1
read_line() is the one incremental reader: it returns the next line without its newline, and nil at end of file. Use it when the file may be larger than memory.
import std:println
import std:io
import std:os
path = `mktemp`
f = io:open(path, true, true)
loop through 1..=1000 with i {
f::write("row ${i}\n")
}
f::close()
# Constant memory: one line at a time
g = io:open(path)
matching = 0
loop {
line = g::read_line()
line == nil && break
match { line ~ /^row 1[0-9][0-9]$/ => { matching++ } }
}
g::close()
os:rm(path)
println(matching) # 100
Batch your writes
Each write() is a syscall. Build the text and write once when you can:
import std:println
import std:io
import std:os
path = `mktemp`
items = [1, 2, 3, 4, 5]
content = items::map(|i| i::to_string())::join("\n")
out = io:open(path, true, true)
out::write(content)
out::close()
check = io:open(path)
println(check::read_all())
check::close()
os:rm(path)
# 1
# 2
# 3
# 4
# 5
Choosing Data Structures
- Lists are positional.
xs[i]is a direct index, butcontains()andindex_of()scan linearly — inside a loop that becomes quadratic. Build a map when you need repeated membership tests. - Maps are keyed lookups and keep insertion order. Prefer them for “have I seen this?” questions.
- Tuples are fixed-size and cannot be indexed; they are for returning a couple of values, not for storage.
import std:println
names = ["ann", "bo", "cy"]
# Slow inside a loop: linear scan each time
println(names::contains("bo")) # true
# Faster for repeated tests: build a lookup map once
seen = {}
loop through names with n {
seen[n] = true
}
println(seen::contains("bo")) # true
println(seen::contains("zed")) # false
Hoist Work Out of Loops
Anything that does not depend on the loop variable belongs above the loop:
import std:println
items = ["Ada", "Bo", "Cy"]
prefix = "user:"::upper()
out = []
loop through items with item {
out::push(prefix + item::upper())
}
println(out::join(",")) # USER:ADA,USER:BO,USER:CY
Early exit avoids the rest of the work entirely:
import std:println
find_first = |xs, predicate| {
loop through xs with item {
predicate(item) && return item
}
return nil
}
println(find_first([1, 5, 9], |x| x > 4)) # 5
println(find_first([1, 2], |x| x > 100)) # nil
Performance Checklist
DO:
- Measure with
os:uptime_ms()before and after a change - Collapse multi-stage list chains into one pass when lists are large
- Collect strings and
joinonce instead of concatenating in a loop - Convert a string to a list once rather than indexing it repeatedly
- Use a map for repeated membership tests
- Use
read_line()when a file may not fit in memory - Memoise expensive recursive computations
- Batch writes into a single
write() - Exit loops early with
breakorreturn
DON’T:
- Expect
map/filter/foldor ranges to be lazy — they are not - Materialise a huge range just to count
- Recurse deeply; there is no tail-call optimisation
- Pass large maps and lists as arguments in hot code — they are copied
- Assume
read_lines()streams; it loads the whole file - Optimise before measuring
When to Optimize
- Measure first: don’t guess, time it
- Find the real bottleneck: usually I/O or an accidental quadratic loop
- Fix the algorithm: constant-factor tweaks rarely matter in an interpreter
- Keep it readable: an eager three-stage chain is fine until it isn’t
- Re-measure: confirm the change actually helped
See Also
- Lists -
map,filter,foldand their costs - Strings - character indexing
- Loops - ranges and
break - Streams -
read_line()vsread_all() - Time Module and OS Module - timing primitives
- File Processing
Standard Library
Everything Suji ships in std, and how to import it.
Overview
The standard library is small and entirely explicit. There is no prelude —
nothing at all is available until you import it, including println:
import std:println
println("hello") # hello
Every module lives under std. There is no top-level module, so import json
fails with Invalid operation: Module 'json' not found; the correct form is
import std:json.
The Complete Module List
These are all of the modules, and the pages below document every function each one exports.
Printing
| Import | Provides | Page |
|---|---|---|
import std:println | println(text = "", out = nil) | Print Functions |
import std:print | print(text, out = nil) | Print Functions |
Data Formats
| Module | Exports | Page |
|---|---|---|
std:json | parse, generate | JSON |
std:yaml | parse, generate | YAML |
std:toml | parse, generate | TOML |
std:csv | parse, generate | CSV |
System
| Module | Exports | Page |
|---|---|---|
std:io | open, stdin, stdout, stderr | I/O and Streams |
std:env | var, args, argv | Environment |
std:os | name, hostname, uptime_ms, tmp_dir, home_dir, work_dir, pid, ppid, uid, gid, exit, mkdir, rm, rmdir, stat | Operating System |
std:path | is_abs, join, dirname, basename, extname, normalize | Paths |
std:dotenv | load | Dotenv Files |
Utilities
| Module | Exports | Page |
|---|---|---|
std:random | random, seed, integer, pick, shuffle, sample, string, hex_string, alpha_string, numeric_string, alphanumeric_string | Random Numbers |
std:time | now, sleep, parse_iso, format_iso | Time and Dates |
std:uuid | v4, v5, is_valid | UUID |
std:encoding | base64_encode, base64_decode, hex_encode, hex_decode, percent_encode, percent_decode | Text Encoding |
std:math | PI, E, sin, cos, tan, asin, acos, atan, atan2, log, log10, exp | Mathematics |
std:crypto | md5, sha1, sha256, sha512, hmac_sha256 | Cryptography |
There is no HTTP client module: make requests with backtick shell commands (see HTTP with curl). There is no logging, testing, regex, string or collections module either — string and collection operations are methods on values rather than library functions.
Import Forms
import std # binds std; then std:println(...), std:math:PI
import std:math # binds math; math:PI
import std:println # binds println
import std:json:parse # deep import, binds parse
import std:println as say # alias
say("all four forms work") # all four forms work
import std:json:parse
import std:yaml:generate as to_yaml
import std:println
println(to_yaml(parse('{"port": 8080}'))) # port: 8080
Functions vs. Values
Most exports are functions, but a few are values and must not be called:
| Value | Kind |
|---|---|
math:PI, math:E | Numbers |
io:stdin, io:stdout, io:stderr | Streams |
env:var, env:args, env:argv | Map-like values |
import std:env
import std:io
import std:math
import std:println
println(math:PI > 3) # true
println(io:stderr::is_stream()) # true
println(env:var::contains("PATH")) # true
Quick Start
Reading and Writing a File
import std:io
import std:println
p = `mktemp`
out = io:open(p, true, true) # create = true, truncate = true
out::write("Hello, World!\n")
out::close()
f = io:open(p)
println(f::read_all()::trim()) # Hello, World!
f::close()
Parsing Configuration
import std:json
import std:println
config = json:parse('{"server": {"host": "0.0.0.0", "port": 8080}}')
println(config:server:port) # 8080
println(config:server::get("tls", false)) # false
Calling an External Command
import std:json
import std:println
# users = json:parse(`curl -fsS https://api.example.com/users || echo '[]'`)
users = json:parse(`printf '[{"id":1},{"id":2}]'`)
println(users::length()) # 2
Timestamps
import std:time
import std:println
started = time:now():epoch_ms
time:sleep(20)
println(time:now():epoch_ms - started >= 20) # true
Error Handling
Standard library functions signal failure by raising a runtime error, and Suji has no way to catch one — the process prints a diagnostic and exits with status 1. The only strategy is to check before you act:
| Risk | Defensive form |
|---|---|
| Missing map key | m::get(key, default), m::contains(key) |
| Missing environment variable | env:var::get(name, default) |
| File may not exist | `test -e "${p}" && echo yes || echo no` before io:open / os:stat |
| Command may fail | `cmd || true` — a non-zero exit status otherwise ends the script |
| Empty list | xs::length() > 0 before pick, first, indexing |
| Unparsable number | validate with a regex before ::to_number() |
import std:env
import std:io
import std:os
import std:println
p = `mktemp`
size = match `test -e "${p}" && echo yes || echo no` {
"yes" => os:stat(p):size,
_ => 0,
}
println(size) # 0
println(env:var::get("MISSING_VAR", "n/a")) # n/a
See Error Handling Deep Dive for the full picture.
Common Patterns
Converting Between Formats
import std:json
import std:yaml
import std:println
data = yaml:parse("name: demo\nport: 8080\n")
println(json:generate(data)) # {"name":"demo","port":8080}
Processing a File Line by Line
import std:io
import std:println
p = `mktemp`
f = io:open(p, true, true)
f::write("INFO ok\nERROR bad\nWARN hmm\nERROR worse\n")
f::close()
r = io:open(p)
lines = r::read_lines()
r::close()
errors = lines::filter(|line| line ~ /ERROR/)
println(errors::length()) # 2
Building a Path
import std:os
import std:path
import std:println
target = path:join([os:tmp_dir(), "reports", "summary.csv"])
println(path:extname(target)) # .csv
println(path:basename(target)) # summary.csv
Implementation Note
The standard library is a virtual, embedded module tree: some modules are Suji source files that delegate to runtime builtins, and others are builtins directly. Either way they are resolved from inside the interpreter, so there are no files to install and no package manager involved.
See Also
Print Functions (std:print, std:println)
Write text to standard output or to any stream.
Overview
Printing is not built into the language — there is no prelude, so every program that prints must import the function it uses:
println(text = "", out = nil)→ Number (bytes written)print(text, out = nil)→ Number (bytes written, no trailing newline)
Both live directly under std, not inside a submodule:
import std:println
import std:print
print("Hello, ")
println("world!")
Quick Start
import std:println
println("Hello, world!")
println(42)
println([1, 2, 3])
println() # writes just a newline
println(text = "", out = nil)
Writes text followed by a newline and returns the number of bytes written.
text— any value. Non-string values are converted the same wayvalue::to_string()converts them. Defaults to"", soprintln()prints an empty line.out— a stream to write to.nil(the default) means standard output.
import std:println
n = println("hi")
println(n) # 3
Values are rendered without quotes, and maps and tuples use Suji’s own display form rather than JSON:
import std:println
println("text") # text
println(true) # true
println(nil) # nil
println([1, 2, 3]) # [1, 2, 3]
println({"a": 1, "b": 2}) # {a: 1, b: 2}
println((1, 2)) # (1, 2)
For JSON output use std:json instead:
import std:json
import std:println
println(json:generate({"a": 1, "b": 2})) # {"a":1,"b":2}
print(text, out = nil)
Writes text with no trailing newline and returns the number of bytes
written. Unlike println, text is required.
import std:print
import std:println
print("Loading")
print("...")
println(" done") # Loading... done
Writing to a Stream
Pass a stream as the second argument. std:io exposes the standard streams, and
io:open returns a writable stream for a file.
import std:io
import std:println
println("this goes to stderr", io:stderr)
println("this goes to stdout", io:stdout)
import std:io
import std:println
p = `mktemp`
f = io:open(p, true, true)
println("first line", f)
println("second line", f)
f::close()
r = io:open(p)
println(r::read_lines()::length()) # 2
r::close()
Because stderr is a separate stream, diagnostics can be kept out of a pipeline’s stdout:
import std:io
import std:println
report = |label, value| {
println("processing ${label}", io:stderr)
println(value)
}
report("row-1", 42)
Interpolation Instead of Concatenation
+ never mixes types, so build messages with ${...} interpolation rather than
concatenating a string with a number:
import std:println
count = 3
println("found ${count} items") # found 3 items
Gotchas
- Nothing is imported implicitly. A snippet that calls
printlnwithoutimport std:printlnfails withUndefined variable: println. printlnreturns a number, so calling it as the last expression of a function makes that function return the byte count rather thannil.printhas no default text:print()raises an arity mismatch.- Output is not buffered per line by the language; interleaving
printto stdout and stderr may not appear in source order when both are redirected to the same file.
See Also
Data Formats
Convert between text in a structured format and Suji values.
Overview
Four modules share the same two-function shape — one parser and one generator:
| Module | Functions | Parses into |
|---|---|---|
std:json | parse(text), generate(value) | Maps, lists, strings, numbers, booleans, nil |
std:yaml | parse(text), generate(value) | Maps, lists, strings, numbers, booleans, nil |
std:toml | parse(text), generate(value) | Maps, lists, strings, numbers, booleans |
std:csv | parse(text, delimiter = ","), generate(rows, delimiter = ",") | List of lists of strings |
The names are exactly parse and generate. There is no stringify, dump,
load, encode or decode, and no pretty-printing option.
Quick Start
import std:json
import std:println
data = json:parse('{"name": "Alice", "roles": ["admin", "dev"]}')
println(data:name) # Alice
println(data:roles[0]) # admin
println(json:generate(data)) # {"name":"Alice","roles":["admin","dev"]}
Importing
Every module needs an explicit import; there is no prelude and no bare
import json.
import std:json
import std:yaml
import std:toml
import std:csv
import std:println
println(json:generate(yaml:parse("a: 1"))) # {"a":1}
Deep imports bind a single function, and aliases keep two modules’ functions apart:
import std:json:parse as parse_json
import std:yaml:generate as to_yaml
import std:println
println(to_yaml(parse_json('{"port": 8080}'))) # port: 8080
Choosing a Format
| Use | Format |
|---|---|
| API payloads, machine-to-machine exchange | JSON |
| Hand-edited configuration with comments and nesting | YAML |
| Flat, table-oriented configuration | TOML |
| Tabular data, spreadsheet exchange | CSV |
Converting Between Formats
Because all four parse into ordinary Suji values, conversion is just parse-then-generate:
import std:json
import std:yaml
import std:println
config = yaml:parse("""
server:
host: localhost
port: 8080
""")
println(json:generate(config)) # {"server":{"host":"localhost","port":8080}}
import std:csv
import std:json
import std:println
rows = csv:parse("name,age\nAlice,30\nBob,25\n")
header = rows[0]
records = rows[1;]::map(|row| {
{
"name": row[0],
"age": row[1]::to_number(),
}
})
println(json:generate(records)) # [{"age":30,"name":"Alice"},{"age":25,"name":"Bob"}]
println(header::join(",")) # name,age
Key Order
The generators differ in how they order map keys, which matters when you compare generated text or check it into version control:
import std:json
import std:toml
import std:yaml
import std:println
m = {"zeta": 1, "alpha": 2}
println(json:generate(m)) # {"alpha":2,"zeta":1}
println(yaml:generate(m))
println(toml:generate(m))
JSON and TOML sort keys alphabetically; YAML preserves the map’s insertion order.
Reading and Writing Files
None of these modules touch the filesystem. Combine them with
std:io:
import std:io
import std:json
import std:println
p = `mktemp`
out = io:open(p, true, true)
out::write(json:generate({"version": 2}))
out::close()
f = io:open(p)
config = json:parse(f::read_all())
f::close()
println(config:version) # 2
Errors
A malformed document raises a runtime error that terminates the program — there is no way to catch it:
import std:json
# json:parse("{bad")
# Error: JSON parse error: Invalid JSON: key must be a string at line 1 column 2
The generators fail on values with no representation in the target format:
functions, streams and regexes cannot be serialized at all, and TOML additionally
rejects nil. Validate or strip such values before generating.
After parsing, treat the result as untrusted: a missing map key raises
Key not found, so read optional fields with get.
import std:json
import std:println
data = json:parse('{"name": "Alice"}')
println(data::get("nickname", "(none)")) # (none)
println(data::contains("name")) # true
See Also
JSON (std:json)
Parse and generate JSON text.
Overview
std:json exports exactly two functions:
parse(text)→ Suji valuegenerate(value)→ String (compact JSON)
There is no stringify, dump or load, and no pretty-printing option.
Quick Start
import std:json
import std:println
data = json:parse('{"name": "Alice", "age": 30}')
println(data:name) # Alice
println(data:age) # 30
user = {"name": "Bob", "age": 25, "active": true}
println(json:generate(user)) # {"active":true,"age":25,"name":"Bob"}
Importing
import std:json # json:parse, json:generate
import std:json:parse # binds parse
import std:json:generate as to_json # binds to_json
import std:println
println(to_json(parse('{"a": 1}'))) # {"a":1}
parse(text)
Parses a JSON document into Suji values.
| JSON | Suji |
|---|---|
| object | Map |
| array | List |
| string | String |
| number | Number |
true / false | Boolean |
null | nil |
Any JSON value works at the top level, not just objects and arrays:
import std:json
import std:println
println(json:parse("42")) # 42
println(json:parse('"text"')) # text
println(json:parse("[1,2,3]")) # [1, 2, 3]
println(json:parse("null") == nil) # true
Nested structures are read with : for map keys and [] for list indices, and
the chains can be combined:
import std:json
import std:println
text = '''
{
"company": {
"name": "Acme Corp",
"employees": [
{"name": "Alice", "role": "Engineer"},
{"name": "Bob", "role": "Designer"}
]
}
}
'''
data = json:parse(text)
println(data:company:name) # Acme Corp
println(data:company:employees[0]:name) # Alice
println(data:company:employees::length()) # 2
Malformed input raises a runtime error that terminates the program:
import std:json
# json:parse('{"name": "Alice"')
# Error: JSON parse error: Invalid JSON: EOF while parsing an object at line 1 column 16
generate(value)
Converts a Suji value to a compact JSON string.
import std:json
import std:println
data = {
"name": "Alice",
"hobbies": ["reading", "coding"],
"active": true,
"manager": nil,
}
println(json:generate(data))
# {"active":true,"hobbies":["reading","coding"],"manager":null,"name":"Alice"}
Notable details:
- Keys are sorted alphabetically, not kept in insertion order.
- Output is compact: no spaces, no newlines, no trailing newline.
nilbecomesnull.- Tuples are written as arrays.
- Strings are escaped as JSON requires.
import std:json
import std:println
println(json:generate((1, 2))) # [1,2]
println(json:generate({"text": "l1\nl2", "q": "say \"hi\""}))
# {"q":"say \"hi\"","text":"l1\nl2"}
Functions, streams and regexes have no JSON representation:
import std:json
# json:generate({"action": |x| x + 1})
# Error: JSON generation error: Function values cannot be converted to JSON
Reading Optional Fields
A missing map key raises Key not found, so use get and contains for fields
that may be absent:
import std:json
import std:println
data = json:parse('{"name": "Alice"}')
println(data::get("email", "unknown")) # unknown
println(data::contains("email")) # false
println(data:name) # Alice
The same applies to nested maps — check each level you are not sure about:
import std:json
import std:println
config = json:parse('{"server": {"port": 8080}}')
server = config::get("server", {})
port = server::get("port", 3000)
tls = server::get("tls", false)
println("${port} ${tls}") # 8080 false
Round-Tripping Numbers
Suji numbers are fixed-precision decimals, so ordinary decimal values survive a round trip exactly:
import std:json
import std:println
data = {"value": 1.23456789012345}
parsed = json:parse(json:generate(data))
println(parsed:value == data:value) # true
println(0.1 + 0.2 == 0.3) # true
Files
import std:io
import std:json
import std:println
p = `mktemp`
out = io:open(p, true, true)
out::write(json:generate({"users": [{"name": "Alice"}, {"name": "Bob"}]}))
out::close()
f = io:open(p)
data = json:parse(f::read_all())
f::close()
println(data:users::length()) # 2
println(data:users[1]:name) # Bob
Transforming Parsed Data
Lists of maps combine well with map, filter and fold. Note that a map
literal as a whole lambda body must use quoted keys or be wrapped in a block:
import std:json
import std:println
input = '[{"first":"Alice","last":"Smith","status":"active"},{"first":"Bob","last":"Jones","status":"inactive"}]'
users = json:parse(input)
active = users::filter(|u| u:status == "active")
summary = active::map(|u| {
{
"full_name": "${u:first} ${u:last}",
"active": true,
}
})
println(json:generate(summary)) # [{"active":true,"full_name":"Alice Smith"}]
Merging Documents
merge mutates the receiving map and returns nil, so merge first and then
generate:
import std:json
import std:println
base = json:parse('{"a": 1, "b": 2}')
override = json:parse('{"b": 3, "c": 4}')
base::merge(override)
println(json:generate(base)) # {"a":1,"b":3,"c":4}
Shell and HTTP
JSON pairs naturally with backtick commands, for example a curl call. A
non-zero exit status from the command terminates the script, so -f plus a
fallback is worth using in real scripts:
import std:json
import std:println
# body = `curl -fsS https://api.example.com/users || echo '[]'`
# users = json:parse(body)
body = `printf '[{"id":1},{"id":2}]'`
users = json:parse(body)
println(users::length()) # 2
Gotchas
generatesorts keys, so generated text will not match the order you wrote the map in.- There is no pretty-printer; pipe through an external tool
(
`printf '%s' "${text}" | python3 -m json.tool`) if you need indentation. - Parse and generation errors terminate the program; validate input before parsing untrusted text.
- Bare identifier keys in a map literal are only recognised where a map is expected — use quoted keys when a map literal is a lambda or match-arm body.
See Also
YAML (std:yaml)
Parse and generate YAML text.
Overview
std:yaml exports exactly two functions:
parse(text)→ Suji valuegenerate(value)→ String
There is no stringify, dump or load.
Quick Start
import std:yaml
import std:println
config = yaml:parse("""
name: Alice
age: 30
hobbies:
- reading
- coding
""")
println(config:name) # Alice
println(config:hobbies[1]) # coding
println(yaml:generate({"name": "Bob", "age": 25}))
parse(text)
Mappings become maps, sequences become lists, scalars become strings, numbers, booleans or nil.
| YAML | Suji |
|---|---|
| mapping | Map |
| sequence | List |
| quoted or plain scalar | String |
| number | Number |
true / false | Boolean |
null or an empty value | nil |
import std:yaml
import std:println
data = yaml:parse("""
enabled: true
retries: 3
ratio: 0.75
label: staging
missing:
""")
println(data:enabled) # true
println(data:retries + 1) # 4
println(data:ratio) # 0.75
println(data:label) # staging
println(data:missing == nil) # true
A sequence at the top level parses into a list:
import std:yaml
import std:println
println(yaml:parse("- 1\n- 2\n- 3")) # [1, 2, 3]
Comments and Block Scalars
Comments are ignored, | keeps line breaks and > folds them into spaces:
import std:yaml
import std:println
doc = yaml:parse("""
# deployment settings
region: eu-west-1 # inline comment
notes: |
line one
line two
summary: >
folded across
two lines
""")
println(doc:region) # eu-west-1
println(doc:notes::split("\n")::length()) # 3
println(doc:summary::trim()) # folded across two lines
Nested Access
import std:yaml
import std:println
config = yaml:parse("""
database:
host: localhost
port: 5432
servers:
- name: web-1
port: 8080
- name: web-2
port: 8081
""")
println(config:database:port) # 5432
println(config:servers[1]:name) # web-2
println(config:servers::length()) # 2
generate(value)
Converts a Suji value to YAML. Unlike JSON and TOML generation, map keys keep
their insertion order, and the result has no trailing newline and no leading
--- marker.
import std:yaml
import std:println
println(yaml:generate({"server": {"host": "localhost", "ports": [80, 443]}}))
Output:
server:
host: localhost
ports:
- 80
- 443
Scalars that would otherwise be read back as something else are quoted
automatically — including the strings "yes", "1" and "", and the key on:
import std:yaml
import std:println
println(yaml:generate({"a": "yes", "b": "1", "c": "with: colon", "d": ""}))
Output:
a: "yes"
b: "1"
c: "with: colon"
d: ""
Functions, streams and regexes cannot be serialized:
import std:yaml
# yaml:generate({"action": |x| x + 1})
# Error: YAML generation error: Function values cannot be converted to YAML
Files
import std:io
import std:yaml
import std:println
p = `mktemp`
out = io:open(p, true, true)
out::write(yaml:generate({"server": {"host": "0.0.0.0", "port": 8080}}))
out::close()
f = io:open(p)
config = yaml:parse(f::read_all())
f::close()
println(config:server:port) # 8080
Reading Optional Settings
A missing key raises Key not found, so read optional settings through get:
import std:yaml
import std:println
config = yaml:parse("server:\n port: 8080\n")
server = config::get("server", {})
println(server::get("port", 3000)) # 8080
println(server::get("host", "0.0.0.0")) # 0.0.0.0
Gotchas
- Only the first document of a multi-document stream is returned; text after
a
---separator is ignored. - Merge keys are not expanded.
<<: *anchoris kept as a literal<<key rather than being merged into the mapping, so avoid anchor-based reuse in files you intend to parse. - Comments are dropped on parse, so a parse-then-generate round trip loses them.
- Indentation in a
"""…"""literal is part of the string, so keep YAML written inline flush against the left margin. - Parse and generation errors terminate the program.
See Also
TOML (std:toml)
Parse and generate TOML configuration text.
Overview
std:toml exports exactly two functions:
parse(text)→ Mapgenerate(value)→ String
There is no stringify, dump or load.
Quick Start
import std:toml
import std:println
config = toml:parse('''
title = "My App"
[server]
host = "localhost"
port = 8080
''')
println(config:title) # My App
println(config:server:port) # 8080
println(toml:generate({"title": "My App", "server": {"port": 8080}}))
parse(text)
A TOML document is always a table, so parse always returns a map.
| TOML | Suji |
|---|---|
| table | Map |
| array | List |
| array of tables | List of maps |
| string | String |
| integer / float | Number |
true / false | Boolean |
| date, time, datetime | String |
import std:toml
import std:println
config = toml:parse('''
name = "myapp"
version = "1.0.0"
ratio = 0.5
enabled = true
tags = ["cli", "tool"]
created = 1979-05-27T07:32:00Z
[dependencies]
lib1 = "^1.0"
''')
println(config:name) # myapp
println(config:ratio) # 0.5
println(config:enabled) # true
println(config:tags) # [cli, tool]
println(config:created) # 1979-05-27T07:32:00Z
println(config:dependencies:lib1) # ^1.0
Dates and datetimes come back as strings, not numbers. Convert with
std:time when you need a timestamp:
import std:time
import std:toml
import std:println
config = toml:parse('released = 2023-11-10T15:30:00Z\n')
released = time:parse_iso(config:released)
println(released:epoch_ms) # 1699630200000
Nested and Repeated Tables
Dotted table headers nest maps, and [[…]] builds a list of maps:
import std:toml
import std:println
config = toml:parse('''
[database.primary]
host = "db-1"
[[servers]]
host = "web-1"
port = 8080
[[servers]]
host = "web-2"
port = 8081
''')
println(config:database:primary:host) # db-1
println(config:servers::length()) # 2
println(config:servers[1]:port) # 8081
total = config:servers::fold(0, |acc, s| acc + s:port)
println(total) # 16161
generate(value)
Converts a Suji value to TOML. Keys are sorted alphabetically, scalars are emitted before tables, and the result ends with a newline.
import std:toml
import std:println
println(toml:generate({"title": "app", "ports": [1, 2], "owner": {"name": "Alice"}}))
Output:
ports = [1, 2]
title = "app"
[owner]
name = "Alice"
A list of maps becomes an array of tables:
import std:toml
import std:println
println(toml:generate({"servers": [{"host": "a"}, {"host": "b"}]}))
Output:
[[servers]]
host = "a"
[[servers]]
host = "b"
TOML Has No Null
nil cannot be represented, anywhere in the value:
import std:toml
# toml:generate({"nickname": nil})
# Error: TOML conversion error: TOML does not support nil values
Drop empty entries before generating:
import std:toml
import std:println
record = {"name": "Alice", "nickname": nil}
clean = {}
loop through record with k, v {
match {
v != nil => { clean[k] = v }
}
}
println(toml:generate(clean)) # name = "Alice"
Functions, streams and regexes cannot be serialized either.
Non-Map Values
generate expects a table. A scalar or list is wrapped under the key value
rather than rejected, which is rarely what you want:
import std:toml
import std:println
println(toml:generate([1, 2])) # value = [1, 2]
Files
import std:io
import std:toml
import std:println
p = `mktemp`
out = io:open(p, true, true)
out::write(toml:generate({"server": {"host": "0.0.0.0", "port": 8080}}))
out::close()
f = io:open(p)
config = toml:parse(f::read_all())
f::close()
println("${config:server:host}:${config:server:port}") # 0.0.0.0:8080
Reading Optional Settings
import std:toml
import std:println
config = toml:parse("[server]\nport = 8080\n")
server = config::get("server", {})
println(server::get("port", 3000)) # 8080
println(server::get("workers", 4)) # 4
println(config::contains("logging")) # false
Gotchas
generatesorts keys, so generated files will not preserve your map’s order.nilvalues raise a TOML conversion error; strip them first.- Comments are dropped on parse, so round-tripping a hand-written config loses them.
- Dates parse to strings; there is no TOML date type in Suji.
- Parse and generation errors terminate the program.
See Also
CSV (std:csv)
Parse and generate delimiter-separated tabular text.
Overview
std:csv exports exactly two functions:
parse(text, delimiter = ",")→ List of lists of stringsgenerate(rows, delimiter = ",")→ String
parse is purely positional: it does not interpret the first row as headers
and never produces maps. Every cell comes back as a string.
Quick Start
import std:csv
import std:println
rows = csv:parse("name,age\nAlice,30\nBob,25\n")
println(rows::length()) # 3
println(rows[0]) # [name, age]
println(rows[1][0]) # Alice
println(rows[1][1]::to_number() + 1) # 31
parse(text, delimiter = ",")
Returns a list of rows, each row a list of string cells. Quoted fields may contain the delimiter, embedded quotes and newlines; empty input yields an empty list, and blank trailing lines are ignored.
import std:csv
import std:println
println(csv:parse("")) # []
println(csv:parse("a,\"b,c\",d")) # [[a, b,c, d]]
println(csv:parse("a,\"multi\nline\"")) # [[a, multi
# line]]
Every row must have the same number of fields — a ragged row raises
CSV parse error:
import std:csv
# csv:parse("a,b\nc")
# Error: CSV parse error: Invalid CSV: CSV error: record 1 (line: 2, byte: 4):
# found record with 1 fields, but the previous record has 2 fields
Other Delimiters
import std:csv
import std:println
println(csv:parse("a;b\n1;2", ";")) # [[a, b], [1, 2]]
println(csv:parse("x\ty", "\t")) # [[x, y]]
Using the Header Row
Convert rows to maps yourself when you want access by name:
import std:csv
import std:println
rows = csv:parse("name,age,city\nAlice,30,Boston\nBob,25,NYC\n")
header = rows[0]
records = rows[1;]::map(|row| {
record = {}
loop through 0..header::length() with i {
record[header[i]] = row[i]
}
record
})
println(records::length()) # 2
println(records[0]:name) # Alice
println(records[1]:city) # NYC
Filtering and Aggregating
import std:csv
import std:println
rows = csv:parse("name,age\nAlice,30\nBob,17\nCara,42\n")
data = rows[1;]
adults = data::filter(|row| row[1]::to_number() >= 18)
println(adults::length()) # 2
ages = data::map(|row| row[1]::to_number())
println(ages::sum()) # 89
println(ages::max()) # 42
generate(rows, delimiter = ",")
Takes a list of lists and returns CSV text ending with a newline. Cells containing the delimiter, a quote or a newline are quoted and escaped automatically.
import std:csv
import std:println
text = csv:generate([["name", "age"], ["Alice", "30"], ["Bob", "25"]])
println(text::trim())
Output:
name,age
Alice,30
Bob,25
import std:csv
import std:println
println(csv:generate([["has \"quote\"", "and,comma"]])::trim())
# "has ""quote""","and,comma"
Every Cell Must Be a String
Numbers and booleans are rejected, so convert before generating:
import std:csv
# csv:generate([["Alice", 30]])
# Error: CSV generation error: csv:generate expects all cells to be strings
import std:csv
import std:println
people = [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]
rows = [["name", "age"]]
loop through people with p {
rows::push([p:name, p:age::to_string()])
}
println(csv:generate(rows)::trim())
Output:
name,age
Alice,30
Bob,25
Rows must be lists: a list of maps or a flat list of strings raises
CSV generation error: csv:generate expects all rows to be lists.
Files
import std:csv
import std:io
import std:println
p = `mktemp`
out = io:open(p, true, true)
out::write(csv:generate([["name", "age"], ["Alice", "30"], ["Bob", "17"]]))
out::close()
f = io:open(p)
rows = csv:parse(f::read_all())
f::close()
adults = rows[1;]::filter(|row| row[1]::to_number() >= 18)
println(adults::length()) # 1
println(adults[0][0]) # Alice
Gotchas
parsereturns lists, never maps:rows[0]:nameis a type error. Index by position, or build maps from the header row as shown above.- All parsed cells are strings, including numeric columns; use
::to_number(), and validate first because an unparsable value is a runtime error. - All generated cells must already be strings.
- Ragged rows fail on parse rather than being padded.
generateoutput ends with a newline;::trim()it when printing inline.
See Also
I/O and Streams (std:io)
Open files and work with the standard streams.
Overview
std:io exports one function and three stream values:
| Export | Kind | Description |
|---|---|---|
open(path, create = false, truncate = false) | Function → Stream | Open a file for reading and writing |
stdin | Stream | Standard input |
stdout | Stream | Standard output |
stderr | Stream | Standard error |
stdin, stdout and stderr are values, not functions — write
io:stdout, never io:stdout().
There are no io:read_file / io:write_file helpers: reading a whole file means
opening a stream and calling read_all() on it.
Quick Start
import std:io
import std:println
p = `mktemp`
f = io:open(p, true, true) # create = true, truncate = true
f::write("first\nsecond\n")
f::close()
r = io:open(p)
println(r::read_all())
r::close()
open(path, create = false, truncate = false)
Opens path and returns a stream. The stream is always readable and
writable.
create— create the file when it does not exist. Withcreate = false(the default) a missing file raisesStream error: Failed to open '<path>'.truncate— empty the file on open.
import std:io
import std:println
p = `mktemp`
# create and overwrite from scratch
out = io:open(p, true, true)
out::write("data\n")
out::close()
# read an existing file
r = io:open(p)
println(r::read_all()) # data
r::close()
Opening a file that does not exist without create terminates the program:
import std:io
# io:open("no-such-file.txt")
# Error: Stream error: Failed to open 'no-such-file.txt': No such file or directory
Since there is no way to trap a runtime error, test for the file first with a shell command:
import std:io
import std:println
p = `mktemp`
exists = `test -e "${p}" && echo yes || echo no`
content = match exists {
"yes" => {
f = io:open(p)
text = f::read_all()
f::close()
text
}
_ => { "" }
}
println(content::length()) # 0
Stream Methods
All stream reads are eager and blocking — there are no lazy iterators.
| Method | Returns | Description |
|---|---|---|
read(chunk_kb = 8) | String or nil | Read up to chunk_kb kilobytes from the current position; nil at end of input |
read_line() | String or nil | Read one line without its newline; nil at end of input |
read_all() | String | Read everything from the current position to end of input; "" at end of input |
read_lines() | List | Read the rest of the stream and split it into lines; [] at end of input |
write(text) | Number | Write text, returns the number of bytes written |
is_terminal() | Boolean | Whether the stream is attached to a terminal |
close() | nil | Close the stream |
to_string() | String | Display form of the stream |
Reading Line by Line
read_lines() returns a list, which can then be iterated. A stream itself is
not iterable — loop through f { … } is a runtime error.
import std:io
import std:println
p = `mktemp`
f = io:open(p, true, true)
f::write("alpha\nbeta\ngamma\n")
f::close()
r = io:open(p)
loop through r::read_lines() with line {
println(line::upper())
}
r::close()
read_line() reads one line at a time and returns nil once the input is
exhausted:
import std:io
import std:println
p = `mktemp`
f = io:open(p, true, true)
f::write("one\ntwo\n")
f::close()
r = io:open(p)
println(r::read_line()) # one
println(r::read_line()) # two
println(r::read_line() == nil) # true
r::close()
Reading in Chunks
read(chunk_kb) reads at most chunk_kb kilobytes and advances the position,
which keeps memory use bounded for large files. It returns nil once the end of
input is reached, which is the signal to stop.
import std:io
import std:println
p = `mktemp`
`yes aaaaaaaaaa | head -n 2000 > "${p}"`
f = io:open(p)
total = 0
loop {
chunk = f::read(4)
match {
chunk == nil => { break }
_ => { total += chunk::length() }
}
}
f::close()
println(total) # 22000
Writing
write(text) returns the number of bytes written and starts at the stream’s
current position. There is no append mode: reopening a file and writing
overwrites from the beginning of the file rather than adding to the end.
import std:io
import std:println
p = `mktemp`
f = io:open(p, true, true)
f::write("abcdef")
f::close()
# writing again starts at offset 0 and overwrites in place
g = io:open(p)
println(g::write("XY")) # 2
g::close()
r = io:open(p)
println(r::read_all()) # XYcdef
r::close()
To add to a file, read it, concatenate, then write the whole thing back with
truncate = true:
import std:io
import std:println
p = `mktemp`
first = io:open(p, true, true)
first::write("line 1\n")
first::close()
r = io:open(p)
existing = r::read_all()
r::close()
out = io:open(p, true, true)
out::write(existing + "line 2\n")
out::close()
check = io:open(p)
println(check::read_lines()) # [line 1, line 2]
check::close()
The Standard Streams
io:stdout and io:stderr are writable; io:stdin is readable. They can be
passed as the second argument to print / println.
import std:io
import std:println
println("progress goes to stderr", io:stderr)
println("results go to stdout", io:stdout)
is_terminal() distinguishes an interactive run from a redirected or piped one:
import std:io
import std:println
interactive = io:stdout::is_terminal()
println(interactive::is_bool()) # true
Reading Piped Input
A closure on the right of | receives the previous stage’s output on io:stdin:
import std:io
import std:println
count_lines = || {
lines = io:stdin::read_lines()
println("lines: ${lines::length()}")
}
`printf 'alpha\nbeta\ngamma\n'` | count_lines()
read_all() grabs the whole piped payload at once:
import std:io
import std:println
shout = || {
println(io:stdin::read_all()::trim()::upper())
}
`echo hello` | shout()
Gotchas
io:opennever appends; use read-concatenate-rewrite as shown above.- Any method call on a closed stream raises
Stream error: Operation on closed stream. read_all()onio:stdinblocks until the input is closed, so avoid it in a program that is meant to run interactively without piped input.- Reads are relative to the stream position, and each read type signals
exhaustion differently: at end of input
read_all()returns"",read_lines()returns[], andread(n)/read_line()returnnil. - Streams are not iterable and have no
length(); callread_lines()first.
See Also
Environment (std:env)
Read and write environment variables, and reach the script’s command-line arguments.
Overview
std:env exports three values — not functions:
| Export | Kind | Description |
|---|---|---|
var | Map-like object | The live process environment |
args | Map | Command-line arguments, keyed by "0", "1", … |
argv | Map | The same snapshot as args |
Because these are values, they are used without call parentheses:
env:var:HOME, env:args["0"]. There is no env:get(...) and no env:args().
Quick Start
import std:env
import std:println
println(env:var::contains("PATH")) # true
println(env:var::get("NO_SUCH_VAR", "none")) # none
env:var["APP_MODE"] = "debug"
println(env:var:APP_MODE) # debug
var — The Process Environment
env:var behaves like a map of strings to strings, and it is live: assigning
to it changes the environment of the running process, so child processes started
with backticks see the new value.
Reading
import std:env
import std:println
home = env:var:HOME # bare-identifier key
path = env:var["PATH"] # any string expression as key
println(home::is_string()) # true
println(path::is_string()) # true
A missing variable raises Key not found: Environment variable not found,
which terminates the program. Use get with a default, or contains, for
anything that may be absent:
import std:env
import std:println
port = env:var::get("DEMO_PORT", "8080")
println(port) # 8080
println(env:var::contains("DEMO_PORT")) # false
Writing
import std:env
import std:println
env:var["GREETING"] = "hello"
println(`echo $GREETING`) # hello
merge sets several variables at once, and delete removes one:
import std:env
import std:println
env:var::merge({"SERVICE": "api", "REGION": "eu-west-1"})
println(env:var:SERVICE) # api
println(env:var::delete("SERVICE")) # true
println(env:var::contains("SERVICE")) # false
Methods
env:var supports the map methods:
| Method | Returns | Description |
|---|---|---|
get(name, default = nil) | String or default | Safe read |
contains(name) | Boolean | Whether the variable is set |
keys() | List | All variable names |
values() | List | All variable values |
to_list() | List | (name, value) tuples |
length() | Number | Number of variables |
delete(name) | Boolean | Unset a variable |
merge(map) | nil | Set every pair in map |
import std:env
import std:println
env:var::merge({"DEMO_ONE": "1", "DEMO_TWO": "2"})
demo = env:var::keys()::filter(|k| k::starts_with("DEMO_"))
println(demo::sort()) # [DEMO_ONE, DEMO_TWO]
args and argv — Command-Line Arguments
env:args and env:argv are maps captured at startup and keyed by strings,
so index them with "0", "1", … and never with the numbers 0, 1.
Interpreter flags (anything starting with -) are consumed by the CLI and never
appear here.
import std:env
import std:println
println(env:args::is_map()) # true
println(env:args::get("0", nil)::is_string()) # true
The intended layout is "0" for the script path and "1", "2", … for the
script’s own arguments.
Known bug in 0.1.22: positional arguments do not work. Every argument is written back to key
"0", so the map always holds exactly one entry: the script path when the script is run with no arguments, and otherwise the last argument. Keys"1"and up are never populated.suji script.si # env:args is {0: script.si} suji script.si build # env:args is {0: build} suji script.si build fast # env:args is {0: fast}Until this is fixed, treat
env:argsas unreliable for anything but a single value, read it defensively, and pass multiple inputs another way.
import std:env
import std:println
println(env:args::length()) # 1
println(env:args::contains("1")) # false
Read the value defensively and do not rely on higher indices:
import std:env
import std:println
first = env:args::get("1", nil)
println(first == nil) # true
For scripts that need several inputs, prefer environment variables or a single argument that you split yourself:
import std:env
import std:println
# TARGETS="a,b,c" suji script.si
raw = env:var::get("TARGETS", "")
targets = match {
raw == "" => [],
_ => raw::split(","),
}
println(targets::length()) # 0
Gotchas
env:var,env:argsandenv:argvare values; calling them (env:var()) is an error.- Environment values are always strings — convert with
::to_number()when you need arithmetic, and validate first because an unparsable string is a runtime error. - Reading a missing variable with
:or[]terminates the program;getis the safe form. argsandargvare snapshots taken at startup, so mutating them has no effect on anything else.
See Also
Operating System (std:os)
Inspect the host and the current process, and create or remove files and directories.
Overview
std:os exports:
| Function | Returns | Description |
|---|---|---|
name() | String | "linux", "darwin" or "windows" |
hostname() | String | Host name |
uptime_ms() | Number | Milliseconds since the machine booted |
tmp_dir() | String | Temporary directory |
home_dir() | String | Current user’s home directory |
work_dir() | String | Current working directory |
pid() | Number | Process id |
ppid() | Number | Parent process id |
uid() | Number | User id |
gid() | Number | Group id |
exit(code) | — | Terminate the process with code |
mkdir(path, create_all = true) | nil | Create a directory |
rm(path) | nil | Delete a file |
rmdir(path) | nil | Delete an empty directory |
stat(path, follow_symlinks = false) | Map | File metadata |
There is no os:exec — run external commands with backtick shell templates.
There is no os:env either; environment variables live in std:env.
Directory listing and file moves are not part of the module; use the shell
(`ls -1 dir`, `mv a b`).
Quick Start
import std:os
import std:println
println(os:name()::is_string()) # true
println(os:work_dir()::is_string()) # true
println(os:pid() > 0) # true
Host and Process Information
import std:os
import std:println
platform = os:name()
println(["linux", "darwin", "windows"]::contains(platform)) # true
println(os:hostname()::length() > 0) # true
println(os:uid() >= 0) # true
println(os:gid() >= 0) # true
println(os:ppid() > 0) # true
Platform Branching
import std:os
import std:println
opener = match os:name() {
"darwin" => "open",
"linux" => "xdg-open",
_ => "start",
}
println(opener::is_string()) # true
Directories
tmp_dir(), home_dir() and work_dir() return absolute paths.
import std:os
import std:path
import std:println
cache = path:join([os:home_dir(), ".cache", "demo"])
println(path:is_abs(cache)) # true
scratch = path:join([os:tmp_dir(), "demo-work"])
os:mkdir(scratch)
println(os:stat(scratch):is_directory) # true
os:rmdir(scratch)
uptime_ms()
Milliseconds since the machine booted, derived from a whole-second value — so it
is not useful for timing short operations. Use time:now():epoch_ms for that.
import std:os
import std:println
println(os:uptime_ms() > 0) # true
exit(code)
Terminates the process immediately with the given exit status. Nothing after it runs.
import std:os
import std:println
status = 0
match {
status != 0 => { os:exit(status) }
_ => { println("continuing") }
}
Files and Directories
mkdir(path, create_all = true)
Creates a directory. With the default create_all = true any missing parents are
created too and an existing directory is not an error. With create_all = false
the parent must already exist.
import std:os
import std:path
import std:println
root = `mktemp -d`
nested = path:join([root, "a", "b", "c"])
os:mkdir(nested)
println(os:stat(nested):is_directory) # true
os:mkdir(nested) # idempotent with create_all = true
println(os:stat(nested):is_directory) # true
rm(path) and rmdir(path)
rm deletes a file and rmdir deletes an empty directory. Using rm on a
directory raises Invalid operation: Cannot remove directory '<path>'; use os:rmdir.
import std:io
import std:os
import std:path
import std:println
root = `mktemp -d`
file = path:join([root, "note.txt"])
f = io:open(file, true, true)
f::write("temporary\n")
f::close()
os:rm(file)
println(`test -e "${file}" && echo yes || echo no`) # no
os:rmdir(root)
println(`test -e "${root}" && echo yes || echo no`) # no
To remove a non-empty tree, delete the contents first or shell out to
`rm -rf dir`.
stat(path, follow_symlinks = false)
Returns a map of metadata. follow_symlinks = false (the default) describes the
link itself; passing true describes the target.
| Field | Type | Description |
|---|---|---|
size | Number | Size in bytes |
is_directory | Boolean | Whether the path is a directory |
is_symlink | Boolean | Whether the path is a symbolic link |
mtime | Number | Last modification time, epoch milliseconds |
atime | Number | Last access time, epoch milliseconds |
ctime | Number | Creation time, epoch milliseconds |
link | String or nil | Symlink target, nil when not a symlink |
inode | Number | Inode number (0 on Windows) |
mode | Number | Raw Unix mode bits (file attributes on Windows) |
uid | Number | Owning user id (0 on Windows) |
gid | Number | Owning group id (0 on Windows) |
import std:io
import std:os
import std:println
p = `mktemp`
f = io:open(p, true, true)
f::write("0123456789")
f::close()
s = os:stat(p)
println(s:size) # 10
println(s:is_directory) # false
println(s:is_symlink) # false
println(s:link) # nil
println(s:mtime > 0) # true
Permission bits are the low nine bits of mode, so mode % 512 gives the
familiar octal value as a decimal number (384 is 0600):
import std:io
import std:os
import std:println
p = `mktemp`
f = io:open(p, true, true)
f::write("x")
f::close()
`chmod 640 "${p}"`
println(os:stat(p):mode % 512) # 416
Following a symlink changes both is_symlink and size:
import std:io
import std:os
import std:path
import std:println
root = `mktemp -d`
target = path:join([root, "target.txt"])
link = path:join([root, "link.txt"])
f = io:open(target, true, true)
f::write("hello world\n")
f::close()
`ln -s "${target}" "${link}"`
println(os:stat(link):is_symlink) # true
println(os:stat(link):link == target) # true
println(os:stat(link, true):is_symlink) # false
println(os:stat(link, true):size) # 12
Checking Whether a Path Exists
os:stat raises on a missing path rather than returning nil, and there is
no way to trap a runtime error, so probe with the shell before calling it:
import std:os
import std:println
p = `mktemp`
exists = `test -e "${p}" && echo yes || echo no`
size = match exists {
"yes" => os:stat(p):size,
_ => 0,
}
println(size) # 0
The same pattern distinguishes files from directories:
import std:println
d = `mktemp -d`
kind = `test -d "${d}" && echo dir || echo file`
println(kind) # dir
Gotchas
os:staton a missing path terminates the program withInvalid operation: Failed to stat '<path>'.os:rmrefuses directories andos:rmdirrefuses non-empty directories.uptime_ms()measures machine uptime, not process or wall-clock time.- A backtick command that exits non-zero terminates the script, which is why the
existence probes above end in
|| echo no. mode,uid,gidandinodeare placeholders on Windows.
See Also
Paths (std:path)
Build and take apart filesystem paths as strings.
Overview
std:path exports five functions, all of them purely textual — none of them
touches the filesystem:
| Function | Returns | Description |
|---|---|---|
is_abs(p) | Boolean | Whether p is an absolute path |
join(parts) | String | Join the parts of one list into a path |
dirname(p) | String | Everything before the final component |
basename(p) | String | The final component |
extname(p) | String | The extension, including the leading dot |
normalize(p) | String | Collapse . and .. segments lexically |
There is no path:exists, path:absolute, path:extension, path:read or
path:write. Use std:os for metadata and std:io for
contents.
Quick Start
import std:path
import std:println
full = path:join(["/var", "data", "users.json"])
println(full) # /var/data/users.json
println(path:dirname(full)) # /var/data
println(path:basename(full)) # users.json
println(path:extname(full)) # .json
println(path:is_abs(full)) # true
is_abs(p)
import std:path
import std:println
println(path:is_abs("/etc/hosts")) # true
println(path:is_abs("etc/hosts")) # false
println(path:is_abs("~/notes")) # false
~ is not expanded — use os:home_dir() when you need the home directory.
join(parts)
Takes a single list of components and joins them with the platform
separator. It is a one-argument function: path:join("a", "b") raises
Arity mismatch: Function expects 1 arguments, got 2.
import std:path
import std:println
println(path:join(["home", "user", "file.txt"])) # home/user/file.txt
println(path:join(["/etc", "nginx", "nginx.conf"])) # /etc/nginx/nginx.conf
An absolute component discards everything before it, and .. segments are kept
until you normalize:
import std:path
import std:println
println(path:join(["a", "/b"])) # /b
println(path:join(["a/b", "../c"])) # a/b/../c
println(path:normalize(path:join(["a/b", "../c"]))) # a/c
Combine it with std:os to build paths relative to a well-known directory:
import std:os
import std:path
import std:println
log_file = path:join([os:tmp_dir(), "demo", "run.log"])
println(path:basename(log_file)) # run.log
println(path:extname(log_file)) # .log
dirname(p)
Returns the parent portion of the path. A bare filename has "." as its
directory, and the root is its own parent.
import std:path
import std:println
println(path:dirname("/home/user/file.txt")) # /home/user
println(path:dirname("file.txt")) # .
println(path:dirname("/")) # /
basename(p)
Returns the final component. A trailing separator is ignored.
import std:path
import std:println
println(path:basename("/home/user/file.txt")) # file.txt
println(path:basename("/a/b/")) # b
println(path:basename("archive.tar.gz")) # archive.tar.gz
extname(p)
Returns the extension including the dot, or "" when there is none. Only the
last extension is returned, and a leading-dot filename such as .gitignore
counts as having no extension.
import std:path
import std:println
println(path:extname("report.pdf")) # .pdf
println(path:extname("archive.tar.gz")) # .gz
println(path:extname("README")) #
println(path:extname(".gitignore")) #
Strip an extension by combining basename with dirname and join:
import std:path
import std:println
swap_extension = |p, ext| {
name = path:basename(p)
stem = name[0;name::length() - path:extname(p)::length()]
path:join([path:dirname(p), stem + ext])
}
println(swap_extension("/tmp/notes/report.md", ".html")) # /tmp/notes/report.html
normalize(p)
Collapses . and .. segments and redundant separators. It is a purely
lexical operation: symlinks are not resolved, the path need not exist, and a
relative path stays relative.
import std:path
import std:println
println(path:normalize("/home/user/../user/./file.txt")) # /home/user/file.txt
println(path:normalize("a/./b/../c")) # a/c
println(path:normalize("./a//b/")) # a/b
println(path:normalize("../x")) # ../x
Working with Filesystem Metadata
std:path says nothing about whether a path exists. Pair it with os:stat,
guarding the call because os:stat raises on a missing path:
import std:io
import std:os
import std:path
import std:println
p = `mktemp`
f = io:open(p, true, true)
f::write("hello\n")
f::close()
exists = `test -e "${p}" && echo yes || echo no`
size = match exists {
"yes" => os:stat(p):size,
_ => 0,
}
println("${path:basename(p)::length() > 0} ${size}") # true 6
Gotchas
jointakes one list, not a variable number of arguments.normalizenever consults the filesystem, so it cannot resolve symlinks and cannot turn a relative path into an absolute one; prefixos:work_dir()yourself when you need that.extnameincludes the dot, so comparisons are against".json", not"json".- Separators follow the host platform, so hard-coding
/in a joined path defeats the point ofjoin.
See Also
Random Numbers (std:random)
Random numbers, selections, shuffling, and random string generation.
Overview
std:random exports:
| Function | Returns | Description |
|---|---|---|
random() | Number | A value in ([0, 1)) |
seed(value = nil) | nil | Seed the generator; nil reseeds from the system |
integer(a, b) | Number | An integer in ([a, b)) |
pick(xs) | Any | One element of xs |
shuffle(xs) | List | A new list with the elements reordered |
sample(xs, k) | List | Up to k distinct elements of xs |
string(allowed_chars, length) | String | Characters drawn from allowed_chars |
hex_string(length = 16) | String | Lowercase hex characters |
alpha_string(length = 16, capitals = true) | String | Letters |
numeric_string(length = 16) | String | Digits |
alphanumeric_string(length = 16, capitals = true) | String | Letters and digits |
There is no random:int and no random:float — the names are integer and
random.
This is a general-purpose generator, not a cryptographically secure one. Do not use it for keys, tokens or passwords.
Quick Start
import std:random
import std:println
value = random:random()
println(value >= 0 && value < 1) # true
roll = random:integer(1, 7) # 1 through 6
println(roll >= 1 && roll < 7) # true
println(random:pick(["rock", "paper", "scissors"])::is_string()) # true
println(random:hex_string(8)::length()) # 8
random()
A number in ([0, 1)).
import std:random
import std:println
random:seed(7)
println(random:random()) # 0.030317360865101395
seed(value = nil)
Seeding makes a run reproducible, which is what makes the examples on this page
verifiable. Calling seed() with no argument reseeds from the system, restoring
unpredictable output.
import std:random
import std:println
random:seed(7)
first = random:random()
random:seed(7)
second = random:random()
println(first == second) # true
integer(a, b)
An integer in the half-open range ([a, b)) — b is never returned. Use
integer(1, 7) for a six-sided die.
import std:random
import std:println
random:seed(7)
rolls = []
loop through 0..5 with i {
rolls::push(random:integer(1, 7))
}
println(rolls) # [1, 2, 1, 4, 2]
pick(xs)
Returns one element of a list. An empty list raises
Index out of bounds: Index 0 out of bounds for length 0, so check the length
first.
import std:random
import std:println
random:seed(7)
choices = ["rock", "paper", "scissors"]
pick_safe = |xs| match {
xs::length() == 0 => nil,
_ => random:pick(xs),
}
println(pick_safe(choices)) # rock
println(pick_safe([]) == nil) # true
shuffle(xs)
Returns a new list with the elements reordered; the argument is left untouched.
import std:random
import std:println
random:seed(7)
original = [1, 2, 3, 4, 5]
shuffled = random:shuffle(original)
println(shuffled) # [3, 5, 4, 1, 2]
println(original) # [1, 2, 3, 4, 5]
sample(xs, k)
Returns up to k distinct elements, chosen without replacement. Asking for
more than the list holds returns everything, in the original order.
import std:random
import std:println
random:seed(1)
println(random:sample([1, 2, 3, 4, 5], 3)) # [5, 4, 3]
println(random:sample([1, 2, 3], 10)) # [1, 2, 3]
Random Strings
string(allowed_chars, length) draws from a character set you supply; the other
four helpers use fixed alphabets and default to a length of 16.
import std:random
import std:println
random:seed(7)
println(random:string("ACGT", 12)) # ACAGCTACAGGA
import std:random
import std:println
random:seed(7)
println(random:hex_string(8)) # 04284f24
println(random:alpha_string(10)) # GlhBETgtud
println(random:numeric_string(6)) # 564347
println(random:alphanumeric_string(12)) # OQ43B5Zc7TiA
capitals = false restricts alpha_string and alphanumeric_string to
lowercase:
import std:random
import std:println
random:seed(7)
println(random:alpha_string(10, false)) # ahdohyegds
println(random:alphanumeric_string(12, false)) # wadnw56usxon
Defaults produce 16 characters:
import std:random
import std:println
println(random:hex_string()::length()) # 16
println(random:alpha_string()::length()) # 16
println(random:numeric_string()::length()) # 16
println(random:alphanumeric_string()::length()) # 16
Examples
Weighted Choice
import std:random
import std:println
random:seed(7)
weighted = |options| {
total = options::fold(0, |acc, o| acc + o:weight)
target = random:random() * total
running = 0
chosen = nil
loop through options with o {
running += o:weight
match {
chosen == nil && running > target => { chosen = o:name }
}
}
chosen
}
options = [
{"name": "common", "weight": 80},
{"name": "rare", "weight": 19},
{"name": "legendary", "weight": 1},
]
println(weighted(options)) # common
Random Test Data
import std:random
import std:println
random:seed(7)
make_user = || {
{
"id": random:hex_string(8),
"name": random:alpha_string(6),
"age": random:integer(18, 65),
}
}
users = []
loop through 0..3 with i {
users::push(make_user())
}
println(users::length()) # 3
println(users[0]:id::length()) # 8
println(users[0]:age >= 18) # true
Sampling Rows
import std:random
import std:println
random:seed(7)
rows = 1..=100
sampled = random:sample(rows, 5)
println(sampled::length()) # 5
println(sampled::sort()) # [4, 15, 28, 31, 55]
Gotchas
integer(a, b)excludesb.pickon an empty list is a runtime error rather thannil.shufflereturns a new list, sorandom:shuffle(xs)alone does nothing visible — assign the result.- Seeded sequences depend on the order of every call to the module, so inserting an extra call changes everything after it.
- Not suitable for security; use it for simulation, sampling and test data.
See Also
Time and Dates (std:time)
Read the clock, convert between epoch milliseconds and ISO 8601 text, and sleep.
Overview
std:time exports four functions:
| Function | Returns | Description |
|---|---|---|
now() | Map {epoch_ms, iso, tz} | The current instant |
sleep(ms) | nil | Block for ms milliseconds |
parse_iso(text) | Map {epoch_ms, tz} | Parse an ISO 8601 / RFC 3339 timestamp |
format_iso(epoch_ms, tz = "Z") | String | Format epoch milliseconds |
Timestamps are epoch milliseconds — a plain number. There is no date value
type, no calendar arithmetic, no time:format and no strftime-style patterns.
Quick Start
import std:time
import std:println
now = time:now()
println(now:epoch_ms::is_number()) # true
println(now:tz) # Z
parsed = time:parse_iso("2023-11-10T15:30:00.000Z")
println(parsed:epoch_ms) # 1699630200000
println(time:format_iso(parsed:epoch_ms)) # 2023-11-10T15:30:00.000Z
now()
Returns a map with three fields:
| Field | Type | Description |
|---|---|---|
epoch_ms | Number | Milliseconds since the Unix epoch |
iso | String | UTC timestamp with milliseconds, e.g. 2023-11-10T15:30:00.000Z |
tz | String | Always "Z" — now() reports UTC |
import std:time
import std:println
now = time:now()
println(now::keys()) # [epoch_ms, iso, tz]
println(now:iso::length()) # 24
println(now:iso::ends_with("Z")) # true
println(now:epoch_ms > 1700000000000) # true
now() is the timing primitive for measuring elapsed work — os:uptime_ms() has
only whole-second resolution:
import std:time
import std:println
started = time:now():epoch_ms
time:sleep(50)
elapsed = time:now():epoch_ms - started
println(elapsed >= 50) # true
sleep(ms)
Blocks for ms milliseconds and returns nil. ms must be a non-negative
integer: a fraction raises
Type error: time:sleep requires a non-negative integer milliseconds, and a
negative value raises Invalid operation: time:sleep requires non-negative duration.
import std:time
import std:println
time:sleep(100)
println("waited") # waited
A simple retry-with-backoff loop:
import std:time
import std:println
attempt = 0
result = nil
loop {
attempt++
result = `echo ok`
match {
result == "ok" => { break }
attempt >= 3 => { break }
_ => { time:sleep(50 * attempt) }
}
}
println("${result} after ${attempt}") # ok after 1
parse_iso(text)
Parses an ISO 8601 / RFC 3339 timestamp and returns {epoch_ms, tz}. The
epoch_ms value is always UTC; tz reports the offset that appeared in the
input, normalised so that a trailing Z becomes "+00:00".
import std:time
import std:println
utc = time:parse_iso("2023-11-10T15:30:00.000Z")
println(utc:epoch_ms) # 1699630200000
println(utc:tz) # +00:00
offset = time:parse_iso("2023-11-10T15:30:00.000+05:00")
println(offset:epoch_ms) # 1699612200000
println(offset:tz) # +05:00
Milliseconds are optional in the input:
import std:time
import std:println
println(time:parse_iso("2023-11-10T15:30:00Z"):epoch_ms) # 1699630200000
println(time:parse_iso("2023-11-10T15:30:00.123Z"):epoch_ms) # 1699630200123
Unparsable text raises Invalid operation: Invalid ISO-8601 time and terminates
the program, so validate untrusted input before parsing:
import std:time
import std:println
parse_safe = |text| match {
text ~ /^[0-9]{4}-[0-9]{2}-[0-9]{2}T/ => time:parse_iso(text):epoch_ms,
_ => nil,
}
println(parse_safe("2023-11-10T15:30:00Z")) # 1699630200000
println(parse_safe("not a date") == nil) # true
format_iso(epoch_ms, tz = "Z")
Formats epoch milliseconds as an ISO 8601 string with milliseconds. tz is
either "Z" (the default, meaning UTC) or an offset such as "+02:00" or
"-07:00"; the wall-clock part is shifted accordingly. An unrecognised offset
raises Invalid operation: invalid tz offset.
import std:time
import std:println
epoch_ms = 1699630200000
println(time:format_iso(epoch_ms)) # 2023-11-10T15:30:00.000Z
println(time:format_iso(epoch_ms, "+02:00")) # 2023-11-10T17:30:00.000+02:00
println(time:format_iso(epoch_ms, "-07:00")) # 2023-11-10T08:30:00.000-07:00
println(time:format_iso(0)) # 1970-01-01T00:00:00.000Z
Arithmetic on Timestamps
Because timestamps are numbers, offsets are ordinary arithmetic:
import std:time
import std:println
second = 1000
minute = 60 * second
hour = 60 * minute
day = 24 * hour
start = time:parse_iso("2023-11-10T15:30:00.000Z"):epoch_ms
println(time:format_iso(start + hour)) # 2023-11-10T16:30:00.000Z
println(time:format_iso(start + 7 * day)) # 2023-11-17T15:30:00.000Z
println((start - (start - 90 * second)) / second) # 90
Durations are easier to read when formatted yourself:
import std:println
humanize = |ms| {
total_seconds = (ms / 1000)::floor()
minutes = (total_seconds / 60)::floor()
seconds = total_seconds % 60
"${minutes}m ${seconds}s"
}
println(humanize(185000)) # 3m 5s
Timestamping Output
import std:time
import std:println
log = |message| println("[${time:now():iso}] ${message}")
log("started")
println(time:format_iso(time:parse_iso(time:now():iso):epoch_ms)::ends_with("Z")) # true
Date-only strings for filenames come from slicing the ISO text:
import std:time
import std:println
iso = time:format_iso(1699630200000)
println(iso[0;10]) # 2023-11-10
println(iso[11;19]) # 15:30:00
Gotchas
- There is no formatting language: build custom layouts by slicing the ISO string
or by doing arithmetic on
epoch_ms. now():tzis always"Z"; the module never reports the machine’s local timezone.format_isoshifts the displayed wall clock for an offset but always describes the same instant.sleeprequires an integer;time:sleep(0.5)is a type error.- Parse and offset errors terminate the program.
See Also
UUID (std:uuid)
Generate and validate UUID strings.
Overview
std:uuid exports three functions:
| Function | Returns | Description |
|---|---|---|
v4() | String | A random UUID |
v5(namespace, name) | String | A deterministic UUID derived from a namespace and a name |
is_valid(text) | Boolean | Whether text looks like a UUID |
UUIDs are plain lowercase strings of 36 characters — 32 hex digits and 4 hyphens. Only versions 4 and 5 are available; there is no v1, v3, v6 or v7, and no function to inspect a UUID’s version.
Quick Start
import std:uuid
import std:println
id = uuid:v4()
println(id::length()) # 36
println(uuid:is_valid(id)) # true
v4()
Generates a random UUID. Two calls essentially never collide, so it is the right choice for record ids and correlation ids.
import std:uuid
import std:println
a = uuid:v4()
b = uuid:v4()
println(a != b) # true
println(a::split("-")::length()) # 5
v4 draws on the same general-purpose randomness as std:random,
so treat the values as unique but not secret.
v5(namespace, name)
Derives a UUID by hashing a namespace UUID together with a name. The same inputs always produce the same output, which makes it useful for stable ids computed from data you already have.
namespace(String) — a valid UUIDname(String) — any application-defined string
import std:uuid
import std:println
ns = "550e8400-e29b-41d4-a716-446655440000"
id = uuid:v5(ns, "my-resource-name")
println(id) # fcd6217c-e6c0-57a2-a5ba-53789713bce1
println(uuid:v5(ns, "my-resource-name") == id) # true
println(uuid:v5(ns, "other-resource") == id) # false
An invalid namespace raises Invalid operation: invalid namespace uuid and
terminates the program, so keep the namespace a literal or a validated value:
import std:uuid
import std:println
ns = "550e8400-e29b-41d4-a716-446655440000"
println(uuid:is_valid(ns)) # true
stable_id = |namespace, name| match {
uuid:is_valid(namespace) => uuid:v5(namespace, name),
_ => nil,
}
println(stable_id("nope", "x") == nil) # true
println(stable_id(ns, "x")::length()) # 36
is_valid(text)
Checks whether a string has UUID shape. Hyphens are optional and hex digits may be upper or lower case.
import std:uuid
import std:println
println(uuid:is_valid("550e8400-e29b-41d4-a716-446655440000")) # true
println(uuid:is_valid("550e8400e29b41d4a716446655440000")) # true
println(uuid:is_valid("550E8400-E29B-41D4-A716-446655440000")) # true
println(uuid:is_valid("not-a-uuid")) # false
println(uuid:is_valid("")) # false
Note that is_valid accepts the unhyphenated form, so validate and then
normalise if your storage expects hyphens.
Examples
Tagging Records
import std:json
import std:uuid
import std:println
records = ["alpha", "beta"]
tagged = records::map(|name| {
{
"id": uuid:v4(),
"name": name,
}
})
println(tagged::length()) # 2
println(tagged[0]:id::length()) # 36
println(json:generate(tagged[1])::contains("beta")) # true
Stable Ids from Content
import std:uuid
import std:println
ns = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
id_for = |url| uuid:v5(ns, url)
first = id_for("https://example.com/a")
again = id_for("https://example.com/a")
other = id_for("https://example.com/b")
println(first == again) # true
println(first == other) # false
Gotchas
v4values are random per call; do not use one as a cache key for the same logical thing across runs — usev5for that.v5requires a valid namespace UUID; anything else is a runtime error.is_validchecks shape only, not the version or variant bits.- These are not secrets; use
std:cryptofor anything that must be unguessable or authenticated.
See Also
Text Encoding (std:encoding)
Encode and decode text as Base64, hex, or percent-encoding.
Overview
All six functions take one string and return a string:
| Function | Description |
|---|---|
base64_encode(text) | Standard Base64 with = padding |
base64_decode(text) | Decode Base64; invalid input raises |
hex_encode(text) | Lowercase hex of the UTF-8 bytes |
hex_decode(text) | Decode hex; invalid or odd-length input raises |
percent_encode(text) | Percent-encode every non-alphanumeric character |
percent_decode(text) | Decode %XX sequences |
Text is treated as UTF-8, and the decoders must produce valid UTF-8 — these functions handle text, not arbitrary binary data.
Quick Start
import std:encoding
import std:println
text = "hello world"
b64 = encoding:base64_encode(text)
println(b64) # aGVsbG8gd29ybGQ=
println(encoding:base64_decode(b64)) # hello world
hex = encoding:hex_encode(text)
println(hex) # 68656c6c6f20776f726c64
println(encoding:hex_decode(hex)) # hello world
q = encoding:percent_encode("Hello & welcome!")
println(q) # Hello%20%26%20welcome%21
println(encoding:percent_decode(q)) # Hello & welcome!
Base64
import std:encoding
import std:println
println(encoding:base64_encode("data")) # ZGF0YQ==
println(encoding:base64_decode("ZGF0YQ==")) # data
println(encoding:base64_encode("héllo")) # aMOpbGxv
Encoding an empty string returns an empty string. Decoding text that is not valid
Base64 raises Type error: invalid base64:
import std:encoding
# encoding:base64_decode("!!!")
# Error: Type error: invalid base64
Hex
Hex encoding is a fixed two characters per byte, so a multi-byte character produces more than two:
import std:encoding
import std:println
println(encoding:hex_encode("hi")) # 6869
println(encoding:hex_encode("héllo")) # 68c3a96c6c6f
println(encoding:hex_decode("68c3a96c6c6f")) # héllo
Invalid characters and odd-length input both raise Type error: invalid hex:
import std:encoding
# encoding:hex_decode("zz") # Error: Type error: invalid hex
# encoding:hex_decode("abc") # Error: Type error: invalid hex — odd length
Hex is also the output format of std:crypto digests, so
hex_decode can turn a digest back into its raw bytes only when those bytes
happen to be valid UTF-8 — usually they are not.
Percent-Encoding
percent_encode uses a conservative policy: everything that is not an ASCII
letter or digit is encoded, including /, -, _ and .. That makes it right
for a single query-string value, and wrong for a whole URL.
import std:encoding
import std:println
println(encoding:percent_encode("a/b?c=d e")) # a%2Fb%3Fc%3Dd%20e
println(encoding:percent_encode("héllo")) # h%C3%A9llo
println(encoding:percent_decode("h%C3%A9llo")) # héllo
Build a query string by encoding each value separately:
import std:encoding
import std:println
query = |params| {
parts = []
loop through params with k, v {
parts::push("${encoding:percent_encode(k)}=${encoding:percent_encode(v)}")
}
parts::join("&")
}
println(query({"q": "suji lang", "page": "1"})) # q=suji%20lang&page=1
percent_decode is lenient: a malformed escape is left as-is rather than raising,
and + is not treated as a space.
import std:encoding
import std:println
println(encoding:percent_decode("%ZZ")) # %ZZ
println(encoding:percent_decode("a+b")) # a+b
Round Trips
import std:encoding
import std:println
text = "Grüße, Welt! 42"
println(encoding:base64_decode(encoding:base64_encode(text)) == text) # true
println(encoding:hex_decode(encoding:hex_encode(text)) == text) # true
println(encoding:percent_decode(encoding:percent_encode(text)) == text) # true
Gotchas
- Every argument must be a string; a number raises
Type error: argument must be string. - Decoders raise on invalid Base64 or hex, and those errors terminate the program.
- Results must be valid UTF-8, so these functions cannot carry arbitrary binary payloads.
percent_encodeescapes far more than a URL path needs; do not apply it to a whole URL.
See Also
Mathematics (std:math)
Two constants plus trigonometric, logarithmic and exponential functions.
Overview
std:math is small — it exports exactly these twelve items and nothing else:
| Export | Kind | Description |
|---|---|---|
PI | Number | π |
E | Number | Euler’s number |
sin(x) | Function | Sine of x radians |
cos(x) | Function | Cosine of x radians |
tan(x) | Function | Tangent of x radians |
asin(x) | Function | Arcsine, radians; domain [-1, 1] |
acos(x) | Function | Arccosine, radians; domain [-1, 1] |
atan(x) | Function | Arctangent, radians |
atan2(y, x) | Function | Arctangent of y / x, quadrant-aware |
log(x) | Function | Natural logarithm; domain x > 0 |
log10(x) | Function | Base-10 logarithm; domain x > 0 |
exp(x) | Function | e raised to x |
The constants are uppercase: math:PI, math:E.
What Is Not in std:math
Rounding, absolute value, roots, powers and comparisons are number methods, not module functions:
| You might expect | Use instead |
|---|---|
math:abs(x) | x::abs() |
math:sqrt(x) | x::sqrt() |
math:pow(x, n) | x::pow(n) or x ^ n |
math:floor(x) | x::floor() |
math:ceil(x) | x::ceil() |
math:round(x) | x::round() |
math:min(a, b) | a::min(b) |
math:max(a, b) | a::max(b) |
math:random() | random:random() |
import std:println
println(16::sqrt()) # 4
println((0-5)::abs()) # 5
println(2::pow(10)) # 1024
println(3.7::floor()) # 3
println(3.2::ceil()) # 4
println(3.5::round()) # 4
println(5::min(3)) # 3
println(5::max(3)) # 5
There is no TAU, cbrt, log2, hypot, sign, clamp or trunc in any
form. log2(x) can be written as math:log(x) / math:log(2).
Quick Start
import std:math
import std:println
println(math:PI) # 3.14159265358979323846
println(math:E) # 2.71828182845904523536
println(math:sin(0)) # 0
println(math:cos(0)) # 1
println(math:log10(100)) # 2
println(math:exp(0)) # 1
Constants
import std:math
import std:println
radius = 5
println(2 * math:PI * radius) # 31.41592653589793238460
println(math:PI * radius * radius) # 78.53981633974483096150
Trigonometric Functions
All angles are in radians.
import std:math
import std:println
println(math:sin(0)) # 0
println(math:sin(math:PI / 2)) # 1
println(math:cos(0)) # 1
println(math:cos(math:PI)) # -1
println(math:tan(0)) # 0
Convert from degrees before calling them:
import std:math
import std:println
to_radians = |deg| deg * math:PI / 180
to_degrees = |rad| rad * 180 / math:PI
println(to_degrees(math:PI / 4)) # 45
println(math:sin(to_radians(90))) # 1
tan has no special value at π/2 — because the argument is only an
approximation of π/2 the result is a very large number rather than an error, so
guard the inputs yourself if that matters.
Inverse Trigonometric Functions
These return radians. asin and acos require an argument in [-1, 1];
anything else raises Invalid operation: asin domain is [-1,1] and terminates
the program.
import std:math
import std:println
println(math:asin(0)) # 0
println(math:asin(1)) # 1.570796326794897
println(math:acos(1)) # 0
println(math:atan(1)) # 0.785398163397448
atan2(y, x) picks the correct quadrant from the signs of both arguments, which
atan cannot do:
import std:math
import std:println
println(math:atan2(1, 1)) # 0.785398163397448
println(math:atan2(1, 0-1)) # 2.356194490192345
println(math:atan2(0-1, 0-1)) # -2.356194490192345
println(math:atan2(0-1, 1)) # -0.785398163397448
Validate the input range before calling asin or acos:
import std:math
import std:println
safe_asin = |x| match {
x < (0-1) => nil,
x > 1 => nil,
_ => math:asin(x),
}
println(safe_asin(2) == nil) # true
println(safe_asin(0)) # 0
Logarithms and Exponentials
log is the natural logarithm and log10 is base 10. Both require a positive
argument; log(0) and log(-1) raise
Invalid operation: log domain is (0, +inf).
import std:math
import std:println
println(math:log(1)) # 0
println(math:log10(1)) # 0
println(math:log10(100)) # 2
println(math:log10(1000)) # 3
println(math:exp(0)) # 1
Any base can be derived from log:
import std:math
import std:println
log_base = |x, base| math:log(x) / math:log(base)
println(log_base(8, 2)::round()) # 3
println(log_base(81, 3)::round()) # 4
A result larger than the decimal range raises
Invalid operation: math result overflow — math:exp(100) is already too big.
Precision
Arguments and results are Suji’s fixed-precision decimals, but these functions are computed in binary floating point internally. Results are therefore very close to, but not always exactly, the mathematically exact value:
import std:math
import std:println
println(math:log(math:E)) # 0.9999999999999999999998942453
println(math:exp(1)) # 2.7182818261984928651595318263
println(math:sin(math:PI)) # 0.0000000000000000000026433832
println(math:tan(math:PI / 4)) # 0.9999999956815324130588099842
So compare with a tolerance rather than ==:
import std:math
import std:println
close_enough = |a, b| (a - b)::abs() < 0.0000001
println(close_enough(math:log(math:E), 1)) # true
println(close_enough(math:tan(math:PI / 4), 1)) # true
Rounding to a known number of digits works through arithmetic and ::round():
import std:math
import std:println
round_to = |x, digits| {
factor = 10 ^ digits
scaled = x * factor
scaled::round() / factor
}
println(round_to(math:exp(1), 4)) # 2.7183
Examples
Distance Between Two Points
import std:println
distance = |x1, y1, x2, y2| {
dx = x2 - x1
dy = y2 - y1
squares = (dx ^ 2) + (dy ^ 2)
squares::sqrt()
}
println(distance(0, 0, 3, 4)) # 5
Polar and Cartesian Coordinates
import std:math
import std:println
polar_to_cartesian = |r, theta| (r * math:cos(theta), r * math:sin(theta))
cartesian_to_polar = |x, y| (((x ^ 2) + (y ^ 2))::sqrt(), math:atan2(y, x))
x, y = polar_to_cartesian(5, 0)
println("${x} ${y}") # 5 0
r, theta = cartesian_to_polar(3, 4)
println(r) # 5
println(theta > 0.92) # true
Decibels
import std:math
import std:println
to_decibels = |power| 10 * math:log10(power)
println(to_decibels(1)) # 0
println(to_decibels(1000)) # 30
println(to_decibels(0.001)) # -30
Continuous Growth
import std:math
import std:println
compound = |principal, rate, years| principal * math:exp(rate * years)
amount = compound(1000, 0.05, 10)
println(amount::round()) # 1649
Sine Wave Samples
import std:math
import std:println
samples = []
loop through 0..4 with i {
samples::push(math:sin(2 * math:PI * i / 4)::round())
}
println(samples) # [0, 1, 0, -1]
Gotchas
- The constants are uppercase;
math:piis an undefined variable. ^requires an integer exponent, so usex::sqrt()rather thanx ^ 0.5.x::sqrt()on a negative number raisesInvalid operation: Square root of negative number.- Domain and overflow errors terminate the program; check inputs first.
- There is no
NaNand noInfinity, so a bad computation is an error rather than a special value.
See Also
Cryptography (std:crypto)
Hash functions and HMAC-SHA256, returned as lowercase hex digests.
Overview
std:crypto exports five functions. Every argument must be a string, and
every result is a lowercase hex string.
| Function | Digest | Hex length |
|---|---|---|
md5(text) | 128-bit | 32 |
sha1(text) | 160-bit | 40 |
sha256(text) | 256-bit | 64 |
sha512(text) | 512-bit | 128 |
hmac_sha256(key, text) | 256-bit | 64 |
There is no generic crypto:hmac, no other HMAC variant, no raw/binary output
mode, no incremental hashing and no password-hashing function such as bcrypt or
argon2.
Quick Start
import std:crypto
import std:println
println(crypto:sha256("Hello, World!"))
# dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f
println(crypto:hmac_sha256("secret-key", "message to sign"))
# 5e2a3d8758df91e8fb93c09d4141c12ccc1f986cc67edccad3ebc5463c9bb136
Hash Functions
md5(text)
import std:crypto
import std:println
println(crypto:md5("Hello, World!")) # 65a8e27d8879283831b664bd8b7f0ad4
println(crypto:md5("")) # d41d8cd98f00b204e9800998ecf8427e
MD5 is cryptographically broken. Use it only for non-security purposes such as cache keys or change detection.
sha1(text)
import std:crypto
import std:println
println(crypto:sha1("Hello, World!")) # 0a0a9f2a6772942557ab5355d76af442f8f65e01
SHA-1 is also considered weak; prefer SHA-256 for anything new.
sha256(text)
The general-purpose choice.
import std:crypto
import std:println
println(crypto:sha256("Hello, World!"))
# dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f
println(crypto:sha256(""))
# e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
sha512(text)
import std:crypto
import std:println
digest = crypto:sha512("Hello, World!")
println(digest::length()) # 128
println(digest[0;32]) # 374d794a95cdcfd8b35993185fef9ba3
hmac_sha256(key, text)
Computes a keyed message authentication code. Both arguments are strings; the key comes first.
import std:crypto
import std:println
signature = crypto:hmac_sha256("secret-key", "message to sign")
println(signature)
# 5e2a3d8758df91e8fb93c09d4141c12ccc1f986cc67edccad3ebc5463c9bb136
verify = |key, message, expected| crypto:hmac_sha256(key, message) == expected
println(verify("secret-key", "message to sign", signature)) # true
println(verify("wrong-key", "message to sign", signature)) # false
Keep the key out of the source and read it from the environment:
import std:crypto
import std:env
import std:println
env:var["WEBHOOK_SECRET"] = "s3cr3t" # normally set outside the script
secret = env:var::get("WEBHOOK_SECRET", "")
payload = '{"event":"push"}'
println(crypto:hmac_sha256(secret, payload)::length()) # 64
Comparison with == is not constant time, so it can in principle leak timing
information. Suji offers no constant-time comparison primitive.
Text and Encoding
The input is a Suji string, hashed as UTF-8, so non-ASCII text hashes consistently:
import std:crypto
import std:println
println(crypto:sha256("héllo"))
# 3c48591d8d098a4538f5e013dfcf406e948eac4d3277b10bf614e295d6068179
Non-string arguments raise Type error: argument must be string, so convert
first:
import std:crypto
import std:println
println(crypto:md5(42::to_string())) # a1d0c6e83f027327d8461063f4ac58a6
For base64 or hex conversions of the data itself — rather than a digest of it —
see std:encoding.
Examples
File Checksum
import std:crypto
import std:io
import std:println
p = `mktemp`
f = io:open(p, true, true)
f::write("important contents\n")
f::close()
checksum = |path| {
stream = io:open(path)
content = stream::read_all()
stream::close()
crypto:sha256(content)
}
println(checksum(p))
# 8dd64c9b0c49e80ad4361b4604284ec5c2b20613faed29af2fc5fd9cd922db3a
Content-Addressed Storage
import std:crypto
import std:io
import std:os
import std:path
import std:println
root = `mktemp -d`
store = |content| {
key = crypto:sha256(content)
out = io:open(path:join([root, key]), true, true)
out::write(content)
out::close()
key
}
load = |key| {
f = io:open(path:join([root, key]))
content = f::read_all()
f::close()
content
}
key = store("Important data")
println(key::length()) # 64
println(load(key)) # Important data
Deduplicating by Digest
import std:crypto
import std:println
documents = ["alpha", "beta", "alpha", "gamma"]
seen = []
unique = []
loop through documents with doc {
digest = crypto:sha256(doc)
match {
seen::contains(digest) => { continue }
_ => {
seen::push(digest)
unique::push(doc)
}
}
}
println(unique) # [alpha, beta, gamma]
Cache Key from Parameters
Because json:generate sorts keys, two equal maps produce the same key
regardless of the order they were built in:
import std:crypto
import std:json
import std:println
cache_key = |name, params| crypto:md5("${name}:${json:generate(params)}")
a = cache_key("search", {"q": "suji", "page": 1})
b = cache_key("search", {"page": 1, "q": "suji"})
println(a == b) # true
println(a::length()) # 32
Signed Payload
import std:crypto
import std:json
import std:println
sign = |secret, data| {
body = json:generate(data)
{
"body": body,
"signature": crypto:hmac_sha256(secret, body),
}
}
check = |secret, envelope| {
crypto:hmac_sha256(secret, envelope:body) == envelope:signature
}
envelope = sign("shared-secret", {"user": "alice", "action": "update"})
println(check("shared-secret", envelope)) # true
println(check("other-secret", envelope)) # false
Gotchas
- All arguments must be strings; numbers and maps raise a type error.
- Digests are hex text, so
::length()is 32/40/64/128 characters, not bytes. - Raw hashes are unsuitable for password storage; there is no salted key derivation function in the standard library.
- Hashing is one shot over a whole string, so a very large file must be read fully into memory first.
See Also
Dotenv Files (std:dotenv)
Load KEY=value pairs from a .env file into the process environment.
Overview
std:dotenv exports a single function:
load(path = ".env", override = false)→ Map of the pairs it applied
load reads the file, sets each pair in env:var, and returns a map
containing only the pairs it actually set. Values already present in the
environment are left alone unless override is true.
Quick Start
import std:dotenv
import std:env
import std:io
import std:println
# create a .env to load
p = `mktemp`
f = io:open(p, true, true)
f::write("APP_NAME=demo\nAPP_PORT=8080\n")
f::close()
loaded = dotenv:load(p)
println(loaded::keys()) # [APP_NAME, APP_PORT]
println(env:var:APP_NAME) # demo
With no arguments, load() reads .env from the current working directory:
import std:dotenv
# dotenv:load() # reads ./.env
# dotenv:load("config/.env") # reads a specific file
File Format
Each line is trimmed and then interpreted as follows:
| Line | Result |
|---|---|
KEY=value | Sets KEY to value; both sides are trimmed |
| (empty) | Ignored |
# comment | Ignored |
KEY= | Ignored — an empty value is skipped |
KEY | Ignored — no = means no pair |
The first = separates key from value, so values may contain = freely:
import std:dotenv
import std:io
import std:println
p = `mktemp`
f = io:open(p, true, true)
f::write("DATABASE_URL=postgres://user:pass@localhost:5432/db\nSPACED = padded \n")
f::close()
loaded = dotenv:load(p)
println(loaded:DATABASE_URL) # postgres://user:pass@localhost:5432/db
println(loaded:SPACED) # padded
The Parser Is Deliberately Literal
There is no quote stripping, no inline-comment stripping, no escape processing,
and no export prefix handling. Whatever follows the first = (after trimming)
becomes the value verbatim:
import std:dotenv
import std:io
import std:println
p = `mktemp`
f = io:open(p, true, true)
f::write("QUOTED=\"hi there\"\nINLINE=bar # trailing\nexport EXPORTED=yes\n")
f::close()
loaded = dotenv:load(p)
println(loaded:QUOTED) # "hi there"
println(loaded:INLINE) # bar # trailing
println(loaded::contains("EXPORTED")) # false
println(loaded::contains("export EXPORTED")) # true
So write .env files without quotes, without trailing comments on value lines,
and without export.
override
By default an existing environment variable wins, and the returned map tells you what was actually applied — an empty map means nothing changed:
import std:dotenv
import std:env
import std:io
import std:println
p = `mktemp`
f = io:open(p, true, true)
f::write("MODE=from-file\n")
f::close()
env:var["MODE"] = "from-shell"
first = dotenv:load(p)
println(first::length()) # 0
println(env:var:MODE) # from-shell
second = dotenv:load(p, true)
println(second::keys()) # [MODE]
println(env:var:MODE) # from-file
This makes the default order of precedence “real environment beats file”, which is usually what you want for deployment overrides.
Loading Layered Files
Later loads do not overwrite earlier ones unless you ask them to, so load the most specific file first:
import std:dotenv
import std:env
import std:io
import std:println
base = `mktemp`
b = io:open(base, true, true)
b::write("REGION=us-east-1\nTIER=free\n")
b::close()
local = `mktemp`
l = io:open(local, true, true)
l::write("TIER=pro\n")
l::close()
dotenv:load(local) # wins
dotenv:load(base) # fills in the rest
println(env:var:TIER) # pro
println(env:var:REGION) # us-east-1
Gotchas
- A missing file raises
Stream error: Failed to open '<path>'and terminates the program. Probe first with`test -e .env && echo yes || echo no`if the file is optional. - Values are always strings; convert with
::to_number()where needed. - Reading a key that the file did not define raises
Key not found; useenv:var::get(name, default). loadmutates the process environment, so backtick commands started afterwards inherit the loaded values.
import std:dotenv
import std:io
import std:println
p = `mktemp`
f = io:open(p, true, true)
f::write("GREETING=hello\n")
f::close()
dotenv:load(p)
println(`echo $GREETING`) # hello
See Also
Examples Gallery
Real-world examples demonstrating Suji’s capabilities.
Overview
Each example includes:
- Overview: What the example demonstrates
- Complete Code: Full working implementation
- Step-by-Step Explanation: Detailed breakdown
- Variations: Alternative approaches
- Exercises: Practice challenges
- See Also: Related concepts and examples
Every code block in this section is a complete program. Copy one into example.si and run suji example.si — blocks that need input create it in a temp file first.
Example Categories
Algorithms
- Fibonacci Sequence - Recursion, memoization and the limits of both
- Quicksort - Divide and conquer with pattern matching and list slices
Functional Programming
- Function Composition - Composing and chaining functions
Text Processing
- Regex Matching - Pattern matching for text processing
Automation
- CLI Tools - Building command-line utilities
Task-oriented recipes for files, data formats, configuration and shell scripting live in the Cookbook.
Quick Examples
Hello World
import std:println
println("Hello, Suji!")
Calculate Factorial
import std:println
factorial = |n| {
match n {
0 | 1 => 1,
_ => n * factorial(n - 1),
}
}
println(factorial(5)) # 120
Every match arm whose body is a bare expression needs a trailing comma — including the last one.
Filter and Transform a List
import std:println
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
filtered = numbers::filter(|n| n % 2 == 0)
result = filtered::map(|n| n * n)
println(result) # [4, 16, 36, 64, 100]
Read and Parse JSON
import std:io
import std:json
import std:os
import std:println
path = `mktemp`
f = io:open(path, true, true)
f::write("""{"users": [{"name": "Alice"}, {"name": "Bob"}]}""")
f::close()
file = io:open(path)
data = json:parse(file::read_all())
file::close()
println("Loaded ${data:users::length()} users") # Loaded 2 users
os:rm(path)
Run a Shell Command
import std:println
# Backticks return stdout with the trailing newline trimmed
println(`echo hello from the shell`) # hello from the shell
# A non-zero exit ends the script, so absorb failures in the shell itself
println(`grep nothing /etc/hosts || echo "no match"`) # no match
Getting Started
- Browse examples by category
- Read the overview and prerequisites
- Study the complete code
- Review the step-by-step explanation
- Try the exercises
- Experiment with variations
See Also
- Cookbook - Task-oriented recipes
- Standard Library - Built-in modules
- Functions - Function programming guide
Fibonacci Sequence
Learn recursion, memoization, and functional programming through the classic Fibonacci sequence.
Overview
This example demonstrates:
- Recursive function definitions
- Pattern matching with
match - Method chaining over lists
- Memoization for performance
- Building sequences from ranges
Prerequisites
Complete Code
import std:println
# Simple recursive Fibonacci
fib = |n| {
match n {
0 | 1 => n,
_ => fib(n - 1) + fib(n - 2),
}
}
# Generate the first N Fibonacci numbers
first_n_fibs = |n| {
numbers = (0..n)::map(|i| fib(i))::join(", ")
println("The first ${n} Fibonacci numbers are: ${numbers}")
}
first_n_fibs(10)
Output:
The first 10 Fibonacci numbers are: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34
Note the comma after _ => fib(n - 1) + fib(n - 2). A match arm whose body is a bare expression must be followed by a comma, including the last arm — leaving it off is a parse error.
Step-by-Step Explanation
1. Define Recursive Fibonacci
import std:println
fib = |n| {
match n {
0 | 1 => n,
_ => fib(n - 1) + fib(n - 2),
}
}
println(fib(10)) # 55
- Base cases:
fib(0) = 0,fib(1) = 1 - Recursive case:
fib(n) = fib(n-1) + fib(n-2) - Pattern alternation
0 | 1matches both values - The lambda calls itself through the name it was assigned to
2. Generate the Sequence
import std:println
square = |i| i * i
println((0..5)::map(square)::join(", ")) # 0, 1, 4, 9, 16
(0..n)builds a list from0ton - 1(ranges evaluate to real lists immediately)::map(fn)transforms every element::join(", ")produces a comma-separated string
3. Format and Print
import std:println
n = 10
numbers = "0, 1, 1, 2"
println("The first ${n} Fibonacci numbers are: ${numbers}")
- String interpolation with
${...} printlncomes fromstd:println; there is no prelude, so every program that prints needs the import
Variation 1: Iterative Approach
Much faster, and the only sensible choice for large n:
import std:println
fib_iterative = |n| {
match n {
0 => 0,
1 => 1,
_ => {
a = 0
b = 1
i = 2
loop {
i > n && break
temp = a + b
a = b
b = temp
i++
}
b
}
}
}
println(fib_iterative(10)) # 55
println(fib_iterative(90)) # 2880067194370816120
println(fib_iterative(139)) # 50095301248058391139327916261
Advantage: O(n) time, O(1) space, and no recursion depth to worry about.
fib_iterative(140) would abort with Addition overflowed: Suji has a single fixed-precision decimal number type whose maximum is 79228162514264337593543950335, so 139 is the last Fibonacci number the language can represent.
Variation 2: Memoized Fibonacci
Cache results so each value is computed once:
import std:println
create_fib_memo = || {
cache = {0: 0, 1: 1}
fib = |n| {
match cache::contains(n) {
true => cache::get(n, nil),
_ => {
result = fib(n - 1) + fib(n - 2)
cache[n] = result
result
},
}
}
fib
}
fib_memo = create_fib_memo()
println(fib_memo(100)) # 354224848179261915075
Advantage: O(n) time. The closure captures cache by reference, so the map survives between calls.
Use cache::contains(n) rather than checking for a nil result: reading a missing key with cache[n] raises Key not found and terminates the program.
Variation 3: Sequence as a List
import std:println
fib_list = |count| {
out = []
a = 0
b = 1
loop through 0..count {
out::push(a)
temp = a + b
a = b
b = temp
}
out
}
loop through fib_list(8) with n {
println(n)
}
Output:
0
1
1
2
3
5
8
13
Performance Comparison
time:now():epoch_ms is the practical timing primitive (os:uptime_ms() only has second resolution):
import std:println
import std:time
fib = |n| {
match n {
0 | 1 => n,
_ => fib(n - 1) + fib(n - 2),
}
}
fib_iterative = |n| {
a = 0
b = 1
i = 0
loop {
i >= n && break
temp = a + b
a = b
b = temp
i++
}
a
}
benchmark = |name, f| {
start = time:now():epoch_ms
result = f()
elapsed = time:now():epoch_ms - start
println("${name} = ${result} in ${elapsed}ms")
}
benchmark("recursive fib(25)", || fib(25))
benchmark("iterative fib(25)", || fib_iterative(25))
Example output (timings vary by machine):
recursive fib(25) = 75025 in 238ms
iterative fib(25) = 75025 in 0ms
On the reference build the recursive version needs roughly a quarter of a second for fib(25) — it makes about 243 thousand calls — while the iterative version is instant. The gap doubles with every extra n, so fib(35) recursively is minutes of work.
Exercises
Beginner
- Return the Fibonacci sequence as a list instead of printing it
- Find the first Fibonacci number greater than 1000
- Sum the first 10 Fibonacci numbers with
::sum()
Intermediate
- Rewrite
fib_iterativeso it returns both the value and the number of loop iterations (return a, count) - Write
is_fibonacci(n)that reports whether a number appears in the sequence - Add a
cache::length()counter to the memoized version to show how many values were computed
Advanced
- Implement matrix-based Fibonacci (O(log n)) using lists of lists
- Build a generator-style closure that returns the next Fibonacci number on each call
- Detect the overflow point programmatically: stop before the sum exceeds
79228162514264337593543950335
Common Mistakes
Missing Comma After the Last Arm
import std:println
# Correct: every bare-expression arm ends with a comma
classify = |n| {
match {
n < 0 => "negative",
n == 0 => "zero",
_ => "positive",
}
}
println(classify(0)) # zero
Writing _ => "positive" without the trailing comma produces Parse error: Unexpected token: RightBrace.
Stack Overflow
import std:println
# Linear recursion is fine at this depth ...
count_down = |n| match { n <= 0 => 0, _ => 1 + count_down(n - 1), }
println(count_down(500)) # 500
There is no tail-call optimisation. Around 600–700 nested frames the process aborts with thread 'main' has overflowed its stack, so count_down(900) — or a memoized fib(900) — crashes rather than returning. Convert deep recursion into a loop.
Off-by-One Errors
import std:println
println((0..10)::length()) # 10 — exclusive: 0 through 9
println((0..=10)::length()) # 11 — inclusive: 0 through 10
println((0..10)::last(nil)) # 9
See Also
Quicksort Algorithm
Learn divide-and-conquer algorithms, pattern matching, and list operations.
Overview
This example demonstrates:
- Recursive divide-and-conquer strategy
- Pattern matching on list length
- List filtering and concatenation
- Elegant functional implementation
Prerequisites
Complete Code
import std:println
quicksort = |list| {
match list::length() {
0 => [],
1 => list,
_ => {
pivot = list[0]
rest = list[1;]
left = rest::filter(|x| x < pivot)
right = rest::filter(|x| x >= pivot)
quicksort(left) + [pivot] + quicksort(right)
},
}
}
numbers = [5, 3, 8, 1, 9, 2, 7]
println("Original: ${numbers::join(", ")}")
println("Sorted: ${quicksort(numbers)::join(", ")}")
# The same function sorts strings, since `<` compares them too
println(quicksort(["pear", "apple", "fig"])::join(", "))
Output:
Original: 5, 3, 8, 1, 9, 2, 7
Sorted: 1, 2, 3, 5, 7, 8, 9
apple, fig, pear
Step-by-Step Explanation
1. Base Cases
import std:println
base_cases = |list| {
match list::length() {
0 => [],
1 => list,
_ => "needs partitioning",
}
}
println(base_cases([])) # []
println(base_cases([42])) # [42]
println(base_cases([2, 1])) # needs partitioning
- An empty list is already sorted
- A single-element list is already sorted
- Every arm here is a bare expression, so every arm — including the last — ends with a comma
2. Choose Pivot
import std:println
list = [5, 3, 8, 1]
pivot = list[0]
rest = list[1;]
println(pivot) # 5
println(rest::join(", ")) # 3, 8, 1
- The first element becomes the pivot
list[1;]is slice notation — from index 1 to the end. Suji uses;in slices, not:restcontains everything except the pivot, which is what keeps the recursion shrinking
3. Partition
import std:println
rest = [3, 8, 1]
pivot = 5
left = rest::filter(|x| x < pivot)
right = rest::filter(|x| x >= pivot)
println(left::join(", ")) # 3, 1
println(right::join(", ")) # 8
- Left partition: elements less than the pivot
- Right partition: elements greater than or equal to the pivot
filteris eager and returns a new list, sorestis untouched
4. Recursive Sort and Combine
import std:println
sorted_left = [1, 3]
pivot = 5
sorted_right = [8]
println(sorted_left + [pivot] + sorted_right) # [1, 3, 5, 8]
+ concatenates lists, so the combine step is a single expression.
Variation 1: Sorting by Mutation
Suji passes arguments by value: a list handed to a function is copied, and xs::push(...) or xs[0] = ... inside that function leaves the caller’s list untouched. A closure, on the other hand, captures its environment by reference — so a genuinely in-place sort is written against a captured list:
import std:println
data = [5, 3, 8, 1, 9, 2, 7]
swap = |i, j| {
temp = data[i]
data[i] = data[j]
data[j] = temp
}
partition = |low, high| {
pivot = data[high]
i = low - 1
loop through low..high with j {
match {
data[j] < pivot => {
i++
swap(i, j)
},
_ => {},
}
}
swap(i + 1, high)
i + 1
}
sort_range = |low, high| {
match {
low < high => {
p = partition(low, high)
sort_range(low, p - 1)
sort_range(p + 1, high)
},
_ => {},
}
}
sort_range(0, data::length() - 1)
println(data) # [1, 2, 3, 5, 7, 8, 9]
Advantage: no intermediate lists. Cost: the sort is tied to one specific variable, which is why the functional version above is the one worth reaching for first.
Variation 2: Random Pivot
Better average-case performance on partially ordered input:
import std:println
import std:random
quicksort_random = |list| {
match list::length() {
0 | 1 => list,
_ => {
pivot_idx = random:integer(0, list::length())
pivot = list[pivot_idx]
rest = []
loop through 0..list::length() with idx {
match {
idx != pivot_idx => { rest::push(list[idx]) },
_ => {},
}
}
left = rest::filter(|x| x < pivot)
right = rest::filter(|x| x >= pivot)
quicksort_random(left) + [pivot] + quicksort_random(right)
},
}
}
random:seed(7)
input = random:shuffle(1..=12)
println(quicksort_random(input) == (1..=12)) # true
loop through list with item, idx does not work: two bindings are only valid for maps. Iterate over an index range instead, as above.
Variation 3: Three-Way Partitioning
Handle duplicate values efficiently:
import std:println
quicksort_3way = |list| {
match list::length() {
0 | 1 => list,
_ => {
pivot = list[0]
rest = list[1;]
less = rest::filter(|x| x < pivot)
equal = rest::filter(|x| x == pivot)
greater = rest::filter(|x| x > pivot)
quicksort_3way(less) + [pivot] + equal + quicksort_3way(greater)
},
}
}
println(quicksort_3way([3, 1, 3, 2, 3, 1])) # [1, 1, 2, 3, 3, 3]
Advantage: values equal to the pivot are placed once instead of being re-partitioned.
Performance Analysis
Time Complexity
- Best case: O(n log n) - balanced partitions
- Average case: O(n log n) - random pivot
- Worst case: O(n²) - already sorted input with a first-element pivot
Space Complexity
- Functional version: O(n) -
filterand+allocate new lists at every level - Mutating version: O(log n) - only the recursion stack grows
Recursion Depth
There is no tail-call optimisation, and the interpreter aborts the process at roughly 600–700 nested frames. Sorting an already-sorted list recurses once per element, so quicksort(0..300) is fine while quicksort(0..700) crashes with thread 'main' has overflowed its stack. Shuffled input recurses only about log2(n) deep, so large random lists are not a problem — the danger is specifically sorted or reverse-sorted input.
Complete Example with Benchmarking
import std:println
import std:random
import std:time
quicksort = |list| {
match list::length() {
0 | 1 => list,
_ => {
pivot = list[0]
rest = list[1;]
quicksort(rest::filter(|x| x < pivot)) + [pivot] + quicksort(rest::filter(|x| x >= pivot))
},
}
}
benchmark = |name, list| {
start = time:now():epoch_ms
result = quicksort(list)
elapsed = time:now():epoch_ms - start
println("${name} (${list::length()} elements): ${elapsed}ms, sorted correctly: ${result == list::sort()}")
}
random:seed(1)
benchmark("random", random:shuffle(0..300))
benchmark("already sorted", 0..300)
benchmark("reverse sorted", 300..0)
Example output (timings vary by machine):
random (300 elements): 4ms, sorted correctly: true
already sorted (300 elements): 51ms, sorted correctly: true
reverse sorted (300 elements): 48ms, sorted correctly: true
For production sorting of numbers or strings, use the built-in list::sort() — it is implemented in Rust and has none of these depth limits.
Exercises
Beginner
- Sort a list of strings alphabetically, then reverse the result with
::reverse() - Sort in descending order by swapping the two
filterpredicates - Count comparisons by incrementing a counter captured from the enclosing scope
Intermediate
- Implement median-of-three pivot selection
- Add an
ascendingparameter that chooses the comparison direction - Print each partition step to visualise the recursion
Advanced
- Implement dual-pivot quicksort
- Fall back to insertion sort for sublists shorter than 8 elements
- Rewrite the sort as an explicit stack of ranges so it never exceeds the recursion limit
Common Mistakes
Infinite Recursion
import std:println
# Bug: the pivot stays in the right partition, so the list never shrinks
buggy = |list| {
pivot = list[0]
right = list::filter(|x| x >= pivot)
println("right partition still has ${right::length()} of ${list::length()} elements")
}
buggy([1, 3, 2])
Output:
right partition still has 3 of 3 elements
Solution: partition rest = list[1;], not list, so the pivot is removed exactly once.
Missing Comma After the Final Arm
import std:println
sorted = match [3, 1] ::length() {
0 => "empty",
_ => "sort me",
}
println(sorted) # sort me
Dropping the comma after _ => "sort me" is a parse error, not a warning.
Stack Overflow on Sorted Input
import std:println
quicksort = |list| {
match list::length() {
0 | 1 => list,
_ => {
pivot = list[0]
rest = list[1;]
quicksort(rest::filter(|x| x < pivot)) + [pivot] + quicksort(rest::filter(|x| x >= pivot))
},
}
}
println(quicksort(0..300)::length()) # 300
quicksort(0..700) on already-sorted input aborts the process. Shuffle first, pick a random pivot, or use list::sort().
See Also
Function Composition
Build complex transformations by composing simple functions.
Overview
This example demonstrates:
- Forward composition (
>>) - Backward composition (
<<) - Building data pipelines
- Function chaining
- Composing multiple operations
Prerequisites
Complete Code
import std:println
# Simple transformations
double = |x| x * 2
increment = |x| x + 1
square = |x| x * x
# Forward composition (left to right)
transform1 = double >> increment >> square
println("double >> increment >> square: ${transform1(5)}")
# Backward composition (right to left)
transform2 = square << increment << double
println("square << increment << double: ${transform2(5)}")
# Practical example: text processing
trim = |s| s::trim()
upper = |s| s::upper()
add_prefix = |prefix| |s| "${prefix}${s}"
process_title = trim >> upper >> add_prefix("Title: ")
println(process_title(" hello world "))
Output:
double >> increment >> square: 121
square << increment << double: 121
Title: HELLO WORLD
Both compositions compute ((5 * 2) + 1)² = 121; they differ only in reading order.
Step-by-Step Explanation
1. Define Simple Functions
import std:println
double = |x| x * 2
increment = |x| x + 1
square = |x| x * x
println(square(increment(double(5)))) # 121
Each function performs one simple transformation. There is no fn keyword in Suji — functions are lambdas assigned to a name.
2. Forward Composition (>>)
import std:println
double = |x| x * 2
increment = |x| x + 1
square = |x| x * x
transform = double >> increment >> square
println(transform(5)) # 121
- Read left-to-right:
doublefirst, thenincrement, thensquare - Equivalent to
square(increment(double(x)))
3. Backward Composition (<<)
import std:println
double = |x| x * 2
increment = |x| x + 1
square = |x| x * x
transform = square << increment << double
println(transform(5)) # 121
- Read right-to-left, the way nested calls are written
f << gmeans “gthenf”
4. Practical Pipeline
import std:println
trim = |s| s::trim()
upper = |s| s::upper()
add_prefix = |prefix| |s| "${prefix}${s}"
process_title = trim >> upper >> add_prefix("Title: ")
println(process_title(" hello world ")) # Title: HELLO WORLD
add_prefix is a function returning a function — Suji has no partial application syntax, so a lambda returning a lambda is how you bind arguments ahead of time.
Variation 1: Data Validation Pipeline
There is no Result type and no way to catch an error, so a validating pipeline passes nil along and every stage has to tolerate it:
import std:println
normalize = |s| match {
s == nil => nil,
_ => s::lower()::trim(),
}
not_empty = |s| match {
s == nil => nil,
s::length() > 0 => s,
_ => nil,
}
is_email = |s| match {
s == nil => nil,
s ~ /^[^@]+@[^@]+$/ => s,
_ => nil,
}
validate_email = normalize >> not_empty >> is_email
check = |input| {
result = validate_email(input)
match {
result == nil => { println("Invalid email") },
_ => { println("Valid: ${result}") },
}
}
check(" USER@EXAMPLE.COM ")
check(" ")
check("not-an-email")
Output:
Valid: user@example.com
Invalid email
Invalid email
Note the conditional match form. In a subject match, a bare identifier is treated as a string literal pattern, not a binding — match x { email => ... } matches the literal text "email", which is a common source of silent nil results.
Variation 2: Mathematical Functions
import std:println
negate = |x| -x
reciprocal = |x| 1 / x
absolute = |x| x::abs()
safe_reciprocal = absolute >> reciprocal
println(safe_reciprocal(-4)) # 0.25
println(negate(0.25)) # -0.25
abs, sqrt, pow, floor, ceil and round are number methods, not functions in std:math — math only carries the trigonometric and logarithmic functions plus PI and E.
Dividing by zero terminates the program, so a truly safe reciprocal has to check first:
import std:println
reciprocal = |x| match {
x == 0 => nil,
_ => 1 / x,
}
println(reciprocal(4)) # 0.25
println(reciprocal(0)) # nil
Variation 3: List Transformations
import std:println
filter_even = |list| list::filter(|x| x % 2 == 0)
map_double = |list| list::map(|x| x * 2)
sum_all = |list| list::fold(0, |acc, x| acc + x)
sum_of_doubled_evens = filter_even >> map_double >> sum_all
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
println(sum_of_doubled_evens(numbers)) # 60
(2 + 4 + 6 + 8 + 10) * 2 = 60. There is no list::reduce; fold(initial, fn) is the equivalent, and list::sum() covers this particular case in one call.
Complete Example: Data Processing Pipeline
import std:io
import std:json
import std:os
import std:println
# Sample input
path = `mktemp`
f = io:open(path, true, true)
f::write("""{
"users": [
{"name": "Carol", "email": "carol@example.com", "active": true},
{"name": "Alice", "email": "alice@example.com", "active": true},
{"name": "Bob", "email": "bob@example.com", "active": false}
]
}""")
f::close()
# Transformation steps
parse_json = |text| json:parse(text)
extract_users = |data| data:users
filter_active = |users| users::filter(|u| u:active)
map_summary = |users| users::map(|u| {
{ "name": u:name, "email": u:email }
})
sort_by_name = |users| {
# list::sort() sorts numbers and strings, but there is no sort_by,
# so sort the names and rebuild the list in that order.
names = users::map(|u| u:name)::sort()
names::map(|name| users::filter(|u| u:name == name)::first(nil))
}
process_users = parse_json
>> extract_users
>> filter_active
>> map_summary
>> sort_by_name
file = io:open(path)
content = file::read_all()
file::close()
active_users = process_users(content)
println("Found ${active_users::length()} active users")
loop through active_users with user {
println(" ${user:name} <${user:email}>")
}
os:rm(path)
Output:
Found 2 active users
Alice <alice@example.com>
Carol <carol@example.com>
A composition may be written across several lines as long as the continuation line starts with the operator, as process_users does above.
Composition vs Piping
Composition (Creates a New Function)
import std:println
trim = |s| s::trim()
lower = |s| s::lower()
no_spaces = |s| s::replace(" ", "")
normalize = trim >> lower >> no_spaces
println(normalize(" HELLO WORLD ")) # helloworld
println(normalize(" TEST @ TEST ")) # test@test
Piping (Immediate Execution)
import std:println
input = " HELLO WORLD "
# Method chaining
println(input::trim()::lower()) # hello world
# Pipe-apply sends a value into a function
shout = |s| s::upper() + "!"
println("hello" |> shout) # HELLO!
println(shout <| "hello") # HELLO!
Use composition when: you want a reusable transformation function.
Use piping when: you want to transform one value right now.
Exercises
Beginner
- Create a pipeline that doubles a number, adds 10, then halves it
- Compose string functions into a slug maker (lowercase, spaces to hyphens)
- Build a validation chain for passwords (length, digit, symbol)
Intermediate
- Write
compose_all(list_of_functions)that folds a list into one function - Make a composition that returns
(value, error)tuples instead ofnil - Build a sanitisation pipeline for untrusted user input
Advanced
- Add tracing: wrap each stage so it prints its input and output
- Build a pipeline whose stages are chosen from a configuration map
- Implement a reversible pipeline where every stage has an inverse
Common Patterns
Pattern 1: Build Transform, Apply to Many
import std:println
trim = |s| s::trim()
lower = |s| s::lower()
sanitize = trim >> lower
inputs = [" Alice ", "BOB", " Carol "]
println(inputs::map(sanitize)::join(", ")) # alice, bob, carol
Pattern 2: Conditional Composition
import std:println
trim = |s| s::trim()
lower = |s| s::lower()
log_step = |s| {
println(" [log] ${s}")
s
}
with_logging = true
process = trim >> lower
process = match {
with_logging => process >> log_step,
_ => process,
}
println(process(" MIXED Case "))
Output:
[log] mixed case
mixed case
Pattern 3: Partial Application with Composition
import std:println
add = |x| |y| x + y
multiply = |x| |y| x * y
scale_and_shift = multiply(2) >> add(10)
println(scale_and_shift(5)) # 20
See Also
Regex Matching
Pattern matching and text validation with regular expressions.
Overview
This example demonstrates:
- Regex literals (
/pattern/) - Pattern matching with
~/!~ - Using regex patterns inside
match - Extracting simple fields using string methods (
split,index_of, slicing)
Prerequisites
Complete Code
import std:println
# 1) Email validation
email = "user@example.com"
println(email ~ /^[^@]+@[^@]+\.[^@]+$/) # true
println("nope" !~ /@/) # true
# 2) Token extraction: URLs + phone numbers
text = "Visit https://example.com or call 555-1234 for help"
words = text::replace("\n", " ")::replace("\t", " ")::split(" ")
urls = words::filter(|w| w ~ /^https?:\/\/.+$/)
phones = words::filter(|w| w ~ /^\d{3}-\d{4}$/)
println(urls::join(", ")) # https://example.com
println(phones::join(", ")) # 555-1234
# 3) Log parsing (no regex captures; use index_of and slicing)
parse_log_line = |line| {
close = line::index_of("]")
close < 0 && return nil
rest = line[(close + 2);]
sep = rest::index_of(": ")
sep < 0 && return nil
{
"timestamp": line[1;close],
"level": rest[0;sep],
"message": rest[(sep + 2);],
}
}
log = "[2024-01-15 10:30:00] ERROR: Connection failed"
entry = parse_log_line(log)
println(entry:level) # ERROR
println(entry:message) # Connection failed
Regex as a Match Pattern
A regex literal is also a valid pattern in a subject match, which reads better than a chain of ~ tests:
import std:println
classify = |token| {
match token {
/^\d+$/ => "number",
/^[a-z]+@[a-z.]+$/ => "email",
/^https?:\/\// => "url",
_ => "text",
}
}
println(classify("42")) # number
println(classify("me@example.com")) # email
println(classify("https://example.com")) # url
println(classify("hello")) # text
Patterns may also be stored in variables and reused:
import std:println
iso_date = /^\d{4}-\d{2}-\d{2}$/
dates = ["2024-01-15", "15/01/2024", "2024-1-5"]
valid = dates::filter(|d| d ~ iso_date)
println(valid::join(", ")) # 2024-01-15
Note that /${variable}/ is not interpolated — a regex literal is fixed at parse time.
Notes
- Regex matching answers “does it match?” only. There are no capture groups, no
::match(), no regexreplaceand no regexsplit; combine matching withsplit,index_ofand slicing for extraction. string::replace(old, new)takes plain strings, so"abc"::replace(/b/, "B")is an error.- Validate before converting:
::to_number()on a non-numeric string terminates the program, so guard it withs ~ /^\d+$/first.
import std:println
to_number_or = |s, fallback| match {
s ~ /^-?\d+(\.\d+)?$/ => s::to_number(),
_ => fallback,
}
println(to_number_or("42.5", 0)) # 42.5
println(to_number_or("many", 0)) # 0
See Also
Building CLI Tools
Create command-line utilities with argument parsing and user interaction.
Overview
This example demonstrates:
- Reading command-line arguments
- Reading from stdin
- File processing
- User interaction
- Building practical CLI tools
Prerequisites
How Arguments Reach a Script
env:args is a map-like value, not a function and not a list. It is keyed by strings, with "0" intended to be the script path and "1" the first argument. Read it with ::get, ::contains and ::length():
import std:env
import std:println
println(env:args::length() >= 1) # true
println(env:args::contains("1")) # false when no argument was passed
println(env:args::get("1", "(default)")) # (default)
Three rules shape every Suji CLI:
env:argsis a value:env:args::get("1", ""), neverenv:args()orenv:args[1].- Arguments starting with
-are consumed by the interpreter and never reach the script. The interpreter’s only flag is--print-ast; there is no--helpor--versionto imitate. - Positional arguments are broken in 0.1.22: every argument overwrites key
"0", socontains("1")is alwaysfalseand"0"holds the last argument rather than the script path. Seestd:env. Until it is fixed, take input from an environment variable or stdin and always provide a default. Every tool below is written that way.
Complete Code: Word Counter
import std:env
import std:io
import std:os
import std:println
# Input: first argument, then WC_FILE, then a demo file.
from_args = env:args::get("1", "")
from_env = env:var::get("WC_FILE", "")
configured = match {
from_args::length() > 0 => from_args,
_ => from_env,
}
demo_mode = configured::length() == 0
source = match {
demo_mode => {
demo = `mktemp`
f = io:open(demo, true, true)
f::write("the quick brown fox\njumps over the lazy dog\n")
f::close()
demo
}
_ => configured,
}
# Validate before opening: io:open on a missing file terminates the script
match {
`test -f "${source}" && echo yes || echo no` == "no" => {
println("Usage: suji word_count.si <filename> (or set WC_FILE)")
os:exit(1)
},
_ => {},
}
file = io:open(source)
content = file::read_all()
file::close()
lines = content::split("\n")::filter(|l| l::length() > 0)
words = content::replace("\n", " ")::replace("\t", " ")::split(" ")::filter(|w| w::length() > 0)
println("Lines: ${lines::length()}")
println("Words: ${words::length()}")
println("Characters: ${content::length()}")
# Only the demo file is ours to delete
match {
demo_mode => { os:rm(source) },
_ => {},
}
Output:
Lines: 2
Words: 9
Characters: 44
1. Resolve the Input
import std:env
import std:println
source = match {
env:args::get("1", "")::length() > 0 => env:args::get("1", ""),
env:var::contains("WC_FILE") => env:var::get("WC_FILE", ""),
_ => "sample.txt",
}
println(source) # sample.txt
A match with no matching arm evaluates to nil, so always finish with a _ arm that supplies a default.
2. Validate Before Acting
import std:println
check = |path| match {
`test -f "${path}" && echo yes || echo no` == "yes" => "readable",
_ => "missing",
}
println(check("/etc/hosts")) # readable
println(check("/no/such/file")) # missing
os:stat on a missing path is a fatal error, not a nil — check with the shell first. There is no try/catch to fall back on.
3. Process the File
import std:io
import std:os
import std:println
path = `mktemp`
f = io:open(path, true, true)
f::write("alpha beta\ngamma\n")
f::close()
file = io:open(path)
content = file::read_all()
file::close()
words = content::replace("\n", " ")::split(" ")::filter(|w| w::length() > 0)
println("Words: ${words::length()}") # Words: 3
os:rm(path)
4. Report Failure with an Exit Code
import std:os
import std:println
problems = []
match {
problems::length() > 0 => {
println("error: ${problems::join("; ")}")
os:exit(1)
},
_ => {},
}
println("ok") # ok
os:exit(0)
Variation 1: Reading Piped Input
A CLI that reads stdin composes with the rest of the shell. Guard the read with is_terminal() so the tool does not hang when nothing is piped in:
import std:io
import std:println
# Usage: cat access.log | suji filter.si
lines = match io:stdin::is_terminal() {
true => [],
_ => io:stdin::read_lines(),
}
errors = lines::filter(|l| l ~ /ERROR/)
match {
lines::length() == 0 => { println("no input; pipe a file into this script") },
_ => { println("${errors::length()} of ${lines::length()} lines matched") },
}
Output when run with no piped input:
no input; pipe a file into this script
To prompt interactively, print the question with std:print (no trailing newline) and read one line:
import std:io
import std:print
import std:println
ask = |question, fallback| match io:stdin::is_terminal() {
false => fallback,
_ => {
print(question)
answer = io:stdin::read_line()
match {
answer == nil => fallback,
answer::trim()::length() == 0 => fallback,
_ => answer::trim(),
}
}
}
pattern = ask("Search pattern: ", "ERROR")
println("searching for ${pattern}") # searching for ERROR
Variation 2: File Converter
import std:io
import std:json
import std:os
import std:println
import std:yaml
# Sample input
input_file = "${os:tmp_dir()}/convert-demo.json"
f = io:open(input_file, true, true)
f::write("""{"service": "api", "port": 8080}""")
f::close()
output_file = "${os:tmp_dir()}/convert-demo.yaml"
extension = |name| {
parts = name::split(".")
parts[parts::length() - 1]
}
convert_file = |input, output| {
file = io:open(input)
content = file::read_all()
file::close()
data = match extension(input) {
"json" => json:parse(content),
"yaml" | "yml" => yaml:parse(content),
_ => {
println("Unsupported input format: ${extension(input)}")
os:exit(1)
},
}
text = match extension(output) {
"json" => json:generate(data),
"yaml" | "yml" => yaml:generate(data),
_ => {
println("Unsupported output format: ${extension(output)}")
os:exit(1)
},
}
out = io:open(output, true, true) # create=true, truncate=true
out::write(text)
out::close()
println("Converted ${extension(input)} -> ${extension(output)}")
}
convert_file(input_file, output_file)
println(`cat ${output_file}`)
os:rm(input_file)
os:rm(output_file)
Output:
Converted json -> yaml
port: 8080
service: api
Variation 3: Task Runner
import std:env
import std:io
import std:json
import std:os
import std:println
# Sample tasks.json
tasks_file = "${os:tmp_dir()}/tasks-demo.json"
f = io:open(tasks_file, true, true)
f::write("""{
"tasks": {
"greet": {"commands": ["echo hello", "echo world"]},
"list": {"commands": ["printf 'one\\ntwo\\n' | wc -l"]}
}
}""")
f::close()
load_tasks = |path| {
match {
`test -f "${path}" && echo yes || echo no` == "no" => {
println("Error: ${path} not found")
os:exit(1)
},
_ => {},
}
file = io:open(path)
config = json:parse(file::read_all())
file::close()
config:tasks
}
run_task = |tasks, task_name| {
match {
tasks::contains(task_name) == false => {
println("Error: task '${task_name}' not found")
println("Available tasks:")
loop through tasks with name, task {
println(" - ${name} (${task:commands::length()} commands)")
}
os:exit(1)
},
_ => {},
}
println("Running task: ${task_name}")
loop through tasks::get(task_name, nil):commands with cmd {
println(" $ ${cmd}")
# A command that exits non-zero aborts the script; append `|| true`
# to keep going after a failure.
println(" ${`${cmd}`}")
}
println("Task completed")
}
tasks = load_tasks(tasks_file)
task_name = env:args::get("1", "greet") # falls back to the default task
run_task(tasks, task_name)
os:rm(tasks_file)
Output:
Running task: greet
$ echo hello
hello
$ echo world
world
Task completed
Because there is no way to inspect a command’s exit status, a task runner either lets a failing command abort the whole run (often what you want) or appends || true to every command and checks the output itself.
Complete Example: Log Analyzer
Options come from environment variables rather than flags, since --prefixed arguments never reach a Suji script:
import std:env
import std:io
import std:os
import std:println
# Sample log file
log_file = "${os:tmp_dir()}/analyzer-demo.log"
f = io:open(log_file, true, true)
f::write("""[2024-01-15 10:30:00] ERROR: Database connection failed
[2024-01-15 10:30:05] WARN: Retry scheduled
[2024-01-15 10:30:06] INFO: Health check passed
[2024-01-15 10:31:00] ERROR: Database connection failed
""")
f::close()
options = {
"file": env:var::get("LOG_FILE", log_file),
"level": env:var::get("LOG_LEVEL", "ERROR"),
"count": env:var::get("LOG_COUNT", "10")::to_number(),
}
parse_entry = |line| {
close = line::index_of("]")
close < 0 && return nil
rest = line[(close + 2);]
sep = rest::index_of(": ")
sep < 0 && return nil
{
"timestamp": line[1;close],
"level": rest[0;sep],
"message": rest[(sep + 2);],
}
}
analyze = |options| {
match {
`test -f "${options:file}" && echo yes || echo no` == "no" => {
println("Error: file not found: ${options:file}")
os:exit(1)
},
_ => {},
}
file = io:open(options:file)
lines = file::read_lines()
file::close()
entries = []
loop through lines with line {
entry = parse_entry(line)
match {
entry == nil => {},
entry:level == options:level => { entries::push(entry) },
_ => {},
}
}
println("Found ${entries::length()} ${options:level} entries in ${lines::length()} lines")
shown = 0
loop through entries with entry {
shown >= options:count && break
println("[${entry:timestamp}] ${entry:message}")
shown++
}
}
analyze(options)
os:rm(log_file)
Output:
Found 2 ERROR entries in 4 lines
[2024-01-15 10:30:00] Database connection failed
[2024-01-15 10:31:00] Database connection failed
Usage:
# Defaults
suji log_analyzer.si
# Point it at a different file and level
LOG_FILE=/var/log/app.log LOG_LEVEL=WARN LOG_COUNT=20 suji log_analyzer.si
# Or feed it through a pipe and read stdin instead
cat /var/log/app.log | suji log_analyzer.si
Exercises
Beginner
- Create a file size reporter using
os:stat(path):size - Build a grep clone that filters stdin against a regex
- Make a utility that renames every
.txtfile in a directory to.md
Intermediate
- Implement a CSV column selector driven by a
COLUMNSenvironment variable - Build a markdown to HTML converter that reads stdin and writes stdout
- Add a
--verbose-style toggle using an environment variable (VERBOSE=1)
Advanced
- Build a log analyzer that groups entries by level and prints a summary table
- Create a file synchronisation tool on top of
rsyncinvocations - Write a task runner that reads its tasks from YAML and reports per-task timings
Best Practices
DO:
- Print a usage line when required input is missing, then
os:exit(1) - Validate paths with
test -fbefore opening them - Give every option a default, since a missing key raises an error
- Read configuration from environment variables — flags cannot reach the script
- Guard
io:stdinreads withis_terminal()so the tool never hangs
DON’T:
- Call
env:args()— it is a value, not a function - Rely on
--flagarguments; the interpreter consumes them - Assume a failing command can be caught: a non-zero exit ends the script
- Print errors to stdout when they belong on stderr (
print(msg, io:stderr)) - Leave temp files behind; clean up with
os:rm
See Also
Cookbook
Practical recipes for common programming tasks in Suji.
Overview
The Cookbook provides ready-to-use solutions for everyday programming challenges. Each recipe includes:
- Complete working code
- Step-by-step explanations
- Real-world use cases
- Common variations
- Best practices
Every code block on these pages is a complete program: it creates whatever input it needs — a literal, or a temp file it cleans up afterwards — so you can copy one into recipe.si and run suji recipe.si unchanged. The only exception is HTTP with curl, whose recipes make real network requests.
Recipe Categories
File Processing
Work with files efficiently:
- Reading Files Line by Line - Stream a file with
read_line()instead of loading it - Processing CSV Files - Parse and transform CSV data
- Log File Analysis - Extract insights from log files
- Batch File Operations - Rename, copy or back up many files
- Directory Traversal - Walk directory trees with
find - Checking a File Exists - Test paths without aborting the script
Data Transformation
Transform data between formats:
- JSON to YAML - Convert between data formats
- CSV to JSON - Transform tabular data
- Data Filtering - Filter datasets with predicates
- Nested Data Manipulation - Work with complex structures
- Aggregation and Grouping - Summarize data
- Data Validation - Validate data structures
Configuration Management
Manage application configuration:
- Loading Config - Read from multiple sources
- Environment Settings - Handle different environments
- Config Validation - Ensure valid configuration
- Config Merging - Combine configuration objects
- Type-Safe Access - Access config safely
Working with APIs
HTTP and API integration:
- Making HTTP Requests - GET, POST, PUT, DELETE with
curl - JSON API Consumption - Parse API responses
- Authentication - Handle API keys and tokens
- Error Handling - Graceful failure handling
- Rate Limiting - Respect API limits
- Pagination - Handle paginated responses
Text Processing
String manipulation and regex:
- Email Validation - Validate email formats
- URL Extraction - Extract URLs from text
- Log Parsing - Parse structured logs
- Template Generation - Generate text from templates
- Text Search and Replace - Normalize and rewrite text
Scripting Tasks
Automation and workflows:
- Script Arguments - Read
env:argssafely - Standard Input - Consume piped input
- Shell Commands - Backticks, quoting and failure handling
- Pipelines - Pipe closures into commands and back
- Retry and Poll Loops - Wait for something to become ready
- Backup Script - A complete worked script
Quick Examples
Read and Process CSV
import std:csv
import std:io
import std:os
import std:println
path = `mktemp`
f = io:open(path, true, true)
f::write("name,age\nAlice,34\nBob,12\nCarol,29\n")
f::close()
file = io:open(path)
rows = csv:parse(file::read_all())
file::close()
adults = rows[1;]
::filter(|row| row[1]::to_number() >= 18)
::map(|row| row[0])
println("Adults: ${adults::join(", ")}") # Adults: Alice, Carol
os:rm(path)
Parse API Data
import std:json
import std:println
text = '[{"name": "Alice"}, {"name": "Bob"}]'
users = json:parse(text)
println("Loaded ${users::length()} users") # Loaded 2 users
Swap the literal for `curl -fsSL <url>` to read the same shape off the network — see HTTP with curl.
Process Log Files
import std:io
import std:os
import std:println
path = `mktemp`
f = io:open(path, true, true)
f::write("INFO started\nERROR disk full\nERROR disk full\n")
f::close()
file = io:open(path)
lines = file::read_lines()
file::close()
errors = lines::filter(|line| line ~ /ERROR/)
println("Found ${errors::length()} errors") # Found 2 errors
os:rm(path)
Generate Report
import std:io
import std:os
import std:println
data = { total: 12500, average: 4166.67 }
report = """<html>
<head><title>Sales Report</title></head>
<body>
<h1>Sales Report</h1>
<p>Total Sales: ${data:total}</p>
<p>Average: ${data:average}</p>
</body>
</html>"""
path = `mktemp`
out_file = io:open(path, true, true) # create=true, truncate=true
out_file::write(report)
out_file::close()
println(`grep -c '<p>' ${path}`) # 2
os:rm(path)
How to Use This Cookbook
- Browse by Category - Find recipes related to your task
- Copy and Adapt - Start with working code, modify for your needs
- Understand the Pattern - Learn the underlying approach
- Experiment - Try variations and extensions
Common Patterns
Defensive Checking Pattern
Suji has no exceptions: a runtime error prints a diagnostic and ends the process. The only strategy is to check before you act, and to return a (value, error) tuple instead of throwing.
import std:io
import std:println
exists = |p| `test -f "${p}" && echo yes || echo no` == "yes"
process_file = |filename| {
!exists(filename) && return (nil, "File not found")
file = io:open(filename)
content = file::read_all()
file::close()
return (content::length(), nil)
}
size, error = process_file("/no/such/file")
match {
error != nil => { println("Error: ${error}") },
_ => { println("Read ${size} bytes") },
}
Output:
Error: File not found
Pipeline Pattern
Chain transformations with method calls, or compose named steps with >>:
import std:println
validate = |xs| xs::filter(|x| x::is_number())
transform = |xs| xs::map(|x| x * 2)
summarize = |xs| xs::sum()
pipeline = validate >> transform >> summarize
println(pipeline([1, "two", 3, 4])) # 16
Configuration Pattern
Layer defaults, file values and environment variables, then validate the result:
import std:env
import std:println
defaults = { port: 8080, log_level: "info" }
file_config = { log_level: "debug" }
# merge mutates the receiver, so copy the defaults first
config = defaults
config::merge(file_config)
override = env:var::get("APP_PORT", nil)
match { override != nil => { config["port"] = override::to_number() } }
println("${config:port} / ${config:log_level}") # 8080 / debug
Tips for Success
DO:
- Start with working examples
- Check for missing keys, missing files and empty lists before using them
- Use
map::get(key, default)for anything optional - Break complex tasks into small named functions
- Guard shell commands that may fail with
|| true
DON’T:
- Expect
try/catch,if/else,fororwhile— Suji has none of them - Rely on truthiness;
&&,||and!require real booleans - Read a huge file with
read_all()whenread_line()will do - Hardcode configuration or absolute paths
- Assume a non-zero exit status from a command is recoverable
Next Steps
Start with the recipe category most relevant to your task:
- File Processing - Working with files and directories
- Data Transformation - Converting and filtering data
- Configuration - Managing app configuration
- APIs - HTTP requests and API integration
- Text Processing - String manipulation and regex
- Scripting - Automation and workflows
See Also
- Standard Library - Built-in modules reference
- Functions - Function programming guide
- Examples - Complete example programs
HTTP with curl (Cookbook)
Suji has no built-in HTTP client. Use shell commands (e.g. curl) plus Suji’s stdlib modules like std:json to work with HTTP APIs.
The pattern is always the same: a backtick command returns the response body as a string, and json:parse turns that string into maps and lists.
import std:json
import std:println
text = `curl -fsSL https://jsonplaceholder.typicode.com/users/1`
user = json:parse(text)
println(user:name) # Leanne Graham
println(user:company:name) # Romaguera-Crona
Every recipe on this page is a real request against JSONPlaceholder, a public test API that returns fixed data, so you can run each block verbatim and get the output shown in the comments. These are the only blocks in the book that need network access.
GET JSON and parse it
Once the body is parsed, it is ordinary Suji data — filter, fold and indexing all work on it:
import std:json
import std:println
text = `curl -fsSL 'https://jsonplaceholder.typicode.com/todos?userId=1'`
todos = json:parse(text)
done = todos::filter(|t| t:completed)
summary = "${todos::length()} todos, ${done::length()} completed"
println(summary) # 20 todos, 11 completed
Quote the URL in the shell command whenever it contains ? or &, as above. Unquoted, the shell treats them as a glob and a background-job separator.
Inspect status codes
Ask curl to print only the status code, then branch on the string it returns:
import std:println
url = "https://jsonplaceholder.typicode.com/users/999"
status = `curl -s -o /dev/null -w '%{http_code}' ${url}`
message = match status {
"200" => "healthy",
"404" => "no such endpoint",
"500" | "502" | "503" => "server error",
_ => "unexpected status ${status}",
}
println(message) # no such endpoint
Note that -f is deliberately absent here. With -f, curl exits non-zero on a 4xx/5xx response, and a non-zero exit status from a backtick command is a runtime error that terminates the script — there is no way to catch it. Reading the status code as a string keeps the decision in Suji.
POST JSON
Long curl invocations read better when the parts are named first. Interpolating a value you wrote yourself, like the URL and header below, is safe:
import std:json
import std:println
url = "https://jsonplaceholder.typicode.com/posts"
head = "Content-Type: application/json"
data = '{"title":"hello","userId":1}'
body = `curl -fsSL -X POST -H '${head}' --data '${data}' ${url}`
created = json:parse(body)
println(created:id) # 101
println(created:title) # hello
A body built at runtime is a different matter. Never interpolate json:generate output into a command — a value containing a quote or a $ would break the command or inject into it. Write the payload to a file and let curl read it with --data @file:
import std:io
import std:json
import std:os
import std:println
url = "https://jsonplaceholder.typicode.com/posts"
head = "Content-Type: application/json"
payload = json:generate({ title: "hello", userId: 1 })
p = `mktemp`
f = io:open(p, true, true)
f::write(payload)
f::close()
body = `curl -fsSL -X POST -H '${head}' --data @${p} ${url}`
os:rm(p)
println(json:parse(body):id) # 101
Requests that are allowed to fail
A failing request aborts the script. This program prints nothing — it dies on line 3:
import std:println
body = `curl -fsSL https://jsonplaceholder.typicode.com/nope`
println(body)
[406] Error: Shell command failed
Shell command 'curl -fsSL https://jsonplaceholder.typicode.com/nope' failed with
exit code 56: curl: (56) The requested URL returned error: 404
The script itself exits with status 1. The inner number is curl’s own exit code and varies between curl versions; what matters is that it is non-zero.
Since a non-zero exit terminates the script, make the shell itself absorb the failure and return something you can test:
import std:json
import std:println
body = `curl -fsSL https://jsonplaceholder.typicode.com/nope || true`
result = match {
body::length() == 0 => "request failed",
_ => "got ${json:parse(body)::length()} records",
}
println(result) # request failed
The same trick covers connectivity checks:
import std:println
url = "https://jsonplaceholder.typicode.com"
up = `curl -fsS -o /dev/null ${url} && echo up || echo down`
println(up) # up
Notes
curloutput is a string with the trailing newline trimmed; parse structured responses withstd:json:parse,std:yaml:parse, etc.- Only stdout is captured. Add
-sto silence the progress meter, whichcurlwrites to stderr. - Interpolating runtime data into a command is a shell injection risk — prefer
--data @filefor bodies, andpercent_encodefromstd:encodingfor query parameters. - There is no timeout inside Suji, so
curl --max-timeis worth setting on every call in a long-running script.
See Also
File Processing Recipes
Practical recipes for working with files and directories.
Every recipe below creates its own sample input in a temp file, so you can paste any block straight into a .si file and run it.
Reading Line by Line
stream::read_all() and stream::read_lines() are eager — they pull the whole file into memory. To process a large file one line at a time, loop on read_line(), which returns nil at end of file.
Recipe
import std:io
import std:os
import std:println
# Sample input
path = `mktemp`
f = io:open(path, true, true)
f::write("""INFO service started
ERROR database connection refused
INFO retrying
ERROR database connection refused
""")
f::close()
process_large_file = |filename| {
line_count = 0
error_count = 0
file = io:open(filename)
loop {
line = file::read_line()
line == nil && break
line_count++
match { line ~ /ERROR/ => { error_count++ } }
}
file::close()
{ "lines": line_count, "errors": error_count }
}
stats = process_large_file(path)
println("Processed ${stats:lines} lines, found ${stats:errors} errors")
os:rm(path)
Output:
Processed 4 lines, found 2 errors
Use Cases
- Processing log files
- Analyzing large text files
- Streaming data transformation
- Memory-efficient file parsing
CSV Processing
Parse and transform CSV data with std:csv. csv:parse returns a list of rows, and every cell is a string — convert with ::to_number() when you need arithmetic.
Recipe
import std:csv
import std:io
import std:os
import std:println
# Sample input
path = `mktemp`
f = io:open(path, true, true)
f::write("""customer,amount,date,category
Alice,1200,2024-01-15,retail
Bob,300,2024-01-16,retail
Carol,4500,2024-01-17,wholesale
""")
f::close()
file = io:open(path)
content = file::read_all()
file::close()
rows = csv:parse(content)
# Skip the header row, keep the large orders
large = rows[1;]
::filter(|row| row[1]::to_number() > 1000)
::map(|row| {
"customer": row[0],
"amount": row[1]::to_number(),
"date": row[2],
"category": row[3],
})
total = large::fold(0, |acc, row| acc + row:amount)
println("Large orders: ${large::length()}")
println("Total: ${total}")
# Write the filtered rows back out. csv:generate requires string cells,
# so convert numbers with ::to_string().
body = large::map(|row| [row:customer, row:amount::to_string()])
out_rows = [["customer", "amount"]] + body
out_path = `mktemp`
out_file = io:open(out_path, true, true) # create=true, truncate=true
out_file::write(csv:generate(out_rows))
out_file::close()
println(`cat ${out_path}`)
os:rm(path)
os:rm(out_path)
Output:
Large orders: 2
Total: 5700
customer,amount
Alice,1200
Carol,4500
Variations
Convert CSV to JSON
import std:csv
import std:io
import std:json
import std:os
import std:println
path = `mktemp`
f = io:open(path, true, true)
f::write("id,name\n1,Alice\n2,Bob\n")
f::close()
file = io:open(path)
rows = csv:parse(file::read_all())
file::close()
headers = rows[0]
records = rows[1;]::map(|row| {
record = {}
loop through 0..headers::length() with i {
record[headers[i]] = row[i]
}
record
})
println(json:generate(records))
os:rm(path)
[{"id":"1","name":"Alice"},{"id":"2","name":"Bob"}]
Log Analysis
Extract and analyze information from log files. Suji’s regex support is match-only (no capture groups), so pull fields out with index_of and slicing.
Recipe
import std:io
import std:os
import std:println
# Sample input
path = `mktemp`
f = io:open(path, true, true)
f::write("""[2024-01-15 10:30:00] ERROR: Database connection failed
[2024-01-15 10:30:05] WARN: Retry scheduled
[2024-01-15 10:30:06] INFO: HTTP GET /health 200
[2024-01-15 10:31:00] ERROR: Database connection failed
""")
f::close()
parse_entry = |line| {
close = line::index_of("]")
timestamp = match { close >= 0 => line[1;close], _ => "unknown", }
rest = match { close >= 0 => line[(close + 2);], _ => line, }
sep = rest::index_of(": ")
level = match { sep >= 0 => rest[0;sep], _ => "UNKNOWN", }
message = match { sep >= 0 => rest[(sep + 2);], _ => rest, }
{ "timestamp": timestamp, "level": level, "message": message }
}
analyze_logs = |log_file| {
errors = []
warnings = []
requests = 0
file = io:open(log_file)
lines = file::read_lines()
file::close()
loop through lines with line {
entry = parse_entry(line)
match entry:level {
"ERROR" => { errors::push(entry) }
"WARN" => { warnings::push(entry) }
}
match { line ~ /HTTP/ => { requests++ } }
}
{
"error_count": errors::length(),
"warning_count": warnings::length(),
"request_count": requests,
"first_error": errors::first(nil),
}
}
report = analyze_logs(path)
println("Errors: ${report:error_count}")
println("Warnings: ${report:warning_count}")
println("Requests: ${report:request_count}")
println("First error: ${report:first_error:message}")
os:rm(path)
Output:
Errors: 2
Warnings: 1
Requests: 1
First error: Database connection failed
Batch Operations
The standard library has no directory listing function, so use the shell for discovery and Suji for the logic. ls -1 returns one name per line; split it and drop the empty trailing entry.
Recipe: Batch Rename
import std:os
import std:path
import std:println
# Sample directory with three files
dir = `mktemp -d`
loop through ["notes.txt", "todo.txt", "image.png"] with name {
`touch ${dir}/${name}`
}
list_files = |directory| `ls -1 ${directory}`
::split("\n")
::filter(|n| n::length() > 0)
batch_rename = |directory, pattern, old_ext, new_ext| {
renamed = 0
loop through list_files(directory) with file {
file !~ pattern && continue
new_name = file::replace(old_ext, new_ext)
old_path = path:join([directory, file])
new_path = path:join([directory, new_name])
`mv ${old_path} ${new_path}`
renamed++
println("Renamed: ${file} -> ${new_name}")
}
println("Renamed ${renamed} files")
}
batch_rename(dir, /\.txt$/, ".txt", ".md")
`rm -rf ${dir}`
Output:
Renamed: notes.txt -> notes.md
Renamed: todo.txt -> todo.md
Renamed 2 files
Recipe: Batch Backup
import std:os
import std:path
import std:println
import std:time
dir = `mktemp -d`
loop through ["a.json", "b.json", "c.yaml"] with name {
`touch ${dir}/${name}`
}
backup_files = |directory, pattern| {
stamp = time:now():epoch_ms
backup_dir = "${directory}_backup_${stamp}"
os:mkdir(backup_dir)
files = `ls -1 ${directory}`
::split("\n")
::filter(|n| n::length() > 0)
matching = files::filter(|f| f ~ pattern)
loop through matching with file {
source = path:join([directory, file])
dest = path:join([backup_dir, file])
`cp ${source} ${dest}`
println("Backed up: ${file}")
}
println("Backed up ${matching::length()} files")
backup_dir
}
backup_dir = backup_files(dir, /\.json$/)
`rm -rf ${dir} ${backup_dir}`
Output:
Backed up: a.json
Backed up: b.json
Backed up 2 files
Directory Traversal
There is no recursive walker in the stdlib either — find does the walking and Suji does the work.
Recipe
import std:println
dir = `mktemp -d`
`mkdir -p ${dir}/src/nested`
`touch ${dir}/src/main.si ${dir}/src/nested/util.si ${dir}/src/notes.txt`
walk_files = |root| `find ${root} -type f`
::split("\n")
::filter(|p| p::length() > 0)
all_files = walk_files(dir)
si_files = all_files::filter(|p| p ~ /\.si$/)
println("Found ${all_files::length()} files")
println("Suji sources: ${si_files::length()}")
`rm -rf ${dir}`
Output:
Found 3 files
Suji sources: 2
Use Case: Count Lines of Code
import std:io
import std:println
dir = `mktemp -d`
f1 = io:open("${dir}/a.si", true, true)
f1::write("import std:println\nprintln(1)\n")
f1::close()
f2 = io:open("${dir}/b.si", true, true)
f2::write("x = 1\n")
f2::close()
count_loc = |directory, pattern| {
total_lines = 0
file_count = 0
paths = `find ${directory} -type f`
::split("\n")
::filter(|p| p::length() > 0)
loop through paths::filter(|p| p ~ pattern) with filepath {
file = io:open(filepath)
total_lines += file::read_lines()::length()
file::close()
file_count++
}
{
"files": file_count,
"lines": total_lines,
"average": match {
file_count > 0 => total_lines / file_count,
_ => 0,
},
}
}
stats = count_loc(dir, /\.si$/)
println("Files: ${stats:files}")
println("Total lines: ${stats:lines}")
println("Average: ${stats:average::round()}")
`rm -rf ${dir}`
Output:
Files: 2
Total lines: 3
Average: 2
Checking Whether a File Exists
os:stat raises a fatal error when the path is missing, and there is no way to catch it — so test first with the shell, which can absorb the failure:
import std:println
exists = |p| `test -e "${p}" && echo yes || echo no` == "yes"
println(exists("/etc/hosts")) # true
println(exists("/no/such/file")) # false
Once you know the path exists, os:stat(path) gives you size, is_directory, mtime and friends.
Best Practices
DO:
- Use
read_line()in a loop for large files;read_all()andread_lines()load everything - Check that a file exists (
test -evia the shell) beforeos:statorio:open - Close streams with
::close()when you are done - Build paths with
path:join([a, b])instead of string concatenation - Quote interpolated paths in shell commands:
`ls -1 "${dir}"`
DON’T:
- Assume
os:statreturnsnilfor a missing path — it terminates the script - Let a shell command that may fail run unguarded; add
|| trueor&& echo yes || echo no - Feed numbers to
csv:generate— every cell must be a string - Hardcode file paths that only exist on your machine
- Forget that
csv:parsekeeps the header row as row0
See Also
Data Transformation Recipes
Convert, filter, and manipulate data efficiently.
Each recipe writes its own sample input to a temp file so the block runs as-is; replace the temp path with your real file when you adapt it.
JSON to YAML
Convert between JSON and YAML formats. std:json and std:yaml both expose exactly parse(text) and generate(value).
Recipe
import std:io
import std:json
import std:os
import std:println
import std:yaml
# Sample input
json_path = `mktemp`
f = io:open(json_path, true, true)
f::write("""{"service": "api", "port": 8080, "tags": ["web", "public"]}""")
f::close()
file = io:open(json_path)
json_content = file::read_all()
file::close()
data = json:parse(json_content)
yaml_path = `mktemp`
out_file = io:open(yaml_path, true, true) # create=true, truncate=true
out_file::write(yaml:generate(data))
out_file::close()
println(`cat ${yaml_path}`)
os:rm(json_path)
os:rm(yaml_path)
Output:
port: 8080
service: api
tags:
- web
- public
Reverse: YAML to JSON
import std:io
import std:json
import std:os
import std:println
import std:yaml
yaml_path = `mktemp`
f = io:open(yaml_path, true, true)
f::write("service: api\nport: 8080\n")
f::close()
file = io:open(yaml_path)
data = yaml:parse(file::read_all())
file::close()
println(json:generate(data)) # {"port":8080,"service":"api"}
os:rm(yaml_path)
CSV to JSON
Transform tabular data to JSON. Remember that every cell coming out of csv:parse is a string.
Recipe
import std:csv
import std:io
import std:json
import std:os
import std:println
path = `mktemp`
f = io:open(path, true, true)
f::write("""id,name,email,age,active
1,Alice,alice@example.com,34,true
2,Bob,bob@example.com,17,false
""")
f::close()
file = io:open(path)
rows = csv:parse(file::read_all())
file::close()
# Skip the header row and give every column a type
users = rows[1;]::map(|row| {
"id": row[0]::to_number(),
"name": row[1],
"email": row[2],
"age": row[3]::to_number(),
"active": row[4] == "true",
})
println(json:generate(users))
println("Converted ${users::length()} rows")
os:rm(path)
Output:
[{"active":true,"age":34,"email":"alice@example.com","id":1,"name":"Alice"},{"active":false,"age":17,"email":"bob@example.com","id":2,"name":"Bob"}]
Converted 2 rows
Filtering
Filter datasets with list::filter and a predicate closure.
Recipe
import std:io
import std:json
import std:os
import std:println
path = `mktemp`
f = io:open(path, true, true)
f::write("""[
{"name": "Alice", "age": 34, "active": true},
{"name": "Bob", "age": 17, "active": true},
{"name": "Carol", "age": 41, "active": false}
]""")
f::close()
filter_data = |input_file, output_file, predicate| {
file = io:open(input_file)
data = json:parse(file::read_all())
file::close()
filtered = data::filter(predicate)
out_file = io:open(output_file, true, true) # create=true, truncate=true
out_file::write(json:generate(filtered))
out_file::close()
println("Filtered: ${data::length()} -> ${filtered::length()}")
filtered
}
out_path = `mktemp`
adults = filter_data(path, out_path, |user| user:active && user:age >= 18)
println(adults::map(|u| u:name)::join(", "))
os:rm(path)
os:rm(out_path)
Output:
Filtered: 3 -> 1
Alice
Note that && requires booleans on both sides — there is no truthiness in Suji, so write user:age >= 18, never user:age.
Nested Data
Work with complex nested structures. Chained key access (user:profile:contact:email) reads several levels at once.
Recipe
import std:json
import std:println
data = json:parse("""{
"users": [
{
"id": 1,
"profile": {
"name": "Alice",
"contact": {"email": "alice@example.com"},
"address": {"city": "Oslo", "country": "NO"}
},
"permissions": [{"role": "admin"}, {"role": "billing"}]
}
]
}""")
transform_nested = |data| data:users::map(|user| {
"id": user:id,
"name": user:profile:name,
"email": user:profile:contact:email,
"city": user:profile:address:city,
"roles": user:permissions::map(|p| p:role),
})
flattened = transform_nested(data)
println(json:generate(flattened))
Output:
[{"city":"Oslo","email":"alice@example.com","id":1,"name":"Alice","roles":["admin","billing"]}]
Safe Access Pattern
Reading a missing key raises Key not found, and there is no way to catch it. Walk the path with ::get(key, nil) instead, which yields nil for a missing key. nil::is_map() is false rather than an error, so one guard covers both a missing key and a non-map value part-way down the path:
import std:println
safe_get = |data, path| {
result = data
loop through path with key {
result = match {
!result::is_map() => nil,
_ => result::get(key, nil),
}
}
result
}
user = { profile: { contact: { email: "alice@example.com" } } }
println(safe_get(user, ["profile", "contact", "email"])) # alice@example.com
println(safe_get(user, ["profile", "phone", "mobile"])) # nil
Aggregation
Summarize and group data.
Recipe: Group By
There is no list::group_by, but the map methods make it a five-line helper:
import std:println
group_by = |list, key_fn| {
result = {}
loop through list with item {
key = key_fn(item)
group = result::get(key, [])
group::push(item)
result[key] = group
}
result
}
users = [
{ role: "admin", name: "Alice" },
{ role: "user", name: "Bob" },
{ role: "admin", name: "Carol" },
]
by_role = group_by(users, |u| u:role)
loop through by_role with role, members {
println("${role}: ${members::map(|m| m:name)::join(", ")}")
}
Output:
admin: Alice, Carol
user: Bob
Recipe: Aggregation
sum, average, min and max are list methods, so most summaries need no folding at all:
import std:println
sales = [
{ customer: "Alice", amount: 1200 },
{ customer: "Bob", amount: 300 },
{ customer: "Carol", amount: 4500 },
]
aggregate = |data| {
amounts = data::map(|x| x:amount)
{
"total": data::length(),
"sum": amounts::sum(),
"average": match {
amounts::length() > 0 => amounts::average(),
_ => 0,
},
"max": amounts::max(),
"min": amounts::min(),
}
}
stats = aggregate(sales)
println("Count: ${stats:total}")
println("Sum: ${stats:sum}")
println("Average: ${stats:average}")
println("Range: ${stats:min} - ${stats:max}")
Output:
Count: 3
Sum: 6000
Average: 2000
Range: 300 - 4500
::average() returns nil for an empty list, and ::min() / ::max() only work on lists of numbers, which is why the guard is there.
Validation
Validate data structures before processing them. Collect the problems in a list and return a (ok, errors) tuple.
Recipe
import std:println
validate_user = |user| {
errors = []
# && short-circuits, so the key check protects the value check
has_name = user::contains("name") && user:name::length() > 0
has_email = user::contains("email") && user:email ~ /^[^@]+@[^@]+$/
has_age = user::contains("age") && user:age >= 0 && user:age <= 150
match { !has_name => { errors::push("Name is required") } }
match { !has_email => { errors::push("Valid email required") } }
match { !has_age => { errors::push("Valid age required") } }
match errors::length() {
0 => (true, nil),
_ => (false, errors),
}
}
validate_dataset = |users| {
results = users::map(|user| {
valid, errors = validate_user(user)
{ "user": user, "valid": valid, "errors": errors }
})
{
"total": users::length(),
"valid": results::filter(|r| r:valid)::length(),
"invalid": results::filter(|r| !r:valid)::length(),
"results": results,
}
}
report = validate_dataset([
{ name: "Alice", email: "alice@example.com", age: 34 },
{ name: "Bob", email: "not-an-email", age: 17 },
{ name: "", email: "carol@example.com", age: 200 },
])
println("Valid: ${report:valid}/${report:total}")
loop through report:results with r {
r:valid && continue
println("${r:user:name} -> ${r:errors::join("; ")}")
}
Output:
Valid: 1/3
Bob -> Valid email required
-> Name is required; Valid age required
The safety here rests on && short-circuiting: when user::contains("name") is false, user:name::length() is never evaluated, so a missing key produces false instead of aborting the script. Writing the two checks in the opposite order would crash.
Complete Example: Data Pipeline
Transform, filter, validate, and aggregate in one pass.
import std:io
import std:json
import std:os
import std:println
path = `mktemp`
f = io:open(path, true, true)
f::write("""[
{"timestamp": "2024-01-15T10:00:00Z", "customer_name": "Alice",
"total_amount": "1200", "line_items": [1, 2], "region": "eu"},
{"timestamp": "2024-01-15T11:00:00Z", "customer_name": "Bob",
"total_amount": "0", "line_items": [1], "region": "us"},
{"timestamp": "2024-01-16T09:00:00Z", "customer_name": "Carol",
"total_amount": "4500", "line_items": [1, 2, 3], "region": "eu"}
]""")
f::close()
group_by = |list, key_fn| {
result = {}
loop through list with item {
key = key_fn(item)
group = result::get(key, [])
group::push(item)
result[key] = group
}
result
}
process_sales_data = |input_file| {
# 1. Load
file = io:open(input_file)
raw_data = json:parse(file::read_all())
file::close()
# 2. Transform
transformed = raw_data::map(|sale| {
"date": sale:timestamp[0;10],
"customer": sale:customer_name,
"amount": sale:total_amount::to_number(),
"items": sale:line_items::length(),
"region": sale:region,
})
# 3. Filter (valid sales only)
is_valid = |sale| sale:amount > 0 && sale:customer::length() > 0
valid_sales = transformed::filter(is_valid)
# 4. Aggregate
amounts = valid_sales::map(|s| s:amount)
by_region = group_by(valid_sales, |s| s:region)
{
"total_sales": valid_sales::length(),
"revenue": amounts::sum(),
"average_sale": match {
amounts::length() > 0 => amounts::average(),
_ => 0,
},
"regions": by_region::keys(),
}
}
summary = process_sales_data(path)
println("Processed ${summary:total_sales} sales")
println("Total revenue: ${summary:revenue}")
println("Average sale: ${summary:average_sale}")
println("Regions: ${summary:regions::join(", ")}")
os:rm(path)
Output:
Processed 2 sales
Total revenue: 5700
Average sale: 2850
Regions: eu
Best Practices
DO:
- Validate data before transforming it
- Read optional keys with
map::get(key, default) - Convert CSV cells with
::to_number()before doing arithmetic - Keep transformations pure — return new values instead of mutating inputs
- Document the shape of the data a function expects
DON’T:
- Index into a map with
:keyunless you know the key is present - Rely on truthiness —
&&,||and!need real booleans - Assume
json:generatepreserves key order (it sorts keys) - Mix types with
+;"total: " + 1is a type error, use"total: ${1}" - Build deeply nested one-liners when a named helper reads better
See Also
Configuration Management
Configuration is usually a combination of:
- Defaults (checked into source control)
- Environment variables (per-deployment)
- Optional config files (per-environment or per-user)
Suji has no if statements, so use match for branching and guard clauses for early exits.
Loading
Layer the sources: start from the defaults, overlay the config file, then let environment variables win. map::merge(other) mutates the receiver and returns nil, so merge into the accumulating config.
import std:dotenv
import std:env
import std:io
import std:json
import std:os
import std:println
# Sample .env and config file
env_path = `mktemp`
f = io:open(env_path, true, true)
f::write("APP_PORT=9000\n")
f::close()
config_path = `mktemp`
g = io:open(config_path, true, true)
g::write("""{"log_level": "debug", "port": 8081}""")
g::close()
exists = |p| `test -f "${p}" && echo yes || echo no` == "yes"
load_json = |p| {
file = io:open(p)
parsed = json:parse(file::read_all())
file::close()
parsed
}
dotenv:load(env_path)
config = { port: 8080, log_level: "info" }
# 1. Overlay the config file, if there is one
match { exists(config_path) => { config::merge(load_json(config_path)) } }
# 2. Environment variables win
env_port = env:var::get("APP_PORT", nil)
match { env_port != nil => { config["port"] = env_port::to_number() } }
println("port=${config:port}") # port=9000
println("log_level=${config:log_level}") # log_level=debug
os:rm(env_path)
os:rm(config_path)
dotenv:load(path) returns the map it loaded and also writes the values into env:var, so later lookups see them.
Environments
import std:env
import std:println
env_name = env:var::get("APP_ENV", "development")
settings = match env_name {
"production" => { "debug": false, "workers": 8 },
"staging" => { "debug": false, "workers": 2 },
_ => { "debug": true, "workers": 1 },
}
println("${env_name}: debug=${settings:debug} workers=${settings:workers}")
Output when APP_ENV is unset:
development: debug=true workers=1
Note the quoted keys: a { ... } with bare identifier keys as a match-arm body is parsed as a block, so quote the keys (or wrap the map in parentheses).
Validation
There is no exception mechanism, so validation returns a (ok, message) tuple that the caller destructures:
import std:println
validate_config = |config| {
match {
!config::contains("port") => return (false, "port is required"),
!config:port::is_number() => return (false, "port must be a number"),
config:port < 1 => return (false, "port must be >= 1"),
config:port > 65535 => return (false, "port must be <= 65535"),
}
return (true, nil)
}
ok, message = validate_config({ port: 8080 })
println("${ok} ${message}") # true nil
ok2, message2 = validate_config({ port: "8080" })
println("${ok2} ${message2}") # false port must be a number
Since a runtime error terminates the process, check ::contains before reading a key and ::is_number() before comparing — the order of the arms matters.
Merging
map::merge mutates its receiver. When you need the original left untouched, copy first:
import std:println
merge = |base, override| {
out = base # maps are copied on assignment
out::merge(override)
out
}
defaults = { port: 8080, log_level: "info" }
overrides = { port: 9000 }
merged = merge(defaults, overrides)
println(merged) # {port: 9000, log_level: info}
println(defaults) # {port: 8080, log_level: info}
For a deep merge, recurse when both sides hold a map:
import std:println
deep_merge = |base, override| {
out = base
loop through override with key, value {
old = out::get(key, nil)
out[key] = match {
old::is_map() && value::is_map() => deep_merge(old, value),
_ => value,
}
}
out
}
result = deep_merge(
{ server: { host: "localhost", port: 8080 }, debug: false },
{ server: { port: 9000 } },
)
println(result) # {server: {host: localhost, port: 9000}, debug: false}
Type-Safe Access
Configuration values that arrive from files or the environment are often strings. Check the type before using them, and fall back to a default:
import std:println
get_port = |config| {
port = config::get("port", nil)
match {
port::is_number() => port,
port::is_string() && port ~ /^\d+$/ => port::to_number(),
_ => 8080,
}
}
println(get_port({ port: 3000 })) # 3000
println(get_port({ port: "3000" })) # 3000
println(get_port({ port: "http" })) # 8080
println(get_port({})) # 8080
::to_number() on a non-numeric string is a runtime error, which is why the regex guard comes first.
See Also
Working with APIs
Use backtick shell commands (e.g. curl) and parse responses with std:json, std:yaml, etc.
See also: HTTP with curl
Recipes about request mechanics make real calls against JSONPlaceholder and are marked as needing network access. Recipes about handling a response use a literal body instead, so the shape under discussion is visible in the block itself.
HTTP Requests
Naming the base URL and wrapping the call in a helper keeps the call sites short. Quote the interpolated URL so a ? or & in the path reaches curl intact:
import std:json
import std:println
api = "https://jsonplaceholder.typicode.com"
get = |path| json:parse(`curl -fsSL "${api}${path}"`)
user = get("/users/1")
println(user:name) # Leanne Graham
todos = get("/todos?userId=1")
println(todos::length()) # 20
JSON APIs
A response body is just a string, so parsing is one call. From there it is ordinary map and list work:
import std:json
import std:println
body = """{
"count": 3,
"users": [
{"id": 1, "name": "Alice", "role": "admin"},
{"id": 2, "name": "Bob", "role": "user"},
{"id": 3, "name": "Carol", "role": "admin"}
]
}"""
data = json:parse(body)
admins = data:users
::filter(|u| u:role == "admin")
::map(|u| u:name)
println("${data:count} users") # 3 users
println("admins: ${admins::join(", ")}") # admins: Alice, Carol
Missing keys raise Key not found, so read anything optional with ::get:
import std:json
import std:println
data = json:parse('{"name": "Alice"}')
println(data::get("name", "unknown")) # Alice
println(data::get("email", "unknown")) # unknown
Authentication
Keep credentials in the environment, never in the source file, and check that they are present before making the call:
import std:env
import std:println
token = env:var::get("API_TOKEN", nil)
match token {
nil => println("API_TOKEN is not set; skipping the request"),
_ => {
# A token with spaces or shell metacharacters must stay quoted.
auth = "Authorization: Bearer ${token}"
println(`curl -fsSL -H "${auth}" https://api.example.com/me`)
},
}
Output when the variable is unset:
API_TOKEN is not set; skipping the request
For header-heavy requests, a config file read with std:dotenv or a ~/.netrc handled by curl --netrc keeps secrets out of the process listing.
Error Handling
Suji has no try/catch, and a command that exits non-zero terminates the script. Make the shell return a value you can inspect instead:
import std:json
import std:println
# `curl -fsSL "${url}" || true` yields "" when the request fails,
# which is what this empty body stands for.
body = ""
summary = match {
body::length() == 0 => "request failed or returned nothing",
_ => "parsed ${json:parse(body)::length()} records",
}
println(summary) # request failed or returned nothing
HTTP with curl shows the same guard against a live endpoint.
The other half of defensive API code is validating the payload before using it:
import std:json
import std:println
data = json:parse('{"error": "rate limited"}')
result = match {
data::contains("error") => "API error: ${data:error}",
data::contains("users") => "got ${data:users::length()} users",
_ => "unrecognised response",
}
println(result) # API error: rate limited
Rate Limiting
time:sleep(ms) between calls is the simplest way to stay under a quota:
import std:println
import std:time
endpoints = ["/users", "/repos", "/issues"]
delay_ms = 20 # a real client would use several hundred milliseconds
results = []
loop through endpoints with endpoint {
# Real call: results::push(`curl -fsSL "${api}${endpoint}"`)
results::push("ok")
time:sleep(delay_ms)
}
waited = endpoints::length() * delay_ms
println("${results::length()} requests, at least ${waited}ms waiting")
Output:
3 requests, at least 60ms waiting
If the API reports its own limits (for example X-RateLimit-Remaining), fetch the headers with curl -sD - and slow down when the remaining count gets low.
Pagination
Request pages in a loop and stop when one comes back short. The safety valve matters: without it a misbehaving API that always returns a full page would loop forever.
import std:json
import std:println
api = "https://jsonplaceholder.typicode.com"
per_page = 40
all_items = []
page = 1
loop {
url = "${api}/posts?_page=${page}&_limit=${per_page}"
items = json:parse(`curl -fsSL "${url}"`)
all_items = all_items + items
items::length() < per_page && break
page++
page > 50 && break # a broken API cannot loop forever
}
println("fetched ${all_items::length()} items over ${page} pages")
fetched 100 items over 3 pages
Notes
- Backtick commands raise a runtime error if the command exits non-zero;
curl -fturns a non-2xx response into exactly that, so add|| truewhen you want to handle failure yourself. - Only stdout is captured, and the trailing newline is trimmed.
curl --max-timeis worth setting on every call: there is no timeout mechanism inside Suji.
See Also
Text Processing Recipes
String manipulation and regex patterns for common tasks.
Suji’s regex support answers one question — does this match? There are no capture groups, no regex replace and no regex split, so extraction is done with index_of, slicing (s[a;b]) and split.
Email Validation
Validate email addresses with regex.
import std:println
email_pattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
validate_email = |email| email ~ email_pattern
println(validate_email("user@example.com")) # true
println(validate_email("user@localhost")) # false
println(validate_email("not an email")) # false
URL Extraction
Extract URLs from text by splitting into words and matching each one.
import std:println
extract_urls = |text| {
words = text::replace("\n", " ")::replace("\t", " ")::split(" ")
words::filter(|w| w::length() > 0 && w ~ /^https?:\/\/.+$/)
}
text = "Visit https://example.com or http://test.org for more info"
urls = extract_urls(text)
println(urls::length()) # 2
println(urls::join(", ")) # https://example.com, http://test.org
Trailing punctuation is part of the word, so trim it when the text is prose:
import std:println
clean = |w| w::trim(".,;:!?)")
println(clean("https://example.com.")) # https://example.com
Log Parsing
Parse structured log entries. Locate the delimiters with index_of, then slice.
import std:println
parse_log_line = |line| {
close = line::index_of("]")
close < 0 && return nil
timestamp = line[1;close]
rest = line[(close + 2);]
sep = rest::index_of(": ")
sep < 0 && return nil
{
"timestamp": timestamp,
"level": rest[0;sep],
"message": rest[(sep + 2);],
}
}
line = "[2024-01-15 10:30:00] ERROR: Database connection failed"
entry = parse_log_line(line)
println(entry:level) # ERROR
println(entry:message) # Database connection failed
println(entry:timestamp) # 2024-01-15 10:30:00
A malformed line returns nil, so check before reading fields:
import std:println
describe = |entry| match {
entry == nil => "unparsable line",
_ => entry:level,
}
println(describe(nil)) # unparsable line
println(describe({ level: "WARN" })) # WARN
Template Generation
Generate text from templates. string::replace takes plain strings, so {{name}}-style placeholders are the easiest scheme.
import std:println
render = |tmpl, data| {
result = tmpl
loop through data with key, value {
result = result::replace("{{${key}}}", value::to_string())
}
result
}
email_template = """Hello {{name}},
Your order #{{order_id}} has been shipped.
Total: \${{total}}"""
message = render(email_template, {
name: "Alice",
order_id: "12345",
total: "99.99",
})
println(message)
Output:
Hello Alice,
Your order #12345 has been shipped.
Total: $99.99
Note the \$ escape: ${...} is string interpolation everywhere in Suji, so a literal dollar sign in front of a brace must be escaped.
Text Search and Replace
Collapsing runs of a separator is the same shape every time: split on it, drop the empty pieces, and join. No character loop is needed.
import std:println
normalize_whitespace = |text| text
::replace("\n", " ")
::replace("\t", " ")
::split(" ")
::filter(|w| w::length() > 0)
::join(" ")
messy = " too many \n\t spaces "
println(normalize_whitespace(messy)) # too many spaces
When the rule is per-character, strings are not iterable — call ::to_list() for a list of single-character strings. Map each character, then collapse with the same split-filter-join:
import std:println
slugify = |text| {
out = ""
loop through text::lower()::to_list() with ch {
out = out + match {
ch ~ /^[a-z0-9]$/ => ch,
_ => "-",
}
}
out::split("-")::filter(|p| p::length() > 0)::join("-")
}
println(slugify("Hello World!")) # hello-world
println(slugify(" Suji: A Small Language")) # suji-a-small-language
Plain substring replacement needs no loop at all:
import std:println
println("2024-01-15"::replace("-", "/")) # 2024/01/15
println("a,b,,c"::split(",")::filter(|p| p::length() > 0)::join("|")) # a|b|c
Complete Example: Markdown Parser
Simple line-based markdown to HTML converter (headers + paragraphs).
import std:println
wrap = |tag, text| "<${tag}>${text}</${tag}>"
markdown_to_html = |md| {
out = []
loop through md::split("\n") with line {
match {
line::starts_with("## ") => { out::push(wrap("h2", line[3;])) },
line::starts_with("# ") => { out::push(wrap("h1", line[2;])) },
line::trim()::length() == 0 => {},
_ => { out::push(wrap("p", line)) },
}
}
out::join("\n")
}
md = "# Title\n\nAn opening paragraph.\n\n## Section\n\nMore text."
println(markdown_to_html(md))
Output:
<h1>Title</h1>
<p>An opening paragraph.</p>
<h2>Section</h2>
<p>More text.</p>
See Also
Scripting Recipes
Suji is a good glue language: shell commands are part of the syntax, and the standard library covers arguments, environment variables, files, JSON and time. This page collects the patterns you need for day-to-day automation.
Run a script with suji script.si. The only other flag is --print-ast; everything else your script needs comes from arguments, the environment or stdin.
Script Arguments
env:args is a value, not a function — a map-like object keyed by the strings "0", "1", "2" and so on, where "0" is meant to be the script path. Read it with ::get, ::contains and ::length():
import std:env
import std:println
target = env:args::get("1", "")
message = match {
target::length() == 0 => "usage: report.si <path>",
_ => "processing ${target}",
}
println(message) # usage: report.si <path>
Two things to know before designing a command line:
- Arguments that start with
-are consumed by the interpreter and never reach the script, so a Suji script cannot take--flagstyle options. Use positional arguments or environment variables instead. - Positional arguments are broken in 0.1.22. Every argument overwrites key
"0", soenv:argsalways holds exactly one entry — the script path when no arguments are given, and otherwise the last argument — andenv:args::contains("1")is alwaysfalse. Seestd:env. Until it is fixed, take input from an environment variable or stdin, and always supply a fallback as the recipe above does.
Reading Standard Input
io:stdin is a stream. read_lines() drains it into a list, read_all() into one string, and read_line() reads a single line (nil at end of input). All reads are blocking, so guard interactive scripts with is_terminal():
import std:io
import std:println
# Usage: cat access.log | suji count.si
lines = match io:stdin::is_terminal() {
true => [], # nothing piped in — do not block waiting for a human
_ => io:stdin::read_lines(),
}
errors = lines::filter(|l| l ~ /ERROR/)
println("read ${lines::length()} lines") # read 0 lines
println("errors: ${errors::length()}") # errors: 0
To prompt a user, write the question first — io:print does not add a newline:
import std:io
import std:println
ask = |question, fallback| {
!io:stdin::is_terminal() && return fallback
io:print(question)
answer = io:stdin::read_line()
answer == nil && return fallback
trimmed = answer::trim()
match {
trimmed::length() == 0 => fallback,
_ => trimmed,
}
}
name = ask("Your name: ", "anonymous")
println("hello ${name}") # hello anonymous
Environment Variables
env:var is a map-like value too. Reading a missing variable with :NAME raises an error, so use ::get with a default:
import std:env
import std:println
log_level = env:var::get("LOG_LEVEL", "info")
println("log level: ${log_level}") # log level: info
# Assignments are visible to commands this script runs
env:var["GREETING"] = "hei"
println(`echo $GREETING`) # hei
println(env:var::contains("HOME")) # true
For per-project settings, dotenv:load(path) reads a .env file into env:var and returns what it loaded.
Running Shell Commands
A backtick template runs its command through the shell and evaluates to stdout with the trailing newline trimmed. ${...} interpolation works inside:
import std:println
name = "world"
println(`echo hello ${name}`) # hello world
# A whole shell pipeline is fine inside one template
count = `printf 'a\nb\nc\n' | wc -l`::trim()::to_number()
println(count) # 3
A command that exits non-zero is a fatal error — the script stops and there is nothing to catch it with. Make the shell return a value instead of an exit status:
import std:println
# `|| true` swallows the failure and yields an empty string
output = `grep nothing-here /etc/hosts || true`
println(output::length()) # 0
# `&& echo / || echo` turns a test into a string you can match on
state = `test -d /etc && echo present || echo missing`
println(state) # present
exists = |path| `test -e "${path}" && echo yes || echo no` == "yes"
println(exists("/etc/hosts")) # true
Only stdout is captured; redirect with 2>&1 if you need stderr, and always quote interpolated paths ("${path}") so spaces do not split into extra arguments.
Pipelines
The | operator pipes between closures and shell templates. A closure that reads piped input uses io:stdin::read_lines() or read_all(); a closure that produces output just prints:
import std:io
import std:println
produce = || {
println("alpha")
println("beta")
println("gamma")
}
summarize = || {
lines = io:stdin::read_lines()
println("${lines::length()} lines, sorted: ${lines::join(",")}")
}
produce() | `sort` | summarize()
matches = produce() | `grep -c ma`
println("matched ${matches::trim()}")
Output:
3 lines, sorted: alpha,beta,gamma
matched 1
Unlike a plain backtick command, the value of a pipeline keeps its trailing newline — hence the ::trim().
Temp Files and Cleanup
mktemp is the simplest way to get a scratch path; io:open(path, true, true) creates and truncates it, and os:rm / os:rmdir clean up:
import std:io
import std:os
import std:println
path = `mktemp`
f = io:open(path, true, true) # create=true, truncate=true
f::write("first\nsecond\n")
f::close()
reader = io:open(path)
lines = reader::read_lines()
reader::close()
println(lines::length()) # 2
dir = `mktemp -d`
`touch ${dir}/a.txt ${dir}/b.txt`
println(`ls -1 ${dir}`::split("\n")::length()) # 2
os:rm(path)
`rm -rf ${dir}`
os:tmp_dir(), os:home_dir() and os:work_dir() give you the usual base directories when you would rather build the path yourself.
Exiting With a Status Code
os:exit(code) ends the script immediately. Use it to report failure to whatever called your script:
import std:os
import std:println
checks = [
{ name: "config readable", ok: true },
{ name: "port free", ok: true },
]
failed = checks::filter(|c| !c:ok)
match {
failed::length() > 0 => {
println("FAIL: ${failed::map(|c| c:name)::join(", ")}")
os:exit(1)
}
}
println("all ${checks::length()} checks passed") # all 2 checks passed
os:exit(0)
Since there is no exception handling, os:exit(1) after printing a message is the error handling strategy for scripts.
Retry and Poll Loops
Combine loop, a counter and time:sleep(ms). Always cap the attempts — an unbounded retry loop has no way to be interrupted from inside the language:
import std:println
import std:time
wait_for = |max_attempts, delay_ms| {
attempt = 0
loop {
attempt++
# Stand-in for a real health check, e.g.
# `curl -fsS -o /dev/null ${url} && echo up || echo down`
state = `test ${attempt} -ge 3 && echo up || echo down`
state == "up" && break
attempt >= max_attempts && break
time:sleep(delay_ms)
}
attempt
}
attempts = wait_for(10, 20)
println("ready after ${attempts} attempts") # ready after 3 attempts
The same shape works for polling a file (test -f), a lock, or a queue length.
Worked Example: Backup Script
A complete script that collects matching files, archives them, verifies the archive and reports a status code — using every pattern above.
import std:env
import std:io
import std:os
import std:println
import std:time
# Source directory: first argument, then BACKUP_SRC, then a demo directory.
arg = env:args::get("1", "")
configured = match {
arg::length() > 0 => arg,
_ => env:var::get("BACKUP_SRC", ""),
}
demo_mode = configured::length() == 0
source = match {
demo_mode => {
demo = `mktemp -d`
`touch ${demo}/notes.txt ${demo}/todo.txt ${demo}/image.png`
demo
}
_ => configured,
}
files = `find ${source} -type f -name '*.txt'`
::split("\n")
::filter(|p| p::length() > 0)
match {
files::length() == 0 => {
println("nothing to back up in ${source}")
os:exit(0)
}
}
stamp = time:now():epoch_ms
archive = "${os:tmp_dir()}/backup-${stamp}.tar.gz"
names = files::map(|p| p::split("/")::last(""))
`tar -czf ${archive} -C ${source} ${names::join(" ")}`
ok = `test -s "${archive}" && echo yes || echo no` == "yes"
size = match {
ok => os:stat(archive):size,
_ => 0,
}
println("archived ${files::length()} files")
println("archive created: ${ok}")
println("archive is non-empty: ${size > 0}")
# Clean up: never delete a directory the caller asked us to back up
os:rm(archive)
match { demo_mode => { `rm -rf ${source}` } }
os:exit(0)
Output:
archived 2 files
archive created: true
archive is non-empty: true
See Also
Contributing
How to build Suji, where to change it, and what a change is expected to ship with.
Prerequisites
Stable Rust, installed through rustup. Nothing else — the workspace has no
external system dependencies beyond a shell for backtick templates.
Build and Run
make build # debug build
make release # optimised build -> target/release/suji
cargo run -- examples/hello.si # run a program from the debug build
cargo run # start the REPL
After make release you can call the binary directly:
target/release/suji examples/hello.si
Lint and Test
make lint # cargo clippy --all-targets, then cargo fmt --check
make test # rust_tests, then verify_spec, then verify_examples
make rust_tests # Rust unit and integration tests only
make verify_spec # every spec/*.si against its expected output
make verify_examples # every examples/*.si must exit cleanly
make lint and make test should both pass before you consider a change
finished. make verify_spec and make verify_examples depend on release, so
they rebuild the optimised binary first.
Crate Map
The workspace is a chain of single-purpose crates with no dependency cycles:
suji-ast → suji-values → suji-runtime → suji-interpreter → suji-cli / suji-repl
| Crate | Responsibility |
|---|---|
suji-ast | AST node definitions (expr.rs, stmt.rs, pattern.rs, literal.rs, function.rs) |
suji-lexer | Scanner core plus per-construct state handlers under src/states/ |
suji-parser | Expression and statement parsing under src/expressions/ and src/statements/ |
suji-values | Value types, Env, RuntimeError, and all value methods |
suji-runtime | The Executor trait, ModuleRegistry, and the builtin registry |
suji-interpreter | The default AST-walking interpreter, evaluators under src/eval/ |
suji-stdlib | Standard library modules and builtin functions |
suji-diagnostics | Error codes, error builders, and the framed diagnostic emitters |
suji-repl | The REPL loop |
suji-cli | Binary entry point and argument handling |
Where to Make a Change
| If you are changing… | Start in |
|---|---|
| Tokens, escapes, string/regex/shell scanning | crates/suji-lexer/src/token.rs and src/states/ |
| Regex-versus-division disambiguation | ScannerContext::should_parse_as_regex in crates/suji-lexer/src/states/context.rs |
| Grammar or operator precedence | crates/suji-parser/src/expressions/ (precedence lives in binary.rs) |
| Evaluation semantics | crates/suji-interpreter/src/eval/ |
| Function and method invocation | crates/suji-interpreter/src/eval/function_call.rs |
A value method such as list::sort | crates/suji-values/src/methods/ |
Value types or RuntimeError variants | crates/suji-values/src/value/ |
| A stdlib module or builtin | crates/suji-stdlib/src/runtime/builtins/ |
| Module resolution | crates/suji-runtime/src/module_registry.rs |
| An error message, code, or suggestion | crates/suji-diagnostics/src/ |
Errors stay in the crate that raises them: LexError in suji-lexer,
ParseError in suji-parser (wrapping LexError), and RuntimeError in
suji-values (wrapping ParseError). This is what keeps the dependency chain
acyclic, so resist the urge to move an error type “somewhere central”.
Value methods are generic over the Executor trait rather than tied to the
interpreter directly. If you add a method that needs to call back into user
code — anything taking a closure, like filter — keep it generic.
What a Change Ships With
A language change is not finished until all four of these exist in the same change:
- A spec file in
spec/, following the conventions in Spec Tests. - A Rust test in the matching suite under
tests/, described in Writing Tests. - Documentation: The affected chapters of this book.
- Green checks:
make lintandmake test.
Bug fixes follow the same rule — a fix without a spec file that would have caught the bug will regress.
Editing This Book
The book is an mdBook project in book/, with its own Makefile:
cd book
make build # build the Suji-aware highlighter, then the HTML into book/
make serve # local server with live reload
make verify # run every example, then check links, anchors and tables
make test # check the highlight definition and that the build is complete
Every ```suji block must be a complete program that runs on its own, and
every # … comment claiming an output must match what the interpreter actually
prints. make verify enforces the first half by executing each block; it finds
the binary at target/release/suji or from $SUJI. A block whose whole purpose
is to demonstrate an error is exempted by putting a marker on the line above the
opening fence:
<!-- verify: skip -->
Prefer not to use the marker. A block that shows a failure can usually keep the
offending line commented out and describe the error in prose, which keeps the
example runnable. Examples needing an input file should create it with
p = `mktemp` rather than referring to a path that only exists on your
machine, and examples using std:random should call random:seed(n) so their
output is stable.
Syntax highlighting comes from highlight/suji.js, bundled by
npm run build-highlight into theme/highlight.js, which mdBook picks up as a
theme override. npm run test-highlight asserts the token classes for the
constructs that are easy to get wrong — pipelines, match alternatives,
division versus regex, and interpolation in all four string forms.
Coding Standards
- Prefer clarity over cleverness; explicit control flow beats a dense expression.
- Functions are verbs, variables are descriptive nouns, and abbreviations are avoided.
- Use guard clauses and early returns instead of deep nesting.
- Use structured errors via
thiserror, with actionable messages and precise spans from the diagnostics helpers. - Comments explain why, not how, and sit above the code they describe.
- Match the surrounding formatting. Do not reformat unrelated code.
- Keep hot paths — the lexer and parser inner loops, runtime tight loops — allocation-lean. Non-trivial optimisations should come with a measurement.
- No
unsafeunless it is genuinely necessary and reviewed.
Workflow Guardrails
- Keep changes small and cohesive. Do not mix a cross-cutting refactor into a feature change.
- Search for existing helpers and tests in the area before adding new ones, and
read the related material under
docs/. - Do not change public-facing behaviour without a test that demonstrates the change, and call the change out explicitly.
- Justify any new dependency.
- If a change spans lexer, parser, and runtime, split it into reviewable steps where you can.
- Record the rationale for a restructuring alongside the other design notes in
internal_docs/, and stage larger deliveries through a per-version implementation plan document, as every release so far has done.
See Also
Testing
Suji is tested at three layers, each answering a different question.
The Three Layers
| Layer | Location | Question it answers | Runner |
|---|---|---|---|
| Rust tests | tests/ | Does this component behave correctly in isolation? | make rust_tests |
| Spec programs | spec/ | Does the language produce this exact output? | make verify_spec |
| Examples | examples/ | Does a realistic program still run end to end? | make verify_examples |
make test runs all three in that order.
Rust Tests
The Rust suites live under tests/ and are grouped by what they exercise:
| Suite | Covers |
|---|---|
tests/components/ | Single components: lexer/, parser/, ast/, runtime/, values/ |
tests/integration/ | Whole programs through the interpreter: arithmetic, functions, maps, match, methods, modules, pipes, ranges, strings, shell templates, indexing, JSON |
tests/spec/ | Rust-side counterparts of the spec areas, such as spec_methods.rs and spec_strings_regex.rs |
tests/stdlib/ | One file per standard library module, such as std_path.rs and std_time.rs |
These are the right place for anything a .si program cannot observe: token
streams, AST shapes, specific RuntimeError variants, and error spans.
make rust_tests
cargo test --workspace
cargo test --package suji-tests --test integration
Spec Programs
spec/ holds 650 single-assertion .si programs. Each one prints exactly one
value, and the expected output is a trailing comment on that final line:
import std:println
inc = |x| x + 1
result = 3 |> inc
println(result) # 4
scripts/verify_spec.sh runs each file from inside spec/, takes the last line
of stdout, and compares it to the text after the # on the file’s last line.
The conventions are strict and easy to get wrong — see Spec Tests.
Spec files are the executable definition of the language. When you want to know
whether a behaviour is intentional, spec/ is the authority.
Examples
examples/ holds complete programs that demonstrate the language rather than
assert on it. scripts/verify_examples.sh runs each one and checks only that it
exits successfully, so an example is a smoke test against regressions that a
narrow spec file would miss.
make verify_examples
Examples must remain deterministic and must not depend on network access or on files outside the repository.
Which Layer to Use
| Change | Add |
|---|---|
| New syntax or new semantics | A spec file and a parser or interpreter test |
| A new value method | A spec file and a tests/components/values/methods.rs case |
| A new stdlib function | A spec file and a case in the module’s tests/stdlib/ file |
| A lexer or parser fix | A tests/components/ test asserting on tokens or AST, plus a spec file if the behaviour is user-visible |
| A new error or error message | A tests/components/runtime/errors.rs case asserting the variant |
| A bug fix | A spec file that fails before the fix |
| A new end-to-end capability | An example, in addition to the above |
The rule of thumb: a spec file proves the behaviour, a Rust test proves the mechanism. Most language changes need both.
See Also
Spec Tests
The 650 programs in spec/ are the executable definition of Suji. Their format is strict.
The Rules
| Rule | Why |
|---|---|
| One assertion per file | The runner only reads the last line of output |
import std:println at the top | There is no prelude; nothing prints without it |
Exactly one println(...) at the end | That line’s output is what gets compared |
Expected output in a trailing # comment on that line | The runner extracts the text after # |
Two spaces before the # | House style, consistent across the suite |
| Blank line after the imports | House style |
Blank line before the final println | House style |
| No trailing blank line | The runner uses tail -n 1; a blank last line means an empty expectation |
Named feature_area_NN.si | Two-digit, zero-padded counter |
| Deterministic output | The comparison is exact |
A Correct Spec File
spec/pipe_apply_01.si:
import std:println
inc = |x| x + 1
result = 3 |> inc
println(result) # 4
The file ends immediately after that line — no trailing newline beyond the one terminating it, and no blank line.
How the Runner Compares
scripts/verify_spec.sh changes into spec/ so that relative imports resolve,
then for each file:
- Takes the last line of the file and strips everything up to and including
the first
#, giving the expected string. - Runs
../target/release/suji <file>, discards stderr, takes the last line of stdout, and strips ANSI colour codes. - Compares the two strings exactly.
Two consequences follow from step 1. First, the expectation lives in the source
file, so a spec file documents itself. Second, anything after the last # on
the last line is the expectation — including nothing at all.
Run the suite with:
make verify_spec
A failure reports both sides:
FAIL: my_feature_01.si - Expected '4', got '5'
Broken Spec Files
Each of these looks fine and fails.
A trailing blank line. The runner reads the blank last line, so the expectation becomes the empty string:
import std:println
println(3 |> inc) # 4
<- this blank line breaks the file
The reported failure is Expected '', got '4', which is the signature of this
mistake.
A missing comma after the final match arm. Every bare-expression arm needs a trailing comma, including the last:
import std:println
result = match 2 { 1 => "one", 2 => "two" } # parse error
println(result) # two
Adding the comma after "two" fixes it.
More than one println. Only the last line of output is compared, so the
earlier assertion is silently ignored:
import std:println
println(1 + 1) # 2
println(2 + 2) # 4
Split this into two files.
A missing import std:println. The program fails with
Undefined variable: println and produces no stdout at all.
Non-deterministic output. A spec that prints time:now():epoch_ms, a UUID,
a random number, or a hostname can never match a fixed expectation. If a feature
depends on the environment, assert on something stable about the result instead:
import std:println
import std:uuid
id = uuid:v4()
println(id::length()) # 36
Naming and Placement
Files sit directly in spec/ — the runner only globs *.si in that directory
and ignores subdirectories. Name a file after its feature area with a
zero-padded counter, matching the neighbours you find there:
spec/list_methods_07.si
spec/operator_precedence_03.si
spec/pipe_apply_01.si
Keep the body minimal. A spec file should isolate one behaviour, so avoid constructs unrelated to what is being tested.
Adding One
- Find the existing family:
ls spec/ | grep <feature>. - Create the next number in that sequence.
- Write the smallest program that exhibits the behaviour.
- Run it directly first —
target/release/suji spec/my_feature_01.si— and paste the real output into the trailing comment. - Run
make verify_specand confirm the new file passes along with the rest.
Never guess the expected output. Run the program and copy what it actually prints, otherwise the spec encodes a wish rather than a fact.
See Also
Writing Tests
Practical guidance for the Rust side of the test suite.
Where Tests Live
All Rust tests sit under tests/ in the suji-tests package. Four top-level
files declare the suites, and each pulls in a directory of modules:
| Entry point | Directory | Purpose |
|---|---|---|
tests/components.rs | tests/components/ | One component at a time |
tests/integration.rs | tests/integration/ | Whole programs through the interpreter |
tests/spec.rs | tests/spec/ | Rust counterparts to the spec areas |
tests/stdlib.rs | tests/stdlib/ | One file per standard library module |
Modules are wired in explicitly with #[path = "..."] declarations, so a new
file must be added to its mod.rs (or to the suite entry point) before it will
run.
cargo test --workspace
cargo test --package suji-tests --test integration --test components --test stdlib --test spec
make rust_tests
Crate-internal unit tests stay in the crate they belong to, in a
#[cfg(test)] mod tests block next to the code — error_codes.rs, for example,
carries its own uniqueness and range checks.
Unit or Integration?
Put it in tests/components/ when | Put it in tests/integration/ when |
|---|---|
| You are asserting on a token stream | You are asserting on a program’s value |
| You are asserting on AST shape | Several components must cooperate |
| You are calling a method dispatcher directly | The behaviour crosses lexer, parser, and interpreter |
You are checking a specific RuntimeError variant | You want the closest thing to a real program |
If a test would read naturally as a .si program, it probably belongs in
spec/ instead — see Spec Tests.
Shared Helpers
tests/common/mod.rs provides the plumbing so individual tests stay short:
| Helper | Use |
|---|---|
parse_expression(src) | Parse one expression |
parse_statement(src) | Parse one statement |
parse_program(src) | Parse a whole program |
create_test_env() | An Env with globals installed |
eval_string_expr(src) | Evaluate a single expression to a Value |
eval_program(src) | Evaluate a program, returning the last value |
eval_program_with_modules(src) | As above, with the module registry wired up |
can_eval(src) / can_eval_program(src) | Boolean success check |
assert_parse_fails(src, fragment) | Assert a parse error containing a fragment |
assert_eval_fails(src, fragment) | Assert a runtime error containing a fragment |
Reach for these before writing your own setup. assert_parse_fails also accepts
several acceptable fragments separated by ||, which is useful while an error
message is in transition.
Testing the Lexer
Lex a small input and compare the token vector, remembering the trailing
Token::Eof:
#[test]
fn test_keywords() {
let input = "return loop as through with continue break match import export true false nil";
let tokens = Lexer::lex(input).unwrap();
let expected = vec![
Token::Return,
Token::Loop,
// ...
Token::Eof,
];
let actual: Vec<Token> = tokens.into_iter().map(|t| t.token).collect();
assert_eq!(actual, expected);
}
Group new cases into the existing files by construct: strings.rs, regex.rs,
shell.rs, comments.rs, operators.rs, ranges.rs, unicode.rs,
basics.rs. Regex-versus-division disambiguation belongs in regex.rs, and it
is worth testing both the regex and the division reading of the same character.
Testing the Parser
Assert on the AST shape rather than on a rendered string, so the test survives formatting changes:
let expr = parse_expression("2 + 3 * 4").unwrap();
assert!(matches!(expr, Expr::Binary { .. }));
Precedence and associativity cases go in tests/components/parser/precedence.rs.
The most valuable parser tests are the negative ones — confirming that an
invalid program is rejected, and with a comprehensible message:
assert_parse_fails(
"match x { 1 => \"one\", _ => \"other\" }",
"Unexpected token",
);
Testing Runtime Behaviour
Evaluate a program and compare the resulting Value:
let value = eval_program("xs = [1, 2, 3]\nxs::sum()").unwrap();
assert_eq!(value, Value::Number(DecimalNumber::from_i64(6)));
Method dispatch can also be exercised directly through call_method, which is
how tests/components/values/methods.rs covers the dispatcher without going
through the parser:
let s = Value::String("hello".to_string());
let result = call_method(None, ValueRef::Immutable(&s), "length", vec![]).unwrap();
assert_eq!(result, Value::Number(DecimalNumber::from_i64(5)));
Asserting on Errors
Match on the RuntimeError variant rather than on its rendered text. Messages
get reworded; variants do not:
assert!(matches!(
result,
Err(RuntimeError::MethodError { .. })
));
When the message itself is the thing under test, use assert_eval_fails with
the smallest distinctive fragment.
Errors carry spans, and error.span() returns the source range. If your change
touches where an error is raised, assert that the span still covers the right
text — that is what makes the framed diagnostic point at the correct expression:
let span = error.span().expect("error should have a span");
assert!(source[span.start..span.end].contains('x'));
Testing the Standard Library
One file per module, named after it: std_path.rs, std_time.rs,
std_crypto.rs. Test through Suji source with eval_program_with_modules so
that the import path is exercised too, and keep assertions deterministic —
assert on the shape or length of a UUID or a timestamp rather than on its value.
Add a Spec File Too
Almost every Rust test for user-visible behaviour should be accompanied by a
.si file in spec/. The Rust test proves the mechanism; the spec file proves
the behaviour, in the language itself, in a form that any contributor can read.
Getting into that habit is the single most useful thing you can do for the
suite. See Spec Tests.
Before You Push
make lint
make test
See Also
Language Design
The choices behind Suji, and the costs each one carries.
Suji is a scripting language for the kind of work you would otherwise do in a shell script: gluing commands together, reshaping data, and producing text. Most of its unusual decisions follow from that goal, and each one buys something at a price. This page states both halves.
One Exact Decimal Number Type
There is no int, no float, and no separate integer type — just a
fixed-precision base-10 decimal. 42 and 42.0 are the same value.
import std:println
println(0.1 + 0.2) # 0.3
println(0.1 + 0.2 == 0.3) # true
println(19.99 * 3) # 59.97
Why. Scripts deal in money, percentages, and human-entered quantities.
Binary floating point produces 0.30000000000000004 for the first line above,
which is a bug waiting to be reported by a user rather than a compiler. A single
type also removes an entire category of decisions: no integer division surprise,
no widening rules, no literal suffixes.
The trade-off. Precision is fixed at roughly 28–29 significant digits with a
maximum of 79228162514264337593543950335. This is not arbitrary-precision
arithmetic: exceeding the range aborts the process rather than promoting to a
bignum, and 2::pow(100) overflows. Repeating divisions round —
10 / 3 yields 3.3333333333333333333333333333. Decimal arithmetic is also
slower than hardware floats, which matters in a tight numeric loop.
A related consequence: scale is part of the value, so 1.50 prints as 1.50
and 2.50 + 1 prints as 3.50. That is correct for currency and occasionally
surprising elsewhere.
Strong Dynamic Typing, No Coercion, No Truthiness
Types are checked at runtime and never converted implicitly.
import std:println
n = 1
println("count: " + n::to_string()) # count: 1
println("count: ${n}") # count: 1
"a" + 1 is a type error, "ab" * 3 is a type error, 1 == "1" is false,
and nil || "default" is a type error because || requires booleans.
Why. Silent coercion is the classic source of scripting bugs: a number read
from a file concatenating instead of adding, or an empty string quietly taking
the else branch. Suji makes the conversion visible — ::to_string(),
::to_number(), or interpolation.
The trade-off. More typing, and the loss of some genuinely convenient
idioms. The absence of truthiness costs the value || default pattern
outright; the replacements are m::get(k, default), xs::first(default), and
an explicit match.
match as the Only Conditional
There is no if, no else, no elif, no ternary, and no switch. Every
branch is a match, in one of two forms.
import std:println
grade = |score| {
match {
score >= 90 => "A",
score >= 80 => "B",
_ => "F",
}
}
name = |code| {
match code {
200 => "OK",
404 => "Not Found",
_ => "Unknown",
}
}
println(grade(85)) # B
println(name(404)) # Not Found
Why. One construct means one set of rules. There is no dangling-else
ambiguity, no statement-versus-expression split between if and a ternary, and
no question about whether a conditional produces a value — it always does. The
conditional form covers what if/else if chains do, and the subject form
covers dispatch, so nothing is lost.
The trade-off. The comma rule. An arm whose body is a bare expression must
be followed by a comma, including the last one, and forgetting it is the most
common syntax error in Suji. A single-branch conditional is also wordier than
if, which is why guards are usually written with short-circuit operators
instead:
import std:println
check = |n| {
n < 0 && return "negative"
return "non-negative"
}
println(check(-1)) # negative
Pattern matching itself is deliberately shallow: literals, negative numbers,
regex, tuples, alternatives, and _. There are no binding patterns, list
patterns, map patterns, range patterns, or if guards. That keeps the matcher
small and predictable, at the cost of some destructuring expressiveness — and it
produces one real trap, since a bare identifier in a pattern is read as a string
literal rather than a binding.
Expression-Oriented Blocks and Implicit Returns
A function body’s last expression is its result, and match is an expression,
so most functions need no return at all.
import std:println
double = |x| x * 2
classify = |n| {
match {
n > 0 => "positive",
n < 0 => "negative",
_ => "zero",
}
}
println(double(21)) # 42
println(classify(-3)) # negative
Why. Short transformations are the common case in a scripting language, and
|x| x * 2 reads better than a three-line block. Making match an expression
means a branch can be assigned, returned, or piped without restructuring.
The trade-off. A bare { … } is a block and not an expression, so
x = { 1 } is a parse error, and a map literal with bare identifier keys in a
position where a block is expected — a whole lambda body, or a whole match-arm
body — parses as a block. Quote the keys or add parentheses there.
Explicit Imports and No Prelude
Nothing is in scope by default. Even printing requires an import.
import std:println
import std:json
println(json:generate({ok: true})) # {"ok":true}
Why. A script that reads import std:crypto at the top declares its
dependencies the way a shell script’s command -v checks never quite do. It
also keeps the global namespace empty, so user names never collide with the
standard library, and it lets modules load lazily.
The trade-off. Boilerplate at the top of every file, and one predictable
first-time error: a snippet copied without its import fails with
Undefined variable: println. Import paths are also structural rather than
string-based — import lib:util rather than import "./lib/util.si" — which
reads well but means paths are constrained to identifier-shaped segments.
Shell Integration and Two Pipe Families as First-Class
Backtick templates run commands, and | connects stages the way a shell does,
while |> and <| apply values to functions.
import std:io
import std:println
count = || io:stdin::read_lines()::length()
report = |n| "found ${n}"
`printf 'a\nb\n'` | count() |> report |> println # found 2
Why. The alternative — a subprocess-style API with argument lists, pipes,
and exit codes — turns a one-line shell idiom into ten lines. Suji instead
treats a command as an expression that evaluates to its stdout. Keeping the two
pipe families separate is what makes this work: | moves bytes between
processes and closures, while |> moves values between functions. Conflating
them would make it ambiguous whether a stage is being called or composed.
The trade-off. Three pipe-like operators (|, |>, <|) plus two
composition operators (>>, <<) is a lot of surface for newcomers, and their
relative precedence has to be learned. Shell integration also inherits the
shell’s weaknesses: stderr is not captured, the exit code is not observable, and
a non-zero status is fatal.
Errors as Fatal Diagnostics, Not Exceptions
There is no try, no catch, no throw, no Result, and no Option. Any
error prints a framed diagnostic and terminates the script with exit status 1.
import std:println
config = {host: "localhost"}
xs = []
println(config::get("port", 8080)) # 8080
println(config::contains("host")) # true
println(xs::first("none")) # none
Why. For a script, failing loudly at the point of the fault with a source span and a suggestion is usually the correct behaviour — a half-completed script that swallowed an error is worse than one that stopped. Removing exceptions also removes non-local control flow, so reading a function tells you everything about how it can exit.
The trade-off. This is the sharpest edge in the language. There is no error
recovery whatsoever, no cleanup hook, and no way to retry. Every API that could
fail must therefore offer a checking counterpart — m::get, m::contains,
xs::first(default), xs::length() — and any code that must survive a failure
has to test its preconditions first. It also means a long-running script cannot
be made robust against a single bad record; the defensive check has to be there
in advance. Suji is a poor fit for programs that must not stop.
A Small, Orthogonal Method Set
Methods are called with :: and the list per type is short and deliberately
non-overlapping. Lists have map, filter, and fold but no reduce, each,
any, all, find, unique, flatten, zip, or group_by. Strings have
replace and trim but no capitalize, pad_start, or slice. Rounding and
roots are number methods rather than math functions, so std:math holds
only constants, trigonometry, and logarithms.
import std:println
xs = [1, 2, 3, 4, 5]
evens = xs::filter(|x| x % 2 == 0)
total = xs::fold(0, |acc, x| acc + x)
println(evens) # [2, 4]
println(total) # 15
println(16::sqrt()) # 4
println(3.7::floor()) # 3
Why. One obvious way to do each thing. A small set is memorable, documents itself in a single table per type, and keeps the surface small enough to specify exactly.
The trade-off. Some operations take a fold where another language would
offer a named method, and the gaps are real: no any/all, no unique, no
zip, no sort_by. Collections are also eager, so map and filter each
allocate a new list and a range materialises fully — 0..1000000 really does
build a million elements.
What This Adds Up To
Suji optimises for short programs that are read more often than they are written, where a wrong answer is worse than no answer. It gives up arbitrary-precision arithmetic, exception handling, laziness, a rich collection API, and deep pattern matching to get there. Those are the right trades for a shell-script replacement and the wrong ones for a long-running service.
See Also
Roadmap
Where Suji stands at 0.1.22, stated as current condition rather than as promises.
This page describes what works and what is missing. It deliberately contains no dates and no commitments. The honest summary is that Suji is a pre-1.0 language, built from source, whose surface is settled enough to write real scripts against and not settled enough to depend on.
Current Version
0.1.22, from the workspace Cargo.toml. There is no
suji --version flag; the version lives in the source tree.
Suji is distributed only as a source build. There is no Homebrew formula, no
apt package, no published crate, and no prebuilt binary. Building is
make release, and the result is target/release/suji. See
Installation.
Versions in the 0.1.x series have changed language syntax — 0.1.11 changed
match arms from : to =>, and 0.1.22 changed slices from list[a:b] to
list[a;b]. Treat a version bump as potentially source-breaking until 1.0. The
full history is in Language Versions.
What Is Stable Today
These parts of the language are exercised by the full spec suite and are unlikely to move under you:
| Area | State |
|---|---|
| Exact decimal numbers and arithmetic | Settled since 0.1.9 |
| Strings, interpolation, multiline strings | Settled |
| Lists, maps, tuples, and their methods | Settled |
match in both forms | Settled since 0.1.17 |
loop with through/with, labels, break/continue | Settled |
| Lambdas, defaults, closures, multiple returns | Settled |
| Ranges, inclusive and descending | Settled since 0.1.18 |
Regex matching with ~ and !~ | Settled |
Shell templates and | pipelines | Settled |
|>, <|, >>, << | Settled since 0.1.12 |
| Imports, exports, local file modules | Settled since 0.1.16 |
| The standard library modules listed in the Standard Library Overview | Settled |
| Numeric error codes and framed diagnostics | Settled since 0.1.15 |
The AST-walking interpreter is the reference implementation, and it passes all
650 spec programs. suji --print-ast program.si dumps the parsed AST without
running the program.
Known Limitations
These are the sharp edges as of 0.1.22. Each is a real constraint on what you can write today, and each is the obvious place for future work.
No error-handling construct
There is no try/catch, no throw, no Result or Option, and no defer.
Any runtime error prints a diagnostic and terminates the process with exit
status 1. The only available strategy is checking before acting:
m::get(k, default), m::contains(k), xs::length() > 0. A script cannot be
made to survive a single bad record. See Error Handling Deep Dive.
Regex is match-only
/pattern/ supports ~ and !~ and can be used as a match arm, and that is
all. There are no capture groups, no ::match() or ::captures(), no
regex-based replace, and no regex split. string::replace(old, new) takes
strings only. Extracting a substring means combining split, index_of, and
slicing, or shelling out to sed. Regex literals are also not interpolated, so
a pattern cannot be built from a variable.
No tail-call optimisation
Recursion is bounded by the native stack; roughly 600–700 frames deep, a program aborts with a stack overflow. Deeply recursive algorithms need to be rewritten as loops. See Recursion.
A small collection API
Lists have map, filter, and fold but no any, all, find, unique,
flatten, zip, enumerate, group_by, or sort_by. Maps have no set
method — assign through m[k] = v. Strings have no slice, pad_start,
capitalize, or lines. Most gaps can be closed with a fold or a loop, but
they are gaps.
Decimal overflow aborts
Numbers are fixed-precision, maximum 79228162514264337593543950335. Exceeding
it terminates the process rather than promoting to a wider type, so 2::pow(100)
is not a large number — it is a crash. Division by zero is likewise fatal.
Everything is eager
Ranges materialise as full lists, so 0..1000000 allocates a million elements.
map and filter allocate a new list per stage. There are no lazy sequences,
no generators, and no parallelism. See Performance Considerations.
Other current gaps
| Gap | Detail |
|---|---|
| No packaging | Source builds only; nothing published to a package manager |
No --version, --help, or -e | The only flag is --print-ast |
| No tuple indexing | t[0] is a type error; destructure or use t::to_list() |
No binding patterns in match | A bare identifier in a pattern is a string literal |
| No variadic or keyword arguments | Use a list or a map parameter |
No break <value> | Loops always evaluate to nil |
| Strings and streams are not iterable | Convert with ::to_list() or ::read_lines() first |
| Minimal REPL | :help, :quit, :exit only |
| No formatter or checker | make lint covers the Rust source, not .si files |
Following Along
Progress is recorded in the repository rather than announced: the per-version
implementation plans under internal_docs/, the growth of spec/, and the
version history in Language Versions. The most
reliable way to know whether something works is to write a spec file for it and
run it.
See Also
Language Versions
The change history of the Suji language, newest first.
The current version is 0.1.22, taken from the workspace Cargo.toml. Suji is
pre-1.0 and the 0.1.x series has changed syntax more than once — most recently
slices in 0.1.22 and match arms in 0.1.11 — so treat every version bump as
potentially source-breaking. For the present state of the language see
Roadmap; for what each error code means see
Error Codes.
Version 0.1.22
Slice syntax change (colon to semicolon) — breaking
- Changed the slice separator from
:to; - Old:
list[start:end],list[:end],list[start:],list[:] - New:
list[start;end],list[;end],list[start;],list[;] - Resolves the ambiguity between slicing and map access, which share
:
import std:println
data = {items: [10, 20, 30, 40], start: 1, end: 3}
println(data:items[data:start;data:end]) # [20, 30]
To migrate, search for : inside [ … ] and replace it with ;.
Bugfix: Map literals as standalone expressions
- Map literals can now be used as standalone expressions, not only in
returnstatements or assignments - Enables implicit returns of map literals
Note that a map literal with bare identifier keys is still parsed as a block where a block is expected — as a whole lambda body or match-arm body, quote the keys or wrap the literal in parentheses.
Version 0.1.21
Bugfix: Break and continue with newline-separated identifiers
- Fixed parser bug where
breakandcontinueincorrectly consumed identifiers from next line as labels - Labels must now be on the same line as the control flow keyword
Type checking methods
- All types now support type checking methods:
is_number(),is_bool(),is_string(),is_list(),is_map(),is_stream(),is_function(),is_tuple(),is_regex() - Available on all values including
nil
std:os:stat function
- Added
os:stat(path, follow_symlinks)for file/directory metadata - Returns map with mode, inode, uid, gid, size, timestamps, and type information
Filesystem operation functions
- Added
os:rm(path)- remove file - Added
os:mkdir(path, create_all)- create directory - Added
os:rmdir(path)- remove empty directory
Random string generation functions
- Added
random:string(allowed_chars, length)- generate random string from character set - Added
random:hex_string(length)- generate hexadecimal string - Added
random:alpha_string(length, capitals)- generate alphabetic string - Added
random:numeric_string(length)- generate numeric string - Added
random:alphanumeric_string(length, capitals)- generate alphanumeric string
Version 0.1.20
String trim with custom characters
string::trim(chars)now accepts optional argument to specify which characters to trim- Default behavior unchanged (trims whitespace)
Bugfix: Negative integers in match patterns
- Fixed parsing issue where negative integer literals could not be used as match patterns
- Negative integers now work correctly in all match pattern contexts
std:io:open file creation and truncation control
- Added
createandtruncateparameters toio:open(path, create, truncate) - Controls file creation and truncation behavior
Version 0.1.19
std:os module
- Added operating system utilities:
name(),hostname(),uptime_ms(),tmp_dir(),home_dir(),work_dir(),exit(),pid(),ppid(),uid(),gid()
std:dotenv module
- Added
dotenv:load(path, override)for loading environment variables from.envfiles
std:csv module
- Added
csv:parse(text, delimiter)andcsv:generate(rows, delimiter)for CSV parsing and generation
std:path module
- Added path utilities:
join(),dirname(),basename(),extname(),normalize(),is_abs()
Version 0.1.18
Module system: lazy loading
- Modules (including
std) are now loaded lazily on first access - Improves startup time and avoids cyclic import recursion
Inclusive range syntax (..=)
- Added inclusive range variant:
start..=endincludes both endpoints - Exclusive range unchanged:
start..endexcludes end
Bugfix: Map and list access with complex expressions
- Fixed limitation where indexing required simple identifiers or literals
- Complex expressions (function calls, method calls, arithmetic, pipelines) now work as indices
Bugfix: Short-circuit evaluation with statements
- Fixed limitation where logical operators (
&&and||) could not short-circuit to statements - Now supports
condition && break,condition && continue,condition && return value
Version 0.1.17
Match syntax changes (optional trailing commas for braced arms)
- Trailing commas now optional for match arms with braced bodies (
=> { ... }) - Single-expression arms still require trailing commas, including the final arm
import std:println
expression_arms = match 1 { 1 => "a", _ => "b", }
braced_arms = match 1 { 1 => { "a" } _ => { "b" } }
println(expression_arms) # a
println(braced_arms) # a
std:time module
- Added time utilities:
now(),sleep(ms),parse_iso(text),format_iso(epoch_ms, tz)
std:uuid module
- Added UUID generation:
v4(),v5(namespace_uuid, name),is_valid(text)
std:encoding module
- Added encoding utilities:
base64_encode(),base64_decode(),hex_encode(),hex_decode(),percent_encode(),percent_decode()
std:math module
- Added mathematical constants:
PI,E - Added trigonometric functions:
sin(),cos(),tan(),asin(),acos(),atan(),atan2() - Added logarithmic functions:
log(),log10(),exp()
std:crypto module
- Added hash functions:
md5(),sha1(),sha256(),sha512() - Added HMAC:
hmac_sha256(key, text)
Version 0.1.16
Export expressions (maps and leaf values)
exportnow accepts any expression that evaluates to a value- Supports both map exports (modules) and leaf exports (single values)
Import path resolution (files and directories)
- Enhanced import resolution with support for files and directories
- Supports nested module structures
Special builtins import object (builtins)
- Added virtual module
__builtins__for accessing builtin functions
Standard library directory (std/) and delegation to builtins
- Standard library now loaded from
std/directory - Modules delegate to runtime builtins via
__builtins__
Version 0.1.15
Improvement: Numeric error codes grouped by phase
- Diagnostics gained numeric codes grouped by the phase that raised them,
starting with lexer (
1xx), parser (2xx) and runtime ranges - All errors carry spans, so diagnostics can underline the offending expression
The ranges have since shifted: runtime errors moved to 4xx. See
Error Codes for the current list.
Version 0.1.14
Bugfix: function invocation in | pipelines
- Fixed function invocation semantics for
|pipelines with backtick commands and closures
Bugfix: operator precedence between | and |> / <|
- Fixed operator precedence so stream pipelines (
|) bind tighter than apply pipelines (|>/<|)
Version 0.1.13
Formal name change to SUJI
- Language now referred to as SUJI or suji
- File extension
.si
Repository organization
- Source repository split into a Cargo workspace of focused crates: AST, diagnostics, lexer, parser, runtime, stdlib, REPL and CLI
Version 0.1.12
Compositional function operators (>> and <<)
- Added
f >> gandf << gfor composing unary functions
Pattern alternation in match arms (|)
- Allows multiple patterns in single match arm:
p1 | p2 | p3 => expr
Pipe apply with expressions on either side (|>, <|)
- Clarified that
|>and<|accept arbitrary expressions on their expression sides
Version 0.1.11
Match syntax changes (=> and trailing commas) — breaking
- Match arms now use
=>instead of: - Each arm must end with a trailing comma
, - Applies to both match statements and match expressions
- 0.1.17 later relaxed the comma requirement for braced arms
Version 0.1.10
Runtime error spans and positions
- Runtime errors now include precise source locations with line and column numbers
Bugfix: String literal interpolation within strings
- Fixed bug where string literals inside interpolation expressions caused errors
Bugfix: Module method calls in match conditions
- Fixed parsing error where conditional match arms failed with module path plus method invocation
Version 0.1.9
Decimal number semantics (rust_decimal)
- Numbers now use base-10 decimal arithmetic via Rust’s
rust_decimal - Removes binary floating point rounding issues
- Expressions like
0.1 + 0.2 == 0.3evaluate totrue
import std:println
println(0.1 + 0.2) # 0.3
println(0.1 + 0.2 == 0.3) # true
std:io:open(path)
- Opens a file as a
streamfor both reading and writing
Multiple return values and destructuring assignment
- Functions can return multiple values:
return a, b, c - Assignments can destructure:
x, y, z = fn_call() - Use
_to discard unneeded values
Bugfix: Pipe operator requires function invocations
- The
|pipe operator is used between function invocations, not bare function values
Version 0.1.8
Backtick commands in pipes
- Backtick command expressions can be used on either side of the
|pipe operator
std:random module
- Added random number generation:
random(),integer(a, b),seed(n) - Added list helpers:
pick(list),shuffle(list),sample(list, k)
Pipe operators (|> and <|)
- Added
|>(forward pipe apply) and<|(backward pipe apply) operators
Version 0.1.7
Rename std:FD to std:io
std:FDmodule renamed tostd:io- Standard streams accessed as
io:stdin,io:stdout,io:stderr
Rename std:ENV to std:env:var
std:ENVmodule renamed tostd:env:var- Environment variables accessed via
var
stream::read_line()
- Added method to read a single line from a stream
stream::is_terminal()
- Added method to check if stream is attached to a terminal
std:env:args and std:env:argv
- Added command-line arguments access
Pipe operator (|)
- Added pipe operator
|that connects stdout of source closure to stdin of destination closure
Version 0.1.6
ENV map (std)
- Added
ENVmap understdmodule for environment variables
List first/last default parameter
list::first(default=nil)andlist::last(default=nil)accept an optional default value
List average method
- Added
list::average()for arithmetic mean
Stream type
- Added
streamdata type for blocking I/O over file descriptors
FD streams (std)
- Added
FDvalue understdmodule for standard streams
std:print function
- Added
std:print(text, out)for writing to streams
std:println function
- Added
std:println(text, out)as wrapper aroundstd:print
Version 0.1.5
New string methods
- Added:
contains(),starts_with(),ends_with(),replace(),trim(),upper(),lower(),reverse(),repeat()
New list methods
- Added:
push(item),pop(),length(),join(separator=" "),index_of(),contains(),filter(),map(),fold(),sum(),product(),reverse(),sort(),min(),max(),first(default=nil),last(default=nil),average()
New map methods
- Added:
get(key, default),merge(other_map)
New number methods
- Added:
abs(),ceil(),floor(),round(),sqrt(),pow(exponent),min(other),max(other)
New tuple methods
- Added:
length(),to_list(),to_string()
Multiline string support
- Added triple quotes
"""and'''for multiline strings
Version 0.1.4
YAML module
- Added
std:yamlmodule withparse()andgenerate()functions
TOML module
- Added
std:tomlmodule withparse()andgenerate()functions
Bugfix: Deep nesting support for maps and lists
- Fixed limitation where access was restricted to 2-3 levels of nesting
- Now supports arbitrary depth
Bugfix: Method calling in conditional match conditions
- Fixed limitation where conditional match statements could not use method calls
Bugfix: Deep nesting support for import statements
- Fixed limitation where imports were restricted to 2-3 levels of nesting
Single quote string support
- Single quotes now supported for string literals with same functionality as double quotes
Version 0.1.3
Match without expression
- Match statements can now be used without expression:
match { condition => ... }
New map methods
- Added:
keys(),values(),to_list(),length()
JSON module
- Added
std:jsonmodule withparse()andgenerate()functions
Version 0.1.2
String indexing
- Strings now support single character indexing with same syntax as lists
Descending ranges
- Range literals now support descending ranges where start > end
List concatenation
- Lists can now be concatenated using the
+operator
Wildcards in tuple patterns
- Match statements support wildcard patterns (
_) within tuple patterns
Map iteration
- Maps can be iterated using
loop through map with key, value
Map contains method
- Added
map::contains(key)method
Bugfixes
- Return statements in match arms
- Map literals in match arms
- Nil comparisons in match arms
Version 0.1.1
Variable scope changes
- No variable shadowing: assignment in nested scope assigns to parent variable
New operators
- Compound assignment operators:
+=,-=,*=,/=,%=
New type methods
- Cast methods:
string::to_number(),number::to_string(),string::to_list() - Number validation:
number::is_int()
New iterator methods
- List iterator methods:
filter(),map(),fold(),sum(),product()
New search methods
list::index_of(elem),string::index_of(substring)
New string slicing
- String slicing works same as list slicing
Complex assignments
- Support for nested assignments to complex data structures
Optional return statements
- Functions can omit
returnkeyword - automatically return last expression
Match expressions
- Match statements are expressions that evaluate to value of matching branch
Optional braces for single expressions
- Match branch blocks and function bodies can omit curly braces for single expressions
Semicolon statement separators
- Semicolons can be used as statement separators
Null type
- Added
niltype representing absence of value
Version 0.1.0
Initial release with core features:
- Basic data types (Number, Boolean, String, List, Map, Tuple, Regex)
- Control flow (loops, match)
- Functions and closures
- Modules and imports
- String interpolation
- Regular expressions
- Shell integration
See Also
Syntax Reference
Every piece of Suji syntax on one page, with the things that look like Suji but are not.
Reserved Words
Suji has exactly thirteen keywords:
break continue export import loop match return
as through with true false nil
_ is a wildcard in patterns and destructuring targets. Everything else is an
identifier, including words familiar from other languages: if, else, while,
for, fn, def, let, var, const, class, try, catch, throw,
and, or, and not are not keywords. Writing if x > 1 { … } produces
Undefined variable: if.
Comments and Separators
| Form | Meaning |
|---|---|
# text | Line comment, runs to end of line |
| newline | Ends a statement |
; | Optional statement separator |
There are no block comments.
import std:println
a = 1; b = 2 # two statements on one line
println(a + b) # 3
A bare { … } is a block, not an expression, so x = { 1 } is a parse error.
Numbers
There is one numeric type: an exact base-10 decimal. 42 and 42.0 are the
same type; there is no separate integer type.
import std:println
println(0.1 + 0.2) # 0.3
println(7 / 2) # 3.50
println(1.50) # 1.50
Literals accept decimal digits and at most one .. These forms do not exist:
| Not supported | Write instead |
|---|---|
0xFF, 0o77, 0b1010 | decimal digits only |
1_000_000 | 1000000 |
3e8, 6.626e-34 | 300000000, or a product such as 6.626 * 0.0000000000000000000000000000001 |
-5 as a literal | -5 is unary minus applied to 5 |
Precision is roughly 28–29 significant digits, with a maximum of
79228162514264337593543950335. Exceeding it aborts the program.
Strings
| Form | Notes |
|---|---|
"text" | Double-quoted |
'text' | Single-quoted, identical behaviour |
"""text""" | Triple-quoted, spans newlines |
'''text''' | Triple-quoted, single-quote flavour |
Interpolation is ${expr} and works in every string form and in backtick shell
templates. The escape set is closed:
| Escape | Produces |
|---|---|
\n | newline |
\t | tab |
\r | carriage return |
\" | " |
\' | ' |
\` | backtick |
\\ | backslash |
\$ | literal $, suppressing interpolation |
Any other escape is a lex error. \u0041, \u{1F600} and \0 are not
supported, and there are no raw strings.
import std:println
who = "world"
println("Hello, ${who}!") # Hello, world!
println("tab:\tdone") # tab: done
println("literal \${who}") # literal ${who}
Booleans and Nil
true, false and nil are literals. There is no truthiness: &&, || and
! require boolean operands, and nil || "default" is a type error rather than
a defaulting idiom.
import std:println
x = nil
println(x == nil) # true
println(!false) # true
Lists, Maps and Tuples
import std:println
xs = [1, "two", true] # heterogeneous, 0-based
m = {a: 1, "b": 2, 3: "c"} # insertion-ordered
t = (1, 2) # fixed size
println(xs::length()) # 3
println(m::keys()) # [a, b, 3]
println(t::to_list()) # [1, 2]
Map keys may be bare identifiers, strings, numbers or booleans. Map literals
with bare identifier keys are only recognised where a map is expected — as a
whole match-arm body or lambda body, { name: "x" } parses as a block, so quote
the keys or wrap the literal in parentheses there.
Tuples are not indexable: t[0] is a type error and there is no t::get(0).
Destructure with a, b = t, or convert with t::to_list().
Indexing and Slicing
| Form | Meaning |
|---|---|
xs[i] | Element at i (lists, strings) |
xs[-1] | Element counted from the end |
xs[a;b] | Slice from a up to but excluding b |
xs[;b] | Slice from the start |
xs[a;] | Slice to the end |
The slice separator is a semicolon, not a colon; : is reserved for map
access.
import std:println
xs = [10, 20, 30, 40, 50]
println(xs[0]) # 10
println(xs[-1]) # 50
println(xs[1;3]) # [20, 30]
println(xs[;2]) # [10, 20]
println(xs[3;]) # [40, 50]
s = "hello"
println(s[1]) # e
println(s[1;3]) # el
Strings are indexed and sliced by character, not byte. An out-of-range index is a fatal runtime error.
Map Access
| Form | Meaning |
|---|---|
m:key | Bare identifier key |
m["key"] | Any expression as key |
m::get(k, default) | Safe read, no error when missing |
m::contains(k) | Membership test |
A missing key read through m:key or m[k] raises Key not found and
terminates the program. Access chains nest to any depth and are assignable.
import std:println
data = {users: [{email: "a@example.com"}]}
println(data:users[0]:email) # a@example.com
println(data::get("missing", "-")) # -
data:users[0]:email = "b@example.com"
println(data:users[0]:email) # b@example.com
Method Calls
Methods use :: and exist on values, not on modules. Chains may break across
lines with a leading ::.
import std:println
result = [1, 2, 3, 4]
::map(|x| x * 2)
::filter(|x| x > 4)
::sum()
println(result) # 14
Variables and Assignment
There is no declaration keyword — assignment creates or updates a binding. There is no shadowing: assigning inside a nested scope writes to the outer variable if one exists.
| Form | Meaning |
|---|---|
x = expr | Bind or rebind |
x += expr | Also -=, *=, /=, %= |
x++, x-- | Postfix statements that mutate x |
a, b = expr | Destructure a tuple or multi-value return |
a, _ = expr | Discard a position |
There is no prefix ++x.
import std:println
n = 5
n += 3
n++
println(n) # 9
pair = (1, 2)
a, b = pair
println("${a} ${b}") # 1 2
Functions
Functions are lambdas assigned to names; there is no fn or def form.
| Form | Meaning |
|---|---|
|x| expr | One parameter, expression body |
|x, y| { … } | Block body |
|| expr | No parameters |
|a, b = 10| … | Default parameter value |
return expr | Explicit return |
return a, b | Return a tuple |
The last expression of a body is returned implicitly. Recursion works through the assigned name, but there is no tail-call optimisation, so depth is bounded by the native stack. There are no variadic parameters and no keyword arguments.
import std:println
add = |a, b = 10| a + b
minmax = |xs| {
return xs::min(), xs::max()
}
lo, hi = minmax([3, 1, 4])
println(add(1)) # 11
println(add(1, 2)) # 3
println("${lo} ${hi}") # 1 4
Closures capture the enclosing environment by reference and can mutate what they capture.
import std:println
make_counter = || {
count = 0
return || {
count++
return count
}
}
next = make_counter()
next()
println(next()) # 2
Match
match is the only conditional construct. It has two forms and is always an
expression.
import std:println
value = 2
subject = match value { # patterns compared against the subject
1 => "one",
2 => "two",
_ => "other",
}
conditional = match { # each arm is a boolean expression
value > 10 => "big",
_ => "small",
}
println(subject) # two
println(conditional) # small
The Comma Rule
An arm whose body is a bare expression must be followed by a comma,
including the last arm. An arm whose body is a { … } block may omit it. This
is the most common syntax error in Suji code:
match x { 1 => "one", _ => "other" } # parse error: no comma after last arm
match x { 1 => "one", _ => "other", } # correct
match x { 1 => { "one" } _ => { "other" } } # also correct
A match with no matching arm evaluates to nil rather than raising an error.
Patterns
| Supported | Example |
|---|---|
Number, string, boolean, nil literals | 1, "a", true, nil |
| Negative numbers | -1 |
| Regex literals | /^h/ |
| Tuple patterns | (1, 2), (1, _) |
| Alternatives | 1 | 2 | 3 |
| Wildcard | _ |
| Not supported | Note |
|---|---|
| Variable binding | A bare identifier is read as a string literal |
List patterns [a, b] | Use indexing after a length check |
| Map patterns | Use m::get / m::contains in a conditional match |
Range patterns 1..10 | Use a conditional match with comparisons |
if guards | Use a conditional match |
| Interpolated strings | Not allowed as patterns |
Because a bare identifier is a string literal, match 5 { n => n * 2, } yields
nil, not 10.
import std:println
classify = |v| {
match v {
0 => "zero",
1 | 2 | 3 => "small",
-1 => "minus one",
/^[a-z]+$/ => "lowercase word",
_ => "other",
}
}
println(classify(2)) # small
println(classify(-1)) # minus one
println(classify("abc")) # lowercase word
println(classify(99)) # other
Loop
loop is the only iteration keyword.
| Form | Meaning |
|---|---|
loop { … } | Infinite; needs break |
loop as name { … } | Labeled |
loop through xs { … } | Iterate without binding |
loop through xs with x { … } | Bind each element (list or range) |
loop through m with k, v { … } | Bind key and value — maps only |
break, continue, break label and continue label are statements. A label
must sit on the same line as the keyword. break <value> does not exist, and a
loop always evaluates to nil.
import std:println
total = 0
loop through 1..=4 with n {
n == 3 && continue
total += n
}
println(total) # 7
i = 0
loop {
i++
i >= 3 && break
}
println(i) # 3
loop as outer {
loop as inner {
break outer
}
}
println("done") # done
Iterables are lists, ranges and maps. Iterating a string or a stream is a
runtime error — use s::to_list() or stream::read_lines() first. Two bindings
on a list is also a runtime error.
Ranges
| Form | Meaning |
|---|---|
a..b | Exclusive of b |
a..=b | Inclusive of b |
Ranges evaluate immediately to a list; they are not lazy. Descending ranges work.
import std:println
println(1..4) # [1, 2, 3]
println(1..=4) # [1, 2, 3, 4]
println(5..1) # [5, 4, 3, 2]
Regular Expressions
Regex literals are written /pattern/ and support matching only.
| Operator | Meaning |
|---|---|
s ~ /re/ | true when the pattern matches |
s !~ /re/ | true when it does not |
There are no capture groups, no regex-based replace and no regex split.
/${var}/ is not interpolated. A regex may be stored in a variable and used as
a match-arm pattern.
import std:println
digits = /^[0-9]+$/
println("2024" ~ digits) # true
println("hello" !~ digits) # true
println("a@b.co" ~ /^[^@]+@[^@]+$/) # true
Shell Templates and Pipelines
A backtick template runs a command through the shell and evaluates to its stdout
with the trailing newline trimmed. ${expr} interpolation works inside.
import std:println
word = "suji"
println(`echo hello`) # hello
println(`echo ${word}`) # suji
stderr is not captured, and a non-zero exit status is a fatal runtime error with
no way to trap it. Guard commands that may fail, for example with
`cmd || true` or `test -f f && echo yes || echo no`.
The | operator pipes stdout between closures and shell templates. A closure on
the receiving side reads with io:stdin::read_lines() or read_all().
import std:io
import std:println
producer = || {
println("alpha")
println("beta")
}
count = || {
return io:stdin::read_lines()::length()
}
println(producer() | `grep a` | count()) # 2
Operators
Full precedence and associativity live in Operator Precedence. The set is:
| Group | Operators |
|---|---|
| Assignment | = += -= *= /= %= |
| Pipe apply | |> <| |
| Stream pipe | | |
| Composition | >> << |
| Logical | && || ! |
| Regex | ~ !~ |
| Equality | == != |
| Relational | < <= > >= |
| Range | .. ..= |
| Arithmetic | + - * / % ^ |
| Postfix | () [] :: : ++ -- |
+ concatenates strings and lists but never mixes types: "a" + 1 is a type
error. ^ requires an integer exponent and is right-associative.
Modules
Only std, the internal __builtins__, and local .si files are importable.
Nothing is available without an import — there is no prelude, so every program
that prints needs import std:println.
| Form | Binds |
|---|---|
import std | std, used as std:println(…) |
import std:math | math |
import std:println | println |
import std:json:parse | parse |
import std:println as say | say |
import std:println as say
import std:math
say(math:E) # 2.71828182845904523536
Local imports use path segments, not strings — import "./helpers.si" is a
parse error. Segments are directory and file names relative to the importing
file, without the .si extension:
import helpers # ./helpers.si binds `helpers`
import lib:util # ./lib/util.si binds `util`
import lib:util:greet # one key out of util.si's exported map
import lib:util as u # alias; only legal when the path has 2+ segments
A file has at most one export, and its value is whatever the export evaluates
to:
export 42 # `import leaf` binds the number 42
export { value: 1, f: |x| x } # `util:value` and `util:f` become available
See Also
Operator Precedence
How Suji groups an expression, from the loosest binding to the tightest.
The Table
Level 1 binds loosest, level 17 tightest. Operators on the same level are applied in the order given by their associativity.
| Level | Operators | Associativity | Notes |
|---|---|---|---|
| 1 | =, destructuring a, b = … | right | a = b = 3 assigns 3 to both |
| 2 | += -= *= /= %= | right | Compound assignment |
| 3 | <| | right | Backward pipe apply |
| 4 | |> | left | Forward pipe apply |
| 5 | | | left | Stream pipeline (shell and closures) |
| 6 | >> << | left | Function composition |
| 7 | || | left | Boolean or |
| 8 | && | left | Boolean and |
| 9 | ~ !~ | left | Regex match / not-match |
| 10 | == != | left | Equality |
| 11 | < <= > >= | left | Relational |
| 12 | .. ..= | — | Ranges do not chain |
| 13 | + - | left | Addition, subtraction, concatenation |
| 14 | * / % | left | Multiplication, division, remainder |
| 15 | unary - ! | prefix | Binds looser than ^ |
| 16 | ^ | right | Exponentiation |
| 17 | () [] :: : ++ -- | postfix | Call, index, method, map key, increment |
Surprises Worth Memorising
^ is right-associative and needs an integer exponent
import std:println
println(2 ^ 3 ^ 2) # 512
println(2 ^ 10) # 1024
2 ^ 3 ^ 2 groups as 2 ^ (3 ^ 2), which is 2 ^ 9. A non-integer exponent
such as 4 ^ 0.5 is a runtime error; use 16::sqrt() for roots.
Unary minus binds looser than ^
import std:println
println(-2 ^ 2) # -4
println((-2) ^ 2) # 4
-2 ^ 2 is -(2 ^ 2), not (-2) ^ 2.
Ranges sit between comparison and +
Arithmetic inside a range endpoint needs no parentheses, but a comparison against a range does.
import std:println
println(1 .. 3 + 1) # [1, 2, 3]
1 .. 3 + 1 groups as 1 .. (3 + 1), producing the exclusive range 1..4.
~ binds looser than ==
This is the opposite of what most people expect. "abc" ~ /b/ == true groups as
"abc" ~ (/b/ == true) and fails with a type error. Parenthesise the match:
import std:println
println(("abc" ~ /b/) == true) # true
In practice you rarely need this, because ~ already yields a boolean.
There are three distinct pipe operators
| Operator | Level | What it does |
|---|---|---|
|> | 4 | Applies the left value to the right function |
<| | 3 | Applies the right value to the left function |
| | 5 | Connects stdout of one stage to stdin of the next |
Because \| binds tighter than both apply operators, a shell pipeline is built
first and its result is then piped into a function.
import std:io
import std:println
count = || io:stdin::read_lines()::length()
label = |n| "lines: ${n}"
`printf 'a\nb\nc\n'` | count() |> label |> println # lines: 3
|| versus the empty-parameter lambda
|| is boolean or, and || expr is a lambda that takes no parameters. The
parser distinguishes them by position: || after a value is the operator,
|| where a value is expected starts a lambda.
import std:println
flag = true || false # boolean or
zero = || 42 # lambda with no parameters
println(flag) # true
println(zero()) # 42
Both && and || require boolean operands. nil || "default" is a type error,
so there is no || idiom for defaults — use m::get(k, default) or a match.
Worked Examples
Each result below was produced by running the expression.
| Expression | Groups as | Result |
|---|---|---|
2 + 3 * 4 | 2 + (3 * 4) | 14 |
2 * 3 ^ 2 | 2 * (3 ^ 2) | 18 |
2 ^ 3 ^ 2 | 2 ^ (3 ^ 2) | 512 |
-2 ^ 2 | -(2 ^ 2) | -4 |
10 - 3 - 1 | (10 - 3) - 1 | 6 |
1 .. 3 + 1 | 1 .. (3 + 1) | [1, 2, 3] |
true || false && false | true || (false && false) | true |
1 + 2 |> inc | (1 + 2) |> inc | 4 |
3 |> inc >> double | 3 |> (inc >> double) | 8 |
double <| inc <| 3 | double <| (inc <| 3) | 8 |
import std:println
inc = |x| x + 1
double = |x| x * 2
println(2 + 3 * 4) # 14
println(2 * 3 ^ 2) # 18
println(10 - 3 - 1) # 6
println(true || false && false) # true
println(1 + 2 |> inc) # 4
println(3 |> inc >> double) # 8
println(double <| inc <| 3) # 8
3 |> inc >> double is worth a second look. Composition binds tighter than pipe
apply, so inc >> double is built into a single function first and 3 is then
applied to it. Had the grouping been (3 |> inc) >> double, the left operand of
>> would be the number 4 and composition would fail.
double <| inc <| 3 shows the right-associativity of <|: the innermost apply
inc <| 3 runs first, and its result is handed to double. Left-associative
grouping would try to compose double with inc, which <| does not do.
Forcing a Grouping
Parentheses always win, and they are the cheapest way to make an expression readable when several pipe families meet in one line.
import std:println
value = ((1 + 2) * 3) ^ 2
println(value) # 81
See Also
Error Codes
Every diagnostic Suji prints carries a numeric code. This page lists all of them.
How Errors Behave
Suji has no error-handling construct: no try/catch, no throw, no Result
or Option type, no defer. Any diagnosed error terminates the script
immediately with exit status 1, and there is no way to trap it from Suji code.
The only strategy is to check before you act — see
Error Handling Deep Dive.
Two failures escape this taxonomy because they abort the process before a diagnostic can be produced. Neither carries a code, and neither can be caught:
| Failure | What you see | Exit status |
|---|---|---|
Numeric overflow, including 10 ^ 16 | thread 'main' panicked … Multiplication overflowed | 101 |
| Recursion deeper than a few hundred frames | thread 'main' has overflowed its stack | 134 |
Code Ranges
| Range | Phase | Meaning |
|---|---|---|
1xx | Lexing | The source could not be turned into tokens |
2xx | Parsing | The tokens do not form a valid program |
4xx | Runtime | The program started and then failed while running |
A 1xx or 2xx error means nothing ran at all. A 4xx error means everything
before the failing line already executed, including its side effects.
Diagnostic Format
The interpreter prints a framed diagnostic to stderr, followed by a one-line summary. The code appears in brackets before the title:
[403] Error: Index out of bounds
╭─[ e1.si:4:9 ]
│
4 │ println(xs[5])
│ ──┬──
│ ╰──── Index 5 out of bounds for length 2
│
│ Note 1: Use list::length() to check the size before indexing
│
│ Note 2: Check array/map bounds and key existence
│
│ Note 3: Use length() methods to verify bounds before access
───╯
Error: Index out of bounds: Index 5 out of bounds for length 2
Reading it: 403 is the code, Index out of bounds is the title,
e1.si:4:9 is the file, line and column, the underline marks the offending
expression, and the notes are generic suggestions for that error category.
Lexer Errors (1xx)
| Code | Title | Usual cause and fix |
|---|---|---|
| 101 | Unterminated string literal | A quote was never closed. Check for a stray " or ', and remember that """ must be closed by """ |
| 102 | Unterminated shell command | A backtick template was never closed. Escape literal backticks inside strings as \` |
| 103 | Unterminated regex literal | A /pattern/ is missing its closing slash. Escape a literal slash inside the pattern as \/ |
| 104 | Invalid escape sequence | Only \n \t \r \" \' \ \ $exist.\u0041, \0and\e` are not supported; write the character directly |
| 105 | Invalid number literal | Numbers are decimal digits with at most one .. Hex, octal, binary, digit separators and exponents do not exist |
| 106 | Unexpected character | A character that is not part of any token, often a smart quote pasted from a document, or @/? |
Parser Errors (2xx)
| Code | Title | Usual cause and fix |
|---|---|---|
| 201 | Unexpected token | Something is in a position the grammar does not allow. The classic case is a missing comma after a bare-expression match arm |
| 202 | Unexpected end of input | A brace, bracket or parenthesis was never closed |
| 203 | Parse error | A general parse failure that does not fit a more specific code |
| 204 | Multiple export statements found | A file may contain at most one export. Merge the values into a single map |
| 205 | Expected token | A specific token was required and something else appeared, for example a missing => in a match arm |
| 206 | Expected item name after : | An import path ends in a colon, as in import std: |
| 207 | Expected alias name after as | import std:println as has no name after as. Note that aliasing a single-segment local import is also rejected |
Runtime Errors (4xx)
Types and Operations
| Code | Title | Usual cause and fix |
|---|---|---|
| 400 | Type error | Mixed types in an operation. "a" + 1, nil || "x", indexing a tuple, comparing a number with a string using < |
| 402 | Invalid operation | The operation is not defined for these values at all, such as an unsupported unary application |
| 410 | Invalid number conversion | "abc"::to_number() on text that is not numeric. Validate with a regex before converting |
Names and Access
| Code | Title | Usual cause and fix |
|---|---|---|
| 401 | Undefined variable | A typo, a missing import, or a keyword from another language such as if being read as an identifier |
| 403 | Index out of bounds | A list index outside 0 .. length-1 (or the negative equivalent). Check xs::length() first |
| 404 | Key not found | m:key or m[k] on an absent key. Use m::get(k, default) or m::contains(k) |
| 405 | Invalid key type | Map keys must be numbers, booleans, strings or tuples. Lists and maps cannot be keys |
| 412 | String index error | A character index outside the string. Check s::length() first |
| 413 | Range error | Range bounds must be integers, and very large ranges allocate a full list |
Calls and Control Flow
| Code | Title | Usual cause and fix |
|---|---|---|
| 408 | Arity mismatch | Wrong number of arguments. Give the parameter a default value if it should be optional |
| 409 | Method error | The method does not exist for that type. Check the spelling and the type’s method list |
| 411 | Internal control flow error | A break, continue or return escaped its construct. Usually indicates a bug worth reporting |
| 416 | Conditional match error | An arm of a match { … } did not evaluate to a boolean |
| 426 | Map method error | A map method was called with the wrong arguments, for example m::get() with no key |
Collections and Destructuring
| Code | Title | Usual cause and fix |
|---|---|---|
| 414 | List concatenation error | + was used between a list and a non-list. Use xs::push(v) to add one item |
| 415 | Map contains error | m::contains(k) was called with a key of an unusable type |
| 434 | Destructuring type error | a, b = value where the right side is not a tuple |
| 435 | Destructuring arity mismatch | The number of targets does not match the tuple size. Use _ to discard a position |
| 436 | Invalid destructuring target | A destructuring target is not assignable |
Pipes
| Code | Title | Usual cause and fix |
|---|---|---|
| 429 | Pipe stage type error | A | stage is neither a closure call nor a shell template |
| 430 | Empty pipe expression | A | has nothing on one side |
| 431 | Pipe execution error | A stage of a | pipeline failed while running |
| 432 | Pipe apply type error | |> has a non-function on the right |
| 433 | Pipe apply type error | <| has a non-function on the left |
System, Streams and Regex
| Code | Title | Usual cause and fix |
|---|---|---|
| 406 | Shell command failed | A backtick command exited non-zero. Neutralise it with `cmd || true` or make the command print a value you can match on |
| 407 | Regex error | The pattern could not be compiled, or ~ was applied to something other than string ~ regex |
| 427 | Stream error | A read or write on a closed, missing or unreadable stream |
Data Formats
| Code | Title | Usual cause and fix |
|---|---|---|
| 417 | JSON parse error | Malformed JSON text |
| 418 | JSON generation error | The value contains something JSON cannot hold, such as a function or a regex |
| 419 | YAML parse error | Malformed YAML text, usually indentation |
| 420 | YAML generation error | The value contains something YAML cannot hold |
| 421 | TOML parse error | Malformed TOML text |
| 422 | TOML generation error | TOML has no nil, and functions and regex cannot be written |
| 423 | TOML conversion error | A TOML key was not a string, or a nil appeared in the value |
| 424 | CSV parse error | Unclosed quotes or an unusable delimiter |
| 425 | CSV generation error | csv:generate expects a list of lists of strings; convert numbers first |
| 428 | Serialization error | A value that cannot be serialised at all, such as a stream, reached a format writer |
Checking Before You Act
Because nothing can be caught, the defensive patterns below are the whole error-handling story.
import std:println
safe_divide = |a, b| {
match {
b == 0 => nil,
_ => a / b,
}
}
config = {host: "localhost"}
xs = [1, 2, 3]
println(safe_divide(10, 0)) # nil
println(safe_divide(10, 4)) # 2.50
println(config::get("port", 8080)) # 8080
println(config::contains("host")) # true
println(xs::length() > 0) # true
println(xs::first(0)) # 1
For a shell command that may fail, make failure produce a value instead of an error:
import std:println
status = `test -f /definitely/not/here && echo found || echo missing`
println(status) # missing
See Also
Glossary
Terms as this book uses them, which is to say as Suji actually defines them.
A
Arm — One pattern => body clause of a match.
An arm whose body is a bare expression must be followed by a comma, including
the last one; an arm whose body is a { … } block may omit it.
Arity — The number of parameters a function declares. Suji checks arity at call time and raises error 408 on a mismatch, unless the missing parameters have defaults.
B
Backtick template — A `command` literal that runs a command through
the shell and evaluates to its stdout with the trailing newline trimmed. Also
called a shell template. See Shell Integration.
Binding — The association of a name with a value, created by assignment. Suji has no declaration keyword and no shadowing: assigning inside a nested scope writes to the outer binding when one exists.
C
Closure — A function that captures its enclosing environment. Suji closures capture by reference, so they can read and mutate the variables they close over. See Closures.
Composition — Building one function from two with >> or <<. f >> g
means “f then g”; f << g means “g then f”. See
Function Composition.
Conditional match — The match { condition => body, … } form, in which each
arm is a boolean expression rather than a pattern. This is Suji’s replacement
for if/else if chains. See Conditional Logic.
D
Decimal number — Suji’s only numeric type: an exact base-10 fixed-precision
decimal of roughly 28–29 significant digits. 0.1 + 0.2 == 0.3 is true. There
is no integer type and no floating-point type. See Numbers.
Deep import — An import that reaches past a module to one of its members,
such as import std:json:parse, which binds parse directly. See
Imports.
Destructuring — Unpacking a tuple or a multi-value return into several
bindings at once: a, b = point. Use _ to discard a position. See
Multiple Return Values.
E
Eager — Evaluated immediately rather than on demand. Suji is eager
throughout: ranges materialise as lists, map and filter allocate a new list
per step, and stream reads block.
Expression statement — A statement that is just an expression evaluated for its value or its side effects, such as a bare function call.
Export — The single value a module makes available, written export expr.
A file may have at most one. export { a: 1, f: |x| x } makes module:a and
module:f reachable. See Exports.
G
Guard — An early exit written with a short-circuit operator, such as
n < 0 && return nil or done || continue. Suji has no if guards inside
match patterns, so guards live either in a conditional match or in these
short-circuit statements. See Guard Clauses.
H
Higher-order function — A function that takes or returns a function.
list::map, list::filter and list::fold are the built-in examples. See
Higher-Order Functions.
I
Implicit return — The value of the last expression in a function body,
returned without a return keyword.
Interpolation — Embedding an expression in a string or shell template with
${expr}. It is the only interpolation syntax, and it does not apply inside
regex literals. See String Interpolation.
Iterable — A value loop through accepts: a list, a range (which is a
list), or a map. Strings and streams are not iterable; convert with
s::to_list() or stream::read_lines() first.
L
Lambda — A function literal, |params| body. Suji has no other way to
define a function; a named function is a lambda assigned to a name. See
Basic Functions.
M
Map — An insertion-ordered collection of key-value pairs, written
{a: 1, "b": 2}. Keys may be strings, numbers, booleans or tuples. Read with
m:key, m["key"] or m::get(k, default). See Maps.
Match — Suji’s only conditional construct, and always an expression. It has
a subject form (match value { … }) and a conditional form (match { … }). A
match with no matching arm evaluates to nil. See Match Expressions.
Method — A function attached to a value and called with ::, as in
xs::length(). Methods belong to types, not to modules; module functions are
called with :, as in json:parse(text).
Module — A unit of importable code: std and its submodules, or a local
.si file. Local imports use colon-separated path segments relative to the
importing file, never string paths. See Modules.
N
Nil — The absence of a value, written nil. It is not falsey — Suji has no
truthiness — so test it with x == nil. See Nil.
P
Pattern — The left side of a match arm. Suji supports literal, negative
number, regex, tuple, alternative (|) and wildcard patterns. It does not
support variable binding, so a bare identifier in a pattern is read as a string
literal. See Pattern Matching.
Pipe apply — The |> and <| operators, which apply a value to a function.
5 |> inc and inc <| 5 both call inc(5). See Pipe Apply.
Pipeline — A chain of stages joined by |, where each stage’s stdout
becomes the next stage’s stdin. Stages are closure calls or shell templates. See
Pipe.
Predicate method — One of is_number(), is_bool(), is_string(),
is_list(), is_map(), is_stream(), is_function(), is_tuple(),
is_regex(), available on every value. There is no is_nil() and no type().
See Type Checking Methods.
R
Range — a..b (exclusive) or a..=b (inclusive). A range evaluates
immediately to a list, so 0..1000000 allocates a million elements. Descending
ranges work: 5..1 is [5, 4, 3, 2].
Regex literal — A /pattern/ value used with ~ and !~ or as a match
pattern. Matching is the only operation: there are no capture groups, no
regex replace and no regex split. See Regular Expressions.
S
Scale — The number of decimal places a number carries. Suji preserves it, so
1.50 prints as 1.50 and 2.50 + 1 prints as 3.50, while the literal 1.0
normalises to 1.
Shell template — See backtick template.
Spec test — A single-assertion .si program under spec/, whose final
println carries the expected output in a trailing # comment. See
Spec Tests.
Stream — A blocking I/O handle produced by io:open(path) or supplied as
io:stdin, io:stdout and io:stderr. All reads are eager. See
Streams.
Subject — The value between match and { in the subject form of a match,
against which each pattern is compared.
T
Tuple — A fixed-size ordered group, written (1, 2). Tuples are not
indexable: use destructuring or t::to_list(). Their only methods are
length(), to_list() and to_string(). See Tuples.
W
Wildcard — _, which matches any value in a pattern and discards a position
in destructuring. It is conventionally the last arm of a match; without it, an
unmatched match yields nil.
See Also
Resources
Where to look when this book runs out, listing only material that actually exists.
Status
Suji is pre-1.0 — the current version is 0.1.22 — and is distributed only as
a source build. There is no package-manager release, no suji --version flag,
and no stability guarantee across 0.1.x versions. Build it with
cargo build --release or make release; the binary lands at
target/release/suji. See Installation.
The Source Repository
The project lives at https://github.com/suji-lang/suji. The workspace is split into single-purpose crates:
| Path | Contents |
|---|---|
crates/suji-ast/ | AST node definitions |
crates/suji-lexer/ | Scanner, tokens, and the string/regex/shell state machines |
crates/suji-parser/ | Expression and statement parsing, operator precedence |
crates/suji-values/ | Value types, environment, methods, RuntimeError |
crates/suji-runtime/ | Executor trait, module registry, builtin registry |
crates/suji-interpreter/ | The default AST-walking interpreter |
crates/suji-stdlib/ | Standard library modules and builtin functions |
crates/suji-diagnostics/ | Error codes, templates, and the framed diagnostic output |
crates/suji-repl/ | The REPL loop |
crates/suji-cli/ | Binary entry point |
Supporting directories:
| Path | Contents |
|---|---|
spec/ | 650 single-assertion .si programs — the executable definition of the language |
examples/ | Complete runnable programs |
tests/ | Rust unit and integration tests |
scripts/ | verify_spec.sh and verify_examples.sh |
Makefile | Every build and test entry point |
Reading the Spec Suite
spec/ is the most reliable answer to “does Suji support this?”. Each file is
one assertion whose expected output sits in a trailing comment, so a file is
both a question and its answer:
import std:println
inc = |x| x + 1
result = 3 |> inc
println(result) # 4
Files are named feature_area_NN.si, so ls spec/ | grep map_methods is a fast
way to find every documented behaviour of a feature. The conventions are
described in Spec Tests.
Make Targets
| Command | What it does |
|---|---|
make build | Debug build |
make release | Optimised build, producing target/release/suji |
make test | Rust tests, then spec verification, then examples |
make rust_tests | Rust unit and integration tests only |
make verify_spec | Runs every spec/*.si and compares against its expected output |
make verify_examples | Runs every examples/*.si and checks it exits cleanly |
make lint | cargo clippy --all-targets plus cargo fmt --check |
make help | Lists the full target set |
Reading Diagnostics
Errors are your main feedback channel, since nothing can be caught at runtime.
Each diagnostic prints a numeric code, a title, the source line with the failing
expression underlined, and one or more suggestions. Look the code up in
Error Codes: the range alone tells you whether the failure was
lexical (1xx), syntactic (2xx) or a runtime fault (4xx).
A Path Through This Book
If you are new, this order works well:
- Installation and Quick Start — get a working binary and run something.
- Language Overview — the shape of the language in one sitting.
- Data Types — one decimal number type, and the collections.
- Control Flow —
matchandloopare the whole story. - Functions — lambdas, closures, and multiple returns.
- Operators — especially the three pipe families.
- Modules — imports, exports, and resolution.
- Standard Library Overview — what you can import.
- Examples and Cookbook — complete programs.
- Advanced Topics — error behaviour, pattern matching, performance.
Keep Syntax Reference, Operator Precedence and Error Codes open while you write.
Contributing
If you want to change the language rather than use it, start with Contributing, then Testing. A language change is expected to ship with a spec file, a Rust test, and a documentation update in the same commit.