λ EELisp

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.

#ChapterWhat's in it
1IntroductionWhat EELisp is for, running it, the design in one page
2Syntax and valuesHow source text is read: atoms, lists, dicts, quoting, truth
3The languageDefinitions, control flow, functions, tail calls, macros, errors
4Working with dataLists, dicts, strings, numbers, dates, JSON, HTTP, output
5The databasedBASE-style tables on SQLite: define, insert, query, forms
6The agendaItems, natural-language capture, categories, rules, views
7SheetsSpreadsheets whose formulas are EELisp
8A self-documenting languagefunctions, source, and writing docs as comments
9ArchitectureHow the engine is built, with sequence diagrams
10EmbeddingUsing the engine from Rust, over JSON, and in a browser
—Function referenceEvery 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, insert and query work 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:B9 is 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:

DecisionConsequence
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 immutabledict-set returns a new dict; lists are never changed in place
Tail calls don't grow the stackRecursion 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 commentsA ;; block above a defn is its documentation, shown by (source f)
The host decides the reachEditor 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 writeIt reads as
-5the number −5
-the symbol - (subtraction)
(- 1 3)a call: −2
1e3the number 1000
0xff, 0x4000, -0x10hex 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:

ShorthandLong formMeaning
'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:

TypeExampleNotes
number42, -3.5a 64-bit float
string"hi"Unicode; lengths count characters
booltrue
nilnil
symbol'fooa name
keyword:fooa name that evaluates to itself
list(1 2 3)immutable, cheap to share
dict{:a 1}insertion-ordered, immutable
function(fn (x) x), incwritten in EELisp
builtinmap, +written in Rust
macrowhen
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, def and let. The evaluator handles these itself, and each one decides which of its arguments to evaluate.
  • Macros, such as when and unless. 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:

ErrorExample cause
Undefined symbol: xusing 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 listunbalanced 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-add and date-diff use 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:

PatternGivesPatternGives
yyyy / yy2026 / 26HH / Hhour, 00–23
MM / M09 / 9hh / hhour, 01–12
MMM / MMMMSep / Septembermmminutes
dd / d05 / 5ssseconds
EEE / EEEEFri / 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 │
└────┴────────────────┴─────────┴──────┘
OptionMeaning
: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 truethe sort direction
:limit nat 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#

Your codeYour codeEvaluatorEvaluatorquery (builtin)query (builtin)Database (SQLite)Database (SQLite)(query books :where "year > ?":params (list 1980))query is a TableFirst builtin, sobooks is passed bareevaluate the remainingarguments["books", :where, "year > ?",:params, (1980)]SELECT … FROM books WHERE _deleted= 0 AND (year > ?)rowsresult-set {table, columns,records}result-set, printed as a grid orsent to a host as $resultSet

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 recognisesExamplesBecomes
An ISO date2026-10-12:when
Relative daystoday, tomorrow, yesterday, in 3 days, in 2 weeks:when
Urgency wordsurgent, 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:

NameIs
text, notes, idthe item's own fields
categoriesa list of category paths
created, modifiedtimestamps, as ISO strings
every property, such as when, priority, whoits 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.

Your codeYour codeapply-rulesapply-rulesDatabaseDatabaseEvaluatorEvaluator(apply-rules)BEGINload rules (_rules) and items(_items)condition and action source text,itemseval condition in a scope binding text, id, when, has-category …true or false (an error skips the rule)eval each :assign / :actionassign / item-set write the itemthe updated item, seen by later rulesopt[the condition held]loop[every enabled rule]loop[every item]COMMIT (ROLLBACK on failure)number of items changed

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 typeIt becomes
1200the number 1200
rentthe text "rent"
'42the 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,50money: 1200.5, with a currency format
1 200,501200.5
2026-09-16a date, which sorts and takes part in date arithmetic
16/09/2026text, 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 writeIt means
B1the value in B1 (capitals only, since b1 is an ordinary name)
B1:B9the range as a flat list, with blanks as nil (one row or one column)
A1:C9a block wider and taller than one: a list of rows, (("apple" 3) ("pear" 5) …)
E:E, B:Dwhole columns, read down to the last filled row — E:E flat, B:D as rows
$B$1, B$1, $B1pinned whole, by row, or by column
Rates!A1, Rates!A1:A9a 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"
Host or your codeHost or your codesheet-setsheet-setSheet (plan)Sheet (plan)EvaluatorEvaluatorOther open sheetsOther open sheets(sheet-set "Budget" "B2" "500")stage the new input for B2plan(B2): B2 and everything thatreads it, transitivelysteps in order: Compute B3,Compute C1 … or Cycle [F1 G1]read the Sheet!A1 cells itnames in other sheetseval the formula, its cell names bound to valuesvalue or error"circular reference" on everycell of the cyclealt[Compute cell][Cycle]loop[each step]save the changed cells toBudget.eesheet in one writerecompute open sheets that read Budget, once eachchanged cells as (row col inputvalue error fmt)

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.

EELisp engine architecture Hosts call the Interpreter API, which drives the language core. The core reaches the database, agenda and sheet layers, which store their data in SQLite files. HOSTS eelisp CLI REPL · file · -e eelisp --serve JSON lines on stdio EngineHandle thread · Send + Sync eelisp-web WebAssembly Engine Your Rust program Interpreter directly INTERPRETER eval_str · eval_all · eval_host → {ok, result, output} · eval_json · open_database · import/export LANGUAGE CORE lexer tokens + spans parser values + comments eval TCO trampoline · env builtins Rust functions prelude EELisp source docs · printer manual, source index host JSON · output DOMAINS database tables · queries · views agenda items · rules · smart parser · dates sheets A1 refs · plan · recalculation editor bridge host-installed callbacks STORAGE SQLite, compiled in: a .db file or :memory: for tables and agenda · one .eesheet file per sheet the host's editor

The layers#

LayerFilesResponsibility
Hostsbin/eelisp.rs, server.rs, web/src/lib.rsTurn a request (a line, a JSON object, a JS call) into eval_host
Interpreterinterpreter.rsBuild the environment, own the shared state, evaluate source
Readerlexer.rs, parser.rsText → values, remembering each top-level form's text and comments
Evaluatoreval.rs, env.rsSpecial forms, macros, application; the tail-call trampoline
Valuesvalue.rs, printer.rs, host.rsThe Value enum; printing for people; tagged JSON for hosts
Librarybuiltins.rs, prelude.rs, docs.rs, output.rsCore functions in Rust, the prelude in EELisp, the manual, output capture
Databasedatabase.rs, db_builtins.rsSQLite store with a JSON schema, soft delete, views
Agendaagenda.rs, agenda_builtins.rs, smart_parser.rs, dates.rsItems, categories, rules, recurrence, natural-language capture
Sheetssheet_ref.rs, sheet.rs, sheet_builtins.rsA1 addressing, the dependency graph, recalculation
Editor bridgeeditor.rsFunctions whose behaviour the host installs as callbacks

Two rules hold the design together:

  1. 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.
  2. The interpreter is single-threaded. Shared state is Rc<RefCell<…>>. A multi-threaded host uses EngineHandle, which keeps the interpreter on its own thread and hands out a Send + Sync handle (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.

HostHostInterpreter::buildInterpreter::buildGlobal envGlobal envDatabaseDatabasePreludePreludeInterpreter::with_database("notes.db")open the file, create the folder, ensure the agenda tablesbuiltins::register (arithmetic,strings, lists, dicts, dates …)db_builtins and agenda_builtins,sharing the Databaseoutput::register (print andprintln capture into a buffer)editor::register (callbacks startempty, so the engine runsheadless)sheet_builtins::register anddocs::registereval_str(PRELUDE)defn inc, first, take … and defmacro when, unlessa ready Interpreter

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.

HostHostInterpreterInterpreterLexerLexerParserParserevalevalSourceIndexSourceIndexeval_str("(defn sq (x) (* x x))(sq 9)")lex_spanned(source)tokens, each with its char spantop forms: (value, text, comments above)eval(form, global env)value, or an error that stops the looprecord the text if it defines a nameloop[each top-level form]the last value, here 81

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.

CallerCallereval loopeval loopEnvEnvFunction or builtinFunction or builtineval((fact 10), env)a list whose head isn't a specialform, so apply itlook up factFunction {params, body, closure}evaluate the arguments left torightbind_params: a child of theclosure, n = 10the body's last form is in tailposition: set expr and env,continueloop: eval (loop (i n acc 1) …)recur raises Recur([9, 10])back to its loop, which rebindsand retries3628800, with a constant Ruststack depth

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 modeUsed byBehaviour
Evalalmost everythingevery argument is evaluated
TableFirstquery, insert, count-records, show …the first argument is passed bare, unless it's a call, and the rest are evaluated
AllRawdeftable, defform, defrule, defview, defcategorynothing 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.

GroupVariants
AtomsNumber(f64), Str, Bool, Null, Symbol, Keyword
CollectionsList(Rc<Vec<Value>>), Dict(Rc<OrderedDict>)
CodeFunction, Builtin, Macro
DatabaseTable, Record, ResultSet
AgendaItem
ViewsTableView, 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#

WhatStored in
Your tablesa table of the same name, plus _id and _deleted columns
Table schemasJSON in _eelisp_schema, so types, defaults and choices survive a restart
Agenda_items, _categories, _rules, _views and _templates in the same database
Rules and viewstheir conditions as EELisp text, re-read and evaluated when they run
Sheetsone .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:

HostUse it whenEntry point
Rust, directlyyour program is Rust and single-threadedeelisp::Interpreter
Rust, across threadsa Tauri app, or anything that shares state between threadseelisp::server::EngineHandle
Any languageNode, Python, a shell scripteelisp --serve over stdin/stdout
A browsera web page or Web Workereelisp-web, compiled to WebAssembly
A sandboxscripting inside another program: a game's mods, an app's pluginseelisp 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": "" }
User interfaceUser interfaceInterpreterInterpreterOutput bufferOutput buffereval_streval_strhost::to_jsonhost::to_jsoneval_host("(println \"hi\")(browse books)")clearevaluate every formprintln appends "hi\n"Ok(TableView) or Err(LispError)take the captured textto_json(value){"$tableView": {tableName, tableDef, resultSet}}{"ok": true, "result": …,"output": "hi\n"}

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:

TagValueShape
$kw / $symkeyword / symbol{"$kw": "name"}
$dictdict{"$dict": [["name", "Ada"], ["born", 1815]]}, a list of pairs so the key order survives
$recorddatabase row{"$record": {"table", "id", "data": {…}}}
$resultSetquery result{"$resultSet": {"table", "columns", "records": […]}}
$tabletable definition{"$table": {"name", "fields": [{name, type, required, choices}]}}
$itemagenda 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 / $macrocode{"$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
UI thread AUI thread AUI thread BUI thread BEngineHandleEngineHandleEngine threadEngine threadInterpreterInterpreterspawn: the thread opens the database and runssetupeval("(query books)")Job::Eval(src, reply channel)eval("(item-count)")Job::Eval(src, reply channel)eval_host (jobs run one at a time,in order)envelopeenvelope for Aeval_hostenvelopeenvelope for Bdropping the handle sends Job::Stop and joins thethread

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
CallbackEELisp 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
rootsfolders 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)
ScriptScriptEvaluatorEvaluatoreditor builtineditor builtinHost callbackHost callback(replace-range 0 5 "Hello")replace-range [0, 5, "Hello"]replace_range(0, 5, "Hello")the document changed in the hostnothing happensalt[the host installed replace_range][headless, as on the bare command line]nil

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:

FeatureDefaultWhat it adds
sqliteonthe database, the agenda and sheets, on SQLite
httponhttp-get and http-post, with ureq
jsoffin 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)))