Hurl's syntax

Hurl provides a minimal language based around control flow via exception handling. It provides precious few other constructs, just what we need in order to write useful1 programs.

The core language gives you a few primary things:

  • binding and assigning to variables
  • defining anonymous functions
  • exception handling with two types of exceptions
  • all the extras like includes and built-in functions

We'll walk through each to get the basics, and you'll see more in the sections on common patterns and examples.

Binding and assigning to variables

Binding a new variable looks like it does in many languages.

let language = "hurl";
let year = 2023;

And then you can also re-assign, using some math operations. (I even snuck a comment in here.)

let year = 2023;

# happy new year!
year = year + 1;

A few things to note about variables:

  • A variable must always be initialized
  • Every statement ends with a semicolon
  • Variables are dynamically typed
  • Assigning will find the variable in the closest containing scope and assign to it
  • If you try to assign to a variable that has not been bound, then the interpreter will panic
  • Lists can be defined with the [1, 2, "c"] notation, and are 1-indexed

Anonymous functions

Anonymous functions use the func keyword to create a function. These can be used on their own, or they can also be combined with variable binding to reference them again later.

Here's one example that just adds two numbers together and binds it to a variable for use again later.

let add = func(a, b) {
    hurl x + y;
};

Note the hurl keyword. We'll cover that, but briefly, it means we're throwing an exception from the expression following it. Functions cannot pass out a value through any means other than throwing an exception to be caught later on. (return does exist, but it does not do what you may expect. I'm trying to avoid saying "return" here in reference to functions...)

Since Hurl is dynamically typed, our add function can also concatenate strings:

# hurls 3
add(1, 2);

# hurls 2023
add("20", "23");

# panics, because + is undefined for bools
add(true, true);

Functions also can be recursive. They have no way to refer to themselves directly (since their name isn't bound until after they are constructed), but you can use the name it gets bound to since it doesn't resolve names until runtime.

Here's an infinite loop that will terminate once the OS stack overflows:

let overflow = func(depth) {
    println("depth: ", depth);
    overflow(depth + 1);
};

overflow(1);

If you run this, you'll get the following output:

depth: 1
...
depth: 10901
depth: 10902
depth: 10903

thread 'main' has overflowed its stack
fatal runtime error: stack overflow
Aborted (core dumped)

Exception handling

Okay, so now we get to the meat of what makes Hurl special: exception handling. This is our primary construct for control flow. Without exception handling, we can do an infinite loop, but we cannot break out of the loop until the interpreter panics, which is not exactly useful. We also can't do conditionals. But we can do all these things via exceptions.

There are two ways to throw exceptions in Hurl:

  • hurl an exception and it will unwind the stack until it finds a handler which catches it. Then execution will continue from that handler.
  • toss an exception and it will walk the stack until it finds a handler to catch it, then it will walk back to where it was tossed from and resume execution from there.

hurling is what we normally see as exceptions in other languages. tossing is kind of a continuation which suspends execution to run a handler, then resumes.

There are also three ways to catch an exception:

  • catch (<value>) will match the value proivded as a literal
  • catch as <ident> will catch any value and provide it in the catch body as a bound variable (only in scope for the catch body)
  • catch into <ident> takes no body and catches any value, assigning it into the provided identifier (which must already be defined in some containing scope)

Here's an example of computing a factorial.

let factorial = func(n) {
    try {
        hurl n == 0;
    } catch (true) {
        hurl 1;
    } catch (false) {
        let next = 1;
        try {
            factorial(n - 1);
        } catch into next;
        hurl n * next;
    };
};

try {
    factorial(10);
} catch as x {
    println("factorial(10) = ", x);
};

And the result:

> hurl run examples/factorial.hurl
factorial(10) = 3628800

You can also make closures:

let make_counter = func() {
    let x = 1;

    let counter = func() {
        x = x + 1;
        hurl x;
    };

    hurl counter;
};

let c = func () {
};

try {
    make_counter();
} catch into c;

try {
    c();
} catch as x {
    println(x);
};

try {
    c();
} catch as x {
    println(x);
};

Now let's see an example with toss. This is used mostly for passing multiple values out of the function. You don't really need it, but it's cute.

let f = func() {
    toss "hello";
    toss 2023;
    hurl "bye-bye";
};

try {
    f();
    println("unreachable");
} catch as x {
    println("caught: ", x);
    return;
};
println("done");

When we run it:

> hurl run foo.hurl
caught: hello
caught: 2023
caught: bye-bye
done

toss isn't particularly useful, and it's probably buggier than hurl, but again: it's cute, and we stan cuteness.

Extras!

There are a few other things you can do.

  • Call built-in functions; refer to the standard library reference for what they are and their parameters.
  • Include other files with include <filename-expr>;. Note that you can provide any expression that evaluates to a filename, so this can even use more complex expressions, fun! The filename will be resolved relative to the first interpreted file.
  • Standard arithemtic is defined and works how you'd expect.

Now you have the core syntax, so you can move on to some common patterns and some examples.


1

For certain values of "useful"...