# Culebra Tooling The `culebra` binary is the whole toolchain: the same executable that runs a program also carries the test runner, the linter, the formatter, the debug adapter, the language server, and the reference documentation itself. This document is the reference for those development subcommands. | Subcommand | What it does | Reference | |---|---|---| | `culebra init` | set up this directory and machine's editors | [§4](#quick-setup-culebra-init) | | `culebra test [paths...]` | run tests and doctests | [§1](#1-testing-culebra-test) | | `culebra lint [paths...]` | report static problems without running the program | [§2](#2-linting-culebra-lint) | | `culebra fmt [paths...]` | reformat source to the canonical style | [§3](#3-formatting-culebra-fmt) | | `culebra dap` | speak the Debug Adapter Protocol over stdio | [§4](#4-debugging-culebra-dap) | | `culebra lsp` | serve diagnostics, hover and formatting to an editor over stdio | [§8](#8-editor-support-culebra-lsp) | | `culebra docs [topic]` | read and search the embedded reference docs | [§5](#5-reading-the-docs-culebra-docs) | | `culebra serve [-p PORT] [-d DIR]` | serve a directory of static files | [§6](#6-serving-static-files-culebra-serve) | | `culebra build -o ` | compile ahead-of-time into a standalone executable | [`deployment.md` §1](deployment.md#1-standalone-binary-build-culebra-build) | | `culebra wrap` | build an extended binary exposing your own C++ classes | [`deployment.md` §3](deployment.md#3-wrapping-c-libraries-culebra-wrap) | The plain `culebra [flags] script.cul` form — `--jit`, `-O0`..`-O3`, `--ast`, `--shell` and the rest — is specified in [§22 of the language spec](language.md#22-command-line-interface). For a narrative introduction to the same tools see [`handbook.md` §15](handbook.md#15-tooling-test-lint-fmt-debug). The browser Playground is the one tool that is not the binary. [§7](#7-linking-to-the-playground) specifies the URLs that open it on a given program, which is also the format the Share button writes. Contents -------- 1. [Testing (`culebra test`)](#1-testing-culebra-test) * [Writing tests](#writing-tests) * [Running](#running) * [Doctests](#doctests) 2. [Linting (`culebra lint`)](#2-linting-culebra-lint) 3. [Formatting (`culebra fmt`)](#3-formatting-culebra-fmt) 4. [Debugging (`culebra dap`)](#4-debugging-culebra-dap) * [Quick setup: `culebra init`](#quick-setup-culebra-init) * [VSCode](#vscode) * [Vim (vimspector)](#vim-vimspector) * [Zed](#zed) 5. [Reading the docs (`culebra docs`)](#5-reading-the-docs-culebra-docs) 6. [Serving static files (`culebra serve`)](#6-serving-static-files-culebra-serve) 7. [Linking to the Playground](#7-linking-to-the-playground) * [The project](#the-project) * [The text](#the-text) * [The page](#the-page) * [The Share button](#the-share-button) 8. [Editor support (`culebra lsp`)](#8-editor-support-culebra-lsp) * [Setting it up](#setting-it-up) --- ## 1. Testing (`culebra test`) `culebra test [paths...]` discovers and runs test files. `test()`, `@test`, `@parametrize`, the matcher family, and dependency-injected fixtures are all available. ### Writing tests Three forms are available — the call form and the `@test` decorator are equivalent; pick whichever reads better at the call site. `@parametrize` registers one test per case. ```culebra # doctest: skip # tests/test_string.cul # Call form test("interpolation embeds Long", fn () { let x = 42 assert_eq("hi {x}", "hi 42") }) # Decorator form — fn name becomes the test name @test fn interpolation_embeds_float() { let pi = 3.14 assert_eq("π = {pi}", "π = 3.14") } # Parametrize — one test per case, named `[i]` @parametrize([(1, 2, 3), (2, 3, 5), (10, 20, 30)]) fn adds_correctly(a, b, want) { assert_eq(a + b, want) } ``` **No `describe` nesting.** Group by file path (`tests/strings/`) and by `/` in the test name (`"Array/push: appends element"`). **Fixtures by DI.** A test's positional parameters are resolved by name against the surrounding env: any fn in env can be a fixture, no decorator required. Fixtures can themselves take fixtures. ```culebra # doctest: skip fn db() { {users: [], next_id: 1} } fn user(db) { db.users.push({id: 1, name: "alice"}) db.users[0] } @test fn user_has_name(user) { assert_eq(user.name, "alice") } ``` Within one test, each fixture is evaluated **once** — multiple mentions (direct + transitive) share the same instance. Across tests, fixtures are fresh. **Cleanup via class `drop`.** Resources needing teardown wrap themselves in a class with a `drop` method ([`language.md` §17](language.md#17-memory-model)). The runtime's ref-count finalization fires when the per-test fixture cache is released at test end. ```culebra # doctest: skip class TestDB { new() { self.conn = Database.connect("memory") } drop() { self.conn.close() } users() { self.conn.users } } fn db() { TestDB.new() } @test fn user_count(db) { db.users().create("alice") assert_eq(db.users().count(), 1) # db drops at test end -> conn.close() } ``` `defer` inside a fixture body would fire when the fixture fn returns (before the test runs), so class `drop` is the right tool for cleanup. Long-lived state shared across files (e.g. a model loaded once) goes at module top level instead, since the module system caches the binding. **Matchers.** Assertions use the matcher family — there is no `assert` keyword or builtin. Matchers are **3-backend globals** (bound on every environment, same as `inspect` / `Math`), so they work identically under `culebra script.cul`, `culebra --jit script.cul`, `culebra build`, and `culebra test`: ```culebra # doctest: skip assert_eq(arr.len(), 3) # == ; shows both sides on failure assert_throws("TypeError", fn () { let _ = 1 + 'b' }) assert_close(0.1 + 0.2, 0.3, 1e-9) # |a - b| <= tol ``` Full matcher list (`assert_true`/`false`/`ne`/`lt`/`le`/`gt`/`ge` and the `__eq__`/`__lt__` dispatch rule): [`stdlib.md` §13](stdlib.md#13-matchers). **Production invariants.** For `if (!cond) throw {...}` checks outside the test suite, write the `if`/`throw` directly (Go-style, see [`language.md` §15](language.md#15-error-handling)). There is no separate `assert` keyword to disable in production builds. ### Running When invoked through this subcommand, `test` / `@test` / `@parametrize` become **ambient globals** alongside the always-available matcher family — no `import` required. They are bound for the length of one run, on top of the stdlib names every lane already has. ```sh culebra test # discover & run from current dir culebra test tests/strings/ # run a subtree culebra test --filter "Array/push" # name-substring filter culebra test --reporter json # NDJSON output (one JSON per line) culebra test --bail # stop after the first failure culebra test --bail 3 # stop after 3 failures culebra test --list # discover only; print test names culebra test --doc docs # run the doctests in markdown files culebra test --doc --vm docs # ... on the bytecode VM (or --jit) culebra test --doc --jit --jobs 8 docs # ... in 8 processes ``` Discovery: any path that is a file is included as-is; any path that is a directory is walked recursively for files matching `test_*.cul`. Exit code is `0` when all tests pass, `1` when any fail. **Each file is its own program.** A file gets its own scope, and the runner runs that file's tests before it loads the next one. What a file writes at the top level is the file's — a `mut range = 5` leaves `range` a function everywhere else — and so are its classes, its overloads, its `Sys.script`, and the directory `Dir.embedded(...)` resolves against. A fixture declared in one file is not visible from another; pass it through a parameter, or declare it in each file that wants it. A file may `import`, and its modules run as one unit the way they do in a script. A file's top level runs in a *session* scope rather than a script's. Scoping reads the same either way — a bare write inside a function is that function's own local unless something already declared the name — with one consequence: the runner declares `test` / `parametrize` and the matcher family before the file, so those names count as declared. A bare write to one from inside a function reassigns it, and is refused, where in a script the same line would declare a local. **Reporters.** Default is human-readable. `--reporter json` emits one JSON object per line (NDJSON) — useful for agent loops and CI: ``` {"event":"test_pass","name":"adds_correctly","source":"tests/test_math.cul","stdout":""} {"event":"test_fail","name":"divides_correctly","kind":"AssertionError", "message":"assert_eq failed:\n left: 3\n right: 4","line":12,"col":3,"stdout":""} {"event":"run_end","passed":42,"failed":1,"files":7,"errored_files":0,"bailed":false} ``` `files` counts the test files that ran — the number `--list` would print paths for. A `--doc` run reports blocks, not files, and its `run_end` carries no `files`. User `inspect(...)` from inside a test is captured into the event's `stdout` field rather than interleaved with the NDJSON stream, and failure events carry a `snippet` with the failing line marked `>` for context. ### Doctests `culebra test --doc ` extracts every ` ```culebra ` block from the markdown under `` and runs it in a fresh VM Runtime, checking its output against the markers below. Every block in `handbook.md`, `language.md` and `stdlib.md` follows this convention: - `# => ` — expected stdout (one line) - `# => |` followed by `# ` lines — expected multi-line stdout - `# !! ` — expected `throw`, matched as substring - `# doctest: ` (block-leading line) — modes: - `skip` — illustration only, do not run (e.g. *Planned* features) - `compile-only` — syntax check only - `jit-only` / `aot-only` — backend filter Blocks are independent; there is no `setup` / `teardown` across blocks, so an example needing several steps has to be one block. The runner currently honors `skip`; `compile-only` and the backend filters are reserved (such blocks run normally for now). `--jit` runs the same blocks on the LLVM JIT instead of the VM, so a documented example is checked against every engine that will run it — the same output, the same throw. The unit-test runner (`culebra test` without `--doc`) runs on the VM. `--jobs ` runs the blocks in `n` copies of this process, each taking a contiguous slice of the block list; the parent replays their output in source order, so the report reads the same as a serial run. The JIT lane compiles one LLVM module per block and is the reason the flag exists. `--bail` and `--list` stay serial: "stop at the first failure" has no meaning across processes racing each other, and a listing runs nothing. Two consequences are worth knowing when writing examples. An expression on its own prints nothing, so a checked example has to go through `inspect(...)` or `println(...)`. And `//` opens a line comment in Culebra just as `#` does, so a `// => value` marker parses fine but is **not** recognized as an expectation — the block then runs unchecked. **Still planned**: an explicit `import { test } from "std/test"` for code that doesn't run under `culebra test`; and an AOT doctest lane beside `--jit` and `--vm`. --- ## 2. Linting (`culebra lint`) `culebra lint [paths...]` reports static problems **without running** the program, and exits non-zero so CI can gate on it (0 = clean, 1 = warnings only, 2 = errors). It reuses the same load-stage static analysis every backend already runs — so the errors it reports are exactly the ones that would abort a run — and adds advisory warnings on top. ```bash culebra lint app.cul # app.cul:12:7: warning: unused variable 'tmp' # app.cul:20:3: error: undefined variable 'reuslt' culebra lint . # recurse into every .cul under the current directory, # like `culebra fmt -i .` ``` Paths holding no `.cul` file exit 2 rather than reporting a clean run, so a mistyped directory can't pass the gate. A file named `test_*.cul` — the same convention `culebra test` discovers by — is linted as that runner would run it, so `test` and `parametrize` count as bound there. In any other file they are the undefined names they have always been, and `culebra lint .` gets both right in one pass. What it reports today: - **Errors** — the sound, certain-to-fail set: `break` / `continue` / `return` out of place, malformed parameter or assignment forms, duplicate parameters or class members, shadowing, and reads of a name bound nowhere (the undefined-variable subset). These already abort any run; `lint` just surfaces them all at once instead of stopping at the first. - **Warnings** — advisory findings that don't stop a run: - **Unused local variable** — a `let` / `mut` binding inside a function that is never read. - **Unused top-level binding** — a top-level `let` / `mut` that the module never reads and never re-exports. Function / class / enum / trait declarations are the module's export surface and are never flagged. - **Unused import** — an `import`ed name the module never uses. - **Unreachable code** — a statement that can never run because a `return` / `throw` / `break` / `continue` precedes it in the same block, and a `match` arm or `catch` clause that can never run because an unguarded one before it (`_`, or a bare binding) already accepts every value. - **Non-exhaustive enum match** — a `match` whose arms name some but not all of an `enum`'s variants, with no catch-all (`_`, a bare binding, or a type pattern naming the enum itself). `match` returns `nil` on no arm matching rather than raising, so a variant added later — or simply forgotten — falls through silently. Only checked when every arm's pattern unambiguously names one enum declared in the same file; a variant name two enums in the file both declare is skipped rather than guessed at. A guarded arm (`Circle(r) if r > 0 => …`) does not count as covering what it names, since the guard may reject at runtime. A leading underscore (`_x`, or the bare sink `_`) marks a binding as intentionally unused and is never flagged. **Parameters are not flagged**: an unused parameter is overwhelmingly intentional in Culebra — a multidispatch clause or method signature fixes the arity, and a higher-order callback (a route handler `fn(req)`, an `|i| 4.0`) ignores an argument it must still declare — so the check would be all noise. - **Idiom warnings** — rewrites with no exception, so no false positive: - **Redundant self-assignment** — `x = x + 1` says `x += 1` the long way (also `-=`, `*=`, `/=`). - **`.size()` compared to zero** — `xs.size() == 0` / `> 0` / `!= 0` asks what `.empty()` / `!xs.empty()` answers more directly. - **`range(0, n)`** — the explicit zero start is redundant; `range(n)` already means that. Two related rewrites — an `if`/`else` a ternary could express, and a manual index loop `enumerate()` could replace — stay prose in [`quick-guide.md` §3](quick-guide.md#3-what-does-not-carry-over) instead: both have real exceptions, so neither clears the zero-false- positive bar this check holds to. `culebra lint --fix ` mechanically removes unused-import lines — the only warning safe to autofix unattended, since deleting a dead `import` can never change behavior (unlike an unused `let`/`mut`, whose initializer may carry a side effect). Every other warning stays report-only. After editing, the fixed source is re-parsed and re-linted; the rewrite is written only if that re-check confirms the imports are gone and no new error appeared — the same re-parse safety net `culebra fmt` uses. The fix deletes whole lines, so it only touches a line holding one `import` and nothing else. An import `;`-joined to another statement is reported and left for you: deleting its line would take the neighbour with it, and the re-check cannot see that, since the file parses and lints just as cleanly with a statement missing. ```bash culebra lint --fix app.cul # app.cul: fixed 1 unused import culebra lint --fix joined.cul # joined.cul: --fix skipped 1 unused import sharing a line with other code # joined.cul:1:8: warning: unused import 'Math' ``` An unrecognized flag is an error (exit 2), not a path: `culebra lint --fixx app.cul` reports the typo instead of linting the file with the fix silently disabled. Editors show these diagnostics as you type through `culebra lsp` ([§8](#8-editor-support-culebra-lsp)). Planned: inline `# lint: ignore` suppression. --- ## 3. Formatting (`culebra fmt`) `culebra fmt [paths...]` reformats source to one canonical style: normalized operator spacing, two-space indentation, brace blocks laid out multi-line, and argument lists / collection literals wrapped when they exceed the line width. It is opinionated and zero-config (no style flags). ```bash culebra fmt app.cul # write the formatted source to stdout culebra fmt -i app.cul # rewrite the file in place culebra fmt -i . # format every .cul under the current directory culebra fmt --check app.cul # exit 1 if it isn't already formatted (CI gate) culebra fmt -l src/*.cul # list the files that would change cat app.cul | culebra fmt - # stdin -> stdout (editor format-on-save) ``` A directory argument is scanned recursively for `.cul` files, so `culebra fmt -i .` formats a whole project and `culebra fmt --check .` gates it in CI. Paths holding no `.cul` file are an error (exit 2), so a mistyped path fails loudly instead of silently formatting nothing. The output modes compose: `culebra fmt -i -l .` rewrites the files that need it *and* prints their names (like `gofmt -l -w`), and `-i --check` does the same silently. With `-l` or `--check`, the exit code is 1 whenever some file's formatting differed — so it still gates CI after a rewrite. The formatted source goes to stdout only when no other output mode was asked for. `-` reads stdin and stands alone: combining it with file paths (two inputs, one stdout) or with `-i` (no file to rewrite) exits 2 rather than letting one side quietly win. `culebra fmt -i` with no paths is the same case. An unrecognized flag is an error too, instead of being taken for a file name — `culebra fmt --wirte app.cul` exits 2 and formats nothing, so a typo can't look like a successful run. Comments are preserved: a leading comment stays above the statement it introduces, a trailing comment stays on the same line, and a single blank line between statements is kept (runs of blank lines collapse to one). match / cond arms, class / trait / enum members, destructuring patterns, and parameter lists are all normalized; long binary expressions and method chains wrap at the line width. An `if` / `while` / `match` head carries no brackets of its own, so it wraps only at the `||` / `&&` / `??` joints of its own chain — never between the two sides of a comparison — and indents its continuation past the body. An object literal that holds a comment is written one entry per line, with the comment kept where you put it; one without comments stays compact. An array literal holding nothing but numbers is a table. When it does not fit on its line it fills rows instead of taking a line per number: every row but a short last one holds the same count, and the numbers are right-aligned so the columns read down. The count is the one you wrote on the first line, as long as that many fit the line width — break the line where a row should end and `fmt` lays out the rest: ```culebra let colors = [ 255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0, 0, 255, 255, 255, 0, 255, 128, 128, 128, 64, 64, 64, 32, 32, 32, ] ``` A table written one number per line, or with a first line too long to keep, has no row to follow. It goes on a single row if that holds all of it, and otherwise takes the largest power of two the line width allows, so a row starts at a round index: ```culebra let lengths = [ 10, 254, 20, 2, 40, 4, 80, 6, 160, 8, 60, 10, 14, 12, 26, 14, 12, 16, 24, 18, 48, 20, 96, 22, 192, 24, 72, 26, 16, 28, 32, 30, ] ``` A table that fits on its line is written on one line, however it was broken. An array that holds anything else (a name, a string, a nested array) goes one element per line, and one that holds a comment keeps the rows it was written with. How it works: the source is parsed, re-printed from the syntax tree, and then **re-parsed and compared** against the original — if formatting would change the program's meaning, or would drop or duplicate a comment, `fmt` refuses and leaves the file untouched rather than risk corrupting it. Formatting is idempotent: running it twice yields the same result as running it once. ### Editor integration An editor formats either through the language server ([§8](#8-editor-support-culebra-lsp)) or through the stdin form (`culebra fmt -`). Both format the whole buffer and leave it untouched on a parse or safety error. - **VSCode** — the bundled extension formats through the language server, so **Format Document** and `editor.formatOnSave` work for `.cul` files once it's installed (`culebra init`, or `misc/vscode/install.sh` from a source checkout). - **Zed** — the extension's language server formats `.cul` files; no settings are needed. - **Vim/Neovim** — the bundled `ftplugin` provides a `:CulebraFmt` command (cursor preserved, untouched on error); set `let g:culebra_fmt_autosave = 1` for format-on-save. It deliberately skips `gq` / `'formatprg'`, which would replace an unparseable range with empty output. Neovim 0.10+ can also format through the language server with `:lua vim.lsp.buf.format()`. - Any other editor with a format-on-save hook can pipe the buffer through `culebra fmt -` the same way. --- ## 4. Debugging (`culebra dap`) Culebra ships a [Debug Adapter Protocol](https://microsoft.github.io/debug-adapter-protocol/) (DAP) server, so you can set breakpoints, step, and inspect variables visually in any DAP-capable editor — VSCode, Vim (vimspector), Zed, Emacs (dap-mode), Helix, and others. One adapter serves every editor. ``` culebra dap # speak DAP over stdin/stdout ``` You rarely run this by hand; your editor launches it (see the per-editor setup below). **Prerequisites** - **`culebra` must be runnable** — either on your `PATH` or referenced by an absolute path in the editor config below. (Quick check: `echo | culebra dap` should hang waiting for DAP input; `Ctrl+C` to quit. If it errors, fix the path first.) - **Debugging runs on the VM** — don't pass `--jit`. The adapter runs your program on the bytecode VM; the JIT/AOT backends compile to machine code and aren't source-debuggable. **How it works.** The adapter reads the VM's bytecode debug tables to pause on a breakpoint, a `debugger` statement, or a step, while the DAP loop answers the editor's requests and resumes execution. Your program's `stdout`/`stderr` is forwarded to the editor's debug console as `output` events, so it never collides with the protocol. Supported: - **Line breakpoints**, **conditional breakpoints** (break only when an expression is truthy, e.g. `i == 3`), and the `debugger` statement - **Stepping**: continue, step over (`next`), step in, step out; stop on entry - **Call stack**: a named multi-frame stack (`inner` ← `outer` ← `main`), each frame with its source location - **Variables**: in-scope locals of the selected frame — pick a frame in the call stack to inspect its own locals. A value is shown as `inspect` shows it, so a class's `__str__` runs; one that fails is shown as its failure (``) and the program is left where it is parked - **Watch / evaluate**: evaluate an expression in the selected frame (watch panel, hover, debug console) — e.g. `x + y`, `arr[0]`, a function call - **Set variable**: edit a `mut` variable's value in the selected frame (an immutable `let` binding is rejected); the change takes effect in the running program - **Program output** in the debug console Notes that apply to every editor: - The program to debug and its arguments come from the `launch` request's `program` field, not the `culebra dap` command line. - Breakpoints are matched by canonical (symlink-resolved) path, so a breakpoint set on a file under a symlinked directory still binds. - A `debugger` statement in source forces a stop regardless of breakpoints — handy for a one-off pause without configuring anything. ### Quick setup: `culebra init` Run `culebra init` in your project directory to install or update the editor integration (syntax highlighting, the `culebra lsp` language server and the `culebra dap` debug adapter) for whichever of VSCode, Vim, Neovim, or Zed it finds on this machine, plus AI coding agent instructions — no source checkout needed, since the payload travels inside the binary. Zed is the one exception to "no source checkout needed" for its syntax grammar: Zed can only fetch a Tree-sitter grammar from a git repository, so `init` points it at this binary's release tag on the public repo instead of a local checkout (see the Zed section below for what that means and its one manual step in Zed's UI). `init` prints what it's about to do and, at an interactive terminal, asks you to confirm before touching anything; a non-interactive run (piped, CI) or `--yes`/`-y` skips the prompt and applies immediately. It's safe to re-run any time; every run overwrites with whatever this binary carries, so re-running after an upgrade is the update path. Every integration highlights `.cul` the same way, from one generated word list: a capitalized name the language already provides — a stdlib namespace such as `Canvas`, or a type-annotation name such as `Long` — is colored as a built-in type, and any other capitalized name as one the program declares. A class, trait, or enum you write therefore reads as yours rather than as something the language knows. The Playground's editor makes the same split. The per-editor steps below build the same integration from a source checkout instead — for contributing to it, or if `init` can't reach your setup. ### VSCode VSCode needs a tiny extension to highlight `.cul` files and register the `culebra` debug type (debugging is pure registration — all the logic lives in `culebra dap`). Publishing isn't required: the repo ships a template under `misc/vscode/` with an installer. 1. Install the extension — `culebra init`, or from a source checkout: ```sh misc/vscode/install.sh ``` Both package the extension as a `.vsix` and install it with `code --install-extension` — the method VS Code supports. (Copying the folder into `~/.vscode/extensions` directly is *not* supported and is often not detected.) If `culebra` is on your `PATH`, its absolute path is baked into the debug adapter config. Both work with `code-insiders` / `cursor` / `codium` if that's your CLI; if none is on `PATH`, `install.sh` tells you how to install the `.vsix` from the Extensions view instead. Syntax highlighting then applies to any `.cul` file with no further setup; the steps below are only needed for debugging. The grammar's keyword list is generated from the parser by `just sync-grammar` (the same source as the Vim syntax file), so it never drifts from the language. 2. Fully quit VSCode (Cmd+Q) and reopen — a freshly installed extension may not be picked up by a window reload alone. 3. In your project, add `.vscode/launch.json`: ```jsonc { "version": "0.2.0", "configurations": [{ "type": "culebra", "request": "launch", "name": "Debug current file", "program": "${file}", "cwd": "${workspaceFolder}", "stopOnEntry": false }] } ``` 4. Open a `.cul` file, click in the gutter to set a breakpoint, and press F5. > **Iterating on the extension itself?** Instead of reinstalling the > `.vsix` on every change, open `misc/vscode/` in VSCode and press > F5 with an `extensionHost` launch config — that opens a > separate *Extension Development Host* window with the extension loaded > live, where you debug your `.cul` project. For just *using* the > extension, `install.sh` above is simpler. ### Vim (vimspector) No extension needed — install [vimspector](https://github.com/puremourning/vimspector), then add `.vimspector.json` to the project root (vimspector configs are per-project, so this isn't something `culebra` can ship for you). With the Vim syntax files installed (`misc/vim/install.sh`), open any `.cul` file in the project root and run `:CulebraVimspectorInit` to write it — it won't overwrite an existing `.vimspector.json`. Or create it by hand: ```json { "configurations": { "Debug file": { "adapter": { "command": ["culebra", "dap"] }, "configuration": { "request": "launch", "program": "${file}", "stopOnEntry": false } } } } ``` vimspector ships **no key mappings by default**, so add this to your `vimrc` once — otherwise ``/`` do nothing and it looks like setup failed: ```vim let g:vimspector_enable_mappings = 'HUMAN' ``` Then set a breakpoint with `` and start with `` (the `HUMAN` mappings; ``/``/`` step over/in/out, `` or `:VimspectorReset` to stop). No gadget install (`:VimspectorInstall`) is needed — the adapter is launched straight from the `command` above over stdio. Vim must be a `+python3` build. For syntax highlighting, run `culebra init`, or `misc/vim/install.sh` from a source checkout. ### Zed Zed needs an extension for both syntax highlighting (a Tree-sitter grammar) and debugging (a debug adapter must be *registered* by an extension — Zed can't point at an arbitrary DAP command from `debug.json` alone). Both ship as one dev extension. `culebra init` writes that extension the same way it lays down the VSCode/Vim payload — no source checkout needed for the adapter shim or language config, since they travel inside the binary. The one thing it can't avoid is how Zed fetches a Tree-sitter grammar: a dev extension can only name a git `repository` + `rev` + `path`, never bundle the parser source directly. So the extension `culebra init` writes points at this binary's `vX.Y.Z` release tag on the public repo (`github.com/yhirose/culebra`) instead of a local path — exact for a release download, since the binary and the tag always match. Editing the grammar itself needs a source checkout instead — see "Building from a source checkout" below. `init` writes the extension to `~/.local/share/culebra-zed-extension` and `.zed/debug.json` for the current project. Install it once in Zed: 1. Command palette → **`zed: install dev extension`** → select the directory `init` printed. Zed compiles the Rust adapter shim to `wasm32-wasip2`, so you need a recent Zed plus Rust with that target: `rustup target add wasm32-wasip2` (without it the build fails with "can't find crate for core" and the adapter never registers; highlighting still works). 2. Re-run `culebra init` and re-select the directory after upgrading — this re-pins the release tag. Then open a `.cul` file (it highlights), set a breakpoint, and run **"Debug current Culebra file"** from the debug panel. The generated `.zed/debug.json` is: ```jsonc [ { "label": "Debug current Culebra file", "adapter": "culebra", "request": "launch", "program": "$ZED_FILE", "cwd": "$ZED_WORKTREE_ROOT", "stopOnEntry": false } ] ``` > Zed's debugger and extension APIs are newer than VSCode's and still > evolving, so the exact keys / build steps may differ in your version. If > the dev extension fails to build or the adapter doesn't launch, check > the Zed version against the `zed_extension_api` version in > `misc/zed/Cargo.toml`. #### Building from a source checkout Editing the grammar or the adapter itself needs `misc/zed/install.sh` instead of `culebra init` — it points Zed at your local checkout's `HEAD` (via `file://`), so uncommitted or unpushed changes show up immediately, which the release-tag path above can't do: ```sh misc/zed/install.sh ``` This builds the same extension shape (grammar + adapter + `.zed/debug.json`) from `misc/zed/` directly. Re-run it — and re-select the directory in Zed — after pulling grammar/adapter changes; it re-pins the commit each time. --- ## 5. Reading the docs (`culebra docs`) Every document in this reference set is compiled into the binary, so `culebra docs` answers from the same build that runs the code — there is no checked-out tree to fall behind it and no network to reach. The release archive carries no `docs/` directory precisely because it does not need one. ``` culebra docs # list the topics, with a size estimate culebra docs stdlib # print one topic culebra docs stdlib Scene # print one chapter of it, whole culebra docs -g 'Math.wrap' # print the sections that match culebra docs stdlib -g 'wrap' # search one topic culebra docs --ja ... # the Japanese edition ``` Exit status is the grep convention: `0` printed something, `1` nothing matched, `2` bad usage. That makes an existence check a one-liner, with no output to read: ``` culebra docs -g '' >/dev/null || echo "no such API" ``` (A literal name in that position would be searching for itself — this page is part of the corpus.) ### Searching `-g` takes a regular expression, matched case-insensitively unless the pattern contains a capital. A pattern that does not compile — usually a signature fragment like `get_or_put(` — is searched for literally instead of rejected, with a note on stderr. The unit of output is a section: a heading and the text under it. Hits are ordered by where the match landed, because a heading match in `stdlib.md` is nearly always a signature, and that is what the reader was after: 1. the heading, 2. a code block, 3. body text. Past eight matching sections the output degrades to an index of headings, so a broad pattern costs a screen rather than the whole reference. The strongest few heading matches still print in full. `--full` overrides this, and `--at ` prints one section uncapped — the line number comes from the locator above each hit. Sections stay small by construction (21 lines at the median), so a hit is normally shown whole; anything past 60 lines is truncated with a pointer to `--at`. ### Naming a chapter A second argument names a chapter of the topic and prints it entire — its intro and every subsection, uncapped. That is what `-g` cannot give: a search splits at every heading, so the prose saying how a subsystem is driven and the signatures that drive it come back as separate hits, or as an index of both. ``` culebra docs stdlib Scene # the whole `Scene` chapter culebra docs stdlib Webview # either half of `Desktop` / `Webview` ``` The match is case-insensitive and ignores the chapter number, so `scene` and `Scene` both work. A name no chapter answers to exits 1 after listing the names that exist, which makes a half-remembered one self-correcting. With `-g` the chapter scopes the search instead of printing — the step after "restrict it to one topic" when a pattern is still too broad: ``` culebra docs stdlib Scene -g 'mesh' # only Scene's sections that mention it ``` ### Which topic to read `culebra docs quick-guide` is the condensed pack: the syntax, the habits from other languages that do not carry over, and the signatures a program reaches for unprompted, in a file that fits in a prompt. It is the one to read before writing culebra rather than to search. The stateful subsystems — `Scene`, `Canvas`, `Net`, `PEG`, `CodeGen` and the rest — it names rather than lists, because their signatures do not say how to drive them; print the chapter for one when the task calls for it. The rest of the set is larger than a prompt window, which is what `-g` is for. `culebra docs agent` is shorter still, and the only topic written to leave the program: rules to append to whatever file a coding agent already reads, so it looks signatures up here instead of guessing them. ``` culebra docs agent >> CLAUDE.md # Claude Code culebra docs agent >> .github/copilot-instructions.md # GitHub Copilot culebra docs agent >> AGENTS.md # Codex, Cursor ``` The destinations are listed on stderr, not stdout, so a redirect gets the markdown alone. Neither `agent` nor `llm` takes part in a corpus-wide search — both condense the other topics, and would report the same API twice — but naming one searches it: `culebra docs agent -g …`. `culebra init` does this append for you — it detects which of the three files above already exist (creating `AGENTS.md` if none do) and keeps the block up to date on every re-run. Reach for `culebra docs agent` directly only when you want the raw markdown or a destination this list doesn't cover. ## 6. Serving static files (`culebra serve`) `culebra serve [-p PORT] [-d DIR]` serves one directory over plain HTTP — a drop-in for `python3 -m http.server` when you just want a set of files (a `culebra build` output, a docs preview, a static site) reachable on `localhost`, with no OpenSSL, routes, or WebSocket in the picture. Port defaults to `8000`, directory to the current one. ``` culebra serve # serve . on :8000 culebra serve -p 3000 -d site # serve site/ on :3000 ``` For routes, WebSocket, or an API alongside static files, reach for the `Http.server()` stdlib namespace instead — see [`stdlib.md` §15](stdlib.md#15-http). --- ## 7. Linking to the Playground The Playground runs Culebra in the browser, and what it opens is entirely in the URL. A link has three parts, each independent of the others: the **project** the text runs beside, the **text** itself, and the **page** the recipient lands on. Any of them may be left out. ### The project | Parameter | Opens | |---|---| | `?example=` | a catalog entry, by its exact title in the Examples menu | | `?src=<https URL>` | a `.cul` file fetched from the web | | `?args=<command line>` | the arguments the program is given, overriding the project's own | `?example=` names one of the Playground's own examples, URL-encoded as the menu spells it. Its companion files come with it: an example that lives in its own directory brings the whole directory, so its imports and data files resolve the way they do in a checkout. `?src=` takes a file on GitHub in any of the three spellings a person copies: ``` https://github.com/<owner>/<repo>/blob/<ref>/<path>.cul https://raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>.cul https://cdn.jsdelivr.net/gh/<owner>/<repo>@<ref>/<path>.cul ``` The directory that file lives in comes with it, recursively, fetched through jsDelivr because GitHub's own hosts give a browser no directory listing. A directory of more than 500 files or 20 MB is refused with a message saying so, so a program at the root of a large repository has to move into a directory of its own first. An https URL anywhere else is fetched as a single file with nothing beside it. The entry's path is the program's `Sys.script`, and `import` resolves against it, so a linked program finds its files by the same expressions it would use in a checkout. Without a project the text runs alone as `main.cul`. ### The text `#code=<payload>` carries the source itself. The payload is the UTF-8 source as base64url (`-` and `_` in place of `+` and `/`, no padding), gzip-compressed when that comes out shorter. No parameter says which of the two it is: gzip's two magic bytes cannot begin a Culebra source, so the payload describes itself and the decoder reads them. It is bounded at both ends: a payload longer than a megabyte of characters is refused unread, and one that expands past four megabytes of source is abandoned while it inflates. gzip reaches about 1000:1, so the first bound alone would let a link ask for hundreds of megabytes. It rides the fragment rather than the query string because a fragment never reaches the server, and GitHub Pages' CDN rejects a request line somewhere past 8 KB, which a whole program encoded plain can exceed. `?code=` is the older spelling, still read after the fragment for links already in the wild. Where both a project and a text are present, the text replaces the project's own entry source and the project still supplies everything beside it. That is what an edited example shares as. A payload that cannot be decoded is reported to the browser console and the link falls back to the project's source. With neither, the Playground opens the project's entry source, else the draft it saved on the last visit, else the Hello example. ### The page Two parameters shape what the recipient sees. `?layout=` is how much of the page to build, and `?view=` is which pane it is built around. | Parameter | Shows | |---|---| | `?layout=` absent | the whole Playground | | `?layout=editor` | the editor, Run/Stop and the panes, without the site header, the example menus, the argument box or the New/Share buttons | | `?layout=view` | one pane, alone | | `?view=output` | the Output pane (the default) | | `?view=tui` | the TUI pane | | `?view=canvas` | the Canvas pane | | `?split=top` | the view under the source rather than beside it | They compose, the second naming what the first leaves. `?layout=view` with `?view=canvas` is a game with nothing around it; with `?view=tui` it is the same for a terminal program; `?layout=editor&split=top` is a source listing with its output beneath, inside a page that brings its own branding. `?embed=1` and `?embed=editor` are older spellings of `?layout=editor`, and `?embed=output` of `?layout=view`; all three are still read. `?split=` says which way the source and the output sit. Side by side is the default and wants width; `top` puts the output underneath, which is what a frame with the page's full width and a few hundred pixels of height wants. Below 720px the page stacks either way, there being no width to split. A program that draws moves to its own pane when it starts (a TUI one at the alternate-screen escape, a Canvas one at its first frame), so `?view=` decides what is on screen before that, and decides for good in a `?layout=view` page, where there is no tab strip to change it. `?run=1` starts the program once the engine is ready, rather than waiting for Run. It fires once. A link whose project or text failed to load does not autorun what it fell back to. A `?layout=view` page has no toolbar, so a play button sits over the pane whenever the program is not running: that is how a visitor starts it without `?run=1`, and how anyone replays it once it ends. There is no stop; a program that will not end by itself ends with the tab. Such a page opens on its program rather than on an empty pane. Under `?view=output` that is the source, read-only and highlighted, until the first run replaces it with the output. Under `?view=canvas` and `?view=tui` it is the program's first screen, which is reached by running it that far and stopping there: a Canvas program is held at its first `Canvas.present()`, and a TUI one holds itself at the `Term.read_key` its first screen ends in. The play button lets go of that hold rather than starting a second run, and being a click it is also what unlocks the sound (a browser keeps a page's audio locked until the page has been clicked, which is why an `?run=1` page is silent until its visitor touches it). The program does run to reach that screen, so a link whose program does something on its way there does it when the page opens, not when the visitor presses play. A browser without WebAssembly JSPI (Safari before 27) can neither hold a Canvas or TUI program on its first screen nor give it input, and a `?layout=view` page has no stop. There a `?view=canvas` or `?view=tui` page runs nothing, with or without `?run=1`: it shows the source, as `?view=output` does, under a note naming what the browser lacks, and no play button. ### The Share button Share opens a menu with those choices in it (`Layout`, `View`, `Split`, `On open`) and copies the link for what is chosen. Everything else in the link comes from the page: the project, the arguments when they differ from the project's own, and the text when it was edited, so an unedited example shares as its short name rather than as a fragment of several kilobytes. The defaults reproduce the plain standalone page, which is why an uncustomised link carries none of the four. Split is offered only where it can land: a layout built from the view alone has no source to sit beside, so the row goes quiet there and the link stops carrying it. Putting the Examples menus back on their placeholder says the text is no longer that example, and the project goes with the name: from then on the text runs alone as `main.cul`, with none of the example's companion files or arguments under it, and a link to it names nothing. The text itself stays, which is what separates this from the New button beside Share: that one clears the text as well. A link that names no project carries the text whether or not it was edited, since nothing else would arrive. --- ## 8. Editor support (`culebra lsp`) `culebra lsp` is a [Language Server Protocol](https://microsoft.github.io/language-server-protocol/) (LSP) server. An editor starts it and talks to it over stdin/stdout. It reads your source and never runs it. ``` culebra lsp # speak LSP over stdin/stdout ``` It provides: - **Diagnostics** — what `culebra lint` reports ([§2](#2-linting-culebra-lint)), shown as you type. A changed buffer is analysed again once typing pauses for a quarter of a second, and at once when it is saved. - **Formatting** — the whole document, as `culebra fmt` formats it ([§3](#3-formatting-culebra-fmt)). A buffer that does not parse is left alone. - **Hover** — for a name the document declares, its declaration with the type inference gives it (`parts: Array<String>`). Otherwise, the reference entry for a name the docs give a heading of its own: a stdlib function or constant (`Math.abs`, `Math.pi`) or a global function (`type_of`). A method called on a value (`xs.size()`) shows nothing, because which method it is depends on the value's type. - **Completion** — after `value.`, the members of the value's type as far as it can be established: a built-in type's methods, a class instance's methods and fields (`self` included), an object literal's keys, a namespace's members, and the methods of the class a stdlib function returns (`Regex.compile(p).`). The free functions the call could reach through UFCS follow. A bare name completes to what is visible at that point, then the globals. Types come from literals, return types, annotations and assignments; from a built-in method that takes a function, whose parameter receives the receiver's elements (`words.map(|w| w.`) and whose result follows from what it returns; from the values the calls in the document pass to an unannotated parameter, unless the function is exported or passed around as a value; and through an import, from the imported module's declarations. A value whose type is not known gets only the UFCS candidates. - **Go to definition, find references, highlight** — for a variable, parameter, function, class, enum, import or effect operation, following the scoping rules of [`language.md` §6](language.md#6-variables-and-scope): a block, a loop body and a match arm each have their own names, and a closure sees a variable of the function around it wherever that variable is declared. A keyword argument at a direct call (`f(count: 2)`) counts as a reference to the parameter. Going to the definition of `Alias.member`, where `Alias` is an import, opens the module at its top-level declaration of `member`. - **Outline** — the top-level declarations, with the members of each class, enum and trait. - **Rename** — every occurrence in the document. An object shorthand keeps its key: `{sum}` becomes `{sum: total}`. The server refuses a rename it cannot carry out in full: - the name is exported, so importing modules would not follow; - a method call of the same name exists (`value.name(...)`), which may reach the function through UFCS; - the new name is a keyword, or already names something visible where the renamed name is used; - the document does not parse as it stands. Positions follow the protocol's default: lines count from 0 and columns count UTF-16 code units. While a buffer does not parse, definitions, references, highlights and the outline come from the last version that did. ### Setting it up `culebra init` ([§4](#quick-setup-culebra-init)) wires the server into VSCode, Neovim and Zed along with the rest of the editor integration. - **VSCode** — the bundled extension starts `culebra lsp` for `.cul` files. **Format Document** and `editor.formatOnSave` go through it. - **Neovim 0.10+** — the bundled `ftplugin` starts it for each `.cul` buffer: `K` shows the hover and `:lua vim.lsp.buf.format()` formats. Set `let g:culebra_lsp = 0` to turn it off. - **Zed** — the extension registers the server, and Zed formats through it by default. - **Vim** — Vim has no built-in client. With [vim-lsp](https://github.com/prabirshrestha/vim-lsp), register the server in your `vimrc`: ```vim au User lsp_setup call lsp#register_server({ \ 'name': 'culebra', \ 'cmd': ['culebra', 'lsp'], \ 'allowlist': ['culebra'], \ }) ``` - **Any other LSP client** — run `culebra lsp` for the `culebra` language, files ending in `.cul`.