Hurl, the Exceptional language

Hurl is a language created for one purpose: to explore a language based around exception handling as the only control flow. It was sparked from conversations between Nicole Tietz-Sokolskaya and friends from Recurse Center whose identities will be withheld for their dignity.

This site contains documentation around how to use Hurl. It also provides some examples and guidance for debugging and answers questions.

Praise for Hurl

It comes highly endorsed:

I, uh, have changed my mind about ever implementing a language based on Hurl. This monstrosity is beautiful, and I must never touch it. I don't want my name associated with this in any way1.

-Erika Rowland

Unfortunately, I decided to make this language a reality. I'm sorry.

-Nicole Tietz-Sokolskaya

is "đŸ¤®" an available quote?

-Mary McGrath

Certified unhingedâ„¢!

-nate (@[email protected])

Exceptions as control flow have been a well-guarded secret within the professional programming community for years. It's about time someone codified these techniques into a coherent whole.

-Jonathan Watmough

To add more praise, email Nicole (please include positive consent to include said quote on this site).

Source code

The source code for Hurl and for this site are both available in Hurl's repo. Emailed patches are welcome if you find a bug or an error, but you'll need to sign over all rights to the patch: I need to preserve the ability to relicense this and license it commercially.

Licenses

For this project, I considered joke licenses and unfortunate licenses. Ultimately, this software is licensed under the following three very serious licenses:

  • Under AGPL-3.0
  • Under GAL-1.0 (Gay Agenda License)
  • Under a commercial license

You may use the software under any one of these licenses, without regard for the others.


1

Erika did consent to posting this and provided it in its current form. If someone actually does not want their name associated, I would not include it.

Installation

Hurl is not distributed by any package managers as far as the language maintainers1 are aware. Installing it is straightfowrard as long as your platform supports the standard Rust toolchain.

  1. If you don't have cargo installed, install Cargo. You probably want to have at least 1.73.0-nightly, since that's what the 1.0 release of Hurl was tested on.
  2. Clone the repo:
    git clone https://git.sr.ht/~ntietz/hurl-lang
    
  3. Install Hurl by running these inside the cloned repo:
    cargo install --path .
    

Usage

Hurl ships with one binary, hurl, which is both the interpreter and the formatter.

To see the usage instructions for hurl, any of these should do it.

hurl --help
hurl -h
hurl help

To format code, use the fmt command and pass in the directory containing the files you want to format. It will recursively format all files in-place in the passed-in directory. If you just want to check if formatting is correct, use the -c option.

# in-place format of all files in src/
hurl fmt src/

# checks if the files are correctly formatted, does not update in place
hurl fmt -c src/

To run code, use the run command and pass in the file you want to run.

hurl run my_program.hurl

1

Nicole is the only maintainer, but being all formal about it makes this project sound more serious, doesn't it?

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

Common patterns

While writing Hurl code, there are a few common patterns to do control flow that you may find useful. These are the things you'd get out of the box with other languages, but with Hurl, you're stuck doing them the fun Hurl way.

If-else

Every useful program at some point takes a branch1. To do that in Hurl, you hurl the value you want to branch on, then catch the different values.

Here is how to do an if-else:

try {
    hurl 1 < 2;
} catch (true) {
    println("wow! you learn something every day!");
} catch (false) {
    println("we got a problem");
};

Sometimes you just want to match a particular value, and ignore the rest. To do that, you use a catch-all with an empty block; if you don't catch the exception then it will bubble up and that's a bad time.

let year = 2023;
try {
    hurl year;
} catch (2023) {
    println("wow, this example will be outdated so soon after writing it!");
} catch as x {
};

Note that you cannot use toss for a conditional with resumptions. This probably is supposed to work, semantically, but it doesn't, and I'm not interested in fixing it. Not really sorry about that, just don't do it. (But if you do debug that and submit a patch, I'll be grateful!)

Recursion

Recursion is ever present in Hurl. You do it kind of like you expect. I honestly tried to make this harder than it is, but it wound up pretty easy, so I guess yay.

I wanted this to be how you have to do it, passing the function into itself:

let f = func(f_, args) {
    # do stuff with the args
    f_(modified_args);
};

But because of how Hurl resolves variables, you can just call the function recursively like normal.

let f = func(args) {
    # do stuff with the args
    f(modified_args);
};

Looping

With recursion and if-else, we can put together loops! There are a few loops that are common. I'll show you one here, and the rest are in the standard library.

This is the most basic kind of loop. It expects two arguments, body and locals. body is a function and locals are the local variables to bind in the body by passing them in as the argument.

body must, at the end of each call, first toss the local variables to update for the next iteration, and then hurl a boolean to indicate continuing or halting iteration.

let loop = func(body, locals) {
    let new_locals = locals;
    try {
        body(locals);
    } catch (true) {
        loop(body, new_locals);
    } catch (false) {
        hurl new_locals;
    } catch as update {
        new_locals = update;
        return;
    };
};

You can use it like this, to do Fibonacci:

let fib = func(locals) {
    let a = locals.1;
    let b = locals.2;
    let iters = locals.3;
    let max_iter = locals.4;

    toss [b, a + b, iters + 1, max_iter];
    hurl iters < max_iter;
};

try {
    loop(test, [0, 10]);
} catch as retval {
    println("done");
};

1

There are ways to do this without conditional jumps or other literally conditional instructions, so I'm being particular here.

Standard library

There are two components to the standard library: built-in functions and a distribution of functionality defined in Hurl.

Built-ins

Built-ins are functions which evaluate as an expression to a value; when you use them you do not have to catch the result but may assign it directly. These are the built-ins that are defined, with *args representing any number of arguments:

  • print(*args): prints all arguments with no separators; evaluates to nil
  • println(*args): print, but with a newline; evaluates to nil
  • is_digit(x): evaluates to true if x is an ASCII digit, and false otherwise
  • as_num(x): evaluates to x converted to a number, or panics if that fails
  • as_str(x): evaluates to x as a string
  • str_chars(s): evaluates to a list containing all the chars that are in the string s, and panics if called on a non-string argument
  • str_lines(s): evaluates to a list containing all the lines that are in the string s, and panics if called on a non-string argument
  • len(xs): evaluates to the length of the list or string represented by xs, and panics if provided any other type
  • slice(xs, start, end): evaluates to the substring or sublist of xs starting from start and going to end or the end of the list, whichever is sooner; panics if given a non-list and non-string argument
  • at(xs, index): evaluates to the element of xs at the index location (remember, it's 1-indexed)
  • set(xs, index, val): evaluates to a copy of the list xs with the indexth element replaced with val
  • floor(x): evaluates to the value passed in rounded down to the nearest integer
  • ceil(x): evaluates to the value passed in rounded up to the nearest integer
  • read_file(x): evaluates to the content of the file at path x as a string, or panics if the file does not exist or cannot be read
  • trim(s): evaluates to the string s with all leading and trailing whitespace removed; this can be implemented in pure Hurl, but I'm sorry, I was tired while doing the Advent of Code problems okay?

Hurl stdlib

If you check out the main Hurl repo, there are some included files in the lib/ directory which are the standard library. You can browse them in the repo and new standard library functions are welcome.

Examples

Here are a few small Hurl programs. More examples are in the repo.

Fizzbuzz

include "../lib/loop.hurl";
include "../lib/if.hurl";

let fizzbuzz = func(locals) {
    let x = locals.1;
    let max = locals.2;

    let printed = false;

    if(func() {
        hurl (x % 3 == 0);
    }, func() {
        print("fizz");
        printed = true;
    });

    if(func() {
        hurl (x % 5 == 0);
    }, func() {
        print("buzz");
        printed = true;
    });

    if(func() {
        hurl ~printed;
    }, func() {
        print(x);
    });

    println();

    toss [x + 1, max];
    hurl ~((x + 1) == max);
};

try {
    loop(fizzbuzz, [1, 101]);
} catch as retval {
    println("Final: " + retval);
};

Fibonacci

include "../lib/loop.hurl";

# fibonacci in hurl
let fib = func(locals) {
    let a = locals.1;
    let b = locals.2;
    let iters = locals.3;
    let max_iter = locals.4;

    toss [b, a + b, iters + 1, max_iter];
    hurl iters < max_iter;
};

try {
    loop(test, [0, 10]);
} catch as retval {
    println("done");
};

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);
};

Huh, all the examples start with "F". Weird.

Debugging and errors

Writing Hurl code is not particularly easy since it's so different from what we usually are writing. Unfortunately, the interpreter is not very helpful when we make mistakes: it simply panics at whatever line in the interpreter, which gives you essentially no useful information about what went wrong.

Here are some of the techniques I found helpful while writing and debugging a few programs in Hurl:

  • Start small. If you are doing something with a large input, try a small one first so you can more easily trace the execution.
  • Add print statements. You can put print statements everywhere to figure out what values are where and where things are going wrong.
  • Limit recursion depth. If you run into stack overflows, this is probably because you've looped too far and blown the stack! The stack size is limited by the operating system so if you run into this, you're going to need to work around it by limiting your recursion depth. Nested loops are a nice way to chunk up iteration and limit the max depth.

And then just read your program again and again and you'll find the error eventually! Or you won't, and that's okay. It might also plausibly be an interpreter bug, so feel free to email me for help debugging.

FAQ

Isn't this just <other-language>?

Hurl makes no claims at being unique or novel. The concepts have been explored elsewhere as well. However, to the best of the author's knowledge, it is the first language to provide just exception handling for control flow and make that the primary construct.

Can I call it Hurllang?

No, the name of the language is Hurl. It is not Hurllang, Hurlling, or any other variation.

Did you make it bad on purpose?

No! I assure you, if this were meant to be a bad language it would be far worse. It is testing out how far we can take one thing ("only exceptions!") and the unpleasant nature of the language is a side effect, not the intention.

Is it production ready?

Please don't. If you must, commercial licenses are available for what I assure you is a totally reasonable price. Reach out.

Why did you do this?

To see if we could! We never stopped to ask if we should.

This was an educational project, and much was learned.