No description
  • C 70.8%
  • JavaScript 13%
  • Python 12.2%
  • POV-Ray SDL 1.5%
  • HTML 1.3%
  • Other 1.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-11-06 10:22:27 +00:00
.claude Find the releases' stdlib under tklips/tklips-lang, where the repository moved 2026-11-05 21:57:18 +00:00
bench Check again only the fired activations a change concerns, and index literal fact lookups 2026-11-05 19:56:17 +00:00
include/tklips Find the releases' stdlib under tklips/tklips-lang, where the repository moved 2026-11-05 21:57:18 +00:00
openspec Split the priority requirements out of the ordering ones, to pass strict validation 2026-11-06 10:22:27 +00:00
py Find the releases' stdlib under tklips/tklips-lang, where the repository moved 2026-11-05 21:57:18 +00:00
src Order rules no declaration relates by (*priority* n) 2026-11-06 10:21:31 +00:00
stdlib Draw random numbers from a replayable seed, keyed draws for rules, and *chance* conditions 2026-11-03 23:19:49 +00:00
tests Order rules no declaration relates by (*priority* n) 2026-11-06 10:21:31 +00:00
third_party/linenoise Add the interactive shell: a REPL on a terminal or with -i 2026-10-21 20:05:59 +00:00
tools Build release binaries against musl: five times smaller than static glibc, with tools/musl-cc driving clang against musl's headers and libc 2026-10-22 20:25:35 +00:00
web Find the releases' stdlib under tklips/tklips-lang, where the repository moved 2026-11-05 21:57:18 +00:00
.gitignore Read and write files, URLs and standard streams through scoped resource ids 2026-11-02 20:13:31 +00:00
AGENTS.md scaffold 2026-10-21 20:05:59 +00:00
CLAUDE.md scaffold 2026-10-21 20:05:59 +00:00
Makefile Describe functions with ? and func-info, documented by the comments above them 2026-11-02 16:58:59 +00:00
README.md Order rules no declaration relates by (*priority* n) 2026-11-06 10:21:31 +00:00

tklips

A CLIPS-inspired rule language, designed incrementally. The engine is written in C23 and reads code from stdin.

Requirements

  • A C23 compiler (gcc 15 or recent clang)
  • GNU make
  • bash (for the test runner)
  • Node.js (only to run the OpenSpec CLI)
  • Python 3.10+ with the wasmtime package (only for the Python module)

Build and run

make                       # builds build/tklips
./build/tklips < program   # runs a program from stdin
./build/tklips             # interactive shell, when stdin is a terminal
./build/tklips -i          # interactive shell, even on piped input
./build/tklips main.tk     # runs the program file main.tk instead of stdin
./build/tklips -i main.tk  # runs main.tk, then opens the shell
./build/tklips --allow-url main.tk   # lets sources be required from http(s) URLs
./build/tklips --stdlib ~/tklips/stdlib   # takes the standard library from there
./build/tklips --seed 42 main.tk   # starts random draws from the seed 42
make clean

Releases ship a static binary built against musl, which needs musl's headers and static libc (musl-tools on Debian and Ubuntu):

make BUILD=build/release CC=tools/musl-cc \
     STDLIB=https://code.thunderk.net/tklips/tklips-lang/raw/tag/v1.2.3/stdlib/ \
     CFLAGS='-std=c23 -Wall -Wextra -Wpedantic -O2' LDFLAGS=-s

STDLIB is where the binary and the module look for the standard library by default: the repository's stdlib/ directory unless set, the release's tagged copy in a release.

The engine also compiles to WebAssembly, packaged as a JavaScript module and a web REPL built on it. It needs clang with the wasm32 target, wasi-libc and Node.js:

make js                    # builds build/web/tklips.mjs, a self-contained ES module
make web                   # builds build/web/tklips.html, the REPL page
make test-web              # runs the test suites against the module
make py                    # builds build/py/tklips.py, the same engine in one Python file
make test-py               # runs the test suites against the Python module

make bench times the programs of bench/ with an optimized build of its own, for information.

Set WASM_CC to pick the clang (for example WASM_CC=clang-19), and WASI_FLAGS when wasi-libc is not installed under /usr.

The interactive shell prompts with tklips> , and with ...> while a form spans several lines. It evaluates each form as soon as it is read and keeps going after errors. Lines can be edited and recalled with the arrow keys; Ctrl-C drops the form being typed, or interrupts the one being evaluated, and Ctrl-D ends the session. A result holding nested objects prints one entry per line, indented; batch mode keeps every result on one line, and (pprint value) writes a value in that indented form anywhere.

(? name) writes the help of a function: its signature, what it does, and where a user function was defined. It evaluates none of its arguments and ignores those after the first, so a call being written gets help with ? at its head; (?) lists every function. The # lines right above a top-level func are its doc, and func-info gives the same as an object:

tklips> (require (std text))
_
tklips> (? text-pad-left 42 5)
(text-pad-left s width (opt fill " "))
  `s` padded on the left with copies of `fill` up to `width` characters;
  `s` itself when it is that long already.
  defined at std/text.tk:19
_
tklips> # The double of x.
tklips> (func twice x (* &x 2))
_
tklips> (func-info twice)
(obj
  (.name twice)
  (.kind func)
  (.params (obj
    (obj
      (.name x)
      (.required 1))))
  (.doc "The double of x.")
  (.line 4))

From JavaScript

Releases ship tklips-<version>.mjs, one ES module with the engine inside, for Node 18+ and browsers:

import { createRuntime, TklipsFunction } from "./tklips.mjs";

const rt = await createRuntime({ stdout: (text) => console.log(text.trimEnd()) });
await rt.eval("(func greet name (join Hello \", \" &name))");
await rt.call("greet", "Ann");                          // → "Hello, Ann"
await rt.apply("join", { 0: "a", 1: "b", with: "-" }); // → "a-b", named arguments
await rt.eval("(fact-add person (.name Bob))");
await rt.eval("(rule hi (person .name) > (print (greet &name)))");
await rt.run();                                        // prints Hello, Bob → 1 firing
await rt.eval("(fact-get 1)");                          // → { name: "Bob", id: 1 }
await rt.call("for-each", [1, 2], new TklipsFunction("print"));

Each runtime has its own state. The methods that evaluate (eval, call, apply, run, runProgram, and a session's feed and end) return Promises; define, undefine and reset are synchronous. Values convert both ways: numbers (or BigInt past 2^53), strings, undefined for _, arrays or plain objects for objects. Errors reject with a TklipsError holding the shell's message, and the error's code, data and trace. rt.session() gives an interactive session like the shell's, and rt.runProgram(source) runs a program as batch mode does.

JavaScript functions can be called from tklips, as built-ins:

rt.define("upper", (s) => s.toUpperCase());
await rt.eval("(upper (join a b))");                    // → "AB"
rt.define("label", (n, { unit = "ms" }) => `${n} ${unit}`, { named: true });
await rt.eval("(label 250)");                           // → "250 ms"
await rt.eval("(label 3 (.unit s))");                   // → "3 s"
rt.define("fetch-text", async (url) => (await fetch(url)).text());
await rt.eval("(str-len (fetch-text https://example.org/))"); // waits for the page
rt.define("jsmap", async (xs, f) => Promise.all(xs.map((x) => rt.call(f, x))));
await rt.eval("(jsmap (obj 1 2) (lambda x (* &x 10)))");  // → [10, 20]

Positional arguments arrive as parameters, and named ones, with { named: true }, as a last object. A function may return a Promise: the evaluation waits for it where the call stands, in a function body, a map or a rule alike, and goes on with what it resolves to; one that never settles leaves the evaluation, and the method that started it, pending. A thrown error or a rejection becomes the tklips error <name>: <message>, with the original as the cause of the TklipsError; its code is js/error, unless the thrown value has a code, as the Error that tklipsFail(code, message, data) returns does, and then displays first, as for any error: 1:1: not-found: lookup: no user. A TklipsError thrown on, as rt.call or rt.eval rejects with, is raised again unchanged instead, as (fail e) raises an error object: same code, message, data and position. A function may call back into its runtime, also while its Promise is pending; rt.reset() keeps JavaScript functions, and rt.undefine(name) removes one.

An evaluation lets the event loop run every few tens of milliseconds, so a long one doesn't freeze a page, and rt.interrupt() interrupts what the runtime evaluates, as Ctrl-C does in the shell, a JavaScript function's pending Promise included, whose result is then ignored:

setTimeout(() => rt.interrupt(), 100);
await rt.eval("(while 1 1)");                           // rejects: 1:1: interrupted

(require source) reads sources through two options, which may return Promises: resolve(path, base) gives { id, name } for a path written in the source whose id is base (undefined in the evaluated source), and read(id) gives its text, a string or a Uint8Array:

const files = { "lib.tk": "(func twice x (* &x 2))" };
const rt2 = await createRuntime({
  resolve: (path) => ({ id: path, name: path }),
  read: (id) => files[id],
});
await rt2.eval("(require lib.tk) (twice 21)");          // → 42

By default, paths are URL references against the requiring source, or else the page's URL in a browser and the current directory in Node; Node reads file: URLs inside the current directory from disk, and anything else is fetched: in Node only with allowUrl: true, as the binary needs --allow-url, and in a browser unless allowUrl is false. The standard library is read wherever it is. An error in an imported source is located there, its TklipsError naming it in source. The stdlib option says where the standard library is, a URL or a path resolved like a require path:

const rt3 = await createRuntime({ stdlib: "https://example.org/tklips/stdlib/" });
await rt3.eval("(std text)");               // → "https://example.org/tklips/stdlib/text.tk"

The seed option, an integer Number or a BigInt, starts random draws from that seed, and reset goes back to it; without it, each runtime and each reset start from a fresh seed.

io-open opens resources through the open(path, mode) option, which returns an object whose read(max) gives a string, a Uint8Array or null at the end, whose write(bytes) takes a Uint8Array, and whose close() closes it; any of them may return a Promise. By default, Node opens files inside the current directory and, with allowUrl, fetches URLs; a browser fetches every path, relative to the page. The stdin option, stdin(max), gives what stream 0 reads: Node's standard input by default, nothing in a browser. Stream 2 goes to stderr.

const log = [];
const rt4 = await createRuntime({
  open: (path, mode) => ({ read: () => null, write: (b) => log.push(b), close() {} }),
  stdin: () => "a line\n",
});
await rt4.eval("(io-read-line 0)");                     // → "a line"

From Python

Releases ship tklips-<version>.py, one Python file with the engine inside, for Python 3.10+ with pip install wasmtime:

from tklips import Runtime, TklipsFunction

rt = Runtime()
rt.eval('(func greet name (join Hello ", " &name))')
rt.call("greet", "Ann")                          # → "Hello, Ann"
rt.call("join", "a", "b", **{"with": "-"})       # → "a-b", named arguments
rt.eval("(fact-add person (.name Bob))")
rt.eval("(rule hi (person .name) > (print (greet &name)))")
rt.run()                                         # prints Hello, Bob → 1 firing
rt.eval("(fact-get 1)")                          # → {"name": "Bob", "id": 1}
rt.call("for-each", [1, 2], TklipsFunction("print"))

The API is the JavaScript module's, in Python form: eval, call, apply, run, run_program, reset, session(), define, undefine and interrupt, and the options stdout, stderr, functions, resolve, read, stdlib, allow_url, open, stdin and seed, as keyword arguments. A Runtime runs each method to its end; an AsyncRuntime makes the evaluating ones coroutines, and its Python functions may be async. Every tklips object is a TklipsObject, a dict with int keys for positions and str keys for names, whose to_list() gives the positions' values; integers, floats, texts and None for _ convert as themselves. Errors raise a TklipsError, with the shell's message and the error's code, data and trace.

rt.define("upper", str.upper)
rt.eval("(upper (join a b))")                    # → "AB"
rt.define("label", lambda n, unit="ms": f"{n} {unit}", named=True)
rt.eval("(label 3 (.unit s))")                   # → "3 s"
rt.define("pymap", lambda xs, f: [rt.call(f, x) for x in xs.to_list()])
rt.eval("(pymap (obj 1 2) (lambda x (* &x 10)))")  # → {0: 10, 1: 20}

A Python function's exception becomes the tklips error <name>: <message>, of the code py/error unless the exception has a code, as the one fail(code, message, **data) returns does, displayed then before the message; a TklipsError is raised again unchanged. rt.interrupt() works from any thread, and Ctrl-C during an evaluation of a Runtime is an interrupt, raised, if nothing catches it, as a TklipsInterrupt, also a KeyboardInterrupt. A call from another thread waits for the evaluation in progress: a Python function that waits for another thread calling its runtime never returns. URLs are required only with Runtime(allow_url=True), as the binary needs --allow-url.

Run as a script, python tklips.py behaves as the binary does.

A taste of the language

(fact-add person (.name Ann) (.age 20))
(fact-add person (.name Bob) (.age 12))

(rule adult
  (person .id .name .age (>= &age 18))
  >
  (print (join &name " is an adult"))
  (fact-change &id (.adult 1)))

(run)                                  # Ann is an adult → 1 firing
(fact-match person .adult (= &adult 1))   # → (obj 1)

(rule minor
  (person .name)
  (*none* (person .name .adult (= &adult 1)))
  >
  (print (join &name " is a minor")))

(run)                                  # Bob is a minor → 1 firing

Facts are a relation and an object body, with an implicit .id. Patterns capture entries into variables, by position (name) or by name ((.age a), or .age for (.age age)), filter with predicates ((>= &age 18), or (= &adult 1) for a value), and join when a name is captured twice. Marked conditions say that no facts match (*none*), that some do (*some*), that every match of one pattern has a match of others (*every*), or that one of several alternatives holds (*either*); *collect* gathers a value from each match of its conditions. A rule fires once per combination of matching facts, and again if a value it captured changes, or once its conditions stopped holding and hold again (unless marked *persist*).

A test, (*test* expr...), is a condition that holds when its expressions are true, with the captures of the conditions before it: a comparison between patterns, an alternative of *either*, or what every match of *every* must satisfy. Elsewhere, a predicate in a pattern does the job.

(rule bigger (p x) (q y) (*test* (> &x &y)) > (print (join &x ">" &y)))
(rule light (box (.id b)) (*every* (item (.box b) .w) (*test* (< &w 10)))
  > (print (join "box " &b " is light")))
(fact-add p 5) (fact-add q 3) (fact-add q 7)
(fact-add box) (fact-add item (.box 4) (.w 3))
(run)                                  # 5>3, box 4 is light → 2 firings

Predicates and tests depend only on the captures they see: their result may be reused while the facts those captures come from are unchanged, so how often they run is unspecified. Each rule keeps its pending activations, brought up to date from the facts changed since: run does so in rule order, only as far as it needs, so a rule's conditions are evaluated once every rule before it has nothing pending, and rule-agenda for the rules it lists. They have no access to facts or rules: the fact built-ins, run, run-prepare, rule-agenda and rule fail there, also through the functions they call, with not allowed in a rule condition. A condition on other facts is a pattern or a marked condition, and a test takes no *persist* or *track*, which could change nothing:

(rule r (p x) (*test* (= (fact-find q) (obj))) > 1)
(fact-add p 1)
(run)                                  # error: 1:26: fact-find: not allowed in a rule condition
(rule r (p x) (*none* (q)) > (print &x))
(run)                                  # 1 → 1 firing

Rules fire in definition order. Written among a rule's conditions, (*after* name...) and (*before* name...) put it after or before other rules whenever both have activations pending; neither waits for the other to fire. A declaration may name a rule defined later, so a misspelled name orders nothing. (*priority* n) orders a rule among those that no such declaration relates to it: higher priorities first, 0 by default, so that a library's end-of-turn rule can come after the rules of the modules that require it:

(rule turn-end (*priority* -1000) (turn .id) > (fact-del &id))
(rule play (turn) > (print play))
(fact-add turn)
(run)                                  # play, then the turn ends → 2 firings

A condition marked *track*, or every condition when the rule holds (*track*), supports the facts a firing creates: once one of its tracked conditions stops holding, the next run removes them, as CLIPS does with logical. Facts the firing changes are left as they are.

(rule late
  (order (.id o))
  (*none* (shipment (.order o)))
  (*track*)
  >
  (fact-add alert (.order &o)))

(fact-add order)
(run)                                  # → alert 2 is added
(fact-add shipment (.order 1))
(run)                                  # → alert 2 is removed, 0 firings

(*collect* (name expr) c...) always holds, binding name to an object of the values of expr, one for each way facts satisfy c..., in fact order, or (obj) when none do. expr is evaluated like a test, with the captures of c... and those before; the captures of c... stay inside, and name must not be visible already. The collected object is a capture like any other, so the rule fires again when it changes, which keeps an aggregate fact up to date; spreads and len reduce it:

(rule total
  (order (.id o))
  (*collect* (prices &p) (line p (.order o)))
  >
  (fact total (.order &o) > (.sum (+ *prices)) (.lines (len &prices))))

(fact-add order)
(fact-add line 5 (.order 1))
(fact-add line 3 (.order 1))
(run)                                  # → total 4: (.sum 8) (.lines 2)
(fact-del 2)
(run)                                  # → total 4: (.sum 3) (.lines 1)
(rule r (p xs) (*collect* (xs &v) (q v)) > 1)   # error: rule: already captured xs

Rules can be inspected. An activation shows as (obj (.rule r) (.facts ids) (.captures values)): rule-agenda lists the pending ones in the order run would fire them, changing nothing, rule-fired those each rule remembers having fired, rule-owned the facts tracked firings own, and (rule-firing), in actions, the one being fired. (run-prepare) does what run does before choosing, removing unsupported owned facts and forgetting activations that no longer hold, and says what it did. rule-stats counts, per rule, its firings, the facts tried, the predicates evaluated and the fired activations checked again. The first three take rule names and a .limit:

(rule greet (person .name) > (print (join "Hi " &name)))
(fact-add person (.name Ann))
(rule-agenda)       # → (obj (obj (.rule greet) (.facts (obj 1)) (.captures (obj (.name Ann)))))
(run)               # Hi Ann → 1 firing
(fact-del 1)
(run-prepare)       # → (obj (.removed (obj)) (.forgotten (obj (obj (.rule greet) ...))))
(. (rule-stats greet) 0 fired)         # → 1

Functions are values: &name gives one and lambda makes one. A call whose head is &name calls what name holds, a function value or the name of a function:

(func twice f x (&f (&f &x)))
(twice (lambda n (* &n 2)) 3)          # → 12
(set show pprint)
(&show (obj (.a (obj 1))))             # writes it as pprint does

call calls any function value, map applies one to each item of an object, compose and pipe chain functions, and partial fixes some of their arguments. Wherever a function is expected, its name works too:

(set inc (lambda x (+ &x 1)))
(call (compose &inc (partial &* 10)) 4)        # → 41
(map (obj 1 2 3) (pipe &inc &inc))             # → (obj 3 4 5)
(map (obj Ann Bob) (partial join "Hi "))       # → (obj "Hi Ann" "Hi Bob")

Arguments bind to a user function's parameters by name, then by position, and a function without a rest parameter rejects arguments it has no parameter for. Built-ins whose arguments have roles worth naming take them the same way: str-sub, str-find, str-replace, str-repeat, slice, chunk, repeat, reduce, sort, the higher-order collection built-ins (obj and fn), log, atan, std and run; range takes its step as .step:

(func greet who (opt how Hi) (join &how " " &who))
(greet (.how Bye) Ann)                         # → "Bye Ann"
(greet Ann Bye extra)                          # error: greet: expected at most 2 arguments, got 3
(str-sub hello (.end 2))                       # → he
(reduce (obj 1 2 3) &+ (.init 10))             # → 16
(map (obj (obj 1 3) (obj 2 5)) (partial sort (.cmp (lambda a b (cmp &b &a)))))
                                               # → (obj (obj 3 1) (obj 5 2))
(range 10 (.step 3))                           # → (obj 0 3 6 9)
(str-repeat (.count 2))                        # error: str-repeat: missing argument text

The str- built-ins work on texts only, counting characters, not bytes; join and str turn any value into text, and num reads a number back:

(set line "  Ann,Bob ,  Çan ")
(map (str-split &line (.with ",")) &str-trim)  # → (obj Ann Bob Çan)
(str-sub "Çan" 0 2)                            # → Ça
(str-find "a-b-c" - 2)                         # → 3
(str-replace "a-b-c" - " " (.max 1))           # → "a b-c"
(str-cmp Ann Bob)                              # → -1, as (cmp 1 2)
(num (str-sub "x42" 1))                        # → 42
(str-len 42)                                   # error: str-len: not a text: 42

kind names the kind of any value, and is-int, is-number, is-str, is-obj... test for one. Besides + - * /, numbers have rem and mod, rounding to integers, abs, min, max, pow and the usual float functions:

(map (obj 1 2.5 a (obj) &print) &kind)        # → (obj int float str obj func)
(if (is-str &line) (str-len &line) 0)          # → 17
(mod -7 3)                                     # → 2, where (rem -7 3) → -1
(round 2.5)                                    # → 3
(max 1 2.5 -4)                                 # → 2.5
(pow 2 62)                                     # → 4611686018427387904
(* 2 (asin 1))                                 # → 3.141592653589793
(nears (log 1000 10) 3)                        # → 1, to 10 decimal places
(nears 3.14159 &PI (.digits 4))                # → 1
(sqrt -1)                                      # error: sqrt: not a real number

Random draws come from a generator seeded for each run: (random) gives a float in [0, 1), (random end) and (random start end) a number in [start, end), an integer unless a bound is a float, and shuffle the items of an object in a random order. (seed n) replays the draws of a seed, (seed) sets a fresh one and returns it, and (seed-get) gives the seed in effect, to log it. With a .key, a draw is fixed by its key and the seed, and leaves the generator as it was. The random library picks and samples items, keyed or not. The generators are xoshiro256** and SipHash-1-3, the same on every host: they are not cryptographic.

(seed 42)
(map (range 6) (lambda i (random 1 7)))        # → (obj 1 3 5 6 6 5)
(random 2.5)                                   # → 1.798146444694789, a float in [0, 2.5)
(shuffle (obj a b c d))                        # → (obj a b c d), this time
(random 100 (.key (obj 3 ann)))                # → 31, for this key and seed, every time
(random 0)                                     # error: random: empty range
(require (std random))
(random-pick (obj Ann Bob Cy))                 # → Bob
(random-sample (range 10) 3)                   # → (obj 1 0 7)

A rule's conditions cannot draw from the generator, which would make them depend on how often they are evaluated: random and shuffle without a key, and seed, fail there with not allowed in a rule condition. (*chance* p key...) holds with probability p, the draw keyed by the rule's name and the keys, or, with none written, every capture visible there. A rule then fires on random days, each couple drawing on its own each day; a new seed draws every condition again:

(rule pregnancy
  (day .n) (couple f m) (*none* (pregnant f))
  (*chance* 0.01)                              # keyed by pregnancy, n, f and m
  >
  (fact-add pregnant &f &m (+ &n 100)))
(rule r (p x (< (random) 0.5)) > 1)            # error at run: random: not allowed in a rule condition

(const NAME value) defines a constant at top level: its name is upper case, it cannot be defined again, and &NAME reads it everywhere, function bodies and rules included, unless a parameter or a local variable of that name shadows it. PI, TAU, E, INT-MAX, INT-MIN, STDIN, STDOUT and STDERR are built in, and (const-list) gives them all with their values; the # lines right above a top-level const are its doc, which (? NAME) writes:

# Seconds between two tries.
(const DELAY 5)
(func wait-time n (* &n &DELAY))
(wait-time 3)                          # → 15
(func scaled DELAY (* &DELAY 2))
(scaled 4)                             # → 8, the parameter shadows DELAY
(* 2 &PI)                              # → 6.283185307179586, as &TAU
(const DELAY 6)                        # error: const: constant DELAY already defined
(set DELAY 6)                          # error: set: cannot shadow constant DELAY
(? DELAY)                              # DELAY = 5
                                       #   Seconds between two tries.
                                       #   defined at line 2

The enum library builds such constants for a set of names: enum numbers plain names from 0, a _ skipping a number, named arguments after them giving their own values, and enum-str gives each name its own text. An enum is an object, which ., keys and values read; enum-name, enum-has and enum-check look values up:

(require (std enum))
(const COLOR (enum RED GREEN BLUE))    # → (obj (.RED 0) (.GREEN 1) (.BLUE 2))
(const MONTH (enum _ JAN FEB MAR))     # → (obj (.JAN 1) (.FEB 2) (.MAR 3))
(enum LOW HIGH (.MAX 99))              # → (obj (.LOW 0) (.HIGH 1) (.MAX 99))
(enum-str ON OFF)                      # → (obj (.ON ON) (.OFF OFF))
(. &COLOR GREEN)                       # → 1
(enum-name &COLOR 1)                   # → GREEN
(enum-has &COLOR 7)                    # → 0
(enum-check &COLOR 7)                  # error: std/enum.tk:63:7: enum/invalid: 7 is not one of RED GREEN BLUE

Collection built-ins work on an object's items, keeping their positions (holes included) unless the result is a new sequence, or on its attributes (keys, values, keyvalues, merge); items and attrs split an object in two, and . follows a path of keys:

(set people (obj (obj (.name Ann) (.age 20)) (obj (.name Bob) (.age 12))
                 (obj (.name Cy) (.age 35))))
(map (filter &people (lambda p (>= (. &p age) 18))) (lambda p (. &p name)))
                                               # → (obj Ann (.2 Cy))
(map (sort &people (.key (lambda p (. &p age)))) (lambda p (. &p name)))
                                               # → (obj Bob Ann Cy)
(reduce (map &people (lambda p (. &p age))) &+ 0)   # → 67
(. &people 1 name)                             # → Bob
(group-by (range 1 7) (lambda x (if (= (mod &x 2) 0) even odd)))
                                               # → (obj (.odd (obj 1 3 5)) (.even (obj 2 4 6)))
(slice (range 10) -3)                          # → (obj 7 8 9)
(unique (concat (obj a b) (obj b c)))          # → (obj a b c)

A program can be split into sources: (require source) imports a file, or with --allow-url an http(s) URL, once, in the scope where it is written, writing what its forms print but not their results. A relative path is relative to the requiring source, else to the current directory, and only files inside the current directory can be read:

# lib/greet.tk holds: (func greet n (join "Hi " &n)) (print "greet loaded")
(require lib/greet.tk)                 # prints greet loaded → _
(require ./lib/greet.tk)               # → _, already imported: prints nothing
(greet Ann)                            # → "Hi Ann"

The standard library is written in tklips, in stdlib/, one library per file. (std name) gives the source of a library for require, under the standard library's location, which --stdlib changes; (std name version) gives the copy of a release, under https://code.thunderk.net/tklips/tklips-lang/raw/tag/<version>/stdlib/. Libraries are read wherever they are, without --allow-url, and their errors are located in std/<name>.tk:

(require (std text))
(text-pad-left "7" 3 "0")              # → "007"
(text-center ab 7 -=)                  # → -=ab-=-
(text-wrap "the quick brown fox" 9)    # → "the quick
                                       #    brown fox"
(text-capwords "ann de la")            # → "Ann De La"
(std text v0.3.0)                      # → https://code.thunderk.net/…/tag/v0.3.0/stdlib/text.tk
(text-pad-left 42 5)                   # error: std/text.tk:20:34: str-len: not a text: 42

A function belongs to the standard library when it is easy to write in tklips without a noticeable cost; anything else is a built-in. Library functions don't check their arguments, for speed, so a wrong one fails in the built-in that receives it. A library's functions are named after it (text-), and its helpers with a double dash (text--padding).

Programs read and write text through resources: files, http(s) URLs and the standard streams. (io-open path fn) opens one, calls fn with its id, an integer, closes it however fn ends, errors and interrupts included, and returns what fn returned; (io-with (name path)... body) does so around a body, which sees the variables around it, each name set to its id as set would. A file path is relative to the current directory, and only files inside it can be opened; a URL is read only, with --allow-url. .mode is read, write, which empties the file, or append. The ids 0, 1 and 2, the constants STDIN, STDOUT and STDERR, are stdin, stdout and stderr; stdin holds data when the program comes from a file. Other ids count up from 3, never given twice, so an id kept after its resource closed fails:

(io-with (in people.csv) (out report.txt (.mode write))
  (while (is-str (set line (io-read-line &in)))   # the next line, without its line end; _ at the end
    (io-print &out (str-upper &line))))
(io-open people.csv (lambda f (io-read &f 3)))    # → Ann: 3 characters, or the rest without a count
(io-open people.csv (lambda f (io-info &f)))      # → (obj (.id 6) (.name people.csv) (.kind file) (.mode read))
(io-write &STDERR "warning: ")         # as print writes, without a line feed
(io-open nope.txt &print)              # error: 1:1: io-open: not found: nope.txt
(io-read-line 3)                       # error: 1:1: io-read-line: not an open resource: 3
(require (std file))
(file-read report.txt)                 # → "ANN,20
                                       #    BOB,12
                                       #    "
(file-each-line people.csv (lambda l (print (str-split &l (.with ",")))))
                                       # (obj Ann "20"), then (obj Bob "12")

io-list gives the ids open. Their errors are of the io/ family: io/denied, io/not-found, io/not-accessible, io/closed, io/mode, io/read and io/write. Like the fact built-ins, they fail in rule conditions.

An error has a code, grouped in families (type/not-text, unknown/function, arg/arity…), and a message. (fail code message) raises one, its named arguments as data. try evaluates expressions and hands an error to its first catch that takes its code, a code ending with / taking a whole family. (recovery value) in a block makes it end with value, or a function's result on the error, if any error comes after it. An error caught as e is an object, (. &e code), (. &e message), (. &e data) and (. &e trace), which (fail &e) raises again; one that nothing handles is written with the calls it went through:

(func find-user id
  (if (= &id 1) Ann (fail not-found (join "no user " &id) (.id &id))))
(try (find-user 7) (catch (e not-found) (join "missing " (. &e data id))))
                                       # → "missing 7"
(try (str-len 42) (catch (e type/) (. &e code)))   # → type/not-text
(func name-of id (block (recovery "?") (find-user &id)))
(map (obj 1 2) &name-of)               # → (obj Ann ?)
(func greet id (join "Hi " (find-user &id)))
(greet 3)                              # error: 2:21: not-found: no user 3
                                       #   in find-user, called at 8:28
                                       #   in greet, called at 9:1

An evaluation holds at most 200000 calls of functions, lambdas and run waiting for their result: one more fails with stack overflow, code limit/stack, which try handles like any other error. Through JavaScript or Python functions that call back into their runtime, evaluations nest at most 100 deep.

(func forever n (forever (+ &n 1)))
(try (forever 0) (catch (e limit/) (. &e message)))  # → "forever: stack overflow"

Ctrl-C, or any SIGINT, interrupts an evaluation: it fails at its next step with interrupted, code system/interrupt, and the shell goes on with the state the form left. Errors of the system/ family go only to a catch that names their code or system/, so that neither a bare catch nor recovery swallows an interrupt; once one was caught, the next fails with aborted, code system/abort, which nothing catches. In batch mode, a system/ error that nothing handles ends the program, with exit status 130.

(try (while 1 1) (catch (e system/interrupt) stopped))   # Ctrl-C → stopped
(try (while 1 1) (catch e caught))     # Ctrl-C → error: 1:6: interrupted

Layout

Path Holds
src/lang/, include/tklips/lang/ the general-purpose language: reader, printer, evaluation, built-ins
src/engine/, include/tklips/engine/ the rule engine on top of it: the fact store and its built-ins
src/shell/, include/tklips/shell/ the interactive shell: the REPL and line reading, and the binary's sources: files and URLs require reads
third_party/linenoise/ line editing for the shell, vendored unchanged
web/ the JavaScript module and the web REPL: WebAssembly entry points, the module, the page and a Node runner
py/ the Python module, on the same WebAssembly engine, and its build script
stdlib/ the standard library, written in tklips: (require (std text)) imports stdlib/text.tk
tools/musl-cc the C compiler for release binaries: clang against musl
bench/ programs make bench times
src/main.c reads the command line, wires the engine's built-ins into the language, runs the program file and picks batch or interactive mode

lang never depends on engine: the engine registers its built-ins with builtins_register.

Tests

make test

Each case in tests/cases/ is a set of files sharing a name:

File Required Checks
<name>.input yes fed to the engine on stdin
<name>.expected yes stdout, byte-exact
<name>.stderr no stderr, byte-exact (defaults to empty)
<name>.exit no exit code (defaults to 0)
<name>.args no command-line arguments, split on whitespace, $ROOT standing for the repository root
<name>.env no lines of NAME=value added to the environment, $ROOT standing for the repository root and $PATH for the runner's PATH
<name>.sigint no one time in seconds per line: SIGINT is sent to the engine at each, counted from its start
<name>.hold no with .sigint, the seconds stdin stays open after the input, without data
<name>.merged no without .sigint: stdout and stderr of a second run sent to the same file, byte-exact

Before the cases, make test checks the doc table of the built-ins (src/lang/help_docs.inc, which ? and func-info read) against the built-ins themselves, with tests/check_docs.c: every built-in needs an entry whose parameters match how it takes its arguments.

Each case runs from tests/cases/, where the sources it requires are; tests/std/ holds a stdlib outside that directory, and io/ the files the resource cases read; the files they write go to io/out/, emptied at each run. Cases that require libraries pass --stdlib $ROOT/stdlib, so that they hold for any build. Set TKLIPS_BIN to run the suite against another binary.

tests/js/ tests the JavaScript module with Node's test runner; make test-web runs it, after the case suite run against the module. tests/py/ tests the Python module with unittest; make test-py runs it, after the case suite run against the module as a script.

Design process

The language is specified with OpenSpec (npx @fission-ai/openspec). Current specs live in openspec/specs/, proposed changes in openspec/changes/. Each change ships with its tests.

In Claude Code: /opsx:propose to start a change, /opsx:apply to implement it, /opsx:archive once it's done.