Lynxer

Documentation

Lynxer documentation

Lynxer is a small, standalone C++ toolchain for the Lynxer language. It runs .lynx programs without a Python runtime and ships its standard library as native shared libraries. It provides standalone Linux and Windows executables, 40 natively backed modules, an AST optimizer, a frozen native-module ABI, a source debugger, a language server and performance benchmarks. See limitations for planned runtime and standard-library improvements.

Contents

Getting started

DocumentWhat it covers
installRequirements, build commands, artifacts, environment variables
CLIEvery flag, exit code, and error behaviour of the lynxer executable
language-serverstdio LSP setup, diagnostics, completion, hover and navigation
benchmarksInterpreter, optimizer and compiled startup measurements

The language

DocumentWhat it covers
languageProgram structure, variables, operators, control flow, functions, scoping, interpolation
functionsThe global, func, local, and named lambda forms, inferred generic parameters, and caller-supplied codeblocks
macrosFile-scoped macros, pub exports, token arguments and codeblock parameters
operatorsThe operator set and precedence
conditionalsif/elif/else and switch/case/default
loopswhile, for (C style and range), doWhile, iterate, forever
try-catchThe try/catch statement, raise, and exceptionInfo()
typesEvery type name, ranges, and conversion rules
listslist and tuple values and their builtins
tuplesImmutable ordered sequences
codeblocksInline codeblock arguments
structsData-only records
classesFields, local methods, constructors, and extends inheritance
enumsTagged unions and switch patterns
vargroupsRecords with defaulted fields
modulesimport, search order, module member access
importAsAliasing an imported module

Internals and reference

DocumentWhat it covers
builtinsEvery function the interpreter implements itself
syscallsThe named Linux syscall wrappers: OS/architecture gate, namespaces, full list
asyncLocal async sub-functions, await, and the async* builtins
native-memoryTyped raw memory, blocks/arrays/views, native struct layouts
filesystemThe handle-based filesystem* API
networkingThe managed networking* socket API
processThe managed process* subprocess API
native-module-abiWriting a native .so module: entry point, signatures, data conventions
native-modulesUsing native modules, the nativeModule*/ffi* handle APIs
embeddingExporting Lynxer functions to a C ABI other languages can call
stdlib-contractsThe frozen contract every wrapper and backend must satisfy
extendingAdding a stdlib module end to end
stdlib/One page per standard-library module
legacy-surfaceThe original feature surface and its replacements
removed-featuresBytecode, Python bridging and other removed features
limitationsDeliberate constraints and what is not implemented
windowsWindows build status, embedding, and the features excluded from it

Build and run

make buildLynxer        # interpreter + every native stdlib module
lynxer/lynxer program.lynx
lynxer/lynxer --list-stdlibs

The root Makefile builds both implementations; make alone builds everything. make buildLynxer builds the interpreter and the native modules, and make buildLynxerArm64 cross-builds the ARM64 interpreter. make testLynxer runs the whole suite:

  1. lynxer/scripts/check_module_contracts.py — wrapper/backend contract check.
  2. Every lynxer/examples/*.expected fixture, diffed on stdout+stderr, including the optimizer and low-level native-memory/syscall fixtures.
  3. Interpreted-versus---compile parity for a set of fixtures.
  4. The --format/--format-oneline formatter fixture (output, idempotence, and that the formatted file still runs) and the --validate-executeable self-check.
  5. The bundled-executable, --include, and bytecode-removal checks.

Both Lynxer CI workflows (.github/workflows/build-lynxer-linux-amd64.yml and build-lynxer-linux-arm64.yml) run make testLynxer LYNXER_SKIP_DISPLAY=1, which drops the fixtures that need a display or an audio device (a CI runner has neither, and the graphics backend crashes without a display).

A Rust toolchain is required: every Rust-backed module and the native-call engine are built by cargo, and a missing toolchain or a failed build fails make instead of dropping a backend. python3 is required for the two check scripts in the test suite.

Standard library modules

There are 43 bundled modules. A module is exposed to Lynxer by stdlib/<name>.lynx and backed by a stdlib/<name>.so. The backends marked Rust come from a crate under rust/; pure modules are written in Lynxer only and need no shared library.

ModuleBackendImplementation
clinativePOSIX process/env/terminal APIs
colorlibpureANSI escape sequences
compressRustflate2/zstd/brotli/lz4_flex/zip/tar (rust/compress)
cryptoRustsha2/sha1/md-5/sha3/blake3/hmac/subtle/getrandom/ed25519-dalek (rust/crypto)
csvnativehand-written CSV/TSV reader and writer
debugnative + pure<chrono>, getrusage, assertions in Lynxer
encodingRustbase64, hex, data-encoding, bs58, ascii85, percent-encoding, quoted_printable (rust/encoding)
fileIOnative<fstream>, <filesystem>
gameRustmacroquad (rust/game)
graphicsRustmacroquad (rust/graphics)
imageRustimage (rust/image)
iniRustrust-ini (rust/ini)
jsnativethe node binary
jsonRustserde_json (rust/json)
luaRustmlua with vendored Lua 5.4 (rust/lua)
mathnative<cmath> plus statistics and vector helpers
multiprocessingnative + purestd::thread and shell subprocesses
networkRustureq + tungstenite over rustls
osnative<filesystem>, POSIX
pathnative<filesystem>, POSIX stat
randomnativeseeded linear congruential generator in C++
raylibRustraylib 6.0 via raylib-sys (rust/raylib)
reRustfancy-regex (rust/re)
regexRustfancy-regex with a named-pattern cache (rust/regex)
serverRustaxum + tokio
shellnativepopen, std::system
soundRustrodio + cpal + symphonia
sqldbRustrusqlite (bundled SQLite)
sysnativeC++ runtime and POSIX
textRust + LynxerUnicode operations and string helpers
timenative<chrono>, <ctime>
tomlRusttoml (rust/toml)
tuiRustratatui + crossterm
typingpureLynxer type builtins
uuidRustuuid (rust/uuid)
watchRustinotify (rust/watch)
xmlRustquick-xml (rust/xml)
yamlRustserde_yml (rust/yaml)

The Rust workspace has twenty-six member crates: the twenty-three module cdylib backends listed above (LYNXER_RUST_MODULE_NAMES in the Makefile), the two crates they share (abi, and regex_engine, the fancy-regex engine behind re/regex), and ffi, the required native-call engine — a staticlib linked into the interpreter through lynxer/ffi_abi.h, not a stdlib module. It is built on the libffi crate, so cargo and libffi headers are required to build the interpreter; see native-module-abi. There is no CMake staging step and no third_party/ directory: TLS is rustls (no system OpenSSL) and the HTTP/WebSocket stack is pure Rust.

venv and the OS-native GUI/turtle modules are intentionally excluded — see limitations. The graphics module covers the immediate-mode drawing/window/UI space with macroquad.

Compiling a program

--compile produces one self-contained executable that embeds the program, every transitively imported .lynx source and .so library, plus anything added with --include:

lynxer/lynxer --compile app.lynx extras/helpers.lynx \
        --include vendor/libcustom.so --include assets/message.txt -o app

The first .lynx file is the program; further .lynx/.so inputs are embedded and importable by name. A non-.lynx/.so include is embedded as data and read with bundledFile(name) / bundledFiles(). See CLI.

Adding a stdlib module

  1. Write the backend. A C++ module is stdlib/<name>.cpp exporting lynxer_module_init_v1 and one extern "C" function per entry; it is picked up automatically by the stdlib/*.cpp wildcard. A Rust module is a crate under rust/ exporting the same entry point plus one #[no_mangle] extern "C" op per entry (the lynxer_abi crate provides the packing, panic guards, and registration helper); add it to the workspace and to LYNXER_RUST_MODULE_NAMES in the root Makefile. See native-module-abi.

An op generated by a Rust export_*! macro must use a packed signature — cdecl:int64(...), cdecl:float64(...), or cdecl:cstring(...) — never a fixed shape such as cdecl:int64(int64). The export_*! macros generate a four-scalar packed prototype, so a fixed-shape declaration calls the symbol through the wrong C prototype and segfaults on the first call, with no build-time warning. A hand-written Rust op can use cdecl:v2:value(value) for typed aggregates; see native-module-abi.

  1. Write stdlib/<name>.lynx. Its setup() imports the library with importAs("<name>.so", "native<Name>") and forwards each function as global f(...) { return global.nativeName.f(...); }. Start the file with a line containing exactly ////, then the description, then another ////; lynxer --list-stdlibs prints that block.
  1. Add examples/stdlib_<name>.lynx plus a sibling stdlib_<name>.expected. The suite runs every examples/stdlib_*.lynx and diffs it against its .expected output.
  1. Run make testLynxer. It runs lynxer/scripts/check_module_contracts.py, which compares the wrapper against the backend: every global.native<Alias>.<op>(...) call must name a registered op, and for a Rust backend every args.<kind>(i) read must be in range for the arguments the wrapper passes. This catches contract mismatches a fixture cannot — a fixture records what the code does, so it will happily assert a broken module whose behaviour it reproduces.

See extending and stdlib-contracts for the full checklist and the contract.

Environment variables

VariableEffect
LYNXER_OPT_REPORT=1Print the AST optimizer's transformation counts to stderr after the run
LYNXER_GAME_HEADLESS=1Run the game module without opening a window
LYNXER_GRAPHICS_HEADLESS=1Run the graphics module without opening a window
LYNXER_SKIP_DISPLAY=1make testLynxer skips the display/audio fixtures (used by CI)

File-wide functions

Use func for a helper that belongs to the current source file:

global setup(){}

func double(int value) -> int {
    return value * 2;
}

global main(){
    println(double(21));   // 42
}

func declarations must be top-level and appear between global setup() and global main(). They are called by bare name in their defining file. When a file is imported as a module, callers use global.moduleName.functionName(...). func names are file-scoped, so two different imported files may define the same helper name. global func name(...) is not valid syntax; see language.