lizb
v0.1.0
Published
minimalistic sexp based programming language with js interop
Readme
lizb
minimalistic sexp based programming language with js interop
fizzbuzz example
(map (fun (n)
(if (div n 3)
(if (div n 5) "fizzbuzz" "fizz")
(if (div n 5) "buzz" n)))
(range 100))factorial example
(global fac (fun (n)
(if (< n 1)
1
(* n (fac (- n 1))))))
# prints '87178291200'
(print (fac 14))js interop
(js/console.log "hello world")
(js/document.getElementbyId "primary-btn")usage
./run file.lizbtodo
- 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 — Language & Standard Library Docs (WIP)
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...
Table of contents
- 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)
Quick start
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>Syntax basics
Comments
Lines starting with # are comments.
# this is a comment
(+ 1 2)Lists
Everything is an S-expression list:
(fn arg1 arg2 ...)Atoms
lizb currently recognizes:
- numbers:
12,3.14 - strings:
"hello"(supports\n,\t,\"escapes) - names:
x,myVar,dom/on,js/window/location
Evaluation model
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)Scopes and name lookup
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”.
Module paths: a/b/c or a.b.c
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)Core special forms
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.
Form 1: single binding
(let x 10
(+ x 5)) # => 15Form 2: multiple bindings
(let (a 1 b 2 c 3)
(+ a b c)) # => 6Bindings 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?) # optionalReturns 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:
- 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) # => 6Multiple expressions inside a function
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>)- 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"Data types
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.
Standard library reference
The global context starts with std from standard-library.js.
Arithmetic, comparisons, booleans
(+ 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)Strings, lists, and sequence ops
(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)) # trueHigher-order functions
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, 3reduce
Two forms:
- With explicit accumulator:
(reduce + 0 (list 1 2 3)) # 6- Without accumulator (uses first list element):
(reduce + (list 1 2 3)) # 6where (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]pipe
Pass a value through a sequence of functions.
(pipe
"a,b,c"
(f.x split x ",")
(f.x len x)) # 3call
Pass a list as a function’s arguments.
(call + (list 1 2 3 4)) # 10Ranges and combinatorics
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"]]product
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))Objects / dicts and indexing
dict
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 styleFilesystem (Node only)
When 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 library (dom/*)
dom/* lives under std.dom, so you can call it as dom/query, dom/on, etc.
dom/query
Select first element matching a CSS query.
(global header (dom/query "#header"))
(set header "textContent" "Hello")dom/id
Get element by ID.
(global btn (dom/id "btn"))dom/on
Add an event listener.
(dom/on (dom/query "#btn") "click"
(fun (e)
(print "clicked")))dom/off
Remove an event listener (must pass same callback reference).
dom/once
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.
JS interop patterns
You have two main interop styles:
1) Module-path lookup for globals
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.
2) js/eval for one-off helpers
Sometimes you want a tiny JS lambda:
((js/eval "x=>new URL(x)") href)
# convert iterable to array
((js/eval "Array.from") someIterable)Examples
FizzBuzz with when
(map (fun (n)
(when
(and (div n 3) (div n 5)) "fizzbuzz"
(div n 3) "fizz"
(div n 5) "buzz"
n))
(range 50))Counter (DOM click handler)
(global count 0)
(dom/on (dom/query "#btn") "click" (fun e
(global count (+ count 1))
(set (dom/query "#header") "textContent" (cat "Count:" count))))“Textarea pastebin” idea (URL param storage)
(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)Notes / quirks (current implementation)
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.
Contributing / extending the standard library
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)