Skip to content
Tiny Interpreters
Go back

Setting Up a Reproducible Elm Interpreter Project with Nix

In CONST: The Structure of a Tiny Interpreter in Elm, we established the structure of a tiny interpreter. Then, in Testing an Elm Interpreter with elm-test, we looked more closely at how its tests are organized.

Before moving on to DIFF: Adding Recursive Expressions to a Tiny Interpreter in Elm, let’s step back and set up the reusable project foundation shared by these interpreters:

We use Nix to provide the development tools and record the resolved inputs so the environment can be recreated later. This isn’t a general introduction to Nix or an argument that every Elm project should use it. We only need enough Nix to create and enter the project environment.

Table of contents

Open Table of contents

Prerequisites

You’ll need:

Create the repository

We’ll use CONST as the example:

git init const
cd const
touch README.md flake.nix

For another interpreter, use its lowercase name in the first two commands.

Every interpreter repository will have a README.md, though we won’t cover its contents here.

Define the Nix development environment

Add the following to flake.nix:

{
  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem(system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
      in
      {
        devShells.default = pkgs.mkShell {
          name = "const";

          packages = [
            pkgs.elmPackages.elm
            pkgs.elmPackages.elm-format
            pkgs.elmPackages.elm-test
          ];

          shellHook = ''
            export PROJECT_ROOT="$(git rev-parse --show-toplevel)"
            export PS1="($name)\n$PS1"

            f () {
              elm-format "$PROJECT_ROOT/"{src,tests} "''${@:---yes}"
            }

            t () {
              elm-test "$@"
            }

            c () {
              nix flake check -L &&
              f --validate &&
              t
            }

            clean () {
              rm -rf "$PROJECT_ROOT/elm-stuff"
            }

            echo "Development environment loaded"
            echo ""
            echo "Type 'f' to run elm-format"
            echo "Type 't' to run elm-test"
            echo "Type 'c' to run all checks"
            echo "Type 'clean' to remove build artifacts"
            echo ""
          '';
        };
      }
    );
}

The outputs function receives the flake itself as self, along with the nixpkgs and flake-utils inputs. Because those inputs are not declared explicitly, Nix resolves their names through its flake registry. The specific revisions it finds will be recorded in flake.lock.

flake-utils.lib.eachDefaultSystem defines the same outputs for each of its default systems. For each system, the following line selects the corresponding collection of packages from Nixpkgs and names it pkgs:

pkgs = nixpkgs.legacyPackages.${system};

That gives us access to packages such as:

pkgs.elmPackages.elm

devShells.default defines the environment we enter with nix develop.

The packages list gives us the three development tools we need:

The shellHook runs whenever we enter the environment. It sets the project root, updates the shell prompt, defines a few helper commands, and prints a reminder of the commands available.

The only value we need to change in flake.nix for each interpreter is the shell name:

name = "const";

For DIFF, that becomes:

name = "diff";

For the interpreters in this series, the rest of the file remains the same.

Enter the development environment

Add flake.nix to the staging area before asking Nix to evaluate it:

git add flake.nix
nix develop

A flake inside a Git repository can only see files that Git knows about, which is why the new flake.nix must be staged first.

Run nix develop from the repository root. The development environment uses Git to find the repository root and assigns it to PROJECT_ROOT, so the helper commands can find the project files even after you move into a subdirectory.

You can confirm that the tools are available by checking their versions:

elm --version
elm-format --version
elm-test --version

The first time you run nix develop, Nix also creates flake.lock.

flake.nix describes the development environment. flake.lock records the specific revisions that Nix resolved. Later runs reuse those revisions until the flake is updated.

Add the lock file to Git:

git add flake.lock

Committing flake.lock ensures that those resolved revisions travel with the project, so other developers and future checkouts use the same Nix inputs.

Ignore Elm build artifacts

Create .gitignore at the root of the project:

elm-stuff/

elm-stuff contains generated build data and doesn’t need to be committed.

Initialize Elm and the tests

Start the Elm project:

elm init

Press Enter to accept the default answer.

This creates:

.
├── src/
└── elm.json

The interpreter’s source modules will live under src.

Our interpreters use elm/parser, so install it next:

elm install elm/parser

Press Enter to confirm the changes to elm.json.

Now set up elm-test:

elm-test init

This adds elm-explorations/test to the test dependencies in elm.json and creates an example test module:

.
└── tests/
    └── Example.elm

Remove the example:

rm tests/Example.elm

We’ll replace it with a structure that mirrors the interpreter modules.

At this point, both src and tests exist. Next, we’ll organize the interpreter modules and give the tests a matching structure.

Organize the interpreter modules

Each interpreter’s source modules live under an uppercase namespace directory in src.

For CONST:

mkdir -p src/CONST

The source structure will be:

.
└── src/
    └── CONST/
        ├── AST.elm
        ├── Interpreter.elm
        ├── Lexer.elm
        └── Parser.elm

Each module has a familiar responsibility:

For DIFF, the directory and module namespace will change from CONST to DIFF, but the basic organization will remain the same.

Mirror the source modules in the tests

Create the test-module directory:

mkdir -p tests/Test/CONST

The tests follow the source structure beneath a top-level Test namespace:

.
└── tests/
    └── Test/
        ├── CONST/
        │   ├── Interpreter.elm
        │   ├── Lexer.elm
        │   └── Parser.elm
        └── Lib.elm

Each test module mirrors the source module it tests. For example:

src/CONST/Lexer.elm
tests/Test/CONST/Lexer.elm

These files define the modules:

CONST.Lexer
Test.CONST.Lexer

The same pattern applies to Parser.elm and Interpreter.elm.

We don’t currently need a test module for CONST.AST because it only defines the types used to represent valid programs.

Each test module exposes one value named suite:

module Test.CONST.Lexer exposing (suite)

import Test exposing (Test, describe)


suite : Test
suite =
    describe "CONST.Lexer"
        [ -- Lexer test groups go here.
        ]

elm-test discovers exposed top-level values with the type Test. The value does not have to be named suite; that’s just the convention we’ll use throughout the series.

The top-level describe uses the name of the module under test. The specific groups of test cases live inside it.

Reusable test helpers belong in:

.
└── tests/
    └── Test/
        └── Lib.elm

For example, the testValue helper developed in Testing an Elm Interpreter with elm-test lives in Test.Lib and can be imported by the lexer, parser, and interpreter tests.

That keeps each test module focused on its language examples rather than the mechanics used to run them.

Use the helper commands

The development environment defines four helper commands.

Format the project

Run:

f

This formats the Elm files under both src and tests.

With no arguments, f passes --yes to elm-format. You can override that default by passing another option:

f --validate

That validates the formatting without changing any files.

Run the tests

Run:

t

This calls elm-test and forwards any additional arguments:

t --watch

Run all checks

Run:

c

This runs the following commands in sequence:

nix flake check -L
f --validate
t

The first command checks the flake. The other two validate the Elm formatting and run the tests. If any command fails, c stops and reports the failure.

Remove build artifacts

Run:

clean

This removes the generated elm-stuff directory.

Document the grammar

Create the docs directory and the grammar file:

mkdir -p docs
touch docs/grammar.ebnf

The grammar.ebnf file contains the context-free grammar for the interpreter.

For CONST:

Program ::= Expr
Expr    ::= Const
Const   ::= Number
Number  ::= [0-9]+

We’ll also generate a Markdown version of the grammar with railroad diagrams. Once generated, the documentation structure will be:

.
└── docs/
    ├── grammar/
    │   ├── diagram/
    │   │   └── *.svg
    │   └── README.md
    └── grammar.ebnf

docs/grammar contains the generated Markdown documentation and the SVG railroad diagrams it references.

You can see the generated grammar documentation for CONST in its repository.

To generate them:

  1. Edit docs/grammar.ebnf.
  2. Open the Bottlecaps Railroad Diagram Generator.
  3. Paste the grammar into the Edit Grammar tab.
  4. Open the View Diagram tab.
  5. Under Download Diagram, select Markdown and SVG, then download the archive.
  6. Extract the archive and rename index.md to README.md.
  7. Move the generated directory to docs/grammar.

When the grammar changes, update grammar.ebnf and regenerate the documentation.

Review the finished project structure

Once the interpreter, its tests, and the grammar documentation are in place, the project will look like this:

.
├── docs/
│   ├── grammar/
│   │   ├── diagram/
│   │   │   └── *.svg
│   │   └── README.md
│   └── grammar.ebnf
├── src/
│   └── CONST/
│       ├── AST.elm
│       ├── Interpreter.elm
│       ├── Lexer.elm
│       └── Parser.elm
├── tests/
│   └── Test/
│       ├── CONST/
│       │   ├── Interpreter.elm
│       │   ├── Lexer.elm
│       │   └── Parser.elm
│       └── Lib.elm
├── .gitignore
├── README.md
├── elm.json
├── flake.lock
└── flake.nix

The interpreter name changes from project to project. The surrounding structure stays the same.

Verify the setup

With the source and test modules in place, exit the current development shell and open a fresh one from the repository root:

exit
nix develop

Then run the combined project checks:

c

This checks the flake, validates the Elm formatting, and runs the tests. If the command succeeds, the project setup is ready to use.

Ready for the next interpreter

We now have a reusable foundation for the interpreters in this series: the development tools and helper commands are configured, and the source, test, and grammar documentation structures are defined.

Later posts can therefore concentrate on what changes from one interpreter to the next: the grammar, lexer, AST, parser, evaluator, and tests.

Next, we’ll use this foundation to add difference expressions in DIFF.



Share this post:

Previous Post
Testing an Elm Interpreter with elm-test
Next Post
DIFF: Adding Recursive Expressions to a Tiny Interpreter in Elm