diff --git a/app/imports/api/engine/computation/utility/collate.js b/app/imports/api/engine/computation/utility/collate.js new file mode 100644 index 00000000..d6599d65 --- /dev/null +++ b/app/imports/api/engine/computation/utility/collate.js @@ -0,0 +1,12 @@ +// Collate the array with the given value or array of values, creating the +// array if it doesn't exist +export default function collate(array, toAdd){ + if (Array.isArray(toAdd) && toAdd.length){ + if (!array) array = []; + array.push(...toAdd); + } else if (toAdd) { + if (!array) array = []; + array.push(toAdd); + } + return array; +} diff --git a/app/imports/parser/ResolveContext.js b/app/imports/parser/ResolveContext.js new file mode 100644 index 00000000..3edd2fa8 --- /dev/null +++ b/app/imports/parser/ResolveContext.js @@ -0,0 +1,20 @@ +export default class Context { + constructor({errors = [], rolls = []}){ + this.errors = errors; + this.rolls = rolls; + } + error(e){ + if (!e) return; + if (typeof e === 'string'){ + this.errors.push({ + type: 'error', + message: e, + }); + } else { + this.errors.push(e); + } + } + roll(r){ + this.rolls.push(r); + } +} diff --git a/app/imports/parser/compute.js b/app/imports/parser/compute.js deleted file mode 100644 index 5386218b..00000000 --- a/app/imports/parser/compute.js +++ /dev/null @@ -1 +0,0 @@ -// Takes a parse tree and computes it down as far as possible into a real number diff --git a/app/imports/parser/parseTree/AccessorNode.js b/app/imports/parser/parseTree/AccessorNode.js deleted file mode 100644 index ad2d7cad..00000000 --- a/app/imports/parser/parseTree/AccessorNode.js +++ /dev/null @@ -1,54 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; -import ConstantNode from '/imports/parser/parseTree/ConstantNode.js'; - -export default class AccessorNode extends ParseNode { - constructor({name, path}) { - super(...arguments); - this.name = name; - this.path = path; - } - compile(scope, context){ - let value = scope && scope[this.name]; - // For objects, get their value - this.path.forEach(name => { - if (value === undefined) return; - value = value[name]; - }); - let type = typeof value; - if (type === 'string' || type === 'number' || type === 'boolean'){ - return new ConstantNode({value, type}); - } else if (type === 'undefined'){ - return new AccessorNode({ - name: this.name, - path: this.path, - }); - } else { - if (context) context.storeError({ - type: 'error', - message: `${this.name} returned an unexpected type` - }); - return new AccessorNode({ - name: this.name, - path: this.path, - }); - } - } - reduce(scope, context){ - let result = this.compile(scope, context); - if (result instanceof AccessorNode){ - if (context) context.storeError({ - type: 'info', - message: `${result.toString()} not found, set to 0` - }); - return new ConstantNode({ - type: 'number', - value: 0, - }); - } else { - return result; - } - } - toString(){ - return `${this.name}.${this.path.join('.')}`; - } -} diff --git a/app/imports/parser/parseTree/ArrayNode.js b/app/imports/parser/parseTree/ArrayNode.js deleted file mode 100644 index 2f31fa69..00000000 --- a/app/imports/parser/parseTree/ArrayNode.js +++ /dev/null @@ -1,39 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; -import ConstantNode from '/imports/parser/parseTree/ConstantNode.js'; - -export default class ArrayNode extends ParseNode { - constructor({values}) { - super(...arguments); - this.values = values; - } - static fromConstantArray(array){ - let values = array.map( value => { - let type = typeof value; - if ( - type === 'string' || - type === 'number' || - type === 'boolean' || - type === 'undefined' - ){ - return new ConstantNode({value, type}); - } else { - throw `Unexpected type in constant array: ${type}` - } - }); - return new ArrayNode({values}); - } - resolve(fn, scope, context){ - let values = this.values.map(node => node[fn](scope, context)); - return new ArrayNode({values}); - } - toString(){ - return `[${this.values.map(node => node.toString()).join(', ')}]`; - } - traverse(fn){ - fn(this); - this.values.forEach(value => value.traverse(fn)); - } - replaceChildren(fn){ - this.values = this.values.map(node => node.replaceNodes(fn)); - } -} diff --git a/app/imports/parser/parseTree/CallNode.js b/app/imports/parser/parseTree/CallNode.js deleted file mode 100644 index 59c9e9ff..00000000 --- a/app/imports/parser/parseTree/CallNode.js +++ /dev/null @@ -1,126 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; -import ErrorNode from '/imports/parser/parseTree/ErrorNode.js'; -import ConstantNode from '/imports/parser/parseTree/ConstantNode.js'; -import functions from '/imports/parser/functions.js'; - -export default class CallNode extends ParseNode { - constructor({functionName, args}) { - super(...arguments); - this.functionName = functionName; - this.args = args; - } - resolve(fn, scope, context){ - let func = functions[this.functionName]; - // Check that the function exists - if (!func) return new ErrorNode({ - node: this, - error: `${this.functionName} is not a supported function`, - context, - }); - - // Resolve the arguments - let resolvedArgs = this.args.map(node => node[fn](scope, context)); - // Check that the arguments match what is expected - let checkFailed = this.checkArugments({ - fn, - context, - resolvedArgs, - argumentsExpected: func.arguments - }); - - if (checkFailed){ - if (fn === 'reduce'){ - return new ErrorNode({ - node: this, - error: `Invalid arguments to ${this.functionName} function`, - }); - } else { - return new CallNode({ - functionName: this.functionName, - args: resolvedArgs, - }); - } - } - - // Map contant nodes to constants before attempting to run the function - let mappedArgs = resolvedArgs.map(node => { - if (node instanceof ConstantNode){ - return node.value; - } else { - return node; - } - }); - - try { - // Run the function - let value = func.fn.apply(null, mappedArgs); - - let type = typeof value; - if (type === 'number' || type === 'string' || type === 'boolean'){ - // Convert constant results into constant nodes - return new ConstantNode({ value, type }); - } else { - return value; - } - } catch (error) { - return new ErrorNode({ - node: this, - error: error.message || error, - context, - }); - } - } - toString(){ - return `${this.functionName}(${this.args.map(node => node.toString()).join(', ')})`; - } - traverse(fn){ - fn(this); - this.args.forEach(arg => arg.traverse(fn)); - } - replaceChildren(fn){ - this.args = this.args.map(arg => arg.replaceNodes(fn)); - } - checkArugments({fn, context, argumentsExpected, resolvedArgs}){ - // Check that the number of arguments matches the number expected - if ( - !argumentsExpected.anyLength && - argumentsExpected.length !== resolvedArgs.length - ){ - context.storeError({ - type: 'error', - message: 'Incorrect number of arguments ' + - `to ${this.functionName} function, ` + - `expected ${argumentsExpected.length} got ${resolvedArgs.length}` - }); - return true; - } - - let failed = false; - // Check that each argument is of the correct type - resolvedArgs.forEach((node, index) => { - let type; - if (argumentsExpected.anyLength){ - type = argumentsExpected[0]; - } else { - type = argumentsExpected[index]; - } - if (typeof type === 'string'){ - // Type being a string means a constant node with matching type - if (node.type !== type) failed = true; - } else { - // Otherwise check that the node is an instance of the given type - if (!(node instanceof type)) failed = true; - } - if (failed && fn === 'reduce'){ - let typeName = typeof type === 'string' ? type : type.constructor.name; - let nodeName = node.type || node.constructor.name - context.storeError({ - type: 'error', - message: `Incorrect arguments to ${this.functionName} function` + - `expected ${typeName} got ${nodeName}` - }); - } - }); - return failed; - } -} diff --git a/app/imports/parser/parseTree/ConstantNode.js b/app/imports/parser/parseTree/ConstantNode.js deleted file mode 100644 index 708bfaba..00000000 --- a/app/imports/parser/parseTree/ConstantNode.js +++ /dev/null @@ -1,22 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; - -export default class ConstantNode extends ParseNode { - constructor({value, type}){ - super(...arguments); - // string, number, boolean, uncompiledNode - this.type = type; - this.value = value; - } - compile(){ - return this; - } - toString(){ - return `${this.value}`; - } - get isNumber(){ - return this.type === 'number'; - } - get isInteger(){ - return this.type === 'number' && Number.isInteger(this.value); - } -} diff --git a/app/imports/parser/parseTree/ErrorNode.js b/app/imports/parser/parseTree/ErrorNode.js deleted file mode 100644 index ff9565ee..00000000 --- a/app/imports/parser/parseTree/ErrorNode.js +++ /dev/null @@ -1,21 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; - -export default class ErrorNode extends ParseNode { - constructor({node, error, context}) { - super(...arguments); - this.node = node; - this.error = error; - if (context){ - context.storeError({ - type: 'error', - message: error, - }); - } - } - compile(){ - return this; - } - toString(){ - return this.error.toString(); - } -} diff --git a/app/imports/parser/parseTree/IfNode.js b/app/imports/parser/parseTree/IfNode.js deleted file mode 100644 index a94f21c2..00000000 --- a/app/imports/parser/parseTree/IfNode.js +++ /dev/null @@ -1,42 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; -import ConstantNode from '/imports/parser/parseTree/ConstantNode.js'; - -export default class IfNode extends ParseNode { - constructor({condition, consequent, alternative}){ - super(...arguments); - this.condition = condition; - this.consequent = consequent; - this.alternative = alternative; - } - toString(){ - let {condition, consequent, alternative} = this; - return `${condition.toString()} ? ${consequent.toString()} : ${alternative.toString()}` - } - resolve(fn, scope, context){ - let condition = this.condition[fn](scope, context); - if (condition instanceof ConstantNode){ - if (condition.value){ - return this.consequent[fn](scope, context); - } else { - return this.alternative[fn](scope, context); - } - } else { - return new IfNode({ - condition: condition, - consequent: this.consequent, - alternative: this.alternative, - }); - } - } - traverse(fn){ - fn(this); - this.condition.traverse(fn); - this.consequent.traverse(fn); - this.alternative.traverse(fn); - } - replaceChildren(fn){ - this.condition = this.condition.replaceNodes(fn); - this.consequent = this.consequent.replaceNodes(fn); - this.alternative = this.alternative.replaceNodes(fn); - } -} diff --git a/app/imports/parser/parseTree/_index.js b/app/imports/parser/parseTree/_index.js new file mode 100644 index 00000000..571f7e7a --- /dev/null +++ b/app/imports/parser/parseTree/_index.js @@ -0,0 +1,5 @@ +import accessor from './accessor.js'; + +export default Object.freeze({ + accessor, +}); diff --git a/app/imports/parser/parseTree/accessor.js b/app/imports/parser/parseTree/accessor.js new file mode 100644 index 00000000..5ebbf246 --- /dev/null +++ b/app/imports/parser/parseTree/accessor.js @@ -0,0 +1,66 @@ +import constant from './constant.js'; + +const accessor = { + create({name, path}) { + return { + type: 'accessor', + path, + name, + }; + }, + compile(node, scope, context){ + let value = scope && scope[node.name]; + // For objects, get their value + node.path.forEach(name => { + if (value === undefined) return; + value = value[name]; + }); + let valueType = typeof value; + if (valueType === 'string' || valueType === 'number' || valueType === 'boolean'){ + return { + result: constant.create({ + value, + valueType + }), + context, + }; + } else if (valueType === 'undefined'){ + return { + result: accessor.create({ + name: node.name, + path: node.path, + }), + context, + }; + } else { + context.error(`${node.name} returned an unexpected type`); + return { + result: accessor.create({ + name: node.name, + path: node.path, + }), + context, + }; + } + }, + reduce(node, scope, context){ + let { result } = accessor.compile(node, scope, context); + if (result.type === 'accessor'){ + context.error(`${accessor.toString(result)} not found, set to 0`); + return { + result: constant.create({ + value: 0, + valueType: 'number', + }), + context + }; + } else { + return {result, context}; + } + }, + toString(node){ + return `${node.name}.${node.path.join('.')}`; + } +} + +export default accessor; diff --git a/app/imports/parser/parseTree/array.js b/app/imports/parser/parseTree/array.js new file mode 100644 index 00000000..407ab92a --- /dev/null +++ b/app/imports/parser/parseTree/array.js @@ -0,0 +1,46 @@ +import constant from './constant.js'; +import resolve, { toString, traverse } from '../resolve.js'; + +const array = { + create({values}) { + return { + type: 'array', + values, + }; + }, + fromConstantArray(array){ + let values = array.map( value => { + let valueType = typeof value; + if ( + valueType === 'string' || + valueType === 'number' || + valueType === 'boolean' || + valueType === 'undefined' + ){ + return constant.create({value, valueType}); + } else { + throw `Unexpected type in constant array: ${valueType}` + } + }); + return array.create({values}); + }, + resolve(fn, node, scope){ + let values = node.values.map(node => { + let { result } = resolve(fn, node, scope, context); + return result; + }); + return { + result: array.create({values}), + context, + }; + }, + toString(node){ + return `[${node.values.map(value => toString(value)).join(', ')}]`; + }, + traverse(node, fn){ + fn(node); + node.values.forEach(value => traverse(value, fn)); + }, +} + +export default array; diff --git a/app/imports/parser/parseTree/call.js b/app/imports/parser/parseTree/call.js new file mode 100644 index 00000000..6893dbf4 --- /dev/null +++ b/app/imports/parser/parseTree/call.js @@ -0,0 +1,146 @@ +import error from './error.js'; +import constant from './constant.js'; +import functions from '/imports/parser/functions.js'; +import resolve, { toString, traverse, mergeResolvedNodes } from '../resolve.js'; + +const call = { + create({functionName, args}) { + return { + type: 'call', + functionName, + args, + } + }, + resolve(fn, node, scope, context){ + let func = functions[node.functionName]; + // Check that the function exists + if (!func) { + context.error(`${node.functionName} is not a supported function`); + return { + result: error.create({ + node: node, + error: `${node.functionName} is not a supported function`, + }), + context, + }; + } + + // Resolve the arguments + let resolvedArgs = node.args.map(arg => { + let { result } = resolve(fn, arg, scope, context); + return result; + }); + + // Check that the arguments match what is expected + let checkFailed = call.checkArugments({ + fn, + resolvedArgs, + argumentsExpected: func.arguments, + context, + }); + + if (checkFailed){ + if (fn === 'reduce'){ + context.error(`Invalid arguments to ${node.functionName} function`); + return { + result: error.create({ + node: node, + error: `Invalid arguments to ${node.functionName} function`, + }), + context, + }; + } else { + return { + result: call.create({ + functionName: node.functionName, + args: resolvedArgs, + }), + context, + }; + } + } + + // Map contant nodes to constants before attempting to run the function + let mappedArgs = resolvedArgs.map(arg => { + if (arg.type === 'constant'){ + return arg.value; + } else { + return arg; + } + }); + + try { + // Run the function + let value = func.fn.apply(null, mappedArgs); + + let valueType = typeof value; + if (valueType === 'number' || valueType === 'string' || valueType === 'boolean'){ + // Convert constant results into constant nodes + return { + result: constant.create({ value, valueType }), + context, + }; + } else { + return { + result: value, + context, + }; + } + } catch (error) { + context.error(error.message || error); + return { + result: error.create({ + node: node, + error: error.message || error, + }), + context, + } + } + }, + toString(node){ + return `${node.functionName}(${node.args.map(arg => toString(arg)).join(', ')})`; + }, + traverse(node, fn){ + fn(node); + node.args.forEach(arg => traverse(arg, fn)); + }, + checkArugments({node, fn, argumentsExpected, resolvedArgs, context}){ + // Check that the number of arguments matches the number expected + if ( + !argumentsExpected.anyLength && + argumentsExpected.length !== resolvedArgs.length + ){ + context.error('Incorrect number of arguments ' + + `to ${node.functionName} function, ` + + `expected ${argumentsExpected.length} got ${resolvedArgs.length}`); + return true; + } + + let failed = false; + // Check that each argument is of the correct type + resolvedArgs.forEach((node, index) => { + let type; + if (argumentsExpected.anyLength){ + type = argumentsExpected[0]; + } else { + type = argumentsExpected[index]; + } + if (typeof type === 'string'){ + // Type being a string means a constant node with matching type + if (node.valueType !== type) failed = true; + } else { + // Otherwise check that the node is an instance of the given type + if (!(node instanceof type)) failed = true; + } + if (failed && fn === 'reduce'){ + let typeName = typeof type === 'string' ? type : type.constructor.name; + let nodeName = node.type; + context.error(`Incorrect arguments to ${node.functionName} function` + + `expected ${typeName} got ${nodeName}`); + } + }); + return failed; + } +} + +export default call; diff --git a/app/imports/parser/parseTree/constant.js b/app/imports/parser/parseTree/constant.js new file mode 100644 index 00000000..4220bc96 --- /dev/null +++ b/app/imports/parser/parseTree/constant.js @@ -0,0 +1,18 @@ +const constant = { + create({value, valueType}){ + if (!valueType) throw `Expected valueType to be set, got ${valueType}` + return { + type: 'constant', + valueType, + value, + } + }, + compile(node){ + return node; + }, + toString(node){ + return `${node.value}`; + }, +} + +export default constant; diff --git a/app/imports/parser/parseTree/error.js b/app/imports/parser/parseTree/error.js new file mode 100644 index 00000000..8533061b --- /dev/null +++ b/app/imports/parser/parseTree/error.js @@ -0,0 +1,17 @@ +const error = { + create({node, error}) { + return { + type: 'error', + node, + error, + } + }, + compile(node){ + return node; + }, + toString(node){ + return node.error.toString(); + }, +} + +export default error; diff --git a/app/imports/parser/parseTree/if.js b/app/imports/parser/parseTree/if.js new file mode 100644 index 00000000..369ff924 --- /dev/null +++ b/app/imports/parser/parseTree/if.js @@ -0,0 +1,59 @@ +import resolve, {traverse, toString, mergeResolvedNodes} from '../resolve'; +import collate from '/imports/api/engine/computation/utility/collate.js'; + +const ifNode = { + create({condition, consequent, alternative}){ + return { + type: 'if', + condition, + consequent, + alternative, + }; + }, + toString(node){ + let {condition, consequent, alternative} = node; + return `${toString(condition)} ? ${toString(consequent)} : ${toString(alternative)}` + }, + resolve(fn, node, scope){ + let rest, condition, consequent, alternative; + let resolved = {}; + + ({result: condition, ...rest} = resolve(fn, node.condition, scope)); + mergeResolvedNodes(resolved, rest); + + if (condition.type === 'constant'){ + if (condition.value){ + ({result: consequent, ...rest} = resolve(fn, node.consequent, scope)); + mergeResolvedNodes(resolved, rest); + return { + result: consequent, + ...resolved + }; + } else { + ({result: alternative, ...rest} = resolve(fn, node.alternative, scope)); + mergeResolvedNodes(resolved, rest); + return { + result: alternative, + ...resolved + }; + } + } else { + return { + result: ifNode.create({ + condition: condition, + consequent: node.consequent, + alternative: node.alternative, + }), + ...resolved + }; + } + }, + traverse(node, fn){ + fn(node); + traverse(node.condition, fn); + traverse(node.consequent, fn); + traverse(node.alternative, fn); + }, +} + +export default ifNode; diff --git a/app/imports/parser/parseTree/index.js b/app/imports/parser/parseTree/index.js new file mode 100644 index 00000000..dcb19e2d --- /dev/null +++ b/app/imports/parser/parseTree/index.js @@ -0,0 +1,76 @@ +import resolve, {traverse, toString, mergeResolvedNodes} from '../resolve'; + +const index = { + create({array, index}) { + return { + type: 'index', + array, + index, + } + }, + resolve(fn, node, scope){ + let index, array, rest; + let resolved = {}; + ({result: index, ...rest} = resolve(fn, node.index, scope)); + mergeResolvedNodes(resolved, rest); + ({result: array, ...rest} = resolve(fn, node.array, scope)); + mergeResolvedNodes(resolved, rest); + + if ( + index.valueType === 'number' && + Number.isInteger(index.value) && + array.type === 'array' + ){ + if (index.value < 1 || index.value > array.values.length){ + mergeResolvedNodes(resolved, { + errors: [{ + type: 'warning', + message: `Index of ${index.value} is out of range for an array` + + ` of length ${array.values.length}`, + }] + }); + } + let selection = array.values[index.value - 1]; + if (selection){ + let result; + ({result, ...rest} = resolve(fn, selection, scope)); + mergeResolvedNodes(resolved, rest) + return result; + } + } else if (fn === 'reduce'){ + if (!(array instanceof ArrayNode)){ + return new ErrorNode({ + node: node, + error: 'Can not get the index of a non-array node: ' + + node.array.toString() + ' = ' + array.toString(), + context, + }); + } else if (!index.isInteger){ + return new ErrorNode({ + node: node, + error: array.toString() + ' is not an integer index of the array', + context, + }); + } + } + return new IndexNode({ + index, + array, + previousNodes: [node], + }); + }, + toString(){ + return `${node.array.toString()}[${node.index.toString()}]`; + }, + traverse(fn){ + fn(node); + node.array.traverse(fn); + node.index.traverse(fn); + }, + replaceChildren(fn){ + node.array = node.array.replaceNodes(fn); + node.index = node.index.replaceNodes(fn); + } +} + +export default index; diff --git a/app/imports/parser/parser.test.js b/app/imports/parser/parser.test.js new file mode 100644 index 00000000..30f0b0b9 --- /dev/null +++ b/app/imports/parser/parser.test.js @@ -0,0 +1,11 @@ +import { parse } from './parser'; +import { assert } from 'chai'; + +describe('Parser', function(){ + it('parses valid text without error', function(){ + assert.typeOf(parse('1'), 'object'); + }); + it('parses various operations', function(){ + assert.typeOf(parse('1 + 2 * 3 / 4 * 1d8'), 'object'); + }); +}); diff --git a/app/imports/parser/resolve.js b/app/imports/parser/resolve.js new file mode 100644 index 00000000..86d2fad6 --- /dev/null +++ b/app/imports/parser/resolve.js @@ -0,0 +1,45 @@ +import nodeTypeIndex from './parseTree/index.js'; +import collate from '/imports/api/engine/computation/utility/collate.js'; +import Context from './ResolveContext.js'; + +// Takes a parse ndoe and computes it to a set detail level +// returns {result, context} +export default function resolve(fn, node, scope, context = new Context()){ + let type = nodeTypeIndex[node.type]; + if (!type){ + throw new Meteor.Error(`Parse node type: ${node.type} not implemented`); + } + if (type.resolve){ + return type.resolve(fn, node, scope, context); + } else if (type[fn]) { + return type[fn](node, scope, context); + } else if (fn === 'reduce' && type.roll) { + return type.roll(node, scope, context) + } else if (type.compile){ + return type.compile(node, scope, context) + } else { + throw new Meteor.Error('Compile not implemented on ' + node.type); + } +} + +export function toString(node){ + let type = nodeTypeIndex[node.type]; + if (!type.toString){ + throw new Meteor.Error('toString not implemented on ' + node.type); + } + return type.toString(node); +} + +export function traverse(node, fn){ + let type = nodeTypeIndex[node.type]; + if (type.traverse){ + return type.traverse(node, fn); + } + return fn(node); +} + +export function mergeResolvedNodes(main, other){ + main.errors = collate(main.errors, other.errors); + main.rolls = collate(main.rolls, other.rolls); + return main; +}