conscript
Version:
The JavaScript parser for the Conscript condition-scripting language.
502 lines (452 loc) • 18.9 kB
JavaScript
'use strict'
const arrify = require('arrify')
const caseInsensitive = require('case-insensitive')
const clone = require('clone')
const filterObject = require('filter-obj')
const isObject = require('is-object')
const isNonArrayObject = require('isobject')
const isit = require('isit')
const {has, get} = require('m-o')
const objectEquals = require('equals')
const removePrefix = require('remove-prefix')
const replaceString = require('replace-string')
const toNumber = require('2/number')
const toStr = require('2/string')
const notAVar = Symbol('notAVar')
const u = x => typeof x === 'undefined'
const boolOps = ['&', '|']
const absCompOps = ['<', '<=', '=', '>=', '>', '<>', '~=', '^=', '^~=', '$=', '$~=', '*=', '*~=']
const compOps = [' is ', ' is not ', ' !is ', ' in ', ' ~in ', ' not in ', ' !in ', ' !~in ', ' not ~in ', ' matches ', ' !matches ', ...absCompOps, ...absCompOps.map(s => '!' + s)]
const mathOps = ['+', ' before ', ' then ', '-', '*', '/', '%', '^']
const regexDelimiter = '@'
const identifierName = /[a-zA-Z0-9_ ]/
const notIdentifierName = /[^a-zA-Z0-9_ ]/g
const digits = '0123456789'
const esc = '\\'
const ignore = [['(', ')'], ['[', ']'], ['{', '}'], ['"', '"', {esc}], ["'", "'", {esc}], ['@', '@', {esc}]]
const number = /^-?\.?[0-9]/
function accessArrayProp (x, prop, maybe) {
x = x()
if (Array.isArray(x) || typeof x === 'string') {
switch (prop) {
case 'empty': return x.length === 0
case 'every': return cb => Array.from(x).every(cb)
case 'last': return x[x.length - 1]
case 'length': case 'count': return x.length
case 'map': return cb => Array.from(x).map(cb)
case 'multiple': return x.length > 1
case 'pop': return (num, handler) => {
num = Math.abs(toNumber(num))
const arr = Array.from(x)
if (typeof handler !== 'function') return arr.slice(arr.length - num)
return handler(arr.slice(0, arr.length - num), ...arr.slice(arr.length - num))
}
case 'shift': return (num, handler) => {
num = Math.abs(toNumber(num))
const arr = Array.from(x)
if (typeof handler !== 'function') return arr.slice(0, num)
return handler(...arr.slice(0, num), arr.slice(num, arr.length))
}
case 'some': return cb => Array.from(x).some(cb)
case 'slice': return (start, stop) => x.slice(start, stop)
default: return x[toNumber(prop, {elseThrow: 'Array index `' + prop + '` is not a number'})]
}
} else if (!maybe) {
throw new TypeError('Cannot retrieve property `' + prop + '` from a non-array')
}
return null
}
function accessObjectProp (obj, prop, maybe) {
obj = obj()
if (isObject(obj)) {
if (Array.isArray(obj)) return accessArrayProp(() => obj, prop, maybe)
return has(obj, prop) ? get(obj, prop) : null
} else if (!maybe) {
throw new TypeError('Cannot retrieve property `' + prop + '` from a non-object')
}
return null
}
function applyAbsoluteComparisonOperator (left, op, right, safeOp) {
switch (op) {
case ' is ': return args => isit(right(args), left(args))
case ' !is ': case ' is not ': return args => !isit(right(args), left(args))
case ' in ': return args => applyInclusionOperator(left(args), right(args), false)
case ' !in ': case ' not in ': return args => !applyInclusionOperator(left(args), right(args), false)
case ' ~in ': return args => applyInclusionOperator(left(args), right(args), true)
case ' !~in ': case ' not ~in ': return args => !applyInclusionOperator(left(args), right(args), true)
case ' matches ': return getApplyRegexOperator(left, right, true, safeOp)
case ' !matches ': return getApplyRegexOperator(left, right, false, safeOp)
case '<': return args => left(args) < right(args)
case '<=': return args => left(args) <= right(args)
case '=': return args => equals(left(args), right(args))
case '>=': return args => left(args) >= right(args)
case '>': return args => left(args) > right(args)
case '<>': return args => left(args) !== right(args)
case '~=': return args => toStr(left(args)).toLowerCase() === toStr(right(args)).toLowerCase()
case '^=': return args => toStr(left(args)).startsWith(toStr(right(args)))
case '^~=': return args => toStr(left(args)).toLowerCase().startsWith(toStr(right(args)).toLowerCase())
case '$=': return args => toStr(left(args)).endsWith(toStr(right(args)))
case '$~=': return args => toStr(left(args)).toLowerCase().endsWith(toStr(right(args)).toLowerCase())
case '*=': return args => applyInclusionOperator(right(args), left(args), false)
case '*~=': return args => applyInclusionOperator(right(args), left(args), true)
}
throw new SyntaxError('Unhandled comparison operator `' + op + '`')
}
function applyBooleanOperator (left, op, right) {
switch (op) {
case '&': return args => left(args) && right(args)
case '|': return args => left(args) || right(args)
}
throw new SyntaxError('Unhandled boolean operator `' + op + '`')
}
function applyComparisonOperator (left, op, right, safeOp) {
const [absOp, neg] = removePrefix(op, '!')
const r = applyAbsoluteComparisonOperator(left, absOp, right, safeOp)
return neg ? args => !r(args) : r
}
function applyInclusionOperator (needle, haystack, ci) {
if (!Array.isArray(haystack)) {
haystack = toStr(haystack)
needle = toStr(needle)
}
if (ci) haystack = caseInsensitive(haystack)
return haystack.includes(needle)
}
function applyMathOperator (left, op, right, safeOp) {
function checkResult (result) {
if (Number.isNaN(result)) {
if (safeOp) return 0
throw new TypeError('Cannot perform ' + op + ' operation on a non-number')
}
return result
}
function add (l, r) {
const la = Array.isArray(l)
const ra = Array.isArray(r)
const ln = typeof l === 'number'
const rn = typeof r === 'number'
const ls = typeof l === 'string'
const rs = typeof r === 'string'
if (la) return l.concat(ra ? r : [r])
if (ra) return (la ? l : [l]).concat(r)
if (isNonArrayObject(l) && isNonArrayObject(r)) return {...l, ...r}
if (ln & rs) r = toNumber(r)
else if (ls & rn) l = toNumber(l)
else if (ls & !rs) {
if (!rn && !safeOp) throw new TypeError('Cannot concatenate a non-string to a string')
r = toStr(r)
} else if (!ls & rs) {
if (!ln && !safeOp) throw new TypeError('Cannot concatenate a string to a non-string')
l = toStr(l)
} else if (ln & !rn) {
if (!safeOp) throw new TypeError('Cannot add a non-number to a number')
r = 0
} else if (!ln & rn) {
if (!safeOp) throw new TypeError('Cannot add a number to a non-number')
l = 0
}
return l + r
}
switch (op) {
case '+': return args => checkResult(add(left(args), right(args)))
case '-': return args => {
let l = left(args)
let r = right(args)
if (Array.isArray(l)) {
r = arrify(r)
return l.filter(x => !r.includes(x))
}
if (isObject(l)) {
if (isObject(r) && !Array.isArray(r)) {
const re = Object.entries(r)
return filterObject(l, (lk, lv) => !re.some(([rk, rv]) => equals(lk, rk) && equals(lv, rv)))
}
r = arrify(r)
return filterObject(l, lk => !r.some(rk => equals(lk, rk)))
}
const ln = typeof l === 'number'
const rn = typeof r === 'number'
const ls = typeof l === 'string'
const rs = typeof r === 'string'
if (ls && rs) return replaceString(l, r, '')
if (ln & rs) r = toNumber(r)
else if (ls & rn) l = toNumber(l)
return checkResult(l - r)
}
case '*': return args => checkResult(left(args) * right(args))
case '/': return args => {
const l = left(args)
const r = right(args)
if (Object.is(r, 0)) return Infinity
if (Object.is(r, -0)) return -Infinity
return checkResult(l / r)
}
case '%': return args => checkResult(left(args) % right(args))
case '^': return args => checkResult(left(args) ** right(args))
case ' before ': return args => {
const rightResult = toStr(right(args))
return rightResult ? toStr(left(args)) + rightResult : rightResult
}
case ' then ': return args => {
const leftResult = left(args)
return leftResult ? checkResult(add(leftResult === true ? '' : leftResult, right(args))) : leftResult
}
}
throw new SyntaxError('Unhandled math operator `' + op + '`')
}
function callFunction (identifier, func, funcArgs, maybe) {
func = func()
if (typeof func === 'function') {
return nullify(func(...funcArgs))
} else if (!maybe) {
throw new TypeError('`' + (identifier || func) + '` is not a function')
}
return null
}
function equals (l, r) {
l = zeroStringToNumber(l)
r = zeroStringToNumber(r)
if (l === 0 && r === 0) return Object.is(l, r)
return objectEquals(l, r)
}
function getApplyRegexOperator (left, right, shouldMatch, safeOp) {
return args => {
const l = left(args)
const r = right(args)
if (isit.a(RegExp, l) && isit.string(r)) return !l.test(r) === !shouldMatch
if (isit.a(RegExp, r) && isit.string(l)) return !r.test(l) === !shouldMatch
if (safeOp) return false
throw new TypeError('To use the `matches` operator, one operand must be a regular expression and the other must be a string')
}
}
function getUserVar ([vars], varName) {
if (typeof vars === 'function') {
const value = nullify(vars(varName, notAVar))
if (value !== notAVar) return value
} else if (isObject(vars) && has(vars, varName)) {
return nullify(get(vars, varName))
}
return notAVar
}
function nullify (x) {
return (typeof x === 'undefined' || Number.isNaN(x)) ? null : x
}
function zeroStringToNumber (x) {
if (x === '0') return 0
if (x === '-0') return -0
return x
}
const conscript = require('parser-factory')('start', {
start ({call}) {
const f = call('expression')
return (...args) => f(args)
},
expression ({consume, is, sub, shift, until, untilEnd}, p, {getVar = getUserVar, inTernary} = {}) {
const a2t = until('?', {ignore}).trim()
if (!consume('?')) return sub('expression2', a2t, {getVar, inTernary})
const a2 = sub('expression2', a2t, {getVar, inTernary: true})
const a = args => {
const {defaultLeft} = args[1] || {}
let value
if (a2) value = a2(args)
return (u(value) && !u(defaultLeft)) ? defaultLeft : value
}
const b2 = sub('expression', untilEnd('?', ':', {ignore}).trim(), {getVar, inTernary: true})
const b = args => {
const value = b2(args)
return u(value) ? a(args) : value
}
if (!consume(':')) throw new SyntaxError('Missing second half of ternary expression')
const c = sub('expression', shift(Infinity), {getVar, inTernary: true})
return args => a(args) ? b(args) : c(args)
},
expression2 ({call}, p, t) {
return call('operator', {operators: boolOps, apply: applyBooleanOperator, next: 'expression3', t})
},
expression3 ({call}, p, t = {}) {
const {inTernary} = t
const cb = call('operator', {operators: compOps, apply: applyComparisonOperator, next: 'expression4', t})
return args => {
const {defaultLeft} = args[1] || {}
const value = cb(args)
return (inTernary || u(defaultLeft) || typeof value === 'boolean') ? value : value === defaultLeft
}
},
expression4 ({call}, p, t) {
return call('operator', {operators: mathOps, apply: applyMathOperator, next: 'value', t})
},
operator ({call, char, consume, is, sub, until}, {userArgs: [{safe, safeOp = safe} = {}]}, {operators, apply, next, t}) {
const chunk = op => {
const value = (is('- ') ? '' : consume('-')) + until(...operators, {ignore}).trim()
if (op && !value) throw new SyntaxError('Expected to find an expression to the right of the ' + op + ' operator.')
return sub(next, value, t)
}
const wordOps = operators.reduce((w, op) => { if (op.startsWith(' ')) w.push(op.substr(1)); return w }, [])
let initialWord = is(...wordOps)
let leftChunk
if (!initialWord) leftChunk = chunk()
let left = args => {
const {defaultLeft} = args[1] || {}
if (u(leftChunk) && !u(defaultLeft)) return defaultLeft
if (leftChunk) return leftChunk(args)
}
while (char()) {
const op = initialWord ? ' ' + consume(...wordOps) : consume(...operators)
call('whitespace')
if (op) left = apply(left, op, chunk(op), safeOp); else break
initialWord = false
}
return left
},
whitespace ({consumeWhile}) {
consumeWhile(' \r\n\t')
},
value ({bracket, call, char, consume, is, until}, {userArgs: [{allowRegexLiterals, debugOutput} = {}]}, {getVar} = {}) {
while (char()) {
call('whitespace')
if (consume('(')) return call('valueAccess', {getVar, value: call('parens', {getVar})})
else if (consume('!')) {
const cb = call('value', {getVar})
return args => {
const {defaultLeft} = args[1] || {}
const value = cb(args)
return (u(defaultLeft) || typeof value === 'boolean') ? !value : value !== defaultLeft
}
} else if (consume('debug ')) {
const syntax = char(Infinity)
const cb = call('value', {getVar})
return args => {
const value = cb(args)
if (typeof debugOutput === 'function') debugOutput(syntax, value)
return value
}
} else if (consume('$')) return call('valueAccess', {getVar, identifier: call('identifier', {getVar})})
else if (consume('[')) return call('valueAccess', {accessProp: accessArrayProp, getVar, value: bracket('list', '[', ']', {ignore}, {getVar})})
else if (allowRegexLiterals && consume(regexDelimiter)) return call('regex')
else if (is('"', "'")) return call('valueAccess', {accessProp: accessArrayProp, getVar, value: call('string')})
else if (is('.')) return call('valueAccess', {getVar})
else if (consume('true', {ci: true})) return () => true
else if (consume('false', {ci: true})) return () => false
else if (consume('null', {ci: true})) return () => null
else if (consume('-∞') || consume('-infinity', {ci: true})) return () => -Infinity
else if (consume('∞') || consume('infinity', {ci: true})) return () => Infinity
else if (number.test(char(3))) return call('number')
return call('fallback', {getVar})
}
},
identifier ({bracket, consume, consumeWhile, throughEnd}, p, {getVar}) {
if (consume('(')) return bracket('expression', '(', ')', {ignore}, {getVar})
if (consume('{')) {
const literal = throughEnd('{', '}', {esc})
return () => literal
}
const literal = consumeWhile(identifierName).trim()
return () => literal
},
fallback ({call, char, until}, {userArgs: [{unknownsAre} = {}]}, {getVar} = {}) {
const identifier = until('(', '.').trim()
if (char()) return call('valueAccess', {identifier: () => identifier, getVar})
if (notIdentifierName.test(identifier)) throw new SyntaxError('Unrecognized syntax: `' + identifier + '`')
return args => {
const varValue = getVar(args, identifier)
if (varValue !== notAVar) return varValue
switch (unknownsAre) {
default:
case 'strings':
case 'str':
return identifier
case 'null':
case null:
return null
case 'errors':
case 'err':
throw new ReferenceError('Unknown variable: `' + identifier + '`')
}
}
},
valueAccess ({bracket, call, char, consume}, {userArgs: [{safe, safeNav = safe, safeCall = safe} = {}]}, {accessProp = accessObjectProp, getVar, identifier, value} = {}) {
let cb = value || (identifier ? args => {
const varName = identifier(args)
if (varName === '') return isNonArrayObject(args[0]) ? clone(args[0]) : {}
const val = getVar(args, varName)
return val === notAVar ? null : val
} : ([, {defaultLeft} = {}]) => {
if (u(defaultLeft)) throw new SyntaxError('Property access chains can only begin with a dot (.) if defaultLeft is specified')
return defaultLeft
})
while (char()) {
call('whitespace')
const last = cb
if (consume('(')) {
const funcArgs = bracket('list', '(', ')', {}, {getVar})
cb = args => callFunction(identifier, () => last(args), funcArgs(args), safeCall)
} else if (consume('.')) {
const prop = call('identifier', {getVar})
cb = args => accessProp(() => last(args), prop(args), safeNav)
} else {
break
}
}
return cb
},
parens ({call, char, consume, sub, throughEnd, until}, p, {getVar}) {
const first = throughEnd('(', ')', {ignore})
call('whitespace')
const second = consume('{') ? throughEnd('{', '}', {ignore}) : null
if (second === null) return sub('expression', first)
// We're dealing with a function
const varNames = sub('list', first, {evaluate: false})
.map(varName => varName.replace(notIdentifierName, ''))
return userArgs => (...funcArgs) => {
const argVars = new Map()
for (let i = 0; i < varNames.length; i++) {
const varName = varNames[i]
if (!varName) continue
argVars.set(varName, i >= funcArgs.length ? null : funcArgs[i])
}
return sub('expression', second, {
getVar: ([vars], varName) => argVars.has(varName) ? argVars.get(varName) : getVar([vars], varName),
})(userArgs)
}
},
list ({consume, char, sub, until}, p, {evaluate = true, getVar}) {
const arr = []
while (char()) {
arr.push(evaluate ? sub('expression', until(',', {ignore}), {getVar}) : until(','))
consume(',')
}
return evaluate ? args => arr.map(item => item(args)) : arr.map(item => item.trim())
},
regex ({consume, consumeWhile, char, until}) {
const regex = until(regexDelimiter, {esc})
consume(regexDelimiter)
const flags = consumeWhile('gimsuy')
return () => new RegExp(regex, flags)
},
string ({consume, until}) {
const quote = consume('"', "'")
if (!quote) throw new Error('string subroutine called without quote in queue')
const value = until(quote, {esc})
consume(quote)
return () => value
},
number ({char, consume, consumeWhile}) {
const neg = !!consume('-')
let n = ''
let dec = false
while (char()) {
if (consume('.')) {
if (dec) throw new SyntaxError('Number cannot have more than one decimal point')
dec = true
n += '.'
}
const d = consumeWhile(digits)
if (!d) break
n += d
}
n = Number(n)
if (neg) n = -n
return () => n
},
})
module.exports = (defaultOptions = {}) => (conscription, options = {}) => conscript(conscription, {...defaultOptions, ...options})