minimalistic sexp based programming language with js interop
Serverside usage:
$ npm i -g lizb
$ lizb your_epic_code.lizbBrowser usage:
<script type="module" src="http://willpringle.ca/lizb/web.js"></script>
<script type="text/lizb">
# your code here...
(print "Hello, World!")
</script>(map (fun (n)
(if (div n 3)
(if (div n 5) "fizzbuzz" "fizz")
(if (div n 5) "buzz" n)))
(range 100))(global fac (fun (n)
(if (< n 1)
1
(* n (fac (- n 1))))))
# prints '87178291200'
(print (fac 14))(js/console.log "hello world")
(js/document.getElementbyId "primary-btn")./run file.lizb
- async / promise syntax
- better error handling
- cdn to use it in browsers
ideas
(let (a b (list 1 2)) (print (+ a b)))<-- list deconstruction in let statement((fun ((a b)) (+ a b)) (list 1 2))<-- list deconstruction in function def! cool(in needle haystack)<-- true or false, haystack could be hashmap(enumerate lst)<-- returns list of lists in form [ (idx1 val1) (idx2 val2) ... (idxn valn) ](get lst key1 key2 key3)<-- same aslst[key1][key2][key3]in js
lizb is a small Lisp-like language that evaluates S-expressions and runs on a JavaScript host (Node or the browser). It’s designed to be tiny, hackable, and easy to interop with JS.
These docs focus on:
- core language forms (
if,when,let,global,fun,f.*) - the standard library (
standard-library.js) - browser DOM helpers (
lib/dom.js) - JS interop patterns that show up in your examples
Documentation below authored by ai and has not been verified. Reference standard-library.js as the ultimate source of truth...
- Quick start
- Syntax basics
- Evaluation model
- Scopes and name lookup
- Core special forms
- Data types
- Standard library reference
- DOM library (
dom/*) - JS interop patterns
- Examples
- Notes / quirks (current implementation)
lizb code is a tree of lists and atoms, evaluated like:
(+ 1 2 3) # => 6
(print "hello") # prints hello
(list 1 2 3) # => [1,2,3]You can run lizb scripts in different ways depending on your setup (CLI runner, browser loader, etc.). Your HTML examples load:
<script type="module" src="../../../web.js"></script>
<script type="text/lizb">
(print "hello from lizb")
</script>Lines starting with # are comments.
# this is a comment
(+ 1 2)Everything is an S-expression list:
(fn arg1 arg2 ...)lizb currently recognizes:
- numbers:
12,3.14 - strings:
"hello"(supports\n,\t,\"escapes) - names:
x,myVar,dom/on,js/window/location
An expression is evaluated as:
- Evaluate the first item (the “callee”).
- If it’s a special form, it gets the raw AST and controls evaluation.
- Otherwise, evaluate each argument left-to-right.
- Call the callee:
- if it’s a JS
Function:fn(...args) - if it’s an
Objectand you pass exactly one argument: returnobj[key]
- if it’s a JS
Examples:
(+ 1 2) # calls the "+" function
((dict "a" 10) "a") # object-as-function indexing => 10 (see dict notes)lizb uses nested Context objects:
- A
Contexthasprops(a JS object) and an optionalparent. - Name lookup walks up to the parent if needed.
- Names can contain
/or.to navigate “module paths”.
Name lookup splits on / or . only when it’s between letters (so dom/on and js/window work). Each path step does:
value = value[part]- if that value is a function, it is bound to its receiver (
value.bind(receiver))
This is why DOM methods and JS methods can be used safely.
Example:
# access a nested property
(global href js/window/location/href)
# call a bound method (gets correct "this")
((js/eval "x=>new URL(x)") href)Special forms are not regular functions — they control evaluation.
Define/update a global variable.
(global count 0)
(print count) # => 0
(global count (+ count 1))
globalwrites intoglobalContext.props.
Creates a new inner scope and evaluates one or more expressions inside it.
(let x 10
(+ x 5)) # => 15(let (a 1 b 2 c 3)
(+ a b c)) # => 6Bindings evaluate in-order, and later bindings can see earlier ones.
Evaluates cond. If truthy, evaluates and returns then. Otherwise evaluates else if present.
(if (> 3 2) "yes" "no") # => "yes"
(if false (print "nope")) # => undefinedA compact multi-branch conditional.
Pattern:
(when
cond1 expr1
cond2 expr2
...
defaultExpr?) # optionalReturns the first matching expression result, otherwise the default (if provided), otherwise undefined.
(when
(= x 0) "zero"
(< x 0) "neg"
"pos")Defines a function. Supported forms:
- Regular params
(fun (x y) (+ x y))- Variadic params (single name captures list of args)
(fun args (len args))- No-args function
(fun (print "hi"))- Parameter destructuring (list/tuple unpacking)
(fun ((a b) c)
(+ a b c))
((fun ((a b) c) (+ a b c)) (list 1 2) 3) # => 6fun supports extra expressions before the “return” expression (the last expression).
(fun (x)
(print "x is" x)
(* x x))An anonymous function shortcut.
Form:
(f.x.y <expr1> <expr2> ... <exprN>)- The parameter names come from the token:
f.x.y→ paramsx,y - The body is the remaining expressions (evaluated in sequence)
- The result is the result of the final expression
Examples:
(f.x * x x) # square
(map (f.x * x x) (range 5)) # => [0,1,4,9,16]
(f print "hello") # prints "hello"lizb values are JS values:
- Number (JS number)
- String
- Boolean
- List (JS Array)
- Object (plain JS object)
- Function (JS function; includes lizb functions created by
funandf.*)
Truthiness follows JavaScript rules.
The global context starts with std from standard-library.js.
(+ 1 2 3) # 6
(- 10 3) # 7
(- 5) # -5
(* 2 3 4) # 24
(/ 20 2 5) # 2
(mod 10 3) # 1
(div 10 5) # true (checks divisible: dividend % divisor === 0)
(= 1 1 1) # true
(> 3 2) # true
(<= 2 2) # true
(not true) # false
(and true false true) # false
(or false 0 "" "x") # "x" (JS truthiness)(cat "a" "b" "c") # "abc"
(cat (list 1 2) (list 3 4)) # [1,2,3,4] (concats lists)
(list 1 2 3) # [1,2,3]
(len (list 1 2 3)) # 3
(first (list 10 20)) # 10
(second (list 10 20)) # 20
(last (list 10 20 30)) # 30
(rest (list 10 20 30)) # [20,30]
(get (list "a" "b") 1) # "b"
(set (list "a" "b") 0 "z") # returns old value "a" and mutates list to ["z","b"]
(slice (list 1 2 3 4) 1) # [2,3,4]
(slice (list 1 2 3 4) 1 3) # [2,3]
(split "a,b,c" ",") # ["a","b","c"]Membership:
(in 0 (list "a" "b")) # true/false (JS "in" operator; checks index/property)
(in "length" (list 1 2)) # true(map (f.x * x 2) (list 1 2 3)) # [2,4,6]map also accepts multiple lists to “zip” values into the function (see quirks).
(loop print (list 1 2 3)) # prints 1, 2, 3Two forms:
- With explicit accumulator:
(reduce + 0 (list 1 2 3)) # 6- Without accumulator (uses first list element):
(reduce + (list 1 2 3)) # 6(where (f.x > x 10) (list 5 12 30)) # [12,30](unique (list 1 1 2 3 3)) # [1,2,3](sorted (list 3 1 2)) # [1,2,3]Pass a value through a sequence of functions.
(pipe
"a,b,c"
(f.x split x ",")
(f.x len x)) # 3Pass a list as a function’s arguments.
(call + (list 1 2 3 4)) # 10(range 5) # [0,1,2,3,4]
(range 2 6) # [2,3,4,5]
(range 0 10 2) # [0,2,4,6,8](enumerate (list "a" "b")) # [[0,"a"], [1,"b"]]Cartesian product of lists.
(product (list 1 2) (list "a" "b"))
# => [[1,"a"], [1,"b"], [2,"a"], [2,"b"]]
(product 2 (list 0 1))
# => same as (product (list 0 1) (list 0 1))Creates a plain JS object.
(global d (dict "a" 1 "b" 2))Objects can also be used like a function with one argument:
(d "a") # => 1 (see notes)You can also access properties by using module-path lookup:
(global win js/window)
(win "location") # object indexing style
js/window/location/href # name lookup styleWhen running under Node, fs/read uses readFileSync.
(fs/read "./input.txt") # file contents as stringIn the browser, fs/read will not work (no readFileSync).
dom/* lives under std.dom, so you can call it as dom/query, dom/on, etc.
Select first element matching a CSS query.
(global header (dom/query "#header"))
(set header "textContent" "Hello")Get element by ID.
(global btn (dom/id "btn"))Add an event listener.
(dom/on (dom/query "#btn") "click"
(fun (e)
(print "clicked")))Remove an event listener (must pass same callback reference).
One-time event listener.
(dom/once (dom/query "#btn") "click" (f print "first click only"))All event helpers validate that the target has
addEventListener, and throw a clear error if not.
You have two main interop styles:
Anything in std can be reached by a path name:
js/window/location/href
js/history/replaceStateFunctions reached through paths are bound to their receiver automatically.
Sometimes you want a tiny JS lambda:
((js/eval "x=>new URL(x)") href)
# convert iterable to array
((js/eval "Array.from") someIterable)(map (fun (n)
(when
(and (div n 3) (div n 5)) "fizzbuzz"
(div n 3) "fizz"
(div n 5) "buzz"
n))
(range 50))(global count 0)
(dom/on (dom/query "#btn") "click" (fun e
(global count (+ count 1))
(set (dom/query "#header") "textContent" (cat "Count:" count))))(global article (dom/query "article"))
(global href js/window/location/href)
(global url ((js/eval "x=>new URL(x)") href))
(global start ((js/eval "u=>u.searchParams.get('text')") url))
(if start (set article "textContent" (js/atob start)))
(global update-url (fun (event)
(let (text (article "textContent")
encoded (js/btoa text))
((js/eval "(u,k,v)=>u.searchParams.set(k,v)") url "text" encoded)
(js/history/replaceState 0 "" url))))
(dom/on article "input" update-url)These are behaviors that come directly from the current JS source:
-
String escapes are single replace, not global replace.
Only the first\n,\t,\"occurrence is replaced. -
Object-as-function indexing only triggers when:
- callee is an
Object - exactly one argument is passed
Then it returnsobj[key].
- callee is an
-
mapwith multiple lists has a bug instandard-library.js: it builds arowbut callsfn(...lst)instead offn(...row).
Until fixed, multi-list mapping may behave incorrectly. -
dictstores values as one-element arrays:
obj[key] = [value](note the brackets).
If you want plain values, change it toobj[key] = value. -
sorted(fn, lst)signature is documented but comparator isn’t wired:
The code detects a function argument but never assigns it tocmp, so custom comparators currently won’t be used. -
fs/readworks only in Node. In the browser,readFileSyncisnull.
The standard library is just a JS object (std) inserted into the global context:
- Add new functions by adding properties on
standardLibrary. - Add new special forms by adding to
specialHandlers(asnew Special((ast, ctx) => ...)).
If you add a new module, you can attach it as a nested object:
standardLibrary.myModule = {
hello: () => "hi",
};Then call it in lizb as:
(myModule/hello)