blob: 66f500f9ca3d8d0d6495adf67b2466b64031608d [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const fqn = require('./fqn')
const fs = require('fs')
const util = require('util')
const version = require('./package.json').version
const isObject = obj => typeof obj === 'object' && obj !== null && !Array.isArray(obj)
// error class
class ComposerError extends Error {
constructor (message, argument) {
super(message + (argument !== undefined ? '\nArgument value: ' + util.inspect(argument) : ''))
}
}
const composer = { util: { declare, version } }
const lowerer = {
literal (value) {
return composer.let({ value }, () => value)
},
retain (...components) {
let params = null
return composer.let(
{ params },
composer.finally(
args => { params = args },
composer.seq(composer.mask(...components),
result => ({ params, result }))))
},
retain_catch (...components) {
return composer.seq(
composer.retain(
composer.finally(
composer.seq(...components),
result => ({ result }))),
({ params, result }) => ({ params, result: result.result }))
},
if (test, consequent, alternate) {
let params = null
return composer.let(
{ params },
composer.finally(
args => { params = args },
composer.if_nosave(
composer.mask(test),
composer.finally(() => params, composer.mask(consequent)),
composer.finally(() => params, composer.mask(alternate)))))
},
while (test, body) {
let params = null
return composer.let(
{ params },
composer.finally(
args => { params = args },
composer.seq(composer.while_nosave(
composer.mask(test),
composer.finally(() => params, composer.seq(composer.mask(body), args => { params = args }))),
() => params)))
},
dowhile (body, test) {
let params = null
return composer.let(
{ params },
composer.finally(
args => { params = args },
composer.seq(composer.dowhile_nosave(
composer.finally(() => params, composer.seq(composer.mask(body), args => { params = args })),
composer.mask(test)),
() => params)))
},
repeat (count, ...components) {
return composer.let(
{ count },
composer.while(
() => count-- > 0,
composer.mask(...components)))
},
retry (count, ...components) {
return composer.let(
{ count },
params => ({ params }),
composer.dowhile(
composer.finally(({ params }) => params, composer.mask(composer.retain_catch(...components))),
({ result }) => result.error !== undefined && count-- > 0),
({ result }) => result)
},
merge (...components) {
return composer.seq(composer.retain(...components), ({ params, result }) => Object.assign(params, result))
}
}
// apply f to all fields of type composition
function visit (composition, f) {
composition = Object.assign({}, composition) // copy
const combinator = composition['.combinator']()
if (combinator.components) {
composition.components = composition.components.map(f)
}
for (let arg of combinator.args || []) {
if (arg.type === undefined && composition[arg.name] !== undefined) {
composition[arg.name] = f(composition[arg.name], arg.name)
}
}
return new Composition(composition)
}
// recursively label combinators with the json path
function label (composition) {
const label = path => (composition, name, array) => {
const p = path + (name !== undefined ? (array === undefined ? `.${name}` : `[${name}]`) : '')
composition = visit(composition, label(p)) // copy
composition.path = p
return composition
}
return label('')(composition)
}
// derive combinator methods from combinator table
// check argument count and map argument positions to argument names
// delegate to Composition constructor for the rest of the validation
function declare (combinators, prefix) {
if (arguments.length > 2) throw new ComposerError('Too many arguments in "declare"')
if (!isObject(combinators)) throw new ComposerError('Invalid argument "combinators" in "declare"', combinators)
if (prefix !== undefined && typeof prefix !== 'string') throw new ComposerError('Invalid argument "prefix" in "declare"', prefix)
const composer = {}
for (let key in combinators) {
const type = prefix ? prefix + '.' + key : key
const combinator = combinators[key]
if (!isObject(combinator) || (combinator.args !== undefined && !Array.isArray(combinator.args))) {
throw new ComposerError(`Invalid "${type}" combinator specification in "declare"`, combinator)
}
for (let arg of combinator.args || []) {
if (typeof arg.name !== 'string') throw new ComposerError(`Invalid "${type}" combinator specification in "declare"`, combinator)
}
composer[key] = function () {
const composition = { type, '.combinator': () => combinator }
const skip = (combinator.args && combinator.args.length) || 0
if (!combinator.components && (arguments.length > skip)) {
throw new ComposerError(`Too many arguments in "${type}" combinator`)
}
for (let i = 0; i < skip; ++i) {
composition[combinator.args[i].name] = arguments[i]
}
if (combinator.components) {
composition.components = Array.prototype.slice.call(arguments, skip)
}
return new Composition(composition)
}
}
return composer
}
// composition class
class Composition {
// weaker instanceof to tolerate multiple instances of this class
static [Symbol.hasInstance] (instance) {
return instance.constructor && instance.constructor.name === Composition.name
}
// construct a composition object with the specified fields
constructor (composition) {
const combinator = composition['.combinator']()
Object.assign(this, composition)
for (let arg of combinator.args || []) {
if (composition[arg.name] === undefined && arg.optional && arg.type !== undefined) continue
switch (arg.type) {
case undefined:
try {
this[arg.name] = composer.task(arg.optional ? composition[arg.name] || null : composition[arg.name])
} catch (error) {
throw new ComposerError(`Invalid argument "${arg.name}" in "${composition.type} combinator"`, composition[arg.name])
}
break
case 'name':
try {
this[arg.name] = fqn(composition[arg.name])
} catch (error) {
throw new ComposerError(`${error.message} in "${composition.type} combinator"`, composition[arg.name])
}
break
case 'value':
if (typeof composition[arg.name] === 'function' || composition[arg.name] === undefined) {
throw new ComposerError(`Invalid argument "${arg.name}" in "${composition.type} combinator"`, composition[arg.name])
}
break
case 'object':
if (!isObject(composition[arg.name])) {
throw new ComposerError(`Invalid argument "${arg.name}" in "${composition.type} combinator"`, composition[arg.name])
}
break
default:
if ('' + typeof composition[arg.name] !== arg.type) {
throw new ComposerError(`Invalid argument "${arg.name}" in "${composition.type} combinator"`, composition[arg.name])
}
}
}
if (combinator.components) this.components = (composition.components || []).map(obj => composer.task(obj))
return this
}
// compile composition
compile () {
if (arguments.length > 0) throw new ComposerError('Too many arguments in "compile"')
const actions = []
const flatten = composition => {
composition = visit(composition, flatten)
if (composition.type === 'action' && composition.action) {
actions.push({ name: composition.name, action: composition.action })
delete composition.action
}
return composition
}
const obj = { composition: label(flatten(this)).lower(), ast: this, version }
if (actions.length > 0) obj.actions = actions
return obj
}
// recursively lower combinators to the desired set of combinators (including primitive combinators)
lower (combinators = []) {
if (arguments.length > 1) throw new ComposerError('Too many arguments in "lower"')
if (!Array.isArray(combinators)) throw new ComposerError('Invalid argument "combinators" in "lower"', combinators)
const lower = composition => {
// repeatedly lower root combinator
while (composition['.combinator']().def) {
const path = composition.path
const combinator = composition['.combinator']()
if (Array.isArray(combinators) && combinators.indexOf(composition.type) >= 0) break
// map argument names to positions
const args = []
const skip = (combinator.args && combinator.args.length) || 0
for (let i = 0; i < skip; i++) args.push(composition[combinator.args[i].name])
if (combinator.components) args.push(...composition.components)
composition = combinator.def(...args)
if (path !== undefined) composition.path = path // preserve path
}
// lower nested combinators
return visit(composition, lower)
}
return lower(this)
}
}
// primitive combinators
const combinators = {
sequence: { components: true },
if_nosave: { args: [{ name: 'test' }, { name: 'consequent' }, { name: 'alternate', optional: true }] },
while_nosave: { args: [{ name: 'test' }, { name: 'body' }] },
dowhile_nosave: { args: [{ name: 'body' }, { name: 'test' }] },
try: { args: [{ name: 'body' }, { name: 'handler' }] },
finally: { args: [{ name: 'body' }, { name: 'finalizer' }] },
let: { args: [{ name: 'declarations', type: 'object' }], components: true },
mask: { components: true },
action: { args: [{ name: 'name', type: 'name' }, { name: 'action', type: 'object', optional: true }] },
function: { args: [{ name: 'function', type: 'object' }] },
async: { components: true },
parallel: { components: true },
map: { components: true },
dynamic: {}
}
Object.assign(composer, declare(combinators))
// derived combinators
const extra = {
empty: { def: composer.sequence },
seq: { components: true, def: composer.sequence },
if: { args: [{ name: 'test' }, { name: 'consequent' }, { name: 'alternate', optional: true }], def: lowerer.if },
while: { args: [{ name: 'test' }, { name: 'body' }], def: lowerer.while },
dowhile: { args: [{ name: 'body' }, { name: 'test' }], def: lowerer.dowhile },
repeat: { args: [{ name: 'count', type: 'number' }], components: true, def: lowerer.repeat },
retry: { args: [{ name: 'count', type: 'number' }], components: true, def: lowerer.retry },
retain: { components: true, def: lowerer.retain },
retain_catch: { components: true, def: lowerer.retain_catch },
value: { args: [{ name: 'value', type: 'value' }], def: lowerer.literal },
literal: { args: [{ name: 'value', type: 'value' }], def: lowerer.literal },
merge: { components: true, def: lowerer.merge },
par: { components: true, def: composer.parallel }
}
Object.assign(composer, declare(extra))
// add or override definitions of some combinators
Object.assign(composer, {
// detect task type and create corresponding composition object
task (task) {
if (arguments.length > 1) throw new ComposerError('Too many arguments in "task" combinator')
if (task === undefined) throw new ComposerError('Invalid argument in "task" combinator', task)
if (task === null) return composer.empty()
if (task instanceof Composition) return task
if (typeof task === 'function') return composer.function(task)
if (typeof task === 'string') return composer.action(task)
throw new ComposerError('Invalid argument "task" in "task" combinator', task)
},
// function combinator: stringify function code
function (fun) {
if (arguments.length > 1) throw new ComposerError('Too many arguments in "function" combinator')
if (typeof fun === 'function') {
fun = `${fun}`
if (fun.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function in "function" combinator', fun)
}
if (typeof fun === 'string') {
fun = { kind: 'nodejs:default', code: fun }
}
if (!isObject(fun)) throw new ComposerError('Invalid argument "function" in "function" combinator', fun)
return new Composition({ type: 'function', function: { exec: fun }, '.combinator': () => combinators.function })
},
// action combinator
action (name, options = {}) {
if (arguments.length > 2) throw new ComposerError('Too many arguments in "action" combinator')
if (!isObject(options)) throw new ComposerError('Invalid argument "options" in "action" combinator', options)
let exec
if (Array.isArray(options.sequence)) { // native sequence
exec = { kind: 'sequence', components: options.sequence.map(fqn) }
} else if (typeof options.filename === 'string') { // read action code from file
exec = fs.readFileSync(options.filename, { encoding: 'utf8' })
} else if (typeof options.action === 'function') { // capture function
exec = `const main = ${options.action}`
if (exec.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function in "action" combinator', options.action)
} else if (typeof options.action === 'string' || isObject(options.action)) {
exec = options.action
}
if (typeof exec === 'string') {
exec = { kind: 'nodejs:default', code: exec }
}
const composition = { type: 'action', name, '.combinator': () => combinators.action }
if (exec) composition.action = { exec }
return new Composition(composition)
},
// recursively deserialize composition
parse (composition) {
if (arguments.length > 1) throw new ComposerError('Too many arguments in "parse" combinator')
if (!isObject(composition)) throw new ComposerError('Invalid argument "composition" in "parse" combinator', composition)
const combinator = typeof composition['.combinator'] === 'function' ? composition['.combinator']() : combinators[composition.type]
if (!isObject(combinator)) throw new ComposerError('Invalid composition type in "parse" combinator', composition)
return visit(Object.assign({ '.combinator': () => combinator }, composition), composition => composer.parse(composition))
}
})
module.exports = composer