169 Reference Implementation

A reference implementation of the Predica language is being developed in C++. It is a work in progress. Development began in April 2026.

The initial emphasis is on developing a Turing-complete language in which databases can be created or opened and can be the targets of updates and queries. Predica supports defining types and functions, writing statement-based code, and using control-flow constructs such as loops. The question of how to use Predica from other host languages will be addressed in the future.

Performance summary

The lexer and parser support the complete language described by the grammar chapters and convert Predica source files into ASTs at about 80 MB/sec, or about 4 million lines per second.

Source files

Stable immutable source buffers

The lexer, AST, and parser operate on a std::string_view containing one complete source file. The source is a contiguous immutable byte buffer and remains alive while a parsed result is in use. Tokens and AST nodes can therefore identify text by byte offset and length without allocating strings.

SourceFiles owns loaded source buffers at stable addresses. Adding another file does not invalidate an earlier file, its path, its text, or its file ID. A file is opened in binary mode at its end, its size is obtained with tellg(), and its string is allocated once before a direct read. Source loading deliberately requires an ordinary seekable file and detects a file which grows or shrinks during the read.

Line and column work is kept out of the lexer

Human-readable positions are needed when a diagnostic is displayed, not for every token and AST node. A loaded source file eagerly records the 32-bit byte offset of each line start. Given an error offset, std::upper_bound performs a binary search for the containing line; the byte column is then a subtraction from that line start. This keeps tokens compact and avoids updating line and column state in the lexer hot path.

Lexer

A token is already in its retained representation

The lexer tracks its current byte offset, so it returns the representation which the parser will retain directly:


enum class TokenKind : std::uint8_t
{
    EndOfFile,
    Identifier,
    Keyword,
    BooleanLiteral,
    IntegerLiteral,
    RealLiteral,
    StringLiteral,
    Operator,
    Punctuation
};

struct Token
{
    std::uint32_t begin;
    std::uint32_t size;
    TokenKind kind;
};

static_assert(sizeof(Token) == 12);

Earlier versions returned a TokenKind and std::string_view, then converted each token into a separate 12-byte stored form. The current representation removes that conversion and halves the size of tokens retained in a vector from 24 to 12 bytes. Token text is reconstructed temporarily from the source only when the parser needs to inspect its spelling. The EOF token has an empty range beginning at source.size().

Capacity is estimated and reused

Before tokenisation, the token vector reserves capacity estimated from the source size. A parser workspace retains that vector between files, so later parses clear its logical contents without releasing its allocation. Compact tokens reduce both the final vector size and the copying performed if an estimate is exceeded.

In the current benchmark, a reserved vector and a reused vector sustain about 125 MB/s and 127 MB/s respectively, compared with about 129 MB/s when tokens are consumed immediately without retaining them. Token storage is therefore a small part of total lexer time. The earliest implementation measured about 61 MB/s when retaining tokens and 77 MB/s when consuming them immediately, so the retained-token path is now approximately twice as fast.

These measurements use approximately 10 MB of source on the development x86-64 Linux laptop, compiled by GCC 13.3.0 in release mode. Laptop temperature causes visible variation, so the figures describe sustained ranges rather than portable absolute limits.

Common bytes take short paths

ASCII punctuation and operators are selected by direct first-byte dispatch. Identifiers are scanned with simple ASCII range tests, and keywords are found by binary search in a sorted table. Less common Unicode operators are matched against a compact table, with longer prefix-sharing spellings first. Once a known Unicode spelling has matched, its bytes are not validated a second time. Arbitrary non-ASCII input in comments and string literals is still fully validated, including rejection of overlong UTF-8, surrogate encodings, and code points outside the Unicode range.

Assertions support strong hot-path assumptions

The implementation follows programming by contract: public interfaces state strong preconditions and postconditions, while the implementation maintains explicit invariants. For example, source loading and the public parser establish that a file fits the 32-bit source-offset representation. The lexer and token buffer can therefore narrow offsets directly. Assertions check the contract and invariants in debug builds without adding repeated branches to release hot paths.

This is deliberately different from applying Postel's law within the compiler pipeline. Silently accepting malformed inputs or defensively accommodating states that preceding layers promised not to produce would weaken the contracts, obscure programming errors, and cost performance. Invalid source text is, of course, reported as a normal diagnostic; a violated internal contract is instead treated as an implementation defect.

Abstract Syntax Tree

Named integer indexes replace pointers

Expressions, type expressions, statements, and declarations are held in four separate contiguous vectors. Recursive relationships use named aliases for 32-bit integer indexes rather than pointers to separately allocated nodes. They are deliberately aliases rather than integer wrapper classes: their names document their meaning while allowing direct, uncluttered vector indexing.


using SourceOffset = std::uint32_t;

using ExpressionIndex = std::uint32_t;
using TypeExpressionIndex = std::uint32_t;
using StatementIndex = std::uint32_t;
using DeclarationIndex = std::uint32_t;
using HeapIndex = std::uint32_t;

struct HeapSlice
{
    HeapIndex begin;
    std::uint32_t count;
};

using ExpressionIndexSlice = HeapSlice;
using TypeExpressionIndexSlice = HeapSlice;
using StatementIndexSlice = HeapSlice;
using DeclarationIndexSlice = HeapSlice;

Slice aliases similarly state what the words in a slice mean without adding a new representation or traversal abstraction. Indexes remain valid when vectors move or reallocate, and a complete parsed file is relocatable without rewriting links between nodes. Later compiler passes can store semantic facts in separate arrays addressed by the same indexes, leaving the syntax tree compact and effectively immutable.

Common nodes are fixed-size records

The principal node types each occupy 16 bytes. A node is a compact tagged record whose kind determines the interpretation of detail, data0, and data1. A binary expression, for example, stores its operator in detail and the indexes of its operands in data0 and data1.


struct Expression
{
    SourceOffset source;
    ExpressionKind kind;
    std::uint8_t detail;
    std::uint16_t reserved;
    std::uint32_t data0;
    std::uint32_t data1;
};

static_assert(sizeof(Expression) == 16);

A heap stores variable-sized AST data

The Heap class owns one contiguous vector of 32-bit words. The parser appends variable-length arrays and less common records to this vector. A HeapIndex identifies a record. A HeapSlice identifies the first word of an array and records its number of elements. Records are copied with memcpy, avoiding alignment, object-lifetime, and aliasing problems which would result from casting the word storage to arbitrary record types. Clearing the heap removes its contents but retains the vector's capacity for the next file.


class Heap
{
public:
    template<class T> HeapIndex append(const T& value);
    template<class T> HeapSlice appendArray(const std::vector<T>& values);
    template<class T> T read(HeapIndex index) const;
    void clear() noexcept;

private:
    std::vector<std::uint32_t> words_;
};

struct AstArena
{
    std::vector<Expression> expressions;
    std::vector<TypeExpression> typeExpressions;
    std::vector<Statement> statements;
    std::vector<Declaration> declarations;
    Heap heap;
};

Source provenance is compact and external

A node normally stores a 32-bit byte offset rather than a pointer, line and column, or complete source range. Source-backed text is represented by an offset and byte count. The parsed result aliases its immutable source buffer; it does not own or copy that buffer.


struct ParsedSourceFile
{
    std::string_view source;
    AstArena arena;
    SourceFile root;
};

Parser

The public API makes allocation reuse possible

Returning a large allocation-owning result by value can avoid copying through move elision, but it cannot return the result's vector capacity to the next parse. The public API therefore takes both reusable temporary storage and the output by reference:


void parseSourceFile(
    std::string_view source,
    ParserWorkspace& workspace,
    ast::ParsedSourceFile& output);

ParserWorkspace is opaque in the public header and retains compact token storage. The output owns its AST arena. Before parsing, the five arena vectors are cleared without releasing capacity. A compiler can keep one workspace per parsing thread and reuse an output sequentially, while retaining independent outputs for files whose ASTs must remain available.

The public-API benchmark measures both patterns. Creating a workspace and output for every parse sustains about 66 MB/s. Reusing the token buffer and all five AST vector allocations sustains about 78 MB/s, an improvement of approximately 17%. This difference is why the performance test goes through the public API rather than constructing private parser machinery.

The parser writes directly into the final arena

The parser is a hand-written recursive-descent parser with separate functions for declarations, statements, type expressions, and expression precedence levels. It tokenises the complete file once, advances through the contiguous token vector by index, appends completed nodes directly to the output arena, and returns typed 32-bit indexes. There is no pointer-rich intermediate syntax tree and no per-node allocation.


class Parser
{
public:
    Parser(
        std::string_view source,
        ast::AstArena& arena,
        std::vector<Token>& tokenStorage);

    ast::SourceFile parseSourceFile();

private:
    ast::ExpressionIndex expression();
    ast::TypeExpressionIndex typeExpression();
    ast::StatementIndex statement();
    ast::DeclarationIndex declaration();

    TokenBuffer tokens_;
    std::size_t current_ = 0;
    ArenaBuilder arena_;
};

Sentinel tokens remove repeated bounds branches

The token buffer appends a second EOF token as a sentinel. Parser lookahead can then inspect one token beyond the ordinary EOF position without a bounds test. For example, qualified-name and procedure-call recognition can examine the token after a trailing dot safely. The parser asserts that ordinary advancement never consumes EOF, making the sentinel an internal invariant rather than error recovery.

32-bit overflow is checked outside hot paths

Every AST belongs to one source file, not to an entire multi-file component. The source-size limit therefore bounds typed node counts and slice element counts for that file; debug builds assert those relationships when nodes are appended.

The heap can expand by more than one word per source byte, so its final size still requires a release-build check. Checking every record and array append reduced parser throughput by 2–4%. Instead, heap indexes are not dereferenced during construction, and the four node-vector sizes and heap size are validated once after parsing and before the root is published. If a size cannot be represented, parsing throws a structured ParseError.

Separately retained old and new benchmark executables were run in alternating order to control for laptop temperature and scheduling. End-of-parse validation measured about 0.7% faster than the baseline in both fresh and reused modes, a difference small enough to treat as performance-neutral.

Errors store facts, not formatted messages

enum class ParseErrorKind : std::uint8_t
{
    ExpectedSpelling,
    ExpectedSyntax,
    IntegerLiteralTooLarge,
    ...
};

enum class ParseExpectation : std::uint8_t
{
    Unspecified,
    Expression,
    Declaration,
    ...
};

enum class ParseExpectedSpelling : std::uint8_t
{
    Unspecified,
    LeftBrace,
    RightBrace,
    ...
};

class ParseError : public std::exception
{
public:
    ParseError(
        ParseErrorKind kind,
        std::uint32_t offset,
        ParseExpectation expectation = ParseExpectation::Unspecified,
        ParseExpectedSpelling expectedSpelling =
            ParseExpectedSpelling::Unspecified);

    const char* what() const noexcept override;
    ParseErrorKind kind() const noexcept;
    std::uint32_t offset() const noexcept;
    ParseExpectation expectation() const noexcept;
    ParseExpectedSpelling expectedSpelling() const noexcept;

private:
    std::uint32_t offset_;
    ParseErrorKind kind_;
    ParseExpectation expectation_;
    ParseExpectedSpelling expectedSpelling_;
};

Lexer, parser, and source-loading exceptions store compact structured facts: an error kind, byte offset, and only the additional enum or byte required by that kind. Formatting is performed later by writeErrorMessage and the source diagnostic renderer. This keeps string construction out of parsing and, in particular, out of the sites that throw errors. It also tends to make the binary code for the hot parser paths more compact by keeping formatting machinery out of them, which can improve use of the CPU instruction caches. The structured representation also supports future localisation and allows the compiler to combine the error with a filename, line, byte column, source line, and caret.