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.
⚠️ Only ES5 is supported right now. Unless you write the macros yourself.
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/
:
- Language basics reference
- Macro-writing tutorial
- Module packaging and distribution tutorial
- Comparison against other JS-lisps
- Using source maps
- Using with client-side bundling tools
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.
Or if you'd like to also be able to load macros from .esl
eslisp files
without first compiling them to JavaScript, you can do that with
(macroRequire someName "./file.esl")
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.