minimalistic sexp based programming language with js interop
Serverside usage:
$ npm i -g lizb
$ lizb your_epic_code.lizb
Browser 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
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 as lst[key1][key2][key3] in jslizb 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:
if, when, let, global, fun, f.*)standard-library.js)lib/dom.js)Documentation below authored by ai and has not been verified. Reference standard-library.js as the ultimate source of truth…
dom/*)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:
12, 3.14"hello" (supports \n, \t, \" escapes)x, myVar, dom/on, js/window/locationAn expression is evaluated as:
Function: fn(...args)Object and you pass exactly one argument: return obj[key]Examples:
(+ 1 2) # calls the "+" function
((dict "a" 10) "a") # object-as-function indexing => 10 (see dict notes)
lizb uses nested Context objects:
Context has props (a JS object) and an optional parent./ or . to navigate “module paths”.a/b/c or a.b.cName lookup splits on / or . only when it’s between letters (so dom/on and js/window work). Each path step does:
value = value[part]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.
(global name expr)Define/update a global variable.
(global count 0)
(print count) # => 0
(global count (+ count 1))
globalwrites intoglobalContext.props.
(let ...)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)) # => 6
Bindings evaluate in-order, and later bindings can see earlier ones.
(if cond then [else])Evaluates cond. If truthy, evaluates and returns then. Otherwise evaluates else if present.
(if (> 3 2) "yes" "no") # => "yes"
(if false (print "nope")) # => undefined
(when ...)A compact multi-branch conditional.
Pattern:
(when
cond1 expr1
cond2 expr2
...
defaultExpr?) # optional
Returns the first matching expression result, otherwise the default (if provided), otherwise undefined.
(when
(= x 0) "zero"
(< x 0) "neg"
"pos")
(fun ...)Defines a function. Supported forms:
1) Regular params
(fun (x y) (+ x y))
2) Variadic params (single name captures list of args)
(fun args (len args))
3) No-args function
(fun (print "hi"))
4) Parameter destructuring (list/tuple unpacking)
(fun ((a b) c)
(+ a b c))
((fun ((a b) c) (+ a b c)) (list 1 2) 3) # => 6
fun supports extra expressions before the “return” expression (the last expression).
(fun (x)
(print "x is" x)
(* x x))
(f.x.y ... expr...)An anonymous function shortcut.
Form:
(f.x.y <expr1> <expr2> ... <exprN>)
f.x.y → params x, yExamples:
(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:
fun and f.*)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(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 (for side effects)(loop print (list 1 2 3)) # prints 1, 2, 3
reduceTwo forms:
1) With explicit accumulator:
(reduce + 0 (list 1 2 3)) # 6
2) Without accumulator (uses first list element):
(reduce + (list 1 2 3)) # 6
where (filter)(where (f.x > x 10) (list 5 12 30)) # [12,30]
unique(unique (list 1 1 2 3 3)) # [1,2,3]
sorted(sorted (list 3 1 2)) # [1,2,3]
pipePass a value through a sequence of functions.
(pipe
"a,b,c"
(f.x split x ",")
(f.x len x)) # 3
callPass a list as a function’s arguments.
(call + (list 1 2 3 4)) # 10
range(range 5) # [0,1,2,3,4]
(range 2 6) # [2,3,4,5]
(range 0 10 2) # [0,2,4,6,8]
enumerate(enumerate (list "a" "b")) # [[0,"a"], [1,"b"]]
productCartesian 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))
dictCreates 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 style
When running under Node, fs/read uses readFileSync.
(fs/read "./input.txt") # file contents as string
In the browser, fs/read will not work (no readFileSync).
dom/*)dom/* lives under std.dom, so you can call it as dom/query, dom/on, etc.
dom/querySelect first element matching a CSS query.
(global header (dom/query "#header"))
(set header "textContent" "Hello")
dom/idGet element by ID.
(global btn (dom/id "btn"))
dom/onAdd an event listener.
(dom/on (dom/query "#btn") "click"
(fun (e)
(print "clicked")))
dom/offRemove an event listener (must pass same callback reference).
dom/onceOne-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/replaceState
Functions reached through paths are bound to their receiver automatically.
js/eval for one-off helpersSometimes you want a tiny JS lambda:
((js/eval "x=>new URL(x)") href)
# convert iterable to array
((js/eval "Array.from") someIterable)
when(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.
Objectobj[key].map with multiple lists has a bug in standard-library.js: it builds a row but calls fn(...lst) instead of fn(...row).
Until fixed, multi-list mapping may behave incorrectly.
dict stores values as one-element arrays:
obj[key] = [value] (note the brackets).
If you want plain values, change it to obj[key] = value.
sorted(fn, lst) signature is documented but comparator isn’t wired:
The code detects a function argument but never assigns it to cmp, so custom comparators currently won’t be used.
fs/read works only in Node. In the browser, readFileSync is null.The standard library is just a JS object (std) inserted into the global context:
standardLibrary.specialHandlers (as new 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)