@gcu/atra
v0.1.0
Published
Fortran/Pascal-style language that compiles to WebAssembly. Supports SIMD, multi-memory, structured control flow. Library API + atrac CLI.
Maintainers
Readme
atra
Arithmetic TRAnspiler — wat, but for humans.
C was famously described as "portable assembly" — a thin layer over the PDP-11's instruction set that happened to compile everywhere. That was 1972. The machine had 56KB of memory, 16-bit words, and a register file you could count on one hand. Dennis Ritchie didn't design a language; he gave the hardware a syntax.
Fifty years later, there's a new virtual machine: WebAssembly. Stack-based, 4 numeric types (i32, i64, f32, f64), linear memory, no strings, no IO, no garbage collector. It runs inside every browser on earth. And its "assembly language" — WAT — looks like this:
(func $add (param $a f64) (param $b f64) (result f64)
local.get $a
local.get $b
f64.add)So: what would "C for WebAssembly" look like? Not C compiled to Wasm (that's Emscripten), but a language designed for Wasm the way C was designed for the PDP-11 — one that maps cleanly onto the virtual machine's actual semantics, doesn't try to be more than it is, and compiles in microseconds?
That exploration led here. The result turned out closer to Fortran than to C. Wasm's type system (four numbers, nothing else) and execution model (structured control flow, no goto) are a better fit for formula translation than for systems programming. The syntax ended up a Fortran/Pascal hybrid: begin...end blocks, := assignment, function/subroutine distinction, return by assigning to the function name, ! comments, ** exponentiation. The lineage is literal:
- FORTRAN = FORmula TRANslator (1957, IBM 704 machine code)
- ATRA = Arithmetic TRAnspiler (2025, WebAssembly bytecode)
Same idea, different virtual machine, ~70 years apart.
import { atra } from './atra.js';
const { spherical } = atra`
function spherical(h, range, sill, nugget: f64): f64
begin
if (h == 0.0) then
spherical := 0.0
else if (h >= range) then
spherical := nugget + sill
else
spherical := nugget + sill * (1.5 * h / range - 0.5 * (h / range)**3)
end if
end
`;
spherical(25.0, 80.0, 1.0, 0.1)The tagged template compiles source to Wasm bytecode, instantiates the module, and returns exported functions. No toolchain, no build step, no external compiler, no dependencies. One JS file that turns formulas into native-speed bytecode at runtime.
The language
Four numeric types matching Wasm's value types: i32, i64, f32, f64. No strings, no booleans (use i32, 0 = false), no pointers, no heap. Structs are available via layout declarations (see below) — they describe memory layouts but don't introduce new types.
Functions and subroutines
Functions return a value. The return mechanism is the Fortran convention — assign to the function's own name:
function distance(x1, y1, x2, y2: f64): f64
var
dx, dy: f64;
begin
dx := x2 - x1
dy := y2 - y1
distance := sqrt(dx**2 + dy**2)
endSubroutines have no return value. Use them for operations that write results into memory:
subroutine normalize(arr: array f64; n: i32)
var
i: i32;
sum: f64;
begin
sum := 0.0
for i := 0, n
sum := sum + arr[i]
end for
for i := 0, n
arr[i] := arr[i] / sum
end for
endControl flow
All structured — no goto, because Wasm doesn't have goto.
! if/else
if (x > 0.0) then
result := x
else
result := 0.0 - x
end if
! if-expression (ternary) — compiles to Wasm's if (result T)
sign := if (x < 0.0) then -1 else 1
! for loop (0-based, exclusive upper bound, always)
for i := 0, n
arr[i] := arr[i] * scale
end for
! for with step (countdown)
for i := n - 1, -1, -1
...
end for
! while
while (error > tolerance)
...
end while
! do...while (body executes at least once)
do
count += 1
while (count < limit)
! early return (guard clause)
if (b == 0.0) then
call return(0.0)
end if
! tail call (constant stack space recursion)
tailcall factorial(n - 1, acc * n)Builtins
Common math functions work without any import declaration — the compiler auto-imports them from Math.*: sin, cos, sqrt, ln, exp, abs, floor, ceil, pow, min, max, atan2.
Native Wasm operations (single instruction, zero call overhead): trunc, nearest, copysign, select, clz, ctz, popcnt, rotl, rotr, memory_size, memory_grow, memory_copy, memory_fill.
Type conversions use type names as functions: f64(i), i32(x). No implicit coercion — atra is explicit about everything.
Dotted names
Identifiers can contain dots. The compiler treats physics.gravity as a single flat name — no namespace machinery, just a naming convention that the JS side respects by nesting into objects:
function model.spherical(h, a, c1, c0: f64): f64
begin
...
end
function model.gaussian(h, a, c1, c0: f64): f64
begin
...
endwasm.model.spherical(0.5, 1.0, 1.0, 0.1) // nested access
wasm['model.spherical'](...) // flat access also worksFunction references
Functions can be passed by reference via call_indirect. A bare function name (without parens) evaluates to its table index:
function double(x: f64): f64
begin
double := x * 2.0
end
function triple(x: f64): f64
begin
triple := x * 3.0
end
function apply(f: function(x: f64): f64, x: f64): f64
begin
apply := f(x)
endconst wasm = atra`...`;
wasm.apply(wasm.__table.double, 5.0) // 10
wasm.apply(wasm.__table.triple, 5.0) // 15When any function is used as a reference, the exports include a __table mapping function names to their table indices.
Layouts
Layouts describe struct-like memory regions — named fields at known offsets, with automatic size and alignment computation. Variables typed as layout are i32 pointers into linear memory.
layout Vec3
x, y, z: f64
end layout
layout Sphere
center: Vec3
radius: f64
end layout
function getRadius(s: layout Sphere): f64
begin
getRadius := s.radius
endField access (s.radius, s.center.x) compiles to i32.load/f64.load at the computed offset. Fields follow C-style alignment: f64 fields align to 8 bytes, i32 to 4 bytes.
Packed layouts skip alignment padding:
layout packed Rec
id: i32
value: f64
end layoutHere value is at offset 4 (not 8), and __size is 12 (not 16).
Array fields embed fixed-count arrays of primitives or layouts:
layout Particle
pos: f64[3] ! 3 consecutive f64s (24 bytes)
mass: f64
end layout
layout Mesh
vertices: Vec3[4] ! 4 embedded Vec3s (96 bytes)
count: i32
end layoutAccess array elements with p.pos[i]. For layout arrays, mesh.vertices[i] returns a pointer — assign it to a layout-typed local to access fields:
var v: layout Vec3
v := mesh.vertices[i]
x := v.xLayout constants are available at compile time: Vec3.__size (24), Vec3.__align (8), Vec3.y (offset 8). These emit i32.const — zero runtime cost.
__layouts metadata is attached to the JS exports object. For scalar fields, the value is the byte offset; for array fields, it's { offset, count, elemSize }:
const m = atra({ memory })`...`;
m.__layouts.Vec3 // { x: 0, y: 8, z: 16, __size: 24, __align: 8 }
m.__layouts.Particle // { pos: { offset: 0, count: 3, elemSize: 8 }, mass: 24, ... }SIMD
Vector types f64x2, f32x4, i32x4, i16x8, i8x16 with lane operations. Arithmetic operators (+, -, *, /) are overloaded for vector types:
function test(a, b: f64): f64
var
va, vb, vc: f64x2
begin
va := f64x2.splat(a)
vb := f64x2.splat(b)
vc := va + vb
test := f64x2.extract_lane(vc, 0)
endAPI
Six entry points, from high-level to low-level.
atra`...` — tagged template
Full pipeline: parse, compile, instantiate, nest dotted exports, attach __table. This is the primary interface.
const { add, mul } = atra`
function add(a, b: f64): f64
begin
add := a + b
end
function mul(a, b: f64): f64
begin
mul := a * b
end
`;Template interpolation inlines JS values as constants:
const SIZE = 256;
const { getSize } = atra`
function getSize(): f64
begin
getSize := ${SIZE}.0
end
`;atra({ imports })`...` — curried form
Pass JS functions (or other atra modules) as WebAssembly imports. Atra infers an all-f64 signature from .length:
const { compute } = atra({ lerp: (a, b, t) => a + (b - a) * t })`
function compute(a, b, t: f64): f64
begin
compute := lerp(a, b, t)
end
`;Nested objects are flattened to dotted names — { math: { lerp: fn } } becomes math.lerp in atra source.
atra.run(source, imports?) — string API
Same full pipeline as the tagged template, but takes a plain string. Use when source comes from a file, is generated dynamically, or doesn't need template interpolation:
const src = await fetch('kernels.atra').then(r => r.text());
const { spherical, gaussian } = atra.run(src);With imports:
const { compute } = atra.run(`
function compute(x: f64): f64
begin
compute := scale(x)
end
`, { scale: (x) => x * 2 });atra.compile(source) → Uint8Array
Raw WebAssembly bytes. No instantiation, no export nesting, no __table. Use when you need the binary for manual instantiation, caching, or sending to a worker.
atra.parse(source) → AST
Returns the abstract syntax tree. Useful for tooling, analysis, or custom code generation.
atra.dump(source) → hex string
WebAssembly bytes as a space-separated hex string. For when you want to stare at the bytecode.
Composability
atra output is a plain object of exported functions. Spread it as imports to another atra compilation:
const lib = atra`
function linalg.dot(a, b: f64): f64
begin
linalg.dot := a * b
end
function linalg.scale(x, s: f64): f64
begin
linalg.scale := x * s
end
`;
const app = atra({ ...lib })`
function compute(a, b, s: f64): f64
begin
compute := linalg.scale(linalg.dot(a, b), s)
end
`;
app.compute(3.0, 4.0, 2.0) // → 24.0This works because the convention is symmetric: atra outputs nested objects (lib.linalg.dot) and accepts nested objects as imports ({ linalg: { dot: fn } }). Flat dotted keys ({ 'linalg.dot': fn }) also work. Same with atra.run:
const lib = atra.run(libSource);
const app = atra.run(appSource, { ...lib });Memory
Single memory
Pass a WebAssembly.Memory to enable array parameters. Arrays are typed views into linear memory — array f64 is a base pointer into a Float64Array:
const memory = new WebAssembly.Memory({ initial: 1 }); // 64KB
const { dotProduct } = atra({ memory })`
function dotProduct(a, b: array f64; n: i32): f64
var
i: i32;
sum: f64;
begin
sum := 0.0
for i := 0, n
sum := sum + a[i] * b[i]
end for
dotProduct := sum
end
`;
const f64 = new Float64Array(memory.buffer);
f64.set([1, 2, 3], 0); // a at byte offset 0
f64.set([4, 5, 6], 100); // b at byte offset 800
dotProduct(0, 800, 3) // → 32 (1*4 + 2*5 + 3*6)Array parameters are i32 byte offsets at the Wasm level. 2D indexing uses row-major layout: arr[i, cols, j] computes arr[i * cols + j].
Multi-memory
Multiple independent memory banks (Chrome 120+, Firefox 125+, Edge 120+). Each bank has its own 4 GB address space. Declare named banks with memory, then qualify array parameters with the bank name:
const spatial = new WebAssembly.Memory({ initial: 4 });
const grades = new WebAssembly.Memory({ initial: 4 });
const wasm = atra({ memory: { spatial, grades } })`
memory spatial
memory grades
subroutine set_grade(g: grades array f64; i: i32; v: f64)
begin
g[i] := v
end
function get_grade(g: grades array f64; i: i32): f64
begin
get_grade := g[i]
end
`;Locally declared memories (with page counts) are created by the module itself:
const wasm = atra`
memory scratch: 100
subroutine write(arr: scratch array f64; i: i32; v: f64)
begin
arr[i] := v
end
`;Feature detection: atra.hasMultiMemory is true if the runtime supports multi-memory. If >1 bank is declared but the runtime doesn't support it, compilation throws an error.
Ahead-of-time compilation (atrac)
The runtime API (atra, atra.run, atra.compile) compiles source to Wasm at runtime in the browser. atrac is the ahead-of-time counterpart — a Node.js CLI and library that produces standalone artifacts with no runtime dependency on the atra compiler.
CLI
atrac lib.atra # → lib.js (JS module with embedded Wasm)
atrac lib.atra -o lib.wasm # → raw Wasm binary
atrac --src lib.atra # → lib.src.js (source distribution)
atrac a.atra b.atra # → concatenate, compile as one unit
atrac -o out.js a.atra b.atra # → explicit output nameInstall globally via npm link from the repo root (the bin entry in package.json registers it), or run directly with node ext/atra/atrac.js.
JS bundle output
atrac lib.atra produces a standalone ES module with Wasm bytes inlined as a Uint8Array. Exports include instantiate() and memory helpers:
import { instantiate } from './lib.js';
const memory = new WebAssembly.Memory({ initial: 4 });
const lib = instantiate({ memory });
lib.alpack.dgesv(aPtr, bPtr, n, nrhs, ipivPtr, infoPtr);The bundle handles Math.* imports, memory linking, and dotted-name nesting automatically. No atra compiler needed at runtime.
Memory helpers
Every bundle also exports general-purpose Wasm memory utilities:
import { instantiate, alloc, writeF64, writeI32, readF64, readI32, growMemory } from './lib.js';
const mem = new WebAssembly.Memory({ initial: 4 });
const lib = instantiate({ memory: mem });
const st = { off: 0 }; // allocator state — caller manages offset
const pX = alloc(st, 10); // reserve 10 f64s (80 bytes), 8-byte aligned
const pN = alloc(st, 0, 5); // reserve 5 i32s (20 bytes), 8-byte aligned
growMemory(mem, st.off); // grow memory if allocator exceeds current size
writeF64(mem, pX, [1.0, 2.0, 3.0]);
writeI32(mem, pN, [10, 20]);
const vals = readF64(mem, pX, 3); // Float64Array copy (safe across grow)
const ints = readI32(mem, pN, 2); // Int32Array copyalloc(state, nf64, ni32)— bump allocator; takes{ off }state object, returns byte offsetreadF64/readI32— return copies (.slice), safe acrossgrowoperationsgrowMemory(mem, off)— grows memory only ifoffexceeds current buffer size
Future: a higher-level WasmMem manager class that wraps state + memory into a single object.
Source distribution
atrac --src lib.atra produces a JS module exporting routine strings for ${} interpolation:
import { sources, deps, all } from './lib.src.js';
import { alpack_dgetrf } from './lib.src.js'; // individual routines
// Use with std.include() for dependency resolution
const src = std.include({ sources, deps }, 'alpack.dgesv');
const { alpack } = atra({ memory })`${src}`;Library API
atrac.js is also importable:
import { compile, bundle, buildSrc, formatSrcJs } from './ext/atra/atrac.js';
compile(source) // → Uint8Array (raw Wasm bytes)
bundle(source, { name }) // → string (standalone JS module, with __layouts if applicable)
buildSrc(source) // → { sources, deps, all, layouts? }
formatSrcJs(lib) // → string (.src.js file content, with layouts export if applicable)buildSrc() extracts routines and layout blocks, scans call graphs for dependencies, and returns the structured library object. If the source contains layouts, the result includes a layouts property mapping layout names to their source text. formatSrcJs() serializes routines, dependencies, and layouts as a JS module. bundle() embeds layout metadata (from atra.parse()) alongside the Wasm bytes — the instantiate() export attaches __layouts to the returned exports object. These are the same functions used by ext/atra/build.js to generate alpack.src.js and alpack.js.
Language reference
See SPEC.md for the full language specification — types, operators, control flow, arrays, SIMD, function references, the wasm.* escape hatch, and everything else.
