What is first?
first is a small interpreted language implemented in C99. It serves as a learning platform and a foundation for exploring language design — everything from the lexer to the runtime value is written by hand, with no parser generators or external dependencies.
The current implementation is fully expression-based: arithmetic, relational, equality, and boolean operators; number, string, and boolean literals; let variable declarations; a when expression for predicate-based branching; { } scoped blocks; fn function literals with closures and recursion; and an optional module header naming a program. The pipeline runs from source text through a streaming lexer, a recursive-descent parser, and a tree-walk interpreter.
src/.
Installation
first uses CMake with a debug preset. The only test dependency is Criterion, which must be installed system-wide via your package manager.
Prerequisites
- CMake 3.20 or later
- A C99-capable compiler (gcc or clang)
- Criterion (for tests only) — resolved via
pkg-config
Build
# Clone the repository git clone https://gitlab.com/johnhidey/first-lang cd first # Configure with the debug preset cmake --preset debug # Build everything cmake --build build
The build produces two artifacts in build/: the first interpreter binary and first-test, the test runner. Both link against libfirst, the static library built from src/.
Quick start
Run a file
./build/first path/to/program.first
Run from stdin
echo "let x = 1 + 2" | ./build/first
Your first program
# area.first let width = 20 let height = 15 let area = width * height area
→ 300
The interpreter prints the value of the last evaluated statement. To inspect an intermediate value, make it the final statement.
Syntax overview
A first program is a sequence of expressions. Every construct — declarations, conditionals, blocks, arithmetic — is an expression that produces a value, and the value of the last one evaluated is what the interpreter reports. Whitespace between tokens is ignored; newlines and commas separate items in a few specific positions (let bindings, when arms).
# variable declaration — introduces a new name, evaluates to its value let x = 42 # arithmetic expression x + 8 # boolean literal and comparison x > 40 && true # a scoped block, itself an expression { let y = x * 2 y }
# and run to the end of the line — the lexer discards everything up to the next newline and emits a single TT_NEWLINE in its place. There is no block-comment syntax.
RuntimeValue types today — see Types.
Modules
A program may optionally begin with a module <Name> header. If present, it must be the very first thing in the file, before any declarations or expressions — parse_program() checks for it once, up front.
module Geometry let width = 20 let height = 15 width * height
The name is recorded on the root AST_PROGRAM node (ast->data.AST_PROGRAM.name). Programs without a header parse to a NULL name — the header is entirely optional and changes nothing else about how the rest of the file is parsed.
module currently only attaches a name to the AST — there's no import/export mechanism yet, and interpreter_evaluate() never reads the name back. It's parsed and stored purely as metadata today, ahead of a fuller module system. See Contributing for what's still aspirational.
Name is required once the keyword appears: module with nothing after it is a parse error — the parser unconditionally calls expect_token(self, TT_IDENTIFIER) after consuming TT_KEYWORD_MODULE. There's no enforced casing; module geometry is as valid as module Geometry, though examples in this documentation use PascalCase by convention.
Variables
Declaration
Use let to introduce a new variable. Redeclaring an existing name — in the same scope or any visible parent scope — is a runtime error.
let x = 10 let y = x * 2
Multiple declarations can be combined in a single statement with commas. Each right-hand side is evaluated in order — forward references within the same let are not supported.
let a = 1, b = 2, c = a + b
A let initializer can itself chain further bindings with =, e.g. let x = y = 5 declares both x and y to 5. This chained form is the only place the name = expr shape appears in the grammar.
No bare reassignment yet: there is no top-level x = expr statement that reassigns an existing x. environment_assign_variable exists in environment.c but nothing in interpreter.c currently calls it — every binding, including the chained let form above, goes through environment_declare_variable. Writing x = 5 for an already-declared x outside of a let is not currently reachable from the grammar.
Scope
Variables live in an Environment that chains parent scopes by pointer. A { } block evaluates its body in a fresh child environment, so declarations inside a block don't leak into the enclosing scope — see Blocks.
Expressions
first supports arithmetic, relational, equality, and boolean operators, each with its own precedence layer in the recursive-descent parser (see include/parser.h for the authoritative table). Parentheses and { } blocks override precedence.
| Operator | Name | Precedence | Associativity |
|---|---|---|---|
| || | Logical OR | 9 | Left |
| && | Logical AND | 8 | Left |
| == | Equal | 7 | Left |
| != | Not equal | 7 | Left |
| < | Less than | 6 | Left |
| > | Greater than | 6 | Left |
| <= | Less than or equal | 6 | Left |
| >= | Greater than or equal | 6 | Left |
| + | Addition | 5 | Left |
| - | Subtraction | 5 | Left |
| * | Multiplication | 4 | Left |
| / | Division | 4 | Left |
| % | Modulo | 4 | Left |
| ** | Exponent | 3 | Left |
| + - (unary) | Unary plus / minus | 2 | Right |
2 + 3 * 4 # → 14 (* binds tighter) (2 + 3) * 4 # → 20 (parens override) 10 - 3 - 2 # → 5 (left-associative) 1 < 2 == true # → true (relational binds tighter than equality) true || false && false # → true (&& binds tighter than ||)
Exponent is left-associative in the current implementation — 2 ** 3 ** 2 parses as (2 ** 3) ** 2, not the right-associative reading most languages use. This falls out of parse_exponation() sharing the same left-folding binary_expression() helper as every other binary operator; the parser test suite documents this explicitly as exponent_is_left_associative_in_current_implementation.
The when expression
first has no if/else. Branching is done with when: a series of predicate arms evaluated top to bottom, followed by a mandatory catch-all arm written _ -> expr. when is itself an expression — its value is whichever arm's consequent ran.
let sign = when x > 0 -> "positive", x < 0 -> "negative", _ -> "zero"
Arms may be separated by a comma or a newline — both are accepted, so a when can be written on one line or spread across several. Each arm's predicate is a boolean_or expression; the first arm whose predicate is true wins, and no later arms (including the catch-all) are evaluated.
when n <= 0 -> 0, n == 1 -> 1, _ -> fib(n - 1) + fib(n - 2)
while (!is_wildcard(self->token)) and then unconditionally expects an identifier (_) followed by ->. Omitting it is a parse error.
Blocks
{ } groups a sequence of expressions and evaluates them in a fresh child Environment. A block is itself a primary expression, so it can appear anywhere an expression can — as a when arm's consequent, a let initializer, or standalone.
let area = { let width = 10 let height = 4 width * height } # area = 40 — width and height are not visible outside the block
A block's value is the value of the last expression in its body. Because declarations inside the block live in a child environment, they're discarded when the block finishes evaluating.
Functions
Functions are values, not their own kind of declaration. The fn keyword produces an anonymous function literal — fn(params) -> body — which is then bound to a name the same way any other value is, with let.
let add = fn(x, y) -> x + y add(2, 3) # → 5
fn add(x, y) -> ... form. fn only ever produces a value — naming it is an ordinary let binding, not a separate grammar rule.
Parameters and calls
Parameters are a comma-separated list of identifiers in parentheses, and zero or more are allowed. The body is a single expression — use a { } block to sequence several. Every call site is checked for arity: too few or too many arguments is a runtime error.
let greet = fn() -> "hi" let scale = fn(x) -> { let factor = 10 x * factor } greet() # → "hi" scale(4) # → 40
Closures
A function literal captures the Environment it was defined in — not a snapshot of the variables in it, but the live environment itself (eval_function_literal stores the Environment * pointer as-is). Looking up a free variable inside the body walks that captured environment, and its parents, at call time — so it sees whatever is bound there by the time the call happens, not just what existed when the closure was created.
let x = 10 let add_x = fn(y) -> x + y add_x(5) # → 15 — x resolves from the defining scope, not the call site
Recursion
A function can call itself by name, including through when. This works even though the name doesn't exist yet at the instant the function literal to its right is evaluated: eval_variable_declaration evaluates the initializer — creating the closure over the current environment — before declaring the name into that same environment. Because the closure holds a pointer to the environment rather than a copy, the name is visible by the time the function actually runs.
let fact = fn(n) -> when n <= 1 -> 1, _ -> n * fact(n - 1) fact(5) # → 120
Function literals can't yet appear as call arguments or other inline expressions: fn is only recognized in the handful of places parse_assignment() is called directly — chiefly a let initializer. Call arguments are parsed with parse_boolean_or(), which never checks for the fn keyword, so passing a function literal straight into a call — a map-style inline callback — isn't supported yet; bind it to a name with let first and pass the identifier instead. Chained calls on a call's own result, like f()(), aren't supported either — parse_primary() only applies one call to an identifier before returning.
Types
The runtime has five value types, represented by the RuntimeValue tagged union in include/runtime.h.
| Type | C representation | Status |
|---|---|---|
| INTEGER | int | Implemented |
| FLOAT | float | Implemented |
| STRING | char * | Implemented |
| BOOLEAN | bool | Implemented |
| ERROR | char *description, char *stack_trace | Type defined; not yet produced by the interpreter |
Numeric literals are parsed as NUMBER_INTEGER or NUMBER_FLOAT at the AST level depending on whether a . is present. Every binary operation calls promote_types() first, but the only call site passes allow_promotion = false — so mixed int/float operands (e.g. 1 + 2.0) are rejected with a type error rather than actually promoted. Both operands currently need to already share a type.
Known gap: src/runtime.c exists but is absent from src/CMakeLists.txt's add_library(libfirst ...) sources and is not compiled into any target. Integer division and modulo by zero are not guarded against — they hit undefined behaviour rather than producing a runtime ERROR value.
Lexer API
The lexer is exposed through four functions in include/lexer.h. The Lexer struct is opaque — interact with it only through the public API.
/* Create a lexer. Takes ownership of the FILE* stream. */ Lexer *lex_create(FILE *stream); /* Retrieve the next token. Returns 0 on success. */ int32_t lex_next_token(Lexer *self, Token *token); /* Free all resources including the stream. Returns 0 on success. */ int32_t lex_destroy(Lexer *self); /* Render a TokenType as a human-readable string. */ const char *lex_token_type_to_string(TokenType type);
lex_create takes only a FILE * — it no longer accepts a filename argument.
Usage example
FILE *f = fopen("program.first", "r"); Lexer *lexer = lex_create(f); Token tok; do { lex_next_token(lexer, &tok); printf("%s\n", tok.value); free(tok.value); } while (tok.type != TT_EOI); lex_destroy(lexer);
Token types
All token types are defined in the TokenType enum in include/lexer.h. Every token's value field is a heap-allocated char *. The caller is responsible for calling free(token.value) after use.
| Token type | Description | Example value |
|---|---|---|
| TT_WHITESPACE | Spaces, tabs, LF, CR | " \n" |
| TT_NUMBER | Contiguous digit sequence | "42" |
| TT_STRING | Double-quoted character sequence | "hello" |
| TT_IDENTIFIER | Starts with a letter or _ | "width" |
| TT_KEYWORD_TRUE | The true literal | "true" |
| TT_KEYWORD_FALSE | The false literal | "false" |
| TT_ADDITION | Plus sign | "+" |
| TT_SUBTRACTION | Minus sign | "-" |
| TT_MULTIPLICATION | Asterisk | "*" |
| TT_DIVISION | Forward slash | "/" |
| TT_MODULO | Percent sign | "%" |
| TT_EXPONENT | Double asterisk | "**" |
| TT_AND | Logical AND | "&&" |
| TT_OR | Logical OR | "||" |
| TT_LEFT_BRACE | Opening brace | "{" |
| TT_RIGHT_BRACE | Closing brace | "}" |
| TT_LEFT_PAREN | Opening parenthesis | "(" |
| TT_RIGHT_PAREN | Closing parenthesis | ")" |
| TT_EQUAL | Assignment sign | "=" |
| TT_EQUAL_EQUAL | Equality comparison | "==" |
| TT_NOT_EQUAL | Inequality comparison | "!=" |
| TT_LESSTHAN | Relational less-than | "<" |
| TT_GREATERTHAN | Relational greater-than | ">" |
| TT_LESSTHAN_EQUAL | Relational less-or-equal | "<=" |
| TT_GREATERTHAN_EQUAL | Relational greater-or-equal | ">=" |
| TT_KEYWORD_LET | The let keyword | "let" |
| TT_KEYWORD_WHEN | The when keyword | "when" |
| TT_KEYWORD_MODULE | The module keyword | "module" |
| TT_KEYWORD_FN | The fn keyword | "fn" |
| TT_ARROW | Links a when arm predicate to its consequent | "->" |
| TT_COMMA | Separates declarations and when arms | "," |
| TT_NEWLINE | Statement/arm terminator | "\n" |
| TT_EOI | End of input sentinel | — |
The lexer performs no type conversion — everything is a string at the tokenization stage. Numeric interpretation happens in the interpreter.
AST nodes
The AST structure is a tagged union keyed on a Kind tag field, defined in include/parser.h. The interpreter dispatches on this tag in interpreter_evaluate().
| Node kind | Description |
|---|---|
| AST_PROGRAM | Root node; wraps the top-level expression list and an optional module header name |
| AST_EXPRESSION_LIST | Ordered sequence of expressions (program body, block body) |
| AST_VARIABLE_DECLARATION | One or more comma-separated let name = expr bindings |
| AST_ASSIGNMENT | name = expr — only appears inside a let initializer chain |
| AST_BINARY_EXPRESSION | Two operands and an Operation (arithmetic, relational, equality, or boolean) |
| AST_UNARY | Unary + or - applied to an operand |
| AST_CONDITIONAL | A when expression — an array of predicate/consequent arms plus a mandatory catch-all |
| AST_BLOCK | A { } scoped group, evaluated in a child environment |
| AST_FUNCTION | A fn(params) -> body literal — holds its parameter list and body expression |
| AST_CALL | A function call — a callee expression plus a list of argument expressions |
| AST_IDENTIFIER | A variable name reference |
| AST_NUMBER | An integer or float literal, tagged by NumberKind |
| AST_STRING | A string literal |
| AST_BOOLEAN | A true/false literal |
AST_DECLARATION is also a Kind value but is never assigned as an actual node's tag — struct AST_DECLARATION is a plain (non-tagged) helper struct used as the element type of AST_VARIABLE_DECLARATION.declarations, one per name = init pair in a let.
Environment
Variables are stored in an Environment (src/environment.c), which wraps a JMap hash map and a parent pointer for scope chaining. environment_create(parent) makes a new child scope — this is what eval_block() calls for every { }.
environment_declare_variable— inserts a new binding, walking up the parent chain first to check for an existing declaration. Errors (and exits) on redeclaration anywhere in the visible scope chain. This is the only pathinterpreter.ccurrently uses for all bindings, includingletand the chained assignment form.environment_assign_variable— intended to update an existing binding by walking up the parent chain, but not currently called anywhere ininterpreter.c. There is no workingx = exprreassignment statement today.environment_lookup_variable— looks up a value, also walking the parent chain.
JMap (src/jmap.c) is a custom open-addressing hash map with linear probing. Keys are char * variable names; values are RuntimeValue *. It doubles capacity and rehashes at a 0.7 load factor.
Build system
The project uses CMake 3.20+ with a debug preset defined in CMakePresets.json. The preset sets CMAKE_BUILD_TYPE=Debug and CMAKE_VERBOSE_MAKEFILE=ON, outputting to build/.
# Configure cmake --preset debug # Build cmake --build build # Clean rebuild cmake --build build --clean-first
All targets compile with -Wall -Wextra -Wpedantic -std=c99 and extensions disabled. vcpkg is configured but carries no managed dependencies — Criterion is resolved via pkg-config.
Testing
Tests use the Criterion framework. Test source lives in test/ and links against libfirst. Current coverage includes the lexer and parser suites.
# Build and run all tests cmake --build build && ./build/first-test # Run with CTest ctest --test-dir build # Run a specific Criterion suite ./build/first-test --filter lexer/<test_name>
Contributing
first is an open project. Contributions, bug reports, and ideas are welcome on GitLab. A few conventions to follow:
- All code must build cleanly with
-Wall -Wextra -Wpedantic. No warnings permitted. - New features should come with Criterion tests in
test/. - Keep the C99 baseline — no C11/C23 features without discussion.
- The
examples/directory still contains aspirational syntax not yet implemented — built-in I/O (read,write), string interpolation, and bare reassignment withoutlet. See Modules for whatmoduleactually does today and Functions for whatfnactually supports. Prototypes welcome.