lizb

minimalistic sexp based programming language with js interop

Install

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>

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.lizb

todo

ideas

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:


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

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:


Evaluation model

An expression is evaluated as:

  1. Evaluate the first item (the “callee”).
  2. If it’s a special form, it gets the raw AST and controls evaluation.
  3. Otherwise, evaluate each argument left-to-right.
  4. Call the callee:
    • if it’s a JS Function: fn(...args)
    • if it’s an 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)

Scopes and name lookup

lizb uses nested Context objects:

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:

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))

global writes into globalContext.props.


(let ...)

Creates a new inner scope and evaluates one or more expressions inside it.

Form 1: single binding

(let x 10
  (+ x 5))     # => 15

Form 2: multiple bindings

(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

Multiple 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>)

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:

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))  # true

Higher-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, 3

reduce

Two 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]

pipe

Pass a value through a sequence of functions.

(pipe
  "a,b,c"
  (f.x split x ",")
  (f.x len x))         # 3

call

Pass a list as a function’s arguments.

(call + (list 1 2 3 4))  # 10

Ranges 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 style

Filesystem (Node only)

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 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/replaceState

Functions 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:


Contributing / extending the standard library

The standard library is just a JS object (std) inserted into the global context:

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)