Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Clarity over cleverness - Code should be easy to read and understand
  2. Explicit is better than implicit - Imports, types, and control flow are clear
  3. Composability - Small, focused functions that work together
  4. Practical defaults - Common cases should be easy, edge cases possible
  5. 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:

FeaturePythonJavaScriptBashSuji
Dynamic typingYesYesYesYes
Default numbers are exact decimalsNoNoNoYes
Shell command literalsNoNoYesYes
Pipelines in the languageNoNoYesYes (|, |>, <|)
Pattern matchingYes (3.10+)NoNoYes (match)
if/for/while statementsYesYesYesNo — match and loop
ExceptionsYesYesNoNo — errors are fatal
TruthinessYesYesYesNo — booleans only
JSON/YAML/TOML/CSV in the stdlibPartlyJSON onlyNoYes

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:

  1. Installation - Install Suji on your system
  2. Quick Start - Write your first programs
  3. Hello World - Detailed first program walkthrough
  4. CLI & REPL - Master the command-line tools

Documentation Structure

This book is organized into several main sections:

For Beginners

For Practitioners

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

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

  1. Work through the Quick Start
  2. Walk through a first program in Hello World
  3. Learn the tooling in CLI & REPL
  4. 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:

Try It Yourself

Experiment with these examples:

  1. Modify the greeting: Change the greet function to include the age
  2. Filter numbers: Use pipes to find all even numbers in a list
  3. Parse data: Load a JSON file and extract specific fields
  4. 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 no if, else, for or whilematch and loop cover their jobs
  • Missing colons in maps: use key: value, not key = value
  • Expecting truthiness: &&, || and ! require real booleans, so value || "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:

  • std is the standard library module
  • The colon : is used to access nested modules or functions
  • println is 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)
  • println adds 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 called name
  • ${name} is string interpolation - it inserts the value of name into 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 called greet
  • |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?

  1. We define a greet function that takes two parameters
  2. We use match to choose a greeting based on the language
  3. Every arm ends with a comma — including the last one, which is required whenever an arm’s body is a plain expression
  4. 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:

ConceptSyntaxExample
Importimport module:functionimport std:println
String (double quotes)"text""Hello"
String (single quotes)'text''Hello'
String interpolation"${variable}""Hello, ${name}!"
Variablename = valuename = "Alice"
Functionname = |params| { body }greet = |n| "Hi, ${n}!"
Function callfunction(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:

OptionDescription
<file>Execute a Suji program
(no arguments)Start the REPL
--print-astParse 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", so suji args.si alpha beta leaves env:args as {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; see std: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

CommandDescription
:helpShow the built-in help
:quitExit
:exitExit
Ctrl+CDiscard the input being typed
Ctrl+DExit
Arrow Up/DownBrowse 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

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: match expressions are the only conditional construct — there is no if, else, while or for
  • 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 .env files 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

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:

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, merge and delete mutate the value in place

Quick Reference

TypeLiteralExampleMutable?
Number42, 3.14age = 30Immutable
Booleantrue, falseactive = trueImmutable
String"text", 'text'name = "Alice"Immutable
Nilniloptional = nilImmutable
List[...]nums = [1, 2, 3]Mutable
Map{...}user = {name: "Alice"}Mutable
Tuple(...)point = (10, 20)Immutable
Function|x| x * 2double = |x| x * 2Immutable
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:

  1. Numbers - Learn arithmetic and math operations
  2. Strings - Master text manipulation
  3. Booleans - Understand logical operations
  4. Lists - Work with collections
  5. 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.3 is true
  • 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 supportedWrite instead
Hex 0xFF, octal 0o77, binary 0b1010the decimal value (255, 63, 10)
Digit separators 1_000_0001000000
Scientific notation 3e8300000000 (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:sqrt or math: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

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 true or false
  • Logical operators - &&, ||, ! (there are no and/or/not keywords)
  • Comparison results - All comparisons return booleans
  • Short-circuit evaluation - Efficient logical operations
  • No truthiness - Other types are not “falsy”; !nil and nil || "x" are type errors, and a conditional match only takes an arm that tests true

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 && expr evaluates expr, which must itself be a boolean
  • anything other than true on the left of && behaves like false and short-circuits, without complaining about its type
  • a non-boolean on the right of &&/|| is a type error once it is reached: true && 5 fails with Logical AND requires boolean operands, and nil || "default" fails with Logical OR requires boolean operands
  • ! always requires a boolean: !nil and !5 are 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 true or false
  • Create complex nested conditions
  • Use non-descriptive names (flag, b, x)
  • Rely on implicit truthiness (Suji requires explicit booleans)

Next Steps

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 forUse 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

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 forUse 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() and last() 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

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() and loop through all 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:absent is a runtime error, not nil; use m::get(k, default) or m::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 forUse 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() or delete() 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

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 no get, first or second either
  • Destructuring - a, b = t is 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 for get/first/second — destructure, or call to_list() if you really need positional access
  • Wrap destructuring targets in parentheses ((a, b) = t is 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

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 no fn, def, func or function keyword; 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

See Also

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 match based 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, because replace() 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 | nil
  • stream::read_line() → String | nil
  • stream::read_all() → String
  • stream::read_lines() → List[String]
  • stream::write(text) → Number (bytes written)
  • stream::is_terminal() → boolean
  • stream::close() → nil
  • stream::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:

OperatorNameMeaning
|pipelinestdout of one stage becomes stdin of the next
|>forward applyvalue |> f is f(value)
<|backward applyf <| value is f(value)
>>composef >> 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:

  1. Assignment (=, including destructuring a, b = …)
  2. Compound assignment (+=, -=, *=, /=, %=)
  3. Backward pipe-apply (<|, right-associative)
  4. Forward pipe-apply (|>)
  5. Pipeline (|)
  6. Function composition (>>, <<)
  7. Logical OR (||)
  8. Logical AND (&&)
  9. Regex match (~, !~)
  10. Equality (==, !=)
  11. Ordering (<, <=, >, >=)
  12. Range (.., ..=)
  13. Addition, Subtraction (+, -)
  14. Multiplication, Division, Modulo (*, /, %)
  15. Unary (-, !)
  16. Exponentiation (^, right-associative)
  17. 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

CategoryOperatorsExample
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:

  1. Arithmetic Operators - Mathematical operations
  2. Relational Operators - Equality and ordering
  3. Logical Operators - Boolean logic
  4. Assignment Operators - Variable assignment
  5. Matching Operators - Regex matching
  6. Pipeline Operator - stdin/stdout pipelines
  7. Pipe Apply - Applying a value to a function
  8. 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

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.5Invalid operation: Power exponent must be an integer
  • 2 ^ (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:

  1. Addition, Subtraction (+, -)
  2. Multiplication, Division, Modulo (*, /, %)
  3. Unary negation and not (-, !)
  4. 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" * 3 or math: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

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 < c doesn’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]
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

OperatorNameExampleResult
==Equal5 == 5true
!=Not equal5 != 3true
<Less than3 < 5true
<=Less or equal5 <= 5true
>Greater than5 > 3true
>=Greater or equal5 >= 5true

See Also

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:

  1. OR (||) - lowest
  2. AND (&&)
  3. 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 (&&)

ABA && B
TTT
TFF
FTF
FFF

OR (||)

ABA || B
TTT
TFT
FTT
FFF

NOT (!)

A!A
TF
FT

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

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 iterableloop 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

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() or stage(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 redirecting std:io streams 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 |> f is equivalent to f(value)
  • f <| value is equivalent to f(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

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}")
}

Learn more about Loops →

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

ConstructUse WhenExample
MatchAll conditional logicCheck if user is admin, grade calculator
LoopRepeat until conditionRead until EOF
Loop ThroughIterate collectionsProcess each item in list
GuardsValidate inputs, early exitsCheck 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 match for all conditional logic
  • Use loop through for collections
  • Keep nesting shallow (max 2-3 levels)
  • Use meaningful condition names

DON’T:

  • Try to use if statements (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:

  1. Conditional Logic - Using match for conditionals
  2. Loops - Iteration and repetition
  3. Match - Pattern matching
  4. 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 true
  • condition || 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 match for 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, elif or a ternary operator (none of them exist in Suji)
  • Write and, or or not — 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, unless nil is 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 LanguageSuji Equivalent
if (condition) { ... }match { condition => { ... } }
if (condition) { ... } else { ... }match { condition => { ... } _ => { ... } }
if (x) return yx && return y (guard clause; x must be boolean)
if (!x) return yx || return y (guard clause; x must be boolean)
if-else if-else chainmatch { condition1 => ..., condition2 => ..., _ => ..., }
condition ? a : bmatch { condition => a, _ => b, }
x = y ?? "default"m::get("y", "default"), or match { y == nil => "default", _ => y, }

See Also

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
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 through for collections
  • Provide clear exit conditions
  • Use break for early exit
  • Use continue to skip iterations
  • Label the outer loop when a nested loop needs to break out of it
  • Consider functional methods for simple transformations

DON’T:

  • Create infinite loops without break
  • Expect break to return a value, or a loop to evaluate to anything but nil
  • 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) and nil
  • 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 (_) unless nil is 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) and m::contains(k) over a direct m:k that can raise
  • Use guards to avoid deep nesting
  • Make error messages descriptive
  • Use && return for positive conditions (return if true)
  • Use || return for 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 match instead)
  • Complex decision trees (use match instead)
  • 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

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.

Learn more about Closures →

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.

Learn more about Recursion →

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

ConceptSyntaxExample
Basic functionname = |params| { body }add = |a, b| a + b
Default value|param = expr|inc = |x, step = 1| x + step
Call functionname(args)add(5, 3)
ClosureCaptures outer scopemake_counter = || { ... }
Higher-orderTakes/returns a functionxs::map(|x| x * 2)
Composition>>, <<f >> g
RecursionCalls itself by namefib = |n| ... fib(n - 1)
Multiple returnsreturn a, b then a, b = f()return result, nil
AnonymousLambda|x| x * 2

Next Steps

Dive deep into each function concept:

  1. Function Basics - Parameters, returns, calling
  2. Closures - Variable capture, scope
  3. Higher-Order Functions - Functions as values
  4. Recursion - Self-referential functions
  5. Multiple Returns - Returning tuples
  6. 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 a loop when 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 m is 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 import lines).
  • 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 m is a parse error; write import lib:math as m, or import normally and rebind with m = 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 export statement. A second one is a parse error: Multiple export statements found.
  • A file with no export can still be run directly; it just has nothing to import.
  • Exports are either:
    • Map export: export { name: expr, ... }
    • Expression export: export <expr>

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:

  1. one.si (file)
  2. 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:

  1. File-as-module, then key lookup:
    • Load one.si.
    • It must export a map.
    • Look up two in that map, then three, etc.
  2. Nested file:
    • Load one/two/three.si (and import its exported value).
  3. Nested directory:
    • Load one/two/three/ as a directory module map.

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 Infinity1 / 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 use t::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:

RangePhaseExamples
1xxLexing101 unterminated string, 104 invalid escape, 105 invalid number
2xxParsing201 unexpected token, 202 unexpected EOF, 205 expected token
4xxRuntime400 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" + 1 or nil || "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

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:

PatternExampleMatches
Number literal200, -1, 3.5that exact number
String literal"admin"that exact string
Boolean literaltrue, falsethat boolean
nilnilnil
Regex literal/^[0-9]+$/a string matching the regex
Tuple pattern(0, 0), ("GET", _)a tuple whose elements match
Alternatives200 | 201 | 204any 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 * 2 does not bind n.
  • 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 want nil for 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 = value before 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_of and slices instead
  • Write unreachable arms after a broad one

See Also

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() - returns true if value is a number
  • value::is_bool() - returns true if value is a boolean
  • value::is_string() - returns true if value is a string
  • value::is_list() - returns true if value is a list
  • value::is_map() - returns true if value is a map
  • value::is_stream() - returns true if value is a stream
  • value::is_function() - returns true if value is a function
  • value::is_tuple() - returns true if value is a tuple
  • value::is_regex() - returns true if 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() - returns true if 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 with value == nil
  • No is_boolean() - the method is called is_bool()
  • No type(), type_of() or typeof - 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() or type() - 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

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}; $var without 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.

EscapeProduces
\nnewline
\ttab
\rcarriage 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:var or 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 form match { 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

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 fail and match on 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:stdinread_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

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, but contains() and index_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 join once 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 break or return

DON’T:

  • Expect map/filter/fold or 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

  1. Measure first: don’t guess, time it
  2. Find the real bottleneck: usually I/O or an accidental quadratic loop
  3. Fix the algorithm: constant-factor tweaks rarely matter in an interpreter
  4. Keep it readable: an eager three-stage chain is fine until it isn’t
  5. Re-measure: confirm the change actually helped

See Also

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

ImportProvidesPage
import std:printlnprintln(text = "", out = nil)Print Functions
import std:printprint(text, out = nil)Print Functions

Data Formats

ModuleExportsPage
std:jsonparse, generateJSON
std:yamlparse, generateYAML
std:tomlparse, generateTOML
std:csvparse, generateCSV

System

ModuleExportsPage
std:ioopen, stdin, stdout, stderrI/O and Streams
std:envvar, args, argvEnvironment
std:osname, hostname, uptime_ms, tmp_dir, home_dir, work_dir, pid, ppid, uid, gid, exit, mkdir, rm, rmdir, statOperating System
std:pathis_abs, join, dirname, basename, extname, normalizePaths
std:dotenvloadDotenv Files

Utilities

ModuleExportsPage
std:randomrandom, seed, integer, pick, shuffle, sample, string, hex_string, alpha_string, numeric_string, alphanumeric_stringRandom Numbers
std:timenow, sleep, parse_iso, format_isoTime and Dates
std:uuidv4, v5, is_validUUID
std:encodingbase64_encode, base64_decode, hex_encode, hex_decode, percent_encode, percent_decodeText Encoding
std:mathPI, E, sin, cos, tan, asin, acos, atan, atan2, log, log10, expMathematics
std:cryptomd5, sha1, sha256, sha512, hmac_sha256Cryptography

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:

ValueKind
math:PI, math:ENumbers
io:stdin, io:stdout, io:stderrStreams
env:var, env:args, env:argvMap-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:

RiskDefensive form
Missing map keym::get(key, default), m::contains(key)
Missing environment variableenv: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 listxs::length() > 0 before pick, first, indexing
Unparsable numbervalidate 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 way value::to_string() converts them. Defaults to "", so println() 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 println without import std:println fails with Undefined variable: println.
  • println returns a number, so calling it as the last expression of a function makes that function return the byte count rather than nil.
  • print has no default text: print() raises an arity mismatch.
  • Output is not buffered per line by the language; interleaving print to 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:

ModuleFunctionsParses into
std:jsonparse(text), generate(value)Maps, lists, strings, numbers, booleans, nil
std:yamlparse(text), generate(value)Maps, lists, strings, numbers, booleans, nil
std:tomlparse(text), generate(value)Maps, lists, strings, numbers, booleans
std:csvparse(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

UseFormat
API payloads, machine-to-machine exchangeJSON
Hand-edited configuration with comments and nestingYAML
Flat, table-oriented configurationTOML
Tabular data, spreadsheet exchangeCSV

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 value
  • generate(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.

JSONSuji
objectMap
arrayList
stringString
numberNumber
true / falseBoolean
nullnil

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.
  • nil becomes null.
  • 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

  • generate sorts 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 value
  • generate(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.

YAMLSuji
mappingMap
sequenceList
quoted or plain scalarString
numberNumber
true / falseBoolean
null or an empty valuenil
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. <<: *anchor is 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) → Map
  • generate(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.

TOMLSuji
tableMap
arrayList
array of tablesList of maps
stringString
integer / floatNumber
true / falseBoolean
date, time, datetimeString
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

  • generate sorts keys, so generated files will not preserve your map’s order.
  • nil values 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 strings
  • generate(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

  • parse returns lists, never maps: rows[0]:name is 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.
  • generate output 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:

ExportKindDescription
open(path, create = false, truncate = false)Function → StreamOpen a file for reading and writing
stdinStreamStandard input
stdoutStreamStandard output
stderrStreamStandard 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. With create = false (the default) a missing file raises Stream 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.

MethodReturnsDescription
read(chunk_kb = 8)String or nilRead up to chunk_kb kilobytes from the current position; nil at end of input
read_line()String or nilRead one line without its newline; nil at end of input
read_all()StringRead everything from the current position to end of input; "" at end of input
read_lines()ListRead the rest of the stream and split it into lines; [] at end of input
write(text)NumberWrite text, returns the number of bytes written
is_terminal()BooleanWhether the stream is attached to a terminal
close()nilClose the stream
to_string()StringDisplay 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:open never appends; use read-concatenate-rewrite as shown above.
  • Any method call on a closed stream raises Stream error: Operation on closed stream.
  • read_all() on io:stdin blocks 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 [], and read(n) / read_line() return nil.
  • Streams are not iterable and have no length(); call read_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:

ExportKindDescription
varMap-like objectThe live process environment
argsMapCommand-line arguments, keyed by "0", "1", …
argvMapThe 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:

MethodReturnsDescription
get(name, default = nil)String or defaultSafe read
contains(name)BooleanWhether the variable is set
keys()ListAll variable names
values()ListAll variable values
to_list()List(name, value) tuples
length()NumberNumber of variables
delete(name)BooleanUnset a variable
merge(map)nilSet 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:args as 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:args and env:argv are 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; get is the safe form.
  • args and argv are 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:

FunctionReturnsDescription
name()String"linux", "darwin" or "windows"
hostname()StringHost name
uptime_ms()NumberMilliseconds since the machine booted
tmp_dir()StringTemporary directory
home_dir()StringCurrent user’s home directory
work_dir()StringCurrent working directory
pid()NumberProcess id
ppid()NumberParent process id
uid()NumberUser id
gid()NumberGroup id
exit(code)Terminate the process with code
mkdir(path, create_all = true)nilCreate a directory
rm(path)nilDelete a file
rmdir(path)nilDelete an empty directory
stat(path, follow_symlinks = false)MapFile 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`.

Returns a map of metadata. follow_symlinks = false (the default) describes the link itself; passing true describes the target.

FieldTypeDescription
sizeNumberSize in bytes
is_directoryBooleanWhether the path is a directory
is_symlinkBooleanWhether the path is a symbolic link
mtimeNumberLast modification time, epoch milliseconds
atimeNumberLast access time, epoch milliseconds
ctimeNumberCreation time, epoch milliseconds
linkString or nilSymlink target, nil when not a symlink
inodeNumberInode number (0 on Windows)
modeNumberRaw Unix mode bits (file attributes on Windows)
uidNumberOwning user id (0 on Windows)
gidNumberOwning 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:stat on a missing path terminates the program with Invalid operation: Failed to stat '<path>'.
  • os:rm refuses directories and os:rmdir refuses 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, gid and inode are 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:

FunctionReturnsDescription
is_abs(p)BooleanWhether p is an absolute path
join(parts)StringJoin the parts of one list into a path
dirname(p)StringEverything before the final component
basename(p)StringThe final component
extname(p)StringThe extension, including the leading dot
normalize(p)StringCollapse . 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

  • join takes one list, not a variable number of arguments.
  • normalize never consults the filesystem, so it cannot resolve symlinks and cannot turn a relative path into an absolute one; prefix os:work_dir() yourself when you need that.
  • extname includes the dot, so comparisons are against ".json", not "json".
  • Separators follow the host platform, so hard-coding / in a joined path defeats the point of join.

See Also

Random Numbers (std:random)

Random numbers, selections, shuffling, and random string generation.

Overview

std:random exports:

FunctionReturnsDescription
random()NumberA value in ([0, 1))
seed(value = nil)nilSeed the generator; nil reseeds from the system
integer(a, b)NumberAn integer in ([a, b))
pick(xs)AnyOne element of xs
shuffle(xs)ListA new list with the elements reordered
sample(xs, k)ListUp to k distinct elements of xs
string(allowed_chars, length)StringCharacters drawn from allowed_chars
hex_string(length = 16)StringLowercase hex characters
alpha_string(length = 16, capitals = true)StringLetters
numeric_string(length = 16)StringDigits
alphanumeric_string(length = 16, capitals = true)StringLetters 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) excludes b.
  • pick on an empty list is a runtime error rather than nil.
  • shuffle returns a new list, so random: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:

FunctionReturnsDescription
now()Map {epoch_ms, iso, tz}The current instant
sleep(ms)nilBlock for ms milliseconds
parse_iso(text)Map {epoch_ms, tz}Parse an ISO 8601 / RFC 3339 timestamp
format_iso(epoch_ms, tz = "Z")StringFormat 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:

FieldTypeDescription
epoch_msNumberMilliseconds since the Unix epoch
isoStringUTC timestamp with milliseconds, e.g. 2023-11-10T15:30:00.000Z
tzStringAlways "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():tz is always "Z"; the module never reports the machine’s local timezone.
  • format_iso shifts the displayed wall clock for an offset but always describes the same instant.
  • sleep requires 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:

FunctionReturnsDescription
v4()StringA random UUID
v5(namespace, name)StringA deterministic UUID derived from a namespace and a name
is_valid(text)BooleanWhether 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 UUID
  • name (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

  • v4 values are random per call; do not use one as a cache key for the same logical thing across runs — use v5 for that.
  • v5 requires a valid namespace UUID; anything else is a runtime error.
  • is_valid checks shape only, not the version or variant bits.
  • These are not secrets; use std:crypto for 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:

FunctionDescription
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_encode escapes 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:

ExportKindDescription
PINumberπ
ENumberEuler’s number
sin(x)FunctionSine of x radians
cos(x)FunctionCosine of x radians
tan(x)FunctionTangent of x radians
asin(x)FunctionArcsine, radians; domain [-1, 1]
acos(x)FunctionArccosine, radians; domain [-1, 1]
atan(x)FunctionArctangent, radians
atan2(y, x)FunctionArctangent of y / x, quadrant-aware
log(x)FunctionNatural logarithm; domain x > 0
log10(x)FunctionBase-10 logarithm; domain x > 0
exp(x)Functione 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 expectUse 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 overflowmath: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:pi is an undefined variable.
  • ^ requires an integer exponent, so use x::sqrt() rather than x ^ 0.5.
  • x::sqrt() on a negative number raises Invalid operation: Square root of negative number.
  • Domain and overflow errors terminate the program; check inputs first.
  • There is no NaN and no Infinity, 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.

FunctionDigestHex length
md5(text)128-bit32
sha1(text)160-bit40
sha256(text)256-bit64
sha512(text)512-bit128
hmac_sha256(key, text)256-bit64

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:

LineResult
KEY=valueSets KEY to value; both sides are trimmed
(empty)Ignored
# commentIgnored
KEY=Ignored — an empty value is skipped
KEYIgnored — 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; use env:var::get(name, default).
  • load mutates 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

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

  1. Browse examples by category
  2. Read the overview and prerequisites
  3. Study the complete code
  4. Review the step-by-step explanation
  5. Try the exercises
  6. Experiment with variations

See Also

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 | 1 matches 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 from 0 to n - 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 ${...}
  • println comes from std: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

  1. Return the Fibonacci sequence as a list instead of printing it
  2. Find the first Fibonacci number greater than 1000
  3. Sum the first 10 Fibonacci numbers with ::sum()

Intermediate

  1. Rewrite fib_iterative so it returns both the value and the number of loop iterations (return a, count)
  2. Write is_fibonacci(n) that reports whether a number appears in the sequence
  3. Add a cache::length() counter to the memoized version to show how many values were computed

Advanced

  1. Implement matrix-based Fibonacci (O(log n)) using lists of lists
  2. Build a generator-style closure that returns the next Fibonacci number on each call
  3. 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 :
  • rest contains 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
  • filter is eager and returns a new list, so rest is 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) - filter and + 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

  1. Sort a list of strings alphabetically, then reverse the result with ::reverse()
  2. Sort in descending order by swapping the two filter predicates
  3. Count comparisons by incrementing a counter captured from the enclosing scope

Intermediate

  1. Implement median-of-three pivot selection
  2. Add an ascending parameter that chooses the comparison direction
  3. Print each partition step to visualise the recursion

Advanced

  1. Implement dual-pivot quicksort
  2. Fall back to insertion sort for sublists shorter than 8 elements
  3. 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: double first, then increment, then square
  • 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 << g means “g then f

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:mathmath 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

  1. Create a pipeline that doubles a number, adds 10, then halves it
  2. Compose string functions into a slug maker (lowercase, spaces to hyphens)
  3. Build a validation chain for passwords (length, digit, symbol)

Intermediate

  1. Write compose_all(list_of_functions) that folds a list into one function
  2. Make a composition that returns (value, error) tuples instead of nil
  3. Build a sanitisation pipeline for untrusted user input

Advanced

  1. Add tracing: wrap each stage so it prints its input and output
  2. Build a pipeline whose stages are chosen from a configuration map
  3. 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 regex replace and no regex split; combine matching with split, index_of and 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 with s ~ /^\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:args is a value: env:args::get("1", ""), never env:args() or env: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 --help or --version to imitate.
  • Positional arguments are broken in 0.1.22: every argument overwrites key "0", so contains("1") is always false and "0" holds the last argument rather than the script path. See std: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

  1. Create a file size reporter using os:stat(path):size
  2. Build a grep clone that filters stdin against a regex
  3. Make a utility that renames every .txt file in a directory to .md

Intermediate

  1. Implement a CSV column selector driven by a COLUMNS environment variable
  2. Build a markdown to HTML converter that reads stdin and writes stdout
  3. Add a --verbose-style toggle using an environment variable (VERBOSE=1)

Advanced

  1. Build a log analyzer that groups entries by level and prints a summary table
  2. Create a file synchronisation tool on top of rsync invocations
  3. 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 -f before 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:stdin reads with is_terminal() so the tool never hangs

DON’T:

  • Call env:args() — it is a value, not a function
  • Rely on --flag arguments; 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:

Data Transformation

Transform data between formats:

Configuration Management

Manage application configuration:

Working with APIs

HTTP and API integration:

Text Processing

String manipulation and regex:

Scripting Tasks

Automation and workflows:

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

  1. Browse by Category - Find recipes related to your task
  2. Copy and Adapt - Start with working code, modify for your needs
  3. Understand the Pattern - Learn the underlying approach
  4. 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, for or while — Suji has none of them
  • Rely on truthiness; &&, || and ! require real booleans
  • Read a huge file with read_all() when read_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:

  1. File Processing - Working with files and directories
  2. Data Transformation - Converting and filtering data
  3. Configuration - Managing app configuration
  4. APIs - HTTP requests and API integration
  5. Text Processing - String manipulation and regex
  6. Scripting - Automation and workflows

See Also

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

  • curl output is a string with the trailing newline trimmed; parse structured responses with std:json:parse, std:yaml:parse, etc.
  • Only stdout is captured. Add -s to silence the progress meter, which curl writes to stderr.
  • Interpolating runtime data into a command is a shell injection risk — prefer --data @file for bodies, and percent_encode from std:encoding for query parameters.
  • There is no timeout inside Suji, so curl --max-time is 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() and read_lines() load everything
  • Check that a file exists (test -e via the shell) before os:stat or io: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:stat returns nil for a missing path — it terminates the script
  • Let a shell command that may fail run unguarded; add || true or && 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:parse keeps the header row as row 0

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 :key unless you know the key is present
  • Rely on truthiness — &&, || and ! need real booleans
  • Assume json:generate preserves key order (it sorts keys)
  • Mix types with +; "total: " + 1 is 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 -f turns a non-2xx response into exactly that, so add || true when you want to handle failure yourself.
  • Only stdout is captured, and the trailing newline is trimmed.
  • curl --max-time is 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 --flag style options. Use positional arguments or environment variables instead.
  • Positional arguments are broken in 0.1.22. Every argument overwrites key "0", so env:args always holds exactly one entry — the script path when no arguments are given, and otherwise the last argument — and env:args::contains("1") is always false. See std: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
CrateResponsibility
suji-astAST node definitions (expr.rs, stmt.rs, pattern.rs, literal.rs, function.rs)
suji-lexerScanner core plus per-construct state handlers under src/states/
suji-parserExpression and statement parsing under src/expressions/ and src/statements/
suji-valuesValue types, Env, RuntimeError, and all value methods
suji-runtimeThe Executor trait, ModuleRegistry, and the builtin registry
suji-interpreterThe default AST-walking interpreter, evaluators under src/eval/
suji-stdlibStandard library modules and builtin functions
suji-diagnosticsError codes, error builders, and the framed diagnostic emitters
suji-replThe REPL loop
suji-cliBinary entry point and argument handling

Where to Make a Change

If you are changing…Start in
Tokens, escapes, string/regex/shell scanningcrates/suji-lexer/src/token.rs and src/states/
Regex-versus-division disambiguationScannerContext::should_parse_as_regex in crates/suji-lexer/src/states/context.rs
Grammar or operator precedencecrates/suji-parser/src/expressions/ (precedence lives in binary.rs)
Evaluation semanticscrates/suji-interpreter/src/eval/
Function and method invocationcrates/suji-interpreter/src/eval/function_call.rs
A value method such as list::sortcrates/suji-values/src/methods/
Value types or RuntimeError variantscrates/suji-values/src/value/
A stdlib module or builtincrates/suji-stdlib/src/runtime/builtins/
Module resolutioncrates/suji-runtime/src/module_registry.rs
An error message, code, or suggestioncrates/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:

  1. A spec file in spec/, following the conventions in Spec Tests.
  2. A Rust test in the matching suite under tests/, described in Writing Tests.
  3. Documentation: The affected chapters of this book.
  4. Green checks: make lint and make 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 unsafe unless 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

LayerLocationQuestion it answersRunner
Rust teststests/Does this component behave correctly in isolation?make rust_tests
Spec programsspec/Does the language produce this exact output?make verify_spec
Examplesexamples/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:

SuiteCovers
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

ChangeAdd
New syntax or new semanticsA spec file and a parser or interpreter test
A new value methodA spec file and a tests/components/values/methods.rs case
A new stdlib functionA spec file and a case in the module’s tests/stdlib/ file
A lexer or parser fixA tests/components/ test asserting on tokens or AST, plus a spec file if the behaviour is user-visible
A new error or error messageA tests/components/runtime/errors.rs case asserting the variant
A bug fixA spec file that fails before the fix
A new end-to-end capabilityAn 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

RuleWhy
One assertion per fileThe runner only reads the last line of output
import std:println at the topThere is no prelude; nothing prints without it
Exactly one println(...) at the endThat line’s output is what gets compared
Expected output in a trailing # comment on that lineThe runner extracts the text after #
Two spaces before the #House style, consistent across the suite
Blank line after the importsHouse style
Blank line before the final printlnHouse style
No trailing blank lineThe runner uses tail -n 1; a blank last line means an empty expectation
Named feature_area_NN.siTwo-digit, zero-padded counter
Deterministic outputThe 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:

  1. Takes the last line of the file and strips everything up to and including the first # , giving the expected string.
  2. Runs ../target/release/suji <file>, discards stderr, takes the last line of stdout, and strips ANSI colour codes.
  3. 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

  1. Find the existing family: ls spec/ | grep <feature>.
  2. Create the next number in that sequence.
  3. Write the smallest program that exhibits the behaviour.
  4. Run it directly first — target/release/suji spec/my_feature_01.si — and paste the real output into the trailing comment.
  5. Run make verify_spec and 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 pointDirectoryPurpose
tests/components.rstests/components/One component at a time
tests/integration.rstests/integration/Whole programs through the interpreter
tests/spec.rstests/spec/Rust counterparts to the spec areas
tests/stdlib.rstests/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/ whenPut it in tests/integration/ when
You are asserting on a token streamYou are asserting on a program’s value
You are asserting on AST shapeSeveral components must cooperate
You are calling a method dispatcher directlyThe behaviour crosses lexer, parser, and interpreter
You are checking a specific RuntimeError variantYou 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:

HelperUse
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:

AreaState
Exact decimal numbers and arithmeticSettled since 0.1.9
Strings, interpolation, multiline stringsSettled
Lists, maps, tuples, and their methodsSettled
match in both formsSettled since 0.1.17
loop with through/with, labels, break/continueSettled
Lambdas, defaults, closures, multiple returnsSettled
Ranges, inclusive and descendingSettled since 0.1.18
Regex matching with ~ and !~Settled
Shell templates and | pipelinesSettled
|>, <|, >>, <<Settled since 0.1.12
Imports, exports, local file modulesSettled since 0.1.16
The standard library modules listed in the Standard Library OverviewSettled
Numeric error codes and framed diagnosticsSettled 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

GapDetail
No packagingSource builds only; nothing published to a package manager
No --version, --help, or -eThe only flag is --print-ast
No tuple indexingt[0] is a type error; destructure or use t::to_list()
No binding patterns in matchA bare identifier in a pattern is a string literal
No variadic or keyword argumentsUse a list or a map parameter
No break <value>Loops always evaluate to nil
Strings and streams are not iterableConvert with ::to_list() or ::read_lines() first
Minimal REPL:help, :quit, :exit only
No formatter or checkermake 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 return statements 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 break and continue incorrectly 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 create and truncate parameters to io: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 .env files

std:csv module

  • Added csv:parse(text, delimiter) and csv: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..=end includes both endpoints
  • Exclusive range unchanged: start..end excludes 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)

  • export now 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 >> g and f << g for 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.3 evaluate to true
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 stream for 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:FD module renamed to std:io
  • Standard streams accessed as io:stdin, io:stdout, io:stderr

Rename std:ENV to std:env:var

  • std:ENV module renamed to std: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 ENV map under std module for environment variables

List first/last default parameter

  • list::first(default=nil) and list::last(default=nil) accept an optional default value

List average method

  • Added list::average() for arithmetic mean

Stream type

  • Added stream data type for blocking I/O over file descriptors

FD streams (std)

  • Added FD value under std module 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 around std: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:yaml module with parse() and generate() functions

TOML module

  • Added std:toml module with parse() and generate() 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:json module with parse() and generate() 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 return keyword - 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 nil type 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

FormMeaning
# textLine comment, runs to end of line
newlineEnds 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 supportedWrite instead
0xFF, 0o77, 0b1010decimal digits only
1_000_0001000000
3e8, 6.626e-34300000000, 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

FormNotes
"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:

EscapeProduces
\nnewline
\ttab
\rcarriage 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

FormMeaning
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

FormMeaning
m:keyBare 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.

FormMeaning
x = exprBind or rebind
x += exprAlso -=, *=, /=, %=
x++, x--Postfix statements that mutate x
a, b = exprDestructure a tuple or multi-value return
a, _ = exprDiscard 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.

FormMeaning
|x| exprOne parameter, expression body
|x, y| { … }Block body
|| exprNo parameters
|a, b = 10| …Default parameter value
return exprExplicit return
return a, bReturn 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

SupportedExample
Number, string, boolean, nil literals1, "a", true, nil
Negative numbers-1
Regex literals/^h/
Tuple patterns(1, 2), (1, _)
Alternatives1 | 2 | 3
Wildcard_
Not supportedNote
Variable bindingA bare identifier is read as a string literal
List patterns [a, b]Use indexing after a length check
Map patternsUse m::get / m::contains in a conditional match
Range patterns 1..10Use a conditional match with comparisons
if guardsUse a conditional match
Interpolated stringsNot 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.

FormMeaning
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

FormMeaning
a..bExclusive of b
a..=bInclusive 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.

OperatorMeaning
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:

GroupOperators
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.

FormBinds
import stdstd, used as std:println(…)
import std:mathmath
import std:printlnprintln
import std:json:parseparse
import std:println as saysay
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.

LevelOperatorsAssociativityNotes
1=, destructuring a, b = …righta = b = 3 assigns 3 to both
2+= -= *= /= %=rightCompound assignment
3<|rightBackward pipe apply
4|>leftForward pipe apply
5|leftStream pipeline (shell and closures)
6>> <<leftFunction composition
7||leftBoolean or
8&&leftBoolean and
9~ !~leftRegex match / not-match
10== !=leftEquality
11< <= > >=leftRelational
12.. ..=Ranges do not chain
13+ -leftAddition, subtraction, concatenation
14* / %leftMultiplication, division, remainder
15unary - !prefixBinds looser than ^
16^rightExponentiation
17() [] :: : ++ --postfixCall, 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

OperatorLevelWhat it does
|>4Applies the left value to the right function
<|3Applies the right value to the left function
|5Connects 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.

ExpressionGroups asResult
2 + 3 * 42 + (3 * 4)14
2 * 3 ^ 22 * (3 ^ 2)18
2 ^ 3 ^ 22 ^ (3 ^ 2)512
-2 ^ 2-(2 ^ 2)-4
10 - 3 - 1(10 - 3) - 16
1 .. 3 + 11 .. (3 + 1)[1, 2, 3]
true || false && falsetrue || (false && false)true
1 + 2 |> inc(1 + 2) |> inc4
3 |> inc >> double3 |> (inc >> double)8
double <| inc <| 3double <| (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:

FailureWhat you seeExit status
Numeric overflow, including 10 ^ 16thread 'main' panicked … Multiplication overflowed101
Recursion deeper than a few hundred framesthread 'main' has overflowed its stack134

Code Ranges

RangePhaseMeaning
1xxLexingThe source could not be turned into tokens
2xxParsingThe tokens do not form a valid program
4xxRuntimeThe 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)

CodeTitleUsual cause and fix
101Unterminated string literalA quote was never closed. Check for a stray " or ', and remember that """ must be closed by """
102Unterminated shell commandA backtick template was never closed. Escape literal backticks inside strings as \`
103Unterminated regex literalA /pattern/ is missing its closing slash. Escape a literal slash inside the pattern as \/
104Invalid escape sequenceOnly \n \t \r \" \' \ \ $exist.\u0041, \0and\e` are not supported; write the character directly
105Invalid number literalNumbers are decimal digits with at most one .. Hex, octal, binary, digit separators and exponents do not exist
106Unexpected characterA character that is not part of any token, often a smart quote pasted from a document, or @/?

Parser Errors (2xx)

CodeTitleUsual cause and fix
201Unexpected tokenSomething is in a position the grammar does not allow. The classic case is a missing comma after a bare-expression match arm
202Unexpected end of inputA brace, bracket or parenthesis was never closed
203Parse errorA general parse failure that does not fit a more specific code
204Multiple export statements foundA file may contain at most one export. Merge the values into a single map
205Expected tokenA specific token was required and something else appeared, for example a missing => in a match arm
206Expected item name after :An import path ends in a colon, as in import std:
207Expected alias name after asimport 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

CodeTitleUsual cause and fix
400Type errorMixed types in an operation. "a" + 1, nil || "x", indexing a tuple, comparing a number with a string using <
402Invalid operationThe operation is not defined for these values at all, such as an unsupported unary application
410Invalid number conversion"abc"::to_number() on text that is not numeric. Validate with a regex before converting

Names and Access

CodeTitleUsual cause and fix
401Undefined variableA typo, a missing import, or a keyword from another language such as if being read as an identifier
403Index out of boundsA list index outside 0 .. length-1 (or the negative equivalent). Check xs::length() first
404Key not foundm:key or m[k] on an absent key. Use m::get(k, default) or m::contains(k)
405Invalid key typeMap keys must be numbers, booleans, strings or tuples. Lists and maps cannot be keys
412String index errorA character index outside the string. Check s::length() first
413Range errorRange bounds must be integers, and very large ranges allocate a full list

Calls and Control Flow

CodeTitleUsual cause and fix
408Arity mismatchWrong number of arguments. Give the parameter a default value if it should be optional
409Method errorThe method does not exist for that type. Check the spelling and the type’s method list
411Internal control flow errorA break, continue or return escaped its construct. Usually indicates a bug worth reporting
416Conditional match errorAn arm of a match { … } did not evaluate to a boolean
426Map method errorA map method was called with the wrong arguments, for example m::get() with no key

Collections and Destructuring

CodeTitleUsual cause and fix
414List concatenation error+ was used between a list and a non-list. Use xs::push(v) to add one item
415Map contains errorm::contains(k) was called with a key of an unusable type
434Destructuring type errora, b = value where the right side is not a tuple
435Destructuring arity mismatchThe number of targets does not match the tuple size. Use _ to discard a position
436Invalid destructuring targetA destructuring target is not assignable

Pipes

CodeTitleUsual cause and fix
429Pipe stage type errorA | stage is neither a closure call nor a shell template
430Empty pipe expressionA | has nothing on one side
431Pipe execution errorA stage of a | pipeline failed while running
432Pipe apply type error|> has a non-function on the right
433Pipe apply type error<| has a non-function on the left

System, Streams and Regex

CodeTitleUsual cause and fix
406Shell command failedA backtick command exited non-zero. Neutralise it with `cmd || true` or make the command print a value you can match on
407Regex errorThe pattern could not be compiled, or ~ was applied to something other than string ~ regex
427Stream errorA read or write on a closed, missing or unreadable stream

Data Formats

CodeTitleUsual cause and fix
417JSON parse errorMalformed JSON text
418JSON generation errorThe value contains something JSON cannot hold, such as a function or a regex
419YAML parse errorMalformed YAML text, usually indentation
420YAML generation errorThe value contains something YAML cannot hold
421TOML parse errorMalformed TOML text
422TOML generation errorTOML has no nil, and functions and regex cannot be written
423TOML conversion errorA TOML key was not a string, or a nil appeared in the value
424CSV parse errorUnclosed quotes or an unusable delimiter
425CSV generation errorcsv:generate expects a list of lists of strings; convert numbers first
428Serialization errorA 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

Rangea..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:

PathContents
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:

PathContents
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
MakefileEvery 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

CommandWhat it does
make buildDebug build
make releaseOptimised build, producing target/release/suji
make testRust tests, then spec verification, then examples
make rust_testsRust unit and integration tests only
make verify_specRuns every spec/*.si and compares against its expected output
make verify_examplesRuns every examples/*.si and checks it exits cleanly
make lintcargo clippy --all-targets plus cargo fmt --check
make helpLists 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:

  1. Installation and Quick Start — get a working binary and run something.
  2. Language Overview — the shape of the language in one sitting.
  3. Data Types — one decimal number type, and the collections.
  4. Control Flowmatch and loop are the whole story.
  5. Functions — lambdas, closures, and multiple returns.
  6. Operators — especially the three pipe families.
  7. Modules — imports, exports, and resolution.
  8. Standard Library Overview — what you can import.
  9. Examples and Cookbook — complete programs.
  10. 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.

See Also