/subscript

Expression evaluator with standard c/js-like syntax

Primary LanguageJavaScriptISC LicenseISC

subscript

Subscript is expression evaluator / microlanguage with common syntax (JavaScript, Java, C, C++, Rust, Go, Python, Kotlin etc).

  • Tiny size npm bundle size
  • 🚀 Fast performance
  • Configurable & extensible
  • Trivial to use
import subscript, { parse, compile } from './subscript.js'

// create expression evaluator
let fn = subscript('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
fn = compile(tree)
fn({a:{b:1}, c:2}) // 3 

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 (see lino)
  • preprocessors (see prepr)

Subscript has 3.5kb footprint, compared to 11.4kb jsep + 4.5kb expression-eval, with better test coverage and better performance.

Operators / literals

↑ precedence order

  • ( a, b, c )
  • a.b, a[b], a(b, c)
  • a++, a-- unary postfix
  • !a, +a, -a, ++a, --a unary prefix
  • 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
  • a , b
  • "abc" strings
  • 1.2e+3 numbers

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 literal
  • in binary
  • ; expression separator
  • //, /* */ comments
  • true, false, null, undefined literals
import jstin from 'subscript/justin.js'

let xy = jstin('{ x: 1, "y": 2+2 }["x"]')
xy()  // 1

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, map) − register custom token or literal. map takes last token argument and returns calltree node.
  • operator(str, fn) − register evaluator for operator. fn takes 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 boolean literals
token('true', 20, prev => ['',true])
token('false', 20, prev => ['',false])
operator('', boolNode => ctx => boolNode[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 / nisp), opposed to ESTree:

  • not limited to particular language (JS), can be compiled to different targets;
  • reflects execution sequence, rather than code layout;
  • has minimal possible overhead (object wrappers, named properties), directly maps to operators;
  • simplifies manual evaluation and debugging;
  • has conventional form and one-line docs:
import { compile } from 'subscript.js'

const fn = compile(['+', ['*', 'min', ['',60]], ['','sec']])

fn({min: 5}) // min*60 + "sec" == "300sec"

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: -

Alternatives

🕉