Skip to content

Rad Language Reference

This document provides a comprehensive overview of Rad's syntax for quick reference in development sessions.

Script Structure

Basic Script Format

#!/usr/bin/env rad
---
Script description goes here.
Multi-line descriptions are supported.
---

// Optional arguments section
args:
    name str              # Required string argument
    count int = 5         # Optional with default value
    verbose v bool        # Boolean flag (can use short form)

// Script body
print("Hello {name}!")

File Header for Automatic Help Generation

The file header automatically generates the help text shown when users run your script with -h or --help. This is a core Rad feature that eliminates the need to manually write help documentation.

---
Script description that appears in --help.

Detailed explanation of what the script does.
Can be multiple paragraphs.

@stash_id = my_script_data
@enable_global_options = true  
@enable_args_block = true
---

Special @ Macros

  • @stash_id - Sets the stash identifier for the script
  • @enable_global_options - Enable/disable global Rad options (default: true)
  • @enable_args_block - Enable/disable argument parsing (default: true)

Static Analysis

Use rad check <script> to statically analyze your scripts for errors before running them. This catches syntax errors, type mismatches, and other issues without executing the script.

rad check my_script.rad

Comments

// Code comments use double slash
// Multi-line code comments use multiple // lines
// like this

// In args blocks, argument descriptions use # for help text generation:
args:
    name str    # This comment appears in --help usage
    count int   # This also appears in --help

Data Types

Primitives

// Strings
name = "alice"
path = 'path/to/file'

// Multi-line strings (triple quotes)
text = """
Hello world
How are you?
"""

// Numbers
age = 25                   // int
height = 5.9               // float
scientific = 1.23e4        // exponential notation
large_num = 1_234_567      // underscore separators for readability
float_with_sep = 123.456_789

// Booleans
is_valid = true
is_empty = false

// Null
value = null

Collections

// Lists
numbers = [1, 2, 3, 4, 5]
mixed = [1, "hello", true, [1, 2]]
empty_list = []

// Maps/Objects
person = {"name": "alice", "age": 25}
nested = {"user": {"name": "bob", "roles": ["admin", "user"]}}
empty_map = {}

Multi-line Strings (Triple Quotes)

// Basic multi-line string
text = """
Hello world
How are you?
"""
// Contents: "Hello world\nHow are you?" - the newlines adjacent to the
// opening and closing """ are excluded, so the string does NOT end with
// a newline. Add a blank last line (or concatenate "\n") if you need one,
// e.g. when assembling scripts or files from fragments.

// Indentation control - closing """ position determines whitespace stripping
indented = """
  Line 1
   Line 2
     Line 4
 """
// Result: " Line 1\n  Line 2\n    Line 4" (1 space stripped from each line)

// String interpolation works in multi-line strings
name = "alice"
message = """
Hello {name}!
How are you today?
"""

// Raw multi-line strings (no interpolation or escape processing)
raw_text = r"""
Literal \n and {name}
No processing here
"""

// Comments allowed after opening triple quotes
documented = """ // This is allowed
Content starts on next line
Always on next line
"""

Multi-line String Rules: - Content must start on a new line after opening """ - The indentation of the closing """ determines how much leading whitespace is stripped from each line - Support string interpolation with {variable} syntax unless raw (r""") - Support escape sequences (like \n, \t) unless raw - Comments can follow the opening """ on the same line - Cannot have content on the same line as the opening """

Raw Strings

// Raw strings: no interpolation, no escape processing
regex = r"\d{2}:\d{2}"            // stays literally \d{2}:\d{2}
win_path = r"C:\Users\notes.txt"
single = r'single quotes work'
ticks = r`backticks too`

Raw String Rules: - Prefix any string delimiter with r: r"...", r'...', r`...`, or multi-line r"""...""" - Contents are taken literally: no {interpolation} and no escape sequences - Handy for regexes, Windows paths, and text full of braces

Variables and Assignment

Basic Assignment

name = "alice"
age = 25

Multiple Assignment

a, b = 1, 2
x, y = some_function_returning_tuple()

Compound Assignment

count = 5
count += 1              // count = 6
count -= 2              // count = 4
count *= 3              // count = 12
count /= 4              // count = 3

Increment/Decrement

i = 0
i++                     // i = 1
i--                     // i = 0

String Interpolation

name = "alice"
age = 25
message = "Hello {name}, you are {age} years old!"

// Works with expressions
total = "Result: {x + y}"

// Format specifiers
price = 123.456
formatted = "Price: {price:.2}"          // "Price: 123.46" (2 decimal places)
padded = "Name: {name:<10}"              // "Name: alice     " (left-aligned, padded to 10)
right_aligned = "Name: {name:>10}"       // "Name:      alice" (right-aligned)

// Thousands separators (numbers only)
big_num = 1234567
formatted = "{big_num:,}"                // "1,234,567"
with_decimals = "{big_num:,.2}"          // "1,234,567.00"
padded_thousands = "{big_num:>15,}"      // "      1,234,567"

Braces in Strings

A { opens an interpolation. Rad never silently re-reads a broken interpolation as ordinary text, so a brace you meant literally either falls under one of the narrow exemptions below or has to be escaped.

  1. {expr}, optionally with a format specifier, interpolates.
  2. A { is literal where an interpolation is impossible: the string ends immediately after it, or the {...} pair holds only ASCII spaces or tabs. Other Unicode whitespace does not count - { followed by U+00A0 is an error, not a literal brace.
  3. A } with no interpolation open is literal.
  4. \{ and \} are literal braces. A raw string (r"...") disables interpolation and escapes for the whole string.
  5. Anything else that opens like an interpolation and isn't one is an error.
print("{")             // {        -- string ends after the brace
print("{}")            // {}       -- pair holds nothing
print("{ }")           // { }      -- pair holds only whitespace
print("}")             // }        -- no interpolation open
print("\{a\}")         // {a}      -- both escapes
print(r"\d{4}")        // \d{4}    -- raw string, nothing interpolates

The rule that catches people is the regex quantifier. "\d{4}" is a valid interpolation of the number 4, so it produces \d4:

pattern = "\d\{4}"     // \d{4}   -- escape the brace
pattern = r"\d{4}"     // \d{4}   -- or use a raw string

Rad warns (RAD40017) when an interpolation holds a literal, since that is almost always this mistake.

Exemption 2 is deliberately narrow. "{name" stays an error rather than printing itself, because a forgotten closing brace should be loud.

An interpolation must not open with a same-delimiter string, since "{" is then indistinguishable from a literal brace at the end of a string. Reach for a different quote type:

print("{'ERROR'.red()}")     // use a different delimiter
print("{ "ERROR".red() }")   // or separate the braces from the quotes
print("{"ERROR".red()}")     // error - reads as the literal brace "{"

Ternary Operator

// Basic ternary syntax
result = condition ? value_if_true : value_if_false

// Examples
status = age >= 18 ? "adult" : "minor"
message = count == 1 ? "1 item" : "{count} items"
max_value = a > b ? a : b

// Can be chained
category = age < 13 ? "child" : age < 18 ? "teen" : "adult"

Collection Access and Slicing

Indexing

items = [10, 20, 30, 40, 50]
first = items[0]        // 10
last = items[-1]        // 50

person = {"name": "alice", "age": 25}
name = person["name"]   // "alice"

// Fallback for out-of-bounds or missing keys with ??
value = items[100] ?? "not found"       // "not found" (index out of bounds)
role = person["role"] ?? "guest"        // "guest" (key not found)
char = "hello"[99] ?? "?"               // "?" (string index out of bounds)

Slicing

items = [10, 20, 30, 40, 50]
subset = items[1:3]     // [20, 30]
from_start = items[:3]  // [10, 20, 30]
to_end = items[2:]      // [30, 40, 50]
all_items = items[:]    // [10, 20, 30, 40, 50]

// Negative indices
end_items = items[-2:]  // [40, 50]

String Slicing

text = "hello"
substring = text[1:4]   // "ell"

Control Flow

If Statements

if age >= 18:
    print("Adult")
else if age >= 13:
    print("Teen")
else:
    print("Child")

// Single condition
if is_valid:
    process_data()

Switch Statements

// Expression switches (single values)
result = switch value:
    case "a" -> "Apple"
    case "b" -> "Banana"
    default -> "Unknown"

// Multiple cases
status = switch code:
    case 200, 201, 204 -> "Success"
    case 400, 401, 403 -> "Client Error"
    case 500, 502, 503 -> "Server Error"
    default -> "Unknown"

// Multi-assignment from switch
a, b = switch condition:
    case true -> 10, 20
    case false -> 30, 40

// Block switches (multiple statements)
switch value:
    case "a":
        print("Found A!")
        do_something()
    case "b", "c":
        print("Found B or C!")
        do_something_else()
    default:
        print("Unknown value")

// Mixed syntax in same switch with yield
result = switch value:
    case 1:
        complex_processing()
        yield calculated_result    // Return value from case block
    case 2 -> simple_result
    default -> fallback_value

// Yield multiple values
a, b = switch condition:
    case "pair":
        calculate_values()
        yield first_result, second_result
    case "single" -> default_value, 0

Loops

For Loops

// Iterate over list
items = ["a", "b", "c"]
for item in items:
    print(item)

// Access loop index and total via context
for item in items with loop:
    print("{loop.idx + 1}/{loop.src.len()}: {item}")

// Iterate with unpacking (for lists of lists)
data = [["alice", 25], ["bob", 30], ["charlie", 35]]
for name, age in data:
    print(name, age)

// Unpacking WITH context
for name, age in data with loop:
    print(loop.idx, name, age)

// Multiple variable unpacking with zip
names = ["alice", "bob", "charlie"]
ages = [25, 30, 35]
cities = ["NYC", "LA", "Chicago"]
for name, age, city in zip(names, ages, cities):
    print(name, age, city)

// With context
for name, age, city in zip(names, ages, cities) with loop:
    print(loop.idx, name, age, city)

// Iterate over map keys
person = {"name": "alice", "age": 25}
for key in person:
    print(key, person[key])

// Map iteration with context
for key in person with loop:
    print(loop.idx, key, person[key])

// Map with key and value
for key, value in person:
    print(key, value)

// Map with key, value, and context
for key, value in person with loop:
    print(loop.idx, key, value)

// Range iteration
for i in range(5):      // 0, 1, 2, 3, 4
    print(i)

Loop Context Object

When you use with <identifier> in a for-loop, you get access to a context object with these fields:

  • idx - Current iteration index (0-based)
  • src - Immutable snapshot of the original collection
items = [10, 20, 30]
for item in items with loop:
    print("Index: {loop.idx}, Item: {item}, Total: {loop.src.len()}")

While Loops

// Basic while loop
count = 0
while count < 5:
    print(count)
    count++

// Infinite loop with break
while:
    if condition:
        break
    // do something

// Continue statement
while condition:
    if skip_condition:
        continue
    process_item()

Additional Control Flow

Delete Statement

// Delete variables or data structures
my_var = "test"
del my_var              // Remove variable from scope

my_list = [1, 2, 3, 4]
del my_list[0]         // Remove first element

my_map = {"a": 1, "b": 2}
del my_map["a"]        // Remove key "a"

Return Statement

fn calculate(x, y):
    if x < 0:
        return error("negative values not allowed")
    result = x * y
    return result      // Return single value

fn get_coordinates():
    x = 10
    y = 20
    return x, y        // Return multiple values

fn early_exit():
    if condition:
        return         // Early return with no value
    do_more_work()

Yield Statement

// Yield can only be used in switch case blocks to return values
// (Examples shown in Switch Statements section above)

// NOT valid - yield cannot be used in regular functions
// fn generate_values():
//     yield i        // This would be an error

Functions

Function Definition

// Function definitions use fn name(): syntax
fn double(x):
    return x * 2

fn add(a, b):
    return a + b

fn greet():
    print("Hello!")

// Block functions
fn calculate(x, y):
    result = x * y + 10
    return result

// Function with multiple return values
fn coords(point):
    x = point["x"]
    y = point["y"]
    return x, y

// Anonymous functions can be assigned to variables
double_var = fn(x) x * 2
add_var = fn(a, b) a + b
greet_var = fn() print("Hello!")

Function Calls

result = double(5)      // 10
sum_val = add(3, 4)     // 7

// UFCS (Uniform Function Call Syntax)
"hello".upper()         // "HELLO"
[1, 2, 3].len()        // 3

// You're encouraged to use UFCS, especially to un-nest function calls.
upper(trim(text))    // BAD
text.trim().upper()  // GOOD

// Built-in function assignment to variables
my_upper = upper
"test".my_upper()      // "TEST"

Named Arguments

print("hello", "world", sep="|")    // hello|world

List Comprehensions

// Basic comprehension
numbers = [1, 2, 3, 4, 5]
squares = [x * x for x in numbers]

// With function calls
words = ["hello", "world"]
uppers = [upper(word) for word in words]

// Side effects (returns empty list)
[print(x) for x in items]

Argument Parsing

Basic Arguments

args:
    name str                    # Required string
    age int                     # Required integer
    height float               # Required float
    verbose bool               # Required boolean

    // Optional arguments
    role str?                  # Optional string (null if not provided)
    count int = 10            # Optional with default
    debug d bool              # Short form flag

Int Short Clustering

Int arguments with short forms can be "clustered" to increment the value:

args:
    verbosity v int
./script -v        # verbosity = 1
./script -vv       # verbosity = 2
./script -vvvvv    # verbosity = 5
./script -vv -v=10 # verbosity = 10 (explicit value overrides)

This is useful for verbosity levels and similar patterns where users want quick increment syntax.

Argument Constraints

args:
    status str
    age int
    email str
    username str?
    password str?

    status enum ["active", "inactive", "pending"]
    age range [0, 120]        // Inclusive range
    age range (0, 120]        // Exclusive start, inclusive end
    email regex ".*@.*\\..*"
    username requires password   // If username provided, password required

Relational constraints: - a requires b - a mutually requires b - a excludes b - a mutually excludes b

Script Commands

Organize CLI tools around subcommands, like git commit or docker build. The args: and command blocks must appear before any executable code.

#!/usr/bin/env rad
---
A deployment tool.
---

// Shared args available to all commands
args:
    verbose v bool
    config str = "config.yaml"

command deploy:
    ---
    Deploy the application.
    ---
    env str
    calls do_deploy

command status:
    ---
    Check deployment status.
    ---
    env str
    calls do_status

// Shared setup - runs before any command callback
validate_config(config)

fn do_deploy():
    if verbose:
        print("Config: {config}")
    print("Deploying to {env}...")

fn do_status():
    print("Status of {env}...")

Usage: ./tool.rad deploy staging --verbose

Command Name Hyphenation

Command names follow the same underscore-to-hyphen convention as arguments. Define with underscores, invoke with hyphens:

command deploy_staging:
    calls do_deploy_staging

// Invoked as: ./tool.rad deploy-staging

Inline Callbacks

// Short implementations can use inline lambdas
command greet:
    name str
    calls fn():
        print("Hello, {name}!")

Nested Commands

Group related commands under a namespace, like git remote add. A command that contains other commands is a namespace: it has no calls of its own and routes to its sub-commands instead. Args declared on it are shared - inherited by every descendant, and accepted anywhere on the path.

command remote:
    ---
    Manage remotes.
    ---
    timeout int = 30        // shared with every sub-command

    command add:
        name str
        url str
        calls do_add

    command remove:
        name str
        calls do_remove

fn do_add():
    print("Adding {name} -> {url} (timeout {timeout})")

fn do_remove():
    print("Removing {name}")

./tool.rad remote add origin https://... --timeout 5 runs do_add, and --timeout is equally valid before the sub-command. Namespaces nest as deep as you like; only the terminal command's calls runs.

Default Commands

Mark one command per level default and it runs when the user names none, so a tool's primary action doesn't have to be typed:

command add:
    ---
    Add a reminder.
    ---
    *tokens str
    default
    calls do_add

command list:
    ---
    Show upcoming reminders.
    ---
    calls do_list

fn do_add():
    print("Adding {tokens}")

fn do_list():
    print("Listing")

./tool.rad 15m standup and ./tool.rad add 15m standup both run do_add.

A named command always wins, so a value that happens to match one goes to that command instead. Two ways to say you meant the value: name the default explicitly, or use --.

./tool.rad list            # runs do_list
./tool.rad add list        # adds a reminder saying "list"
./tool.rad -- list         # same

The rule applies at every level, so a namespace's default is what ./tool.rad remote runs. Only leaf commands can be marked - a namespace routes rather than runs, so marking one would leave the question unanswered.

Automatic Help

  • ./tool.rad -h — lists all commands with descriptions
  • ./tool.rad deploy -h — shows command-specific arguments
  • ./tool.rad remote -h — lists a namespace's sub-commands and shared args

When a default exists, -h marks it and shows a second usage line spelling out the shorter invocation it enables.

Shell Commands

Invocation ($) — critical by default

Shell commands can be invoked with $ and are critical by default: if the exit code != 0, an error will propagate unless you handle it. If an error propagates up to the root level of the script and doesn't get handled, it exits.

Note on signals: Rad shares its process group with subprocesses, so Ctrl+C reaches them too. The subprocess typically dies before Rad's signal handler runs. See Signal Handling for details.

// Fails script if code != 0
$`make build`
stdout = $`cmd`
stdout, stderr = $`cmd`
stdout, stderr, code = $`cmd`

$ takes the command and nothing more: a command literal, a list, a variable holding a command string, or a parenthesized expression. Anything written after it reads the command's result (see Reading one result inline), so a computed command goes in parentheses:

cmds = [`ls -la`, `ls -l`]
parts = ["git", "status"]

$(cmds[1])
$(parts.join(" "))

Capture & assignment

Capture behavior depends on how many variables you assign. A stream you don't capture goes to the terminal; a stream you do capture goes only to your variable.

$`cmd`                        // No capture, stdout/stderr → terminal
stdout = $`cmd`               // Capture stdout, stderr → terminal
stdout, stderr = $`cmd`       // Capture both
stdout, stderr, code = $`cmd` // Capture both, plus the exit code

Assignment semantics support both positional and named assignment.

  • Positional targets are filled (stdout, stderr, code) in that order. The exit code is last: it needs no capturing, and a non-zero exit already raises a catchable error, so it's the value you reach for least.
  • Positional (default): when variable names are arbitrary, assignment is by position.
out = $`cmd`                        // out = stdout
out, err = $`cmd`                   // out = stdout, err = stderr
result, errors, status = $`cmd`     // result = stdout, errors = stderr, status = code
  • Named: if all variables are exactly code, stdout, or stderr, assignment is by name (order independent).
stdout, code = $`cmd`     // stdout = stdout, code = code
stderr = $`cmd`           // only capture stderr
code, stderr = $`cmd`     // capture code and stderr
code = $`cmd`             // only way to get the code without capturing a stream

Rule: If all variables use the exact names code, stdout, or stderr, uses named assignment. Otherwise, assignment is positional.

The two rules agree on the canonical order, so stdout = $cmd and stdout, stderr = $cmd mean the same thing read either way. They disagree when you mix reserved and unreserved names, because one unreserved name makes the whole statement positional:

code, output = $`cmd`     // 'code' gets stdout! Warns: RAD40018

Reading one result inline

A shell command can be used as an expression when it's immediately followed by one of four accessors: .stdout, .stderr, .code, or .ok.

branch = $`git branch --show-current`.stdout.trim() catch "unknown"

if $`git status --porcelain`.stdout.trim():
    print_err("Uncommitted changes!")

if not $`which docker`.ok:
    exit(1)
Accessor Type Fails on non-zero exit
.stdout str yes
.stderr str yes
.code int no
.ok bool no

Capture follows the accessor, exactly as it follows target names in a named assignment: .stdout captures stdout and lets stderr through to the terminal, .stderr the reverse, and the two status accessors capture nothing.

The output accessors fail, the status accessors don't. .stdout and .stderr propagate a non-zero exit and compose with ??, catch and catch:. .code and .ok never propagate - using them is handling the failure, which is what makes them the right way to test a command. Note this differs from statement-form code = $cmd, which still propagates.

There is no truthiness. A command with no accessor is a check-time error (RAD40025), because shell's if cmd (exit code) and if $(cmd) (printed anything) are different questions that look alike.

One value inline. The expression form never gives you both streams from one run; use assignment when you need more than one.

quiet and confirm work here too, and the ⚡️ echo stays on - under and / or short-circuit it shows which commands actually ran.

tag = quiet $`git describe --tags`.stdout.trim() catch "untagged"

Handling failures with a catch block

Use a suffix catch: block to handle non‑zero exit codes. Variables are already assigned to the actual results inside the block; you may log or reassign fallbacks, then execution continues.

code, stdout = $`grep hello file` catch:
    // Runs because code != 0
    print_err("grep failed with code {code}: {stdout}")
    stdout = ""  // example fallback

You can also attach catch: without capturing any variables:

$`make build` catch:
    pass  // continue on failure (do nothing)

$`make build` catch:
    print_err("Build failed")
    exit(1)

Shell Command Modifiers

Two modifiers control shell command behavior:

// quiet - suppresses the ⚡️ command echo
quiet $`make build`
code, stdout = quiet $`grep pattern file` catch:
    stdout = ""

// confirm - prompts user approval before running (useful for destructive ops)
confirm $`rm -rf node_modules`

Modifiers go after = in assignments: result = quiet $cmd, not `quiet result = $`cmd.

JSON Processing and Display Blocks

JSON Path Definitions

// Define JSON field mappings
Name = json.results[].name
Email = json.results[].email
Age = json.results[].age

// JSON path with wildcards
AllFields = json.results[].*         // All fields in each result
DeepFields = json.data.*.value       // Wildcard in path
Indexed = json.items[0].name         // Specific index

Rad Blocks

// Rad blocks do HTTP request + JSON extraction + print table (all-in-one)
url = "https://api.example.com/users"
Name = json.results[].name
Email = json.results[].email

rad url:
    fields Name, Email
    sort Name
    // Automatically prints formatted table after extraction

Rad Blocks Without URL Source

// Rad block with no source: assumes lists are already populated, prints as table
names = ["alice", "bob", "charlie"]
ages = [25, 30, 35]

rad:
    fields names, ages
    sort ages desc, names

// Rad block with list/map source: runs JSON extraction + prints table
data = [
    {"name": "alice", "age": 25},
    {"name": "bob", "age": 30}
]

Name = json[].name
Age = json[].age
rad data:
    fields Name, Age
    sort Age desc, Name

Field Modifiers

Field modifiers transform or filter data before display. Apply them to specific fields:

ages = [15, 25, 30, 12]
names = ["Alice", "Bob", "Charlie", "David"]

rad:
    fields ages, names
    ages:
        filter fn(a) a >= 18       // Keep only adults
        map fn(a) "{a} years"      // Transform display

// Output:
// ages       names
// 25 years   Bob
// 30 years   Charlie

Filter removes rows where the predicate returns false. Map transforms values for display. Filter runs before map.

// Apply same modifier to multiple fields
ages, scores:
    map fn(v) "{v}!"

// Use a named function
is_adult = fn(age) age >= 18
ages:
    filter is_adult

Field Modifier Context

When a map or filter lambda accepts two parameters, the second receives a context object:

nums = [10, 20, 30]
rad:
    fields nums
    nums:
        map fn(x, ctx) "{ctx.idx + 1}. {x}"
        // Output: "1. 10", "2. 20", "3. 30"

Context fields:

Field Type Description
idx int Current row index (0-based)
src list Immutable snapshot of the full column data
field str Name of the field being processed
// Filter to values below average
nums:
    filter fn(x, ctx) x < sum(ctx.src) / ctx.src.len()

// Show position in total
nums:
    map fn(x, ctx) "{x} ({ctx.idx + 1}/{ctx.src.len()})"

Single-parameter lambdas continue to work unchanged for simple transformations.

Advanced Features

String Escape Sequences

// Supported escape sequences in strings
text = "Quote: \"Hello\""          // Escaped double quote
single = 'It\'s working'           // Escaped single quote  
backtick = `Backtick: \`command\`` // Escaped backtick
newline = "Line 1\nLine 2"         // Newline
tab = "Column1\tColumn2"           // Tab
backslash = "Path\\to\\file"       // Literal backslash
brace = "Not interpolated: \{var}" // Escaped brace (literal {)
close = "Escaped: \}"              // Escaped brace (literal })

Defer and Error Defer Blocks

// Defer block - runs before script ends regardless of success/failure
defer:
    print("This always runs before script exits")
    cleanup_resources()

// Error defer block - only runs before script ends if an error occurs
errdefer:
    print("This only runs if script exits with error")
    emergency_cleanup()

process_data()
// More code can run here
exit(0)  // defer blocks run just before this

Defer and signals: SIGINT (Ctrl+C) and SIGTERM are intercepted by Rad and routed through the normal exit path with code 128 + signal_number. This means defer blocks run on Ctrl+C, and errdefer blocks run because the exit code is non-zero. Scripts that allocate temp files or write half-output get a chance to clean up. See Signal Handling below for custom handlers beyond auto-defer.

Signal Handling

Scripts can register handlers for OS signals using signal_trap, and ignore signals at the OS level using signal_ignore. See the function reference for full details.

fn cleanup():
    print_err("removing temp files")

// Clean up on Ctrl+C
signal_trap("sigint", fn(ctx):
    print_err("Cancelled")
    cleanup()
    exit(ctx.exit_code)
)

// Don't crash when a downstream pipe consumer closes
signal_ignore("sigpipe")

// Long-running loop that responds to SIGUSR1 for status dumps
n = 0
total = 100
signal_trap("sigusr1", fn(ctx):
    print("progress: {n}/{total}")  // no exit() - script continues
)

Semantics worth knowing:

  • Handlers run between statements, not mid-statement. A signal arriving while a long computation is running waits for the current statement to finish before the handler fires.
  • After a handler returns, execution continues. To terminate, the handler must explicitly call exit(). This matches Bash, Python, Ruby, and Node.
  • An unhandled error in a handler aborts the script (exit 1), running defer/errdefer along the way - the same as an unhandled error anywhere else. Only a clean return continues execution.
  • defer runs on signal-triggered exit (whether by default behavior or by the handler calling exit()).
  • A second SIGINT during a SIGINT handler force-exits with code 130. Defer blocks do not run on this force-exit path - it's the escape hatch when a handler hangs.
  • Subprocesses started with $\cmd`` share Rad's process group, so Ctrl+C reaches both Rad and the subprocess. The Rad handler runs after the subprocess has already terminated. This is the same behavior as Bash.
  • On Windows, only sigint and sigterm are supported.

Rad Block Options

// noprint: run JSON extraction but don't print table
url = "https://api.example.com/users"
Name = json.results[].name
Email = json.results[].email

rad url:
    noprint
    fields Name, Email
    sort Name

// After this block, Name and Email contain the extracted data
print(Name)  // ["alice", "bob", "charlie"]
print(Email) // ["alice@example.com", "bob@example.com", "charlie@example.com"]

// quiet: suppress "Querying url: ..." stderr log
rad url:
    quiet
    fields Name, Email

// transpose: swap rows and columns - fields become rows, records become columns
Name = ["Alice", "Bob", "Charlie"]
Age = [30, 40, 25]
rad:
    fields Name, Age
    transpose
// Output:
//   Name  Alice  Bob  Charlie
//   Age   30     40   25

// All three options accept an explicit boolean expression:
//   noprint true / quiet false / transpose some_variable
// And can appear inside `if` blocks for conditional behavior.

Advanced Function Features

// Vararg parameters
fn sum_all(*numbers: int):
    total = 0
    for num in numbers:
        total += num
    return total

result = sum_all(1, 2, 3, 4, 5)

// Named-only parameters (after *)
fn format_text(text: str, *, uppercase: bool = false, prefix: str = ""):
    result = prefix + text
    return uppercase ? upper(result) : result

formatted = format_text("hello", uppercase=true, prefix=">>> ")

// Function-typed parameters and union returns
fn process_data(
    input: str[],
    callback: fn(str) -> bool
) -> int|error:
    matched = 0
    for s in input:
        if callback(s):
            matched += 1
    return matched

fn is_long(s: str) -> bool:
    return s.len() > 5

count = process_data(["a", "bb", "ccccc", "dddddd"], is_long)

Map Dot Syntax

person = {"name": "alice", "details": {"age": 25}}
name = person.name              // Same as person["name"]
age = person.details.age        // Nested access

Error Handling

Errors in Rad are values. Functions typically return value | error. By default, errors propagate and exit if unhandled.

Simple fallback: ??

Use ?? to provide a fallback value when the left side returns an error (right side is evaluated lazily).

result = text.parse_int() ?? 0
result = text.parse_int() ?? compute_fallback()
// Multi-return requires an explicit list fallback
a, b = fallible_pair() ?? [0, 0]

Complex handling: suffix catch:

Attach a catch: block to inspect the error (the assigned variable is the error string inside the block), log, and/or reassign a fallback. Execution continues after the block, unless it invokes exit().

value = text.parse_int() catch:
    print_err("Parse failed: {value}")
    value = 0

For multi-return functions with catch::

a, b = fallible_pair() catch:
    // a = error string, b = null
    print_err("Failed: {a}")
    a, b = 0, 0

Held errors stay live

A catch: block that doesn't reassign leaves the error in the variable. Rad doesn't track that it was handled, so returning it from another function raises it again, at that call:

fn fail() -> str|error:
    return error("boom")

fn relay(x):
    return x

stored = fail() catch:
    pass

relay(stored)               // exits
relay(stored) ?? "default"  // "default"

Assigning the value elsewhere and reading it are safe; only a call that returns it raises it. ?? and catch inspect the value the left side produced, so they fire on a held error too; a catch: block catches a failing operation, so it does not.

Operators

Arithmetic

a + b       // Addition
a - b       // Subtraction
a * b       // Multiplication
a / b       // Division
a % b       // Modulo

Comparison

a == b      // Equal
a != b      // Not equal
a < b       // Less than
a <= b      // Less than or equal
a > b       // Greater than
a >= b      // Greater than or equal

Logical

a and b     // Logical AND
a or b      // Logical OR
not a       // Logical NOT

Membership

item in collection      // Check if item exists in collection
item not in collection  // Check if item doesn't exist

For maps, in/not in test key membership, not values:

scores = {"alice": 1}
has_alice = "alice" in scores   // true - "alice" is a key
has_one = 1 in scores           // false - values aren't checked

Coalescing Operator (??)

The ?? operator yields the left value if it is not null or an error; otherwise it evaluates and yields the right-hand side. Useful for concise fallbacks when calling fallible functions or handling nullable values.

count = get_env("COUNT").parse_int() ?? 0
data = read_file("config.txt") ?? {"content": ""}
a, b = some_fallible_fn() ?? [0, 0]  // multi-return requires list fallback

Error Catching Operator (catch)

The catch operator catches errors from the left side and returns the right side instead. Unlike ??, it only catches errors - null values pass through.

count = parse_int(input_str) catch 0
data = parse_json(raw) catch {}
result = risky_call() catch fallback_call() catch "final"

catch is distinct from the catch: block syntax. The operator form is an inline expression, while catch: is a block attached to statements.

Scoping and Variables

// Global scope
global_var = "global"

if true:
    // Can access global variables
    print(global_var)

    // Variables defined in blocks persist after the block
    block_var = "accessible"

// block_var IS accessible here
print(block_var)  // Works fine

// Variables defined in for loops also persist
for i in range(3):
    loop_var = "also accessible"

print(i)          // Last value: 2
print(loop_var)   // "also accessible"

// Function closures with anonymous functions assigned to variables
outer_var = 10
fn_with_closure = fn(x):
    return x + outer_var    // Can access outer_var

Built-in Types and Methods

For a complete reference of all built-in functions, see: https://amterp.dev/rad/reference/functions/

String Methods

text = "hello world"
text.upper()            // "HELLO WORLD"
text.lower()            // "hello world" 
text.split(" ")         // ["hello", "world"]
text.replace("hello", "hi")  // "hi world"

List Methods

items = [3, 1, 4, 1, 5]
items.len()             // 5
items.sort()            // Returns sorted copy
items.reverse()         // Returns reversed copy

Type Checking

// Runtime type checking happens automatically
// Type errors will be caught during execution

Type System

Rad has a dynamic type system with runtime type checking. Here are the type annotations used in function signatures and documentation:

Basic Types

str           // String type
int           // Integer type  
float         // Float type
bool          // Boolean type
any           // Any type (dynamic)
void          // No return value
error         // Error type

Collection Types

list          // List of any type
str[]         // List of strings
int[]         // List of integers
any[]         // List of any type
map           // Map/object with any keys/values
{str: int}    // Map with specific key/value types

The T[] suffix is the only way to write a typed list. There's no list[T] form; one canonical spelling keeps things simple.

Optional Types

str?          // Optional string (can be null)
any?          // Optional any type
int?          // Optional integer

Union Types

int|float     // Either int or float
str|list      // Either string or list
error|str     // Either error or string (common for fallible operations)

Parenthesized Type Groups

Parentheses group a union so list / optional modifiers stack on top of the whole group rather than just the rightmost arm:

(int|str)[]      // List whose elements are int or str
(int|str)?       // Optional value that's int or str
((int|str)|bool)[]?  // Combinations compose freely

Without the parens, int|str[] parses as int | (str[]) - an int or a list of strings, not a list of (int|str).

Enum Types

["option1", "option2"]        // Enum with specific string values
["auto", "seconds", "millis"] // Enum for time units

Function Types

fn(any) -> any                     // Function taking any, returning any
fn(any, any) -> bool               // Function taking two any params, returning bool
fn() -> any                        // Function with no params, returning any

Complex Return Types

// Map with specific structure
{ "exists": bool, "size"?: int }

// Map with optional fields (? suffix)
{ "content": str, "created"?: bool }

// Nested structures
{ "epoch": { "seconds": int, "millis": int } }

Variadic Parameters

*_items: any          // Variable number of any type
*_others: list|str    // Variable number of lists or strings

Named Parameters

// Required named parameter
func(*, required_param: str)

// Optional named parameter with default
func(*, optional_param: str = "default")

// Mixed positional and named
func(_pos: str, *, named: int = 5)

Parameter Constraints

// Parameter with underscore prefix (positional-only)
_path: str

// Named parameter (after *)
sep: str = " "

// Optional with default value
end: str = "\n"

Real Examples from Built-ins

print(*_items: any, *, sep: str = " ", end: str = "\n") -> void
range(_arg1: float|int, _arg2: float?|int?, _step: float|int = 1) -> list[float|int] 
zip(*_lists: list, *, fill: any?, strict: bool = false) -> error|list[]
read_file(_path: str, *, mode: ["text", "bytes"] = "text") -> error|{ "size_bytes": int, "content": str|[int] }

Rad Code Style

Argument Block Formatting

// Good: Group args together, then constraints with empty line separation
args:
    name str           # Required string argument
    count int = 5      # Optional with default
    verbose v bool     # Boolean flag with short form
    email str?         # Optional argument

    name regex "^[a-zA-Z]+$"
    count range [1, 100]
    email requires verbose

Comment Alignment

As a convention, align comments 2 spaces from the longest argument in the aligned group.

Good - align comments within reason (2 spaces from longest):

args:
    name str        # User's full name
    age int         # Age in years
    email str?      # Contact email
    very_long_param str  # Don't align with this one if it's much longer
    city str        # Align with the shorter ones instead

Bad - inconsistent alignment:

args:
    name str      # User's full name
    age int            # Age in years
    email str?  # Contact email

Shell Command Delimiters

// Good: Prefer backticks to avoid delimiter conflicts
result = $`echo "Hello world"`
output = $`grep 'pattern' file.txt`
status = $`curl -H "Content-Type: application/json" api.example.com`

// Avoid: Quotes can conflict with shell command content
// result = $"echo "Hello world""  // This breaks

Variable Naming

// Good: Use snake_case for variables
user_name = "alice"
max_retry_count = 3
is_valid = true

// Good: Common abbreviations are fine for CLI scripting
msg = "Hello world"
req = http_get(url)
cfg = load_config()
args = get_args()

// Good: Use descriptive names when clarity matters
user_data = load_user_info()
processed_items = filter(items, is_active)

// Avoid: Unclear single letters and confusing abbreviations
x = load_user_info()  // Too generic
a, b = 1, 2          // Meaningless names
usr_nm = "alice"     // Unclear abbreviation

Function Definition Style

// Good: Clear, descriptive function names
fn calculate_tax(amount, rate):
    return amount * rate

fn validate_email(email):
    return "@" in email and "." in email

// Good: Anonymous functions for simple operations (note lack of colon)
double = fn(x) x * 2
is_even = fn(n) n % 2 == 0

Control Flow Formatting

// Good: Consistent indentation and spacing
if user.is_admin:
    print("Admin access granted")
    log_admin_action(user.name)
else if user.is_moderator:
    print("Moderator access granted")
else:
    print("Regular user access")

// Good: Switch formatting
result = switch status:
    case "active" -> "Running"
    case "paused" -> "Waiting" 
    case "stopped" -> "Inactive"
    default -> "Unknown"

Collection and Data Structure Style

// Good: Readable list formatting with trailing commas
users = [
    {"name": "alice", "role": "admin"},
    {"name": "bob", "role": "user"},
    {"name": "charlie", "role": "moderator"},
]

// Good: Multiline maps with trailing commas
config = {
    "host": "localhost",
    "port": 8080,
    "debug": true,
}

// Good: Simple lists on one line when short
colors = ["red", "green", "blue"]
numbers = [1, 2, 3, 4, 5]

// Good: Clear JSON path definitions
UserName = json.users[].name
UserEmail = json.users[].email  
UserRole = json.users[].role

String Interpolation Style

// Good: Clear variable interpolation
message = "Hello {user_name}, you have {message_count} messages"
file_path = "{base_dir}/{filename}.txt"

// Good: Format specifiers for numbers
price = "Total: ${amount:.2}"
percentage = "Progress: {progress:.1}%"

Error Handling Style

// Good: Clear handling with suffix catch:
user_data = load_user(user_id) catch:
    print_err("Failed to load user data: {user_id}")
    exit(1)

// Good: Simple fallback with ?? (right side evaluated lazily)
backup_data = load_backup() ?? {"version": "none"}

// Good: Multi-return fallback with ?? list
a, b = risky_pair() ?? [0, 0]

// Good: Shell command handling with catch
code, stdout = $`potentially_failing_command` catch:
    print_err("Command failed with code {code}")
    stdout = ""