shelly-shell 0.5.0

A Rust based Unix style shell with a typed and structured language syntax.
shelly-shell-0.5.0 is not a library.

Shelly

A Unix-style shell written in Rust, growing toward a language for working with commands, structured data, and network services in the same place.

Shelly is early work. Development of the shell is already being done in the shell: running builds, using development tools, and trying new features from its prompt. The examples below describe the current implementation.

Build and run

Shelly runs on Linux (including WSL) and macOS. Build with a Rust toolchain supporting edition 2024.

git clone https://github.com/cstrainge/shelly.git
cd shelly
cargo build --locked
cargo run --locked

Shelly supports interactive use, script files, command-line source, and stdin:

./target/debug/shelly                         # Interactive when attached to a terminal
./target/debug/shelly example.shy one two     # Execute a file
./target/debug/shelly -c 'echo $args...' one two
printf 'echo "hello"\n' | ./target/debug/shelly -s

Without a file or -c, noninteractive input is read from stdin. -i forces the REPL. $args is an array of the supplied arguments, excluding the script filename; $args... passes its elements as separate arguments. Shell options go before the script filename or script arguments. Use --help for all options.

In the REPL, Ctrl+Enter switches to multiline entry without changing prompt width. The prompt marker and continuation > turn yellow on 256-color and true-color terminals while multiline entry is active. Monochrome mode disables editor and default-prompt coloring. Enter then inserts a newline at the cursor; Ctrl+Enter again submits the whole buffer and restores normal entry. Ctrl+C discards the buffer and restores normal entry. Outside multiline mode, Enter submits as usual. Shift+Enter always inserts a newline. Definitions and variables persist between submissions. Leave with exit or Ctrl+D. Tab completes a unique name or common prefix; a second Tab opens the completion menu. Arrow keys navigate an open menu. Ctrl+Enter and Shift+Enter require a terminal that reports those key combinations.

Interactive startup loads ~/.shelly_init.shy. --rcfile PATH selects another init file; --norc skips it. -l enables login startup, which loads /etc/shelly/profile.shy, then ~/.shelly_profile.shy, before interactive init. The standard prelude loads once after those profiles and before the init file, unless a profile has already loaded it with prelude_reload. See Modules for standard-library paths, prelude selection, and profile examples. --norc does not disable login profiles. Script, -c, and stdin modes skip interactive init. -b suppresses the banner; -m requests monochrome output.

The built-in $prompt variable has type Prompt, a distinct type accepting either a String or a zero-argument function returning String. It initially calls the default prompt function. Set it in the init file to customize the prompt. A string is interpolated on every prompt; use single quotes to defer interpolation:

$prompt = '\n<shelly>\n: ${pwd}\n'

A function value is called without arguments, and its returned string becomes the prompt text directly, without another interpolation pass. Printed stdout is discarded. For example:

$prompt = fn (): String { "\n<shelly> ${rendered_widgets}\n: ${pwd}\n" }

You can also assign a named function reference with $prompt = `my_prompt. Assignments reject incompatible values and function signatures; functions returning any are checked when called. An empty string, including a function returning "", gives an empty prompt. Evaluation errors use the default prompt. Redeclaring let $prompt: Prompt restores the default function.

Before each prompt, Shelly calls the functions in $widgets in order. Each returned nonempty string is enclosed in brackets, and the results are joined without a separator. An empty string contributes no text or brackets; whitespace is preserved. Colored widgets use dark grey brackets and the banner's cyan field-label color; monochrome mode keeps them uncolored. The built-in $widget_color: String holds the ANSI foreground escape sequence for widget text, initially the cyan appropriate to the terminal's color mode. Override it in your init file or at the prompt, for example $widget_color = "\x1b[33m" for yellow or $widget_color = "\x1b[38;2;255;205;110m" for a custom RGB color. An empty string uses the terminal's default text color. The brackets stay grey, and monochrome mode ignores this setting. The temporary String variable $rendered_widgets holds that text only while $prompt is evaluated; an empty widget array produces "". Widget stdout is discarded. Widget or prompt errors use the default prompt, and later cycles can recover.

$last_cmd_time is a String containing the elapsed time of the last submitted REPL command, including failed commands. It starts as "0:00" and uses whole seconds: mins:seconds, hours:mins:seconds from one hour, and days:hours:mins:seconds from one day (for example "2:05", "1:02:05", and "1:03:02:05"). Typing, widgets, and prompt evaluation are excluded. Every new prompt gets fresh timing: empty or whitespace-only input and editor cancellation reset it to "0:00", even when the prompt is empty. Prompts and widgets can both read it:

$widgets = [$widgets..., fn (): String { "took ${last_cmd_time}" }]

The standard library's std/widgets.shy provides these widgets. Import the ones you want and register their function references in $widgets:

Widget Example rendered text Behavior
clock_widget [14:32] Local time in 24-hour format (HH:MM).
clock_12_widget [02:32 PM] Local time in 12-hour format with AM/PM.
git_widget [git: main] or [git: main, 2 changed] Git branch, with a count of status entries when there are changes. Empty outside a Git repository or if either Git command fails.
command_time_widget [Took: 0:02] Last command's duration. Empty when $last_cmd_time is "0:00", including commands shorter than one second.
venv_widget [venv: myenv] or [venv: inactive] Active Python environment's name. Empty outside a Python workspace.
node_widget [node: 20.11.1] or [node: expected 20.11.1, current: 22.0.0] Current Node version, with the expected version when it differs. Empty outside a Node project.

Both clock widgets return "" if date fails. Empty results contribute no brackets. Use clock_12_widget instead of clock_widget for a 12-hour clock, and add command_time_widget to show command durations alongside the clock and Git status.

The workspace widgets search the current directory and its parents without changing the working directory. Python markers are pyproject.toml, setup.py, setup.cfg, requirements.txt, and .venv or venv directories. Within a Python workspace, venv_widget shows the basename of $VIRTUAL_ENV, or venv: inactive if it is unset or empty.

Node markers are package.json, .nvmrc, and .node-version. At the nearest project root, node_widget reads the expected version from .nvmrc, then .node-version, then package.json's engines.node. It compares that text with node --version, ignoring a leading v. Exact matches show only the current version; ranges and aliases remain visible as the expected version. Missing Node displays node: missing, with the expected version when a version file provides one. Widget child processes use the shell's current exported environment, so changing $VIRTUAL_ENV or $PATH after importing the widgets takes effect at the next prompt.

To add both workspace widgets alongside those already registered:

import std::widgets::{ venv_widget, node_widget }
$widgets = [$widgets..., `venv_widget, `node_widget]

For example, add this to ~/.shelly_init.shy to register the standard-library clock and Git widgets and display them on the first prompt line:

import std::widgets::{ clock_widget, git_widget }

$widgets = [$widgets..., `clock_widget, `git_widget]

$prompt = '\n<shelly> ${rendered_widgets}\n: ${pwd}\n'

The backticks store function references for later execution, and $widgets... preserves any widgets already registered. Their order in $widgets controls their display order. A prompt might look like this:

<shelly> [14:32][git: main, 2 changed]
: ~/workdir/shelly
$

Place ${rendered_widgets} anywhere in the prompt text to position the widget group. For example, this puts it after the working directory instead:

$prompt = '\n<shelly>\n: ${pwd} ${rendered_widgets}\n'

You can also use $prompt = fn (): String { "${rendered_widgets}\n" } to give widgets their own line. The Git widget returns "" outside a Git repository, so it contributes no brackets there.

Statements, values, and arithmetic

Commands use space-separated arguments. Newlines and semicolons separate statements; # starts a comment. A backslash followed by a newline continues a logical source line, including inside arithmetic and return expressions. Put spaces around binary arithmetic operators; braces may touch the expressions they enclose.

Declare variables with let, reference them with $name or ${name}, and assign an existing variable with $name = expression:

let $x = 1024 + 2 * 2
echo $x                     # 1028
$x = ($x - 4) / 2
echo $x                     # 512

Variable names cannot contain =, including braced names and function parameters. Keep spaces around assignment =; adjacent == and != remain comparisons.

Use let _ = expression to evaluate an expression once and discard its value without creating a variable. This also consumes a command's exit status, just as assigning it to a variable does; evaluation errors still propagate. Printed output is unaffected. The discard requires an initializer and has no type annotation or export modifier. let $_ = expression remains an ordinary named binding.

An optional : Type annotation constrains a variable's initializer and subsequent writes. Type names are case-sensitive: use Number, not number.

let $count: Number                 # Defaults to 0
let $label: String = "items"
let $values: [Number] = [1, 2.5]
let $lookup: [String: Number]
let $maybe: optional Number        # Defaults to ()
$count = 3.5
$lookup["count"] = $count

Annotated declarations may omit = expression. Defaults are:

Type Default
Number, Integer, Float Zero of the appropriate numeric type
Boolean false
String Empty string
[T], Array []
[K: V], HashMap [:]
optional T, None, any ()
Range 0..0
ExecResult ExecResult(0)
ArgumentExpansion Empty argument expansion

Structs and enums require an initializer unless wrapped in optional; empty containers of those types need no initializer. let $x without a type or an initializer remains an error. Annotations use the same types and nested container constraints as struct fields. Number accepts integers and floats, but does not coerce strings or booleans. Failed initializers and writes preserve the previous binding, including its constraint. Indexed and field writes validate the updated value before committing it.

Unannotated bindings remain dynamically typed. A new let declaration may replace an existing binding and its annotation; inner declarations shadow outer bindings. An Integer is promoted to Float wherever a Float is required: declarations, assignments, parameters (including variadic parameters), return values, struct fields, and typed collection elements or values. Promotion applies recursively through optional types and nested collections. Strings and booleans are not implicitly promoted. Number preserves whether its value is an Integer or Float. Other typed bindings preserve the assigned value's type, including ArgumentExpansion; untyped assignments retain their expansion-to-array conversion. A Float never implicitly narrows to Integer; use Integer(...) explicitly. That checked conversion rejects fractional and out-of-range values. Promoted, converted, and computed Floats display with a decimal point (7.0) or scientific notation for very large or small values. Float literals retain their original spelling.

let $x: Float = 7
echo $x                   # 7.0
echo ($x / 2)              # 3.5
$x = 9
echo ($x / 2)              # 4.5
let $n: Integer = Integer(8.0)
echo ($n / 2)              # 4

Values include signed 64-bit integers, floating-point values, booleans, strings, arrays, hash maps, ranges, enums, structs, the no-value result displayed as (), and external command statuses such as ExecResult(0). Arrays come from literals, $args, and file globs. Without ..., an array becomes colon-separated text when passed to a command. () is also a literal that evaluates to None, including in assignments and returns.

Use $args... or ${args}... to expand command arguments. A splat cannot be the executable: $cmd... 2 is a parse error; use $cmd 2 to call a stored command.

Arithmetic supports +, -, *, /, and %, with normal precedence, left associativity, and parentheses. Integer operands produce Integer results: 7 / 2 produces 3, truncating toward zero. If either operand is a Float, the other numeric operand is promoted and the result remains a Float: 7.0 / 2, 7 / 2.0, and 7.0 / 2.0 all produce 3.5. This applies to all five operators; 2.5 + 1 produces 3.5, and 7.5 % 2 produces 1.5.

When both operands are Strings, + concatenates them: "Hello, " + "world!" produces "Hello, world!", and "7" + "2" produces "72". The result is plain string data, even when an operand carries an executable marker.

All other arithmetic requires numeric operands. Strings (including numeric text), booleans, arrays, maps, ranges, structs, enums, command statuses, and () produce errors. Use an explicit numeric conversion when needed, such as Integer("7") + 2. Signed numbers and unary minus work: -2 + 3 produces 1, and -(2.5 + 3) produces -5.5. Division/remainder by zero, integer overflow, and non-finite floating-point operands or results produce language errors, including in release builds.

Ordering comparisons <, <=, >, and >= accept numbers, promoting Integer and Float operands to Float when mixed. Integer pairs compare without losing precision. Other values and non-finite floats are errors; numeric strings require explicit conversion. Same-type named numbers can be compared; mixing named types requires explicit conversion. Ordering binds more tightly than equality (==, !=) and logical operators (&&, ||), and less tightly than arithmetic. Spaces around ordering operators are optional. Quote literal < and > text when passing it as a command argument.

Expressions can stand alone, including inside functions. A top-level expression is evaluated without automatically printing its value; use echo to display it.

Arrays

Create an empty array with [] or use comma-separated expressions:

let $a = [10, 20 + 2, "three", [4, 5]]
echo $a[1]                   # 22
$a[1] = 99
$a[3][0] = 40
echo $a[3]...                # 40 5
echo [7, 8][0]               # 7

Index an array with a range to copy a slice. The end is exclusive unless ..= is used; omitted bounds use the array edges:

let $a = [10, 20, 30, 40]
echo $a[1..3]...             # 20 30
echo $a[1..=3]...            # 20 30 40
echo $a[1..]...              # 20 30 40
echo $a[..2]...              # 10 20
echo $a[..]...               # 10 20 30 40

Bounds must be nonnegative integers within the array. An exclusive end or start may equal its length; an inclusive end must identify an existing element. Reversed bounds yield an empty array after both bounds are checked. Slices retain their element types. Slice assignment, including writing through a slice or a mutating method on one, is rejected. A slice stored in a variable is an independent array that can be modified without changing its source.

Slicing works with user methods and iteration:

fn Array::min(): Number
{
    let $value = $self[0]
    for $next in $self[1..]
    {
        if $next < $value { $value = $next }
    }
    $value
}
echo [4, 2, 6, -3, 5].min    # -3

This method requires a nonempty numeric array; indexing element zero of an empty array is an error.

Elements evaluate left to right and retain their types. Nested arrays remain nested. Newlines, comments, and a trailing comma are allowed between elements. Each element accepts the same value expressions as an initializer, including calls and conditionals: [foo 3, if true { 1 } else { 2 }].

Individual element indexes are zero-based integers. $a[$i + 1], ${a}[0], (make_array)[0], and chained $a[1][2] reads work. The opening index bracket must touch its value: $a[0] is an element, while $a [0] supplies a separate array argument. Negative, out-of-range, or non-integer indexes (including "0" and 1.0) produce errors for arrays. Only arrays and maps support indexing. Array writes replace existing elements; they do not append or grow an array. Numeric function arguments retain their types; use Integer($index) to convert an explicitly textual index, such as one read from $args.

Indexed assignment must start with a variable, as in $a[0] = value or $a[0][1] = value. It updates the nearest visible binding. Index expressions evaluate once, left to right, followed by the right-hand expression; the complete path is checked before replacing the element. Evaluation side effects remain if a later check fails. Assigning or reading an array copies its value: changing $b after let $b = $a does not change $a. Internally, arrays and argument expansions share reference-counted storage; indexed writes copy shared arrays only along the modified path.

Use ... to spread an array into arguments or another literal:

let $a = [2, 3]
let $b = [1, $a..., 4]        # [1, 2, 3, 4]
echo $b...                    # 1 2 3 4

Indexed executable strings follow the same call rules as executable variables: $commands[0] 3 calls with an argument; $commands[0] invokes a marked executable in statement position. As a command argument it passes the reference unchanged; use echo ($commands[0]) to call it and pass the result. Indexing is expression syntax; quoted string interpolation still supports variable names rather than arbitrary expressions.

An array cannot be a command. A standalone $a or $a[0] whose value is an array and is discarded reports Cannot execute an array as a command; explicit calls such as $a "argument" reject arrays too. Arrays remain valid as function return values, conditional results, and arguments (echo $a or echo $a...).

A leading [ starts an array or map literal. For bracket globs use a path prefix, such as ./[ab].txt or fixtures/[ab].txt; quote brackets to pass literal text.

Builtin type methods

Methods use member access and ordinary shell-style arguments. Arrays and argument expansions (including glob results) provide these methods:

Method Result
$items.sort A new array sorted in ascending order; the receiver is unchanged
$items.zip $other An array of two-element arrays, stopping at the shorter input
$items.count The number of elements as an Integer
let $items = [3, 1, 2]
let $sorted = $items.sort
echo $sorted...                         # 1 2 3
echo $items.count                       # 3
let $pairs = $sorted.zip ["a", "b"]
echo $pairs[0][0] $pairs[0][1]           # 1 a
echo ($items.zip [4, 5]).count           # 2
let $files = (./src/*.rs).sort

Methods with no arguments run when accessed, including in assignments and chained expressions such as $items.sort.count. Calls with arguments use spaces; parenthesize a nested call as in echo ($items.zip $other). Empty parentheses are still a None argument, so use .sort, not .sort().

Sorting is stable: equal values retain their relative order. Numbers sort numerically, including mixed integers and floats without rounding large integers before comparison. Strings sort lexicographically by their text, booleans put false first, and variants of one enum sort by .index. Mixed categories, different enum types, nonfinite numbers, and structured elements are rejected. Empty arrays are valid. Sorting strings does not execute them.

Zip preserves element types and value semantics, including nested collections. Its argument must be an array; explicitly expanded arguments still follow normal command expansion rules. Use Array($expansion) to pass an argument expansion as one array. For example, [1, 2].zip ["a", "b"] produces [[1, "a"], [2, "b"]]. Iterate over those arrays with a destructuring pattern:

for ($number, $letter) in $items.zip ["a", "b"]
{
    echo $number $letter
}

Use for $pair in ... to keep each two-element array as a single value.

Backtick access retains a callable method bound to its live receiver:

let $sort = `$items.sort
$items[0] = 9
echo ($sort)...                         # 1 2 9, using the updated receiver

Methods are registered per type in the type registry. Adding a builtin method does not require a new parser rule or a separate opcode for each method. Struct fields and enum .index retain their existing behavior.

User-defined type methods

Use fn TypeName::method(...) to extend any visible type, including builtin types, structs, and enums. The method does not have to be declared alongside the type. An implicit, typed $self parameter receives the value before the explicit parameters. Do not declare $self in the parameter list.

struct Item { quantity: Integer }

fn Item::add($amount: Integer): Item
{
    $self.quantity = $self.quantity + $amount
    $self
}

let $item = Item(quantity: 2)
let $updated = $item.add 3
echo $item.quantity $updated.quantity   # 5 5

fn Array::first($fallback: optional any): any
{
    if $self.count == 0 { $fallback } else { $self[0] }
}

echo [4, 5].first                       # 4
echo ([].first "empty")                 # empty

fn Integer::sum($rest: Integer...): Integer
{
    for $value in $rest { $self = $self + $value }
    $self
}

let $start = 10
echo ($start.sum 1 2 3)                 # 16

Numeric literals support method access directly, including negative and scientific notation: 1.sum, 1.0.sum, -1.5.sum, and 1e2.sum. The receiver retains its numeric type. For example:

fn Float::sum($rest: Float...): Float
{
    for $value in $rest { $self = $self + $value }
    $self
}
echo (1.0.sum 2 3 4 5 6)                # 21.0

Integer arguments promote to Float for this method's parameters. Quote a literal filename or argument such as "1.0.sum" when its dots should remain text.

Methods use the same parameter annotations, trailing optional parameters, final variadic parameter, return annotations, explicit return, and implicit final result as ordinary functions. Calls use the same dot syntax as builtin methods. $self is a mutable alias to the receiver. Assigning $self or updating its members changes the caller's variable immediately; returning or reassigning the result is not required. This also works through nested fields and array or map indexes, such as $items[0].add 3. Receiver indexes are evaluated once.

Ordinary value copies and explicit function parameters remain independent. Methods on literals, constructor results, or other temporary values mutate only that temporary. Returned values are ordinary values, so chaining a method onto a returned value operates on that result. Existing type constraints still apply to mutations, including constraints on containing fields and collections. Successful mutations remain visible if a later statement in the method fails.

Extensions on Number apply to both integers and floats; extensions on any provide a fallback for all values. Methods on the concrete type take precedence, followed by Number for numeric receivers, then any. A user method may replace a builtin method on that type. Struct fields and enum .index take precedence over fallback methods, and declaring a method directly on a type with the same name as one of its data members is an error.

Methods follow ordinary function scoping and forward-declaration rules. Different types may use the same method name, and method names do not occupy the namespace of bare function calls. Already compiled calls retain their visible method versions when a method is redefined. Backtick references such as let $add = `$item.add keep a live reference to the receiver binding and pin the method version; $add 3 updates $item. Reassigning the same binding is visible through the reference, while declaring a new variable with let creates a separate binding. Captured bindings remain alive after their scope exits. References to collection elements retain their evaluated index or key; an invalid path or incompatible receiver type produces an error when called. Redefining a type creates a distinct type, so old values retain their original methods.

Hash maps

Use [key: value, key: value] to create a map and [:] for an empty map. [] remains an empty array. Keys and values are expressions, evaluated left to right, key before value. Newlines and a trailing comma are allowed; array elements and map pairs cannot be mixed in one literal. Quote literal string keys to avoid the usual bare-word command lookup rules.

let $myHash = ["key": 42, "items": [1, 2]]
let $x = $myHash["key"]          # 42
echo $myHash["missing"]          # ()
$myHash["new"] = 7              # Insert
$myHash["key"] = 99             # Replace
$myHash["items"][0] = 8         # Nested write

Any value can be a key, including arrays and maps:

let $lookup = [[1, 2]: "array key", ["a": 1]: "map key"]
echo $lookup[[1.0, 2]]           # array key
echo $lookup[["a": 1.0]]         # map key

Keys compare by value. 1 and 1.0 identify the same key, while "1" is distinct. String execution flags and float source spelling do not affect key identity; array order matters and map entry order does not. Collection keys are immutable snapshots: modifying the original array or map leaves its stored key unchanged. NaN keys are canonicalized to one key so they can be looked up reliably. Duplicate keys keep the last value, while all key and value expressions still execute.

Maps use reference-counted storage and copy-on-write, like arrays. Indexed writes insert or replace the final key; missing intermediate containers cause an error. A missing read returns () without inserting an entry. Maps compare by their entries and convert to false only when empty. Arithmetic on maps is an error. Text conversion produces a bracketed list of key/value pairs in a stable order, with quoted strings and bracketed nested collections. Passing a map as an argument uses that text; ... treats a map as one value and does not iterate its entries. A map cannot be executed as a command.

Inside a collection literal, : separates map keys and values. Use parentheses for a nested call that takes an unquoted colon argument, such as ["result": (command :)].

Ranges

Ranges hold integer bounds without allocating their elements:

Syntax Meaning
1..5 Start included, end excluded
1..=5 Both ends included
1.. Start specified, end omitted
..5 or ..=5 Start omitted, end excluded or included
.. Both bounds omitted

Either bound can be a variable or an expression. Bounds evaluate once, left to right, when the range is created; later variable assignments do not change it.

let $start = 1
let $end = 4
let $r = $start..$end
echo $r                       # 1..4
echo $r...                    # 1 2 3
echo ($start..=$end)...        # 1 2 3 4
let $inner = ($start + 1)..($end - 1)
let $items = [0, $r..., 4]     # [0, 1, 2, 3, 4]

... expands a bounded range in ascending steps of one, materializing its elements as arguments or array elements. Reversed ranges expand to no values; 3..3 is empty and 3..=3 contains one value. Expansion is reusable and does not consume the range. Parenthesize a literal before expanding it: (1..4).... Without expansion, a command receives the range's text, such as 1..4.

Bounds must be integers; floats, numeric strings, and other types produce an error. Use Integer($start) to explicitly convert a numeric string; integer function arguments already retain their type. Inclusive ranges require an end bound, and chained ranges such as 1..2..3 are rejected. Omitted bounds remain unspecified; expanding such a range is an error. Ranges can index arrays to select slices; indexing a Range value itself is not implemented.

Ranges compare by their bounds and inclusivity: 1..3 differs from 1..=2 even though they expand to the same elements. They can be map keys and function return values, but cannot be commands. Boolean conversion is false for an empty bounded range and true otherwise. Explicit numeric conversion of a range and arithmetic on a range are errors.

Arithmetic binds more tightly than range operators, which bind more tightly than comparisons. Spaces around .. and ..= are optional. Parenthesize open ranges when followed by other arguments, as in echo (1..) (..5). Ordinary words retain embedded dots (file..name); quote text that would otherwise parse as a range. Paths such as ./file, ../file, and cd .. continue to work.

Enums

Enums define named alternatives. The current implementation supports unit variants:

enum Color
{
    Red,
    Green,
    Blue,
}

let $color = Color::Green
echo $color                       # Color::Green
echo ($color == Color::Green)      # true
let $labels = [Color::Red: "stop", Color::Green: "go"]
echo $labels[$color]               # go
let $index: Integer = $color.index # 1
echo Color::Blue.index             # 2

fn is_green($value) { $value == Color::Green }
echo (is_green $color)             # true

Commas separate variants; trailing commas and newlines are allowed. Names contain letters, digits, or underscores and cannot start with a digit or use a reserved keyword. Empty enums, duplicate variants, and duplicate type declarations in the same scope and submission are errors. Unit variants have no constructor arguments: use Color::Red, not Color::Red(). Payload variants are not implemented yet. Unit variants can be used as values in match arms.

Enum names are lexical: a declaration is available throughout its containing block, including earlier expressions and function definitions. Inner declarations can shadow outer types. The name does not escape its block, but returned or assigned values retain their definition. Repeated calls and loop iterations reuse the same compiled declaration's identity.

Enums compare by declaration identity and variant. A variant differs from its printed string and from identically named variants of other declarations. Enums can be array elements, map keys, function arguments, and return values. They always convert to true, even if a variant is named False or Error. Arithmetic, indexing, iteration, and using an enum as a command are errors. Expansion with ... passes one value; enum text such as Color::Green is display output, not serialized source.

Every enum value has a read-only .index member: an Integer starting at zero in declaration order. It works on variables, literal variants, and enum values inside collections or struct fields. The index belongs to the value's original declaration, so redeclaring an enum does not change existing values' indexes. Use .index for numeric access; Integer($color) does not convert an enum directly.

The shared type registry persists across REPL submissions. Redeclaring a type in a later submission creates a new identity; existing values and previously compiled functions retain the old definition. Checking resolves enum names and variants in the entire submitted AST, including unused functions and skipped branches, before bytecode generation. A parsing, checking, or compilation failure does not publish new types or functions. A runtime failure occurs after declarations are committed.

Structs

Structs declare bare field names without $. A type annotation follows a colon: name: Type. Omitting the annotation makes the field accept any value:

enum Status { Ready, Busy }

struct Item
{
    quantity: Number,
    state: Status,
    children: [Item],
    labels: [String: String],
    next: optional Item,
    payload: any,
}

let $item = Item(
    quantity: 2,
    state: Status::Ready,
    children: [],
    labels: ["name": "widget"],
    payload: (),
)
echo $item.quantity $item.labels["name"]    # 2 widget
echo $item.next                            # ()
$item.quantity = 3.5
$item.labels["name"] = "updated"

Commas separate declarations and constructor arguments; newlines, comments, and trailing commas are allowed. Declaration names and constructor labels omit $. The opening parenthesis can touch the type name, be separated by spaces (Item (quantity: 42, ...)), or follow it on a new line, including across blank lines and comments:

let $item = Item
    (
        quantity: 42,
        state: Status::Ready,
        children: [],
        labels: [:],
        payload: (),
    )

The newline form requires named fields. Constructors with no supplied fields use MyType() or MyType (), with the opening parenthesis on the same line as the type name. The type registry distinguishes MyType () from a function call: declared type names take precedence; otherwise foo () passes None to foo. A following ordinary grouped expression, including (), remains a separate statement across a newline. Semicolons always end the statement. Shell-function calls retain space-separated arguments, including foo (expression) on one line.

Declaration Meaning
field or field: any Required field accepting any value, including ()
field: optional or field: optional any Any value; defaults to () when omitted
field: Number Integer or float; strings and booleans do not qualify
field: MyEnum or field: MyStruct A value of that specific type declaration
field: [T] Array whose elements satisfy T
field: [K: V] Map whose keys satisfy K and values satisfy V
field: optional T Either T or (); defaults to () when omitted

The existing builtin names also work, including Integer, Float, String, Boolean, None, Range, Array, HashMap, and ExecResult. Annotations check types and promote Integer values wherever Float is required, including inside typed collections. Other conversions require explicit Type(value) syntax. Array and HashMap accept those containers without constraining their contents; any accepts every value.

Container annotations compose: [String: [Item]], [MyEnum: optional Item], [optional Number], and optional [String: any]. An array of optional elements still requires an array; an optional array may itself be (). Empty arrays and maps satisfy their respective element constraints. Map-key constraints follow key equality: 1 and 1.0 denote the same key. This applies recursively to collection keys; struct keys retain a typed snapshot of their original fields.

Every nonoptional field must be supplied, including untyped fields. Optional fields may be omitted or supplied explicitly. Unknown and duplicate fields are errors. Supplied expressions evaluate once, left to right in source order; fields are stored and displayed in declaration order. Empty structs are allowed: struct Empty {} and Empty().

Read and update members with $item.field, including mixed paths such as $item.groups["name"][0].field. An assignment must start with a variable. Fields cannot be added or removed, and unknown members are errors. Accessing a field through () is an error; an optional field containing a struct permits normal member access. User-defined methods use fn TypeName::method(...) declarations; optional-chaining syntax is not implemented.

Member syntax preserves word interpolation: $item.field accesses a field, ${name}.txt concatenates text, and (${item}).field accesses a field using a braced variable. String interpolation still accepts variable names rather than member expressions. Literal paths such as file.txt retain their meaning.

Structs have value semantics and share reference-counted storage. Assignment, function calls, and collection insertion preserve their type; writes copy shared values along the modified path. A method's implicit $self instead aliases its receiver binding, so its mutations update that binding. All nested writes must satisfy the containing field's constraints. An invalid update leaves the target unchanged, although side effects from evaluating its indexes and right-hand expression remain.

Structs and enums share the same lexical type namespace, forward-reference rules, and REPL identity rules. A scope cannot declare an enum and a struct with the same name. An inner declaration may shadow an outer type. Type names any and optional are reserved. Redeclaration in a later REPL submission creates a new identity; existing values, field annotations, and compiled functions retain the old definitions.

Self and mutual references are supported through optional fields or containers. Cycles consisting entirely of required struct fields are rejected because they cannot form a finite, fully initialized value. For example, next: optional Item and children: [Item] are valid; a required next: Item inside Item is not.

Equality compares declaration identity and all field values. Structs can be map keys, using immutable snapshots and normal value equality. They always convert to true, including empty structs, and expansion with ... passes one value. Arithmetic, execution, iteration, and bracket-indexing a struct are errors; use member access for its fields. Text such as Item(quantity: 2, ...) is display output, not a serialization format.

The AST checker rejects invalid declarations, constructor shapes, known value mismatches, and known invalid member accesses before executing the submitted source. Dynamic values are checked during construction and updates. A failed check or compilation does not publish new types or functions; runtime failures occur after declarations have been committed.

Distinct named types and unions

type declares a new identity backed by an existing type or type expression:

type Count = Integer
type MyHash = [String: Integer]
type Foo = Integer | String | ()

let $count: Count = 7         # Wraps the integer in Count
let $other = Count(2)         # Explicit construction
let $total = $count + $other  # Still Count
echo Integer($total)          # Explicitly unwraps: 9

let $scores: MyHash = ["alice": 10]
for ($name, $score) in $scores { echo $name $score }
let $item: Foo                # A Foo containing ()
$item = "ready"

These are distinct types, not interchangeable names. Count(7) == 7 is false, and assigning a Count to an Integer requires Integer($count). Annotations can wrap compatible underlying values in variables, arguments, returns, struct fields, and collections. Integer-to-Float promotion still applies inside the wrapper. Count(value) wraps compatible values and can explicitly convert from another named wrapper; parsing text requires Count(Integer("7")). any(value) preserves the named identity.

Named types inherit their underlying fields, methods, indexing, iteration, boolean conversion, and display behavior. Same-type arithmetic, including unary negation (- $count), preserves the named type. Arithmetic between distinct named types, or between a named and an unnamed value, requires explicit conversion. Methods retain their declared return types; an inherited method returning Integer returns an Integer. Define methods with fn Count::method(...) to override or extend the inherited behavior. Inherited mutating methods update the original receiver while enforcing its named type's constraints.

Named collections yield their underlying items during indexing and iteration, with the declared item types available to the checker. Named hash keys retain their identity: use Key("x") to look up a key of type Key. Key annotations can wrap keys on assignment; conversions that would merge distinct entries are errors. Named structs are constructed by wrapping the underlying constructor, for example Position(Point(x: 1)); named enums also support State::Variant.

A named type uses its underlying default when one exists. Structs and nonoptional unions require an initializer. A named value containing () remains a distinct value, so iterator termination still requires an actual () return. Declare iterator results as Item | ().

Named types follow the lexical scope, forward-reference, import/export, and REPL identity rules of structs and enums. Redeclaration creates a new identity without changing existing values or compiled annotations. Recursive type definitions are rejected; recursion through optional or collection fields of structs remains supported.

Union annotations accept any listed member:

let $value: Integer | String = 7
$value = "seven"
let $number: Integer | Float = 7  # Stays Integer
let $ratio: Float | Boolean = 7   # Promoted to Float
let $maybe: Integer | String | () # Defaults to ()

An existing member match preserves the value. Otherwise, implicit conversions must produce one unambiguous result. For example, if A and B are distinct Integer types, assigning 7 to A | B is ambiguous; use A(7) or B(7). Unions without () require an initializer. Parentheses group type expressions, and unions work inside collection annotations, such as [String: Integer | Boolean]. T | () and optional T are equivalent.

Function prototypes

Function prototypes are types, and can appear in type declarations or directly in annotations:

type Transform = fn(Integer): String
type Factory = fn(): Transform

fn describe($value: Integer): String { String($value) }
let $convert: Transform = `describe
echo ($convert 7)                       # 7

let $callbacks: [fn(Integer): String] = [`describe]
echo ($callbacks[0] 8)                  # 8

The syntax is fn(Type, Type, ...): ReturnType, with a comma-separated list of parameter types. fn() takes no arguments. Omitting : ReturnType means (): fn(Integer) and fn(Integer): () describe the same contract. This default is specific to prototypes; existing function definitions without a return annotation keep their dynamic return behavior. Prototypes require an initializer unless made optional. Each prototype has a fixed argument count; a variadic function can satisfy any prototype whose arguments it accepts.

any is a proper type name and explicitly permits values of any type:

fn first($args...): any { $args[0] }

let $number: fn(Integer): Integer = `first
let $text: fn(String): String = `first

echo ($number 42) ($text "hello")

Known incompatible signatures and argument counts are rejected when the reference is bound. any parameters and returns, including unannotated function parameters, allow more specific prototypes; every call checks its arguments and returned value. For example, a function declared to return any can satisfy fn(): Integer, but returning a String through that prototype is an error. Integer-to-Float promotion and named-type wrapping apply at these call boundaries. Existing contracts remain in force when a callable is assigned to another prototype, even one using any.

Prototypes accept bound scripted functions, native functions, and bound methods. Native checks use available signature metadata and enforce the prototype at call time. A plain string or external-command reference does not provide a function signature. Native return values are preserved: cd, for example, can be used as fn(String): ExecResult.

A typed reference preserves its function version or its method's live receiver. Copying it does not call it: let $copy = $convert copies the reference; ($convert 7) calls it. Use ($factory) to call a zero-argument factory and obtain its returned function. Typed functions compare by callable identity and prototype, can be hash keys, and are distinct from strings with the same display name.

Named prototypes remain distinct, with the same wrapping and explicit conversion rules as other named types. They can be imported and used in fields, collections, parameters, returns, and other prototypes. For a union of a function and (), write (fn(Integer): String) | (); without those parentheses, fn(Integer): String | () describes a function returning either String or (). Recursive type definitions remain unsupported.

Explicit type conversions

Use Type(value) to request conversion. All builtin types support this syntax. Annotations implicitly promote Integer to Float wherever Float is required and wrap compatible values in declared named types. Other conversions, including Float to Integer and unwrapping named values, must be explicit. Integer and Float are concrete numeric types; Number accepts either (Integer | Float).

Target Accepted input and behavior
Integer Integers, integral floats, decimal integer text, booleans, numeric exit statuses
Float Numbers, numeric text, booleans, and numeric exit statuses
Number Preserves numbers; converts numeric text, booleans, and numeric exit statuses
Boolean Uses the truth rules below
String Display text for any value, with executable flags removed
Array Arrays, argument expansions, and bounded ranges
ArgumentExpansion Arrays, argument expansions, and bounded ranges
HashMap Existing hash maps
Range Existing ranges, including unbounded ranges
ExecResult Existing statuses, integer-valued numbers, decimal integer text, or booleans
None Evaluates its input, then produces ()
any Preserves the input value and its concrete type

Numeric conversions map booleans to 0 or 1 and numeric command statuses to their exit code. ExecResult requires a code in 0..=255; it maps true to status 0 and false to status 1. Number parses text as an integer when it fits, otherwise as a finite float; booleans and numeric command statuses become integers.

Numeric text may have surrounding whitespace. Integer("2.0") is rejected; Integer(Float("2.0")) succeeds. Fractional float-to-integer conversions, overflow, invalid text, and nonfinite numeric conversions are errors. Floating-point conversion has normal f64 precision limits. A status caused by a signal has no numeric code, so it cannot convert to an integer or float.

Collection conversions preserve elements without converting them. Arrays and argument expansions share their reference-counted storage; writes retain value semantics. Bounded ranges materialize their integer elements. Other collection conversions, including arrays of pairs to maps, are errors.

let $count: Integer = Integer("42")
let $ratio: Float = Float("2.5")
let $items: Array = Array(1..4)
echo ArgumentExpansion($items)      # 1 2 3
let $status: ExecResult = ExecResult(false)
echo Boolean($status) Integer($status)  # false 1

Conversions require one expression. Newlines inside the parentheses and a trailing comma are allowed. Builtin conversions also allow a space before (; named type construction requires an attached (, as in Count(7); a newline before ( starts a separate statement. Use None(()), for example, rather than an empty None(). Type names take precedence over function names in this syntax. A struct or enum declaration with a builtin name shadows that conversion. Resolved conversion targets and conversion-method versions remain fixed in previously compiled functions.

Explicit Target(value) conversions first look for a factory on the target type named from_<source_type>, using the source type's name in lowercase. For example:

type Count = Integer
fn Count::from_string($value: String): Count
{
    Integer($value)
}

echo Count("7")  # 7, with type Count

Each type declaration provides a default factory from its immediate underlying type. For example, type Count = Integer provides Count::from_integer($value: Integer): Count, which wraps the value without changing it. It can be called directly or stored as a function reference:

type Count = Integer
let $make_count = `Count::from_integer
echo ($make_count 7)  # 7, with type Count

fn Count::from_integer($value: Integer): Count { $value + 1 }
echo Count(7) (Count::from_integer 7)  # 8 8

A scripted factory with the same name overrides the default. Existing function references and compiled functions retain the factory version they captured. Nested typedefs use the immediate original type: type Outer = Count provides Outer::from_count. Collection typedefs use the collection names described below, such as from_array_integer. A pub type exports its default factory; an explicit override follows its own pub visibility. Annotations still wrap values through normal coercion, independently of factory overrides.

Factories have one required source argument and no $self. They declare the target type as their return type, or any with the result checked against the target at runtime. A named source retains its identity for lookup: a Count value selects from_count, while an Integer selects from_integer. Ranges select from_range.

Collection factory names include their contained types:

fn Count::from_array_integer($values: [Integer]): Count { $values.count }
fn Count::from_hash_string_integer($values: [String: Integer]): Count
{
    $values["count"]
}

echo Count([1, 2])           # selects from_array_integer
echo Count(["count": 7])     # selects from_hash_string_integer

Declared collection constraints determine the name, including for empty typed collections. Otherwise Shelly infers types from the contents; mixed or unknown types use any, so an untyped empty array selects from_array_any, and an untyped empty hash selects from_hash_any_any. Key and value types are inferred separately. Names are lowercase, including named element types. Nested collections use the same spelling recursively, such as from_array_array_integer and from_hash_string_array_integer. A distinct named collection type keeps its own name: type Numbers = [Integer] selects from_numbers.

A matching factory runs before existing conversion rules, even when the source already has the target type. If none exists, the existing rules apply. A factory error propagates without trying a fallback. Annotations and assignments continue using their normal coercion rules; they do not invoke conversion factories. Factories can also be called as Count::from_string "7" or stored as function references. Use pub fn Count::from_string(...) to make a factory available to importers; private factories remain in their defining module.

Boolean expressions

== and != compare values and produce booleans. Numbers compare numerically, including integer/float pairs; strings compare their text, ignoring executable flags. Float source spelling does not affect equality. Arrays compare their elements in order. Unrelated types are unequal: "1" == 1 and true == 1 are false. () equals (). Command statuses compare as statuses, not as integers or booleans.

Boolean(value) explicitly converts a value to a boolean. !, &&, and || use the same conversion rules:

Value Boolean conversion
() False
Boolean Its existing value
Integer or float False for zero, true otherwise
String False for empty text (""); true for every nonempty string
Array, hash map, or argument expansion False when empty, true otherwise
Range False for an empty bounded range; true otherwise
Enum or struct Always true, including empty structs
External command result True for exit status 0; false for nonzero status or termination by signal
let $result = /bin/true
let $succeeded: Boolean = Boolean($result)  # true
echo Boolean(0) Boolean("false") Boolean([1])  # false true true

Boolean annotations check values without converting them: let $flag: Boolean = 1 is an error. !!value remains a shorthand for boolean conversion. Boolean(value) requires one expression; use Boolean(()) to convert None.

Logical operators always return a boolean. && skips its right operand when the left is false; || skips it when the left is true. Skipped operands have no side effects and cannot cause runtime errors, but must still be valid syntax.

Newlines, blank lines, and comments may appear before or after &&, ||, ==, and !=. A newline before an operator continues the expression; otherwise it still ends the statement. Precedence and short-circuit behavior are unchanged:

if    ($expected != "")
   && ($actual != $expected)
{
    echo "Output differs"
}

Precedence, highest first: parentheses and indexing; unary ! and unary minus; * / %; + -; .. ..=; < <= > >=; == !=; &&; ||. Range operators cannot be chained; other binary operators at the same precedence associate left to right. Boolean operators do not require surrounding spaces, so $x!=0 and !$x work. Quote operator text when passing it literally.

echo (1 == 2) (2.5 == 2.50)    # false true
echo !"false" !"0"             # true true
let $ready = 2 + 3 == 5 && !false
echo $ready                    # true
echo (false && (1 / 0))         # false; division is skipped
echo ((/usr/bin/false) || (/usr/bin/true))  # true

Use parenthesized calls to make command results operands: (foo 3) && (bar 4). These are value expressions, not shell command chains: echo true && false passes the single value false to echo. Bare words within boolean expressions are string operands; foo == foo compares text. Function parameters preserve their argument types: passing 3 gives an integer, while passing "3" gives a string. Equality does not coerce one into the other.

Strings and paths

Double-quoted strings interpolate $name and ${name}. Single-quoted strings keep variable references literal. Missing variables are errors. Both quote forms process backslash escapes, including \n, \r, \t, hexadecimal \x41, octal \o101, and decimal \065 (the last three produce A). Use \$ inside double quotes to keep a dollar sign literal: "\$name" produces $name. This also works in multiline strings.

let $name = 'Shelly'
echo "Hello, $name!"
echo "Building ${name}..."
echo '$name stays literal here'

Multiline strings use "* ... *" or '* ... *'. Leading whitespace before the first text is skipped, and the first line establishes the indentation removed from subsequent lines. Extra indentation and embedded newlines are preserved, including the newline before a closing delimiter on its own line.

let $project = 'Shelly'
echo "*
    Building $project
      Source: src/
      Mode: development
    *"

The double-quoted form interpolates variables; the single-quoted form keeps them literal. Ordinary single-line quotes cannot contain a raw newline.

Reading variables and interpolating strings shortens paths under the current $HOME to ~ or ~/..., including $pwd and strings stored in arrays and map values. Map keys retain their original values. Only complete home-directory prefixes match; similarly named sibling directories stay unchanged. Stored values are not rewritten by reading them.

Shelly expands leading ~ or ~/ at filesystem boundaries: cd, executable lookup, glob variable prefixes, path settings, and external-command arguments. This also applies to quoted or variable-derived arguments. Thus cd $p and cat "$p/file" work with shortened paths, and echo $pwd prints an absolute path. Embedded text such as echo "cwd: ${pwd}" retains the shortened path, as does a custom prompt using ${pwd} after its label or color codes. ~someone is not expanded. This external-command expansion does not apply at a shell function call boundary; reading the function parameters follows the same path shortening rules as other variable reads.

Unquoted paths can begin with a variable. Its value and the suffix remain one argument, including spaces in the value:

let $root = '/tmp'
echo $root/project/file.txt
let $name = 'report'
echo ${name}suffix           # reportsuffix
echo ${name}.txt             # report.txt

Braces mark the end of a variable name within a word: ${name}suffix is one argument, even when the variable's value contains spaces. ${name} suffix remains two arguments. ${items}[0] and ${items}... retain their indexing and expansion meanings.

Variable-prefixed executable paths such as $tools/echo also work. Write $a / $b for division; $a/file is a path. Variable-prefixed glob patterns such as $root/*.txt interpolate the prefix before expanding the pattern. Characters from the variable's value remain literal, including [ or * in a directory name.

Unquoted *, ?, and bracket patterns in paths such as ./[ab] expand matching paths in sorted order; ** supports recursive matching. Hidden entries require an explicit leading dot, . and .. are excluded, and no matches is an error. Quotes preserve a glob as text.

let $sources = src/language/*.rs
echo $sources...

Anonymous functions and closures

fn ($parameter: Type): ReturnType { ... } creates a function value. Pass it directly as an argument, store it in a variable or collection, or return it from another function:

fn foo($func: fn(Integer): String)
{
    echo ($func 7)
}

foo fn ($x: Integer): String
    {
        "This is a string with an integer ${x}!"
    }

Creating or copying an anonymous function does not run its body. Call a stored function with $func arguments, or ($func) for a zero-argument call used as an expression. Anonymous functions use the same parameter, return, optional, and variadic rules as named functions. Their annotations can be omitted; an unannotated function definition has unrestricted parameters and returns, whereas a function prototype with an omitted return type requires ().

Closures capture live variable bindings where they are created. Captured locals remain available after the outer function returns, and writes remain visible to other closures sharing those bindings:

fn counter($n: Integer): fn(): Integer
{
    fn (): Integer
        {
            $n = $n + 1
            $n
        }
}

let $next = counter 0
echo ($next) ($next)             # 1 2

Each call has its own parameters and local declarations. Caller-local variables do not replace captured bindings. Assignment updates a binding; a new let declaration shadows it without changing an existing closure's capture. Closures also preserve a captured method's live $self receiver, their defining module, and the function versions visible when their code was compiled.

Anonymous functions satisfy compatible function prototypes, including named prototype types, with the usual argument and return checks. Each evaluation of a function literal creates a distinct callable identity; copying it preserves that identity. Function values can be hash keys and are distinct from their display text, <anonymous>.

Functions, calls, and return values

Functions have named parameters and local variable scopes. Call them like commands. Arguments are evaluated left to right and retain their types when passed to Shelly functions, including enums, arrays, maps, and executable markers. External commands and builtins receive text. Alias defaults and command-line $args remain strings. Arrays passed to a function remain one array argument unless expanded with ....

fn foo($a)
{
    2048 * $a
}

let $y = foo 3
echo $y                     # 6144
echo (foo 3)                # 6144
echo (foo 3) + 1            # 6145

Parameters and return values can also be annotated:

struct Item { value: Number }

fn make_item($value: Number): Item
{
    Item(value: $value)
}

let $item: Item = make_item 42
echo $item.value

Parameter types are checked before the function body runs and remain constraints on assignments to those parameters. The return annotation applies to both explicit and implicit returns. Bare return and fallthrough returning () require a type that accepts None, such as optional Number or any. Unannotated parameters and returns remain unrestricted. Integer arguments widen to Float where required; other implicit conversions are rejected.

Trailing parameters annotated optional T may be omitted from right to left:

fn describe($value: Number, $label: optional String, $limit: optional Number)
{
    echo $value $label $limit
}

describe 7                       # 7 () ()
describe 7 "items"               # 7 items ()
describe 7 "items" 10            # 7 items 10

Required parameters cannot follow optional ones. Use optional any for an omittable parameter accepting any value. Explicit () occupies its argument position, so describe 7 () 10 skips the label while supplying the limit. The compiler emits parameter-binding instructions containing () defaults; these apply equally to direct calls, aliases, executable variables, and calls whose arguments are expanded with ....

The final parameter may collect extra arguments into an array. $rest... accepts elements of any type; $rest: Number... binds a [Number]:

fn collect($rest...): Array
{
    return $rest
}

fn sum($values: Number...): Number
{
    let $total: Number
    for $value in $values { $total = $total + $value }
    return $total
}

echo (collect "hello" 3 true)...  # hello 3 true
echo (sum 1 2 3)                 # 6
echo (sum)                       # 0

A variadic parameter receives [] when there are no remaining arguments. It may follow required and optional parameters; fixed parameters consume their positions first, and all remaining arguments go into the array. Use () explicitly to skip an optional position before supplying variadic arguments. The suffix follows the element type: $rows: [Number]... receives [[Number]], and $values: optional Number... receives [optional Number].

The final expression or command supplies the function's result. A trailing semicolon or newline does not discard it. return expression exits the current function immediately with that value; bare return returns ().

fn answer()
{
    return 2048
    echo "unreachable"
}

fn greet($name)
{
    echo "Hello, $name!"
    return
}

return outside a function is an error. Empty functions, including fn f() {}, and functions ending in a declaration, assignment, alias, nested function definition, or completed loop return (). Duplicate parameter names are rejected.

Parentheses evaluate one expression and preserve its result. They support nested calls and arithmetic, but do not contain statement sequences. Missing or extra closing parentheses are errors, including echo foo 3).

A bare name has different behavior depending on its context:

Form Current behavior
foo as a statement Call foo with no arguments; an unknown command errors.
let $x = foo Call it if it resolves to a function, builtin, alias, or executable; otherwise store the word as text.
let $x = foo a b Call it with arguments; an unknown command errors.
echo foo Pass the literal word foo, even when it names a command.
echo (foo) Call foo with no arguments, then pass its result to echo.
echo (foo 3) Call foo with 3, then pass its result directly to echo.
echo "foo" Pass literal text.

A backtick prefix creates a string marked executable without calling it. There is no closing backtick. For a known Shelly function, the reference retains that function's version, including its parameter and return constraints. Redefining the name does not change a previously stored reference. A visible native function also retains its registered identity, including through qualified imports. External commands and unresolved names remain name-based references.

fn answer() { 2048 }
let $call = `answer
let $copy = $call            # Copy the reference without calling it
$call                       # Call it; top-level values are not printed
echo "$call"                # answer
echo $call                  # answer
echo ($call)                # 2048
echo `answer                # answer

A standalone variable or a variable/string inside parentheses is called with no arguments when its value is marked executable. In argument positions, bare names stay literal and executable variables, collection elements, and struct data fields pass their values without being called. Use parentheses to execute them: git diff passes the subcommand name, while git (diff) calls diff first and passes its result. This applies to arguments of external commands, Shelly functions, and methods. Property-style methods remain implicit: echo $items.count evaluates .count. Ordinary string values stay text. Direct call results and nested groups do not cause the returned value to be called a second time.

A backtick argument passes an executable reference. Grouping changes this: echo (`answer) calls answer. A backtick-prefixed name cannot be the head of a grouped call with arguments: echo (`foo 3) is an error. To pass a stored reference without calling it, use $call. Use "$call" to pass ordinary text, or `$call to explicitly mark the value executable. The backtick-variable form reads the value and marks its name executable, so it can also create a reference from a stored ordinary string.

Calls through variables with arguments work as statements, in assignments and returns, and inside parentheses:

let $call = `foo
let $result = $call 3
echo ($call 4)              # 8192

An explicit call with arguments also accepts an ordinary string variable as its command name. The executable marker controls implicit zero-argument calls; an explicit call does not require that check.

Function definitions are registered before executing the submitted source, so forward calls work. Once a function is known, compiled calls and references retain that version, including across later redefinitions in the same submission. Forward references with no known version resolve through that submission's completed function namespace. Later submissions cannot change that namespace. Redefinitions do not retarget existing direct, recursive, or sibling calls; newly compiled code sees the new definitions. Stored backtick references also retain their original versions when copied, passed to functions, returned, or stored in array elements, map values, and struct fields. A bound function reference is not redirected by an alias added later.

Functions can contain helper functions:

fn welcome($name)
{
    fn say_hello()
    {
        echo "Welcome to Shelly, $name!"
    }
    say_hello
}
welcome 'world'

Variable lookup searches active block and call scopes; assignment updates the nearest visible binding. This currently gives variables dynamic caller scope. Nested function names follow their containing function blocks. let creates or replaces a binding in the current scope. The initializer runs before the binding is replaced, so let $x = $x + 1 can read the old value. An initializer error does not overwrite the binding with an empty value.

Scoped code blocks

Standalone { ... } blocks create variable scopes and may be nested, both at the top level and inside functions. let creates a binding local to the block; assignment without let updates the nearest visible binding.

let $x = 1
{
    let $x = $x + 1
    { let $x = 3; echo $x }  # 3
    echo $x                 # 2
}
echo $x                     # 1
{ $x = 4 }
echo $x                     # 4

Returns and runtime errors unwind any active block scopes. return inside a block exits the enclosing function; it remains an error at the top level. A block at the end of a function supplies its last expression as the implicit return value, including through nested blocks. An empty final block, or one ending in a declaration, supplies ().

Blocks are statements; { ... } is not yet an expression for assignments or command arguments. Blocks scope variables; function definitions retain their existing hoisting into the enclosing function or top level, and aliases remain global.

Conditional expressions

if condition { ... }, else if condition { ... }, and else { ... } form a chain. Every branch requires a block with its own variable scope. Conditions use the same boolean conversion as !, &&, and ||. They are evaluated in order; only the first matching branch runs, and later conditions are skipped. Newlines and comments may separate a condition from its block or one branch from the next.

let $count = 2
let $message = if $count == 0
{
    "empty"
}
else if $count == 1
{
    "one item"
}
else
{
    let $description = "several items"
    $description
}
echo $message               # several items
echo (if true { 10 } else { 20 }) + 1   # 11

if is an expression: use it in assignments, arguments, arithmetic, returns, or other conditions. Its value is the selected block's last expression. An empty block, a block ending in a declaration, or an unmatched chain without else evaluates to (). A final if expression supplies a function's implicit return value. return inside a branch still exits the enclosing function, unwinding the branch scope.

Conditions also accept command calls, for example if /usr/bin/true { echo "success" }. Exit status zero is true; nonzero or signaled results are false. Use parentheses when combining calls with operators: if (check 3) && (check 4) { ... }. The condition is evaluated in the surrounding scope; branch-local bindings do not escape. Branch blocks follow the function hoisting and alias rules described above.

All branches must parse, including those skipped at runtime. else must belong to the same chain; a semicolon ends the chain, so put else after the closing brace or on the next line, without a separating semicolon. To pass the literal word if as a command argument, quote it.

Runtime types and type guards

Every value has a read-only .type property returning a Type value. Type names are values in expression positions, so they can be compared, stored, returned, and used as match patterns:

let $expected: Type = Integer
let $foo: any = 1024
echo ($foo.type == $expected)     # true

match $foo.type
{
    Integer =>
        {
            let $number: Integer = $foo
            echo $number
        },
    Float =>
        {
            let $number: Float = $foo
            echo $number
        }
    _ => { echo "another type" }
}

if    $foo.type == Integer
   && $foo == 1024
{
    let $number: Integer = $foo
}

Type equality compares exact identities. 7 has type Integer; 7.0 has type Float. Neither has type Number or any, although those annotations accept them. A value wrapped in type Count = Integer has type Count, and remains distinct from Integer. Structs, enums, and imported types retain their defining identities. Redeclaring a type creates a new identity; existing values and stored type references keep the previous one. Type values are distinct from strings such as "Integer"; String($foo.type) returns the display name.

Direct variable type guards narrow the variable inside the selected match arm, if branch, or while/until body. The compiler also follows ==, !=, !, short-circuit && and ||, and remaining alternatives in else and _ branches. For example, the right side of && above sees $foo as an Integer. Incompatible annotations, returns, and fields in a narrowed branch are compile errors.

Narrowing does not change the variable's declared assignment constraint or convert its value. Assignments, calls, and control-flow joins discard facts that may have changed. Closures check captured mutable values again when called; creation inside a guard does not permanently narrow a live capture. Runtime type checks still enforce annotations on writes and calls.

Ordinary arrays report Array and maps report HashMap; a guard retains any stronger element/key/value constraints already known by the compiler. Named collection types report their own distinct identity. Type values can themselves be collection elements and hash keys, and require an initializer when annotated as Type. The property name type is reserved for metadata and cannot be declared as a struct field or method, or assigned through .type.

Bare command arguments keep their word semantics: foo Integer passes the word Integer. Use foo (Integer) or a variable to pass a Type value. Existing Integer(value) and other conversion expressions keep their conversion behavior.

Match expressions

match evaluates a subject once and tries arm expressions from top to bottom. The first matching arm runs; its block supplies the result:

let $description = match $value
    {
        0            => { "zero" }
        1..10        => { "one through nine" }
        $expected    => { "the expected value" }
        $valid_range => { "inside the configured range" }
        $a..$b       => { "inside the other configured range" }
        _            => { "something else" }
    }

Non-range arm values use the same equality as ==, including literal expressions, arrays, maps, structs, and enum values. Variables supply their current values; they do not introduce bindings. Arm expressions are evaluated only when reached, and later expressions and bodies are skipped after a match. Function calls and arithmetic can supply the subject, an arm value, or range bounds.

A range-valued arm tests integer membership. a..b excludes b, a..=b includes it, and omitted bounds are unbounded. Reversed or empty ranges match nothing. Non-integer subjects do not match range arms; other arms can handle them. A variable holding a range has exactly the same behavior as a written range.

A bare _ is an optional fallback and must be last; "_" is an ordinary string pattern. Every arm requires a block. Commas after arm blocks are optional. An empty arm block returns (). An empty arm list is a syntax error. If no arm matches and there is no fallback, execution raises Match error: No arm matched the value.

Arm blocks have the same variable scopes, function hoisting, and alias rules as other blocks. return exits the enclosing function; break and continue target the enclosing loop. All arms are parsed and type-checked, even when not selected. Match expressions also work in assignments, returns, collections, and grouped command arguments.

Loops

All loops require a block and are statements. Body results are discarded; a function or conditional branch ending in a completed loop returns (). Each iteration creates a fresh scope for bindings and body-local variables. These can shadow outer variables; assignment still updates the nearest visible binding. Body-local bindings do not escape. Loops may nest in any combination.

return exits the enclosing function, and runtime errors stop execution. Both clean up active loop scopes and iterators. Function definitions and aliases inside loops follow the hoisting and global-alias rules for blocks.

For

Use one binding per yielded item, or destructure a yielded array into multiple bindings. Arrays yield their elements, ranges yield integers, and maps yield [key, value] pairs. The expression after in can be a literal, a variable, an indexed value, a conditional, or a function call:

for $index in 1..4
{
    echo $index              # 1, then 2, then 3
}

let $items = ["red", "green", "blue"]
for $value in $items { echo $value }
for $value in [10, 20] { echo $value }

let $settings = ["width": 80, "height": 24]
for $key, $value in $settings { echo $key $value }
for $key, $value in ["answer": 42] { echo $key $value }

A parenthesized binding list destructures each yielded array. This works with map entries, arrays returned by .zip, and custom iterators:

for ($test_file, $output_file) in $tests.zip $outputs
{
    echo $test_file $output_file
}

for ($x, $y, $z) in [[1, 2, 3], [4, 5, 6]] { echo $x $y $z }

Each element must be an array with exactly as many elements as the pattern has bindings. The check happens before binding any values or entering the loop body; a mismatch reports the loop's source location. Patterns are flat lists of one or more distinct variables; trailing commas, newlines, and comments are allowed. for ($value,) in [[1], [2]] unwraps each one-element array. An empty iterable skips the body without attempting to destructure an element.

The iterable is evaluated once, before any loop bindings are created. Array elements retain their types and are visited in array order. Maps visit each key/value pair once in unspecified order; keys use the map's canonical value representation. Iteration uses a snapshot: assigning to the source collection or mutating it during the loop does not change the remaining iterations. Changing a collection held by a loop binding also leaves the original element unchanged.

Range iteration is lazy and requires both integer bounds. .. excludes the end, ..= includes it, and reversed ranges are empty. Empty arrays, maps, and ranges skip the body. A map yields one [key, value] array per step, so either a single binding or for ($key, $value) in $map works. The existing unparenthesized for $key, $value in $map syntax remains supported and also destructures array items from other iterators. Duplicate binding names are errors.

for uses the next_item protocol. Any type can define a zero-argument method whose declared return type is T | ():

struct Counter { remaining: Integer }

fn Counter::next_item(): Integer | ()
{
    if $self.remaining == 0 { return () }
    let $item = $self.remaining
    $self.remaining = $self.remaining - 1
    $item
}

let $counter = Counter(remaining: 3)
for $item in $counter { echo $item }  # 3, 2, 1
echo $counter.remaining             # Still 3

The iterable is evaluated once and copied into a private receiver. Each step calls that receiver's next_item method. Changes to $self advance only that private value, so repeated and nested loops over the source have independent state. The method version is captured when the loop is compiled, following the same rules as other method calls. Methods can still perform ordinary external side effects or modify other variables in their defining scope.

Returning () ends the loop; every other value is yielded, including false, zero, empty strings, and empty arrays. Consequently an array containing () ends iteration at that element. To yield a unit value as data, wrap it in an array or struct. A map entry whose value is () remains a valid two-element array and does not end iteration.

Arrays, argument expansions, hash maps, and bounded ranges provide native next_item methods. Their signatures reflect the collection's contents:

Receiver type next_item return type
[Integer] Integer | ()
[String: Integer] [String, Integer] | ()
Range Integer | ()
Unspecified array or argument expansion any | ()
Unspecified hash map [any, any] | ()

Explicit annotations supply element types; otherwise homogeneous contents are inferred. Empty, mixed, or unknown contents use any. Hash keys and values are inferred independently, and nested collection types retain their inner types. Inference does not convert mixed integers and floats or constrain an unannotated variable's later assignments.

let $scores = ["alice": 10, "bob": 20]  # Inferred [String: Integer]
for ($name, $score) in $scores
{
    let $points: Integer = $score
    echo $name $points
}

The checker carries these item types into loop bindings, including destructured map pairs and annotated array shapes. It also reads existing collection types and contents when compiling a later REPL input. Inferred types are discarded where writes, calls, or control flow make them uncertain; explicit annotations remain authoritative. User overrides are never assumed to have a native method's signature, and dynamic values still receive runtime checks.

Native loops use efficient private cursors, and ranges remain lazy. User-defined overrides participate in the same protocol. A type without next_item produces an iteration error; methods with parameters or a return type lacking | () are rejected.

A direct call such as $items.next_item advances the actual receiver: it removes the first array element or one map entry, or advances a range's start. for advances a private copy instead. A saved method reference such as let $next = `$items.next_item retains the normal live-receiver behavior.

Fixed-length array annotations describe each position's type, making structured yields explicit:

struct Pairs { remaining: Integer }

fn Pairs::next_item(): [Integer, String] | ()
{
    if $self.remaining == 0 { return () }
    $self.remaining = $self.remaining - 1
    [$self.remaining, "item"]
}

for ($index, $label) in Pairs(remaining: 2) { echo $index $label }

[Integer, String] requires exactly two elements with those respective types. [Integer,] requires exactly one element; [Integer] remains a homogeneous array of any length. Fixed-length annotations can be nested and used for variables, parameters, returns, fields, and collections. Numeric widening applies to each position. Wrong lengths or element types fail validation. Destructuring still checks the actual yielded array before creating bindings.

T | () is another spelling of optional T, including in other annotations. General unions are supported too: an iterator can return Integer | String | (). Existing optional T iterator return annotations are equivalent.

Unbounded loop

loop { ... } repeats its required block without a condition or iterable:

let $count = 0
loop
{
    $count = $count + 1
    if $count == 2 { continue }
    echo $count
    if $count == 3 { break }
}
# Prints 1, then 3

An empty loop {} runs indefinitely.

While and until

while condition { ... } repeats while the condition converts to true. until condition { ... } repeats while it converts to false. Both check the condition before the first iteration and before every subsequent iteration:

let $n = 0
while $n != 3
{
    echo $n                  # 0, 1, 2
    $n = $n + 1
}
until $n == 0
{
    echo $n                  # 3, 2, 1
    $n = $n - 1
}

Conditions accept the same expressions and command calls as if, with the same boolean conversion and short-circuit rules. A command's zero exit status is true; nonzero status is false. while false { ... } and until true { ... } skip their bodies, although those bodies must still contain valid syntax. A block is always required, and newlines/comments may separate the condition from its opening brace.

The condition runs in the surrounding scope, before the body scope is created. continue rechecks it; break exits without evaluating it again.

Break and continue

break leaves the innermost active loop; continue skips the remainder of its current iteration. In for, it advances to the next element; in loop, it restarts the body; in while and until, it rechecks the condition. Neither accepts a value or a loop label:

for $i in 0..5
{
    if $i == 1 { continue }
    if $i == 3 { break }
    echo $i                  # 0, then 2
}

Both statements unwind the current iteration's scopes, including nested blocks, and discard abandoned expression temporaries. Executing either without an active loop in the current function or top-level execution produces an error. A called function cannot control its caller's loop. Skipped branches still require valid syntax, but do not execute their loop-control statements.

Processes, aliases, and environment

cd PATH changes directory and returns ExecResult(0) on success or ExecResult(1) on failure, so it can be used directly as a condition. exit stops execution with status zero; exit 7 stops it with status 7. An explicit exit status must be an integer from 0 to 255. echo and the other Unix commands in these examples are external programs found through PATH.

External commands return an ExecResult status. Their stdout is inherited by Shelly unless redirected; assigning a command result does not capture its printed output.

let $status = /usr/bin/false
echo $status                # ExecResult(1)

Assignment can retain a failed status as a value. An uncaptured failing command stops the remaining submitted source; an intermediate failing command also stops a function. The interactive REPL reports the error and accepts another input. A failing noninteractive script exits unsuccessfully.

Aliases prepend fixed arguments. Alias arguments are stored literally, without variable interpolation or automatic calls. Aliases are global even when declared inside a function. A newline, semicolon, or closing function brace ends an alias definition.

alias say = echo "prefix"
say 'hello'                 # prefix hello

Resolution expands aliases, then checks builtins, functions, and external programs, in that order. An alias can add defaults to its own command name; indirect alias cycles produce an error.

Shelly imports the environment. New variables are private unless declared with let export; only exported variables reach child processes.

let export $SHELLY_PROJECT = 'shelly'

Useful predefined variables include $args, $pid, $pwd, $HOSTNAME, $HOME, $PATH, $shelly (an executable reference to this binary), $version, $os (also $OS), $build_date, $build_time, $interactive, $login, and $rc_path. $pid is the running Shelly process's ID as an Integer. $last_cmd_time is the last REPL command's formatted elapsed time as a String. $os identifies the host operating system, for example "macos" or "linux", and can be used in functions to select platform-specific commands. $rc_path is the configured init path, <not found> when missing, or <unloaded> when init loading is disabled.

Each module also starts with an empty $widgets: [WidgetFn] array, where WidgetFn is a predefined distinct type equivalent to type WidgetFn = fn(): String. Assign named function references or closures to the array; each callback takes no arguments and returns a String.

$widgets = [fn (): String { "ready" }]
for $widget in $widgets { echo ($widget) }

File and variable redirection

-> redirects stdout, ~-> redirects stderr, and ~+-> sends both streams to the same destination. Data flows from left to right. These operators can be combined on one command:

let $output: String
let $errors: String
input.txt -> $output
sh -c 'echo output; echo error >&2' -> $output ~-> $errors
let $status = sh -c 'echo failed >&2; exit 1' ~+-> $output
echo $status                         # ExecResult(1)

A bare variable on the right of an output operator receives text; declare it first, with a type that accepts String. Captures preserve all UTF-8 text, including trailing newlines. Invalid UTF-8 produces an error. File-to-file and process-to-file transfers preserve arbitrary bytes. Capturing output does not change the command's return value or its normal failure handling.

Other destinations are file paths, created or truncated when opened. Quote a variable to use its value as a filename instead of capturing into the variable:

echo hello -> output.txt
let $log = 'command.log'
sh -c 'echo output; echo error >&2' ~+-> "$log"

A file can also supply text directly to a variable. A variable used as this source holds the filename, matching test.shy:

let $contents: String
input.txt -> $contents
let $path = 'input.txt'
$path -> $contents

A bare source word that resolves to a command runs that command; otherwise it names a file. Quote the source path to force file access when its name matches a command. Redirections also apply to commands called inside Shelly functions and to builtin diagnostics. Streams are restored on completion, errors, and returns. Each stream may be redirected once per expression. Left-facing redirection operators are not supported. Input from files or variables into a process will use the | pipelines described below. Append redirection is also not implemented.

Pipelines

Pipelines associate left to right. Shelly stages pass their returned values with their types intact; external programs pass stdout as an ordinary byte stream. Bare $ refers to the current incoming item within a stage. A function with no explicit arguments receives that item as its one argument. If arguments are specified, include $ wherever the incoming item belongs:

fn increment($n: Integer): Integer { $n + 1 }
let $answer: Integer
7 | increment | ($ * 2) | $answer

let $inputs: [Integer] = [1, 2, 3, 4, 5, 6, 7]
let $answers: [Integer]
$inputs | increment | ($ * 2) | $answers
echo $answers... # 4 6 8 10 12 14 16

fn append_header($message: String, $name: String, $value: String): String
{
    $message + "\n" + $name + ": " + $value
}
"hello" | append_header $ "Content-Type" "application/json"
producer | consumer

Parentheses group stage expressions and let a pipeline appear inside another expression. A stage returning () passes that value onward. A final bare named variable is an assignment destination and must already exist. Array destinations collect final items, checking their element types; scalar destinations receive successive items and retain the last. Empty input clears an array destination and leaves a scalar destination unchanged. Intermediate tabular stages process one row at a time without collecting the complete stream. Property-style getters such as $.count keep their normal implicit call behavior.

An iterable source yields items through its next_item protocol, just as a for loop does. Arrays yield elements, hashes yield key/value pairs, ranges yield integers, and custom iterators use their declared method. Iteration uses a private receiver, leaving the source unchanged. Only the source is expanded: an array returned by a later stage remains one item. An explicit outgoing format such as $inputs |:json command encodes the whole source value instead of iterating it.

Conversion annotations belong to the boundary, not to command arguments:

Boundary Operation
` `
` :json`
`json: `
`json: :ApiResponse`
`csv: :tsv`
let $message = ["message": "hello"]
$message |:json external_command

struct ApiResponse { message: String }
let $response = ApiResponse(message: "")
let $status = external_command json:|:ApiResponse $response

let $rows: [[String]]
external_command csv:| $rows
producer tsv:|:ssv consumer

json, csv, tsv, and ssv have shared conversion factories. Encoding invokes String::from_<format>(value); decoding invokes <format>::from_string(text). Scripted conversion factories can supply additional formats or override the native implementations, including through imported type extensions. A requested target first uses its from_<source_type> factory, then shared structural mapping. Fields and collection elements use that conversion protocol recursively. A name that identifies both an outgoing format and a destination type is an error. Unsupported formats and failed conversions report the annotated pipe's location, format, source type, and requested target. They never silently use display text.

CSV, TSV, and SSV decode each record into [String]. Delimiters inside quoted fields, doubled quotes, embedded newlines, Unicode, and CRLF records are supported. There is no implicit header when decoding ordinary rows. Decoding into a struct uses the first record as field-name headers, then maps each row's fields to the struct's declared types. An array-of-struct type annotation also maps one element record at a time; the final destination collects the array. Add a Shelly stage to add or remove headers as needed. Direct csv::from_string, tsv::from_string, and ssv::from_string convert one record; pipelines handle multiple records incrementally. Their corresponding String factories accept one row or an array of rows.

At an unannotated external boundary, Strings pass directly and other values need the existing String conversion protocol. Structured arrays, hashes, and structs need an explicit encoding or a matching String conversion factory. Unannotated external output entering a Shelly stage becomes a String after EOF; invalid UTF-8 is an error there. Bytes between external programs are preserved without UTF-8 conversion. External stages with explicit $ arguments run once per incoming item with closed stdin; other external consumers read one continuous stdin stream.

Execution status stays separate from data. If any external stage participates, the pipeline expression returns an aggregate ExecResult: the first failing stage's status, or success if all stages succeed. Stdout or decoded values travel through stages and into destinations independently. Capturing the status, as above, allows the existing ExecResult handling; an uncaptured failure fails the script. Pipelines made entirely of Shelly values and functions return their final value. Existing ->, ~->, and ~+-> redirections retain their meanings.

Supervised processes and terminals

run_process accepts an argument array and an options map. It returns a result map rather than raising a language error for a nonzero exit, signal, timeout, or launch failure. Inspect the result explicitly:

let $result = run_process ["/usr/bin/cat"] [
    "stdin_file": "input.bin", "stdout_file": "output.bin",
    "stderr_file": "errors.txt", "timeout_ms": 3000,
]
echo $result["exit_code"] $result["signal"] $result["timed_out"] $result["error"]

Options are cwd, env, stdin_file, stdout_file, stderr_file, and timeout_ms. Arguments, paths, and environment names/values must be strings. An omitted env inherits Shelly's exported variables; an explicit map replaces the environment, including [:] to clear it. File paths are relative to the calling shell's directory, independently of the child's cwd. Output files are truncated; use distinct files for separate streams. Omitted streams inherit the current streams, including Shelly's ->, ~->, and ~+-> redirections. Explicit file options override that inheritance. File I/O preserves arbitrary bytes.

Results always contain exit_code (integer or ()), signal (integer or ()), timed_out (boolean), and error (launch/I/O error text or ()). Invalid API arguments are language errors. Deadlines are nonnegative integer milliseconds; omitting timeout_ms waits indefinitely. On timeout Shelly sends SIGTERM to the child's process group, waits 100 ms, then sends SIGKILL and reaps the child. It also stops remaining group members after the direct child exits normally. Children that deliberately create a different process group escape this group cleanup. This API currently requires Unix.

The native terminal API creates a child with a controlling PTY:

let $terminal: Terminal = open_terminal ["/usr/bin/cat"] ["rows": 40, "columns": 140]
terminal_write $terminal "hello\n"
let $reply = terminal_read $terminal 1000
echo $reply["text"] $reply["eof"] $reply["timed_out"]
let $status = terminal_close $terminal

open_terminal accepts cwd, env, rows, and columns. Dimensions must be integers from 1 to 65535; defaults are 40 by 140. Terminal is an opaque shared handle with identity equality; assigning it shares the same terminal. Reads return UTF-8 text, EOF/timeout flags, and the process-result fields above. A read timeout does not kill the process or imply EOF. Reads may return partial output; UTF-8 sequences split between reads are retained, while invalid or truncated UTF-8 raises an error. Writes accept strings and have a five-second deadline if the PTY stops accepting input. Closing releases the PTY, stops the process group, and reaps the child; repeated closes return the same status. Dropping the last handle also cleans up. Writes and reads after explicit close are errors.

Strings provide $text.chars, $text.contains $part, $text.starts_with $prefix, $text.ends_with $suffix, $text.replace $old $new, $text.split $separator, $text.trim, $text.trim_start, and $text.trim_end. They return new values and never mutate the receiver. chars returns Unicode scalar strings; split retains empty fields, including leading/trailing ones. Trimming uses Unicode whitespace and is always explicit: captures still preserve trailing newlines.

Modules

Module declarations are private by default. Prefix a declaration with pub to make it available to importers, both through qualified names and selected imports:

# Example module: counter.shy
let $state = 0
fn increment(): Integer { $state = $state + 1; $state }

pub fn next(): Integer { increment }
pub let $label: String = "counter"
pub type Count = Integer
pub struct Snapshot { count: Count }
pub enum Status { Ready, Done }
pub fn Snapshot::read(): Count { $self.count }
pub alias advance = increment

Private names remain available inside their defining module, including in public functions, methods, and aliases. pub is allowed only at module top level, including declarations inside an enabled [when ...] declaration block. Methods are private unless marked pub, even when their receiver type is public. Struct fields and enum variants retain their existing access rules.

pub let export $NAME = ... makes a variable public and exports it to child processes. Plain let export affects the child environment only; it does not make the variable accessible through imports.

Imports are top-level declarations:

import foo
import net::{ ping }
import config::{ $enabled, Settings }
import platform when $OS == "linux"

foo::run "argument"
echo $foo::value
let $settings: config::Settings = config::Settings(enabled: $enabled)

import foo searches for foo.shy beside the importing file, then searches the directories in $SHELLY_MODULE_PATH, in order. Set that variable in the environment or an earlier REPL submission; it is a colon-separated String. For command-line source and REPL input, the first search directory is the current directory. Modules are cached by canonical path and initialized once per interpreter. Circular imports are errors.

Only public names listed in ::{ ... } enter the enclosing scope. Other public variables, functions, types, structs, enums, and aliases remain accessible through the module namespace: $foo::value, foo::run, foo::Point, and foo::State::Ready. Selected imports clone the original object's Rc into the enclosing scope map. They preserve type and function identity, and assigning through an imported variable updates the module's binding. A new let declaration creates a separate local binding. Reimporting the same object is allowed; importing a different object over an existing name is an error. Public aliases retain their defining module when resolving their targets.

Importing a module activates its public type extensions in the current module, including ordinary methods, iterators, and conversion factories. This also applies to selective imports: import helpers::{ run } activates the public extensions from helpers without bringing other names into the local scope. Private extensions remain available only in their defining module.

Imports are private too. Re-export selected names with pub import, or publish an entire module namespace with an unselected pub import:

import implementation::{ helper }       # Local access only.
pub import counter::{ next, Count }     # Export next and Count, not counter's namespace.
pub import net                         # Export the net namespace.

A pub import also re-exports the source module's public type extensions, even when selecting particular names. Extensions therefore remain active through chains of public imports. A private import keeps those extensions local to its module. Reimporting the same extension is allowed. An imported scripted conversion factory takes precedence over a typedef's implied factory, regardless of import order. Different imported scripted implementations of the same type method conflict. Local method definitions take precedence over imported extensions.

An importing script can then use bridge::next, bridge::Count, and bridge::net::ping. It cannot access bridge::implementation::helper or bridge::counter::next. Implicit prelude bindings are local to each module; re-export a prelude type explicitly when it belongs in that module's interface.

Imported functions execute with their defining module's variables and functions. Qualified function calls follow ordinary argument rules: echo foo::read passes the name as text; echo (foo::read) calls it. A backtick, as in `foo::read, keeps a callable reference.

Native registration and module visibility are separate. Rust registers a type or function once; its registration can be NativeVisibility::Visible or NativeVisibility::Hidden. Visible registrations seed each module's local names. Hidden registrations remain available internally without appearing in module name lookup. Existing core native registrations remain visible by default.

The visible builtin accepts one function reference or type name. It makes the symbol available locally; pub visible also adds it to the current module's exports:

# Example module: std/process_tools.shy
visible "Terminal"                 # Local type for implementation signatures.
visible `run_process               # Local native function used by wrappers.
pub visible `open_terminal         # Native function exposed to importers.

pub fn run($command: Array, $options: HashMap): HashMap
{
    run_process $command $options
}

An importer can call std::process_tools::run and std::process_tools::open_terminal, or select those names with ::{ ... }. The module does not export Terminal or run_process in this example. To expose a type as well, use pub visible "Terminal". Scripted definitions continue to follow the normal module export rules, so the wrapper and the native function share one public interface.

For a hidden native function, use a backtick reference such as visible `native_fn. For a hidden native type, visible "NativeType" looks directly in the native registration table. Plain strings name types, not functions. Qualified references and type names work too: pub visible `other::helper and pub visible "other::Type" publish their unqualified names in the current module. Function references stored in variables are accepted. External commands and bound methods are not module function declarations and cannot be published.

Literal top-level visibility declarations are processed before type checking, so annotations and wrappers throughout the same file can use native implementation types. Declarations inside excluded [when false] blocks are skipped. Computed arguments take effect at runtime. Local visible calls inside functions or ordinary blocks also take effect at runtime; pub visible requires module top level. New type names are then usable by subsequent compilations. Failed compilation rolls back the declarations prepared for that submission.

Visibility changes apply to the current module. Hidden registrations do not become visible in unrelated modules; importers see only exported names. Repeated publication of the same object is allowed, conflicting bindings are errors, and calling local visible does not revoke an existing export. visible --export is replaced by pub visible. Native function references and imported types retain their original identities through imports and re-exports.

On the Rust side, NativeFunction::new(name, visibility, body) creates a native function registration, and TypeRegistry::register_native(name, kind, visibility) registers a native type. The registry retains hidden entries while new module scopes copy only initially visible names. This lets a future std/json.shy select native JSON exports with pub visible and implement its remaining interface in Shelly; a native JSON API is not implemented yet.

Standard-library imports use a separate search path:

import std::foo
import std::net::{ ping }
import std::foo when $OS == "linux"

std::net::ping "host"

std::net resolves to net.shy directly inside a standard-library directory. It never searches beside the importing file or through $SHELLY_MODULE_PATH. $SHELLY_STD_PATH is a colon-separated String; a set value replaces all default search directories, including when it is empty. When unset, the directories are:

/etc/shelly/std:/usr/local/share/shelly/std:/usr/share/shelly/std

Search proceeds left to right; the first matching module wins. An error in that module is reported instead of trying a later copy. Selection happens per module, so different modules can come from different directories in the same search path. Empty path entries are skipped. To put a development library before the installed library while retaining fallback directories, include them explicitly:

let export $SHELLY_STD_PATH = "/home/me/workdir/shelly/std:/etc/shelly/std:/usr/local/share/shelly/std:/usr/share/shelly/std"

Qualified access retains the std:: prefix, including variables ($std::foo::value) and types (std::foo::Type).

Shelly selects std::prelude by searching for prelude.shy using the same standard-library search path. Its public types are automatically available without qualification in the main scope and subsequently loaded modules. These imports share the original type handles and preserve type identity. Prelude functions and variables remain qualified unless explicitly selected:

import std::prelude::{ helper, $setting }

Startup runs in this order:

Stage When it runs Purpose
/etc/shelly/profile.shy Login shells (-l or --login) System-wide environment and library selection.
~/.shelly_profile.shy Login shells, after the system profile User environment and overrides.
Initial prelude load All modes, unless already loaded by a profile Select and execute the prelude once.
~/.shelly_init.shy or --rcfile PATH Interactive shells, unless --norc User configuration with prelude types available.
User input or script After startup Execute commands with the selected prelude.

Missing profile files are skipped. Profiles initially have no implicit prelude; they can configure its location before it loads. If the system profile explicitly loads it, the user profile also has those types available. Non-login shells skip both profiles and select the library from their inherited environment. Noninteractive script, -c, and stdin modes still load the prelude, but skip the interactive init file. --norc skips only that init file, not login profiles or the prelude. Configure a terminal to launch shelly --login when its sessions should read the profiles.

For example, a system administrator can put this in /etc/shelly/profile.shy to select the machine's standard library:

let export $SHELLY_STD_PATH = /home/me/workdir/shelly/std

With no explicit reload, Shelly waits until both profiles have run before selecting the prelude. The user can therefore set a different path in ~/.shelly_profile.shy before that first load. let export also passes the configured path to child processes, including non-login Shelly instances. Each new interpreter loads its own prelude; the cache is not shared across processes.

To load the chosen prelude immediately for following startup scripts, add the zero-argument builtin prelude_reload to the profile:

# /etc/shelly/profile.shy
let export $SHELLY_STD_PATH = /home/me/workdir/shelly/std
prelude_reload

A successful profile reload satisfies startup's initial load, so Shelly does not execute it again before the RC. If a later user profile or RC chooses another library, it must call prelude_reload after setting the path to replace the already selected prelude. The same sequence works at the REPL.

Changing $SHELLY_STD_PATH alone changes subsequent uncached standard-library lookups; it does not rerun the prelude or replace cached modules. The selected prelude remains cached even if its source file is removed. Explicit import std::prelude reuses the current scope's cached generation. If no prelude exists during initial discovery, execution continues without one and Shelly does not automatically search again. Errors in a prelude found during automatic initialization stop startup. The prelude does not implicitly import itself; its dependencies bootstrap before its exported types are distributed.

prelude_reload searches the current $SHELLY_STD_PATH, or the defaults when unset, and executes the selected prelude.shy again.

A successful reload replaces the implicit prelude types and std::prelude namespace in the current module scope, and supplies that generation to future modules. Previously loaded modules retain their bindings. Existing values, function references, and explicitly selected functions or variables keep their original identities. Old implicit type names absent from the new prelude are removed; conflicting local types cause an error. Reloading the same file still creates new nominal types. Dependencies already loaded remain cached.

New type bindings apply to subsequent compilations: the next REPL input, startup script, or newly loaded module. A script's imports and type references are resolved before its body runs, so a reload in that body cannot retroactively change them.

Unlike optional startup discovery, an explicit reload reports a missing prelude. Lookup, parse, evaluation, and binding conflicts preserve the previous prelude bindings. Script side effects, such as output or file writes, cannot be undone. Recursive reloads during prelude loading are rejected.

All when conditions are evaluated before loading the containing file's explicit imports or compiling its body. They see the existing caller environment, including prelude types and prior REPL submissions, and cannot use names declared or imported in the same file. A false condition skips file lookup and initialization entirely. After the conditions are evaluated, enabled imports load in source order, then the body compiles and runs. Import placement does not delay loading until execution reaches that line.

Conditional declarations

Attach [when expression] to a function or a declaration block to select code before compilation:

[when $os == "linux"]
fn os_gadget()
{
    echo "Linux implementation"
}

[when $os == "macos"]
{
    fn os_gadget() { echo "macOS implementation" }
    let $platform_label = "macOS"
}

The AST retains each condition until the compilation prepass evaluates it using Shelly's normal truthiness rules. A false condition removes the entire function or block before import resolution, type checking, and function registration. Evaluation errors are reported; they are not treated as false. Excluded source must still be syntactically valid, but may refer to unavailable types or modules. Nested conditions inside excluded code are not evaluated.

An included annotated block inserts its contents into the surrounding scope. Its variables, types, and functions remain visible afterward, and its statements execute in their original position. Ordinary unannotated blocks retain their usual scope. To conditionally declare a struct, enum, variable, or import, put it inside an annotated block.

Like import ... when, exclusion conditions use the environment from before the containing file or REPL submission loads. They cannot depend on declarations or imports in that submission, or on function parameters and runtime loop variables. Conditions are evaluated once during compilation, including those written inside function bodies. Prior REPL bindings and functions are available.

Current limitations and known issues

  • Variables use dynamic caller scope; function definitions are hoisted within each input.
  • Command results are statuses, not captured stdout. Shell errors in noninteractive execution return a general failure status; only explicit exit N selects a specific status.
  • Append redirection and array append syntax are not implemented.
  • Iterating or expanding a range requires both bounds. break cannot carry a value or target a named loop. Standalone blocks are statements, not general expressions.
  • Some parser diagnostics contain verbose lists of attempted alternatives.

Implementation and development

src/main.rs selects the execution mode. src/runtime/repl.rs implements the Reedline editor, completion, and prompt. The active tokenizer, parser, AST, compiler, values, and interpreter live under src/language/. Source is tokenized and parsed into an AST, checked against a staged type registry, compiled to bytecode, optimized, linked, then executed. The initial checking pass registers lexically scoped enum, struct, and named type identities before resolving annotations. It checks constructors, required-field cycles, annotations, known incompatible initializers, assignments and returns, and known member accesses. Runtime checks cover dynamic values, function arguments, return paths, and nested writes. Collection and local binding inference propagates known item types into loops; broader function and control-flow inference remains future work. Builtin types, container constraints, unions, enums, structs, and named types share stable TypeIds independent of name visibility.

Two optimization passes run before linking: adjacent PopResult/PushResult pairs are removed, and redundant CheckResult instructions are dropped only when the compiler can prove the result is already empty. The proof is conservative across calls and control-flow boundaries.

The interpreter stores named scopes in a HashMap<String, Scope> and tracks the current scope by name. Startup creates and selects the main scope. Loaded modules have separate entries keyed by canonical source path; qualified names resolve through this registry. Function calls select their defining scope and restore the caller's scope on completion or error.

Each Scope owns variable bindings, the type registry, function namespaces, and aliases. It preserves caller-scoped variables and lexical type and function identities across submissions. Function entry and exit update the variable scope and active function namespace together. Built-in handlers, special variable readers, I/O state, and execution results remain on the interpreter.

Jumps and EnterLoop initially refer to labels. Linking resolves them to numeric instruction indexes independently for each function and the top-level code, rejecting missing or duplicate labels. JumpTarget instructions remain as landing points, without their labels; the interpreter sees only numeric destinations.

Blocks use EnterScope and ExitScope. EnterLoop pushes the continue/break addresses and saved execution depths; ExitLoop pops that frame. Break and Continue unwind scopes and temporary values before jumping. For loops also use iterator instructions that capture and advance a private next_item receiver; while and until use ToBoolean and conditional jumps. Loop and iterator stacks belong to the current execution frame and are cleaned up on returns and errors.

Build with cargo build --locked and check behavior through command-line source, scripts, or the REPL. cargo clippy --locked --all-targets runs the Rust lints.

test.shy runs the Shelly test suite from the repository root:

./target/debug/shelly -m test.shy
./target/debug/shelly -m test.shy native
./target/debug/shelly -m test.shy C001

The suite includes 4,319 process cases, 98 stateful REPL scenarios, prompt/path checks, watchdog probes, native API tests, and harness failure controls. All orchestration and assertions run in Shelly; no Python, pexpect, or other shell is needed. Standard Unix utilities still provide file operations and byte/regex comparisons. See tests/README.md for the case format and tests/MIGRATION.md for coverage mapping.

Each case gets a private temporary fixture, isolated HOME/TMPDIR, an explicit child environment, and native process deadlines. The runner continues after failures and exits 1 if any test fails or the selection is empty. Normal completion removes temporary fixtures; interrupted runs can leave directories under /tmp/shelly-suite.*.

Keep validation releases separate from an installed/default-shell binary:

cargo build --locked --release --target-dir target/test-validation
./target/test-validation/release/shelly -m test.shy

This does not replace target/release/shelly.

Direction

The aim is to keep the immediacy of a shell while giving larger scripts a clear path to structure. Future work includes broader type inference and contracts, network APIs and richer format conversion for structured pipelines.