Skip to content

oxur/lykn

Repository files navigation

lykn

S-expression syntax for JavaScript

lykn is a lightweight Lisp that compiles to clean, readable JavaScript. No runtime, no dependencies in the output — just JS you'd write by hand, but expressed in s-expressions.

The name means good luck in Norwegian, luck in Swedish, and — if you squint at the Icelandic — closure.

Status

v0.2.0 — Full macro system with quasiquote, auto-gensym hygiene, and cross-module macro imports. 56KB browser bundle. 423 tests.

Quick taste

;; Define macros with quasiquote templates
(macro when (test (rest body))
  `(if ,test (block ,@body)))

(macro unless (test (rest body))
  `(if (! ,test) (block ,@body)))

;; Import macros from other files
(import-macros "./control-flow.lykn" (when unless))

;; Use them like built-in forms
(when (> x 0)
  (console:log "positive"))

;; Classes, destructuring, template literals — all the JS you need
(class Dog (Animal)
  (field -name)
  (constructor (name)
    (super name)
    (= this:-name name))
  (speak ()
    (console:log (template this:-name " says woof!"))))

(const (object name (default age 0)) (get-user))

Compiles to:

if (x > 0) {
  console.log("positive");
}
class Dog extends Animal {
  #_name;
  constructor(name) {
    super(name);
    this.#_name = name;
  }
  speak() {
    console.log(`${this.#_name} says woof!`);
  }
}
const {name, age = 0} = getUser();

Architecture

.lykn source → reader → expander → compiler → astring → JavaScript
  • Reader (src/reader.js) — parses s-expressions, handles # dispatch (`, ,, ,@, #a(...), #o(...), #NNr, #;, #|...|#), dotted pairs

  • Expander (src/expander.js) — three-pass macro expansion pipeline. Resolves quasiquote (Bawden's algorithm), sugar forms (cons/list/ car/cdr), user-defined macros, import-macros, as patterns

  • Compiler (src/compiler.js) — transforms core forms to ESTree AST, generates JS via astring

  • Browser shim (src/lykn-browser.js) — 56KB bundle with <script type="text/lykn"> support and window.lykn API

  • Rust tools (crates/lykn-cli/) — linter, formatter, syntax checker. Single binary, no runtime dependencies. Publishable to crates.io.

Toolchain

brew install biome deno

Lint

# JS (src/)
deno lint src/
biome lint src/

# Rust
cargo clippy

Format

# JS (src/)
biome format src/
biome format --write src/    # fix in place

# Rust
cargo fmt

Test

deno task test              # all tests
deno task test:unit         # unit tests only
deno task test:integration  # integration tests only
cargo test                  # Rust tests

Usage

Browser

<script src="dist/lykn-browser.js"></script>
<script type="text/lykn">
  ;; Macros work inline in the browser!
  (macro when (test (rest body))
    `(if ,test (block ,@body)))

  (const el (document:query-selector "#output"))
  (when el
    (= el:text-content "Hello from lykn!"))
</script>

Or use the API directly:

lykn.compile('(+ 1 2)')   // → "1 + 2;\n"
lykn.run('(+ 1 2)')       // → 3
await lykn.load('/app.lykn')

Note: import-macros is not available in the browser (no file system access). Inline macro definitions work.

Build Browser Bundle

deno task build

Format (Rust)

# Build from source
mkdir -p ./bin
cargo build --release && cp ./target/release/lykn ./bin

# Format a file (stdout)
./target/release/lykn fmt main.lykn

# Format in place
./target/release/lykn fmt -w main.lykn

# Syntax check
./target/release/lykn check main.lykn

Supported forms

Basics

lykn JS
(const x 1) const x = 1;
(let x 1) let x = 1;
my-function myFunction
console:log console.log
this:-name this.#_name
(get arr 0) arr[0]

Functions

lykn JS
(=> (a b) (+ a b)) (a, b) => a + b
(function add (a b) (return (+ a b))) function add(a, b) { return a + b; }
(lambda (a) (return a)) function(a) { return a; }
(async (=> () (await (fetch url)))) async () => await fetch(url)
(=> ((default x 0)) x) (x = 0) => x
(function f (a (rest args)) ...) function f(a, ...args) { ... }

Modules

lykn JS
(import "mod" (a b)) import {a, b} from "mod";
(import "mod" name) import name from "mod";
(export (const x 42)) export const x = 42;
(export default my-fn) export default myFn;
(dynamic-import "./mod.js") import("./mod.js")

Control flow

lykn JS
(if cond a b) if (cond) a; else b;
(? test a b) test ? a : b
(for-of item items (f item)) for (const item of items) { f(item); }
(while cond body...) while (cond) { body }
(try body (catch e ...) (finally ...)) try { body } catch(e) { ... } finally { ... }
(switch x ("a" (f) (break)) (default (g))) switch(x) { case "a": f(); break; default: g(); }
(throw (new Error "oops")) throw new Error("oops");

Expressions

lykn JS
(template "hi " name "!") `hi ${name}!`
(tag html (template ...)) html`...`
(object (name "x") age) {name: "x", age}
(array 1 2 (spread rest)) [1, 2, ...rest]
(regex "^hello" "gi") /^hello/gi
(new Thing a b) new Thing(a, b)

Destructuring

lykn JS
(const (object name age) person) const {name, age} = person;
(const (array first (rest tail)) list) const [first, ...tail] = list;
(const (object (alias data items)) obj) const {data: items} = obj;
(const (object (default x 0)) point) const {x = 0} = point;
(const (array _ _ third) arr) const [, , third] = arr;

Classes

lykn JS
(class Dog (Animal) ...) class Dog extends Animal { ... }
(field -count 0) #_count = 0;
(get area () (return x)) get area() { return x; }
(static (field count 0)) static count = 0;
(async (fetch-data () ...)) async fetchData() { ... }

Operators

lykn JS
(+ a b c) a + b + c
(++ x) ++x
(+= x 1) x += 1
(** base exp) base ** exp
(?? a b) a ?? b

Macros

lykn What it does
(macro when (test (rest body)) `(if ,test (block ,@body))) Define a macro with quasiquote template
(import-macros "./lib.lykn" (when unless)) Import macros from another file
`(if ,test ,@body) Quasiquote with unquote and splicing
temp#gen Auto-gensym (hygienic binding)
(gensym "prefix") Programmatic gensym

Data literals and sugar

lykn JS
#a(1 2 3) [1, 2, 3]
#o((name "x") (age 42)) {name: "x", age: 42}
#16rff 255 (radix literal)
#2r11110000 240 (binary)
(cons 1 2) [1, 2]
(list 1 2 3) [1, [2, [3, null]]]
(car x) / (cdr x) x[0] / x[1]
#; expr Expression comment (discards next form)
#| ... |# Nestable block comment

Design principles

  • Thin skin over JS. lykn is not a new language. It's a syntax for the language you already have. The output should look like code you'd write.
  • No runtime. Compiled lykn is just JS. Nothing extra ships to the browser.
  • Small tools. The full pipeline (reader + expander + compiler) is ~3,000 lines. The browser bundle is 56KB minified. You can read the whole thing.
  • Two worlds. Use Rust for dev-side tooling (fast, single binary). Use JS for the compiler (because it targets JS and can run in the browser).

References

  • ESTree spec — the AST format lykn targets
  • astring — ESTree to JS code generation
  • Bawden 1999 — "Quasiquotation in Lisp", the algorithm behind lykn's macro expansion
  • Fennel — inspiration for enforced gensym hygiene model
  • eslisp — spiritual ancestor; reference implementation
  • BiwaScheme — inspiration for the in-browser <script> workflow

License

Apache-2.0

About

S-expression syntax for JavaScript

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors