Skip to content
yhirosePublic

Repository files navigation

Culebra Programming Language

CI

Status: pre-1.0 and under active development — APIs and syntax may change.

A dynamically-typed cross-platform scripting language with three backends: a bytecode VM, an LLVM JIT, and an ahead-of-time build that emits a standalone binary. Write scripts, CLI tools, machine learning, desktop apps, and games.

The stdlib, test runner, linter, formatter, debugger and docs are all in that one executable. Nothing else to install! Or try it in the browser first on the Playground.

Quickstart

Paste this into a terminal — it downloads culebra, writes an example to hello.cul, and runs that same source file three ways: as a script, JIT-compiled, and built into a standalone binary.

curl -fsSL https://raw.githubusercontent.com/yhirose/culebra/master/install.sh | sh
culebra --version

cat > hello.cul <<'EOF'
let people = [
  { name: 'Taro', greeting: 'Konnichiwa' },
  { name: 'John', greeting: 'Hello' },
  { name: 'Ada',  greeting: 'Bonjour' },
]

for p in people.sorted_by(|p| p.name) {
  println("{p.greeting}, {p.name}!")
}
EOF

culebra hello.cul                            # the bytecode VM (default)
culebra --jit hello.cul                      # JIT
culebra build hello.cul -o hello && ./hello  # AOT: compile once, ship the binary
cat hello.cul | culebra -                    # stdin (curl ... | culebra - also works)

Optional — this one also touches your editor's config (VSCode/Vim/Neovim) and writes AGENTS.md/CLAUDE.md into the current directory, so it isn't bundled into the block above:

culebra init

culebra init installs syntax highlighting and the debug adapter for whichever of VSCode, Vim, or Neovim it finds on this machine, and adds coding-agent instructions to AGENTS.md (or CLAUDE.md/.github/copilot-instructions.md if one already exists) — reopen hello.cul afterward and the highlighting is already on, and Claude Code or another agent reading AGENTS.md already knows the conventions.

For Linux (x86-64), swap the first line for culebra-linux-x64.tar.gz; Windows and a permanent, system-wide install are below.

Platform Download
macOS (Apple Silicon) culebra-macos-arm64.tar.gz
Linux (x86-64) culebra-linux-x64.tar.gz
Windows (x86-64) culebra-windows-x64.zip

These always point at the newest release; culebra --version reports which one you have. Extracting from the command line also avoids the quarantine flag macOS puts on anything unpacked through Finder — the binaries are unsigned. To put culebra on PATH permanently instead of just for this shell:

sudo mv culebra-*/culebra /usr/local/bin/

Or skip the manual steps — install.sh downloads the right archive for your OS/arch, verifies its checksum, and installs it to /usr/local/bin (macOS Apple Silicon and Linux x86-64 only; use CULEBRA_INSTALL_DIR to change the destination):

curl -fsSL https://raw.githubusercontent.com/yhirose/culebra/master/install.sh | sh

macOS also installs through a package manager:

brew install yhirose/culebra/culebra

Windows and Linux have no package manager entry yet — use the download above.

Checksums and every release's notes are on the releases page.

Highlights

Cold start, single binary, cross-platform

  • CLI script. Cold start in tens of milliseconds.
  • Standalone binary. culebra build emits a single executable — nothing to install alongside it, no runtime dependencies.
  • Embedded library. Embeds the bytecode VM into a C++ host.
  • Cross-platform. macOS / Linux / Windows; cross-compile to any LLVM target from any host.

Friendly to agent loops

Fast cold start, single-file programs, and a stdlib that is already in scope add up to a runtime that doesn't punish an agent for invoking it every turn.

  • No import lines for the stdlib. JSON, Http, Regex, PEG, FS, Tensor, SQLite, Search, and the rest are bound before the program runs.
  • One file, one program. Run it without a project layout.
  • Ship the result. culebra build turns the script the agent just wrote into a binary it can hand back to the user.

Immutable by default, mutability opt-in

A bare x = 1 is an immutable binding — reassigning it is an error; write mut x = 1 for one you intend to change. No declaration keyword for the common case.

Batteries included, in one binary

The whole standard library is bound before the program runs — no package manager, no lockfile:

  • Data. JSON (with optional JSONC comments and trailing commas), CSV, TOML, XML, .env, UUID, and SQLite — the amalgamation is compiled in, so there is no system library to install.
  • Text. Grapheme-aware regex, encodings (base64 / hex / url / HTML entities), SHA / MD5 digests and HMAC, gzip / deflate.
  • System. Paths and whole-file I/O, streaming file handles, subprocesses, time, math, randomness, CLI argument parsing, and leveled structured logging.
  • Network. An HTTP/HTTPS client and server (routes, static files, Server-Sent Events, WebSocket), plus raw TCP / UDP sockets and name resolution underneath.
  • Concurrency. Isolates, channels, Parallel, shared buffers, and Ctrl+C delivered as a channel message.
  • Terminal. Term — color, cursor control, the alternate screen, and key/mouse input for TUIs, downsampled to whatever the terminal supports (and silent under NO_COLOR).
  • 3D. Scene — a retained-mode 3D renderer for procedural geometry with physically based lighting. Experimental: opt-in (-DCULEBRA_ENABLE_SCENE=ON) and not in the released binaries. It builds on all three platforms; the window is exercised on macOS and Linux.

Built-in Tensor

Tensor is a language primitive, not a library. Arithmetic is operators over a lazy graph that Tensor.eval materializes; matmul is .dot(). Ops run on the CPU (AVX2 / NEON kernels, Accelerate on macOS) or the GPU (Metal on macOS, CUDA where nvcc built it in), picked per op by size unless a Tensor.use_*() call pins one.

x = Tensor.from([[1.0, 2.0], [3.0, 4.0]])
y = x.dot(x.transpose())
Tensor.eval(y)  # [[5.0, 11.0], [11.0, 25.0]]

Embedded assets

Dir.embedded(name) hands a directory of files to your program with no code change across backends: the VM and JIT read it live from disk — edit a file, rerun — and culebra build bakes every byte into the executable, so the shipped binary needs nothing alongside it. It is one kind of Dir, read the same way as a directory on disk (Dir.disk) or files held in memory (Dir.memory).

let assets = Dir.embedded("dist")       # index.html, favicon.ico, ...
println(assets.exists("index.html"))   # => true
println(assets.exists("favicon.ico"))  # => true

Desktop app creation

A desktop GUI in web tech: a local HTTP server supplies the UI, the OS's own WebView engine displays it, and culebra build ships the whole thing — server, routes, and embedded assets — as one binary.

Desktop.run({
  title: "Hello from culebra",
  assets: Dir.embedded("dist"),  # index.html, favicon.ico, ...
  routes: fn (srv) {
    srv.get("/api/hello", fn (req) {
      "hi from the embedded server"
    })
  },
})

2D Canvas

An immediate-mode 2D framebuffer — draw, present, poll input, repeat — with sprites and text; sound is the Audio namespace (tones, samples, music, synthesised PCM), which needs no window. It opens a real window on macOS, Linux and Windows; a run that declares itself headless does the same pixel work and displays nothing, which is how the tests and a displayless server run it.

Canvas.run(160, 160, fn () {
  Canvas.clear(Canvas.rgba(20, 24, 40))
  Canvas.rect(20, 76, 8, 8, Canvas.rgba(220, 60, 60))
  false  # stop after one frame
})

Language features

  • Pattern matching. Literals, type guards, destructuring, and rest patterns, with optional if guards.
  • Sum types. enum Shape { Circle(Float), Rect(Float, Float) }, generic where you need it, matched by variant or by enum.
  • Tuples and sets. (3, 4) is immutable and hashable; Set(1, 2, 3) is an insertion-ordered set of unique values.
  • String interpolation. "head={head}, rest={tail.size()}", with format specs, triple-quoted blocks that dedent, and re"…" regex literals.
  • Gradual typing. Annotations runtime-checked at boundaries; Union, Optional, Tuple, Trait and Generic all check today.
  • UFCS. Free functions callable as methods (x.f(y) ≡ f(x, y)).
  • Multiple dispatch. Free functions dispatch on argument types across VM / JIT / AOT.
  • Traits. Built-in Iterable / Iterator; user-defined traits with default methods.
  • Closures and class form. Both supported, same encapsulation semantics.
  • Generators. A fn whose body contains yield returns an Iterator; yield from delegates to any iterable.
  • Algebraic effects. effect fn / perform / handle … with, with multi-shot resume — one mechanism covering generators, exceptions, dependency injection, and backtracking search.
  • Decorators. @expr before a fn or class wraps the declared value before it is bound.
  • Doctests in comments. 1 + 1 # => 2 is an executable assertion under culebra test.
  • Structural assert diagnostics. assert_eq prints a diff.
  • Defer / RAII. Scope-bound cleanup.
  • Threaded concurrency. No async/await. Isolate.spawn runs a closure on its own thread with its own heap, values cross by copy, and Channel carries them — so two isolates cannot race on one object. SharedBuffer (fixed-layout, zero-copy) and Shared (immutable, by reference) opt out of the copy where it matters.

The toolchain is the same binary

Every subcommand below is part of the executable that runs a program.

Command What it does
culebra test [paths...] run tests and the doctests written in comments
culebra lint [paths...] report static problems without running the program
culebra fmt [paths...] reformat source to the canonical style
culebra dap speak the Debug Adapter Protocol over stdio (VSCode / Vim / Zed)
culebra lsp serve diagnostics, hover and formatting to an editor over stdio
culebra docs [topic] read and search the embedded reference docs
culebra init set up this directory's AI agent instructions and this machine's editors
culebra build <in.cul> -o <out> compile ahead-of-time into a standalone executable
culebra wrap build an extended binary exposing your own C++ classes

Details are in docs/tooling.md.

Standalone binaries

culebra build compiles a .cul source ahead-of-time into a self-contained executable. No LLVM at runtime; tree-shaking drops the ~500 runtime helpers a program doesn't reference. Tensor-free programs also drop the Accelerate / Metal framework dependency.

culebra build path/to/script.cul -o ./out
./out

The link step runs the host's C++ compiler on macOS (Xcode's Command Line Tools) and Linux (cc); on Windows the binary carries its own linker, and culebra toolchain install fetches the mingw libraries it links against. Everything else is inside the binary. Cross-compile is also supported. Host requirements and binary sizes per feature axis are in docs/deployment.md.

Embedding in a C++ host

The bytecode VM embeds into a C++23 host: header-only, no LLVM, no culebra library to build or install. C++ and the script call each other, so a script can be what the host is configured — or extended — with.

#include <culebra.h>

int main() {
  culebra::vm::Embed embed;   // stdlib installed, traits registered

  std::vector<double> readings{18.5, 41.2, 79.8};   // the host's own data
  size_t call_count = 0;

  // The argument and return types come from the signature; the host state
  // the body reads and writes is an ordinary capture.
  embed.define("temperature", [&](int64_t sensor) {
    call_count++;
    return readings.at(sensor);
  }, {"sensor"});

  embed.eval(R"(
    config = {mut limit: 40.0}
    fn too_hot(sensor) { temperature(sensor) > config.limit }
    too_hot(0)
  )");

  // The session outlives the run, so the host reads what the script left,
  // calls back into it, and writes into its values — an Object is a
  // reference, not a copy.
  auto config = embed.global("config");
  auto limit = config["limit"].as<double>();        // 40.0
  auto hot = embed.call("too_hot", 1).as<bool>();   // true: 41.2 > 40.0
  config.set("limit", 50.0);
  hot = embed.call("too_hot", 1).as<bool>();        // false, same script

  // call_count is 3 by now: the script's own call, and the two above.
}

Build it with the flags the engine itself reports:

c++ $(culebra embed-flags --cflags) host.cpp $(culebra embed-flags --libs) -o host

A failure in script code arrives as culebra::CulebraError, with the kind, message and position the script's own catch sees.

docs/deployment.md has the rest: the types define converts, the rest of the Value surface over a script's Objects and Arrays, C++ classes through culebra wrap, the JIT path, threading, and interrupts.

Design choices

  • Two backends, one compiler. The bytecode VM and the JIT share one parser, AST, and bytecode compiler; both maintained, no plan to consolidate.
  • Predictable threaded concurrency. No async/await. Stack traces stay readable, the debugger sees every frame, and library authors don't write a colored version of every function. Targeted at services up to a few-thousand-connection ceiling — the shape SQLite, Redis, and most line-of-business backends already use. The reasoning is written out in docs/essays/concurrency.md.
  • Deterministic destruction, and no weak. An object with a drop property has it called when the last reference goes away — including for a reference cycle a scope leaves behind, so a back-reference to a parent needs no weak annotation. Reference counting drives the timing; a tracing collector backs it up for cycles. The reasoning is written out in docs/essays/memory.md.
  • Gradual typing without a compile step. Annotations are runtime-checked at boundaries; startup stays instant. Union, Optional, Tuple, Trait and Generic annotations are all in.
  • Immutable by default, no declaration keyword. A bare x = 1 creates an immutable binding — reassigning it is an error. Use mut for a variable you intend to change (mut x = 1; x = 2), so mut marks exactly what changes; let is an optional, explicit marker for the immutable form. The default binding is both safe and ceremony-free, and mutation is visible where it happens.
  • UFCS, not pipeline. x.f(...) doubles as the resolution path for free functions over user types.
  • The stdlib needs no import. Namespaces are bound before the program runs, so a one-file script has no import block. Splitting across files uses top-level import / export, which keeps the dependency graph known at parse time — what AOT bundling and tree-shaking need.
  • Batteries included. Everyday scripting needs ship in the binary, not as third-party packages.
  • Pre-1.0. Releases follow semver's 0.x reading: a minor bump may break things. No package registry yet — download a binary or build it.

Performance

Culebra targets three forms of performance: fast startup, small binaries, and BLAS-class numerics.

  • Tensor lands in the BLAS-bound cluster with numpy / Julia / PyTorch CPU on the MNIST MLP. See benchmarks/mnist/ and benchmarks/microgpt/.
  • The JIT is fastest on compute-bound loops.
  • The bytecode VM is the tier-0 default: no JIT warmup, no LLVM dependency.

Documentation

Building from source and running the tests: CONTRIBUTING.md.

License

MIT

Releases

Contributors

Languages