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.
Shelly supports interactive use, script files, command-line source, and stdin:
|
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 Nselects 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.