Lynxer

Documentation

Standard library module contracts

Frozen at Lynxer 0.1.8.1 — 2026-09-25.

Every stdlib/<name>.lynx wrapper and its stdlib/<name>.so backend are two halves of one contract. This page states that contract once, so a second backend for an existing module — or a new module — can be written against it without re-deriving the conventions from the code.

The conventions below apply to all bundled modules. The per-module table records only the dimensions that actually vary between them. For an individual operation's argument list and return value, the authoritative sources are the wrapper itself (stdlib/<name>.lynx), docs/stdlib/<name>.md, and --list-stdlibs, which prints each wrapper's docstring.

The module pair

A module is always two files with the same stem:

FileRole
stdlib/<name>.lynxThe Lynxer-facing module. Defines setup() and one global function per operation, and forwards each one to the namespace its setup() imported.
stdlib/<name>.soThe native backend. Built from stdlib/<name>.cpp (C++) or rust/<name>/ (Rust), and registers its operations through lynxer_module_init_v1.

The wrapper is the contract's public surface; the backend is an implementation detail behind it. A wrapper must never expose a crate- or library-specific type. Everything crossing the boundary is a number, a string, or an integer handle — see native-module-abi for why.

Conventions that apply to every module

Operation names

Argument order and types

Handles

Strings

Error sentinels

There is no error channel across the ABI, so failures are in-band. Each module picks one family and applies it consistently:

FamilyShapeModules
Scalar sentinel-1 for indices and sizes, 0 / 0.0 for numbers, false for predicates, "" for stringscli, crypto, debug, encoding, fileIO, game, image, ini, json, math, multiprocessing, os, path, random, re, regex, sound, sys, time, toml, uuid, watch, xml, yaml
Process exit codethe command's exit status; a negative value when it did not runshell
Status string"ok" on success; "ERROR: <message>" on failurecompress, csv, network, server
Error string"Error: <message>"js, lua, tui
Catchable runtime errorRaises a SourceError caught by try/catchsqldb string-returning operations

csv mixes the first two: its table-returning operations answer with the status string ("ok" / "ERROR: ..."), and the rest use scalar sentinels. compress mixes them too: its in-memory stream operations use scalar sentinels (""), while its file and archive operations answer with the status string. sqldb translates its native backend's internal error string into a catchable runtime error in the Lynxer wrapper; integer and boolean operations keep their documented sentinels. colorlib and typing are pure Lynxer and have no native boundary to report across. raylib is the exception to the sentinel families: its struct handles are raw boxed pointers with no registry and no validation, so an invalid, stale or double-freed handle is undefined behaviour rather than a result. A raylib program owns each handle and must free it exactly once (see limitations).

Operations returning a JSON document return [] or {} for an empty result and an error string for a failure — never an exception. jsonParse on malformed input yields the empty string (""), not []; test the result for "".

A failure sentinel is a result, not a diagnostic: a program checks it and decides what to do. No bundled module aborts the process, and none panics across the C boundary (Rust backends wrap every operation in a panic guard that yields the module's sentinel).

Callbacks

One module currently registers a callback into Lynxer: game, through the optional lynxer_module_attach_v1 host API. The contract for it is:

Any future callback must be declared here before the module ships.

Interruption

Cleanup

What each module commits to

ModuleBackendIdentity modelCleanup owner
cliC++nonenone
colorlibpure Lynxernonenone
compressRustnone — paths are strings; archives are named by pathnone
cryptoRustnone — digests and signatures are strings; keys are base64none
csvC++none — paths are stringsnone
debugC++ + purenonenone
encodingRustnone — payloads are stringsnone
fileIOC++none — paths are stringsnone
gameRustinteger indices: cached textures and spritesnone (caches live for the run; close() ends the loop)
graphicsRustinteger handles into backend registries (textures, images, fonts, materials, render targets) plus UI widget idsnone (registries live for the run; run() ends the loop)
imageRustinteger handles into a backend registrycaller: close(handle)
iniRustnone — documents are stringsnone
jsC++nonenone
jsonRustnone — documents are stringsnone
luaRustnonenone
mathC++nonenone
multiprocessingC++integer handles for result setswrapper releases automatically
networkRustWebSocket connections keyed by name; HTTP is statelesscaller: wsClose(name)
osC++nonenone
pathC++none — paths are stringsnone
randomC++none — seeded generator lives in the backendnone
raylibRustraw pointer handles for non-scalar structs (no registry); scalar-only structs are flattened by valuecaller: <struct>Free(handle) (exactly once)
reRustnonenone
regexRustnone — the named-pattern cache lives in the backendnone
serverRustroutes keyed by path, in registration ordercaller: clearRoutes() / stop()
shellC++nonenone
soundRustinteger handles into a backend registrycaller: releaseSound(handle)
sqldbRustinteger handles from open(); the *On forms reuse one connectioncaller: close(handle) (the path forms open and close per call)
sysC++nonenone
textRust + Lynxerstring/integerempty string / false
timeC++nonenone
tomlRustnone — documents are stringsnone
tuiRustinteger handles into backend registries (tables, trees, layouts, progress, status, live)none (registries live for the run)
turtlepure Lynxerthe graphics module holds the state; this drives its implicit default turtle (handle 0)none
typingpure Lynxernonenone
uuidRustnone — UUIDs are stringsnone
watchRustinteger handles into a backend registrycaller: watchClose(handle)
xmlRustnone — documents are a JSON element treenone
yamlRustnone — documents are stringsnone

Per-module constraints are recorded in limitations, not here: this page fixes what Lynxer's own contract is, and that page records where it deliberately differs.

Changing a contract

The contract is frozen as of the version above. Changing any part of it — an operation name, an argument's type or order, which argument is the handle, a sentinel value, or cleanup ownership — is a breaking change and needs:

  1. the wrapper and every backend for that module updated together;
  2. the module's docs/stdlib/<name>.md and this page updated;
  3. its fixture and .expected updated, and the change noted in limitations if it diverges further from the reference.

An operation may be added without unfreezing anything, provided it uses the existing conventions and does not need a new ABI shape.

How it is enforced

MechanismWhat it checks
scripts/check_module_contracts.py, run by make testEvery global.native<Alias>.<op>(...) call in a wrapper names a registered op; a Rust backend's packed reads are in range for the arguments that wrapper passes; and any registered op no wrapper calls is reported.
Fixture suite (examples/stdlib_*.lynx + .expected)Each module's observable behaviour, per operation, including failure paths.
Compiled/bundled parity (make test)The same fixtures behave identically when compiled into a standalone executable, with the module sources and .so files embedded.
stdlib/*.so registrationThe interpreter rejects a module whose initialiser fails, whose name is a duplicate, or whose symbol does not resolve.

The contract check covers the mechanical half — names and argument arity. The semantic half — which argument means what — is what this page freezes, and it is the part a reviewer must check against a new backend by hand.