๐Ÿš€ What is NitroLISP?

NitroLISP is a statically typed, ahead-of-time Lisp dialect with an S-expression surface that compiles to modern C++23. You write homoiconic Lisp; NitroLISP analyzes it, expands your macros, optimizes tail calls, and emits clean C++23 that a bundled zig/clang toolchain builds into a native executable.

Its defining idea is a tiny compiler-known core plus macros that grow the language. The compiler does not keep gaining features over time; the prelude, written in NitroLISP itself, does.

Write a source file. Run NLC. Get a native binary.

// hello.nls
#include <cstdio>;

(module exe HelloWorld
  (println "Hello, World!")
  (printf "C++ passthrough: 2 + 3 = %d\n" (+ 2 3)))
> NLC -s hello.nls -r
Hello, World!
C++ passthrough: 2 + 3 = 5
Tip

๐Ÿ’ก Fast path: read Getting Started, skim the Language Reference, then read the Macro System to see how the language grows itself.

๐Ÿšฆ Documentation Roadmap

Reader Goal Start Here Why
๐Ÿš€ Run your first program Getting Started Install assumptions, your first .nls file, the NLC compiler, and build modes
๐Ÿ“˜ Learn the language Language Reference Types, forms, modules, lambdas, control flow, directives, and C++ interop
๐Ÿงฌ Grow the language Macro System defmacro, quasiquote, compile-time eval, the auto-loading prelude, threading
๐Ÿงพ Verify exact syntax BNF Grammar Formal EBNF grammar and lexical rules
๐Ÿ› ๏ธ Use the toolchain Tools Compiler CLI, DAP debugger, LSP server, and the zig/clang C++ build
๐Ÿ”Œ Embed NitroLISP API Reference Consuming a compiled NitroLISP lib / dll from a host (preview)
๐Ÿงช Solve a task How-To Guide Practical recipes with complete examples

๐Ÿ’ก Core Idea

NitroLISP is designed around one direct workflow:

write .nls  ->  run NLC  ->  C++23  ->  native executable

The core the compiler knows is deliberately small: lambdas and closures, the data and control forms, modules, and a macro engine. Everything ergonomic, when / unless, threading, and the rest, is a macro living in the auto-loaded prelude. New language features ship as NitroLISP code, not as compiler changes.

Important

๐Ÿงฑ NitroLISP is statically typed. Every value has a known type at compile time, and : introduces a type annotation, as in (n : int32). There is no dynamic typing and no runtime type tags in user code.

โœจ Key Features

Feature What It Means
๐Ÿงฌ Tiny core, macros grow it A small set of compiler-known forms plus a real macro engine. when, unless, threading, and more are NitroLISP code in the prelude, not compiler features.
๐Ÿ—’๏ธ Homoiconic S-expressions Code is data. Cons-cell structure means macros transform the program with the same syntax you write programs in.
โš™๏ธ Compiles to C++23 NitroLISP emits readable, modern C++23 and builds it with a bundled zig/clang toolchain into a native binary.
๐Ÿง  Static typing Every value has a known type at compile time. Type annotations use : and expose full-width numeric types for clean C interop.
๐Ÿ” First-class functions lambda, closures (compiler-driven free-variable capture), and an fn function type backed by std::function.
๐Ÿ“ฆ Modules exe, dll, and lib modules with import, exported declarations, and scope-resolved cross-module calls (module.fn).
๐ŸŒ‰ C++ interop is first-class Raw #include, calling printf and other C/C++ as S-expression forms. Interop is a designed-in feature, not an escape hatch.
๐Ÿชœ Tail-call optimization Self tail-calls are rewritten to loops, so recursion-as-iteration runs flat without overflowing the stack.
๐Ÿงฐ Zero external dependencies The compiler and a bundled zig/clang toolchain ship in a single executable. No separate compiler, linker, or runtime to install.

๐Ÿ—๏ธ Architecture

Source (.nls)
    |
    v
+-------------------------------------------+
|  NitroLISP Pipeline                       |
|                                           |
|  Tokenize --> Parse --> AST               |
|              |                            |
|              v                            |
|  Macro expansion (defmacro rewrite pass)  |
|              |                            |
|              v                            |
|  Semantics (types, scope, modules)        |
|              |                            |
|              v                            |
|  TCO (self tail-calls --> loops)          |
|              |                            |
|              v                            |
|  C++23 emit                               |
|              |                            |
|              v                            |
|  zig / clang build                        |
+-------------------------------------------+
    |
    v
Native executable

The pipeline is expressed as .nld language-definition files: the S-expression surface, the macro expansion pass, the TCO pass, and the C++23 emitters are all language definition, so the language is defined declaratively rather than hand-coded stage by stage.

Note

๐Ÿงฉ The macro pass runs as a rewrite over the program before semantics, and TCO runs as a separate rewrite after macro expansion. Both are isolated passes, which keeps the core forms small and the emitter untouched by either feature.

๐ŸŽฏ Who Is This For?

๐Ÿ“Œ Current Status

The pipeline is working end-to-end, grounded in the language definition, with support for:

Tip

๐Ÿ’ก The exact, verified surface syntax always lives in the BNF Grammar. When the prose and the grammar ever seem to disagree, the grammar is authoritative.

๐Ÿ’ป System Requirements

Area Requirement
Operating system Windows x64
Runtime dependencies None
External toolchain None (zig / clang is bundled)
Building from source Delphi 12 Athens or higher

๐Ÿ—บ๏ธ Table of Contents

๐Ÿš€ Getting Started

NitroLISP ships as a single compiler executable, NLC (the NitroLISP Compiler). It carries its own language definition, standard library, and a bundled zig/clang toolchain, so there is nothing else to install: no separate C++ compiler, no linker, no runtime.

Note

๐ŸชŸ NitroLISP targets Windows x64 for the host compiler and can emit for win64 or linux64. The zig/clang toolchain is bundled with NLC and resolved relative to the executable.

โœ๏ธ Your First Program

Create a file named hello.nls:

// hello.nls
#include <cstdio>;

(module exe HelloWorld
  (println "Hello, World!")
  (printf "C++ passthrough: 2 + 3 = %d\n" (+ 2 3)))

Compile and run it in one step with -r:

> NLC -s hello.nls -r
Hello, World!
C++ passthrough: 2 + 3 = 5

That is the whole loop: a .nls file goes in, a native binary comes out. The #include <cstdio>; line is raw C++ passthrough, and printf is called directly as an S-expression form, which is how NitroLISP's first-class C++ interop looks in practice.

Tip

๐Ÿ’ก println uses {} as its format placeholder, as in (println "fib(30) = {}" (fib 30)). printf uses C-style %d / %lld placeholders because it is the actual C printf.

๐Ÿงญ The Compiler CLI

NLC takes one required flag, the source file, plus a few options:

Flag Long form Meaning
-s --source <file> Required. The .nls source file to compile.
-o --output <path> Output path. Defaults to output.
-r --autorun Build, then run the compiled binary.
-d --debug Build, then debug the compiled binary (DAP debugger).
-h --help Show usage.
> NLC -s hello.nls            // compile to ./output
> NLC -s hello.nls -o build   // compile to ./build
> NLC -s hello.nls -r         // compile and run

-r and -d cannot be combined. See Tools for the debugger and the build pipeline in depth.

โš™๏ธ Build Configuration

Build settings are source directives, written at the top of the file before the module form. They start with @ and end with a semicolon. They are part of the source, not command-line flags, so a file always builds the same way regardless of how NLC is invoked.

//@target win64 | linux64;
@target linux64;
@optimize debug;
@subsystem console;

(module exe Fib
  (defn fib ((n : int32)) : int32
    (return (if (< n 2)
                n
                (+ (fib (- n 1)) (fib (- n 2))))))
  (println "fib(30) = {}" (fib 30)))
Directive Valid values Purpose
@target win64, linux64 The platform the emitted C++ is built for.
@optimize debug, releasesafe, releasefast (alias: release), releasesmall Optimization level for the zig/clang build.
@subsystem console, gui The subsystem of the produced binary (default: console).
Important

๐Ÿงฑ Directives are terminated with ; and live above the (module ...) form. This is the same ; terminator NitroLISP uses for C++ passthrough lines such as #include <cstdio>;, which is why // (not ;) is the comment syntax.

Note

๐Ÿงฉ NitroLISP defines a broader directive family, including conditional compilation, version-info, resource, and path directives. See Build Directives for the complete reference with all validated values and predefined symbols.

๐Ÿ—๏ธ Build Modes

The output type is determined by the module declaration in the source file:

Module declaration Output Description
(module exe name ...) name.exe Native executable
(module dll name ...) name.dll Dynamic link library with exported routines
(module lib name ...) (linked inline) Reusable library module, imported by other modules

Use module dll and mark exported declarations with export:

(module dll mylib
  (export (defn calculate ((x : int32) (y : int32)) : int32
    (return (+ (* x x) (* y y))))))

Library Module

Use module lib to produce a reusable library that other modules import. The module name must match the filename:

// mathlib.nls
(module lib mathlib
  (export (defn triple ((n : int32)) : int32
    (return (* n 3)))))

Import it from another module with full qualification:

(module exe main
  (import mathlib)
  (println "{}" (mathlib.triple 7)))   // 21

๐Ÿงฏ Common First-Run Issues

Symptom Likely cause Fix
Module not found Library file is not in the current folder or @modulepath Add @modulepath "folder"; or move the library next to the main file
Unknown symbol Imported symbol called without module qualification Use modulename.symbolname
DLL routine not visible Routine is not wrapped in (export ...) Mark exported declarations with export
Build fails with clang errors Raw C++ passthrough has a syntax error Check that passthrough lines end with ; and parens are balanced

๐Ÿ—‚๏ธ Project Layout

A NitroLISP program is just one or more .nls files. A minimal project is a single source file:

my-project/
  hello.nls          // your source
  output/            // created by NLC
    zig-out/
      bin/
        HelloWorld.exe   // built binary, named after the module

When you run NLC -s hello.nls, the compiler emits C++23, builds it with the bundled toolchain, and writes the binary under the output path. The executable is named after the module ((module exe HelloWorld ...) produces HelloWorld.exe). With -r, NLC runs it for you immediately after the build.

Modules can also pull in other modules with (import name), which resolves a .nls library module by name, compiles it, and links it. See the Language Reference and Macro System for how imports carry both functions and macros.

๐Ÿง  Editor Support

NitroLISP includes a Language Server, so editors that speak LSP can offer live diagnostics and navigation while you edit .nls files. The Tools section covers the LSP server and the DAP debugger in detail.

๐Ÿ“˜ Language Reference

NitroLISP is an S-expression language: every construct is written (head ...) and the grammar dispatches on the head. There is no infix syntax and no operator precedence to memorize. (+ 1 2) is a call to +; (if c a b) is the conditional; (defn f ...) defines a function. Code is data, which is what makes the macro system possible.

Types are static. Every value has a type known at compile time, and : introduces a type annotation in the form name : type.

๐Ÿ’ฌ Comments

// line comment
/* block comment */
Important

๐Ÿงฑ NitroLISP uses // for comments, not ;. The semicolon is the terminator for C++ passthrough lines such as #include <cstdio>;, so it cannot also be a comment character.

๐Ÿ“ฆ Modules

Every program is a module. The module form names a kind, exe, dll, or lib, then a name, then the body:

(module exe HelloWorld
  (println "Hello, World!"))
Kind Produces
exe An executable program.
dll A shared library.
lib A reusable NitroLISP library module, imported by other modules.

The body of a module is a sequence of statements: directives, imports, declarations, and top-level expressions all nest inside the form. The executable is named after the module, so (module exe HelloWorld ...) builds HelloWorld.exe.

๐Ÿ”ข Types

NitroLISP has a concrete type system that maps directly to machine reality. Every value has a known type at compile time, and every primitive has a fixed size.

Integer Types

NitroLISP C++ Size Range
int8 int8_t 1 byte -128 to 127
int16 int16_t 2 bytes -32,768 to 32,767
int32 int32_t 4 bytes -2,147,483,648 to 2,147,483,647
int64 int64_t 8 bytes Full 64-bit signed range
uint8 uint8_t 1 byte 0 to 255
uint16 uint16_t 2 bytes 0 to 65,535
uint32 uint32_t 4 bytes 0 to 4,294,967,295
uint64 uint64_t 8 bytes Full 64-bit unsigned range

Integer literals default to int32. Hex literals use the 0x or 0X prefix: 0xFF00. Append u to force an unsigned literal: 42u.

Floating-Point Types

NitroLISP C++ Size Description
float32 float 4 bytes 32-bit IEEE 754
float64 double 8 bytes 64-bit IEEE 754

Float literals without a suffix default to float64. Append f or F to force float32.

Boolean, Character, String, and Pointer Types

NitroLISP C++ Size Description
boolean bool 1 byte Literals true / false
char char 1 byte 8-bit character
wchar wchar_t 2 bytes 16-bit wide character
string std::string 8 bytes (pointer) Managed UTF-8 string
wstring std::wstring 8 bytes (pointer) Managed UTF-16 string
pointer void* 8 bytes Untyped pointer; nil is the null pointer
set RTSet varies Bit-set type; see Composite Types
Note

๐Ÿ”ค The boolean type keyword is boolean (not bool). Strings are "..." for string and w"..." for wstring.

Escape Sequences

String literals support these escape sequences: \n (newline), \t (tab), \r (carriage return), \0 (null), \\ (backslash), \" (quote), \xNN (hex byte).

Type Widening and Promotion

When two different numeric types appear in the same expression, the type checker automatically widens or promotes:

Composite types -- records, objects, overlays, choices, arrays, pointers, sets, and routine types -- are built with the (type ...) form and covered in Composite Types below.

๐Ÿ“ฅ Values and Variables

define introduces a typed value with an optional initializer. const introduces a compile-time constant.

(define total : int32 0)
(define name  : string "NitroLISP")
(const  limit : int32 100)

define can also bind to an external symbol with external:

(define errno : int32 external)

Mutate a binding with the set! form (NitroLISP has no = assignment; = is the equality operator):

(set! total (+ total 1))

๐Ÿ› ๏ธ Functions

defn defines a function. Parameters are a list of (name : type) pairs, and the return type follows :. The body is a sequence of statements; use (return expr) to return a value.

(defn fib ((n : int32)) : int32
  (return (if (< n 2)
              n
              (+ (fib (- n 1)) (fib (- n 2))))))

Parameters can carry a var or const modifier, a function can be variadic with ..., and it can bind to an external or C-linkage symbol:

(defn clink puts ((s : string)) : int32 external)  // C linkage + external
(defn sumInts (...) : int32                        // variadic
  (define total : int32 0)
  (for (i 0 to (- (varargs count) 1))
    (set! total (+ total (varargs next int32))))
  (return total))
Tip

๐Ÿ’ก A leading clink or cpplink keyword before the function name is the linkage specifier (cpplink, C++ linkage, is the default). external (optionally followed by a symbol name) declares the function without a body, for binding to existing native code.

ฮป Lambdas, the fn Type, and Closures

lambda is an anonymous function with the same parameter and return-type shape as defn. Its body is a sequence of expressions; the value of the last expression is the implicit return.

(lambda ((n : int32)) : int32 (* n n))

The fn type names a function type, (fn (arg-types) : ret), so functions are first-class values you can store and pass:

// a routine that takes a function parameter
(defn apply1 ((f : (fn (int32) : int32)) (x : int32)) : int32
  (return (f x)))

// a lambda bound to a variable of fn type
(define square : (fn (int32) : int32)
  (lambda ((n : int32)) : int32 (* n n)))

Lambdas capture their environment by value, so a closure survives the scope it was created in:

(define adder5 : (fn (int32) : int32)
  (let ((k : int32 5))
    (lambda ((n : int32)) : int32 (+ n k))))   // captures k
Note

๐Ÿง  Free-variable capture is handled by the compiler. At local scope a closure captures by value (C++ [=]), which is why an escaping closure like adder5 keeps working after its let exits.

๐Ÿ”— Local Bindings: let

let introduces local bindings, each (name : type init), then a body. It works as a statement and as an expression (the value of the last body expression is the value of the let):

(let ((z : int32 5))
  (+ z z))          // evaluates to 10

๐Ÿ”€ Control Flow

if takes a condition, a then-form, and an optional else-form. As an expression it is a ternary; as a statement, use (begin ...) for multi-statement arms.

(if (< n 2) n (+ a b))

cond is a multi-branch conditional. Each branch is (test body ...), with an optional final (else body ...):

(cond
  ((= (mod i 15) 0) (println "FizzBuzz"))
  ((= (mod i 3)  0) (println "Fizz"))
  ((= (mod i 5)  0) (println "Buzz"))
  (else             (println "{}" i)))

begin groups a sequence of forms; as an expression its value is the last form.

while loops while a condition holds. for counts a variable from a start to an end with to (or downto). repeat runs the body, then tests (until cond) (a do/while):

(while (> n 0)
  (set! n (- n 1)))

(for (i 0 to 9)
  (println "{}" i))

(repeat (until (>= i 10))
  (set! i (+ i 1)))

match dispatches on a value. Each arm is ((labels ...) body ...), where a label is a single value or a lo .. hi range, with an optional (else ...):

(match grade
  ((90 .. 100) (println "A"))
  ((80 .. 89)  (println "B"))
  (else        (println "F")))

return returns from a function ((return) or (return expr)), leave breaks out of a loop, and skip continues to the next iteration.

Tip

๐Ÿ’ก if, let, cond, and begin are all valid in expression position, so they produce values, not just effects. That is why (println "{}" (let ((z : int32 5)) (+ z z))) prints 10.

โž• Operators

Operators are written head-first like any other form. There is no infix syntax and no operator precedence to memorize.

Arithmetic

Form Operation
(+ a b) Addition
(- a b) Subtraction
(* a b) Multiplication
(/ a b) Division (float)
(div a b) Integer division
(mod a b) Modulo (remainder)
(- x) Unary negate
(+ x) Unary plus

Comparison

Form Operation
(= a b) Equal (emits C++ ==)
(<> a b) Not equal
(< a b) Less than
(> a b) Greater than
(<= a b) Less than or equal
(>= a b) Greater than or equal

Logical (short-circuit)

Form Operation
(and a b) Logical AND
(or a b) Logical OR
(not x) Logical NOT

Bitwise

Form Operation
(and a b) Bitwise AND (context-dependent)
(or a b) Bitwise OR (context-dependent)
(xor a b) Bitwise XOR
(shl x n) Shift left
(shr x n) Shift right

Set Membership

Form Operation
(in elem set) True if elem is a member of set
Important

๐Ÿงฑ There is no = assignment in NitroLISP. = always means equality. All mutation goes through (set! target value). The C operators % and != are also accepted via C++ interop.

๐Ÿ–จ๏ธ Output

print and println map to std::print / std::println and use {} as the format placeholder:

(println "fib(30) = {}" (fib 30))
(print "no newline")

๐Ÿงฉ Imports and Visibility

A library module exports declarations with export. Another module pulls it in with import (one import can name several modules), and calls across modules are scope-resolved with module.function:

// testlib.nls
(module lib testlib
  (export (defn lib_triple ((n : int32)) : int32
    (return (* n 3)))))
// caller
(module exe ImportTest
  (import testlib)
  (printf "lib_triple(7) = %d\n" (testlib.lib_triple 7)))
Note

๐Ÿงท A library module's declared name must match its filename, because a qualified call (testlib.lib_triple ...) emits testlib::lib_triple(...) and must resolve to the module's emitted namespace testlib. One (import ...) carries both the module's functions and its macros, see the Macro System.

๐Ÿ›ก๏ธ Exceptions

guard wraps a protected body with an optional except handler and an optional finally block:

(guard
  (println "before throw")
  (raise "boom")
  (except
    (println "caught: code={} msg={}" (exccode) (excmsg)))
  (finally
    (println "cleanup runs always")))
Form Purpose
(guard body... (except ...) (finally ...)) Protected block. Requires except, finally, or both.
(raise expr) Raise an exception with the default software code
(raisecode code msg) Raise an exception with an explicit integer code and message
(exccode) Get the exception code (inside an except block)
(excmsg) Get the exception message (inside an except block)

A guard block catches both software exceptions (raise / raisecode) and hardware exceptions (such as divide-by-zero). When both except and finally are present, except comes first.

๐Ÿง  Memory Management

NitroLISP provides direct memory-management intrinsics for typed allocation, raw blocks, and dynamic arrays:

Form Purpose
(create x) Allocate and initialize a typed instance (objects, records via pointer)
(destroy x) Free a previously created instance
(getmem n) Allocate n bytes of raw memory, returns a pointer
(freemem p) Free a raw memory pointer
(resizemem p n) Resize a raw allocation
(setlength a n) Resize a dynamic array
Warning

Every (create x) must have a matching (destroy x) to avoid memory leaks. These are general memory operations, not object-specific -- they work for any typed pointer allocation.

๐Ÿงฎ Intrinsics

Form Returns
(len x) Length of a string, wide string, or dynamic array
(size <Type>) Size in bytes of a type (sizeof). Takes a type, not a value.
(utf8 x) Convert a wide string to a UTF-8 string
(paramcount) Number of command-line arguments
(paramstr n) The nth command-line argument
(exccode) Exception code (inside an except block)
(excmsg) Exception message (inside an except block)

๐Ÿ” Variadics

A variadic function declares (...) for its parameters and reads arguments with varargs:

(defn sumInts (...) : int32
  (define total : int32 0)
  (for (i 0 to (- (varargs count) 1))
    (set! total (+ total (varargs next int32))))
  (return total))
Form Returns
(varargs count) Total number of variadic arguments
(varargs next <Type>) Retrieve and consume the next argument as the given type
(varargs get <Index> <Type>) Retrieve argument at index without advancing the cursor
(varargs reset) Reset the cursor back to the first argument
(varargs copy) Copy the current varargs cursor position

๐Ÿงฑ Composite Types

The (type Name ...) form names a new type. It is either a bare type (an alias) or a sub-form whose head names the kind:

(type Celsius int32)                       // alias
(type Point (record (x : int32) (y : int32)))  // record (struct)
(type Color (choices Red Green Blue))      // enum
(type Buffer (array (range 0 255) uint8))  // fixed array
(type IntPtr (pointer int32))              // typed pointer
(type Flags (set of Color))                // set

Records

A record is a value type with named fields (emits a C++ struct). Fields are (name : type) with an optional bitfield width (name : type : width):

(type Point (record
  (x : float32)
  (y : float32)))

(define p : Point)
(set! p.x 10.0)
(set! p.y 20.0)
(println "({}, {})" p.x p.y)

Record inheritance: use (parent Base) to extend a record. The emitted C++ is struct Derived : public Base { using Super = Base; ... };:

(type Shape (record (x : int32) (y : int32)))
(type Circle (record (parent Shape) (radius : float32)))

(define c : Circle)
(set! c.x 100)
(set! c.y 200)
(set! c.radius 50.0)

Packed layout and alignment: the bare word packed removes padding between fields (emits #pragma pack(push, 1)), and (align N) sets a custom alignment (emits alignas(N)):

(type Header (record packed
  (magic   : uint16)
  (version : uint8)
  (flags   : uint8)))

(type AlignedVec (record (align 16)
  (x : float32) (y : float32) (z : float32) (w : float32)))

Bitfields: specify a bit width after a second : for compact binary layouts:

(type Flags (record packed
  (visible  : uint8 : 1)
  (enabled  : uint8 : 1)
  (priority : uint8 : 3)
  (reserved : uint8 : 3)))

Anonymous overlays and records can nest inside a record for C-style union-in-struct patterns:

(type Variant (record
  (kind : int32)
  (overlay
    (int_val   : int64)
    (float_val : float64)
    (str_val   : string))))

Objects

Objects are heap-allocated types with fields and virtual methods. They are NitroLISP's affordance for when the C++ boundary demands a real class with a vtable -- for example, implementing or overriding a C++ interface. Use (create x) / (destroy x) for allocation and deallocation; self accesses the current instance and parent calls the base:

(type TCounter (object
  (value : int32)
  (method increment () : void
    (set! self.value (+ self.value 1)))
  (method get_value () : int32
    (return self.value))))

(define c : (pointer to TCounter))
(create c)
(set! c.value 0)
(c.increment)
(c.increment)
(println "count: {}" (c.get_value))   // count: 2
(destroy c)

Object inheritance: use (parent Base) to derive. Methods are virtual, so a derived method overrides the base. Call the base implementation with parent:

(type TBase (object
  (x : int32)
  (method describe () : int32
    (return (* self.x 10)))))

(type TDerived (object (parent TBase)
  (y : int32)
  (method describe () : int32
    (return (+ (parent.describe) self.y)))))
Note

๐Ÿงฉ Object methods emit as virtual, so derived types can override them. Objects give you real C++ class semantics with a vtable, single inheritance, and self / parent dispatch.

Overlays (Unions)

Overlays share storage between fields (emit a C++ union):

(type Value (overlay
  (as_int   : int32)
  (as_float : float32)
  (as_bytes : (array (range 0 3) uint8))))

Choices (Enumerations)

Choices declare named enumeration values. Values may be bare (auto-numbered) or given an explicit integer:

(type Direction (choices North South East West))
(type Color (choices (Red 0) (Green 1) (Blue 2)))

Arrays

A fixed array uses a (range lo hi) sub-form; a dynamic array omits bounds:

(type Grid (array (range 0 7) int32))          // fixed: std::array<int32_t, 8>
(type Items (array int32))                      // dynamic: std::vector<int32_t>

Use (setlength arr n) to resize a dynamic array and (len arr) to query its current length.

Pointer Types

A typed pointer names its target; const makes it read-only:

(type PInt32     (pointer int32))               // int32_t*
(type PConstInt  (pointer to const int32))       // const int32_t*

A bare (pointer) with no target is void*.

Set Types

A set over a range is a std::bitset; a set over a type is a std::set:

(type CharSet (set (range 0 255)))              // std::bitset<256>
(type IntSet  (set of int32))                    // std::set<int32_t>

Set literals use square brackets with optional (range lo hi) elements: [1 3 (range 5 9)].

Routine Types (Function Pointers)

A routine type declares a callable with an optional linkage and parameter/return types:

(type TCompare (routine ((a : int32) (b : int32)) : int32))
(type TCCallback (routine clink ((data : pointer)) : void))

Routine types use C calling convention by default. Add clink for explicit C linkage or cpplink for C++ ABI compatibility.

Note

๐Ÿง  Routine types (routine) are C function pointers. The fn type ((fn (int32) : int32)) is a std::function -- a first-class callable value that can hold a lambda or closure. Use routine for C interop callbacks; use fn for NitroLISP closures and higher-order functions.

๐Ÿ“ Pointers

Take an address with (address x) (the of word is optional: (address of x)). Dereference with the postfix ^ operator (p^). Cast a value to a typed pointer with (pointer to <Type> expr) (the to is optional, and const may follow it):

(define p : pointer (address total))
(define v : int32 p^)

Postfix operators bind to the preceding operand: a[i] indexes an array, a.field accesses a field, and a^ dereferences a pointer.

๐ŸŽ›๏ธ Directives

Directives configure the build and are written @name value; at the top of a file. The common build directives, @target, @optimize, @subsystem, are covered in Getting Started. NitroLISP also supports conditional compilation (@define, @undef, @ifdef, @ifndef, @elseif, @else, @endif) and a family of version-info and resource directives. The full directive reference with validated values lives in Build Directives.

๐Ÿงช Unit Testing

NitroLISP has a built-in unit testing framework. Test blocks are written inside the module, gated by the @unittestmode directive. When @unittestmode on; is active, the compiler compiles test blocks and replaces the normal entry point with the test runner.

@unittestmode on;

(module exe MathTests
  (defn add ((a : int32) (b : int32)) : int32
    (return (+ a b)))

  (test "addition"
    (testAssertEqualInt 5 (add 2 3))
    (testAssertEqualInt 0 (add -1 1)))

  (test "comparisons"
    (testAssertTrue (> 10 5))
    (testAssertFalse (< 10 5))))

Assertion Family

All assertions continue after failure -- failures accumulate and are reported per test. The compiler injects source file and line number automatically.

Form Description
(testAssert expr) Fails if expression is false
(testAssertTrue expr) Fails if expression is not true
(testAssertFalse expr) Fails if expression is not false
(testAssertEqualInt expected actual) Fails if signed integers are not equal
(testAssertEqualUInt expected actual) Fails if unsigned integers are not equal
(testAssertEqualFloat expected actual) Fails if floats are not equal
(testAssertEqualStr expected actual) Fails if strings are not equal
(testAssertEqualBool expected actual) Fails if booleans are not equal
(testAssertEqualPtr expected actual) Fails if pointers are not equal
(testAssertNil expr) Fails if expression is not nil
(testAssertNotNil expr) Fails if expression is nil
(testFail "message") Unconditional failure with a message
Tip

๐Ÿ’ก Test blocks have access to all module declarations. Use tests to verify routines, types, and module behavior without building a separate test harness.

๐ŸŒ‰ C++ Interop

Interop is not a feature bolted onto NitroLISP. It is what NitroLISP is. The Lisp surface is compiled to modern C++23, and from that point your program is an ordinary C++ translation unit that a real C++ toolchain (the bundled zig/clang) compiles and links. There is no FFI, no binding layer, no marshalling boundary, because there is no boundary -- the code already is C++. Anything C++ can do with C and C++ code, at the source level and at the binary level, NitroLISP can do too.

That gives you the full interop matrix, in both directions:

Consume C / C++ Produce for C / C++
Source level #include any header and call C/C++ functions as ordinary forms; declare external symbols with a typed signature your NitroLISP-as-C++ shares one translation unit with any raw C++ you write inline
Binary level link a .lib or load a .dll built by any modern C/C++ compiler and call it through external declarations compile a (module lib ...) / (module dll ...) to a .lib / .dll that a modern C/C++ compiler links against, with export controlling visibility

Source level: raw C/C++ in the same file

Pull in C/C++ headers with a raw #include passthrough line, terminated with ;, then call the functions directly as ordinary S-expression forms:

#include <cstdio>;

(module exe HelloWorld
  (println "Hello, World!")
  (printf "C++ passthrough: 2 + 3 = %d\n" (+ 2 3)))

Here printf is the real C printf, called as (printf fmt args ...) with C-style %d placeholders. The standard library and existing C/C++ code are available with no binding layer, because the surface is Lisp but the substance is C++. Verbatim C++ can go straight into the file through the same passthrough, so even a construct with no dedicated Lisp form is still reachable, all in one translation unit.

Important

๐Ÿงฑ A passthrough line ends with ;, the same terminator the lexer uses to know where raw C++ stops and S-expression forms resume. This is the reason //, not ;, is the comment syntax in NitroLISP.

Binary level: ship and consume native libraries

Because the output is a normal native artifact, NitroLISP also interoperates with C and C++ across the linker. Compile a lib or dll module and a modern C/C++ compiler can link against it; export marks what is visible on the boundary:

(module lib mathlib
  (export (defn triple ((n : int32)) : int32
    (return (* n 3)))))

Going the other way, declare a function that lives in a linked .lib / .dll with external and call it like any other form:

#include <cmath>;

(defn clink c_pow ((base : float64) (exp : float64)) : float64 external "pow")

clink selects the C ABI (extern "C", a plain unmangled symbol); cpplink selects C++ linkage and is the default, so a bare (defn name ...) is already cpplink. external marks the function as implemented elsewhere; (define name : T external) does the same for a global.

Note

๐Ÿ”— The clink / cpplink choice is the one real interop decision, and it is inherited straight from C++, not a NitroLISP quirk. clink is a stable, universal ABI: extern "C" functions export under plain, unmangled names and link cleanly against MSVC, gcc, or clang. cpplink (the default) is richer -- overloads, namespaces, classes -- but toolchain-bound, since name mangling, the exception ABI, and STL layout are compiler- and version-specific. Overloading rides on mangling, so an overloaded routine must be cpplink; the compiler will not overload a clink symbol. Choose per export where on that spectrum to sit. The artifact-level detail lives in Embedding and Scripting.

Tip

๐Ÿ’ก Because NitroLISP is C++ underneath, "what can it interoperate with?" has the same answer as "what can C++ interoperate with?" -- the entire C and C++ ecosystem, at both source and binary level, in both directions.

๐ŸŽ›๏ธ Build Directives

Directives are compile-time instructions prefixed with @ and terminated with ;. They live at the top of a source file, before the (module ...) form. Directives are part of the source, not command-line flags, so a file always builds the same way regardless of how NLC is invoked.

๐Ÿ“„ Build Configuration

Directive Value type Valid values Effect
@target identifier win64, linux64 Target platform for the emitted C++ build
@optimize identifier debug, releasesafe, releasefast (alias: release), releasesmall Optimization level passed to the zig/clang toolchain
@subsystem identifier console, gui Application subsystem (default: console)
@target win64;
@optimize releasesafe;
@subsystem console;

๐Ÿท๏ธ Version Information

Embed Windows version information in the output binary:

Directive Value type Description
@addverinfo identifier Enable version info embedding (any non-empty value enables)
@vimajor integer Major version number
@viminor integer Minor version number
@vipatch integer Patch version number
@viproductname string Product name
@videscription string File description
@vifilename string Original filename
@vicompanyname string Company name
@vicopyright string Copyright string
@addverinfo true;
@vimajor 1;
@viminor 0;
@vipatch 0;
@viproductname "My Application";
@videscription "A NitroLISP application";
@vicompanyname "My Company";
@vicopyright "Copyright 2026";

๐Ÿ“ Resource and Path

Directive Value type Description
@exeicon string Set the application icon (EXE only)
@copydll string Copy a DLL to the output directory during build
@linklibrary string Link against a native library
@librarypath string Add a library search path
@modulepath string Add a module (import) search path
@includepath string Add a C++ include path
@linklibrary "raylib";
@librarypath "libs/win64";
@includepath "include";
@modulepath "libs/std";

๐Ÿž Debug and Diagnostic

Directive Value type Description
@breakpoint (none) Insert a debugger breakpoint at this source location
@unittestmode identifier on or off (default: off). When on, test blocks are compiled and the test runner replaces the normal entry point.
@message severity + string Emit a compile-time diagnostic. Severity: hint, info, note, warn, error, fatal.

๐Ÿ”€ Conditional Compilation

Conditional compilation lets a source file include or exclude code based on defined symbols. These directives are handled by the lexer preprocessor, not the grammar:

Directive Description
@define SYM Define a symbol
@undef SYM Undefine a symbol
@ifdef SYM Compile the following block if symbol is defined
@ifndef SYM Compile the following block if symbol is NOT defined
@elseif SYM Alternative branch with condition
@else Alternative branch
@endif End conditional block
@define VERBOSE;

@ifdef VERBOSE
  (println "verbose mode is on")
@endif

@ifdef WIN64
  (println "running on 64-bit Windows")
@endif

๐Ÿ Predefined Symbols

Symbol Defined when
NITROLISP Always
TARGET_WIN64 Target is win64
WIN64 Target is win64
MSWINDOWS Target is win64
WINDOWS Target is win64
CPUX64 Always (x64-only target)
TARGET_LINUX64 Target is linux64
LINUX Target is linux64
POSIX Target is linux64
UNIX Target is linux64

๐Ÿงฌ The Macro System

The macro system is the heart of NitroLISP. The compiler knows a deliberately small set of forms; everything else, when, unless, threading, and any language feature you add, is a macro written in NitroLISP. The compiler does not grow new features over time. The prelude does.

This is possible because NitroLISP is homoiconic: code is data. A macro is a compile-time function that receives the unevaluated argument forms, computes a replacement form, and the compiler substitutes it in place before semantics and code generation run.

Important

๐Ÿงฑ Macros run at compile time, as a rewrite pass over the program before semantics. By the time your code is type-checked and emitted, every macro call has already been replaced by the code it produced.

๐Ÿช„ defmacro

defmacro defines a macro: a name, a parameter list, and a body that is evaluated at expansion over the call's argument forms.

(defmacro twice (x) (+ x x))

A macro can take a variadic body with ..., which is how statement macros accept a block of forms (see when / unless below).

๐Ÿ’ฌ Quote, Quasiquote, Unquote

quote yields a form as literal data. quasiquote builds a template, and unquote marks the holes in that template to be filled with the macro's arguments at expansion:

(defmacro square (x)
  (quasiquote (* (unquote x) (unquote x))))

(defmacro shout (msg)
  (quasiquote (println (unquote msg))))

(println "{}" (square 5))          // expands to (* 5 5) -> 25
(println "{}" (square (+ 2 1)))    // expands to (* (+ 2 1) (+ 2 1))
Note

๐Ÿ” square uses its argument twice, so (square (+ 2 1)) substitutes the whole (+ 2 1) form into both holes. The template machinery copies forms structurally, so the expanded code is (* (+ 2 1) (+ 2 1)), not a pre-computed 3.

๐Ÿชข Unquote-Splicing: (splice X)

Inside a quasiquote, (splice X) splices the elements of a list X into the surrounding form, rather than inserting the list as a single element:

(defmacro show3 (xs)
  (quasiquote (println "{} {} {}" (splice xs))))

(defmacro showWith (a rest)
  (quasiquote (println "{} {} {}" (unquote a) (splice rest))))

NitroLISP uses the word-forms quote, quasiquote, unquote, and splice rather than the ' ` , ,@ reader sugar.

โš™๏ธ Compile-Time Evaluation

A macro body is evaluated at expansion, so it can compute with real list operations, list, cons, car, cdr, and append, to build the form it returns:

(defmacro firstof (a b)
  (car (list a b)))                       // -> a

(defmacro secondof (a b)
  (car (cdr (list a b))))                 // -> b

(defmacro appendsecond (a b)
  (car (cdr (append (list a) (list b))))) // -> b

The evaluator also supports arithmetic and the if / cond / let forms at expansion time, so macros can branch and bind while they compute their output.

๐Ÿงผ Hygiene: gensym

gensym produces a fresh, unique name, so a macro can introduce a temporary binding without colliding with names in the calling code:

(defmacro dbl (x)
  (let ((g : int32 (gensym)))
    (quasiquote
      (let (((unquote g) : int32 (unquote x)))
        (+ (unquote g) (unquote g))))))

๐Ÿ“š The Auto-Loading Prelude

The standard prelude is the always-on macro layer. It loads automatically, before your code is expanded, with no import required. This is where the language-growing standard macros live, so forms like when, unless, and the threading macros are available everywhere without ceremony.

Tip

๐Ÿ’ก The prelude proves the central idea: these are not compiler features, they are ordinary NitroLISP macros that happen to ship with the language. You can write your own the same way.

๐Ÿ”€ when and unless

when runs its body when a condition is true; unless runs its body when a condition is false. Both take a multi-statement body:

(when (> 5 3)
  (set! x 10)
  (println "when ran"))

(unless (> 1 2)
  (println "unless ran"))

Because expansion is statement-aware, a when in statement position emits a real if with a braced block, not a both-arms ternary.

โžก๏ธ Threading: thread_first and thread_last

The threading macros fold a value through a sequence of steps. thread_first inserts the running value as the FIRST operand of each step; thread_last inserts it as the LAST. Since subtraction is not commutative, the two differ:

(thread_first 100 (sub 10) (sub 30))   // (sub (sub 100 10) 30) = 60
(thread_last  100 (sub 10) (sub 30))   // (sub 30 (sub 10 100))  = 120
(thread_first 7 dbl)                    // (dbl 7)               = 14
(thread_first 5 (+ 2) (* 3))            // (* (+ 5 2) 3)         = 21
Note

๐Ÿงต Clojure's -> / ->> names are not used: -> is the C++ pointer-member operator and NitroLISP admits no C/C++ operator constructs at its surface. The heads are plain identifiers, thread_first and thread_last, instead.

๐Ÿ“ฆ One Import Carries Functions and Macros

A library is one thing. A single (import name) delivers both the library's functions and its macros, there is no separate macro-import mechanism:

(module exe ImportMacroTest
  (import logging)
  (logging.loginfo "import macro ok")   // a macro from the lib
  (println "{}" (logging.logtag 3)))    // a function from the lib

Macros are module-namespaced exactly like functions. A macro defined in module logging is used qualified as logging.loginfo, just as a function would be logging.logtag. Same-module and prelude macros are used bare; cross-module macros use the qualifier. Two modules can define a macro of the same name without colliding.

Important

๐Ÿงท Imports are processed before macro expansion, so an imported library's macros are registered in time to expand the importing module. That is what lets a third party ship language extensions as an ordinary library.

NitroLISP Language Grammar

Grammar in EBNF (ISO/IEC 14977).

Notation: , concatenation, | alternation, { } zero or more,
[ ] optional, ( ) grouping, "..." literal source text. Non-terminals
are lowercase words. This file is the surface-syntax contract: it describes
what the programmer writes. Every construct is an S-expression (head ...);
the grammar dispatches on the head. There are no infix operators -- ( always
starts a new S-expression, never a grouping.

Program

program         = { directive | module } ;
module          = "(" , "module" , module kind , symbol , { module element } , ")" ;
module kind     = "exe" | "dll" | "lib" ;
module element  = directive | import | export | declaration | statement ;
import          = "(" , "import" , symbol , { symbol } , ")" ;
export          = "(" , "export" , declaration , ")" ;

Declarations

declaration     = function | variable | constant | type decl | method | macro ;

function        = "(" , "defn" , [ linkage ] , symbol , param list ,
                  [ ":" , type ] , ( body | external ) , ")" ;
linkage         = "clink" | "cpplink" ;
param list      = "(" , [ ( param , { param } , [ "..." ] ) | "..." ] , ")" ;
param           = "(" , [ "var" | "const" ] , symbol , ":" , type , ")" ;
external        = "external" , [ cstring | symbol ] ;

variable        = "(" , ( "define" | "var" ) , symbol , ":" , type ,
                  ( [ expression ] | external ) , ")" ;
constant        = "(" , "const" , symbol , [ ":" , type ] , expression , ")" ;
method          = "(" , "method" , symbol , param list , [ ":" , type ] , body , ")" ;
macro           = "(" , "defmacro" , symbol , "(" , { symbol } , [ "..." , symbol ] , ")" , body , ")" ;
lambda          = "(" , "lambda" , param list , [ ":" , type ] , body , ")" ;

Type Declarations

type decl       = "(" , "type" , symbol , type def , ")" ;
type def        = alias | record | object | overlay | choices
                | array type | pointer type | set type | routine type ;

alias           = type ;

record          = "(" , "record" , { record item } , ")" ;
record item     = "packed" | "(" , "parent" , symbol , ")" | "(" , "align" , integer , ")"
                | field | anon overlay ;
object          = "(" , "object" , { "(" , "parent" , symbol , ")" | field | method } , ")" ;
overlay         = "(" , "overlay" , { field | anon record } , ")" ;
anon overlay    = "(" , "overlay" , { field | anon record } , ")" ;
anon record     = "(" , "record" , { field } , ")" ;
field           = "(" , symbol , ":" , type , [ ":" , integer ] , ")" ;
choices         = "(" , "choices" , { symbol | "(" , symbol , expression , ")" } , ")" ;

range           = "(" , "range" , integer , integer , ")" ;
array type      = "(" , "array" , [ range ] , type , ")" ;
pointer type    = "(" , "pointer" , [ "to" ] , [ "const" ] , type , ")" ;
set type        = "(" , "set" , [ range | type ] , ")" ;
routine type    = "(" , "routine" , [ linkage ] , param list , [ ":" , type ] , ")" ;

Statements

statement       = if | cond | let | begin | while | for | repeat
                | set | return | leave | skip | match | guard
                | raise | memory | output | test | expr stmt ;

if              = "(" , "if" , expression , statement , [ statement ] , ")" ;
cond            = "(" , "cond" , { "(" , ( expression | "else" ) , body , ")" } , ")" ;
let             = "(" , "let" , "(" , { binding } , ")" , body , ")" ;
binding         = "(" , symbol , ":" , type , expression , ")" ;
begin           = "(" , "begin" , body , ")" ;
while           = "(" , "while" , expression , body , ")" ;
for             = "(" , "for" , "(" , symbol , expression , ( "to" | "downto" ) , expression , ")" , body , ")" ;
repeat          = "(" , "repeat" , "(" , "until" , expression , ")" , body , ")" ;
set             = "(" , "set!" , place , expression , ")" ;
place           = symbol | postfix ;
return          = "(" , "return" , [ expression ] , ")" ;
leave           = "(" , "leave" , ")" ;
skip            = "(" , "skip" , ")" ;
match           = "(" , "match" , expression ,
                  { "(" , "(" , { match label } , ")" , body , ")" | "(" , "else" , body , ")" } , ")" ;
match label     = expression | range ;
guard           = "(" , "guard" , body ,
                  [ "(" , "except" , body , ")" ] , [ "(" , "finally" , body , ")" ] , ")" ;
raise           = "(" , "raise" , expression , ")"
                | "(" , "raisecode" , expression , expression , ")" ;
memory          = "(" , memory op , { expression } , ")" ;
memory op       = "getmem" | "freemem" | "resizemem" | "setlength" | "create" | "destroy" ;
output          = "(" , ( "print" | "println" ) , { expression } , ")" ;
test            = "(" , "test" , cstring , body , ")"
                | "(" , test assert , { expression } , ")" ;
test assert     = "testAssert" | "testAssertTrue" | "testAssertFalse"
                | "testAssertEqualInt" | "testAssertEqualUInt" | "testAssertEqualFloat"
                | "testAssertEqualStr" | "testAssertEqualBool" | "testAssertEqualPtr"
                | "testAssertNil" | "testAssertNotNil" | "testFail" ;
expr stmt       = "(" , expression , { expression } , ")" ;

Expressions

expression      = atom | quote | quasiquote | binary | unary | logical
                | membership | intrinsic | address | varargs | pointer cast
                | lambda | if | cond | let | begin | set | output
                | call | postfix ;
quote           = "(" , "quote" , datum , ")" ;
quasiquote      = "(" , "quasiquote" , template , ")" ;
template        = atom | "(" , "unquote" , expression , ")"
                | "(" , "unquote-splicing" , expression , ")"
                | "(" , { template } , ")" ;
binary          = "(" , binary op , expression , expression , ")" ;
binary op       = "+" | "-" | "*" | "/" | "%" | "=" | "<>" | "!=" | "<" | ">" | "<=" | ">="
                | "div" | "mod" | "shl" | "shr" | "xor" ;
unary           = "(" , ( "-" | "+" ) , expression , ")" ;
logical         = "(" , "and" , expression , expression , ")"
                | "(" , "or" , expression , expression , ")"
                | "(" , "not" , expression , ")" ;
membership      = "(" , "in" , expression , expression , ")" ;
intrinsic       = "(" , "len" , expression , ")"
                | "(" , "size" , type , ")"
                | "(" , "utf8" , expression , ")"
                | "(" , "paramcount" , ")"
                | "(" , "paramstr" , expression , ")"
                | "(" , "exccode" , ")"
                | "(" , "excmsg" , ")"
                | "(" , "getmem" , expression , ")"
                | "(" , "resizemem" , expression , expression , ")" ;
address         = "(" , "address" , [ "of" ] , expression , ")" ;
varargs         = "(" , "varargs" , ( "count" | "reset" | "copy" | "next" , type ) , ")" ;
pointer cast    = "(" , "pointer" , [ "to" ] , [ "const" ] , type , expression , ")" ;
call            = "(" , expression , { expression } , ")" ;
postfix         = expression , { field access | index | deref } ;
field access    = "." , symbol ;
index           = "[" , expression , "]" ;
deref           = "^" ;
body            = { statement } ;
datum           = atom | "(" , { datum } , ")" ;

Types

type            = scalar | "string" | "wstring" | "char" | "wchar" | "pointer"
                | array type | pointer type | set type | routine type | fn type
                | qualified name ;
scalar          = "int8" | "int16" | "int32" | "int64"
                | "uint8" | "uint16" | "uint32" | "uint64"
                | "float32" | "float64" | "boolean" ;
fn type         = "(" , "fn" , "(" , { type } , ")" , ":" , type , ")" ;
qualified name  = symbol , { "." , symbol } ;

Atoms

atom            = symbol | integer | float | cstring | wstring | character
                | boolean | "nil" | "self" | "parent" | set literal ;
boolean         = "true" | "false" ;
set literal     = "[" , { set element } , "]" ;
set element     = expression | range ;

Lexical

symbol          = ( letter | "_" ) , { letter | digit | "_" } ;
integer         = ( decimal | hexadecimal ) , [ "u" ] ;
decimal         = digit , { digit } ;
hexadecimal     = ( "0x" | "0X" ) , hex digit , { hex digit } ;
float           = digit , { digit } , "." , digit , { digit } , [ ( "e" | "E" ) , [ "+" | "-" ] , digit , { digit } ] , [ "f" | "F" ] ;
cstring         = '"' , { character | escape } , '"' ;
wstring         = "w" , cstring ;
character       = "'" , ? one character ? , "'" ;
escape          = "\" , ( "n" | "t" | "r" | "0" | "\" | "'" | '"' | "x" , hex digit , hex digit ) ;
letter          = ? "A" .. "Z" | "a" .. "z" ? ;
digit           = ? "0" .. "9" ? ;
hex digit       = ? "0" .. "9" | "a" .. "f" | "A" .. "F" ? ;

Reserved Words

Keywords are reserved and cannot be used as identifiers. Every word below is
consumed by a current form: var and const are parameter modifiers (by-ref
and by-value-const) and declaration heads; routine is a function-pointer
type constructor; until is the
repeat header test. The Pascal scaffolding words do, then, end and the
type-test is are NOT reserved -- NitroLISP is committed S-expression, so they
carry no meaning and are free for use as identifiers and C++ interop names.

address   align     and       array     begin     boolean   char
choices   const     cond      create    define    defmacro  defn
destroy   div       downto    else      except    export    external
false     finally   for       freemem   getmem    guard     if
import    in        lambda    leave     len       let       match
method    mod       module    nil       not       object    of
or        overlay   packed    parent    pointer   print     println
quasiquote quote    raise     raisecode range     record    repeat
resizemem return    routine   self      set       setlength shl
shr       size      skip      to        true      type      until
utf8      var       varargs   while     xor
exccode   excmsg    paramcount paramstr

Test keywords (used inside test blocks): test, testAssert,
testAssertTrue, testAssertFalse, testAssertEqualInt,
testAssertEqualUInt, testAssertEqualFloat, testAssertEqualStr,
testAssertEqualBool, testAssertEqualPtr, testAssertNil,
testAssertNotNil, testFail.

Operators and Delimiters

operator        = ":=" | "+=" | "-=" | "*=" | "/=" | "<>" | "<=" | ">="
                | "..." | "=" | "<" | ">" | "+" | "-" | "*" | "/"
                | "^" | "|" | "&" ;
delimiter       = "(" | ")" | "[" | "]" | "," | ":" | ";" | "." ;

Comments

comment         = line comment | block comment ;
line comment    = "//" , { ? any character except newline ? } ;
block comment   = "/*" , { ? any character ? } , "*/" ;

Directives

directive       = "@" , directive name , { symbol | cstring | integer } , [ ";" ] ;
directive name  = "define" | "undef" | "ifdef" | "ifndef" | "elseif" | "else" | "endif"
                | "exeicon" | "copydll" | "linklibrary" | "librarypath" | "modulepath"
                | "includepath" | "subsystem" | "target" | "optimize" | "addverinfo"
                | "vimajor" | "viminor" | "vipatch" | "viproductname" | "videscription"
                | "vifilename" | "vicompanyname" | "vicopyright" | "breakpoint"
                | "message" | "unitTestMode" ;

Known Directive Values

Directive Value type Valid values
@target identifier win64, linux64
@optimize identifier debug, releasesafe, releasefast (alias: release), releasesmall
@subsystem identifier console, gui
@addverinfo identifier any non-empty identifier enables
@unittestmode identifier on, off
@message severity + string hint, info, note, warn, error, fatal
@breakpoint (none) (no value)
@vimajor / @viminor / @vipatch integer version numbers
@viproductname / @videscription / @vifilename / @vicompanyname / @vicopyright string version info strings
@exeicon / @copydll / @linklibrary / @librarypath / @modulepath / @includepath string file/directory paths

Predefined Conditional Symbols

Symbol Defined when
NITROLISP Always
TARGET_WIN64, WIN64, MSWINDOWS, WINDOWS Target is win64
TARGET_LINUX64, LINUX, POSIX, UNIX Target is linux64
CPUX64 Always (x64-only target)

Numeric Literal Type Rules

Literal Suffix Type
42 (none) int32
42u u unsigned (context-resolved)
0xFF (none) int32 (hex)
1.5 (none) float64 (contextual)
1.5f f / F float32

๐Ÿ› ๏ธ Tools

NitroLISP ships as one executable, NLC, that carries the whole toolchain: the compiler, the C++ build pipeline, a debugger, and a Language Server. There is nothing else to download.

๐Ÿงฐ The Compiler CLI

NLC compiles a .nls source file. The full flag set:

Flag Long form Meaning
-s --source <file> Required. The .nls source file.
-o --output <path> Output path. Defaults to output.
-r --autorun Build, then run the compiled binary.
-d --debug Build, then debug the compiled binary.
-h --help Show usage.
> NLC -s hello.nls -r

-r and -d are mutually exclusive. With no arguments, NLC prints its help.

๐Ÿ—๏ธ The C++ Build Pipeline

After NitroLISP emits C23, it builds that C with a bundled zig toolchain, no external compiler or linker required. The build is driven entirely by source directives, so a file always builds the same way.

Capability Controlled by Values
Output kind module form exe, lib, dll
Target platform @target win64, linux64
Optimization @optimize debug, releasesafe, releasefast, releasesmall
Subsystem @subsystem console, gui
Version info @vimajor, @viproductname, ... embedded version resource
Executable icon @exeicon icon resource
Native libraries @linklibrary, @librarypath link against C/C++ libraries
Headers @includepath additional C++ include paths
Preprocessor defines @define, @undef C++ -D defines
Note

๐Ÿงฑ Because the target can be win64 or linux64, NitroLISP cross-compiles: the zig toolchain produces a Linux binary from Windows. A cross-built binary is built but not auto-run on the host.

๐Ÿž The Debugger

NLC -s file.nls -d builds the program and drops into a debugging session. NitroLISP also exposes a Debug Adapter Protocol (DAP) server, so DAP-aware editors can drive the same debugger with a familiar UI.

The debugger supports:

Important

๐ŸชŸ Source-level debugging targets win64. Build for the host platform when you intend to step through code.

๐Ÿง  The Language Server (LSP)

NitroLISP includes a Language Server so any LSP-capable editor gets first-class support while editing .nls files. It implements:

Feature LSP request
Live diagnostics publishDiagnostics
Document sync didOpen / didChange / didClose
Completion completion
Hover hover
Go to definition definition
Find references references
Document symbols documentSymbol
Signature help signatureHelp
Folding ranges foldingRange
Semantic tokens semanticTokens/full
Inlay hints inlayHint
Rename rename
Code actions codeAction
Formatting formatting
Tip

๐Ÿ’ก Diagnostics carry severity (error, warning, info, hint), a code, and a source range, so editors can squiggle the exact span and surface NitroLISP's structured error codes inline.

๐Ÿ”Œ Embedding and Scripting

Note

๐Ÿšง The detailed C-ABI reference is under construction. This section explains the embedding and scripting model so you can choose the right approach today; exact function signatures will be documented in a later revision.

NitroLISP compiles ahead-of-time to C++23, which a bundled zig/clang toolchain builds into a native artifact. There are three ways to put it to work, from shipping a native component to scripting a host application at runtime.

๐Ÿงฉ 1. AOT Component (lib / dll)

Compile NitroLISP to a lib and link it at the host's build time, or compile to a dll and load it at runtime, calling its exported functions across a C ABI. This is what module lib, module dll, export, and defn clink ... external are for. The host gets a fast, native component built from NitroLISP source, a clean plugin / FFI boundary.

๐Ÿ“œ 2. An Interpreter Written in NitroLISP

For runtime scripting, write a reader, an evaluator, and an environment as a NitroLISP program, and compile it once to a small lib or dll. The host embeds that artifact and, at runtime, hands it script text and calls its eval entry point. The interpreter parses and evaluates the script in-process, so scripts are hot-reloadable and sandboxable, and the host registers its own functions as primitives so scripts can drive the application.

The scripted language is whatever that interpreter implements, naturally a Lisp given NitroLISP's S-expression DNA. This is how you script a game at runtime while the engine itself is compiled NitroLISP: both share the same S-expression surface, one AOT-compiled, one interpreted.

Tip

๐Ÿ’ก You can build your entire game in NitroLISP and still expose a runtime-scriptable Lisp to players or designers, by shipping an interpreter written in NitroLISP alongside the compiled engine.

๐ŸŽฎ 3. NitroLISP as the Primary Language

Build the whole application in NitroLISP, a full systems language with first-class C++ interop, and compile it to an executable. NitroLISP is the language you ship, top to bottom.

๐Ÿงช How-To Guide

Practical recipes built from NitroLISP's own test programs. Each compiles and runs as shown.

๐Ÿ—บ๏ธ Recipe Map

Need Recipe
๐Ÿ–จ๏ธ Output text Hello World
๐Ÿ“ฆ Store values Variables and Constants
๐Ÿšฆ Branch or loop Control Flow
๐Ÿ”ง Reuse logic Functions
๐Ÿ” Accept variable args Variadics
ฮป Use anonymous functions Lambdas and Closures
๐Ÿ“‹ Model data Records, Arrays, Choices, Sets, Overlays, Objects
๐Ÿ“ Work close to memory Pointers, Memory Management
๐ŸŒ‰ Call C/C++ C++ Interop
๐Ÿ“ฅ Import a library Modules and Imports
๐Ÿ›ก๏ธ Recover from failures Exceptions
๐Ÿช„ Transform code Macros
โžก๏ธ Thread values Threading Macros
๐Ÿ”€ Conditional compilation Conditional Compilation
๐Ÿงช Verify behavior Unit Testing
Tip

๐Ÿ’ก Run any program straight from source with NLC -s file.nls -r. Add -d instead to build and step through it in the debugger.

๐Ÿ‘‹ Hello World

#include <cstdio>;

(module exe HelloWorld
  (println "Hello, World!")
  (printf "C++ passthrough: 2 + 3 = %d\n" (+ 2 3)))

๐Ÿ“ฆ Variables and Constants

(module exe Vars
  (const MAX : int32 100)
  (define count : int32 0)
  (define name : string "NitroLISP")
  (set! count (+ count 1))
  (println "name={} count={} max={}" name count MAX))

๐Ÿšฆ Control Flow

If/Else

(module exe FlowIf
  (define x : int32 42)
  (if (> x 0)
    (println "positive")
    (println "non-positive")))

While Loop

(module exe FlowWhile
  (define i : int32 0)
  (while (< i 5)
    (println "i = {}" i)
    (set! i (+ i 1))))

For Loop

(module exe FlowFor
  (for (i 0 to 4)
    (println "up: {}" i))
  (for (i 4 downto 0)
    (println "down: {}" i)))

Repeat/Until

(module exe FlowRepeat
  (define n : int32 1)
  (repeat (until (> n 100))
    (println "{}" n)
    (set! n (* n 2))))

Match

(module exe FlowMatch
  (define day : int32 3)
  (match day
    ((1)         (println "Monday"))
    ((2)         (println "Tuesday"))
    ((3)         (println "Wednesday"))
    (((range 4 5)) (println "Thu/Fri"))
    (((range 6 7)) (println "Weekend"))
    (else        (println "Unknown"))))

๐Ÿ”ง Functions

(module exe Routines
  (defn add ((a : int32) (b : int32)) : int32
    (return (+ a b)))

  (defn factorial ((n : int32)) : int32
    (define result : int32 1)
    (for (i 2 to n)
      (set! result (* result i)))
    (return result))

  (println "3 + 4 = {}" (add 3 4))
  (println "5! = {}" (factorial 5)))

๐Ÿ” Variadics

(module exe Variadics
  (defn sumInts (...) : int32
    (define total : int32 0)
    (define i : int32 0)
    (for (i 0 to (- (varargs count) 1))
      (set! total (+ total (varargs next int32))))
    (return total))
  (println "{}" (sumInts 10 20 30)))   // 60

ฮป Lambdas and Closures

(module exe Lambdas
  (defn apply1 ((f : (fn (int32) : int32)) (x : int32)) : int32
    (return (f x)))

  // lambda bound to a variable
  (define square : (fn (int32) : int32)
    (lambda ((n : int32)) : int32 (* n n)))

  (println "square(5) = {}" (apply1 square 5))

  // closure that captures a local
  (define adder5 : (fn (int32) : int32)
    (let ((k : int32 5))
      (lambda ((n : int32)) : int32 (+ n k))))

  (println "adder5(10) = {}" (adder5 10)))   // 15

๐Ÿ“‹ Records

(module exe Records
  (type Customer (record
    (name    : string)
    (email   : string)
    (balance : float64)
    (active  : boolean)))

  (define c : Customer)
  (set! c.name "Ada Lovelace")
  (set! c.email "ada@example.com")
  (set! c.balance 250.0)
  (set! c.active true)
  (println "name    = {}" c.name)
  (println "balance = {}" c.balance))

Record Inheritance

(module exe RecInherit
  (type Shape (record (x : int32) (y : int32)))
  (type Circle (record (parent Shape) (radius : float32)))

  (define c : Circle)
  (set! c.x 100)
  (set! c.y 200)
  (set! c.radius 50.0)
  (println "Circle at ({}, {}) radius {}" c.x c.y c.radius))

Packed Records and Bitfields

(module exe Packed
  (type Flags (record packed
    (visible  : uint8 : 1)
    (enabled  : uint8 : 1)
    (priority : uint8 : 3)
    (reserved : uint8 : 3)))

  (println "Flags size: {}" (size Flags)))   // 1 byte

๐Ÿ“š Arrays

(module exe Arrays
  (type Grid (array (range 0 4) int32))
  (define nums : Grid)
  (for (i 0 to 4)
    (set! nums[i] (* i i)))
  (for (i 0 to 4)
    (println "nums[{}] = {}" i nums[i])))

๐ŸŽ›๏ธ Choices

(module exe Choices
  (type Color (choices (Red 0) (Green 1) (Blue 2)))
  (define c : Color)
  (set! c Color.Green)
  (println "color value: {}" c))

๐Ÿงฎ Sets

(module exe Sets
  (define s1 : set [1 3 (range 5 9)])
  (if (in 7 s1) (println "7 in set") (println "7 NOT in set"))
  (if (in 4 s1) (println "4 in set") (println "4 NOT in set")))

๐ŸงŠ Overlays

(module exe Overlays
  (type Value (overlay
    (as_int   : int32)
    (as_float : float32)))

  (define v : Value)
  (set! v.as_int 42)
  (println "as int: {}" v.as_int)
  (println "size: {} bytes" (size Value)))

๐Ÿ›๏ธ Objects

(module exe Objects
  (type TCounter (object
    (value : int32)
    (method increment () : void
      (set! self.value (+ self.value 1)))
    (method get_value () : int32
      (return self.value))))

  (define c : (pointer to TCounter))
  (create c)
  (set! c.value 0)
  (c.increment)
  (c.increment)
  (c.increment)
  (println "count: {}" (c.get_value))   // 3
  (destroy c))

Object Inheritance

(module exe ObjInherit
  (type TBase (object
    (x : int32)
    (method describe () : int32
      (return (* self.x 10)))))

  (type TDerived (object (parent TBase)
    (y : int32)
    (method describe () : int32
      (return (+ (parent.describe) self.y)))))

  (define d : (pointer to TDerived))
  (create d)
  (set! d.x 7)
  (set! d.y 3)
  (println "describe: {}" (d.describe))   // 73
  (destroy d))

๐Ÿ“ Pointers

(module exe Pointers
  (define x : int32 42)
  (define p : pointer (address x))
  (println "value: {}" p^)    // 42
  (set! p^ 100)
  (println "x is now: {}" x)) // 100

๐Ÿง  Memory Management

(module exe Memory
  (type TData (record (value : int32)))
  (define p : (pointer to TData))
  (create p)
  (set! p^.value 99)
  (println "value: {}" p^.value)
  (destroy p))

๐ŸŒ‰ C++ Interop

#include <cstdio>;

(module exe InteropDemo
  (printf "factorial says hi: %d\n" (* 6 7)))

๐Ÿ“ฅ Modules and Imports

A library is a lib module whose exported declarations are visible to importers. Its declared name must match its filename.

// testlib.nls
(module lib testlib
  (export (defn lib_triple ((n : int32)) : int32
    (return (* n 3)))))
// main.nls
(module exe ImportTest
  (import testlib)
  (printf "lib_triple(7) = %d\n" (testlib.lib_triple 7)))

๐Ÿ›ก๏ธ Exceptions

(module exe Exceptions
  // guard/except with raise
  (guard
    (raise "plain boom")
    (except
      (println "caught: code={} msg={}" (exccode) (excmsg))))

  // raisecode with explicit code
  (guard
    (raisecode 42 "coded boom")
    (except
      (println "caught: code={} msg={}" (exccode) (excmsg))))

  // guard/finally (cleanup runs always)
  (guard
    (println "body runs")
    (finally
      (println "cleanup runs always"))))

๐Ÿช„ Macros

(module exe MacroDemo
  (defmacro square (x)
    (quasiquote (* (unquote x) (unquote x))))

  (println "{}" (square 5))          // 25
  (println "{}" (square (+ 2 1))))   // (* (+ 2 1) (+ 2 1))

โžก๏ธ Threading Macros

(module exe ThreadDemo
  (defn sub ((a : int32) (b : int32)) : int32
    (return (- a b)))

  (println "{}" (thread_first 100 (sub 10) (sub 30)))  // 60
  (println "{}" (thread_last  100 (sub 10) (sub 30)))) // 120

๐Ÿ”€ Conditional Compilation

@define VERBOSE;

(module exe Conditional
  @ifdef VERBOSE
    (println "verbose mode is on")
  @endif

  @ifdef WIN64
    (println "running on 64-bit Windows")
  @endif)

๐Ÿงช Unit Testing

@unittestmode on;

(module exe Tests
  (defn add ((a : int32) (b : int32)) : int32
    (return (+ a b)))

  (defn is_even ((n : int32)) : boolean
    (return (= (mod n 2) 0)))

  (test "addition"
    (testAssertEqualInt 5 (add 2 3))
    (testAssertEqualInt 0 (add -1 1)))

  (test "even check"
    (testAssertTrue (is_even 0))
    (testAssertTrue (is_even 42))
    (testAssertFalse (is_even 7)))

  (test "comparisons"
    (testAssertTrue (> 10 5))
    (testAssertTrue (<= 5 5))
    (testAssertTrue (= 42 42))
    (testAssertTrue (<> 42 99))))

โš™๏ธ Build Modes

Build settings are directives at the top of the file:

@target win64;
@optimize debug;
@subsystem console;

(module exe Fib
  (defn fib ((n : int32)) : int32
    (return (if (< n 2)
                n
                (+ (fib (- n 1)) (fib (- n 2))))))
  (println "fib(30) = {}" (fib 30)))

Switch @target to linux64 to cross-compile. Use releasesafe, releasefast, or releasesmall for optimized builds.


๐Ÿš€ NitroLISPโ„ข - Lisp at the speed of thought.

Copyright ยฉ 2026-present tinyBigGAMESโ„ข LLC
All Rights Reserved.