Foreword

This is the official guide of Hush, a modern shell scripting language. A shell scripting language is a domain specific language which provides constructs for easily invoking and interconnecting external programs. These kind of languages are typically used for integrations in Unix systems and infrastructure programming. GNU Bash, Zsh and Fish are some of the most commonly used ones.

Logo
Hush's logo

But why do we need a new shell scripting language?

Traditional shell scripting languages are notoriously limited, error prone, and frequently result in programs that are hard to maintain. Even Google mentions it on their Shell Style Guide:

If you are writing a script that is more than 100 lines long, or that uses non-straightforward control flow logic, you should rewrite it in a more structured language now. Bear in mind that scripts grow. Rewrite your script early to avoid a more time-consuming rewrite at a later date.

Hush strives to enable the development of robust and maintainable shell scripts. It does so by providing industry proved programming constructs, support for beyond trivial data structures, sane error handling and variable expansion. While most shells are command interpreters that strive to become programming languages, Hush is more like a general purpose programming language with first class shell capabilities.

Overview

In order to attain it's goals, Hush takes great inspiration in Lua, the industry proven embedded scripting language. If you're familiar with Lua, you'll notice that, except for the shell capabilities, Hush feels a lot like a simplified version of it.

As such, Hush provides:

  • Static scoping
  • Strong dynamic typing
  • Garbage collection
  • First class functions
  • Good support for functional programming
  • Basic support for object oriented programming
  • First class shell capabilities

While being somewhat similar to scripting languages like Lua, Python and Ruby, even though it is much simpler than those, Hush strives to feel pretty much like Bash when it comes to shell capabilities. There are only minor syntax differences regarding invoking and interconnecting external programs, and therefore you won't have to learn all the shell syntax again.

But while most shell and even generic purpose script languages focus a lot on flexibility, Hush favors robustness, which may come at cost of some flexibility. As such, the language will make it's best to empower the programmer to write robust scripts that work diverse scenarios. It does so by preventing, by design, whole classes of bugs that often occur in shell scripts.

Being a shell script language, the typical use cases for Hush are operating systems instrumenting and infrastructure programming. In practice, Hush should be a good fit in any scenario where the heavy lifting is done by external programs, and you just need to put them to work together.

Installation

Hush comes as a single binary, which can be installed on Unix systems without much hassle.

Packaged distributions

Currently, Hush is packaged for the following operating systems:

  • Arch Linux: AUR

Binary download

Precompiled binaries may be downloaded from the Github releases page. Currently, there is only a x86 Linux binary. If you would like to help by providing binaries for other architectures or operating systems, please let me know.

Building from source

Hush can be installed from source using cargo:

cargo install hush

Editor support

The following editors have plugins available:

Introduction

In this section, we'll learn more about the basic constructs provided by Hush. That'll enable you to implement Hush scripts using functions, data structures, and control flow.

Hello World

With Hush installed, we're able to execute scripts. Here's the traditional Hello world program in Hush:

#!/usr/bin/env hush

std.print("Hello world!")

The first line is a Shebang. It tells the operating system which interpreter to use for the script. The second line is a function call of the print function from the standard library. When executed, this script will output Hello world! to the standard output.

Making the script executable

To execute this script, save it to a file named hello-world.hsh, give it execution permission, and then run it:

$ chmod +x hello-world.hsh
$ ./hello-world.hsh
Hello world!

Calling Hush directly

You can also execute a given script calling Hush with the script path as argument. When executing a script using this method, the Shebang is unnecessary, and will be disregarded as an ordinary comment.

$ hush hello-world.hsh
Hello world!

Alternatively, when given no aguments, Hush will start a non-interactive shell. You can go ahead and write your Hush script into the terminal. When you're done, press Ctrl-D and Hush will execute everything you've typed. Ctrl-D sends a special End of File (EOF) signal to the Hush interpreter so that it knows where the script ends.

Tooling

Hush provides some tools for static analyses of scripts, which can be invoked by passing flags to the shell. The most useful one is the --check flag, which will check the script for syntax and semantic errors, without executing it.

Consider the following script.hsh, which attempts to use the undeclared variable value:

value = "Hello world!"
std.print(value)

We can check verify that the script has semantic errors:

$ hush --check script.hsh
Error: script.hsh (line 1, column 0) - undeclared variable 'value'
Error: script.hsh (line 2, column 10) - undeclared variable 'value'

To get a list of other useful flags, run hush --help.

Type System

Hush is strongly dynamically typed, which means all values have a well formed type, but variables are untyped. Therefore, you may assign values of distinct types to a given variable. Python, Ruby, and Lua are also dynamically typed languages, in case you're familiar with any of them.

As in Lua, Hush proposes only a handful of built-in types, and no user-defined types. This makes the type system extremely simple, and yet still it remains quite expressive. The following types are available:

  • nil: the unit type, usually for representing missing values.
  • bool: the boolean type.
  • int: a 64 bit integer type.
  • float: a 64 bit floating point type.
  • char: a C-like unsigned char type, 0-255.
  • string: a char-array like immutable string.
  • array: a heterogeneous array, 0-indexed (unlike in Lua).
  • dict: a heterogeneous hash map.
  • function: a callable function.
  • error: a special error type, to ease distinction of errors from other values. This type can only be instantiated by the built-in std.error function.

Although it may seem like a limitation to only have a handful of types, Hush provides facilities that enable these types to be extremely flexible. We'll get more in depth about that in practice on the Paradigms section.

Basic Constructs

As Hush aims to be simple language, only standard control flow constructs and operations are supported.

Comments

Comments start with a #:

# This is a comment.

Variables

In Hush, all variables must be declared. This slightly increases verbosity, but makes scope rules way more reasonable, and will prevent you from ever debugging why the value of the collcetion variable is nil (note the typo).

let x # Introduces the variable in the local scope

let pi = 3.14 # Syntax sugar for assignment

let string = "hello!"

let byte = 'a', # this is a single byte, not a string. Note the single quotes.

let array = [ 1, 2, 3, 4 ]

let dictionary = @[
	key: pi,
	items: array,
	nested: @[ one: 1, two: 2 ],
]

Identifiers must start with a letter or underscore, and may contain further letters, digits and underscores.

Operators

Hush provides standard arithmetic, logical, relational and indexing operators.

  • Arithmetic +, -, *, /: int or float; %: int only. These operators do not promote ints to float, and will panic with mismatching types. You may explicitly convert your ints to float prior to applying the operators.
  • Logical and, or, not: bool only. Logical operators are short-circuiting.
  • Relational ==, !=: all types; <, <=, >, >=: int, float, byte, string only.
  • String concatenation ++: string only.
  • Indexing [], .: array, dict, string, error only. Attempts to access an index out of bounds will result in a panic. Additionally, dicts and errors may be indexed with the dot . operator, as long as the key is a valid identifier.
let array = [
	1 + 2,
	3.0 * std.float(4), # explicit conversion to float.
	21 % std.int(5.2), # explicit conversion to int.
	false and not true, # will short circuit.
	1 == 1.0, # false, int and float are always distinct.
	"abc" > "def",
	"hello" ++ " world",
]

std.assert(array[0] == 3)

# this would cause a panic:
# let x = array[20]

let dictionary = @[
	age: 20,
	height: 150,
	greet: function()
		std.print("hello!")
	end,
]

std.assert(dictionary["age"] == 20)
std.assert(dictionary.height == 150)
dictionary.greet() # prints "hello!"

As you may be wondering, the standard library, which can be used through the std global variable, is nothing but a dictionary full of useful functions.

Control Flow and Functions

If expressions

In Hush, conditionals expect a condition of type bool. Attempt to use values of other types, such as nil, will result in a panic.

If expressions may assume three forms, with and without the else or elseif fragment:

let condition = true

if condition then
	# ...
end

if condition then
	# ...
else
	# ...
end

if condition then
	# ...
elseif condition then
	# ...
end

As they are expressions, they will evaluate to whatever is the value resulting in the last statement of the executed block. If the else/elseif block is omitted and the condition evaluates to false, the expression will result in nil.

let condition = false

let x = if condition then
	1
else
	2
end

std.assert(x == 2)

x = if condition then
	3
end

std.assert(x == nil)

Functions

Functions are first class citizens, which means they are values like any other. The following are equivalent:

let fun = function ()
	# ...
end

function fun()
	# ...
end

They must declare how many arguments they expect, which is enforced when calling a function. Calling a function with less or more arguments than expected will result in a panic.

function takes_one(x)
	# ...
end

function takes_two(x, y)
	# ...
end

takes_one(1)
takes_two("a", 2)

Contrary to Lua, functions in Hush always return a single value, which is the result of the last statement in their body. They also may return early with the return keyword. The following are equivalent:

function fun(x)
	if x >= 2 then
		return # implicitly returns `nil`
	else
		return "lower than 2"
	end
end

function fun(x)
	if x < 2 then
		"lower than 2"
	end
end

Hush implements lexical scoping, which means variables are enclosed in the body in which they are declared, just like in Python and Lua. It also supports closures, which are functions that capture variables from the enclosing scope:

function adder(x)
	let y = x + 1

	return function (z) # `return` may be ommited here
		y + z # captures `y` from the parent scope.
	end
end

std.assert(adder(1)(2) == 4)

Closures may even mutate the captured variables:

let x = 0

function increment()
	x = x + 1
end

increment()
increment()
increment()

std.assert(x == 3)

Functions can also be recursive. As they are values, recursive functions are actually closures on themselves (they capture the variable to which they are assigned).

function factorial(n)
	if n == 0 then
		1
	else
		n * factorial(n - 1)
	end
end

std.assert(factorial(5) == 120)

Self

Hush provides one further facility for functions: the self keyword. When calling a function inside a dictionary using the dot operator, self will be an alias to that dictionary. If the function is called through other means, self will be nil. This is frequently used in object oriented code.

let dict = @[
	value: 5,

	method: function()
		# `self` is a reference to the dictionary which contains the function, if any.
		std.print(self)
	end
]

dict.method() # @[ "value": 5, "method": function<...> ]

# Isolate the method from the object, which will cause `self` to be `nil`:
let method = dict.method
method() # nil

# But we can bind it back to the object using `std.bind(obj, method)`:
method = std.bind(dict, dict.method)
method() # @[ "value": 5, "method": function<...> ]

While loops

While loops are statements, and therefore cannot be used as expressions.

let condition = true
let i = 0

while condition do
	condition = false
	i = i + 1
end

std.assert(i == 1)

For loops

For loops are also statements, but opposed to While loops, they do not expect a boolean condition. First, they expect a variable name, which will be scoped to the loop's body. Second, they expect an iterator function.

An iterator function is a function that may be called repeatedly without arguments, and always returns a dictionary with at least one field:

  • finished: a boolean indicating whether the loop should stop.
  • value: the value to be assigned to the loop variable. May be omitted if finished is true.
# A function to generate an iterator to the given array.
function iter(array)
	let i = -1
	let len = std.len(array)

	function ()
		i = i + 1 # captures `i`, incrementing it on every call.

		if i == len then # check if we reached the captured `len`.
			@[ finished: true ]
		else
			@[ finished: false, value: array[i] ]
		end
	end
end

let array = [1, 2, 3]
let sum = 0

for item in iter(array) do
	sum = sum + item
end

std.assert(sum == 6)

Fortunately, the iter function defined above is present in the standard library, as std.iter(collection). For numeric iterations, the standard library also supplies the std.range(from, to, step) function, which returns an iterator:

let sum = 0

for i in std.range(1, 4, 1) do
	sum = sum + i
end

std.assert(sum == 6)

Break statement

One may also interrupt loops using the break statement:

while true do # this will not run forever.
	if 1 + 2 < 4 then
		break
	end
end

Wrapping up

With these constructs, you should be able to write basic programs in Hush. Next, we'll learn how to implement proper error handling, as robustness is one of the core values of the language.

Error Handling

Hush implements two error mechanisms: panics and errors.

Panic

A Panic is an irrecoverable1 error, and will occur when there is a logic issue in your code. Panics will cause the whole program to crash, and execution will be terminated.

let x = 1 / 0
std.print(x)

Running the above script, you'll get:

Panic in <stdin> (line 2, column 10): division by zero

Examples of errors that cause a panic:

  • Syntax error.
  • Integer division by zero.
  • Index out of bounds.
  • Attempt to call a value that is not a function.
  • Missing or exceeding arguments in function call.

Error

Recoverable errors may be expressed through values of the error type. This is a special type in the language, and it is the only which cannot be expressed through a literal. Values of the error type can only be created using the std.error function, which expects a string description and a arbitrary context:

function check_limit(x)
	if x > 10 then
		std.error("x cannot be bigger than 10", x)
	else
		x
	end
end

One can then check whether the function has returned an error:

let result = check_limit(11)

if std.type(result) == "error" then
	std.print("Error: ", result)
else
	std.print("Success: ", result)
end

The description and context fields can be accessed as in a dictionary, but unlike in dictionaries, those cannot be assigned to:

let error = std.error("oh no!", 42)

std.print(error.description) # oh no!

error.description = "you shouldn't do that!" # panic

Examples of errors should be recoverable:

  • File not found.
  • Insufficient permission
  • Invalid format
  • Command not found
  • Command returned non-zero exit status

Try operator

The try (?2) operator may be used to early return from a function if an error occurs. It is nothing but syntax sugar for an if expression, and therefore it may be used in any expression:

function safe_div_mod(x, y)
	if y == 0 then
		std.error("division by zero", nil)
	else
		@[ div: x / y, mod: x % y ]
	end
end

# The following are equivalent:

function foo()
	let result = safe_div_mod(5, 0)
	
	let value = if std.type(result) == "error" then
		return result
	else
		result
	end

	std.print(value)
end

function bar()
	let value = safe_div_mod(5, 0)?
	std.print(value) # this won't be executed, as `?` will trigger an early return.
end

# The `?` operator may be used in any expression:
function run()
	std.print("div: ", safe_div_mod(5, 0)?.div)
end

1 One can actually catch a panic using the std.catch function, but that should be used sparingly.

2 If you're familiar with both languages, Hush's try operator might feel like the unholy child of Rust's ? operator and Go's if err != nil { return err }.

Command Blocks

Command blocks is the feature that distinguishes Hush from ordinary programming languages. They allow Hush scripts to seamlessly invoke and interconnect external programs.

Great effort has been put to make command syntax in Hush as similar as we're used to in Bash, but some key aspects have been changed in order to favor robustness of scripts. You should never have to use something like the unofficial bash strict mode in Hush:

#!/bin/bash
set -euo pipefail
IFS=$'\n\t'

The unofficial bash strict mode

Let's see why you won't need any of this in Hush:

  • set -e: interrupt execution immediately if a command has non-zero exit status. This is the default behavior for command blocks in Hush.
  • set -u: exit with an error on any attempt to use an undeclared variable. Hush won't even start to execute your script if you mention an undeclared variable.
  • set -o pipefail: if a command in a pipeline fails, make the whole pipeline fail. This is the default behavior in Hush, and can even be controlled on a per-command basis in pipelines.
  • IFS=$'\n\t': do word-splitting using only newlines and hard tabs. Hush does no word splitting whatsoever, so this will never be a source of confusion or bugs.

In the next sections, we'll learn how to use command blocks in Hush.

Command Blocks

In Hush, command blocks are delimited with curly braces:

{ echo Hello world! }

They are valid expressions, and will result in error or nil. Therefore, we can use this result to check whether the block was successfully executed:

let result = {
	touch /etc/config # we may not have permission for that directory!
}

if std.type(result) == "error" then
	std.print("Error: ", result)
end

They may even be combined with the try operator:

function run()
	{ mkdir /etc/config/ }?
	std.print("Success!") # This won't be executed if the command block fails.
end

let result = run()
if std.type(result) == "error" then
	std.print("Error: ", result)
end

As they are blocks, they may have multiple commands, which must be delimited by semicolons. This enables us to include line comments in between their arguments:

{
	find . # search in the current directory.
		-type f # only files.
		-iname '*.png'; # case insensitive match on name.

	ls ../some-other-dir/images; # list files from another directory.

	cat additional-files.txt # The semicolon in the last command is optional.
}

Pipelines and redirections

Pipelines and redirections use standard syntax:

{
	echo Hello world! | sed s/world/universe/g | tr '!' '.';
	echo overwrite file using stdout > file.txt;
	echo overwrite file using stderr 2> file.txt;
	echo append to file using stdout >> file.txt;
	echo stderr too 2>> file.txt;
	cat < file.txt; # redirect file to stdin
	cat << "here's an inline string"; # string to stdin
	rm file.txt 2>1; # redirect stderr to stdout. Opposed to Bash, we don't need an `&`

	# We may compose as many of those as we need:
	cat file.txt # Read a file.
			<< "Hello world!" # Concat it with data from stdin.
			2>> errors.txt # Save errors to file.
		| tee output.txt # Dump data to another file.
			2>> errors.txt # Save errors to file.
		| curl localhost:8080 -d @- # HTTP POST data to server.
			2>> errors.txt; # Save errors to file.
}

But there's an additional requirement for redirections: they may not precede arguments:

{ echo Hello 2>1 } # Ok.
{ echo 2>1 Hello } # Syntax error.

Variables

As in most shells, Hush provides variable substitution in commands. But opposed to traditional shells, variables don't undergo word splitting. As Hush has first class support for arrays, there's really no need to do automatic word splitting.

Variables can be used inside command blocks using dollar syntax:

let var = "hello world"
{
	echo $var; # hello world
	echo "$var"; # hello world
	echo ${var}s; # hello worlds
	echo "${var}s"; # hello worlds
	echo '$var'; # $var
}

Hush uses the following rules when doing variable substitution:

  • nil: converted to an empty argument. Note that this is different than skipping the argument.
  • bool, char, int, float, string: converted to string and passed as a single argument, regardless of containing spaces, asterisks, and whatnot.
  • array: each element will be converted to a single argument, using the other rules. If the array is empty, no argument is produced. This way, arrays can be used to programmatically build lists of command arguments.
  • dict, function, error: won't be converted, causing a panic instead.

Considering the file args.hsh:

let args = std.args() # Returns an array of command line arguments.

for arg in std.iter(args) do
	std.print(arg)
end

The following script

let args = [ "1 2", 3, nil, 4.0 ]

{ hush args.hsh $args }

will output:

1 2
3

4.0

Expansions

In order to provide ergonomic manipulation of the file system, most shells provide a mechanism named expansions. It allows the programmer to refer to multiple file names using a regex-like syntax.

Hush provides automatic expansion only for literal arguments. That means you won't have to worry if your variables contains characters that may be expanded.

let var = "*"
{
	echo *; # Will print all files/directories in the current directory.
	# The following will print just an asterisk.
	echo "*";
	echo $var;
}

Hush currently provides the following expansions:

  • %: matches zero or one character, except for the path separator.
  • *: matches zero or more characters, except for the path separator.
  • **: matches zero or more directories.
  • ~/: matches the $HOME directory, only when in the prefix of an argument.

Opposed to traditional shells, Hush will always expand relative paths prefixed with ./:

{
	touch test.txt; # Create a file
	echo *; # Will print "./test.txt"
}

You won't have to worry about flag injection from file names ever again.

Errors

By default, whenever a command fails in a block, the whole block will be interrupted. This behavior can be disabled on a per-command basis with the ? operator (not to be confused with the try operator outside of command blocks).

{
	echo Hello world!;

	# `false` is a command that always fails. As it's suffixed with `?`,
	# it won't cause the whole block to abort.
	false ?;

	echo "This will be printed";

	# If a command fails, and it makes no use of the `?` operator,
	# no further commands will be executed.
	false;

	echo "This will not be printed";
}

Command blocks will always result in an error whenever one or more of their commands fail. This is true even for commands that use the ? operator.

let result = { false?; }
std.assert(std.type(result) == "error")

An error will be produced for each command that fails. This error will contain a dict with two fields:

  • pos: a string describing the source position of the command.
  • status: the numeric exit status of the program. Always non-zero.

There are scenarios where more than one command may fail, such as when using pipelines or the ? operator. Whenever more than one command fails, the block will result in a generic error. This generic error will contain as context an array of the errors of each command that failed.

let result = { false?; false }
std.print(result.context[0])
# command returned non-zero (@[ "status": 1, "pos": "<stdin> (line 1, column 15)" ])
std.print(result.context[1])
# command returned non-zero (@[ "status": 1, "pos": "<stdin> (line 1, column 23)" ])

Environment variables

Commands may be prefixed by environment assignments, which will apply only for the given command.

{
	VAR=value COLOR=false command; # Set environment variables only for this command.
	another-command;
}

If you wish to set environment variables permanently, you may use the std.export function:

std.export("VAR", "value")
std.export("COLOR", "false")
{
	# The environment setting will now affect both commands.
	command;
	another-command;
}

Capture

One of the most important features of a shell is to be able to manipulate the standard I/O streams of programs. There are three main ways of doing so: pipes, redirection, and capturing. The first two are commonly used when we just want to forward the output or input of a program. But if we want to preprocess the output, or use it as an argument to another program, we'll usually reach for capturing the output stream as a string in the shell, so that we can handle it ourselves.

In Hush, we can capture the output of a whole command block by prefixing it with a dollar:

let result = ${ echo Hello world! }

Instead of resulting in nil or error, as does the standard command block, a capture block will result in either a dict containing the stdout and stderr string fields, or an error if the block fails.

function get_first_word()
	let output = ${ echo Hello world! }?.stdout
	let first_word = std.split(std.trim(output), " ")[0]
	if std.is_empty(first_word) then
		std.error("output was empty", output)
	else
		first_word
	end
end

std.print(get_first_word()) # Hello

The separation of the stdout and stderr fields in the resulting dict enables the programmer to properly handle the standard error stream separately from the standard output stream, something that is more complicated than it should in traditional shells.

If the command block fails, the resulting error will contain the output that was captured before the block failed:

let result = ${
	echo Hello world!;
	echo The next command wil fail 1>2;
	this-command-does-not-exists;
}

std.assert(std.type(result) == "error")
std.assert(result.context.stdout == "Hello world!\n")
std.assert(result.context.stderr == "The next command wil fail\n")

Async

Hush also provides a way to launch commands asynchronously, and then wait for their result at a later time. Traditional shells provide similar functionality through the ampersand operator.

To run a command block asynchronously, prefix it with an ampersand:

let handle = &{ echo Hello world! }

# Do some other work before calling join.
# This may be printed before or after "Hello world!".
std.print("Doing some work...")

# This will wait until the block runs to completion, and will return it's result.
let result = handle.join()

std.assert(result == nil)

Async blocks will start executing immediately, but Hush won't wait for their completion until join is called, and will continue script execution instead. An async block will always result in a dict containing the join method, which will then return the block result (nil or error) when called.

Builtins

Some commands must be implemented as shell builtins, so that they can mutate the shell state, a thing that would not be possible for an external program. A good example of such kind is the cd command, which must change the shell's working directory.

Hush currently provides the following builtin commands:

  • cd TARGET_DIR: change the working directory to TARGET_DIR.
  • exec PROGRAM [ARG1, ARG2, ARG3...]: replace hush process with PROGRAM (argument 0 = PROGRAM).
  • exec0 PROGRAM ARG0 [ARG1, ARG2, ARG3...]: replace hush process with PROGRAM (argument 0 = ARG0).
  • spawn0 PROGRAM ARG0 [ARG1, ARG2, ARG3...]: spawn PROGRAM (argument 0 = ARG0).

As Hush has no such thing as subshells, builtin commands may not be used in pipes, redirections, or capture blocks.

Standard Library

Hush's standard library is available through the std global variable, which is implicitly available to all scripts. It is nothing but a dictionary of predefined functions, which can be used like any other user defined functions.

Currently, the following functions are available:

std.args()

Gets an array of command line arguments.

std.assert(value)

Asserts that value is true, panics otherwise.

std.base64.encode(string)

Convert the given value to a base64 string.

std.base64.decode(string)

Parse the given base64 string. Returns an error if parsing fails.

std.bind(obj, function)

Returns a new function that binds function to obj, so that self will always refer to obj when the function is called.

std.bytes(string)

Convert the given string to an array of chars.

std.catch(function)

Runs the given function, and returns an error if it panics. This should be used sparingly, as panics shouldn't be used for recoverable errors.

std.cd(dir)

Change the current directory to dir.

std.contains(collection, value)

Checks if collection contains an item that is equal to value. May be used with strings, arrays and dicts.

std.cwd()

Returns the current working directory.

std.env(key)

Gets the value of the environment variable key, or nil if it's not defined.

std.error(description, context)

Create a new error with the given description and context. The description must be a string.

std.exit(int)

Terminates execution immediately with the given status code. Panics if the status code is not in the range [0, 255].

std.export(key, value)

Set the environment variable key to value. Both arguments must be strings.

std.float(value)

Convert value to float. Accepts string, int and float.

std.glob(path)

Expands the given path in the current directory, using the shell expansion rules (*, %, etc).

std.has_error(value)

Recursively checks if value contains a value of type error.

std.hex.encode(string)

Convert the given value to a hex string.

std.hex.decode(string)

Parse the given hex string. Returns an error if parsing fails.

std.import(path)

Load the Hush script from the given path, relative to the current file.

std.int(value)

Convert value to int. Accepts string, int and float.

std.is_empty(collection)

Checks if the given collection is empty. Accepts strings, arrays and dicts.

std.iter(collection)

Returns an iterator function for the given collection. Accepts strings, arrays and dicts.

std.json.encode(value)

Convert the given value to a JSON string. Panics if value contains a value that cannot be serialized as JSON (function or error).

std.json.decode(string)

Parse the given json string. Returns an error if parsing fails.

std.len(collection)

Returns the amount of elements in the given collection. Accepts strings, arrays and dicts.

std.panic(value)

Panics with the given value as description.

std.pop(array)

Removes the last element from the given array, returning it. Panics if the array is empty.

std.print(value)

Prints the given value to stdout, including a newline.

std.push(array, value)

Adds the given value to the end of array.

std.range(from, to, step)

Returns an iterator function that yields numbers in the given range.

std.read(prompt)

Read a line from stdin, using the given prompt, which must be a string.

std.regex(pattern)

Build a regex object from pattern, which must be a string. If pattern is not a valid regex, an error will be returned. Otherwise, returns a dict with the following methods:

  • match(string): returns a bool indicating whether the pattern matches the given string.
  • split(string): splits string using the pattern, returning an array of strings.
  • replace(string, replace): returns a string with replaced occurrences of pattern in string. replace must be a string.

std.replace(string, seach, replace)

Replace occurrences of search with replace in string. All parameters must be strings.

std.sleep(ms)

Sleep for the given amount of milliseconds. Accepts positive integers only.

std.sort(array)

Sorts the given array.

std.split(string, pattern)

Splits the given string by occurrences of pattern, returning a non-empty array. Both arguments must be strings.

std.substr(string, from, length)

Slice the given string. The two last parameters must be positive integers.

std.to_string(value)

Converts the given value to string.

std.trim(string)

Removes whitespace from the start and end of the given string.

std.typecheck(value, type)

Checks if the given value has type type, panics otherwise. type must be a string.

std.try_typecheck(value, type)

Checks if the given value has type type, returns an error otherwise. type must be a string.

std.type(value)

Returns a string describing the type of value: "nil", "bool", "char", "int", "float", "string", "array", "dict", "function", or "error".

Paradigms

There are three main programming paradigms that can be used in Hush: procedural, functional, and object oriented programming. As the language has functions, statements and basic flow control, procedural programming comes pretty naturally. For functional programming, the support for first class and higher order functions are the core features that enable usage of such paradigm. As for object oriented programming, the language provides basic mechanisms for defining objects with fields and methods.

In the following sections, we'll explore examples on how to use functional and object oriented programming in Hush.

Object Oriented

Hush provides basic support for object oriented programming. This means you can write object oriented code in Hush, but the language won't give you fancy features for free. Objects can be modeled through dictionaries, using keys as accessors and values as member data and methods. To ease the use of dictionaries as objects, Hush provides the self keyword, which is described in the functions section.

# This function acts like a combination of a class and a constructor. It'll take any
# arguments relevant to the construction of a `MyCounter` object, and will return an
# instance of such object, which is nothing but a dictionary.
let MyCounter = function(start)
	@[
		# A member field. Using the same convention as Python, a field starting with an
		# underscore should be considered private.
		_start: start,

		# A method. Here, we can use the `self` keyword to access the object.
		get: function()
			self._start
		end,

		# Another method.
		increment: function()
			self._start = self._start + 1
		end,
	 ]
end

let counter = MyCounter(1) # object instantiation.

std.print(counter.get()) # 1
counter.increment()
std.print(counter.get()) # 2

Inheritance

Single inheritance can be implemented using a similar technique:

# A derived class.
let MySuperCounter = function (start, step)
	# Instantiate an object of the base class. We'll then augment this object with
	# derived functionality.
	let counter = MyCounter(start)

	counter._step = step # Add new fields to the object.

	# Override a method. Make sure not to change the number of parameters here!
	counter.increment = function ()
		self._start = self._start + self._step
	end

	# In order to override a method and call the parent implementation, you'll need to
	# bind it to the current object, and then store it to a variable:
	let super_get = std.bind(counter, counter.get)
	counter.get = function()
		let value = super_get() # call the parent method.
		std.print(value)
		value
	end

	counter
end


let super_counter = MySuperCounter(2, 3)
super_counter.get() # 2
super_counter.increment()
super_counter.get() # 5

Functional

Hush provides first class higher order functions, which is the fundamental building block for functional programming. As such, the paradigm integrates very well with the language, and it is recommend as the main approach for solving problems in Hush.

Here's an example of a simple iterator library, using functional programming and implementing basic laziness:

# Iterator: traversal utilities.

# Construct an iterator.
# An Iterator is an object capable of successively yielding values.
# Iterators are *mutable* (they may mutate their inner state as they iterate).
# Parameters:
# * next: function() -> @[ finished: bool, value: any ] # the iterator function
# Returns the Iterator instance.
function Iterator(next)
	@[
		# Provide the next method.
		next: next,

		# Get the iterator function.
		# Iterating mutates the iterator, which will successively yield the next value.
		# This should be used in for loops, as in:
		# for item in iterator.iter() do
		# 	...
		# end
		iter: function ()
			self.next
		end,

		# Get the nth element in the iterator.
		# This is equivalent to calling self.next() `n + 1` times,
		# but might be optimized for some iterators.
		# Parameters:
		# * n: the desired element index
		# Returns the nth element.
		nth: function (n)
			for _ in std.range(0, n, 1) do
				let iteration = self.next()

				if iteration.finished then
					return iteration
				end
			end

			self.next()
		end,

		# Map a function over an iterator.
		# This function is lazy.
		# Parameters:
		# * fun: function(any) -> any # the function to apply
		# Returns a new iterator, consuming self.
		map: function (fun)
			let map = function (iteration)
				if iteration.finished then
					iteration
				else
					@[ finished: false, value: fun(iteration.value) ]
				end
			end

			let base = self

			let it = Iterator(
				function ()
					map(base.next())
				end
			)

			# Elide mapping unnecessary elements when jumping.
			# This is an important optimization for, e.g.:
			# Array([1,2,3,4,5])
			# 	.map(fun)
			# 	.skip(3)
			# 	.collect()
			it.nth = function (n)
				map(base.nth(n))
			end

			it
		end,

		# Filter the iterator by the given predicate.
		# This function is lazy.
		# Items for which the predicate returns true are kept.
		# Parameters:
		# * pred: function(any) -> bool # the predicate
		# Returns a new iterator, consuming self.
		filter: function (pred)
			let base = self

			Iterator(
				function ()
					let iteration = base.next()

					while not (iteration.finished or pred(iteration.value)) do
						iteration = base.next()
					end

					iteration
				end
			)
		end,

		# Skip elements from the iterator.
		# Equivalent to calling `nth(size - 1)` on an iterator.
		# Returns a new iterator, consuming self.
		skip: function(size)
			if size > 0 then
				self.nth(size - 1)
			end
			self
		end,

		# Iterate up to some elements.
		# This function is lazy.
		# The iterator that will stop after `size` elements.
		# Parameters:
		# * size: int # how many elements to keep
		# Returns a new iterator, consuming self.
		take: function (size)
			let base = self

			let it = Iterator(
				function ()
					if size == 0 then
						@[ finished: true ]
					else
						size = size - 1
						base.next()
					end
				end
			)

			it.nth = function(n)
				if n + 1 > size then
					size = 0
					@[ finished: true ]
				else
					size = size - n
					base.nth(n)
				end
			end

			it
		end,

		# Fold elements into a single value.
		# Parameters:
		# * merge: function(any, any) -> any # the function to merge elements
		# * acc: any # the default argument for merge
		# Returns the resulting any, consuming self.
		fold: function (merge, acc)
			for item in self.iter() do
				acc = merge(item, acc)
			end

			acc
		end,

		# Collect the iterator's items.
		# Parameters:
		# * target: nil, array or function(any) # where to collect
		# If target is nil, the elements are collected into a new array.
		# If target is an array, the elements are collected by pushing to it.
		# If target is a function, the elements are collected by calling the function.
		# Returns nil if target is a function, or the resulting array otherwise.
		collect: function (target)
			let result = []
			let push

			let type = std.type(target)
			if type == "function" then
				result = nil
				push = target
			else
				if type == "array" then
					result = target
				end
				push = function (item)
					std.push(result, item)
				end
			end

			for item in self.iter() do
				push(item)
			end

			result
		end
	]
end

# Construct an iterator for the given array.
# Returns the Iterator instance.
function Array(array)
	let ix = 0

	# Don't use `std.iter` in order to be able to optimize `nth`.
	let it = Iterator(
		function ()
			if ix >= std.len(array) then
				@[ finished: true ]
			else
				let value = array[ix]
				ix = ix + 1
				@[ finished: false, value: value ]
			end
		end
	)

	# Override nth with a O(1) implementation:
	it.nth = function (n)
		ix = ix + n
		return it.next()
	end

	return it
end

# Construct an iterator for the given table.
# There is no guarantee of iteration order.
# Returns the Iterator instance.
function Dict(dict)
	return Iterator(std.iter(dict))
end

# Construct an empty iterator.
# Returns the Iterator instance.
function Empty()
	return Iterator(
		function ()
			return @[ finished: true ]
		end
	)
end

Wrapping up

This was an all around quick tutorial on Hush, a modern shell programming language. We hope that it has enabled you to write and maintain robust shell scripts, and that the language proves itself to be a solid tool for system and infrastructure programming. If you have any criticism, suggestions, or want to contribute to the project, feel free to reach us out on Github.