README
Subscript is expression evaluator / microlanguage with common syntax.
- Any fragment can be copy-pasted to any language: C++, JS, Java, Python, Go, Rust etc.
- Tiny size
- :rocket: Fast performance
- Configurable & extensible
- Trivial to use
import script, { parse, compile } from './subscript.js'
// create expression evaluator
let fn = script('a.b + c(d - 1)')
fn({ a: { b:1 }, c: x => x * 2, d: 3 }) // 5
// or
// parse expression tree
let tree = parse('a.b + c')
tree // ['+', ['.', 'a', 'b'], 'c']
// compile tree to evaluable function
let evaluate = compile(tree)
Motivation
Subscript is designed to be useful for:
- templates (perfect match with template parts, see templize)
- expressions evaluators, calculators
- configurable subsets of languages (eg. justin)
- pluggable/mock language features (eg. pipe operator)
- sandboxes, playgrounds, safe eval
- custom DSL
Subscript has 2.8kb footprint, compared to 11.4kb jsep + 4.5kb expression-eval, with better test coverage and better performance.
Design
Default operators are (same as JS precedence order):
( a, b, c )
a.b
,a[b]
,a(b, c)
a++
,a--
unary postfix!a
,+a
,-a
,++a
,--a
unary prefixa * b
,a / b
,a % b
a + b
,a - b
a << b
,a >> b
,a >>> b
a < b
,a <= b
,a > b
,a >= b
a == b
,a != b
a & b
a ^ b
a | b
a && b
a || b
a , b
Default literals:
"abc"
strings1.2e+3
numbers
Everything else can be extended via subscript.set(str, prec, fn)
for unary, binary or n-ary operators (detected by number of arguments in fn
), or via subscript.set(str, prec, [parse, compile])
for custom tokens.
import script, { compile } from './subscript.js'
// add ~ unary operator with precedence 15
script.set('~', 15, a => ~a)
// add === binary operator with precedence 9
script.set('===', 9, (a, b) => a===b)
// add literals
script.set('true', 20, [a => ['',true], a => ctx => a[1]])
script.set('false', 20, [a => ['',false], a => ctx => a[1]])
script(`true === false`)() // false
See subscript.js or justin.js for examples.
Parser & Compiler
Subscript exposes separate ./parse.js
and ./compile.js
entries. Parser builds AST, compiler converts it to evaluable function.
AST has simplified lispy calltree structure (inspired by frisk), opposed to ESTree:
- is not limited to particular language, can be cross-compiled;
- reflects execution sequence, rather than code layout;
- has minimal possible overhead, better fits for directly mapping to operators;
- simplifies manual evaluation and debugging;
- has conventional form and one-liner docs:
import { compile } from 'subscript.js'
const fn = compile(['+', ['*', 'min', ['',60]], ['','sec']])
fn({min: 5}) // min*60 + "sec" == "300sec"
Justin
Justin is minimal JS subset − JSON with JS expressions (see original thread).
It extends subscript with:
===
,!==
operators**
exponentiation operator (right-assoc)~
bit inversion operator'
strings?:
ternary operator?.
optional chain operator??
nullish coalesce operator[...]
Array literal{...}
Object literalin
binary;
expression separator//
,/* */
commentstrue
,false
,null
,undefined
literals
import jstin from 'subscript/justin.js'
let xy = jstin('{ x: 1, "y": 2+2 }["x"]')
xy() // 1
Performance
Subscript shows relatively good performance within other evaluators:
1 + (a * b / c % d) - 2.0 + -3e-3 * +4.4e4 / f.g[0] - i.j(+k == 1)(0)
Parse 30k times:
es-module-lexer: 50ms 🥇
subscript: ~150 ms 🥈
justin: ~183 ms
jsep: ~270 ms 🥉
jexpr: ~297 ms
mr-parser: ~420 ms
expr-eval: ~480 ms
math-parser: ~570 ms
math-expression-evaluator: ~900ms
jexl: ~1056 ms
mathjs: ~1200 ms
new Function: ~1154 ms
Eval 30k times:
new Function: ~7 ms 🥇
subscript: ~15 ms 🥈
justin: ~17 ms
jexpr: ~23 ms 🥉
jsep (expression-eval): ~30 ms
math-expression-evaluator: ~50ms
expr-eval: ~72 ms
jexl: ~110 ms
mathjs: ~119 ms
mr-parser: -
math-parser: -
Alternatives
- jexpr
- jsep
- jexl
- mozjexl
- expr-eval
- expression-eval
- string-math
- nerdamer
- math-codegen
- math-parser
- math.js
JS engines
🕉