3
2
Fork
You've already forked shello
0
No description
  • Rust 100%
2025年12月23日 17:20:43 +01:00
shello fix basic parsing, we can now run the example script again. 2025年12月23日 17:20:43 +01:00
shello-syntax fix basic parsing, we can now run the example script again. 2025年12月23日 17:20:43 +01:00
.gitignore initial commit 2025年12月22日 14:16:30 +01:00
LICENSE initial commit 2025年12月22日 14:16:30 +01:00
README.md fix basic parsing, we can now run the example script again. 2025年12月23日 17:20:43 +01:00

Shello

A better alternative to shell scripting.

Why?

Shells suck. Personally, despite literal decades of experience scripting them and knowing how to do everything correctly, somehow I still find it pretty difficult to write 100% correct software in them. They are just horrible and incredibly error prone, not the sort of thing we'd like to be running in production.

Despite this, the average docker container today is seemingly started by a horrible shell script that seems to work until it doesn't. This isn't the fault of the people writing those scripts, they're left choosing between a crap language and paying the tens to hundreds of megabytes that apparently most programming languages cost in disk size these days.

Shello was started primarily to solve this problem - a better programming language for doing shell-like things at a filesize that makes it usable even in many size-sensitive containers.

As I've been building it, I find myself wanting more features to make my common tasks easier, so it's unclear how realistic the size constraint is - we'll see how it evolves.

Aims:

  • Small binary size
    • Ideally deployable even in size-sensitive docker containers.
  • Good developer experience
    • Good for programming
    • Good for running programs
    • Helpful error messages
    • Easy to learn and write
    • LSP integration
  • Native concurrency
    • Green threads powered by async rust.
    • Multithreaded lightweight executor.

Status: pre-alpha

Warning: this software is not ready and doesn't actually work yet. It was hurried because writing a programming language is a surprising amount of work and the code is jank in places because it needs more TLC. In other words, feel free to hack and play, but do not rely on it. At least not yet.

Evaluator:

  • Work through remaining stubbed out implementations
  • Add many more methods
  • Handle ^ (trim leading whitespace) modifier on strings
  • Closure minimisation:
    • Add analysis pass to find the required closure
    • Add the analysis to FunDef
    • Evaluating FunDef, only pack the required closure

Parser:

  • Rewrite operator handling to use winnow's new pratt support
  • Add cut points to help error messages
  • Add context for errors
  • Make streamable for interactive use
  • More checks:
    • Pattern matches must ensure their new bindings are unique

Misc:

  • CLI options
    • default worker thread count
  • CLI parsing and dispatch
  • Multithreading
    • Primitive for spawning worker threads
    • Shutdown mechanism
  • Extract line/column numbers from spans and sources
  • Pretty-print errors
  • Example programs
  • Tests
  • Design libraries

Syntax

Everything is an expression except assignment forms and function declarations which are statements. Expressions are accepted anywhere statements are accepted. Statements are separated by semicolons in blocks

Literals:

true
false
1
1.2
[1,2,3] -- array
{'a': 1, 'b': 2} -- map
${'a', 'b'} -- set

Strings:

:colon-string # ends at a space, no interpolation
$"Hello, $(name)" # interpolate the expression `name`
$"Hello, $name" # interpolate the variable `name`
\""

Quoted strings may span multiple lines.

There are a variety of features you may opt into by prefixing a quoted string literal with one of these modifiers:

  • $ - interpolation ($name or $(expr))
  • \ - character escapes (\c), see table below
  • % - unicode escapes (\u{x} - x is 1-6 hex digits)
  • ^ - trim leading whitespace from each line

Supported escape characters:

  • \n (newline)
  • \r (carriage return)
  • \t (tab)
  • \b (bell)
  • \f (form feed)
  • \\ (backslash)
  • \' (single quote)
  • \" (double quote)

Regex literals:

Regex literals are like quoted string literals except they use backticks (`). They result in a regex object rather than a string:

`Hello (?P<name>.+)`

Simple expressions:

var // access variable `var`
func(1,2,3) // call function `func` with three arguments
func(1, ...as) // splat arguments after the first from `as`
obj.property // access property `property` of `obj`
obj.method() // call method `method` on `obj`
arr[0] // look up item 0 in array `arr`
arr[0...2] // copy first 2 items of array `arr`
map["b"] // lookup `"b"` in map `map`.
a + b // addition
a - b // subtraction
a * b // multiplication
a / b // division
a % b // remainder
+expr // expr
-numexpr // negate numexpr
!expr // boolean negation (false if expr is truthy, else true)
a == b // compare equality
a != b // compare inequality

Control flow expressions:

if cond-expr { statements } else { statements }
if true { true } else { false }
Short circuiting logic ops:
expr && expr
expr || expr

Splices in literals:

a = [2,3]
b = [1, ...a, 4] # [1, 2, 3, 4]
c = {'a' = 1, 'b' = 2}
d = {'c' = 3, ...c} # {'a' = 1, 'b' = 2, 'c' = 3}
e = ${'b','c'}
f = ${'a', 'd', ...e} # ${'a', 'b', 'c', 'd'}

Match expressions

match expr {
 pattern = expr, -- unguarded match
 pattern | expr = expr, -- guarded match
}

Patterns are considered in sequence from first to last, with the first matching pattern causing its corresponding expression to be evaluated and returned. Patterns first attempt to bind, then their guard expression (if any) is checked to return a truthy value.

No more patterns will be considered after the first match and the return of the whole match expression will be the result of evaluating the expressions associated with that match. If no pattern matches, an error will be raised. You may add a catchall clause by binding to a variable to return a default.

Basic data in patterns match when the underlying data is equal to the passed in data. Variables always match, binding the underlying value to their name. To match against dynamic data, you can interpolate with $name or $(expr)

Array patterns are specified like array literals, with patterns substituting for expressions, e.g.:

["a"] # match an array containing "a"
[1, a] # match an array of two items (1 and the value of `a`)
[$a] $ match an array of one item (the value of `a`)

Arrays may optionally use a single 'rest' or 'scoop' argument, where all unnamed arguments are bound to this name as an array. This need not occur at the end as in most languages:

["a", b, ...c] // a traditional scoop argument
["a", ...b, c] // this one takes an argument at either end and binds the extras in the middle, if any.

Set matches are like array matches, except that because sets are logically unordered, we don't permit binding variables in them (even deeply), which would in general require us to exhaustively search the set. The sole exception is the set's optional rest argument, which binds a set.

${$a} # match a set containing the value of `a`
${"a", ...rest} # match a set containing "a" and bind the other items to `rest`.

Maps are a little more complicated - keys work like sets (no variables), but values are not restricted. An optional rest argument is supported and will be received as a map.

{"a" = 1} # match a map containing the key `"a"` with value 1.
${"a" = 1, ...rest} # the same, but bind the leftovers to `rest`.

String matches:

match command {
 "awk" = "no",
 "perl" = "no",
 "shell" = "hell no",
 _ = "ok",
}

Advanced string matches.

hi = re(`^hi, i'm (?P<name>.+)`); # matches a greeting
match greeting {
 # if the regex matches, its captures will be bound in the environment for convenience
 $(hi) = echo "hi, $(name), nice to meet you"
 `goodbye` = echo `see you!`
}

Anonymous functions

fn (args...) { statements }
fn name(args...) { statements } # name is locally visible for recursion
# e.g.
fn (name) { echo("hello ", name) } // unnamed
fn fib(i) { if i < 2 { 1 } else i * fib(i-1) }

Assignment statements:

let x = expr // bind the result of `expr` to a new lexical constant 'x'
mut x = expr // bind the result of `expr` to a new lexical variable 'x'
x = expr // update the existing lexical variable `x` to the result of `expr`

Function definition statements:

def name(args...) { statements }
# e.g.
def hello(name) { echo("hello ", name) }

Error handling

try {
 statements
} catch { pattern = expr, // unguarded pattern match
 pattern | expr = expr, // guarded pattern match
} finally {
 statements
}

At least one of a catch block or a finally block must be provided.

  • A finally block will be run regardless of whether an error is raised
  • If no error is throw, the result of the try expression is the result of the last statement within the try block.
  • If an error is throw, we match against the patterns with the same semantics as a match expression. If any pattern matches, the error is considered resolved and the return value is the value of the

Pattern unification

In general, data literals match themselves. There are some special rules where literals can match a different type of data:

Examined Pattern Action
int float Int cast to float, floats are equal[1]
float int Int cast to float, floats are equal[1]
string regex String matches regex (binds named matches)[2]
regex string String matches regex (binds named matches)[2]
not func function Function called with arg returns truthy[3]
function not func Function called with arg returns truthy[3]

[1]: Floats are not necessarily comparable, results may be surprising, but should work with integrals. [2]: Named patterns in the regex are bound to variables in the new scope. [3]: 2 Functions are compared for identity.

Primitives

  • echo(val, ...vals) - prints all items to the screen followed by a newline.
  • print(val, ...vals) - prints all items to the screen.
  • read_file(path_string) - reads the contents of the file at the path into a string.
  • write_file(path_string, arg, ...args) - writes all stringified items to the path.
  • append_file(path_string, arg, ...args) - writes all stringified items to the path.

Methods

There are piles of simple methods on the rust basic types that i haven't wrapped yet. These might make good introductory contributions as they're typically very straightforward.

Bool:

Int:

Float:

  • abs()
  • acos()
  • acosh()
  • asin()
  • asinh()
  • atan()
  • atan2(float)
  • atanh()
  • ceil()
  • clamp(low_float, high_float)
  • cos()
  • floor()
  • sin()
  • signum()
  • sqrt()
  • tan()

Range:

Regex:

(Regex) Match:

String:

Array:

  • len()

Map:

  • len()

Set:

  • len()

Function:

Command:

Contributing

Human contributions are welcome, LLM contributions are not and you may be banned for trying.

Copyright (c) 2025 James Laver

This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/.