subscript
subscript copied to clipboard
Fast and tiny expression evaluator with minimal syntax.
Subscript is expression evaluator / microlanguage with common expressions syntax (JavaScript, Java, C, C++, Rust, Go, Python, Kotlin etc).
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
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.
Operators
( a, b, c )a.b,a[b],a(b, c)a++,a--unary postfix!a,+a,-a,++a,--aunary prefixa * b,a / b,a % ba + b,a - ba << b,a >> b,a >>> ba < b,a <= b,a > b,a >= ba == b,a != ba & ba ^ ba | ba && ba || ba , b
Literals
"abc"strings1.2e+3numbers
Extending
Operators/tokens can be extended via:
unary(str, precedence, postfix=false)− register unary operator, either prefix or postfix.binary(str, precedence, rightAssoc=false)− register binary operator, optionally right-associative.nary(str, precedence, allowSkip=false)− register n-ary (sequence) operator, optionally allowing skipping args.token(str, precedence, lhs => expr)− register custom token or literal. Mapper takes last token argument and returns calltree node.operator(str, fn)− register evaluator for operator.fntakes node arguments and returns evaluator function.
import script, { operator, unary, binary, token } from './subscript.js'
// add ~ unary operator with precedence 15
unary('~', 15)
operator('~', a => ~a)
// add === binary operator with precedence 9
binary('===', 9)
operator('===', (a, b) => a===b)
// add literals
token('true', 20, a => ['',true])
token('false', 20, a => ['',false])
operator('', a => ctx => a[1]])
See subscript.js or justin.js for examples.
Syntax tree
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 compiled to different targets;
- reflects execution sequence, rather than code layout;
- has minimal possible overhead, directly maps 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 literalinbinary;expression separator//,/* */commentstrue,false,null,undefinedliterals
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. Example expression:
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: -