Architecture Overview

Pipeline

plaintext
Source (.tape)
    │
    ▼
Region Splitter (#code, #view, #style, #localization,
                 #resource, #test, #bytecode markers)
    │
    ├── #code region ──► Scanner ──► Parser (Pratt + recursive descent)
    │                                    │
    ├── #view region ──► Scanner ──► View Parser (component declarations)
    │                                    │
    ├── #style, #localization, #resource, #bytecode ──► their own parsers
    │                                    │
    │                    ┌───────────────┘
    │                    ▼
    │              Merged AST (tape_ast_module)
    │                    │
    │        ┌───────────┼───────────┐
    │        ▼           │           │
    │   Module Resolver  │    (single-module path)
    │   (if imports)     │           │
    │        │           │           │
    │        ▼           ▼           ▼
    │   Comptime Execution (@tape blocks, @emit)
    │                    │
    │                    ▼
    │              Style Resolution (#style rules → props at each site)
    │                    │
    │                    ▼
    │              Type Checker
    │                    │
    │                    ▼
    │              TAC Lowering (AST → three-address code)
    │                    │
    │                    ▼
    │              Link Check (@link symbol verification)
    │                    │
    │        ┌───────────┴───────────┐
    │        ▼                       ▼
    │   VM Interpreter          Native Codegen
    │   (tape run)              (TAC optimizer, inlining,
    │                           register allocation;
    │                           x86-64 / ARM64)
    │                                │
    │                                ▼
    │                           Binary Emitter
    │                           (PE64 / Mach-O / ELF64 / STO)
    │                                │
    │                                ▼
    └── #test region ──►        Output binary
        tape test path
        (separate pipeline)

Key design decisions

  1. Single IR — both VM and native backends consume the same TAC representation
  2. Per-region parsers — #code uses the full parser, #view a component parser, #style a rule parser, #test a test parser, #bytecode an instruction parser (and #localization / #resource their own entry parsers); all feed the same module
  3. No LLVM — direct machine code emission, keeping the compiler self-contained and fast
  4. Comptime before typecheck — @tape blocks and @emit run first, generating AST nodes that the type checker then validates like hand-written code

Region splitting

The first pass splits the file at #region markers (#code, #view, #style, #localization, #resource, #test, #bytecode). If no markers exist, the entire file is #code. A preamble (imports and attributes before the first marker) is prepended to #code. The #view region is parsed separately and its components are merged into the module’s component list. #style rules are resolved after comptime and before type checking: each instantiation site gets the matching rules’ values for the props it doesn’t set, so nothing of a style is left for run time.

Module resolution

When a module has import statements, the compiler enters the multi-module path:

  1. Resolve each import to a file path (include paths + relative paths)
  2. Parse each dependency recursively
  3. Build a dependency order
  4. Process each module in order: inject extern stubs → comptime → typecheck
  5. Build a unified dispatch registry across all modules
  6. Lower each module with shared dispatch info
  7. Merge all TAC modules into one

Single-module files (no imports) take a simpler path: comptime → typecheck → lower directly.

Comptime execution

After parsing but before type checking, the compiler evaluates @tape blocks and @tape fn calls. This stage can emit new AST nodes (@emit struct, @emit fn, etc.) that become part of the module. The type checker sees these generated declarations identically to hand-written ones.

TAC IR

The intermediate representation is linear three-address code:

plaintext
%1 = load_local "x"
%2 = const_i64 1
%3 = add_i64 %1, %2
store_local "x", %3

Explicit data flow, no implicit stack, easy to interpret and compile.

Native backends

Two native code generators read the TAC:

BackendTargetOutput formats
x64_codegenx86-64PE64 executable and DLL (Windows), PE32+ UEFI application (uefi), ELF64 executable, shared object and relocatable object (Linux), Mach-O executable, dylib and object (macOS), ELF64 and STO (Uusi)
aarch64_codegenARM64Mach-O executable, dylib and object (macOS), ELF64 executable, shared object and relocatable object (Linux)

The target is selected with --target (win64, linux, linux-arm64, macos, macos-arm64, uusi, uefi).

VM backend

tape run interprets the TAC directly via tape_vm_run(). The VM also powers tape test — each test runs as its own program via tape_vm_run_test(): globals reset to their initial values, the module initializers, the optional setup, the test, the optional teardown.

Before codegen, tape_link_check() verifies that all @link library references and their symbols can be resolved. This catches missing DLLs or undefined symbols at compile time rather than at runtime. It runs only when building for the host platform (a cross-compile cannot load the target’s libraries), and is skippable with --skip-link-check.

Why not stack-based bytecode?

Tape 1.0 used stack-machine bytecode. This caused:

  • Reverse-engineering value flow for native codegen
  • Phantom operand bugs
  • Undebuggable calling conventions

TAC eliminates all of these by making data flow explicit.

Last modified: