EELisp#
EELisp is a small Lisp for personal data. It's a language with a database, a Lotus-Agenda-style organiser and a spreadsheet built in. It's written in Rust, and SQLite is compiled in. The same engine runs as a REPL, as a library inside a desktop app, as a JSON-line server, and in a browser as WebAssembly.
(deftable books (title:string author:string year:number))
(insert books {:title "Thinking Forth" :author "Brodie" :year 1984})
(query books :where "year > ?" :params (list 1980) :order "year")
(add "call Bob tomorrow !!") ; an agenda item, date and priority parsed out
(defrule calls :when (str-matches text "call|phone") :assign "work/calls")
(apply-rules) ; → 1
(sheet-set "Budget" "B3" "=(sum B1:B2)") ; a spreadsheet formula is just EELisp
These pages cover the whole language, from reading your first expression to embedding the engine in an application. Every example was run against the engine, and the results shown after → are what it printed.
| # | Chapter | What's in it |
|---|---|---|
| 1 | Introduction | What EELisp is for, running it, the design in one page |
| 2 | Syntax and values | How source text is read: atoms, lists, dicts, quoting, truth |
| 3 | The language | Definitions, control flow, functions, tail calls, macros, errors |
| 4 | Working with data | Lists, dicts, strings, numbers, dates, JSON, HTTP, output |
| 5 | The database | dBASE-style tables on SQLite: define, insert, query, forms |
| 6 | The agenda | Items, natural-language capture, categories, rules, views |
| 7 | Sheets | Spreadsheets whose formulas are EELisp |
| 8 | A self-documenting language | functions, source, and writing docs as comments |
| 9 | Architecture | How the engine is built, with sequence diagrams |
| 10 | Embedding | Using the engine from Rust, over JSON, and in a browser |
| — | Function reference | Every special form, builtin, macro and prelude function |
One file to read offline#
[eelisp.html](eelisp.html) is all of the above in one self-contained HTML page. It has no external requests, the diagrams are drawn inline, and it follows your system's light or dark mode. Rebuild it after editing any chapter:
python3 scripts/build-docs.py
The same build writes site/docs.html, the copy published on eelisp.app with the site's navigation. The script also regenerates [reference.md](reference.md) from the engine's built-in manual in src/docs.rs and from the prelude in src/prelude.rs. The reference can't drift from the engine, because it's made from the same tables that (functions) and (source …) read.
1. Introduction#
EELisp is the language inside EEditor. It's a Lisp in the usual sense, with prefix calls, code as data and macros. It also has three things most Lisps don't:
- A database.
deftable,insertandquerywork on SQLite tables, dBASE-style. A query result is a value, so it prints as a grid in a terminal, and a host application can draw it as an interactive table. - An agenda. It's a personal organiser in the style of Lotus Agenda: free-text items, hierarchical categories, and rules written in EELisp that file items as they arrive.
- Sheets. These are spreadsheets whose formulas are ordinary EELisp expressions. In
=(sum B1:B9),B1:B9is just a list.
The engine is a Rust library. It's single-threaded, it has no I/O of its own beyond SQLite files, and no platform types leak into its values. That's what lets the same code run behind a desktop app, a command line, and a browser tab.
A first taste#
(def pi 3.14159)
(defn area (r) (* pi r r))
(area 2) ; → 12.56636
(map (fn (x) (* x x)) (range 1 6)) ; → (1 4 9 16 25)
(filter even? (range 1 10)) ; → (2 4 6 8)
(def ada {:name "Ada" :born 1815})
(dict-get ada :name) ; → "Ada"
Running it#
Build the command-line binary with Cargo:
cargo build --release # target/release/eelisp
cargo test # the acceptance suite
The binary has four modes:
eelisp # an interactive REPL
eelisp script.eelisp # run a file, top to bottom
eelisp -e '(+ 1 2)' # evaluate one expression and print it
eelisp --serve # JSON-line RPC on stdin/stdout, for a host program
eelisp --serve --db notes.db # … keeping tables and agenda in a file
In the REPL, an expression that spans several lines keeps reading until its brackets balance:
EELisp v0.2 (Rust) — :quit to exit
λ> (defn sq (x)
.. (* x x))
#<fn sq>
λ> (sq 9)
81
Files conventionally end in .eelisp. A file is read as a sequence of top-level forms, each evaluated in turn.
Design in one page#
A few decisions shape everything else in these pages:
| Decision | Consequence |
|---|---|
| One numeric type (64-bit float) | (/ 7 2) is 3.5; integers print without a decimal point |
**Only false and nil are false** | 0, "" and () are all true |
| Values are immutable | dict-set returns a new dict; lists are never changed in place |
| Tail calls don't grow the stack | Recursion is the loop; 100 000 nested tail calls are fine |
| Keywords name things | :name is a dict key, an argument name (:where), and never needs quoting |
| Clean tokenizing | -5 is a number and - is a symbol; write (- 1 3) to subtract |
| Docs live in comments | A ;; block above a defn is its documentation, shown by (source f) |
| The host decides the reach | Editor access and the working folder come from callbacks the host installs |
EELisp began as a Swift engine. This Rust version is a deliberate clean break that fixes that engine's known faults: tail-call optimisation, a working quasiquote, macros with rest parameters, and tokenizing without special cases.
Continue with Syntax and values.
2. Syntax and values#
EELisp source is read in two steps. The lexer cuts the text into tokens. The reader turns the tokens into values: lists, symbols, numbers and the rest. Code is data, so what the reader produces is exactly what the evaluator runs and what a macro receives.
Atoms#
42 -3.5 1e3 0xff ; numbers — one type, a 64-bit float
"hello" "a\tb\n" ; strings, with \n \t \r \\ \" escapes
true false ; booleans
nil ; nothing
foo str-len a.b ; symbols — names
:name :where ; keywords — names that stand for themselves
The lexer reads an atom whole and then decides what it is. If it starts with :, it's a keyword. If it parses as a number, it's a number. Otherwise it's a symbol. There's no splitting of operators from numbers, so:
| You write | It reads as |
|---|---|
-5 | the number −5 |
- | the symbol - (subtraction) |
(- 1 3) | a call: −2 |
1e3 | the number 1000 |
0xff, 0x4000, -0x10 | hex whole numbers: 255, 16384, −16 (up to 13 digits, so always exact) |
a.b, ->string, even?, set! | ordinary symbols |
Whole numbers print without a decimal point: (* 2 21) prints 42, and (/ 7 2) prints 3.5.
Lists, brackets and braces#
(f a b) ; a list — evaluated as a call to f
[1 2 (+ 1 2)] ; square brackets are shorthand for (list …) → (1 2 3)
{:name "Ada" :born 1815} ; a dict literal; values are evaluated, keys aren't
Square brackets read as a call to list. [1 2] is exactly (list 1 2), so the elements are evaluated. Quoting a bracket list shows this: '[1 2] is the three-element list (list 1 2).
A dict literal keeps its keys in the order they were written. Keys are usually keywords.
Comments#
; a comment runs to the end of the line
;; by convention, a ;; block directly above a definition documents it
(defn inc (n) (+ n 1)) ; a trailing comment belongs to the form on its line
Comments aren't just ignored. The reader remembers the comment block written directly above each top-level definition, and (source name) prints it back. See A self-documenting language.
Quoting#
Evaluation can be switched off, in whole or in part. The four prefix characters are shorthand for special forms:
| Shorthand | Long form | Meaning |
|---|---|---|
'x | (quote x) | the value x itself, unevaluated |
` x ` | (quasiquote x)` | a template | ||
,x | (unquote x) | inside a template: evaluate x and put the value here |
,@x | (unquote-splicing x) | inside a template: evaluate x and splice the list in |
'(1 2 3) ; → (1 2 3), not a call to "1"
(def xs '(2 3))
`(1 ,(+ 1 1) ,@xs 4) ; → (1 2 2 3 4)
Templates are what macros are built from.
Truth#
Only two values are false: false and nil. Everything else is true, including 0, the empty string and the empty list:
(if 0 "yes" "no") ; → "yes"
(if "" "yes" "no") ; → "yes"
(if (list) "yes" "no") ; → "yes"
(if nil "yes" "no") ; → "no"
Test emptiness explicitly with empty?, zero? or (= s "").
Types#
Every value has a type, and (type x) names it:
| Type | Example | Notes |
|---|---|---|
number | 42, -3.5 | a 64-bit float |
string | "hi" | Unicode; lengths count characters |
bool | true | |
nil | nil | |
symbol | 'foo | a name |
keyword | :foo | a name that evaluates to itself |
list | (1 2 3) | immutable, cheap to share |
dict | {:a 1} | insertion-ordered, immutable |
function | (fn (x) x), inc | written in EELisp |
builtin | map, + | written in Rust |
macro | when | |
table, record, result-set | (query books) | database values |
item | (item-get 1) | an agenda item |
table-view, form-view | (browse books) | interactive views for a host to draw |
Values compare structurally. (= '(1 2) '(1 2)) is true, and two dicts with the same keys in the same order and the same values are equal. Functions never compare equal, not even to themselves.
Continue with The language.
3. The language#
A list is evaluated as a call. The first element says what to do, and the rest are the arguments. (+ 1 2) calls + with 1 and 2. A symbol evaluates to whatever it's bound to. Everything else (numbers, strings, keywords, true, false, nil) evaluates to itself.
There are three kinds of thing that can appear at the head of a list:
- Special forms, such as
if,defandlet. The evaluator handles these itself, and each one decides which of its arguments to evaluate. - Macros, such as
whenandunless. These receive their arguments unevaluated and return code, which is then evaluated in their place. - Functions, either builtins written in Rust or functions written in EELisp. Their arguments are evaluated first, left to right.
Definitions#
(def pi 3.14159) ; a global binding
(defn area (r) (* pi r r)) ; a function
(defun area (r) (* pi r r)) ; the Common Lisp spelling, same form
(def (square x) (* x x)) ; def with a call shape is also a function
(fn (x) (* x x)) ; an anonymous function
(lambda (x) (* x x)) ; the same
((fn (x) (* x x)) 5) ; → 25
set! reassigns a binding that already exists. Using it on a name that was never defined is an error:
(def n 1)
(set! n (+ n 1)) ; → 2
(set! nope 1) ; Error: Undefined symbol: nope
Local bindings#
let takes its bindings as one flat list of name–value pairs. Each binding can see the ones before it:
(let (a 2 b (* a 3)) (+ a b)) ; → 8
The Scheme shape, with nested pairs, is accepted too: (let ((a 1) (b 2)) (+ a b)).
Control flow#
(if (> x 10) "big" "small") ; else is optional; a missing else is nil
(cond (< n 0) "negative" ; cond is flat: test, result, test, result …
(= n 0) "zero"
true "positive") ; true, else or otherwise catch everything
(and a b c) ; the first false value, or the last value
(or a b c) ; the first true value, or the last value
(not x)
(do (println "first") 42) ; evaluate in order, return the last
(begin (println "first") 42) ; the Scheme spelling of do
(when ready (println "go") 1) ; macros from the prelude: a body of several forms
(unless ready (println "wait"))
(for-each x (list 1 2 3) ; a loop for effects; returns nil
(println (* x 10)))
cond pairs aren't wrapped in parentheses. That's the one place EELisp differs visibly from Scheme and Common Lisp.
Functions#
Functions close over the scope they were created in:
(defn make-counter ()
(let (n 0)
(fn () (set! n (+ n 1)) n)))
(def c (make-counter))
(c) (c) (c) ; → 3
A parameter list can end with . rest. The rest parameter collects the remaining arguments into a list:
(defn total (first . others)
(+ first (reduce (fn (a b) (+ a b)) 0 others)))
(total 1 2 3 4) ; → 10
A missing argument is bound to nil rather than raising an error: ((fn (a b) (list a b)) 1) gives (1 nil).
Functions are values. You can pass them, return them and store them:
(map (fn (x) (* x x)) (range 1 6)) ; → (1 4 9 16 25)
(apply + (list 1 2 3)) ; → 6
((compose inc inc) 5) ; → 7
Tail calls and loops#
The evaluator is a trampoline. When the last thing a function does is call another function, the evaluator reuses the current frame instead of stacking a new one. The same goes for the taken branch of an if, the last form of a do or let, the chosen branch of a cond, and the last operand of and and or. So recursion in tail position runs in constant stack space, including mutual recursion:
(defn even2? (n) (if (= n 0) true (odd2? (- n 1))))
(defn odd2? (n) (if (= n 0) false (even2? (- n 1))))
(even2? 100001) ; → false, without exhausting the stack
For an explicit loop, use loop with recur. loop introduces bindings as let does, and recur jumps back to the top with new values:
(defn fact (n)
(loop (i n acc 1)
(if (<= i 1)
acc
(recur (- i 1) (* acc i)))))
(fact 10) ; → 3628800
recur outside a loop is an error: recur used outside of a loop.
Macros#
A macro is a function that runs before evaluation. It receives its arguments as unevaluated code and returns new code, which is evaluated in its place. Quasiquote makes the returned code easy to write:
(defmacro my-unless (c body)
`(if ,c nil ,body))
(my-unless false "ran") ; → "ran"
Macros take rest parameters, which is how a macro accepts a body of several forms. This is when, exactly as the prelude defines it:
(defmacro when (test . body)
`(if ,test (do ,@body) nil))
Macros aren't hygienic. A name the template introduces can capture a name at the call site, so pick unusual names for temporaries:
(defmacro swap! (a b)
`(let (tmp ,a) (set! ,a ,b) (set! ,b tmp)))
(def p 1) (def q 2)
(swap! p q)
(list p q) ; → (2 1)
Code as data#
The reader and evaluator are available from inside the language:
(parse "(+ 1 2)") ; → the list (+ 1 2), not evaluated
(eval (parse "(+ 1 2)")) ; → 3
(eval (list '* 6 7)) ; → 42
The agenda's rules depend on this. A rule's condition is stored as text in the database, then read back and evaluated against each item.
Errors#
An error stops evaluation and reports one line:
| Error | Example cause |
|---|---|
Undefined symbol: x | using a name that isn't bound |
Type mismatch: expected list, got number | (car 5) |
Arity mismatch: f expects …, got … | a builtin called with the wrong number of arguments |
Division by zero | (/ 1 0) |
Invalid syntax: … | a malformed special form, such as (defn) |
Parse error: unclosed list | unbalanced brackets |
Database error: … | SQLite refused, for example a bad :where clause |
Error: … | a runtime failure reported by a builtin |
try catches one. Its body runs; if it stops with an error, the catch clause binds the error to a name and runs instead:
(try (/ 1 0)
(catch e (dict-get e :kind))) ; → "division-by-zero"
(defn price-or-zero (url)
(try (dict-get (json-parse (dict-get (http-get url) :body)) :price)
(catch e (do (println "no price: " (dict-get e :message)) 0))))
The error is a dict: :message, the one line above, and :kind — undefined-symbol, type, arity, syntax, division-by-zero, index, parse, database or runtime. Without a catch clause an error makes the try nil. (error "message") raises one of your own, with :kind runtime and your message exactly as written. A recur inside a try still reaches its loop — it's how loops work, not an error.
An error nothing catches stops evaluation. The REPL prints it and carries on, and the host API returns it as {"ok": false, "error": "…"} (see Embedding). Some builtins return nil for a missing value instead of failing. For example, (nth lst 99) is nil.
Continue with Working with data.
4. Working with data#
Every data operation in EELisp returns a new value and leaves its input alone. Adding to a list gives a new list, and setting a dict key gives a new dict. That makes values safe to share, and it makes the host boundary simple, because a value handed to a host is never changed afterwards.
Numbers#
There's one numeric type, a 64-bit float. Whole results print without a decimal point.
(+ 1 2 3) ; → 6 (- 10 3 2) ; → 5
(* 2 3 4) ; → 24 (/ 7 2) ; → 3.5
(mod 7 3) ; → 1 (abs -4) ; → 4
(pow 2 10) ; → 1024 (expt 2 10) ; → 1024
(floor 2.7) ; → 2 (ceil 2.1) ; → 3
(round 2.5) ; → 3 halves round away from zero
(< 1 2 3) ; → true comparisons take any number of arguments
(= 1 1.0) ; → true
min, max, sum and avg look inside lists and skip nil and text. That's what a spreadsheet range needs, since a range can contain blanks and labels:
(sum 1 (list 2 nil "x" 3)) ; → 6
(min 3 (list 1 nil 2)) ; → 1
(avg (list 2 4 nil)) ; → 3
Lists#
(list 1 2 3) ; → (1 2 3)
[1 2 3] ; the same
(cons 0 (list 1 2)) ; → (0 1 2)
(append (list 1) (list 2 3)) ; → (1 2 3)
(reverse (list 1 2 3)) ; → (3 2 1)
(head lst) (car lst) ; the first element — nil for an empty list
(tail lst) (cdr lst) ; everything after it
(nth lst 1) ; 0-based — nil when out of range
(first l) (second l) (third l) (last l)
(take 2 l) (drop 2 l)
(length l) (count l) (empty? l)
(range 3) ; → (0 1 2)
(range 1 5) ; → (1 2 3 4) end-exclusive
(range 5 0 -1) ; → (5 4 3 2 1)
Higher-order functions do most of the work:
(map (fn (x) (* 2 x)) (range 1 5)) ; → (2 4 6 8)
(filter even? (range 1 10)) ; → (2 4 6 8)
(reduce (fn (a b) (+ a b)) 0 (range 1 5)) ; → 10
(sort-by (fn (p) (nth p 1)) (list (list "a" 3) (list "b" 1)))
; → (("b" 1) ("a" 3))
(zip (list 1 2) (list "a" "b")) ; → ((1 "a") (2 "b"))
(some? even? (list 1 3 4)) ; → true
(every? even? (list 2 4)) ; → true
(apply + (list 1 2 3)) ; → 6
Dicts#
A dict maps keys to values and remembers the order the keys were added in. Keys are usually keywords, but a string works as well: (dict-get d "name") finds :name.
(def d {:name "Ada" :born 1815})
(dict :name "Ada" :born 1815) ; the same, built by a call
(dict-get d :name) ; → "Ada"
(dict-get d :missing) ; → nil
(dict-has d :name) ; → true
(dict-keys d) ; → (:name :born)
(dict-values d) ; → ("Ada" 1815)
(dict-set d :born 1816) ; → {:name "Ada" :born 1816} — d itself is unchanged
(dict-merge {:a 1 :b 2} {:b 3 :c 4}) ; → {:a 1 :b 3 :c 4} — later keys win
Strings#
(str "x=" 42 " " :k) ; → "x=42 :k" — str converts anything
(str-len "hello") ; → 5 (characters, not bytes)
(str-upper "hi") (str-lower "Hi") (str-trim " hi ")
(substr "hello" 1 3) ; → "el" — 0-based, end-exclusive
(str-split "a,b,c" ",") ; → ("a" "b" "c")
(str-join "-" (list "a" "b")) ; → "a-b" — either argument order works
(str-replace "14:30" ":" "") ; → "1430" — every occurrence
(str-contains "hello" "ell") ; → true
(str-starts-with "hello" "he") ; → true
(str-ends-with "hello" "lo") ; → true
(str-matches "a1" "^[a-z][0-9]$") ; → true — a regular expression
Types and conversion#
(type 42) ; → "number"
(number? x) (string? x) (bool? x) (list? x)
(nil? x) (symbol? x) (keyword? x) (fn? x) ; fn? is true for builtins too
(->string 42) ; → "42"
(->number "42") ; → 42 — an error for text that isn't a number
(->bool 0) ; → true — only false and nil are false
Dates#
The engine does its own date arithmetic and has no external date library. Two spellings of a date are in use:
- Epoch seconds, a number. This is what
(now)and(today)return. - **
"YYYY-MM-DD"strings.** Agenda items,date-addanddate-diffuse these.
date-format accepts either spelling.
(now) ; → 1790351486.65 the current instant
(today) ; → 1790294400 midnight today
(date-format (today)) ; → "2026-09-25 00:00"
(date-format "2026-09-25" "EEEE") ; → "Friday"
(date-format "2026-09-25" "d MMMM yyyy"); → "25 September 2026"
(date-add "2026-09-25" 7 :days) ; → "2026-10-02"
(date-add "2026-09-25" 2 :weeks) ; → "2026-10-09"
(date-add "2026-01-31" 1 :months) ; → "2026-02-28" — clamped to the month's end
(date-diff "2026-10-01" "2026-09-25") ; → 6 — the first date minus the second, in days
Format patterns follow the Unicode convention:
| Pattern | Gives | Pattern | Gives |
|---|---|---|---|
yyyy / yy | 2026 / 26 | HH / H | hour, 00–23 |
MM / M | 09 / 9 | hh / h | hour, 01–12 |
MMM / MMMM | Sep / September | mm | minutes |
dd / d | 05 / 5 | ss | seconds |
EEE / EEEE | Fri / Friday | 'text' | literal text |
JSON#
(json-stringify (list 1 "a" nil true {:k :v})) ; → "[1,\"a\",null,true,{\"k\":\"v\"}]"
(json-parse "[1, null, true, {\"x\": {\"y\": 2}}]")
; → (1 nil true {:x {:y 2}})
(dict-get (json-parse "{\"name\":\"Ada\"}") :name) ; → "Ada"
JSON objects become dicts, with their keys sorted. Arrays become lists, and null becomes nil.
CSV#
(csv-parse "symbol,price\nAAPL,231.5\nMSFT,412")
; → (("symbol" "price") ("AAPL" "231.5") ("MSFT" "412"))
(csv-records "symbol,price\nAAPL,231.5\nMSFT,412")
; → ({:symbol "AAPL" :price 231.5} {:symbol "MSFT" :price 412})
(csv-stringify '(("name" "note") ("Ada" "first, and best")))
; → "name,note\nAda,\"first, and best\""
csv-parse gives rows of text; csv-records reads the first row as names and gives one dict per row, with numbers as numbers and empty cells as nil. Both follow the usual quoting — a cell in quotes may hold the separator, a line break, or a quote written twice — and take the separator as a second argument: "\t" for tab-separated text. They read exactly what EEditor's Import as sheet reads.
The network#
(http-get "https://example.com/api") ; → {:status 200 :body "…"}
(http-post "https://example.com/api" body) ; → {:status … :body …}
(json-parse (dict-get (http-get url) :body)) ; a JSON API, read
Both take options after the URL (and a POST's body): :timeout in seconds — 30 unless given, at most 300 — and :headers, a dict sent with the request, which is where most services want their key; a POST also takes :content-type (application/json unless given):
(http-get "https://api.example.com/quote?symbol=AAPL"
:timeout 5
:headers {"x-api-key" key "accept" "application/json"})
These are the only functions that reach the network, and they're synchronous. Every answer comes back as a dict — a 404 or a 500 too, with that :status, so check it before reading the :body. Only when nothing answers at all (no connection, or 30 seconds of silence) is there an error, HTTP request failed, naming what went wrong. The WebAssembly build leaves them out, because a browser has its own fetch. There, the functions say they're unavailable instead of crashing.
Output#
(print "a" "b") ; writes "a b" with no newline
(println "total:" 42) ; writes "total: 42" and a newline
Arguments are separated by spaces, and strings are written without their quotes. Where the text goes depends on the host. The command line echoes it to the terminal. An embedding host captures it, and it comes back in the output field of the JSON envelope (see Embedding).
Continue with The database.
5. The database#
EELisp has a dBASE-style database built in. Tables are real SQLite tables. SQLite is compiled into the engine, so there's nothing to install. Each table's schema is stored as JSON next to the data, so field types, defaults, required flags and choice lists survive a restart.
By default the engine uses an in-memory database. A host can name a file instead, for example eelisp --serve --db notes.db or Interpreter::with_database("notes.db"). (database-info) says which one is in use:
(database-info) ; → {:path ":memory:"}
Defining a table#
The short form lists name:type pairs:
(deftable books (title:string author:string year:number))
The long form describes each field with keywords:
(deftable tasks
((title :type string :required true)
(status :type string :default "open" :choices ("open" "done"))
(due :type date)))
The field types are string, number, bool, date, memo and choice. :default fills a field that an insert leaves out. :required and :choices are part of the schema a host reads to build its forms, and the engine holds a row to them too: a row without a required field is refused (NOT NULL constraint failed), and insert or update with a value outside a field's choices is refused, naming the field and its choices. Leaving a choice field empty (nil) is allowed — that's what :required is for.
(tables) ; → (_categories _items _rules _templates _views books tasks)
(describe tasks) ; → #<table tasks (title:string status:string due:date)>
(drop-table tasks)
Tables whose names start with _ belong to the agenda.
Naming a table#
Table commands take the table's name bare, so (query books) doesn't evaluate books. When a program works out the name at run time, pass a call instead, because a call in that position is evaluated:
(query books) ; the table named books
(def which "books")
(count-records (str which)) ; the table whose name str returns
Rows in, rows out#
(insert books {:title "Thinking Forth" :author "Brodie" :year 1984})
; → {id: 1, title: "Thinking Forth", author: "Brodie", year: 1984}
(update books 1 {:year 1985}) ; change some fields of row 1
(delete books 1) ; a soft delete: the row is only marked
(pack books) ; really remove the marked rows
(count-records books) ; how many live rows
A soft-deleted row disappears from queries and counts at once, but it stays in the file until pack removes it.
Queries#
query returns a result-set. A result-set is a value that knows its table and columns. It prints as a grid:
(query books :where "year > ?" :params (list 1980) :order "year" :desc true)
┌────┬────────────────┬─────────┬──────┐
│ id │ title │ author │ year │
├────┼────────────────┼─────────┼──────┤
│ 2 │ SICP │ Abelson │ 1985 │
│ 1 │ Thinking Forth │ Brodie │ 1984 │
└────┴────────────────┴─────────┴──────┘
| Option | Meaning |
|---|---|
:where "sql" | an SQL condition, with ? for each parameter |
:params (list …) | values for the ? placeholders, in order |
:order "field" | sort by a field |
:asc true / :desc true | the sort direction |
:limit n | at most n rows |
:select (list "a" "b") or :select "a, b" | only these columns |
Always pass values through :params rather than pasting them into :where. The engine binds them as SQL parameters.
count-records takes the same :where and :params:
(count-records books :where "year > ?" :params (list 1980)) ; → 2
Records#
(records rs) turns a result-set into an ordinary list of records, ready for map, filter and the rest:
(map (fn (r) (field-get r "title"))
(records (query books :order "year")))
; → ("Lisp 1.5" "Thinking Forth" "SICP")
(def r (head (records (query books))))
(record-id r) ; → 1
(field-get r :title) ; → "Thinking Forth" — a keyword or a string
(field-set r "title" "TF") ; → a new record; the row is untouched until you update
Grids and forms#
A result-set is data, and a view is data with an intent: "show this to a person". Views are how the database reaches a user interface.
(browse books) ; a table-view: an interactive grid
(edit books 1) ; a form-view: one record in a form
;; a form over an existing table
(defform book-form () :source books)
;; a standalone form with a computed field
(defform invoice (qty:number price:number)
:computed ((total (* qty price))))
On the command line, a table-view prints as an ASCII grid, and a form-view prints as a summary such as #<form invoice (qty price) 1 record(s) standalone>. Inside EEditor, the same values become a spreadsheet-like grid and a form. The host receives them as tagged JSON, with $tableView and $formView, and draws them. A computed field travels with its expression ("(* qty price)"), so the form can recalculate it as the user types. See Embedding.
How a query travels#
Continue with The agenda.
6. The agenda#
The agenda is a personal information manager in the spirit of Lotus Agenda (1988). You write things down as free text first. Structure comes later, partly from you and partly from rules that read each item and file it. The agenda lives in the same SQLite database as your tables, in tables whose names start with _.
Items#
(add-item "call Bob" :when "2026-09-26" :priority 1) ; explicit properties
(add-item "draft budget" :category "work" :notes "Q4 numbers")
(add-item-today "water the plants") ; due today
An item has text, notes, a list of categories, and any number of properties. :when and :priority are properties, and so is anything a rule or item-set adds. Property values are stored as strings.
(item-get 1) ; → #<item 1: call Bob>
(item->dict (item-get 1))
; → {:id 1 :text "call Bob" :priority "1" :when "2026-09-26" :notes ""
; :categories () :created "2026-09-25T15:50:07Z" :modified "…"}
(item-set 1 :priority 2 :notes "after 3pm") ; text, notes or any property
(item-done 1) ; done: soft-deleted, or rolled forward if it recurs
(item-count) ; → how many items
Finding items#
items returns a result-set, which prints as a grid. The filters combine:
(items) ; everything
(items :category "work" :priority 1)
(items :search "budget" :when-before "2026-10-01")
(items :when-after "2026-09-01")
(items-on "2026-09-26")
(items-between "2026-09-01" "2026-12-31") ; both ends included
(records (items :search "budget")) ; as a list, for map and filter
Writing it down in plain language#
add parses a date, a priority and people out of ordinary text. smart-parse shows the parse without storing anything:
(smart-parse "lunch with Ana in 3 days high priority")
; → {:text "lunch with Ana" :when "2026-09-28" :priority 2 :who ("Ana")}
(smart-parse "email Carl 2026-10-12 !")
; → {:text "email Carl" :when "2026-10-12" :priority 3 :who ("Carl")}
(add "call Dana tomorrow urgent") ; stores it
| It recognises | Examples | Becomes |
|---|---|---|
| An ISO date | 2026-10-12 | :when |
| Relative days | today, tomorrow, yesterday, in 3 days, in 2 weeks | :when |
| Urgency words | urgent, asap → 1 · high priority → 2 · low priority → 4 | :priority |
| Exclamation marks | !!! → 1 · !! → 2 · ! → 3 | :priority |
| People | @sam, or a name after call, email, meet, text, with, for, from | :who |
Recurrence#
(add-item "pay rent" :when "2026-10-01" :recur (every 1 :months))
(add-item "standup" :when "2026-09-28" :recur :weekly)
(item-done 1) ; the item is closed and a copy appears with :when "2026-11-01"
(every n unit) takes :days, :weeks or :months, and returns a recurrence string such as "every:1:months".
Categories#
Categories are paths. Filing an item under work/calls also puts it under work as far as filters are concerned: (has-category "work") is true for it, and (items :category "work") finds it.
(defcategory work/calls)
(defcategory (str "home/" room)) ; a call in the name's place is evaluated
(assign 3 "work/calls") ; assigning defines the category if it's new
(unassign 3 "work/calls")
(categories) ; → "work\n work/calls" — an indented tree
An exclusive parent allows only one of its children per item. Assigning a sibling replaces the old one. That's how you model states that can't overlap:
(defcategory priority :exclusive true :children (high low))
(assign 1 "priority/high")
(assign 1 "priority/low") ; the item is now in priority/low only
Rules#
A rule is a condition and some actions, all written in EELisp. The engine stores both as text in the _rules table. When rules run, each condition is read back and evaluated against every item, with the item's fields bound as variables.
(defrule calls
:when (str-matches text "call|phone|ring")
:assign "work/calls")
(defrule tag-urgent
:when (str-matches text "(?i)urgent")
:action (item-set id :priority 1))
(defrule ticket
:when (str-matches text "ticket #([0-9]+)")
:action (item-set id :ticket (match 1))) ; "fix ticket #42" gets :ticket "42"
(apply-rules) ; every item → how many changed
(apply-rules 7) ; just item 7
(auto-categorize true) ; from now on, run the rules on each new item as it's added —
; add and add-item return the item with what the rules assigned
(rules) ; → "calls: (str-matches text \"call|phone|ring\")\n…"
(drop-rule "calls")
:assign and :action may each appear more than once. Inside :when and :action, these names are bound:
| Name | Is |
|---|---|
text, notes, id | the item's own fields |
categories | a list of category paths |
created, modified | timestamps, as ISO strings |
every property, such as when, priority, who | its value, a string |
(get :priority) | one property by keyword; nil when absent |
(has-category "work") | membership, parents included |
(overdue?) | true when when is before today |
(match n) | capture group n of the condition's regular expression |
A rule whose condition raises an error is skipped, as is an action that fails. One bad rule can't stop the others. The whole pass runs in a transaction, so it's committed at the end or rolled back if something below the rules fails.
Rules run in order of name, and each rule sees the changes made by earlier ones in the same pass.
Views#
A view is a saved query over the agenda. Its filter is an expression evaluated in the same scope as a rule condition:
(defview workboard :filter (has-category "work") :sort-by when)
(defview late :filter (overdue?))
(defview p1 :filter (= priority "1")) ; properties are strings
(defview by-prio :group-by priority) ; grouped: an outline, not a grid
(show workboard) ; → a result-set
(views) ; the saved views' names
(drop-view "late")
Templates#
(deftemplate standup :text "standup" :priority 2 :category "work") ; again → replaces it
(from-template standup :when "2026-09-28") ; a new item, template values filled in
(from-template standup "planning standup") ; …with its own text
(templates) ; → "standup — standup"
(drop-template standup)
Several agendas#
Each agenda is its own database file, completely separate from the others. The item functions act on the current agenda.
(open-agenda "work.db") ; → "Opened agenda: work" — and it becomes current
(agendas) ; → "work [active]\nmemory"
(use-agenda memory) ; switch back to the engine's own database
(close-agenda work)
(export-agenda "memory" :format :json :path "backup.json")
(import-agenda "backup.json") ; → "Imported 2 items, 0 templates" — in one transaction
memory is the engine's own database when it runs with none of its own — the command line. A host names its agenda: in EEditor, the folder's agenda is eeditor, so a backup from there is (export-agenda "eeditor" :format :json :path …). The export holds the agenda — items, categories, rules, views, templates — not your own tables, and the folder in :path must already exist.
Continue with Sheets.
7. Sheets#
A sheet is a spreadsheet whose formulas are EELisp. There's no separate formula language. A formula is one expression after =, and inside it B1 is a cell's value and B1:B9 is a list of values. Everything in the rest of these pages works inside a formula: map, filter, date-add, even count-records against the database.
Each sheet is a SQLite file with the extension .eesheet. A sheet is named "Budget", "money/Budget" or by absolute path. A relative name resolves against the host's workspace folder, (current-dir), and .eesheet is implied.
A first sheet#
(sheet-new "Budget")
(sheet-set "Budget" "A1"
'(("rent" "1200")
("food" "450")
("total" "=(sum B1:B2)"))) ; a list of rows types a block
(sheet-get "Budget" "B3") ; → 1650
(sheet-set "Budget" "B2" "500") ; B3 recalculates by itself
(sheet-get "Budget" "B3") ; → 1700
(sheet-rows "Budget" "A1:B3") ; → (("rent" 1200) ("food" 500) ("total" 1700))
sheet-set returns the cells that changed, each one as (row col input value error fmt) with rows and columns counted from 0. A host uses that list to repaint only what moved.
What a cell holds#
A cell keeps what was typed, its input, and the last value that input produced. How the input is read:
| You type | It becomes |
|---|---|
1200 | the number 1200 |
rent | the text "rent" |
'42 | the text "42", since a leading ' forces text |
=(sum B1:B2) | a formula |
50% | 0.5, shown as a percentage |
$1,200.50, R$ 1.200,50 | money: 1200.5, with a currency format |
1 200,50 | 1200.5 |
2026-09-16 | a date, which sorts and takes part in date arithmetic |
16/09/2026 | text, because the sheet won't guess which part is the month |
Whichever of . or , comes last is taken as the decimal point. A lone comma before exactly three digits separates thousands, so 1,200 is twelve hundred and 1,5 is one and a half. A format already set on the cell wins over the guess.
Inside a formula#
| You write | It means |
|---|---|
B1 | the value in B1 (capitals only, since b1 is an ordinary name) |
B1:B9 | the range as a flat list, with blanks as nil (one row or one column) |
A1:C9 | a block wider and taller than one: a list of rows, (("apple" 3) ("pear" 5) …) |
E:E, B:D | whole columns, read down to the last filled row — E:E flat, B:D as rows |
$B$1, B$1, $B1 | pinned whole, by row, or by column |
Rates!A1, Rates!A1:A9 | a cell or range in the sheet named Rates, in the same folder |
#REF! | what a reference turns into when its cell is deleted |
=(sum B1:B9)
=(round (/ B1 $B$3) 2) ; a share of the total; the pin survives filling
=(sum (filter (fn (x) (> x 400)) B1:B3))
=(length (filter (fn (x) (= x "Work")) E:E)) ; how many rows say Work
=(* B2 Rates!A1) ; another sheet
=(date-format (today) "yyyy-MM-dd")
=(count-records people :where "city = ?" :params '("Lisbon"))
sum, avg, min and max skip blanks and text, so a range with labels in it still adds up — and they reach into a block's rows, so (sum A1:C9) adds every number in it. A formula inside the column it reads (=(sum E:E) in E5) is a circular reference, as it would be anywhere else.
Recalculation#
When a cell changes, the engine works out everything downstream of it and recomputes each formula after the formulas it reads. Results are stored in the file, so opening a sheet runs no formula. A sheet with ten thousand formulas opens as fast as one with none.
A formula that reads the database follows it too. While a formula runs, the engine notes every table it reads — through query, count-records, the agenda's items, or a function of yours that calls them — and keeps that list with the cell, in the file. When something writes one of those tables (insert, update, delete), the formulas of open sheets that read it are recalculated once the write's top-level form has finished — a loop of a hundred inserts recalculates once, at the end:
(sheet-set "Portfolio" "D2" "=(latest A2)") ; latest queries the prices table
(insert prices {:symbol "bitcoin" :price 80121 :at "2026-10-26 09:30"})
(sheet-get "Portfolio" "D2") ; → 80121, nothing pressed
It works the same when the write comes from a host calling one of your functions (Interpreter::call), not only from code typed at the prompt.
A formula may write to a table too, but that write doesn't recalculate anything: following it would run the formula again, which would write again, for ever. Only writes made outside formulas are followed.
What a formula reads is noted when it runs, and kept in the file, so a sheet just opened — which runs nothing — still knows. A sheet last saved by an older engine has no such list yet: its formulas learn what they read at its next recalculation (↻ once does it). Formulas that read the clock, or a sheet that was closed when it changed, still need ↻ (sheet-recalc).
Formulas that read each other in a circle are found with Tarjan's strongly-connected-components algorithm, run without recursion so that a long chain of running totals can't overflow the stack. Every cell in the circle gets the same error rather than a wrong number:
(sheet-set "Budget" "F1" "=(+ 1 G1)")
(sheet-set "Budget" "G1" "=(+ 1 F1)")
; both cells: error "circular reference between F1, G1"
Writing to a sheet also recomputes the other open sheets that read it, once each, so two sheets naming each other settle down instead of bouncing back and forth. A formula that reads the database, the clock or a sheet that wasn't open isn't tracked. Run (sheet-recalc "Budget") to recompute everything.
Moving things around#
(sheet-fill "Budget" "C1" "C2:C12") ; copy C1 down; references shift per row
(sheet-copy "Budget" "B1:B3") ; → what was typed, as rows of text
(sheet-paste "Budget" "D1" (sheet-copy "Budget" "B1:B3") "B1")
; paste; formulas move by the distance travelled
(sheet-insert-rows "Budget" 3) ; rows numbered from 1; formulas follow their cells
(sheet-delete-rows "Budget" 3 2) ; a reference to a deleted cell reads #REF!
(sheet-insert-cols "Budget" "B")
(sheet-delete-cols "Budget" "B" 2)
When rows or columns move, the engine rewrites the references in each formula's text and keeps its spacing and comments. =(sum B1:B2) becomes =(sum B2:B3), and nothing else in the formula changes.
Data in, data out#
(sheet-put "Report" "A1" (query people)) ; a header row, then the records
(sheet-put "Report" "A1" '((1 2) (3 4))) ; values stay values: never re-read as input
(sheet-open "Budget") ; everything a host needs to draw it: {:path :version :cells :widths :heights}
(sheet-version "Budget") ; a counter that moves on every write
(sheet-bytes "Budget") ; the whole file as base64
(sheet-from-bytes "Copy" (sheet-bytes "Budget"))
(sheet-close "Budget") ; let go of the file before renaming or deleting it
Formatting#
Formats are stored with the cell and are for the host to apply. The engine only keeps them and moves them around.
(sheet-format "Budget" "B1:B9" {:num "currency" :dp 2})
(sheet-format "Budget" "A1" {:bold true :bg "#fff3c4"})
(sheet-format "Budget" "A1:A9" nil) ; clear
(sheet-col-width "Budget" "A" 160)
(sheet-row-height "Budget" 3 48)
The keys are :num, :dp, :cur, :date, :bold, :italic, :align, :wrap, :bg, :fg and :border.
Continue with A self-documenting language.
8. A self-documenting language#
EELisp carries its own manual. Two functions answer most "what's this called?" questions from inside a REPL.
functions: what's in scope#
(functions) ; every special form, builtin, macro and function, grouped
(functions "str") ; only names containing "str"
(functions :date) ; a keyword or symbol works too
special forms
and (and a b …)
begin (begin e…)
…
builtins
str (str a b …) → string
str-contains (str-contains s needle) → bool
…
macros
unless (unless test . body)
when (when test . body)
functions
compose (compose f g)
…
— 213 functions
Your own definitions appear last, with their real parameter lists.
source: one definition#
For anything written in EELisp, source echoes the definition back as it was written. That includes the comment block above it, its indentation and the comments inside it:
> (source when)
when — macro
;; Run the body only when test is true; otherwise nil.
(defmacro when (test . body)
`(if ,test (do ,@body) nil))
A builtin has no EELisp source, so source shows its manual entry instead. That's a signature, one line of prose and a worked example:
> (source map)
map — builtin
(map f lst) → list
Applies f to every element.
(map (fn (n) (* n n)) '(1 2 3)) → (1 4 9)
source doesn't evaluate its argument, so (source map), (source 'map) and (source "map") all work. Special forms have entries too: (source let).
The same, as data#
A program such as a form that browses the functions wants data, not printed text:
(function-list "sheet-open")
; → ({:kind "builtin" :name "sheet-open" :sig "(sheet-open name) → dict" :summary "…"})
(source-text "map") ; what (source map) prints, as a string
; this one evaluates its argument, so the name can come from a variable
Writing documentation#
Because source shows the comment block above a definition, the comment is the documentation. Nothing has to be repeated anywhere else:
;; The first n elements. (take 2 '(1 2 3)) → (1 2)
(defn take (n lst)
(if (or (= n 0) (empty? lst))
(list)
(cons (head lst) (take (- n 1) (tail lst)))))
The rules for which comment belongs to which definition are short:
- The run of
;lines directly above a top-level definition belongs to it. - A blank line, or a line with code on it, ends the block. A comment only ever documents the definition immediately beneath it.
- A comment after a definition, on the same line, belongs to that definition too. That's how a file of one-line helpers documents itself.
- Redefining a name replaces its recorded source, which is what someone editing at a REPL expects.
(defn inc2 (n) (+ n 2)) ; two more — this trailing comment documents inc2
The whole prelude is written this way. (source take) shows the comment above.
Why it can't go stale#
The engine's test suite checks that the builtin manual and the environment name exactly the same set of functions. A builtin added without a manual entry fails the tests, and so does a manual entry for a function that no longer exists. The function reference in these pages is generated from the same tables.
Continue with Architecture.
9. Architecture#
This chapter is for people changing the engine, or embedding it and wanting to know what happens beneath a call. The source is about 9 400 lines of Rust in src/, and the knowledge graph in graphify-out/ (built with /graphify) maps it symbol by symbol.
The layers#
| Layer | Files | Responsibility |
|---|---|---|
| Hosts | bin/eelisp.rs, server.rs, web/src/lib.rs | Turn a request (a line, a JSON object, a JS call) into eval_host |
| Interpreter | interpreter.rs | Build the environment, own the shared state, evaluate source |
| Reader | lexer.rs, parser.rs | Text → values, remembering each top-level form's text and comments |
| Evaluator | eval.rs, env.rs | Special forms, macros, application; the tail-call trampoline |
| Values | value.rs, printer.rs, host.rs | The Value enum; printing for people; tagged JSON for hosts |
| Library | builtins.rs, prelude.rs, docs.rs, output.rs | Core functions in Rust, the prelude in EELisp, the manual, output capture |
| Database | database.rs, db_builtins.rs | SQLite store with a JSON schema, soft delete, views |
| Agenda | agenda.rs, agenda_builtins.rs, smart_parser.rs, dates.rs | Items, categories, rules, recurrence, natural-language capture |
| Sheets | sheet_ref.rs, sheet.rs, sheet_builtins.rs | A1 addressing, the dependency graph, recalculation |
| Editor bridge | editor.rs | Functions whose behaviour the host installs as callbacks |
Two rules hold the design together:
- The core does no I/O of its own. Apart from SQLite files, everything that touches the outside world is either a builtin the host can leave out (
http-get) or a callback the host installs (current-dir,buffer-text). That's why the same crate compiles to WebAssembly. - The interpreter is single-threaded. Shared state is
Rc<RefCell<…>>. A multi-threaded host usesEngineHandle, which keeps the interpreter on its own thread and hands out aSend + Synchandle (see Embedding).
Start-up#
Interpreter::new() builds one global environment and registers every module's builtins into it. The modules share state, such as the database and the output buffer, through reference-counted cells captured by their closures. Finally it evaluates the prelude, which is EELisp source.
From text to value#
eval_str reads all the top-level forms first, then evaluates them one at a time. After each form is evaluated, its original text and the comment block above it are recorded in the SourceIndex. That's what (source f) prints.
The evaluator#
eval is one function with a loop around a match. A form in tail position doesn't recurse. It reassigns the current expression and environment and goes round the loop again, so the Rust stack doesn't grow. The tail positions are the taken branch of if, the last form of do/begin/let, the chosen cond branch, the last operand of and/or, and the last form of a function body.
loop/recur works a little differently from a plain tail call. recur evaluates its arguments and returns them as a special Recur signal. The nearest enclosing loop catches the signal, rebinds its names and runs the body again. A recur with no enclosing loop reaches the top as the error recur used outside of a loop.
Application#
When the head of a list evaluates to:
- a macro: the arguments are bound unevaluated in a scope made from the macro's closure, the body runs and returns code, and that code becomes the new expression. That's a tail step, so a macro expanding into a loop costs nothing extra.
- a builtin: arguments are evaluated according to the builtin's argument mode, and then the Rust closure is called.
- a function: arguments are evaluated, a child of the function's closure scope is created with the parameters bound (the rest parameter collects the extras), and the body runs with its last form in tail position.
| Argument mode | Used by | Behaviour |
|---|---|---|
Eval | almost everything | every argument is evaluated |
TableFirst | query, insert, count-records, show … | the first argument is passed bare, unless it's a call, and the rest are evaluated |
AllRaw | deftable, defform, defrule, defview, defcategory | nothing is evaluated; the forms are schema or stored code |
AllRaw is why a rule's :when isn't run when the rule is defined. It's stored as text and evaluated later, once per item.
Environments#
An environment is a map from names to values plus a link to its parent. def writes to the scope it's evaluated in, which is the global scope at top level. let, loop, for-each and function calls each create a child scope. set! walks up the chain to the scope that holds the name. Closures capture the environment they were created in, so a function returned from a let keeps its variables alive.
Sheet formulas and rule conditions run in scopes made for the occasion. A formula runs in a child of the global scope with its cell names bound, whatever scope called sheet-set. A rule runs in a child scope that binds the item's fields.
Values#
Value is one Rust enum. Lists and dicts are behind Rc, so passing one around copies a pointer, not the data. Nothing mutates a list or dict after it's built, which is what makes sharing safe.
| Group | Variants |
|---|---|
| Atoms | Number(f64), Str, Bool, Null, Symbol, Keyword |
| Collections | List(Rc<Vec<Value>>), Dict(Rc<OrderedDict>) |
| Code | Function, Builtin, Macro |
| Database | Table, Record, ResultSet |
| Agenda | Item |
| Views | TableView, FormView, the structured contract with a user interface |
OrderedDict keeps its keys in insertion order. That matters because records, forms and the JSON a host receives all depend on field order.
Where the data lives#
| What | Stored in |
|---|---|
| Your tables | a table of the same name, plus _id and _deleted columns |
| Table schemas | JSON in _eelisp_schema, so types, defaults and choices survive a restart |
| Agenda | _items, _categories, _rules, _views and _templates in the same database |
| Rules and views | their conditions as EELisp text, re-read and evaluated when they run |
| Sheets | one .eesheet SQLite file each, with the input, the last value and the format of every cell |
Continue with Embedding.
10. Embedding#
The engine is a library first and a binary second. There are five ways to hold it. The first four end at the same method, Interpreter::eval_host, so they all get the same answer:
| Host | Use it when | Entry point |
|---|---|---|
| Rust, directly | your program is Rust and single-threaded | eelisp::Interpreter |
| Rust, across threads | a Tauri app, or anything that shares state between threads | eelisp::server::EngineHandle |
| Any language | Node, Python, a shell script | eelisp --serve over stdin/stdout |
| A browser | a web page or Web Worker | eelisp-web, compiled to WebAssembly |
| A sandbox | scripting inside another program: a game's mods, an app's plugins | eelisp without its default features, as WebAssembly |
From Rust#
[dependencies]
eelisp = { git = "https://github.com/santacroce-tech/eelisp-rs" }
use eelisp::Interpreter;
let it = Interpreter::new(); // in-memory database
let it = Interpreter::with_database("notes.db"); // on disk; panics if it can't open
let it = Interpreter::with_database_or_memory("notes.db"); // falls back to memory, says why
let v = it.eval_str("(+ 1 2)")?; // → Value, the last form's result
let all = it.eval_all("(def x 1) (* x 41)")?; // → Vec<Value>, one per form
let out = it.take_output(); // what print/println produced
By default, print output is echoed to stdout, which suits a command line. Call it.set_echo(false) in a host that wants to capture it instead.
The JSON boundary#
A user interface wants JSON, not Value. There are two methods for that:
let json: String = it.eval_json("(browse books)")?; // the result as tagged JSON
let envelope: String = it.eval_host("(browse books)"); // the full envelope — never fails
eval_host is the method to build a UI on. It always returns a well-formed envelope, with any error inside it rather than raised:
{ "ok": true, "result": 3.0, "output": "" }
{ "ok": true, "result": [1.0, {"$kw": "a"}], "output": "hi\n" }
{ "ok": false, "error": "Type mismatch: expected list, got number", "output": "" }
The JSON shape of values#
Numbers, strings, booleans, nil and lists map to their JSON equivalents. Everything else becomes an object with a single $-tagged key, so a TypeScript client can switch on the tag:
| Tag | Value | Shape |
|---|---|---|
$kw / $sym | keyword / symbol | {"$kw": "name"} |
$dict | dict | {"$dict": [["name", "Ada"], ["born", 1815]]}, a list of pairs so the key order survives |
$record | database row | {"$record": {"table", "id", "data": {…}}} |
$resultSet | query result | {"$resultSet": {"table", "columns", "records": […]}} |
$table | table definition | {"$table": {"name", "fields": [{name, type, required, choices}]}} |
$item | agenda item | {"$item": {"id", "text", "notes", "categories", "properties", "created", "modified"}} |
$tableView | (browse t) | {"$tableView": {"tableName", "tableDef", "resultSet"}} |
$formView | (edit t id), (defform …) | {"$formView": {…, "computedFields": [{name, type, expression}], "isStandalone"}} |
$fn / $builtin / $macro | code | {"$fn": "square"} |
That's how (browse books) becomes a real grid in EEditor instead of a string. A computed field travels as the text of its expression, so a form can re-run it as the user types.
Holding it across threads#
Interpreter uses Rc and RefCell, so it can't cross threads. EngineHandle owns an interpreter on a thread of its own and talks to it over a channel. The handle you keep is Send + Sync, which is what a Tauri app stores in its state.
use eelisp::server::EngineHandle;
let engine = EngineHandle::spawn("notes.db".to_string());
let envelope: String = engine.eval("(+ 1 2)"); // {"ok":true,"result":3.0,"output":""}
engine.open_database("other/notes.db")?; // move to another workspace between jobs
The database is opened on the engine's thread, so a slow or permission-gated folder holds up evaluation but never your UI thread. If the file can't be opened, the engine runs in memory and (database-info) reports :error.
Giving the language new powers#
The editor functions (buffer-text, cursor-pos, replace-range and the rest) aren't built into the language. The host installs them as callbacks. Until it does, they're inert: reads return "" or nil, and writes do nothing. This is the intended way to extend EELisp. The engine stays free of I/O and platform types, and the embedder decides what the language can reach.
With EngineHandle, install the callbacks in spawn_with. It runs on the engine thread before the first job, so anything it captures must be Send. An Arc<Mutex<…>> that the host also holds is the usual shape.
use std::sync::{Arc, Mutex};
let doc = Arc::new(Mutex::new(String::from("hello")));
let d = doc.clone();
let engine = EngineHandle::spawn_with(":memory:".into(), move |it| {
let mut ed = it.editor.borrow_mut();
let r = d.clone();
ed.buffer_text = Some(Box::new(move || r.lock().unwrap().clone()));
ed.current_dir = Some(Box::new(|| "/Users/me/notes".into()));
});
engine.eval("(str-len (buffer-text))"); // → 5
| Callback | EELisp function |
|---|---|
buffer_text | (buffer-text) |
current_file | (current-file) |
current_dir | (current-dir), which is also where sheet names resolve and where (notes), (read-note path), (dir-list folder) and (file-info path) read |
roots | folders outside the workspace the person granted, as (name, path): a script reads them as ~name/… and lists them with (roots), but can't add one |
cursor_pos / set_cursor | (cursor-pos) / (set-cursor n) |
selection | (selection) → (from to) |
insert_at | (insert-at pos text) |
replace_range | (replace-range from to text) |
Your own functions#
For anything beyond the editor, add functions to the language with define_fn. Their arguments arrive evaluated, and an error they return is the script's error, just as if a builtin had failed.
use eelisp::{Interpreter, LispError, Value};
let it = Interpreter::new();
it.define_fn("set-block", move |args| {
let n = |i: usize| match args.get(i) {
Some(Value::Number(n)) => Ok(*n as i32),
_ => Err(LispError::Runtime("set-block wants x y z".into())),
};
world.set_block(n(0)?, n(1)?, n(2)?);
Ok(Value::Bool(true))
});
it.eval_str("(for-each y (range 0 10) (set-block 1 y 2))")?;
Going the other way, call runs a function the script defined, by name. That's how a host tells a script that something happened. It returns None when the script hasn't defined that function, so a script only writes the handlers it cares about:
it.eval_str(r#"(defn on-join (player name) (println "Hello " name))"#)?;
if let Some(result) = it.call("on-join", &[Value::Number(7.0), Value::Str("Ada".into())]) {
result?; // the handler ran; an error is the script's
}
From any language: --serve#
eelisp --serve reads one request per line on stdin and writes one envelope per line on stdout. There are no sockets, no ports and no protocol library. A request is either a JSON object with a src field, or bare EELisp that's gathered across lines until its brackets balance.
$ eelisp --serve --db notes.db --workspace ~/notes
{"src": "(+ 1 2)"}
{"ok":true,"output":"","result":3.0}
(+ 1
2)
{"ok":true,"output":"","result":3.0}
--db keeps tables and the agenda in a file; without it they're in memory. --workspace sets what (current-dir) answers. Driving it from Node takes about ten lines:
import { spawn } from "node:child_process";
import readline from "node:readline";
const child = spawn("eelisp", ["--serve"], { stdio: ["pipe", "pipe", "inherit"] });
const rl = readline.createInterface({ input: child.stdout });
const queue = [];
rl.on("line", (l) => queue.shift()?.(JSON.parse(l)));
const ev = (src) =>
new Promise((res) => (queue.push(res), child.stdin.write(JSON.stringify({ src }) + "\n")));
await ev("(deftable books (title:string))");
In a browser: eelisp-web#
web/ compiles the whole engine, SQLite included, to WebAssembly. That's about 1 MB gzipped, and it starts in around 20 ms. One Engine is one interpreter with its own in-memory database. It has no thread: it runs on the page, or on a Web Worker.
import init, { Engine } from "./pkg/eelisp_web.js";
await init();
const e = new Engine();
JSON.parse(e.eval('(deftable pets (name:string)) (insert pets {:name "Rex"}) (query pets)'));
const bytes = e.exportDb(); // the database as a SQLite file, to keep between visits
e.importDb(bytes); // …and back
e.changes(); // rows changed since open or import: something new to save?
e.importSheet("examples/Budget", sheetBytes);
e.exportSheet("examples/Budget");
JSON.parse(e.sheetVersions()); // [["examples/Budget.eesheet", 7], …]
Build it with web/build.sh. The browser build leaves out http-get and http-post (they say so instead of crashing), EngineHandle (there are no threads), and agenda file export/import (there's no filesystem).
In a sandbox: the language alone#
To let people script your program safely, run EELisp inside a WebAssembly sandbox (wasmtime, wasmer, or a game's mod runtime) that gives scripts nothing but the functions you add. Build the engine without its default features and it's just the language: no SQLite (so no C code to compile to WebAssembly), no network, and no JavaScript.
[dependencies]
eelisp = { git = "https://github.com/santacroce-tech/eelisp-rs", default-features = false }
That leaves out the database, the agenda and sheets. The rest of the language is there: functions, macros, strings, lists, dicts, dates, CSV, JSON, regular expressions, bytes, and (functions).
A bare WebAssembly module has no clock, so tell the engine the time when you have it, or dates start at 1970:
#[cfg(target_arch = "wasm32")]
eelisp::dates::set_now(seconds_since_1970);
Ourcraft runs its mods this way. Each .el file is a mod, run by its own copy of a small runner that adds the game's functions with define_fn, calls the script's on-join and on-chat with call, and lives in the same sandbox as every other mod. The interpreter, without SQLite, compiles to about 1.3 MB of WebAssembly.
Building#
git clone https://github.com/santacroce-tech/eelisp-rs
cd eelisp-rs
cargo test # the acceptance suite
cargo build --release --bin eelisp # the CLI
cargo llvm-cov --summary-only # line coverage, if cargo-llvm-cov is installed
SQLite is compiled in through rusqlite's bundled feature, so the binary doesn't depend on a system SQLite. The engine is MIT licensed. Its features choose what's built in:
| Feature | Default | What it adds |
|---|---|---|
sqlite | on | the database, the agenda and sheets, on SQLite |
http | on | http-get and http-post, with ureq |
js | off | in a browser, the clock from JavaScript's Date.now() (the web/ crate turns it on) |
cargo build # everything
cargo build --no-default-features --features sqlite # no network
cargo build --no-default-features # the language alone
cargo build --no-default-features --target wasm32-unknown-unknown --lib # …for a sandbox
That's the whole engine. The function reference lists everything callable from EELisp.
Function reference#
Everything callable from EELisp: 241 entries. This page is generated by scripts/build-docs.py from the engine's built-in manual (src/docs.rs) and the prelude (src/prelude.rs), the same text (source name) prints in a REPL. Don't edit it by hand; change the manual or the prelude and rebuild.
Signatures show the return type after →. Arguments written :key … are optional keyword arguments.
Index#
- Special forms: [
def](#def), [defn](#defn), [defun](#defun), [fn](#fn), [lambda](#lambda), [defmacro](#defmacro), [let](#let), [set!](#set), [if](#if), [cond](#cond), [and](#and), [or](#or), [do](#do), [begin](#begin), [for-each](#for-each), [loop](#loop), [try](#try), [recur](#recur), [quote](#quote), [quasiquote](#quasiquote), [unquote](#unquote), [unquote-splicing](#unquote-splicing) - Arithmetic: [
+](#), [-](#-), [*](#-1), [/](#-2), [mod](#mod), [abs](#abs), [min](#min), [max](#max), [sum](#sum), [avg](#avg), [floor](#floor), [ceil](#ceil), [round](#round), [pow](#pow), [expt](#expt) - Comparison and logic: [
=](#-3), [!=](#-4), [<](#-5), [>](#-6), [<=](#-7), [>=](#-8), [not](#not) - Strings: [
str](#str), [str-len](#str-len), [str-upper](#str-upper), [str-lower](#str-lower), [str-trim](#str-trim), [str-split](#str-split), [str-join](#str-join), [str-contains](#str-contains), [str-matches](#str-matches), [str-replace](#str-replace), [substr](#substr), [str-starts-with](#str-starts-with), [str-ends-with](#str-ends-with) - Lists: [
list](#list), [cons](#cons), [head](#head), [car](#car), [tail](#tail), [cdr](#cdr), [nth](#nth), [length](#length), [append](#append), [reverse](#reverse), [range](#range), [map](#map), [filter](#filter), [reduce](#reduce), [sort-by](#sort-by), [zip](#zip), [empty?](#empty) - Dicts: [
dict](#dict), [dict-get](#dict-get), [dict-set](#dict-set), [dict-keys](#dict-keys), [dict-values](#dict-values), [dict-has](#dict-has), [dict-merge](#dict-merge) - Types and conversion: [
type](#type), [number?](#number), [string?](#string), [bool?](#bool), [list?](#list-1), [nil?](#nil), [symbol?](#symbol), [keyword?](#keyword), [fn?](#fn-1), [->string](#-string), [->number](#-number), [->bool](#-bool), [parse](#parse) - Dates: [
now](#now), [today](#today), [date-format](#date-format), [date-add](#date-add), [date-diff](#date-diff) - Evaluation, JSON and the network: [
print](#print), [println](#println), [eval](#eval), [apply](#apply), [error](#error), [csv-parse](#csv-parse), [csv-records](#csv-records), [csv-stringify](#csv-stringify), [json-parse](#json-parse), [json-stringify](#json-stringify), [http-get](#http-get), [http-post](#http-post) - Finding your way around: [
functions](#functions), [source](#source), [function-list](#function-list), [source-text](#source-text) - Database: [
deftable](#deftable), [insert](#insert), [query](#query), [records](#records), [update](#update), [delete](#delete), [pack](#pack), [count-records](#count-records), [tables](#tables), [describe](#describe), [drop-table](#drop-table), [database-info](#database-info), [browse](#browse), [edit](#edit), [defform](#defform), [record->dict](#record-dict), [field-get](#field-get), [field-set](#field-set), [record-id](#record-id) - Agenda: [
add-item](#add-item), [add-item-today](#add-item-today), [add](#add), [smart-parse](#smart-parse), [items](#items), [items-on](#items-on), [items-between](#items-between), [item-get](#item-get), [item->dict](#item-dict), [item-set](#item-set), [item-done](#item-done), [item-count](#item-count), [every](#every), [defcategory](#defcategory), [assign](#assign), [unassign](#unassign), [categories](#categories), [defrule](#defrule), [apply-rules](#apply-rules), [auto-categorize](#auto-categorize), [rules](#rules), [drop-rule](#drop-rule), [defview](#defview), [show](#show), [views](#views), [drop-view](#drop-view), [deftemplate](#deftemplate), [from-template](#from-template), [templates](#templates), [drop-template](#drop-template), [open-agenda](#open-agenda), [use-agenda](#use-agenda), [close-agenda](#close-agenda), [agendas](#agendas), [export-agenda](#export-agenda), [import-agenda](#import-agenda) - Bytes and bits: [
band](#band), [bor](#bor), [bxor](#bxor), [bnot](#bnot), [shl](#shl), [shr](#shr), [make-bytes](#make-bytes), [bytes-len](#bytes-len), [bget](#bget), [bset](#bset), [bfill](#bfill), [bcopy](#bcopy), [list->bytes](#list-bytes), [bytes->list](#bytes-list), [bytes->base64](#bytes-base64), [base64->bytes](#base64-bytes) - Sheets: [
sheet-new](#sheet-new), [sheet-open](#sheet-open), [sheet-bytes](#sheet-bytes), [sheet-from-bytes](#sheet-from-bytes), [sheet-close](#sheet-close), [sheet-get](#sheet-get), [sheet-rows](#sheet-rows), [sheet-set](#sheet-set), [sheet-put](#sheet-put), [sheet-copy](#sheet-copy), [sheet-paste](#sheet-paste), [sheet-fill](#sheet-fill), [sheet-recalc](#sheet-recalc), [sheet-clear](#sheet-clear), [sheet-format](#sheet-format), [sheet-col-width](#sheet-col-width), [sheet-row-height](#sheet-row-height), [sheet-insert-rows](#sheet-insert-rows), [sheet-delete-rows](#sheet-delete-rows), [sheet-insert-cols](#sheet-insert-cols), [sheet-delete-cols](#sheet-delete-cols), [sheet-version](#sheet-version) - Editor RPC: [
buffer-text](#buffer-text), [current-file](#current-file), [current-dir](#current-dir), [notes](#notes), [read-note](#read-note), [roots](#roots), [dir-list](#dir-list), [file-info](#file-info), [cursor-pos](#cursor-pos), [selection](#selection), [set-cursor](#set-cursor), [insert-at](#insert-at), [replace-range](#replace-range) - Prelude: [
id](#id), [inc](#inc), [dec](#dec), [even?](#even), [odd?](#odd), [zero?](#zero), [pos?](#pos), [neg?](#neg), [first](#first), [second](#second), [third](#third), [last](#last), [take](#take), [drop](#drop), [count](#count), [some?](#some), [every?](#every-1), [compose](#compose), [when](#when), [unless](#unless)
Special forms#
def#
(def name value)
Defines a global binding.
(def pi 3.14159) → 3.14159
defn#
(defn name (params) body…)
Defines a function. A parameter list may end with . rest to collect the remaining arguments.
(defn square (n) (* n n))
(square 7) → 49
defun#
(defun name (params) body…)
The Common Lisp spelling of defn — the same form.
(defun square (n) (* n n))
fn#
(fn (params) body…) → function
An anonymous function.
(map (fn (n) (* n 2)) '(1 2 3)) → (2 4 6)
lambda#
(lambda (params) body…) → function
The Common Lisp spelling of fn — the same form.
(map (lambda (n) (* n 2)) '(1 2 3)) → (2 4 6)
defmacro#
(defmacro name (params) body…)
Defines a macro; rest parameters and quasiquote are allowed.
(defmacro when (test . body)
`(if ,test (do ,@body) nil))
let#
(let (a 1 b 2) body…)
Local bindings, given as one flat list. Each may refer to the ones before it.
(let (a 2 b (* a 3)) (+ a b)) → 8
set!#
(set! name value)
Reassigns an existing binding. It is an error if the name is unbound.
(def n 1)
(set! n (+ n 1)) → 2
if#
(if test then else)
Two-way branch. Only false and nil are falsy — 0, "" and () are all true.
(if (> 3 2) "bigger" "smaller") → "bigger"
cond#
(cond test result …)
Flat multi-way branch: pairs of test and result, first match wins.
(cond (< n 0) "neg"
(= n 0) "zero"
true "pos")
and#
(and a b …)
Short-circuiting conjunction — returns the first falsy value, or the last one.
(and 1 2 3) → 3
or#
(or a b …)
Short-circuiting disjunction — returns the first truthy value.
(or nil false 7) → 7
do#
(do e…)
Evaluates its forms in order and returns the last.
(do (println "hi") 42) → 42
begin#
(begin e…)
The Scheme spelling of do — the same form.
(begin 1 2 3) → 3
for-each#
(for-each var list body…)
Runs the body once per element, for its effects. Returns nil.
(for-each x '(1 2 3) (println x))
loop#
(loop (a 0 b 1) body…)
A tail-recursive loop with its own bindings; recur jumps back with new values.
(loop (i 0 acc 0)
(if (> i 4) acc (recur (+ i 1) (+ acc i)))) → 10
try#
(try body… (catch e handler…))
Runs the body; if it stops with an error, binds e to {:message … :kind …} and runs the handler instead. Without a catch, an error makes it nil.
(try (/ 1 0) (catch e (dict-get e :kind))) → "division-by-zero"
recur#
(recur v…)
Jumps back to the enclosing loop with a new set of values. Tail position only.
(recur (+ i 1) (* acc i))
quote#
(quote x) · 'x
Returns its argument unevaluated.
'(1 2 3) → (1 2 3)
quasiquote#
(quasiquote x) · x`
A template: , unquotes one value, ,@ splices a list in.
(def xs '(2 3))
`(1 ,@xs 4) → (1 2 3 4)
unquote#
(unquote x) · ,x
Inside a quasiquote, evaluates x and drops the value in.
unquote-splicing#
(unquote-splicing x) · ,@x
Inside a quasiquote, evaluates x and splices the list in.
Arithmetic#
+#
(+ a b …) → number
Adds every argument.
(+ 1 2 3) → 6
-#
(- a b …) → number
Subtracts the rest from the first. (- 5) is 5 — write -5 for a negative literal.
(- 10 3 2) → 5
*#
(* a b …) → number
Multiplies every argument.
(* 2 3 4) → 24
/#
(/ a b …) → number
Divides left to right. Dividing by zero is an error.
(/ 100 5 2) → 10
mod#
(mod a b) → number
The remainder of a divided by b.
(mod 7 3) → 1
abs#
(abs n) → number
Absolute value.
(abs -4) → 4
min#
(min a b …) → number
The smallest number. Lists are searched too, skipping nil and text — so (min B1:B9) reads a range.
(min 3 '(1 nil 2)) → 1
max#
(max a b …) → number
The largest number. Lists are searched too, skipping nil and text.
(max 3 '(1 nil 7)) → 7
sum#
(sum a b …) → number
Adds the numbers, looking inside lists and skipping nil and text — what a sheet's (sum B1:B9) needs. Nothing to add is 0.
(sum 1 '(2 nil "x" 3)) → 6
avg#
(avg a b …) → number
The mean of the numbers, looking inside lists and skipping nil and text. An error when there are none.
(avg '(2 4 nil)) → 3
floor#
(floor n) → number
Rounds down.
(floor 2.7) → 2
ceil#
(ceil n) → number
Rounds up.
(ceil 2.1) → 3
round#
(round n) → number
Rounds to the nearest whole number, halves away from zero.
(round 2.5) → 3
pow#
(pow base exp) → number
Raises base to exp.
(pow 2 10) → 1024
expt#
(expt base exp) → number
The Common Lisp spelling of pow — the same function.
(expt 2 10) → 1024
Comparison and logic#
=#
(= a b …) → bool
Structural equality: lists and dicts compare by content, functions never compare equal.
(= '(1 2) '(1 2)) → true
!=#
(!= a b) → bool
The negation of =.
(!= 1 2) → true
<#
(< a b …) → bool
True when the arguments are in strictly ascending order.
(< 1 2 3) → true
>#
(> a b …) → bool
True when the arguments are in strictly descending order.
(> 3 2 1) → true
<=#
(<= a b …) → bool
Ascending order, ties allowed.
(<= 1 1 2) → true
>=#
(>= a b …) → bool
Descending order, ties allowed.
(>= 2 1 1) → true
not#
(not x) → bool
Logical negation. Only false and nil are falsy — 0, "" and () are all true.
(not 0) → false
(not nil) → true
Strings#
str#
(str a b …) → string
Concatenates anything, converting as needed.
(str "x=" 42) → "x=42"
str-len#
(str-len s) → number
Length in characters.
(str-len "hello") → 5
str-upper#
(str-upper s) → string
Upper-cases a string.
(str-upper "hi") → "HI"
str-lower#
(str-lower s) → string
Lower-cases a string.
(str-lower "Hi") → "hi"
str-trim#
(str-trim s) → string
Drops leading and trailing whitespace.
(str-trim " hi ") → "hi"
str-split#
(str-split s sep) → list
Splits on a separator.
(str-split "a,b,c" ",") → ("a" "b" "c")
str-join#
(str-join sep parts) → string
Joins a list with a separator. Either argument order is accepted.
(str-join "-" '("a" "b")) → "a-b"
str-contains#
(str-contains s needle) → bool
Is needle anywhere in s?
(str-contains "hello" "ell") → true
str-matches#
(str-matches s pattern) → bool
Regular-expression test.
(str-matches "a1" "^[a-z][0-9]$") → true
str-replace#
(str-replace s target repl) → string
Replaces every occurrence of target.
(str-replace "a-b-c" "-" "+") → "a+b+c"
substr#
(substr s start end) → string
A slice by character index: 0-based, end-exclusive.
(substr "hello" 1 3) → "el"
str-starts-with#
(str-starts-with s prefix) → bool
Does s begin with prefix?
(str-starts-with "hello" "he") → true
str-ends-with#
(str-ends-with s suffix) → bool
Does s end with suffix?
(str-ends-with "hello" "lo") → true
Lists#
list#
(list a b …) → list
Builds a list. [a b] is shorthand for the same thing.
(list 1 2 3) → (1 2 3)
cons#
(cons x lst) → list
Prepends one element.
(cons 1 '(2 3)) → (1 2 3)
head#
(head lst)
The first element, or nil when the list is empty.
(head '(1 2 3)) → 1
car#
(car lst)
The Lisp spelling of head — the same function.
(car '(1 2 3)) → 1
tail#
(tail lst) → list
Everything after the first element.
(tail '(1 2 3)) → (2 3)
cdr#
(cdr lst) → list
The Lisp spelling of tail — the same function.
(cdr '(1 2 3)) → (2 3)
nth#
(nth lst i)
The element at index i, 0-based; nil past the end.
(nth '(1 2 3) 1) → 2
length#
(length lst) → number
How many elements the list has.
(length '(1 2 3)) → 3
append#
(append a b) → list
Concatenates two lists.
(append '(1 2) '(3)) → (1 2 3)
reverse#
(reverse lst) → list
The list back to front.
(reverse '(1 2 3)) → (3 2 1)
range#
(range from to step) → list
A list of numbers, end-exclusive. With one argument it starts at 0; step defaults to 1.
(range 1 5) → (1 2 3 4)
(range 3) → (0 1 2)
map#
(map f lst) → list
Applies f to every element.
(map (fn (n) (* n n)) '(1 2 3)) → (1 4 9)
filter#
(filter pred lst) → list
Keeps the elements pred says yes to.
(filter even? '(1 2 3 4)) → (2 4)
reduce#
(reduce f init lst)
Folds the list left to right, f taking (accumulator element).
(reduce + 0 '(1 2 3)) → 6
sort-by#
(sort-by keyfn lst) → list
A stable ascending sort by a numeric key.
(sort-by str-len '("ccc" "a" "bb")) → ("a" "bb" "ccc")
zip#
(zip a b) → list
Pairs two lists up, stopping at the shorter one.
(zip '(1 2) '("a" "b")) → ((1 "a") (2 "b"))
empty?#
(empty? lst) → bool
Is the list empty?
(empty? '()) → true
Dicts#
dict#
(dict :k v …) → dict
Builds a dict. {:k v} is the literal syntax; keys keep their insertion order.
(dict :a 1 :b 2) → {:a 1 :b 2}
dict-get#
(dict-get d :k)
The value at a key, or nil.
(dict-get {:a 1} :a) → 1
dict-set#
(dict-set d :k v) → dict
A new dict with the key set — the original is untouched.
(dict-set {:a 1} :b 2) → {:a 1 :b 2}
dict-keys#
(dict-keys d) → list
The keys, in insertion order.
(dict-keys {:a 1 :b 2}) → ("a" "b")
dict-values#
(dict-values d) → list
The values, in insertion order.
(dict-values {:a 1 :b 2}) → (1 2)
dict-has#
(dict-has d :k) → bool
Is the key present?
(dict-has {:a 1} :a) → true
dict-merge#
(dict-merge a b) → dict
Merges two dicts; keys from b win.
(dict-merge {:a 1} {:a 9 :b 2}) → {:a 9 :b 2}
Types and conversion#
type#
(type x) → string
The type name: number, string, bool, keyword, nil, list, dict, function, record…
(type '(1)) → "list"
number?#
(number? x) → bool
Is it a number?
(number? 1) → true
string?#
(string? x) → bool
Is it a string?
(string? "a") → true
bool?#
(bool? x) → bool
Is it true or false?
(bool? false) → true
list?#
(list? x) → bool
Is it a list?
(list? '(1)) → true
nil?#
(nil? x) → bool
Is it nil?
(nil? nil) → true
symbol?#
(symbol? x) → bool
Is it a symbol?
(symbol? 'a) → true
keyword?#
(keyword? x) → bool
Is it a keyword?
(keyword? :a) → true
fn?#
(fn? x) → bool
Is it callable — a function or a builtin?
(fn? map) → true
->string#
(->string x) → string
Coerces to a string.
(->string 42) → "42"
->number#
(->number x) → number
Coerces to a number; nil when it doesn't read as one.
(->number "42") → 42
->bool#
(->bool x) → bool
Coerces to a bool: everything but false and nil is true.
(->bool 0) → true
parse#
(parse s)
Reads EELisp source into a value, without evaluating it.
(parse "(+ 1 2)") → (+ 1 2)
Dates#
now#
(now) → number
The current time, in epoch seconds.
(date-format (now) "HH:mm") → "09:41"
today#
(today) → number
Midnight today, in epoch seconds.
(date-format (today) "yyyy-MM-dd") → "2026-09-11"
date-format#
(date-format d pattern) → string
Formats an epoch number or a "YYYY-MM-DD" string. Pattern defaults to "yyyy-MM-dd HH:mm".
(date-format (today) "EEEE") → "Friday"
date-add#
(date-add date n unit) → string
Shifts a "YYYY-MM-DD" date by n :days, :weeks or :months.
(date-add "2026-09-11" 3 :days) → "2026-09-14"
date-diff#
(date-diff a b) → number
Whole days from a to b, both "YYYY-MM-DD" strings.
(date-diff "2026-09-11" "2026-09-14") → 3
Evaluation, JSON and the network#
print#
(print x …) → nil
Prints its arguments with no trailing newline. Captured by the host, not written to a terminal.
(print "a" "b")
println#
(println x …) → nil
Prints its arguments and a newline.
(println "total:" 42)
eval#
(eval form)
Evaluates a value as code — the other half of parse.
(eval (parse "(+ 1 2)")) → 3
apply#
(apply f args)
Calls f with a list of arguments.
(apply + '(1 2 3)) → 6
error#
(error message)
Stops with an error carrying the message — one that try can catch.
(try (error "no price") (catch e (dict-get e :message))) → "no price"
csv-parse#
(csv-parse text sep) → list
Reads CSV into rows of text: quotes, doubled quotes, separators and line breaks inside quotes. The separator is "," unless given — "\t" for TSV.
(csv-parse "a,b\n1,\"x, y\"") → (("a" "b") ("1" "x, y"))
csv-records#
(csv-records text sep) → list
CSV with a header row, as one dict per row keyed by the header; cells that read as numbers are numbers, empty cells nil.
(csv-records "symbol,price\nAAPL,231.5") → ({:symbol "AAPL" :price 231.5})
csv-stringify#
(csv-stringify rows sep) → string
Writes rows as CSV, quoting the cells that need it.
(csv-stringify '(("a" "b") (1 "x, y"))) → "a,b\n1,\"x, y\""
json-parse#
(json-parse s)
Reads a JSON string into dicts, lists and scalars.
(json-parse "{\"a\":1}") → {:a 1}
json-stringify#
(json-stringify v) → string
Writes a value as JSON.
(json-stringify {:a 1}) → "{\"a\":1}"
http-get#
(http-get url :timeout s :headers {…}) → dict
A synchronous GET: {:status 200 :body "…"}. A 4xx/5xx answer is returned the same way, with its status; no answer at all (offline, or :timeout seconds — 30 unless given, at most 300) is an error. :headers sends a dict of header → value. With http-post, the only network access in the language.
(json-parse (dict-get (http-get "https://example.com/api" :timeout 5 :headers {"x-api-key" key}) :body))
http-post#
(http-post url body :timeout s :headers {…} :content-type t) → dict
A synchronous POST; returns {:status … :body …} like http-get, takes the same options, and :content-type (application/json unless given).
(dict-get (http-post "https://example.com/api" "{}") :status) → 200
Finding your way around#
functions#
(functions filter) → nil
Lists every function, macro and special form in scope; the filter keeps the names containing it.
(functions) ; everything
(functions "date") ; just the date ones
source#
(source name) → nil
Shows a definition: the source and comments for EELisp code, the manual entry for a builtin.
(source map)
(source zzalinhar)
function-list#
(function-list filter) → list
What (functions) lists, as data: one dict per name with name, kind, sig and summary.
(map (fn (r) (dict-get r "name")) (function-list "str-j")) → ("str-join")
source-text#
(source-text name) → string
What (source name) prints, as a string. The name is evaluated, so it may come from a variable.
(source-text "square")
Database#
deftable#
(deftable name (fields…)) → table
Defines a table. A field is name:type or (name :type … :required … :default … :choices …); types are string, number, bool, date, memo, choice.
(deftable contacts (name:string age:number))
insert#
(insert table {dict}) → record
Inserts a row and returns it, id included.
(insert contacts {:name "Ada" :age 36})
query#
(query table :where … :params … :order … :asc … :desc … :limit … :select …) → result-set
Selects rows. :where is SQL with ? placeholders filled from :params.
(query contacts :where "age > ?" :params '(30) :order "name")
records#
(records rs) → list
The rows of a result-set, as an ordinary list.
(map record-id (records (query contacts)))
update#
(update table id {dict}) → record
Changes the named fields of one row.
(update contacts 1 {:age 37})
delete#
(delete table id) → bool
A soft delete — the row is marked, not removed. See pack.
(delete contacts 1)
pack#
(pack table) → number
Physically removes the soft-deleted rows and returns how many went.
(pack contacts)
count-records#
(count-records table :where … :params …) → number
How many rows match.
(count-records contacts :where "age > ?" :params '(30))
tables#
(tables) → list
The names of every defined table.
(tables) → ("contacts")
describe#
(describe table) → table
The schema of a table: its fields and their types.
(describe contacts)
drop-table#
(drop-table table) → bool
Deletes a table and everything in it.
(drop-table contacts)
database-info#
(database-info) → dict
Which database file the tables and agenda live in — :memory: when nothing is kept. :error says why, when the host asked for a file that couldn't be opened.
(database-info) → {:path "/Users/me/notes/.eeditor/eeditor.db"}
browse#
(browse table) → table-view
An interactive grid of the rows — a widget in the editor, an ASCII table on the command line.
(browse contacts)
edit#
(edit table id) → form-view
An interactive form for one record.
(edit contacts 1)
defform#
(defform name table (field)…) → form-view
A named form; a field given an expression becomes a computed field.
(defform total invoices (qty) (price) (sum :number (* qty price)))
record->dict#
(record->dict r) → dict
A row as a plain dict, its id included — for json-stringify, dict-get and the rest.
(json-stringify (map record->dict (records (query contacts))))
field-get#
(field-get r "name")
One field of a record.
(field-get r "name") → "Ada"
field-set#
(field-set r "name" v) → record
A copy of the record with one field changed.
(field-set r "age" 37)
record-id#
(record-id r) → number
The record's id.
(record-id r) → 1
Agenda#
add-item#
(add-item text :when … :priority … :category … :notes …) → item
Adds an item with explicit properties.
(add-item "call Bob" :when "2026-09-14" :priority 1)
add-item-today#
(add-item-today text) → item
Adds an item due today.
(add-item-today "water the plants")
add#
(add text) → item
Adds an item written in plain language — the date, priority and people are parsed out of the text.
(add "call Bob tomorrow !!")
smart-parse#
(smart-parse text) → dict
The same parse as add, without storing anything.
(smart-parse "lunch friday @ana")
items#
(items :search … :category … :priority … :when-before … :when-after …) → list
The items matching a filter; no filter means all of them. A category takes in its children.
(items :category "work" :when-before "2026-10-01")
items-on#
(items-on date) → list
Items due on one day.
(items-on "2026-09-11")
items-between#
(items-between from to) → list
Items due in a date range, ends included.
(items-between "2026-09-01" "2026-09-30")
item-get#
(item-get id) → item
One item by id.
(item-get 3)
item->dict#
(item->dict item) → dict
An item's fields as a dict — id, text, its properties (when, priority, …), notes, categories, created, modified.
(dict-get (item->dict (item-get 3)) "when") → "2026-09-14"
item-set#
(item-set id :k v …) → item
Changes an item's text, properties or notes.
(item-set 3 :priority 1)
item-done#
(item-done id) → item
Marks an item done; a recurring item rolls forward to its next date.
(item-done 3)
item-count#
(item-count) → number
How many items the agenda holds.
(item-count)
every#
(every n unit) → string
A recurrence — :days, :weeks or :months — for an item's :recurrence property.
(add-item "rent" :recurrence (every 1 :months))
defcategory#
(defcategory work/calls) → string
Defines a category. Paths are hierarchical and the parents are implied. The name is taken as written; a call there is evaluated.
(defcategory work/calls)
(defcategory (str "work/" name))
assign#
(assign id "path") → item
Files an item under a category, defining the category if it's new.
(assign 3 "work/calls")
unassign#
(unassign id "path") → item
Removes an item from a category.
(unassign 3 "work/calls")
categories#
(categories) → string
Every category as an indented tree, one per line.
(categories) → "work\n work/calls"
defrule#
(defrule name :when … :assign … :action …) → dict
A rule that files items automatically. :assign and :action may repeat.
(defrule calls :when (str-contains (str-lower text) "call") :assign "work/calls")
apply-rules#
(apply-rules id) → number
Runs the rules over one item, or over all of them with no id. Returns how many changed.
(apply-rules) → 4
auto-categorize#
(auto-categorize on) → bool
Whether new items get the rules applied as they arrive — add and add-item then return the item as the rules left it.
(auto-categorize true)
rules#
(rules) → string
Every defined rule, one "name: condition" per line.
(rules)
drop-rule#
(drop-rule "name") → bool
Deletes a rule.
(drop-rule "calls")
defview#
(defview name :filter expr :sort-by field :group-by field :desc bool) → string
A saved, filtered — optionally sorted and grouped — view of the agenda. The filter sees what a rule condition sees: text, when, (has-category …), (overdue?).
(defview late :filter (overdue?) :sort-by when)
(defview calls :filter (has-category "work/calls"))
show#
(show name) → result-set
Runs a saved view — the items as a result-set, or a text outline when the view groups.
(show today)
views#
(views) → string
Every saved view's name, one per line.
(views)
drop-view#
(drop-view "name") → bool
Deletes a saved view.
(drop-view "today")
deftemplate#
(deftemplate name :text … :notes … :category … :priority … :recur …) → string
A template of item properties to stamp out repeatedly. Defining it again replaces it.
(deftemplate standup :priority 2 :category "work")
from-template#
(from-template name text :when … :priority … :category … :notes …) → item
Adds an item from a template, with this text (the template's :text when none is given); keywords override the template.
(from-template standup "monday standup" :when "2026-10-05")
templates#
(templates) → list
Every template.
(templates)
drop-template#
(drop-template name) → bool
Deletes a template.
(drop-template standup)
open-agenda#
(open-agenda "file.db") → string
Opens an agenda file as an isolated database and gives it a name.
(open-agenda "work.db")
use-agenda#
(use-agenda name) → string
Switches which open agenda the item functions act on.
(use-agenda work)
close-agenda#
(close-agenda name) → bool
Closes an open agenda.
(close-agenda work)
agendas#
(agendas) → list
Every open agenda, the current one first.
(agendas)
export-agenda#
(export-agenda name :format :json :path path) → string
Writes an agenda — its items, categories, rules, views and templates — to a JSON file, and says where. name is the agenda's, as (agendas) lists it. The file's folder must exist.
(export-agenda "eeditor" :format :json :path "/tmp/agenda.json")
import-agenda#
(import-agenda path) → string
Reads an exported agenda back into the current one, in one transaction, and says how many items and templates came in.
(import-agenda "/tmp/agenda.json") → "Imported 12 items, 0 templates"
Bytes and bits#
band#
(band a b …) → number
Bitwise AND of the integers.
(band 0xf0 0x3c) ; → 48
bor#
(bor a b …) → number
Bitwise OR of the integers.
(bor 0xf0 0x0f) ; → 255
bxor#
(bxor a b …) → number
Bitwise exclusive OR of the integers.
(bxor 0xff 0x0f) ; → 240
bnot#
(bnot a) → number
Bitwise NOT (two's complement: -a - 1). Mask it for a byte: (band (bnot a) 0xff).
(band (bnot 0x0f) 0xff) ; → 240
shl#
(shl a n) → number
a shifted left n bits. An error past 2^53, where numbers stop being exact.
(shl 1 8) ; → 256
shr#
(shr a n) → number
a shifted right n bits, keeping the sign.
(shr 0x4000 8) ; → 64
make-bytes#
(make-bytes n [fill]) → bytes
A mutable buffer of n bytes, all 0 (or fill). Up to 16 MB.
(make-bytes 65536)
bytes-len#
(bytes-len b) → number
The size of a buffer.
(bytes-len (make-bytes 8)) ; → 8
bget#
(bget b i) → number
The byte at index i, 0 to 255.
(bget (list->bytes '(7 8 9)) 1) ; → 8
bset#
(bset b i v) → number
Stores v & 0xff at index i, in place, and returns the byte stored.
(bset ram 0x4000 0xff)
bfill#
(bfill b start len v) → nil
Sets len bytes from start to v.
(bfill ram 0x4000 6144 0)
bcopy#
(bcopy dst di src si len) → nil
Copies len bytes from src at si to dst at di. dst and src may be the same buffer, overlapping.
(bcopy ram 0x8000 prog 0 (bytes-len prog))
list->bytes#
(list->bytes lst) → bytes
A buffer holding a list of integers, each taken & 0xff.
(list->bytes '(0x3e 0x02 0x76))
bytes->list#
(bytes->list b) → list
A buffer's bytes as a list of numbers.
(bytes->list (make-bytes 3 1)) ; → (1 1 1)
bytes->base64#
(bytes->base64 b [start len]) → string
A buffer, or a run of it, as standard base64 — how bytes cross to a host.
(bytes->base64 ram 0x4000 6912)
base64->bytes#
(base64->bytes s) → bytes
A new buffer from standard base64. An error if s isn't base64.
(bytes->list (base64->bytes "AQID")) ; → (1 2 3)
Sheets#
sheet-new#
(sheet-new name) → string
Creates an empty sheet file and returns its path. An error if the file exists.
(sheet-new "Budget")
sheet-open#
(sheet-open name) → dict
The whole sheet for a host to draw: {:path :version :cells ((row col input value error fmt) …) :widths ((col width) …) :heights ((row height) …)}, rows and columns from 0. Runs no formula — values are the ones stored.
(sheet-open "Budget")
sheet-bytes#
(sheet-bytes name) → string
A sheet's .eesheet file, as base64 — how a host that reads only text carries a sheet (Export as HTML).
(str-len (sheet-bytes "Budget"))
sheet-from-bytes#
(sheet-from-bytes name base64) → string
A new sheet file made from base64 bytes (what sheet-bytes gives) — refuses bytes that aren't a sheet, and an existing file.
(sheet-from-bytes "Copy" (sheet-bytes "Budget"))
sheet-close#
(sheet-close name) → nil
Closes the file. Do it before renaming or deleting one.
(sheet-close "Budget")
sheet-get#
(sheet-get name cell)
One cell's value. A cell whose formula failed is an error.
(sheet-get "Budget" "C3") → 1650
sheet-rows#
(sheet-rows name area) → list
An area's values as a list of rows; whole columns ("E:E") stop at the last filled row.
(sheet-rows "Budget" "A1:B2") → (("rent" 1200) ("food" 450))
sheet-set#
(sheet-set name cell input) → list
Types into a cell — 1200, rent, '42 for text, =(sum B1:B2) for a formula — recalculates what depends on it and saves. A number written as one is read as one: 50% is 0.5 shown as a percentage, $1,200 and 1.200,50 are money and a thousand two hundred. 2026-09-16 is a date; other spellings of one stay text. Given a list of rows, types a whole block from that corner, as a paste would. Returns the changed cells as (row col input value error fmt).
(sheet-set "Budget" "B2" "450")
(sheet-set "Budget" "C1" '(("=(sum B1:B2)") ("=(/ C1 12)")))
sheet-put#
(sheet-put name cell data) → list
Writes a block with its top-left corner at cell: a list of rows, one flat row, or a result set — a header row, then its records. Values stay values: text that would read as a number or formula is kept as text.
(sheet-put "Report" "A1" (query contacts :order "name"))
sheet-copy#
(sheet-copy name area) → list
What was typed into an area, as rows of text — the other half of sheet-paste.
(sheet-copy "Budget" "B1:B3") → (("1200") ("450") ("=(sum B1:B2)"))
sheet-paste#
(sheet-paste name cell rows from) → list
Types rows of input from a cell. Given from — where they were copied — every formula's references move by the distance travelled, so a pasted total adds up its new neighbours. Without it the text is typed exactly, which is what a paste from another program needs.
(sheet-paste "Budget" "C1" (sheet-copy "Budget" "B1:B3") "B1")
sheet-fill#
(sheet-fill name source target) → list
Repeats the source over the target area, each copy's references shifted by where it lands — filling a column of totals down. The source's own cells are left alone.
(sheet-fill "Budget" "C1" "C2:C12")
sheet-recalc#
(sheet-recalc name) → list
Reruns every formula — for ones that read the clock, and for another sheet that wasn't open at the time, which aren't tracked. (A formula that reads the database is recalculated when the tables it read are written.)
(sheet-recalc "Budget")
sheet-clear#
(sheet-clear name area) → list
Empties an area — values and formulas, not formats — and recalculates what read it. Whole columns work: "A:C".
(sheet-clear "Ledger" "A:E")
sheet-format#
(sheet-format name area fmt) → list
Merges a format into every cell of an area — a key set to nil is removed, and a nil format clears it. Given rows of formats instead, sets each cell's exactly from the area's corner. The keys are the host's: :num, :dp, :cur, :date, :bold, :italic, :align, :wrap, :bg, :fg, :border.
(sheet-format "Budget" "B1:B9" {:num "currency" :dp 2})
sheet-col-width#
(sheet-col-width name col width) → nil
A column's width, or nil for the default.
(sheet-col-width "Budget" "A" 160)
sheet-row-height#
(sheet-row-height name row height) → nil
A row's height, or nil for the default. Rows are numbered from 1.
(sheet-row-height "Budget" 3 48)
sheet-insert-rows#
(sheet-insert-rows name row n) → dict
Inserts n rows (default 1) before a row numbered from 1. Formulas are rewritten to follow their cells.
(sheet-insert-rows "Budget" 3)
sheet-delete-rows#
(sheet-delete-rows name row n) → dict
Deletes n rows from a row numbered from 1. A formula that read a deleted cell reads #REF!.
(sheet-delete-rows "Budget" 3 2)
sheet-insert-cols#
(sheet-insert-cols name col n) → dict
Inserts n columns before a column.
(sheet-insert-cols "Budget" "B")
sheet-delete-cols#
(sheet-delete-cols name col n) → dict
Deletes n columns from a column.
(sheet-delete-cols "Budget" "B")
sheet-version#
(sheet-version name) → number
A counter that moves on every write — how a host notices a sheet changed.
(sheet-version "Budget") → 12
Editor RPC#
buffer-text#
(buffer-text) → string
The whole document being edited.
(str-len (buffer-text))
current-file#
(current-file) → string
The path of the open note.
(current-file) → "/notes/today.md"
current-dir#
(current-dir) → string
The workspace root — the folder being edited, not the process's working directory.
(current-dir) → "/Users/me/notes"
notes#
(notes [folder]) → list
Every note (.md) in the workspace, or under one of its folders, as workspace-relative paths, sorted. Hidden folders are skipped.
(notes "reviews") → ("reviews/2026-09-28 week.md")
read-note#
(read-note path) → string
A note's text as saved on disk, or nil if there is no such file. The path is relative to the workspace and can't leave it.
(read-note "inbox.md") → "# Inbox\n…"
roots#
(roots) → list
The folders outside the workspace the person has granted, as the names a path starts with: "~Downloads". Only the host can add one — a script can't.
(roots) → ("~Downloads") (dir-list "~Downloads")
dir-list#
(dir-list [folder] [:hidden true]) → list
One folder's entries, folders first, each a dict: :name, :path (workspace-relative), :dir, :size (bytes), :modified (epoch seconds, like now) and :ext. Names starting with a dot are left out unless :hidden is true. nil when there is no such folder; the path can't leave the workspace — or a granted folder, written ~name/…
(map (fn (e) (dict-get e :name)) (dir-list "notes")) → ("reviews" "inbox.md")
file-info#
(file-info path) → dict
One file or folder as dir-list describes it, or nil when there is none.
(date-format (dict-get (file-info "inbox.md") :modified)) → "2026-11-04 16:35"
cursor-pos#
(cursor-pos) → number
The cursor's character offset in the document.
(cursor-pos) → 142
selection#
(selection) → list
The selection as (from to); both the same when nothing is selected.
(selection) → (10 24)
set-cursor#
(set-cursor n) → nil
Moves the cursor to a character offset.
(set-cursor 0)
insert-at#
(insert-at pos text) → nil
Inserts text at an offset.
(insert-at (cursor-pos) "— ")
replace-range#
(replace-range from to text) → nil
Replaces a range of the document.
(replace-range 0 5 "Hello")
Prelude#
Written in EELisp and loaded when the engine starts. Each definition is shown as written: read them as worked examples of the language.
id#
(id x) · function
The identity function — returns its argument unchanged.
(defn id (x) x)
inc#
(inc n) · function
One more. (inc 4) → 5
(defn inc (n) (+ n 1))
dec#
(dec n) · function
One less. (dec 4) → 3
(defn dec (n) (- n 1))
even?#
(even? n) · function
Is n divisible by two?
(defn even? (n) (= 0 (mod n 2)))
odd?#
(odd? n) · function
Is n not divisible by two?
(defn odd? (n) (not (even? n)))
zero?#
(zero? n) · function
Is n exactly zero?
(defn zero? (n) (= n 0))
pos?#
(pos? n) · function
Is n greater than zero?
(defn pos? (n) (> n 0))
neg?#
(neg? n) · function
Is n less than zero?
(defn neg? (n) (< n 0))
first#
(first lst) · function
The first element of a list.
(defn first (lst) (head lst))
second#
(second lst) · function
The second element of a list.
(defn second (lst) (nth lst 1))
third#
(third lst) · function
The third element of a list.
(defn third (lst) (nth lst 2))
last#
(last lst) · function
The last element of a list.
(defn last (lst)
(if (empty? (tail lst))
(head lst)
(last (tail lst))))
take#
(take n lst) · function
The first n elements. (take 2 '(1 2 3)) → (1 2)
(defn take (n lst)
(if (or (= n 0) (empty? lst))
(list)
(cons (head lst) (take (- n 1) (tail lst)))))
drop#
(drop n lst) · function
Everything after the first n elements. (drop 2 '(1 2 3)) → (3)
(defn drop (n lst)
(if (or (= n 0) (empty? lst))
lst
(drop (- n 1) (tail lst))))
count#
(count lst) · function
How many elements a list has — the same as length.
(defn count (lst) (length lst))
some?#
(some? pred lst) · function
Does any element satisfy pred?
(defn some? (pred lst)
(if (empty? lst)
false
(if (pred (head lst)) true (some? pred (tail lst)))))
every?#
(every? pred lst) · function
Do all elements satisfy pred?
(defn every? (pred lst)
(if (empty? lst)
true
(if (pred (head lst)) (every? pred (tail lst)) false)))
compose#
(compose f g) · function
Combines two functions: (compose f g) is the function x → (f (g x)).
(defn compose (f g) (fn (x) (f (g x))))
when#
(when test . body) · macro
Run the body only when test is true; otherwise nil.
(defmacro when (test . body)
`(if ,test (do ,@body) nil))
unless#
(unless test . body) · macro
Run the body only when test is false; otherwise nil.
(defmacro unless (test . body)
`(if ,test nil (do ,@body)))