gleam_eval

Chain together stateful computations in Gleam, and defer their evaluation until you need them.

Package Version Hex Docs

❗️ While the documentation for this package is still being worked on, the API is very much in flux. Feel free to experiment but know that things may change in the future!

Gleam is an immutable functional programming language, but sometimes it would be really helpful to model some mutable state that can be threaded through multiple computations. That’s where Eval comes in!

Everything in this package is built around the idea of an Eval type, which can be summarised as:

fn (context) -> #(context, Result(a, e))

That is, a function that takes some context and returns a (potentially updated) context and a Result of some a or some error e. With this, we can model all manner of computations that can be expressed in a purely functional way: we can update the context, throw an error, return a value, or some combination!

❗️ This package is written in pure Gleam so you can use it whether you’re targetting Erlang or JavaScript.


Installation

If available on Hex, this package can be added to your Gleam project:

gleam add eval

and its documentation can be found at https://hexdocs.pm/eval.


Usage

We mentioned above that an Eval is essentially a function that takes some context and returns a Result. We can start to express more complex and powerful computations by composing these functions together using the various functions provided in this package.

To demonstrate how everything can neatly fit together, let’s write a parser for a simple expression language. To keep things manageable, our expression language will only allow for two types of expressions:

type Expr {
    Number(Int)
    Add(Expr, Expr)
}

Our Parser type will be a specialisation of the Eval type from this package. The context will be a List(String) that represents the current stream of graphemes (Gleam doesn’t have a traditional Char type). Additionally, we will define an Error type that we can throw if something goes wrong during parsing:

type Error {
    Unexpected(String)
    EOF
    InvalidParser
}

Finally, while we intend on ultimately parsing some Expr value, we will leave the the type free in our Parser type, so that we can write intermediary parsers that may return some other type instead (this will be useful, as we’ll se in a moment).

type Parser(a) =
    Eval(a, Error, List(String))

Basic building blocks

Now we know what types we’ll be dealing with, it’s time to write out first parsers using this package! We’re going to begin by defining a peek and a pop parser that will return the next grapheme in the context (and remove it, in the case of pop).

First, make sure we’ve imported everything we need:

import eval
import eval/context
import gleam/int
import gleam/list
import gleam/string

Then we can define our peek parser:

pub fn peek () -> Parser(String) {
    context.get() |> eval.then(fn (stream) {
        case stream {
            [ grapheme, .. ] ->
                eval.succeed(grapheme)
            
            _ ->
                eval.throw(EOF)
        }
    })
}

The API of this package has been designed so that (hopefully) this reads quite naturally, even if you are not familiar with how things are implemented, or indeed Gleam or functional programming in general. First we get the context, then we look at the stream. If there are still graphemes left we succeed with the first element in the stream, otherwise we throw with an EOF error.

Our pop parser is almost identical, with the additional step of modifying the context to remove the grapheme we want to return:

pub fn pop () -> Parser(String) {
    context.get() |> eval.then(fn (stream) {
        case stream {
            [ grapheme, ..rest ] ->
                context.set(rest)
                    |> eval.replace(grapheme)
            
            _ ->
                eval.throw(EOF)
        }
    })
}

Some parser combinators

With just peek and pop we can start to build up more powerful parsers. To parse an integer we’ll need to continuously pop graphemes off the stack until we come across a non-digit character. We can do this by writing a many combinator:

pub fn many (parser: Parser(a)) -> Parser(List(a)) {
    let append = fn (x, xs) { [ x, ..xs ] }
    let go = fn (xs) {
        eval.attempt(parser |> eval.then(append(_, xs)), catch: fn (_) {
            list.reverse(xs) 
                |> eval.succeed
        })
    }

    go([])
}

This will repeatedly call parser until it throws, and then return the list of all the values it has parsed so far. The list is reversed before returning because we want to return the values in the order they were parsed.

❓ How might we modify this to parse one_or_more of something instead? There’s more than one way, but thinking about it will hopefully help you see all the interesting ways Eval computations can be built up!

It’s worth noting that many itself doesn’t alter the context in any way. Despite this, changes to the context made by running parser will be propagated to later runs as you’d expect. No manual threading of state required.

Before we can parse integers, we’ll want a digit parser to pass to many:

pub fn digit () -> Parser(String) {
    peek() |> eval.then(fn (grapheme) {
        case grapheme {
            "0" | "1" | "2" | "3" | "4" |
            "5" | "6" | "7" | "8" | "9" ->
                pop() |> eval.replace(grapheme)
            
            _ ->
                eval.throw(Unexpected(grapheme))
        }
    })
}

Then, we can write an int parser that consumes as many digits as possible, joins the string back together and then uses Gleam’s own int.parse function to turn that string into a bona fide integer:

pub fn int () -> Parser(Int) {
    many(digit())
        |> eval.map(string.join(_, ""))
        |> eval.map(fn (digits) {
            assert Ok(n) = int.parse(digits)

            n
        })
}

With that out of the way, the final combinator we need is one that let’s attempt multiple parsers, and keep the result of whichever succeeds first. For that we will define one_of:

pub fn one_of (parsers: List(Parser(a))) -> Parser(a) {
    case parsers {
        [ parser, ..rest ] ->
            eval.attempt(parser, catch: fn (_) {
                one_of(rest)
            })

        _ ->
            eval.throw(InvalidParser)
    }
}

Parsing expressions

Now we are ready to write a parser for each our of expression variants, and then combine everything into a single expr parser. We’ve seen enough of the eval package now to be able to put together these parsers will little comment, so here is the rest of the program:

pub fn number () -> Parser(Expr) {
    int() |> eval.map(Number)
}

pub fn expr () -> Parser(Expr) {
    number() |> eval.then(fn (lhs) {
        peek() |> eval.then(fn (grapheme) {
            case grapheme {
                "+" ->
                    pop() |> eval.then(fn (_) {
                        expr() |> eval.map(fn (rhs) {
                            Add(lhs, rhs)
                        })
                    })

                _ ->
                    eval.succeed(lhs)
            }
        })
    })
}

pub fn run (input: String) -> Result(Expr, Error) {
    eval.run(expr(), with:
        string.graphemes(input)
    )
}

Take a look in test/examples/ to see the complete parser example, as well as a handful of others.