npl

Extending NPL

Status: frozen at the language milestone, 2026-11-12. The four ways to extend the language and its libraries, in increasing order of cost. “Relay needed” means tools/update_current_ssa.sh (see testing).

You want to add Touch Relay
a library (most SRFIs, helpers) lib/ no
a C primitive used by a library lib/ + a C file no
a binding to an existing C library program source + C no
derived syntax available without imports src/compiler/frontend/ yes
a runtime primitive needed by the compiler or (scheme base) runtime/ + primitive-table.npl yes

1. A library

A library is one file, lib/<prefix>/<name>.sld, containing exactly one define-library whose name matches the path:

; lib/srfi/42.sld
(define-library (srfi 42)
  (export every fold)
  (import (scheme base))
  (include "42.scm"))       ; helpers, relative to this file

Rules:

Library layers and dependency direction

Use the library graph, not directory labels, to keep dependencies acyclic:

Layer Libraries May import
L0 runtime (npl internal primitive) nothing
L1 core (npl internal frontend), (npl internal repl-io) L0
L2 standard (scheme *) and (scheme internal *) L0, L1, lower (scheme *)
L3 extensions (npl list), (npl string), (npl string cursor), (npl string immutable), (npl vector), (npl hashtable), (npl data ...), (npl ffi), (npl match), (npl record), (npl process) L0-L2, lower L3
L4 SRFI aliases (srfi *) L3 only

Cover the behavior with a fixture or a compiler suite (testing). A library change needs no relay.

2. A C primitive (a new runtime function for a library)

(define-c-primitive (string-trim-left (s string) (chars string)) string)

The C side and the full type list are in C Functions From NPL.

When a primitive belongs in the shared table instead

src/compiler/frontend/primitive-table.npl is the compiler’s own registry. Add a row there only when the primitive is needed before any import: by the compiler itself, by (scheme base)’s implementation, by derived syntax, records, or hash tables. Those rows are exported by (npl internal primitive), participate in control conversion (every row is synchronous), and changing them changes the seed, so they need a relay. Everything else — anything a user program or an ordinary library can declare — belongs in define-c-primitive.

3. A binding to an existing C library

Use level A (NplVal ABI, hand-written against include/npl/npl.h) for full control, or level B (define-c-function) for plain C types:

(define-c-function strlen "strlen" (string) -> size_t)
(nplc prog.scm -L . -l mylib -o prog)

Stubs are generated by nplc for executables, and by nplc --emit=c-stubs prog.scm -o stubs.c for npli; npli -l ./libmylib.so loads a shared library at run time. Pointers are boxed addresses the collector never follows or frees; (npl ffi) provides with-resource for release (exactly once, including on raise, continuation escape, and exit).

This needs no compiler change and no relay. Complete reference, including pointer-out, multiple values, and the portability limits: C Functions From NPL.

4. Derived syntax (macros available without imports)

Two levels, both in src/compiler/frontend/:

A syntax-rules macro — add a define-syntax to the string DERIVED-SYNTAX-SOURCE in derived.npl, or to MATCH-SYNTAX_SOURCE in derived-match.npl (both are parsed at expansion startup by expand-derived-syntax). Keywords are pre-bound before any rule is parsed, so a macro may use one defined later in the string. This is how cond, guard, define-record-type, parameterize, and match are defined.

An expansion-time core form — when syntax-rules cannot do the work (count items, classify a datum, or produce a definition that must reach the enclosing body), add a handler in the expand-keyword-or-application chain in expand.npl, implement it beside the existing ones (expand-record.npl, expand-match.npl), and have the macro call it. Existing forms: %record-index, %record-make, %match-length, %match-literal, %match-identifier?, %match-member?, %match-ellipsis?, %c-call, %c-procedure.

Export it from (npl internal primitive) — add the keyword to PRIMITIVE-MACRO-SOURCE in library-primitives.npl, otherwise (import (npl internal primitive)) and any (npl …) shim fails with missing primitive syntax binding. Optionally add a shim library (lib/npl/match.sld) and a fixture.

Ordering constraint. npli compiles the frontend as the (npl internal frontend) library, and in a library body a name produced by macro output is not bound before the macro use. So a record type — or anything a macro defines — must appear before its first use in the frontend sources. This is why syntax-transformer is defined above parse-syntax-rules (syntax.npl); nplc’s self-compile passes either way, but tools/build_toolchain.sh fails if the order is wrong.

Derived-syntax changes are compiler-source changes: relay, and a test that fails on the previous seed (testing).

What not to do