minimal S-expression syntax and macro system for JavaScript


Keywords
language, compiler, lisp, macros, s-expression, javascript, macro
License
ISC
Install
npm install eslisp@0.9.0

Documentation

eslisp

An S-expression syntax for ECMAScript/JavaScript, with Lisp-like hygienic macros. Minimal core, maximally customisable.

This is not magic: It's just an S-expression encoding of the estree AST format. The macros are ordinary JS functions that return objects, which just exist at compile-time. This means macros can be put on npm to distribute your own language features, like this.

⚠️ Note the 0.x.x semver. The API may shift under your feet.

Philosophy

  • Small core, close to JS. This core eslisp corresponds closely with the estree abstract syntax tree format, and hence matches output JS clearly. It's purely a syntax adapter unless you use macros.

  • Maximum user control. Users must be able to easily extend the language to their needs, and to publish their features independently of the core language.

    User-defined macros must be treated like built-in ones, and are just ordinary JS functions. This means you can write them in anything that compiles to JavaScript, put them on npm, and require them.

Motivating example

Here's an example of implementing conditional compilation in eslisp:

; Macros are functions bound to names, which operate on code.  This one
; checks whether the `$DEBUG` environment variable is set, and if so,
; returns a call to `console.log` that also includes a string of the code
; that was passed in.
(macro debug
 (lambda (expression)
  (if (. process env DEBUG)
    (return `((. console log)
              ; Compile the input expression to JavaScript, and convert
              ; that to a string.
              ,((. this string)
                ((. this compileToJs)
                 ((. this compile) expression)))
              "="
              ,expression))
   (return null))))

(var fib ; Fibonacci number sequence
 (lambda (x)

  ; Conditionally compile logging code
  (debug x)

  ; Basic Fibonacci algorithm
  (switch x
   (0 (return 0))
   (1 (return 1))
   (default (return (+ (fib (- x 1)) (fib (- x 2))))))))

Compiled with DEBUG=1 eslc file.esl, that compiles to this JavaScript:

var fib = function (x) {
    console.log('x', '=', x);
    switch (x) {
    case 0:
        return 0;
    case 1:
        return 1;
    default:
        return fib(x - 1) + fib(x - 2);
    }
};

Note how the generated console.log also has the name of the variable x as a string. Try changing the debug call to (debug ((. Math pow) (+ x 1) 2) and watch the logging code change to say Math.pow(x + 1, 2) also inside the first string. (You can edit it in your browser on runkit here.)

Compiled with just eslc file.esl, the logging code disappears:

var fib = function (x) {
    switch (x) {
    case 0:
        return 0;
    case 1:
        return 1;
    default:
        return fib(x - 1) + fib(x - 2);
    }
};

Doing it this way has a few advantages:

  • Your output code is smaller, compared to the usual technique of hiding your debug code behind a boolean flag.
  • This actually logs the expression that produced the result. Can't do that in JS without writing it manually every time, because you can't invoke the compiler at compile-time.

Why?

I wanted JavaScript to be homoiconic and have modular macros written in the same language. I feel like this is the adjacent possible in that direction. Sweet.js exists for macros, but theyre awkward to write and aren't JavaScript. Various JavaScript lisps exist, but most have featuritis from trying too hard to be Lisp (rather than just being a JS syntax), and none have macros that are just JS functions.

I want a language that I can adapt. When I need anaphoric conditionals, or conditional compilation or file content inlining (like brfs), or a domain-specific language for my favourite library, or something insane that hacks NASA and runs all my while-loops through grep during compilation for some freak reason, I want to be able to create that language feature myself or require it from npm if it exists, and hence make the language better for that job, and for others doing it in the future.

That's the dream anyway.

S-expressions are also quite conceptually beautiful; they're just nested lists, minimally representing the abstract syntax tree, and it's widely known that they rock, so let's use what works.

This has great hack value too of course. Lisp macros are the coolest thing since mint ice cream. Do I even need to say that?

Further documentation in doc/:

Brief tutorial

This is a quick overview of the core language. See the basics reference or the test suite for a more complete document.

Building blocks

Eslisp code consists of comments, atoms, strings and lists.

; Everything from a semicolon to the end of a line is a comment.

hello           ; This is an atom.
"hello"         ; This is a string.
(hello "hello") ; This is a list containing an atom and a string.
()              ; This is an empty list.

Lists describe the code structure. Whitespace is insignificant.

(these mean (the same) thing)

(these
mean (the
same) thing)

(these    mean (the
                same) thing)

All eslisp code is constructed by calling macros at compile-time. There are built-in macros to generate JavaScript operators, loop structures, expressions, statements… everything needed to write arbitrary JavaScript.

Some simple built-in macros

A macro is called by writing a list with its name as the first element and its arguments as the rest:

; The "." macro compiles to property access.
(. a b)
(. a b 5 c "yo")

; The "+" macro compiles to addition.
(+ 1 2)

; ... and similarly for "-", "*", "/" and "%" as you'd expect from JS.
a.b;
a.b[5].c['yo'];
1 + 2;

If the (. a b) syntax feels tedious, you might like the eslisp-propertify transform macro, which lets you write a.b instead.

If the first element of a list isn't the name of a macro which is in scope, it compiles to a function call:

(a 1)
(a 1 2)
(a)
a(1);
a(1, 2);
a();

These can of course be nested:

; The "=" macro compiles to a variable declaration.
(var x (+ 1 (* 2 3)))

; Calling the result of a property access expression
((. console log) "hi")
var x = 1 + 2 * 3;
console.log('hi');

More complex built-in macros

Conditionals are built with the if macro:

; The "if" macro compiles to an if-statement
(if lunchtime                 ; argument 1 becomes the conditional
    (block
      (var lunch (find food)) ; argument 2 the consequent
      (lunch))
    (writeMoreCode))          ; argument 3 (optional) the alternate
if (lunchtime) {
    var lunch = find(food);
    lunch();
} else
    writeMoreCode();

Note how the block statement ((block ...)) has to be made explicit. Because it's so common, other macros that accept a block statement as their last argument have sugar for this: they just assume you meant the rest to be in a block.

For example. the lambda macro (which creates function expressions) treats its first argument as a list of the function's argument names, and the rest as statements in the body.

(var f (lambda (x)
      (a x)
      (return (+ x 2))))
(f 40)
var f = function (x) {
    a(x);
    return x + 2;
};
f(40);

While-loops similarly.

(var n 10)
(while (-- n)   ; first argument is loop conditional
 (hello n)      ; the rest are loop-body statements
 (hello (- n 1)))
var n = 10;
while (--n) {
    hello(n);
    hello(n - 1);
}

You can use an explicit block statements ((block ...)) wherever implicit ones are allowed, if you want to.

(var n 10)
(while (-- n)
 (block (hello n)
        (hello (- n 1))))
var n = 10;
while (--n) {
    hello(n);
    hello(n - 1);
}

Writing your own macros

This is what eslisp is really for.

Macros are functions that run at compile-time. Whatever they return becomes part of the compiled code. User-defined macros and pre-defined compiler ones are treated equivalently.

There's a fuller tutorial to eslisp macros in the doc/ directory. These are just some highlights.

You can alias macros to names you find convenient, or mask any you don't want to use.

(macro a array)
(a 1)
(array 1)     ; The original still works though...

(macro array) ; ...unless we deliberately mask it
(array 1)
[1];
[1];
array(1);

Macros can use quasiquote (`), unquote (,) and unquote-splicing (,@) to construct their outputs neatly.

(macro m (lambda (x) (return `(+ ,x 2))))
((. console log) (m 40))
console.log(40 + 2);

The macro function is called with a this context containing methods handy for working with macro arguments, such as this.evaluate, which compiles and runs the argument and returns the result, and this.atom which creates a new S-expression atom.

(macro add2 (lambda (x)
             (var xPlusTwo (+ ((. this evaluate) x) 2))
             (return ((. this atom) xPlusTwo))))
((. console log) (add2 40))
console.log(42);

You can return multiple statements from a macro by returning an array.

(macro log-and-delete (lambda (varName)
 (return (array
          `((. console log) ((. JSON stringify) ,varName))
          `(delete ,varName)))))

(log-and-delete someVariable)
console.log(JSON.stringify(someVariable));
delete someVariable;

Returning null from a macro just means nothing. This is handy for compilation side-effects and conditional compilation.

; Only include statement if `$DEBUG` environment variable is set
(macro debug (lambda (statement)
 (return (?: (. process env DEBUG) statement null))))

(debug ((. console log) "debug output"))
(yep)
yep();

Because macros are JS functions and JS functions can be closures, you can even make macros that share state. One way is to put them in an immediately-invoked function expression (IIFE), return them in an object, and pass that to macro. Each property of the object is imported as a macro, and the variables in the IIFE are shared between them.

(macro ((lambda ()
        (var x 0) ; visible to all of the macro functions
        (return
         (object increment (lambda () (return ((. this atom) (++ x))))
                 decrement (lambda () (return ((. this atom) (-- x))))
                 get       (lambda () (return ((. this atom) x))))))))

(increment)
(increment)
(increment)

(decrement)

(get)
1;
2;
3;
2;
2;

Macros as modules

The second argument to macro needs to evaluate to a function, but it can be whatever, so you can put the macro function in a separate file and do—

(macro someName (require "./file.js"))

—to use it.

This means you can publish eslisp macros on npm. The name prefix eslisp- and keyword eslisp-macro are recommended. Some exist already.

Transformation macros

When running eslc from the command line, to apply a transformation macro to an eslisp file during compilation, supply the --transform <macro-name> argument (-t for short). For example,

eslc --transform eslisp-propertify myprogram.esl

uses eslisp-propertify to convert all atoms containg dots into member expressions. The flag can be specified multiple times.

Try it

Global install

If you want eslc in your $PATH, npm install --global eslisp. (You might need sudo.) Then eslc program takes eslisp code as input and outputs JavaScript.

If run interactively without arguments, the compiler loads a REPL that you can type commands into to test them.

You can also just pipe data to it to compile it if you want.

echo '((. console log) "Yo!")' | eslc

Or pass a filename, like eslc myprogram.esl.

To remove it cleanly, npm uninstall --global eslisp.

Local install

If you want the compiler in node_modules/.bin/eslc, do npm install eslisp.

You can also use eslisp as a module: it exports a function that takes a string of eslisp code as input and outputs a string of JavaScript, throwing errors if it sees them.

How does it work

In brief: A table of predefined macros is used to turn S-expressions into SpiderMonkey AST, which is fed to escodegen, which outputs JS. Some of those macros allow defining further macros, which get added to the table and work from then on like the predefined ones.

For more, read the source. Ask questions!

Bugs, discussion & contributing

Create a github issue, or say hi in gitter chat.

I'll assume your contributions to also be under the ISC license.

License

ISC.