shelly-shell 0.2.0

A Rust based Unix style shell with a typed and structured language syntax.
shelly-shell-0.2.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. The language audit records working features, known defects, and the results of the repairs following that audit.

Build and run

Use a Rust toolchain supporting edition 2024. The current audit was run on Linux.

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 or Shift+Enter inserts a newline for multiline input; Enter submits the buffer. 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.

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. --norc does not disable login profiles. Script, -c, and stdin modes skip interactive init. -b suppresses the banner; -m requests monochrome output.

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

Values include signed 64-bit integers, floating-point values, booleans, strings, arrays, the no-value result displayed as (), and external command statuses such as ExecResult(0). Arrays currently come from $args and file globs; array literals and indexing are not implemented. Without ..., an array becomes colon-separated text when passed to a command. () is a result's display form, not an accepted empty expression literal.

Arithmetic supports +, -, *, /, and %, with normal precedence, left associativity, and parentheses. Operations currently convert operands to integers: 7 / 2 produces 3, and 2.9 + 1.9 produces 3. Numeric strings convert to integers; a string that cannot be parsed as an integer converts to zero. Boolean operands convert to 1 or 0. This is not floating-point arithmetic. Signed numbers and unary minus work: -2 + 3 produces 1, and -(2 + 3) produces -5. Division/remainder by zero and integer overflow produce language errors, including in release builds.

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

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.

An unquoted source word beginning with ~ or ~/ expands to the home directory. Quoted tildes and tildes obtained from variables remain literal. Variables, interpolated strings, and command arguments retain real filesystem paths, so both cd $p and cat "$p/file" work with a stored absolute path. Shelly shortens home paths to ~/ in its own display formatting, such as the prompt; an external command like echo $p receives and prints the real path.

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

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

Functions, calls, and return values

Functions have named parameters and local variable scopes. Call them like commands. Arguments are evaluated left to right, then converted to text for the callee; numeric types and executable markers do not survive parameter binding.

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

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

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, or nested function definition 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 Call foo with no arguments if it resolves, then pass its result to echo; otherwise pass the word.
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 and stores the name without calling it. There is no closing backtick. The name is resolved when invoked; it is not a captured function object.

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                  # 2048
echo ($call)                # 2048
echo `answer                # answer

A standalone variable, a variable command argument, or a variable/string inside parentheses is called with no arguments when its value is marked executable. 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 suppresses the automatic call for that argument. 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's name without calling it, use "$call" or `$call. 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. The last definition of a name in that source wins even for earlier calls. Functions can contain helper functions:

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

Variable lookup searches active 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.

Processes, aliases, and environment

cd PATH changes directory. 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; 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, $pwd, $HOSTNAME, $HOME, $PATH, $shelly (an executable reference to this binary), $version, $OS, $build_date, $build_time, $interactive, $login, and $rc_path. $rc_path is the configured init path, <not found> when missing, or <unloaded> when init loading is disabled.

Current limitations

The audit report records the repaired defects and reproducible checks. The language still has deliberate limits:

  • Arithmetic converts operands to integers rather than preserving floating-point values.
  • Function arguments become text, losing their original types and executable markers.
  • Variables use dynamic caller scope; function definitions are hoisted within each input.
  • Arrays come from arguments and globs; literal collections and indexing are still planned.
  • 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.
  • Some parser diagnostics still contain verbose lists of attempted alternatives.

Malformed declarations, missing statement separators, duplicate parameters, and invalid UTF-8 source now produce errors. Arithmetic errors no longer panic the shell. The audit covers language behavior on Linux; it is not exhaustive testing of terminal editing or other operating systems.

Where Shelly is going

The aim is to keep the immediacy of a shell while giving larger scripts a clear path to structure. A command that is convenient at the prompt should also be useful inside a function or a longer program.

  • Optional typing. Add annotations and contracts where they help document intent and catch mistakes while keeping small interactive tasks lightweight.
  • Network and JSON support. Work with remote services and structured values directly from commands and functions.
  • Pipelines for text and structured data. Connect Unix tools with commands that consume and produce structs, with conversions at command boundaries.
  • Control flow and collections. Conditionals, loops, comparisons, boolean operators, array literals, and indexing are not yet implemented.

The draft test.shy sketches how Shelly could test itself by discovering scripts, looping over them, inspecting results, and reporting failures. It is a design sketch, not a runnable test suite. Script execution and command results as values already work; the draft's control flow and richer data operations remain future work.