- C 70.8%
- JavaScript 13%
- Python 12.2%
- POV-Ray SDL 1.5%
- HTML 1.3%
- Other 1.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .claude | ||
| bench | ||
| include/tklips | ||
| openspec | ||
| py | ||
| src | ||
| stdlib | ||
| tests | ||
| third_party/linenoise | ||
| tools | ||
| web | ||
| .gitignore | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| Makefile | ||
| README.md | ||
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
wasmtimepackage (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.