introduction

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.

Note: first is in early development. The grammar, API, and internals will evolve. This documentation tracks the current implementation in 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
}
Comments start with # 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.
Strings, booleans, integers, and floats are all live 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.

Note: 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.

OperatorNamePrecedenceAssociativity
||Logical OR9Left
&&Logical AND8Left
==Equal7Left
!=Not equal7Left
<Less than6Left
>Greater than6Left
<=Less than or equal6Left
>=Greater than or equal6Left
+Addition5Left
-Subtraction5Left
*Multiplication4Left
/Division4Left
%Modulo4Left
**Exponent3Left
+ - (unary)Unary plus / minus2Right
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)
Note: the catch-all arm is not optional — the parser loops 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 literalfn(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
No named function declaration syntax: there is no 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.

TypeC representationStatus
INTEGERintImplemented
FLOATfloatImplemented
STRINGchar *Implemented
BOOLEANboolImplemented
ERRORchar *description, char *stack_traceType 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);
Note: 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 typeDescriptionExample value
TT_WHITESPACESpaces, tabs, LF, CR" \n"
TT_NUMBERContiguous digit sequence"42"
TT_STRINGDouble-quoted character sequence"hello"
TT_IDENTIFIERStarts with a letter or _"width"
TT_KEYWORD_TRUEThe true literal"true"
TT_KEYWORD_FALSEThe false literal"false"
TT_ADDITIONPlus sign"+"
TT_SUBTRACTIONMinus sign"-"
TT_MULTIPLICATIONAsterisk"*"
TT_DIVISIONForward slash"/"
TT_MODULOPercent sign"%"
TT_EXPONENTDouble asterisk"**"
TT_ANDLogical AND"&&"
TT_ORLogical OR"||"
TT_LEFT_BRACEOpening brace"{"
TT_RIGHT_BRACEClosing brace"}"
TT_LEFT_PARENOpening parenthesis"("
TT_RIGHT_PARENClosing parenthesis")"
TT_EQUALAssignment sign"="
TT_EQUAL_EQUALEquality comparison"=="
TT_NOT_EQUALInequality comparison"!="
TT_LESSTHANRelational less-than"<"
TT_GREATERTHANRelational greater-than">"
TT_LESSTHAN_EQUALRelational less-or-equal"<="
TT_GREATERTHAN_EQUALRelational greater-or-equal">="
TT_KEYWORD_LETThe let keyword"let"
TT_KEYWORD_WHENThe when keyword"when"
TT_KEYWORD_MODULEThe module keyword"module"
TT_KEYWORD_FNThe fn keyword"fn"
TT_ARROWLinks a when arm predicate to its consequent"->"
TT_COMMASeparates declarations and when arms","
TT_NEWLINEStatement/arm terminator"\n"
TT_EOIEnd 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 kindDescription
AST_PROGRAMRoot node; wraps the top-level expression list and an optional module header name
AST_EXPRESSION_LISTOrdered sequence of expressions (program body, block body)
AST_VARIABLE_DECLARATIONOne or more comma-separated let name = expr bindings
AST_ASSIGNMENTname = expr — only appears inside a let initializer chain
AST_BINARY_EXPRESSIONTwo operands and an Operation (arithmetic, relational, equality, or boolean)
AST_UNARYUnary + or - applied to an operand
AST_CONDITIONALA when expression — an array of predicate/consequent arms plus a mandatory catch-all
AST_BLOCKA { } scoped group, evaluated in a child environment
AST_FUNCTIONA fn(params) -> body literal — holds its parameter list and body expression
AST_CALLA function call — a callee expression plus a list of argument expressions
AST_IDENTIFIERA variable name reference
AST_NUMBERAn integer or float literal, tagged by NumberKind
AST_STRINGA string literal
AST_BOOLEANA true/false literal
Note: AST_DECLARATION is also a Kind value but is never assigned as an actual node's tagstruct 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 path interpreter.c currently uses for all bindings, including let and the chained assignment form.
  • environment_assign_variable — intended to update an existing binding by walking up the parent chain, but not currently called anywhere in interpreter.c. There is no working x = expr reassignment 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 without let. See Modules for what module actually does today and Functions for what fn actually supports. Prototypes welcome.