From 8f9f2df81d56a657f5e8e20143129ec37a356c51 Mon Sep 17 00:00:00 2001 From: Anand Thakker Date: Wed, 28 Jun 2017 22:23:45 -0400 Subject: [PATCH] Refactor to more object-oriented approach --- src/style-spec/function/compile.js | 97 ++-- src/style-spec/function/definitions/curve.js | 130 +++++ src/style-spec/function/definitions/index.js | 359 ++++++++++++++ src/style-spec/function/definitions/match.js | 110 +++++ src/style-spec/function/expression.js | 224 +++++++++ src/style-spec/function/expressions.js | 445 ------------------ src/style-spec/function/index.js | 4 +- src/style-spec/function/parse.js | 210 --------- src/style-spec/function/type_check.js | 111 ++--- src/style-spec/function/types.js | 4 - test/expression.test.js | 3 +- .../parse/unknown-expression/test.json | 4 +- 12 files changed, 922 insertions(+), 779 deletions(-) create mode 100644 src/style-spec/function/definitions/curve.js create mode 100644 src/style-spec/function/definitions/index.js create mode 100644 src/style-spec/function/definitions/match.js create mode 100644 src/style-spec/function/expression.js delete mode 100644 src/style-spec/function/expressions.js delete mode 100644 src/style-spec/function/parse.js diff --git a/src/style-spec/function/compile.js b/src/style-spec/function/compile.js index 00309e4a23a..0cba5569342 100644 --- a/src/style-spec/function/compile.js +++ b/src/style-spec/function/compile.js @@ -5,28 +5,19 @@ const assert = require('assert'); module.exports = compileExpression; -const expressions = require('./expressions'); -const parseExpression = require('./parse'); +const { + LiteralExpression, + parseExpression, + ParsingContext, + ParsingError +} = require('./expression'); +const expressions = require('./definitions'); const typecheck = require('./type_check'); const evaluationContext = require('./evaluation_context'); /*:: import type { Type } from './types.js'; - - import type { TypedExpression } from './type_check.js'; - - import type { Definition } from './expressions.js'; - - export type CompiledExpression = {| - result: 'success', - js: string, - type: Type, - isFeatureConstant: boolean, - isZoomConstant: boolean, - expression: TypedExpression, - function?: Function - |} - + import type { Expression, CompiledExpression } from './expression.js'; type CompileError = {| error: string, @@ -63,40 +54,49 @@ const evaluationContext = require('./evaluation_context'); * @private */ function compileExpression( - definitions: {[string]: Definition}, expr: mixed, expectedType?: Type ) { - const parsed = parseExpression(definitions, expr); - if (parsed.error) { - return { - result: 'error', - errors: [parsed] - }; + let parsed; + try { + parsed = parseExpression(expr, new ParsingContext(expressions)); + } catch (e) { + if (e instanceof ParsingError) { + return { + result: 'error', + errors: [{key: e.key, error: e.message}] + }; + } + throw e; } if (parsed.type) { - const typecheckResult = typecheck(expectedType || parsed.type, parsed); - if (typecheckResult.errors) { - return { result: 'error', errors: typecheckResult.errors }; + const checked = typecheck(expectedType || parsed.type, parsed); + if (checked.result === 'error') { + return checked; } - const compiled = compile(null, typecheckResult); + const compiled = compile(null, checked.expression); if (compiled.result === 'success') { - const fn = new Function('mapProperties', 'feature', ` - mapProperties = mapProperties || {}; - if (feature && typeof feature === 'object') { - feature = this.object(feature); - } - var props; - if (feature && feature.type === 'Object') { - props = (typeof feature.value.properties === 'object') ? - this.object(feature.value.properties) : feature.value.properties; - } - if (!props) { props = this.object({}); } - return this.unwrap(${compiled.js}) - `); - compiled.function = fn.bind(evaluationContext()); + try { + const fn = new Function('mapProperties', 'feature', ` + mapProperties = mapProperties || {}; + if (feature && typeof feature === 'object') { + feature = this.object(feature); + } + var props; + if (feature && feature.type === 'Object') { + props = (typeof feature.value.properties === 'object') ? + this.object(feature.value.properties) : feature.value.properties; + } + if (!props) { props = this.object({}); } + return this.unwrap(${compiled.js}) + `); + compiled.function = fn.bind(evaluationContext()); + } catch (e) { + console.log(compiled.js); + throw e; + } } return compiled; @@ -105,11 +105,11 @@ function compileExpression( assert(false, 'parseExpression should always return either error or typed expression'); } -function compile(expected: Type | null, e: TypedExpression) /*: CompiledExpression | CompileErrors */ { - if (e.literal) { +function compile(expected: Type | null, e: Expression) /*: CompiledExpression | CompileErrors */ { + if (e instanceof LiteralExpression) { return { result: 'success', - js: JSON.stringify(e.value), + js: e.compile().js, type: e.type, isFeatureConstant: true, isZoomConstant: true, @@ -119,8 +119,8 @@ function compile(expected: Type | null, e: TypedExpression) /*: CompiledExpressi const errors: Array = []; const compiledArgs: Array = []; - for (let i = 0; i < e.arguments.length; i++) { - const arg = e.arguments[i]; + for (let i = 0; i < e.args.length; i++) { + const arg = e.args[i]; const param = e.type.params[i]; const compiledArg = compile(param, arg); if (compiledArg.result === 'error') { @@ -137,8 +137,7 @@ function compile(expected: Type | null, e: TypedExpression) /*: CompiledExpressi let isFeatureConstant = compiledArgs.reduce((memo, arg) => memo && arg.isFeatureConstant, true); let isZoomConstant = compiledArgs.reduce((memo, arg) => memo && arg.isZoomConstant, true); - const definition = expressions[e.name]; - const compiled = definition.compile(compiledArgs, e); + const compiled = e.compile(compiledArgs); if (compiled.errors) { return { result: 'error', diff --git a/src/style-spec/function/definitions/curve.js b/src/style-spec/function/definitions/curve.js new file mode 100644 index 00000000000..6f15cea6d77 --- /dev/null +++ b/src/style-spec/function/definitions/curve.js @@ -0,0 +1,130 @@ +'use strict'; + +// @flow + +const { + NullType, + NumberType, + ColorType, + typename, + lambda, + nargs +} = require('../types'); + +const { ParsingError, LiteralExpression, LambdaExpression } = require('../expression'); + +/*:: + import type { Expression, CompiledExpression } from '../expression'; + import type { LambdaType } from '../types'; + type InterpolationType = { name: 'step' } | { name: 'linear' } | { name: 'exponential', base: number } +*/ + +class CurveExpression extends LambdaExpression { + interpolation: InterpolationType; + constructor(key: *, type: *, args: *, interpolation: InterpolationType) { + super(key, type, args); + this.interpolation = interpolation; + } + + static getName() { return 'curve'; } + static getType() { return lambda(typename('T'), NullType, NumberType, nargs(Infinity, NumberType, typename('T'))); } + + static parse(args, context) { + // pull out the interpolation type argument for specialized parsing, + // and replace it with `null` so that other arguments' "key"s stay the + // same for error reporting. + const interp = args[0]; + const fixedArgs = [null].concat(args.slice(1)); + const expression: CurveExpression = (super.parse(fixedArgs, context): any); + + if (!Array.isArray(interp) || interp.length === 0) + throw new ParsingError(`${context.key}.1`, `Expected an interpolation type expression, but found ${String(interp)} instead.`); + + if (interp[0] === 'step') { + expression.interpolation = { name: 'step' }; + } else if (interp[0] === 'linear') { + expression.interpolation = { name: 'linear' }; + } else if (interp[0] === 'exponential') { + const base = interp[1]; + if (typeof base !== 'number') + throw new ParsingError(`${context.key}.1.1`, `Exponential interpolation requires a numeric base.`); + expression.interpolation = { + name: 'exponential', + base + }; + } else throw new ParsingError(`${context.key}.1.0`, `Unknown interpolation type ${String(interp[0])}`); + return expression; + } + + serialize(withTypes: boolean) { + const type = this.type.result.name; + const args = this.args.map(e => e.serialize(withTypes)); + const interp = [this.interpolation.name]; + if (this.interpolation.name === 'exponential') { + interp.push(this.interpolation.base); + } + args.splice(0, 1, interp); + return [ `curve${(withTypes ? `: ${type}` : '')}` ].concat(args); + } + + applyType(type: LambdaType, args: Array): Expression { + return new this.constructor(this.key, type, args, this.interpolation); + } + + compile(args: Array) { + if (args.length < 4) return { + errors: [`Expected at least four arguments, but found only ${args.length}.`] + }; + + const firstOutput = args[3]; + let resultType; + if (firstOutput.type === NumberType) { + resultType = 'number'; + } else if (firstOutput.type === ColorType) { + resultType = 'color'; + } else if ( + firstOutput.type.kind === 'array' && + firstOutput.type.itemType === NumberType + ) { + resultType = 'array'; + } else if (this.interpolation.name !== 'step') { + return { + errors: [`Type ${firstOutput.type.name} is not interpolatable, and thus cannot be used as a ${this.interpolation.name} curve's output type.`] + }; + } + + const stops = []; + const outputs = []; + for (let i = 2; (i + 1) < args.length; i += 2) { + const input = args[i].expression; + const output = args[i + 1]; + if ( + !(input instanceof LiteralExpression) || + typeof input.value !== 'number' + ) { + return { + errors: [ 'Input/output pairs for "curve" expressions must be defined using literal numeric values (not computed expressions) for the input values.' ] + }; + } + + if (stops.length && stops[stops.length - 1] > input.value) { + return { + errors: [ 'Input/output pairs for "curve" expressions must be arranged with input values in strictly ascending order.' ] + }; + } + + stops.push(input.value); + outputs.push(output.js); + } + + return {js: ` + (function () { + var input = ${args[1].js}; + var stopInputs = [${stops.join(', ')}]; + var stopOutputs = [${outputs.map(o => `() => ${o}`).join(', ')}]; + return this.evaluateCurve(input, stopInputs, stopOutputs, ${JSON.stringify(this.interpolation)}, ${JSON.stringify(resultType)}); + }.bind(this))()`}; + } +} + +module.exports = CurveExpression; diff --git a/src/style-spec/function/definitions/index.js b/src/style-spec/function/definitions/index.js new file mode 100644 index 00000000000..6e1806160e7 --- /dev/null +++ b/src/style-spec/function/definitions/index.js @@ -0,0 +1,359 @@ +'use strict'; + +// @flow + +const assert = require('assert'); + +const { + NumberType, + StringType, + BooleanType, + ColorType, + ObjectType, + ValueType, + typename, + variant, + array, + lambda, + nargs +} = require('../types'); + +const { ParsingError, LambdaExpression } = require('../expression'); + +const MatchExpression = require('./match'); +const CurveExpression = require('./curve'); + +/*:: + import type { Type, PrimitiveType, ArrayType, LambdaType } from '../types.js'; + import type { ExpressionName } from '../expression_name.js'; + import type { CompiledExpression } from '../expression.js'; + */ + +const expressions: { [string]: Class } = { + 'ln2': defineMathConstant('ln2'), + 'pi': defineMathConstant('pi'), + 'e': defineMathConstant('e'), + + 'typeof': class TypeOf extends LambdaExpression { + static getName() { return 'typeOf'; } + static getType() { return lambda(StringType, ValueType); } + compile(args) { return fromContext('typeOf', args); } + }, + + // type assertions + 'string': defineAssertion('string', StringType), + 'number': defineAssertion('number', NumberType), + 'boolean': defineAssertion('boolean', BooleanType), + 'array': defineAssertion('array', array(ValueType)), + 'object': defineAssertion('object', ObjectType), + + // type coercion + 'to_string': class extends LambdaExpression { + static getName() { return 'to_string'; } + static getType() { return lambda(StringType, ValueType); } + compile(args) { + return {js: `this.toString(${args[0].js})`}; + } + }, + 'to_number': class extends LambdaExpression { + static getName() { return 'to_number'; } + static getType() { return lambda(NumberType, ValueType); } + compile(args) { + return {js: `this.toNumber(${args[0].js})`}; + } + }, + 'to_boolean': class extends LambdaExpression { + static getName() { return 'to_boolean'; } + static getType() { return lambda(BooleanType, ValueType); } + compile(args) { + return {js: `Boolean(${args[0].js})`}; + } + }, + 'to_rgba': class extends LambdaExpression { + static getName() { return 'to_rgba'; } + static getType() { return lambda(array(NumberType, 4), ColorType); } + compile(args) { + return {js: `this.array('Array', ${args[0].js}.value)`}; + } + }, + + // color 'constructors' + 'parse_color': class extends LambdaExpression { + static getName() { return 'parse_color'; } + static getType() { return lambda(ColorType, StringType); } + compile(args) { return fromContext('parseColor', args); } + }, + 'rgb': class extends LambdaExpression { + static getName() { return 'rgb'; } + static getType() { return lambda(ColorType, NumberType, NumberType, NumberType); } + compile(args) { return fromContext('rgba', args); } + }, + 'rgba': class extends LambdaExpression { + static getName() { return 'rgb'; } + static getType() { return lambda(ColorType, NumberType, NumberType, NumberType, NumberType); } + compile(args) { return fromContext('rgba', args); } + }, + + // object/array access + 'get': class extends LambdaExpression { + static getName() { return 'get'; } + static getType() { return lambda(ValueType, StringType, nargs(1, ObjectType)); } + compile(args) { + return { + js: `this.get(${args.length > 1 ? args[1].js : 'props'}, ${args[0].js}, ${args.length > 1 ? 'undefined' : '"feature.properties"'})`, + isFeatureConstant: args.length > 1 && args[1].isFeatureConstant + }; + } + }, + 'has': class extends LambdaExpression { + static getName() { return 'has'; } + static getType() { return lambda(BooleanType, StringType, nargs(1, ObjectType)); } + compile(args) { + return { + js: `this.has(${args.length > 1 ? args[1].js : 'props'}, ${args[0].js}, ${args.length > 1 ? 'undefined' : '"feature.properties"'})`, + isFeatureConstant: args.length > 1 && args[1].isFeatureConstant + }; + } + }, + 'at': class extends LambdaExpression { + static getName() { return 'at'; } + static getType() { return lambda(typename('T'), NumberType, array(typename('T'))); } + compile(args) { return fromContext('at', args); } + }, + 'length': class extends LambdaExpression { + static getName() { return 'length'; } + static getType() { return lambda(NumberType, variant(array(typename('T')), StringType)); } + compile(args) { + let t = args[0].type; + if (t.kind === 'lambda') { t = t.result; } + assert(t.kind === 'array' || t.kind === 'primitive'); + return { + js: t.kind === 'array' ? + `${args[0].js}.items.length` : + `${args[0].js}.length` + }; + } + }, + + // // feature and map data + 'properties': class extends LambdaExpression { + static getName() { return 'properties'; } + static getType() { return lambda(ObjectType); } + compile() { + return { + js: 'this.as(props, "Object", "feature.properties")', + isFeatureConstant: false + }; + } + }, + 'geometry_type': class extends LambdaExpression { + static getName() { return 'geometry_type'; } + static getType() { return lambda(StringType); } + compile() { + return { + js: 'this.get(this.get(feature, "geometry", "feature"), "type", "feature.geometry")', + isFeatureConstant: false + }; + } + }, + 'id': class extends LambdaExpression { + static getName() { return 'id'; } + static getType() { return lambda(ValueType); } + compile() { + return { + js: 'this.get(feature, "id", "feature")', + isFeatureConstant: false + }; + } + }, + 'zoom': class extends LambdaExpression { + static getName() { return 'zoom'; } + static getType() { return lambda(NumberType); } + static parse(args, context) { + const ancestors = context.ancestors.join(':'); + // zoom expressions may only appear like: + // ['curve', interp, ['zoom'], ...] + // or ['coalesce', ['curve', interp, ['zoom'], ...], ... ] + if ( + !/^(1.)?2/.test(context.key) || + !/(coalesce:)?curve/.test(ancestors) + ) { + throw new ParsingError( + context.key, + 'The "zoom" expression may only be used as the input to a top-level "curve" expression.' + ); + } + return super.parse(args, context); + } + compile() { + return {js: 'mapProperties.zoom', isZoomConstant: false}; + } + }, + + // math + '+': defineBinaryMathOp('+', true), + '*': defineBinaryMathOp('*', true), + '-': defineBinaryMathOp('-'), + '/': defineBinaryMathOp('/'), + '%': defineBinaryMathOp('%'), + '^': class extends LambdaExpression { + static getName() { return '^'; } + static getType() { return lambda(NumberType, NumberType, NumberType); } + compile(args) { + return {js: `Math.pow(${args[0].js}, ${args[1].js})`}; + } + }, + 'log10': defineMathFunction('log10', 1), + 'ln': defineMathFunction('ln', 1, 'log'), + 'log2': defineMathFunction('log2', 1), + 'sin': defineMathFunction('sin', 1), + 'cos': defineMathFunction('cos', 1), + 'tan': defineMathFunction('tan', 1), + 'asin': defineMathFunction('asin', 1), + 'acos': defineMathFunction('acos', 1), + 'atan': defineMathFunction('atan', 1), + '==': defineComparisonOp('=='), + '!=': defineComparisonOp('!='), + '>': defineComparisonOp('>'), + '<': defineComparisonOp('<'), + '>=': defineComparisonOp('>='), + '<=': defineComparisonOp('<='), + '&&': defineBooleanOp('&&'), + '||': defineBooleanOp('||'), + '!': class extends LambdaExpression { + static getName() { return '!'; } + static getType() { return lambda(BooleanType, BooleanType); } + compile(args) { + return {js: `!(${args[0].js})`}; + } + }, + + // string manipulation + 'upcase': class extends LambdaExpression { + static getName() { return 'upcase'; } + static getType() { return lambda(StringType, StringType); } + compile(args) { + return {js: `(${args[0].js}).toUpperCase()`}; + } + }, + 'downcase': class extends LambdaExpression { + static getName() { return 'downcase'; } + static getType() { return lambda(StringType, StringType); } + compile(args) { + return {js: `(${args[0].js}).toLowerCase()`}; + } + }, + 'concat': class extends LambdaExpression { + static getName() { return 'concat'; } + static getType() { return lambda(StringType, nargs(Infinity, ValueType)); } + compile(args) { + return {js: `[${args.map(a => a.js).join(', ')}].join('')`}; + } + }, + + // decisions + 'case': class extends LambdaExpression { + static getName() { return 'case'; } + static getType() { return lambda(typename('T'), nargs(Infinity, BooleanType, typename('T')), typename('T')); } + compile(args) { + args = [].concat(args); + const result = []; + while (args.length > 1) { + const c = args.splice(0, 2); + result.push(`${c[0].js} ? ${c[1].js}`); + } + assert(args.length === 1); // enforced by type checking + result.push(args[0].js); + return { js: result.join(':') }; + } + }, + 'match': MatchExpression, + + 'coalesce': class extends LambdaExpression { + static getName() { return 'coalesce'; } + static getType() { return lambda(typename('T'), nargs(Infinity, typename('T'))); } + compile(args) { + return { + js: `this.coalesce(${args.map(a => `() => ${a.js}`).join(', ')})` + }; + } + }, + + 'curve': CurveExpression +}; + +module.exports = expressions; + +function defineMathConstant(name) { + const mathName = name.toUpperCase(); + assert(typeof Math[mathName] === 'number'); + return class extends LambdaExpression { + static getName() { return name; } + static getType() { return lambda(NumberType); } + compile() { return { js: `Math.${mathName}` }; } + }; +} + +function defineMathFunction(name: ExpressionName, arity: number, mathName?: string) { + const key:string = mathName || name; + assert(typeof Math[key] === 'function'); + assert(arity > 0); + const args = []; + while (arity-- > 0) args.push(NumberType); + return class extends LambdaExpression { + static getName() { return name; } + static getType() { return lambda(NumberType, ...args); } + compile(args) { + return { js: `Math.${key}(${args.map(a => a.js).join(', ')})` }; + } + }; +} + +function defineBinaryMathOp(name, isAssociative) { + const args = isAssociative ? [nargs(Infinity, NumberType)] : [NumberType, NumberType]; + return class extends LambdaExpression { + static getName() { return name; } + static getType() { return lambda(NumberType, ...args); } + compile(args) { + return { js: `${args.map(a => a.js).join(name)}` }; + } + }; +} + +function defineComparisonOp(name) { + const op = name === '==' ? '===' : + name === '!=' ? '!==' : name; + return class extends LambdaExpression { + static getName() { return name; } + static getType() { return lambda(BooleanType, typename('T'), typename('T')); } + compile(args) { + return { js: `${args[0].js} ${op} ${args[1].js}` }; + } + }; +} + +function defineBooleanOp(op) { + return class extends LambdaExpression { + static getName() { return op; } + static getType() { return lambda(BooleanType, nargs(Infinity, BooleanType)); } + compile(args) { + return { js: `${args.map(a => a.js).join(op)}` }; + } + }; +} + +function defineAssertion(name: ExpressionName, type: Type) { + return class extends LambdaExpression { + static getName() { return name; } + static getType() { return lambda(type, ValueType); } + compile(args) { + return { js: `this.as(${args[0].js}, ${JSON.stringify(type.name)})` }; + } + }; +} + +function fromContext(name: string, args: Array) { + const argvalues = args.map(a => a.js).join(', '); + return { js: `this.${name}(${argvalues})` }; +} + diff --git a/src/style-spec/function/definitions/match.js b/src/style-spec/function/definitions/match.js new file mode 100644 index 00000000000..3450cb7d369 --- /dev/null +++ b/src/style-spec/function/definitions/match.js @@ -0,0 +1,110 @@ +'use strict'; + +// @flow + +/*:: + import type { CompiledExpression, Expression } from '../expression.js'; + */ + +const assert = require('assert'); + +const { + typename, + array, + lambda, + nargs +} = require('../types'); + +const { + LiteralExpression, + LambdaExpression, + ParsingError +} = require('../expression'); + +class MatchExpression extends LambdaExpression { + constructor(key: *, type: *, args: *) { + super(key, type, args); + } + + static getName() { return 'match'; } + static getType() { return lambda(typename('T'), typename('U'), nargs(Infinity, array(typename('U')), typename('T')), typename('T')); } + + static parse(args, context) { + if (args.length < 2) + throw new ParsingError(context.key, `Expected at least 2 arguments, but found only ${args.length}.`); + + const normalizedArgs = [args[0]]; + + // parse input/output pairs. + for (let i = 1; i < args.length - 1; i++) { + const arg = args[i]; + if (i % 2 === 1) { + // Match inputs are provided as either a literal value or a + // raw JSON array of literals. Normalize these by wrapping + // them in an array literal `['literal', [...values]]`. + const inputGroup = Array.isArray(arg) ? arg : [arg]; + if (inputGroup.length === 0) + throw new ParsingError(`${context.key}.${i + 1}`, 'Expected at least one input value.'); + for (let j = 0; j < inputGroup.length; j++) { + const inputValue = inputGroup[j]; + if (typeof inputValue === 'object') + throw new ParsingError( + `${context.key}.${i + 1}.${j}`, + 'Match inputs must be literal primitive values or arrays of literal primitive values.' + + ); + } + normalizedArgs.push(['literal', inputGroup]); + } else { + normalizedArgs.push(arg); + } + } + + normalizedArgs.push(args[args.length - 1]); + + return super.parse(normalizedArgs, context); + } + + compile(args: Array) { + const input = args[0].js; + const inputs: Array = []; + const outputs = []; + for (let i = 1; i < args.length - 1; i++) { + if (i % 2 === 1) { + assert(args[i].expression instanceof LiteralExpression); + inputs.push((args[i].expression : any)); + } else { + outputs.push(`() => ${args[i].js}`); + } + } + + // 'otherwise' case + outputs.push(`() => ${args[args.length - 1].js}`); + + // Construct a hash from input values (tagged with their type, to + // distinguish e.g. 0 from "0") to the index of the corresponding + // output. At evaluation time, look up this index and invoke the + // (thunked) output expression. + const inputMap = {}; + for (let i = 0; i < inputs.length; i++) { + assert(Array.isArray(inputs[i].value)); + const values: Array = (inputs[i].value: any); + for (const value of values) { + const type = typeof value; + inputMap[`${type}-${String(value)}`] = i; + } + } + + return {js: ` + (function () { + var outputs = [${outputs.join(', ')}]; + var inputMap = ${JSON.stringify(inputMap)}; + var input = ${input}; + var outputIndex = inputMap[this.typeOf(input).toLowerCase() + '-' + input]; + return typeof outputIndex === 'number' ? outputs[outputIndex]() : + outputs[${outputs.length - 1}](); + }.bind(this))()`}; + } +} + +module.exports = MatchExpression; diff --git a/src/style-spec/function/expression.js b/src/style-spec/function/expression.js new file mode 100644 index 00000000000..6e71352f6ac --- /dev/null +++ b/src/style-spec/function/expression.js @@ -0,0 +1,224 @@ +'use strict'; + +// @flow + +/*:: +import type { Type, PrimitiveType, ArrayType, LambdaType } from './types.js'; +import type { ExpressionName } from './expression_name.js'; +export type Expression = LambdaExpression | LiteralExpression; +export type CompiledExpression = {| + result: 'success', + js: string, + type: Type, + isFeatureConstant: boolean, + isZoomConstant: boolean, + expression: Expression, + function?: Function +|} + +export type LiteralValue = null | string | number | boolean | {} | Array + +*/ + +const { + NullType, + StringType, + NumberType, + BooleanType, + ObjectType, + ValueType, + array +} = require('./types'); + +const primitiveTypes = { + string: StringType, + number: NumberType, + boolean: BooleanType +}; + +class ParsingError extends Error { + key: string; + constructor(key: string, message: string) { + super(message); + this.key = key; + } +} + +class ParsingContext { + key: string; + path: Array; + ancestors: Array; + definitions: {[string]: Class}; + constructor(definitions: *, path: * = [], ancestors: * = []) { + this.definitions = definitions; + this.path = path; + this.key = path.join('.'); + this.ancestors = ancestors; + } + + concat(index: number, expressionName: ?string) { + return new ParsingContext( + this.definitions, + this.path.concat(index), + expressionName ? this.ancestors.concat(expressionName) : this.ancestors + ); + } +} + +class BaseExpression { + key: string; + +type: Type; + constructor(key: *, type: *) { + this.key = key; + (this: any).type = type; + } + + compile(_: Array): {js?: string, isFeatureConstant?: boolean, isZoomConstant?: boolean, errors?: Array} { + throw new Error('Unimplemented'); + } + + serialize(_: boolean): any { + throw new Error('Unimplemented'); + } +} + +class LiteralExpression extends BaseExpression { + type: PrimitiveType | ArrayType; + value: LiteralValue; + constructor(key: *, type: PrimitiveType | ArrayType, value: LiteralValue) { + super(key, type); + this.value = value; + } + + static parse(value: any, context: ParsingContext) { + const type = typeof value; + if ( + type === 'string' || + type === 'number' || + type === 'boolean' + ) { + return new this(context.key, primitiveTypes[type], value); + } + + if (Array.isArray(value)) { + let itemType; + // infer the array's item type + for (const item of value) { + const t = primitiveTypes[typeof item]; + if (t && !itemType) { + itemType = t; + } else if (t && itemType === t) { + continue; + } else { + itemType = ValueType; + break; + } + } + + const type = array(itemType || ValueType, value.length); + return new this( + context.key, + type, + value + ); + } else if (value && typeof value === 'object') { + return new this(context.key, ObjectType, value); + } else { + throw new ParsingError(context.key, `Expected an array or object, but found ${typeof value} instead`); + } + } + + compile() { + let wrapped = this.value; + if (Array.isArray(this.value)) { + wrapped = { + type: this.type.name, + items: this.value + }; + } else if (typeof this.value === 'object') { + wrapped = { + type: this.type.name, + value: this.value + }; + } + return { js: JSON.stringify(wrapped) }; + } + + serialize(_: boolean) { + return this.value; + } +} + +class LambdaExpression extends BaseExpression { + args: Array; + type: LambdaType; + constructor(key: *, type: LambdaType, args: Array) { + super(key, type); + this.args = args; + } + + applyType(type: LambdaType, args: Array): Expression { + return new this.constructor(this.key, type, args); + } + + serialize(withTypes: boolean) { + const name = this.constructor.getName(); + const type = this.type.kind === 'lambda' ? this.type.result.name : this.type.name; + const args = this.args.map(e => e.serialize(withTypes)); + return [ name + (withTypes ? `: ${type}` : '') ].concat(args); + } + + // implemented by subclasses + static getName(): ExpressionName { throw new Error('Unimplemented'); } + static getType(): LambdaType { throw new Error('Unimplemented'); } + + // default parse; overridden by some subclasses + static parse(args: Array, context: ParsingContext): LambdaExpression { + const op = this.getName(); + const parsedArgs: Array = []; + for (const arg of args) { + parsedArgs.push(parseExpression(arg, context.concat(1 + parsedArgs.length, op))); + } + + return new this(context.key, this.getType(), parsedArgs); + } +} + +function parseExpression(expr: mixed, context: ParsingContext) : Expression { + const key = context.key; + if (expr === null || typeof expr === 'undefined') + return new LiteralExpression(key, NullType, null); + + if (primitiveTypes[typeof expr]) + return LiteralExpression.parse(expr, context); + + if (!Array.isArray(expr)) { + throw new ParsingError(key, `Expected an array, but found ${typeof expr} instead.`); + } + + const op = expr[0]; + if (typeof op !== 'string') { + throw new ParsingError(`${key}.0`, `Expression name must be a string, but found ${typeof op} instead.`); + } + + if (op === 'literal') { + if (expr.length !== 2) + throw new ParsingError(key, `'literal' expression requires exactly one argument, but found ${expr.length - 1} instead.`); + return LiteralExpression.parse(expr[1], context.concat(1, 'literal')); + } + + const Expr = context.definitions[op]; + if (!Expr) { + throw new ParsingError(`${key}.0`, `Unknown expression "${op}"`); + } + + return Expr.parse(expr.slice(1), context); +} + +module.exports = { + ParsingContext, + ParsingError, + parseExpression, + LiteralExpression, + LambdaExpression +}; diff --git a/src/style-spec/function/expressions.js b/src/style-spec/function/expressions.js deleted file mode 100644 index 5ca093e9d25..00000000000 --- a/src/style-spec/function/expressions.js +++ /dev/null @@ -1,445 +0,0 @@ -'use strict'; - -// @flow - -const assert = require('assert'); - -const { - NumberType, - StringType, - BooleanType, - ColorType, - ObjectType, - ValueType, - InterpolationType, - typename, - variant, - array, - lambda, - nargs -} = require('./types'); - -/*:: - import type { LambdaType } from './types.js'; - - import type { TypedLambdaExpression } from './type_check.js'; - - import type { CompiledExpression } from './compile.js'; - - import type { ExpressionName } from './expression_name.js'; - - export type Definition = { - name: ExpressionName, - type: LambdaType, - compile: (args: Array, expr: TypedLambdaExpression) => ({ js?: string, errors?: Array, isFeatureConstant?: boolean, isZoomConstant?: boolean }) - } - */ - -const expressions: { [string]: Definition } = { - 'ln2': defineMathConstant('ln2'), - 'pi': defineMathConstant('pi'), - 'e': defineMathConstant('e'), - - 'typeof': { - name: 'typeof', - type: lambda(StringType, ValueType), - compile: fromContext('typeOf') - }, - - // type assertions - 'string': { - name: 'string', - type: lambda(StringType, ValueType), - compile: args => ({ js: `this.as(${args[0].js}, 'String')` }) - }, - 'number': { - name: 'string', - type: lambda(NumberType, ValueType), - compile: args => ({ js: `this.as(${args[0].js}, 'Number')` }) - }, - 'boolean': { - name: 'boolean', - type: lambda(BooleanType, ValueType), - compile: args => ({ js: `this.as(${args[0].js}, 'Boolean')` }) - }, - 'array': { - name: 'array', - type: lambda(array(ValueType), ValueType), - compile: (args) => ({js: `this.as(${args[0].js}, 'Array')`}) - }, - 'object': { - name: 'object', - type: lambda(ObjectType, ValueType), - compile: args => ({js: `this.as(${args[0].js}, 'Object')`}) - }, - - // type coercion - 'to_string': { - name: 'to_string', - type: lambda(StringType, ValueType), - compile: args => ({js: `this.toString(${args[0].js})`}) - }, - 'to_number': { - name: 'to_number', - type: lambda(NumberType, ValueType), - compile: args => ({js: `this.toNumber(${args[0].js})`}) - }, - 'to_boolean': { - name: 'to_boolean', - type: lambda(BooleanType, ValueType), - compile: args => ({js: `Boolean(${args[0].js})`}) - }, - 'to_rgba': { - name: 'to_rgba', - type: lambda(array(NumberType, 4), ColorType), - compile: args => ({js: `this.array('Array', ${args[0].js}.value)`}) - }, - - // color 'constructors' - 'parse_color': { - name: 'parse_color', - type: lambda(ColorType, StringType), - compile: fromContext('parseColor') - }, - 'rgb': { - name: 'rgb', - type: lambda(ColorType, NumberType, NumberType, NumberType), - compile: fromContext('rgba') - }, - 'rgba': { - name: 'rgb', - type: lambda(ColorType, NumberType, NumberType, NumberType, NumberType), - compile: fromContext('rgba') - }, - - // object/array access - 'get': { - name: 'get', - type: lambda(ValueType, StringType, nargs(1, ObjectType)), - compile: args => ({ - js: `this.get(${args.length > 1 ? args[1].js : 'props'}, ${args[0].js}, ${args.length > 1 ? 'undefined' : '"feature.properties"'})`, - isFeatureConstant: args.length > 1 && args[1].isFeatureConstant - }) - }, - 'has': { - name: 'has', - type: lambda(BooleanType, StringType, nargs(1, ObjectType)), - compile: args => ({ - js: `this.has(${args.length > 1 ? args[1].js : 'props'}, ${args[0].js}, ${args.length > 1 ? 'undefined' : '"feature.properties"'})`, - isFeatureConstant: args.length > 1 && args[1].isFeatureConstant - }) - }, - 'at': { - name: 'at', - type: lambda( - typename('T'), - NumberType, - array(typename('T')) - ), - compile: fromContext('at') - }, - 'length': { - name: 'length', - type: lambda(NumberType, variant( - array(typename('T')), - StringType - )), - compile: args => { - let t = args[0].type; - if (t.kind === 'lambda') { t = t.result; } - assert(t.kind === 'array' || t.kind === 'primitive'); - return { - js: t.kind === 'array' ? - `${args[0].js}.items.length` : - `${args[0].js}.length` - }; - } - }, - - // feature and map data - 'properties': { - name: 'properties', - type: lambda(ObjectType), - compile: () => ({ - js: 'this.as(props, "Object", "feature.properties")', - isFeatureConstant: false - }) - }, - 'geometry_type': { - name: 'geometry_type', - type: lambda(StringType), - compile: () => ({ - js: 'this.get(this.get(feature, "geometry", "feature"), "type", "feature.geometry")', - isFeatureConstant: false - }) - }, - 'id': { - name: 'id', - type: lambda(ValueType), - compile: () => ({ - js: 'this.get(feature, "id", "feature")', - isFeatureConstant: false - }) - }, - 'zoom': { - name: 'zoom', - type: lambda(NumberType), - compile: () => ({js: 'mapProperties.zoom', isZoomConstant: false}) - }, - - // math - '+': defineBinaryMathOp('+', true), - '*': defineBinaryMathOp('*', true), - '-': defineBinaryMathOp('-'), - '/': defineBinaryMathOp('/'), - '%': defineBinaryMathOp('%'), - '^': { - name: '^', - type: lambda(NumberType, NumberType, NumberType), - compile: args => ({js: `Math.pow(${args[0].js}, ${args[1].js})`}) - }, - 'log10': defineMathFunction('log10', 1), - 'ln': defineMathFunction('ln', 1, 'log'), - 'log2': defineMathFunction('log2', 1), - 'sin': defineMathFunction('sin', 1), - 'cos': defineMathFunction('cos', 1), - 'tan': defineMathFunction('tan', 1), - 'asin': defineMathFunction('asin', 1), - 'acos': defineMathFunction('acos', 1), - 'atan': defineMathFunction('atan', 1), - '==': defineComparisonOp('=='), - '!=': defineComparisonOp('!='), - '>': defineComparisonOp('>'), - '<': defineComparisonOp('<'), - '>=': defineComparisonOp('>='), - '<=': defineComparisonOp('<='), - '&&': defineBooleanOp('&&'), - '||': defineBooleanOp('||'), - '!': { - name: '!', - type: lambda(BooleanType, BooleanType), - compile: args => ({js: `!(${args[0].js})`}) - }, - - // string manipulation - 'upcase': { - name: 'upcase', - type: lambda(StringType, StringType), - compile: args => ({js: `(${args[0].js}).toUpperCase()`}) - }, - 'downcase': { - name: 'downcase', - type: lambda(StringType, StringType), - compile: args => ({js: `(${args[0].js}).toLowerCase()`}) - }, - 'concat': { - name: 'concat', - type: lambda(StringType, nargs(Infinity, ValueType)), - compile: args => ({js: `[${args.map(a => a.js).join(', ')}].join('')`}) - }, - - // decisions - 'case': { - name: 'case', - type: lambda(typename('T'), nargs(Infinity, BooleanType, typename('T')), typename('T')), - compile: args => { - args = [].concat(args); - const result = []; - while (args.length > 1) { - const c = args.splice(0, 2); - result.push(`${c[0].js} ? ${c[1].js}`); - } - assert(args.length === 1); // enforced by type checking - result.push(args[0].js); - return { js: result.join(':') }; - } - }, - 'match': { - name: 'match', - // note that, since they're pulled out during parsing, the input - // values of type T aren't reflected in the signature here - type: lambda(typename('T'), typename('U'), nargs(Infinity, typename('T'))), - compile: (args, e) => { - if (!e.matchInputs) { throw new Error('Missing match input values'); } - const inputs = e.matchInputs; - if (args.length !== inputs.length + 2) { - return { - errors: [`Expected ${2 * inputs.length + 2} arguments, but found ${inputs.length + args.length} instead.`] - }; - } - - const input = args[0].js; - const outputs = args.slice(1).map(a => `() => ${a.js}`); - - // Construct a hash from input values (tagged with their type, to - // distinguish e.g. 0 from "0") to the index of the corresponding - // output. At evaluation time, look up this index and invoke the - // (thunked) output expression. - const inputMap = {}; - for (let i = 0; i < inputs.length; i++) { - for (const v of inputs[i]) { - const type = v.type.name.slice(0, 1).toUpperCase() + v.type.name.slice(1); - inputMap[`${type}-${String(v.value)}`] = i; - } - } - - return {js: ` - (function () { - var outputs = [${outputs.join(', ')}]; - var inputMap = ${JSON.stringify(inputMap)}; - var input = ${input}; - var outputIndex = inputMap[this.typeOf(input) + '-' + input]; - return typeof outputIndex === 'number' ? outputs[outputIndex]() : - outputs[${outputs.length - 1}](); - }.bind(this))()`}; - } - }, - - 'coalesce': { - name: 'coalesce', - type: lambda(typename('T'), nargs(Infinity, typename('T'))), - compile: args => ({ - js: `this.coalesce(${args.map(a => `() => ${a.js}`).join(', ')})` - }) - }, - - 'curve': { - name: 'curve', - type: lambda(typename('T'), InterpolationType, NumberType, nargs(Infinity, NumberType, typename('T'))), - compile: args => { - const interpolation = args[0].expression; - if (interpolation.literal) { throw new Error('Invalid interpolation type'); } // enforced by type checking - - let resultType; - if (args[3].type === NumberType) { - resultType = 'number'; - } else if (args[3].type === ColorType) { - resultType = 'color'; - } else if ( - args[3].type.kind === 'array' && - args[3].type.itemType === NumberType - ) { - resultType = 'array'; - } else if (interpolation.name !== 'step') { - return { - errors: [`Type ${args[3].type.name} is not interpolatable, and thus cannot be used as a ${interpolation.name} curve's output type.`] - }; - } - - const stops = []; - const outputs = []; - for (let i = 2; (i + 1) < args.length; i += 2) { - const input = args[i].expression; - const output = args[i + 1]; - if (!input.literal || typeof input.value !== 'number') { - return { - errors: [ 'Input/output pairs for "curve" expressions must be defined using literal numeric values (not computed expressions) for the input values.' ] - }; - } - - if (stops.length && stops[stops.length - 1] > input.value) { - return { - errors: [ 'Input/output pairs for "curve" expressions must be arranged with input values in strictly ascending order.' ] - }; - } - - stops.push(input.value); - outputs.push(output.js); - } - - const interpolationOptions: Object = { - name: interpolation.name - }; - - if (interpolation.name === 'exponential') { - const baseExpr = interpolation.arguments[0]; - if (!baseExpr.literal || typeof baseExpr.value !== 'number') { - return {errors: ["Exponential interpolation base must be a literal number value."]}; - } - interpolationOptions.base = baseExpr.value; - } - - return {js: ` - (function () { - var input = ${args[1].js}; - var stopInputs = [${stops.join(', ')}]; - var stopOutputs = [${outputs.map(o => `() => ${o}`).join(', ')}]; - return this.evaluateCurve(${args[1].js}, stopInputs, stopOutputs, ${JSON.stringify(interpolationOptions)}, ${JSON.stringify(resultType)}); - }.bind(this))()`}; - } - }, - 'step': { - name: 'step', - type: lambda(InterpolationType), - compile: () => ({ js: 'void 0' }) - }, - 'exponential': { - name: 'exponential', - type: lambda(InterpolationType, NumberType), - compile: () => ({ js: 'void 0' }) - }, - 'linear': { - name: 'step', - type: lambda(InterpolationType), - compile: () => ({ js: 'void 0' }) - } -}; - -module.exports = expressions; - -function defineMathConstant(name) { - const mathName = name.toUpperCase(); - assert(typeof Math[mathName] === 'number'); - return { - name: name, - type: lambda(NumberType), - compile: () => ({ js: `Math.${mathName}` }) - }; -} - -function defineMathFunction(name: ExpressionName, arity: number, mathName?: string) { - const key:string = mathName || name; - assert(typeof Math[key] === 'function'); - assert(arity > 0); - const args = []; - while (arity-- > 0) args.push(NumberType); - return { - name: name, - type: lambda(NumberType, ...args), - compile: args => ({ js: `Math.${key}(${args.map(a => a.js).join(', ')})` }) - }; -} - -function defineBinaryMathOp(name, isAssociative) { - const args = isAssociative ? [nargs(Infinity, NumberType)] : [NumberType, NumberType]; - return { - name: name, - type: lambda(NumberType, ...args), - compile: args => ({ js: `${args.map(a => a.js).join(name)}` }) - }; -} - -function defineComparisonOp(name) { - const op = name === '==' ? '===' : - name === '!=' ? '!==' : name; - return { - name: name, - type: lambda(BooleanType, typename('T'), typename('T')), - compile: args => ({ js: `${args[0].js} ${op} ${args[1].js}` }) - }; -} - -function defineBooleanOp(op) { - return { - name: op, - type: lambda(BooleanType, nargs(Infinity, BooleanType)), - compile: args => ({ js: `${args.map(a => a.js).join(op)}` }) - }; -} - -function fromContext(name) { - return args => { - const argvalues = args.map(a => a.js).join(', '); - return { js: `this.${name}(${argvalues})` }; - }; -} diff --git a/src/style-spec/function/index.js b/src/style-spec/function/index.js index 1de73ffdfa3..2223d23be95 100644 --- a/src/style-spec/function/index.js +++ b/src/style-spec/function/index.js @@ -1,7 +1,6 @@ 'use strict'; const assert = require('assert'); -const expressions = require('./expressions'); const compileExpression = require('./compile'); const convert = require('./convert'); const {ColorType, StringType, NumberType, ValueType, array} = require('./types'); @@ -22,7 +21,7 @@ function createFunction(parameters, propertySpec) { } const expectedType = getExpectedType(propertySpec); - const compiled = compileExpression(expressions, expr, expectedType); + const compiled = compileExpression(expr, expectedType); if (compiled.result === 'success') { const f = function (zoom, properties) { const val = compiled.function({zoom}, {properties}); @@ -50,7 +49,6 @@ function createFunction(parameters, propertySpec) { interpExpression.push(f.zoomStops[i], i); } const interpFunction = compileExpression( - expressions, ['coalesce', interpExpression, 0], NumberType ); diff --git a/src/style-spec/function/parse.js b/src/style-spec/function/parse.js deleted file mode 100644 index f797995cade..00000000000 --- a/src/style-spec/function/parse.js +++ /dev/null @@ -1,210 +0,0 @@ -'use strict'; - -// @flow - -const { - NullType, - NumberType, - StringType, - BooleanType, - ValueType, - ObjectType, - array -} = require('./types'); - -/*:: - import type { TypeError, TypedExpression } from './type_check.js'; - - import type { ExpressionName } from './expression_name.js'; - - import type { Definition } from './expressions.js'; - - export type ParseError = {| - error: string, - key: string - |} -*/ - -module.exports = parseExpression; - -const primitiveTypes = { - string: StringType, - number: NumberType, - boolean: BooleanType -}; - -/** - * Parse raw JSON expression into a TypedExpression structure, with type - * tags taken directly from the definition of each function (i.e., - * no inference performed). - * - * @private - */ -function parseExpression( - definitions: {[string]: Definition}, - expr: mixed, - path: Array = [], - ancestorNames: Array = [] -) /*: TypedExpression | ParseError */ { - const key = path.join('.'); - if (expr === null || typeof expr === 'undefined') return { - literal: true, - value: null, - type: NullType, - key - }; - - if (primitiveTypes[typeof expr]) return { - literal: true, - value: expr, - type: primitiveTypes[typeof expr], - key - }; - - if (!Array.isArray(expr)) { - return { - key, - error: `Expected an array, but found ${typeof expr} instead.` - }; - } - - if (expr[0] === 'literal') { - if (expr.length !== 2) return { - key, - error: `'literal' expression requires exactly one argument, but found ${expr.length - 1} instead.` - }; - - const rawValue = expr[1]; - let type; - let value; - if (Array.isArray(rawValue)) { - let itemType; - // infer the array's item type - for (const item of rawValue) { - const t = primitiveTypes[typeof item]; - if (t && !itemType) { - itemType = t; - } else if (t && itemType === t) { - continue; - } else { - itemType = ValueType; - break; - } - } - - type = array(itemType || ValueType, rawValue.length); - value = { type: type.name, items: rawValue }; - } else { - type = ObjectType; - value = { type: 'Object', value: rawValue }; - } - - return { - literal: true, - value, - type, - key - }; - } - - const op = expr[0]; - if (typeof op !== 'string') { - return { - key: `${key}.0`, - error: `Expression name must be a string, but found ${typeof op} instead.` - }; - } - - const definition = definitions[op]; - if (!definition) { - return { - key, - error: `Unknown function ${op}` - }; - } - - // special case validation for `zoom` - if (op === 'zoom') { - const ancestors = ancestorNames.join(':'); - // zoom expressions may only appear like: - // ['curve', interp, ['zoom'], ...] - // or ['coalesce', ['curve', interp, ['zoom'], ...], ... ] - if ( - !/^(1.)?2/.test(key) || - !/(coalesce:)?curve/.test(ancestors) - ) { - return { - key, - error: 'The "zoom" expression may only be used as the input to a top-level "curve" expression.' - }; - } - } - - // special case parsing for `match` - if (op === 'match') { - if (expr.length < 3) return { - key, - error: `Expected at least 2 arguments, but found only ${expr.length - 1}.` - }; - - const inputExpression = parseExpression(definitions, expr[1], path.concat(1), ancestorNames.concat(op)); - if (inputExpression.error) return inputExpression; - - // parse input/output pairs. - const matchInputs = []; - const outputExpressions = []; - for (let i = 2; i < expr.length - 2; i += 2) { - const inputGroup = Array.isArray(expr[i]) ? expr[i] : [expr[i]]; - if (inputGroup.length === 0) { - return { - key: `${key}.${i}`, - error: 'Expected at least one input value.' - }; - } - - const parsedInputGroup = []; - for (let j = 0; j < inputGroup.length; j++) { - const parsedValue = parseExpression(definitions, inputGroup[j], path.concat(i, j), ancestorNames.concat(op)); - if (parsedValue.error) return parsedValue; - if (!parsedValue.literal) return { - key: `${key}.${i}.${j}`, - error: 'Match inputs must be literal primitive values or arrays of literal primitive values.' - }; - parsedInputGroup.push(parsedValue); - } - matchInputs.push(parsedInputGroup); - - const output = parseExpression(definitions, expr[i + 1], path.concat(i), ancestorNames.concat(op)); - if (output.error) return output; - outputExpressions.push(output); - } - - const otherwise = parseExpression(definitions, expr[expr.length - 1], path.concat(expr.length - 1), ancestorNames.concat(op)); - if (otherwise.error) return otherwise; - outputExpressions.push(otherwise); - - return { - literal: false, - name: 'match', - type: definition.type, - matchInputs, - arguments: [inputExpression].concat(outputExpressions), - key - }; - } - - const args = []; - for (const arg of expr.slice(1)) { - const parsedArg = parseExpression(definitions, arg, path.concat(1 + args.length), ancestorNames.concat(op)); - if (parsedArg.error) return parsedArg; - args.push(parsedArg); - } - - return { - literal: false, - name: op, - type: definition.type, - arguments: args, - key - }; -} diff --git a/src/style-spec/function/type_check.js b/src/style-spec/function/type_check.js index ee216b4ec6f..aedf7b97020 100644 --- a/src/style-spec/function/type_check.js +++ b/src/style-spec/function/type_check.js @@ -6,29 +6,20 @@ import type { ExpressionName } from './expression_name.js'; + import type { Expression } from './expression.js'; + export type TypeError = {| error: string, key: string |} - export type TypedLambdaExpression = {| - literal: false, - name: ExpressionName, - type: LambdaType, - arguments: Array, - key: string, - matchInputs?: Array> - |} - - export type TypedLiteralExpression = {| - literal: true, - value: string | number | boolean | null, - type: Type, - key: string + export type TypecheckResult = {| + result: 'success', + expression: Expression + |} | {| + result: 'error', + errors: Array |} - - export type TypedExpression = TypedLambdaExpression | TypedLiteralExpression - */ const assert = require('assert'); @@ -36,16 +27,17 @@ const util = require('../../util/util'); const { NullType, lambda, array, variant, nargs } = require('./types'); +const { LiteralExpression } = require('./expression'); + module.exports = typeCheckExpression; -module.exports.serialize = serializeExpression; // typecheck the given expression and return a new TypedExpression // tree with all generics resolved -function typeCheckExpression(expected: Type, e: TypedExpression) /*: TypedExpression | {| errors: Array |} */ { - if (e.literal) { +function typeCheckExpression(expected: Type, e: Expression) /*: TypecheckResult */ { + if (e instanceof LiteralExpression) { const error = match(expected, e.type); - if (error) return { errors: [{ key: e.key, error }] }; - return e; + if (error) return { result: 'error', errors: [{ key: e.key, error }] }; + return {result: 'success', expression: e}; } else { // e is a lambda expression, so check its result type against the // expected type and recursively typecheck its arguments @@ -58,18 +50,18 @@ function typeCheckExpression(expected: Type, e: TypedExpression) /*: TypedExpres // the arguments using e.type, which comes from the expression // definition. const error = match(expected, e.type.result, {}, typenames); - if (error) return { errors: [{ key: e.key, error }] }; + if (error) return { result: 'error', errors: [{ key: e.key, error }] }; expected = e.type; } else { const error = match(expected.result, e.type.result, typenames); - if (error) return { errors: [{ key: e.key, error }] }; + if (error) return { result: 'error', errors: [{ key: e.key, error }] }; } // "Unroll" NArgs if present in the parameter list: // argCount = nargType.type.length * n + nonNargParameterCount // where n is the number of times the NArgs sequence must be // repeated. - const argValues = e.arguments; + const argValues = e.args; const expandedParams = []; const errors = []; for (const param of expected.params) { @@ -88,6 +80,7 @@ function typeCheckExpression(expected: Type, e: TypedExpression) /*: TypedExpres if (expandedParams.length !== argValues.length) { return { + result: 'error', errors: [{ key: e.key, error: `Expected ${expandedParams.length} arguments, but found ${argValues.length} instead.` @@ -112,12 +105,13 @@ function typeCheckExpression(expected: Type, e: TypedExpression) /*: TypedExpres const resultType = resolveTypenamesIfPossible(expected.result, typenames); if (isGeneric(resultType)) return { + result: 'error', errors: [{key: e.key, error: `Could not resolve ${e.type.result.name}. This expression must be wrapped in a type conversion, e.g. ["string", ${stringifyExpression(e)}].`}] }; // If we already have errors, return early so we don't get duplicates when // we typecheck against the resolved argument types - if (errors.length) return { errors }; + if (errors.length) return { result: 'error', errors }; // resolve typenames and recursively type check argument subexpressions const resolvedParams = []; @@ -126,43 +120,39 @@ function typeCheckExpression(expected: Type, e: TypedExpression) /*: TypedExpres const t = expandedParams[i]; const arg = argValues[i]; const expected = resolveTypenamesIfPossible(t, typenames); - const result = typeCheckExpression(expected, arg); - if (result.errors) { - errors.push.apply(errors, result.errors); + const checked = typeCheckExpression(expected, arg); + if (checked.result === 'error') { + errors.push.apply(errors, checked.errors); } else if (errors.length === 0) { resolvedParams.push(expected); - checkedArgs.push(result); + checkedArgs.push(checked.expression); } } // handle 'match' expression input values - let matchInputs; - if (e.matchInputs) { - matchInputs = []; - const inputType = resolvedParams[0]; - for (const inputGroup of e.matchInputs) { - const checkedGroup = []; - for (const inputValue of inputGroup) { - const result = typeCheckExpression(inputType, inputValue); - if (result.errors) { - errors.push.apply(errors, result.errors); - } else { - checkedGroup.push(((result: any): TypedLiteralExpression)); - } - } - matchInputs.push(checkedGroup); - } - } - - if (errors.length > 0) return { errors }; + // let matchInputs; + // if (e.matchInputs) { + // matchInputs = []; + // const inputType = resolvedParams[0]; + // for (const inputGroup of e.matchInputs) { + // const checkedGroup = []; + // for (const inputValue of inputGroup) { + // const result = typeCheckExpression(inputType, inputValue); + // if (result.errors) { + // errors.push.apply(errors, result.errors); + // } else { + // checkedGroup.push(((result: any): TypedLiteralExpression)); + // } + // } + // matchInputs.push(checkedGroup); + // } + // } + + if (errors.length > 0) return { result: 'error', errors }; return { - literal: false, - name: e.name, - type: lambda(resultType, ...resolvedParams), - arguments: checkedArgs, - key: e.key, - matchInputs + result: 'success', + expression: e.applyType(lambda(resultType, ...resolvedParams), checkedArgs) }; } } @@ -232,15 +222,8 @@ function match(expected: Type, t: Type, expectedTypenames: { [string]: Type } = throw new Error(`${expected.name} is not a valid output type.`); } -function serializeExpression(e: TypedExpression, withTypes) { - if (e.literal) { - return e.value; - } else { - return [ e.name + (withTypes ? `: ${e.type.kind === 'lambda' ? e.type.result.name : e.type.name}` : '') ].concat(e.arguments.map(e => serializeExpression(e, withTypes))); - } -} -function stringifyExpression(e: TypedExpression, withTypes) /*:string*/ { - return JSON.stringify(serializeExpression(e, withTypes)); +function stringifyExpression(e: Expression, withTypes: boolean = false) /*:string*/ { + return JSON.stringify(e.serialize(withTypes)); } function isGeneric (type, stack = []) { diff --git a/src/style-spec/function/types.js b/src/style-spec/function/types.js index 66ba0c8ac1b..f7ce2a5b93d 100644 --- a/src/style-spec/function/types.js +++ b/src/style-spec/function/types.js @@ -30,9 +30,6 @@ const ValueArray = array(ValueType); ValueType.members.push(ValueArray); ValueType.name = 'Value'; - -const InterpolationType = primitive('interpolation_type'); - function primitive(name) /*: PrimitiveType */ { return { kind: 'primitive', name }; } @@ -85,7 +82,6 @@ module.exports = { ColorType, ObjectType, ValueType, - InterpolationType, typename, variant, array, diff --git a/test/expression.test.js b/test/expression.test.js index 7cfa5761f6c..1f3ce8de6c9 100644 --- a/test/expression.test.js +++ b/test/expression.test.js @@ -3,7 +3,6 @@ require('flow-remove-types/register'); const util = require('../src/util/util'); const expressionSuite = require('./integration').expression; -const expressions = require('../src/style-spec/function/expressions'); const compileExpression = require('../src/style-spec/function/compile'); let tests; @@ -13,7 +12,7 @@ if (process.argv[1] === __filename && process.argv.length > 2) { } expressionSuite.run('js', {tests: tests}, (fixture) => { - const compiled = compileExpression(expressions, fixture.expression); + const compiled = compileExpression(fixture.expression); const testResult = { compileResult: util.pick(compiled, ['result', 'js', 'isFeatureConstant', 'isZoomConstant', 'errors']) diff --git a/test/integration/expression-tests/parse/unknown-expression/test.json b/test/integration/expression-tests/parse/unknown-expression/test.json index 64aff8bcc26..2884d922a8d 100644 --- a/test/integration/expression-tests/parse/unknown-expression/test.json +++ b/test/integration/expression-tests/parse/unknown-expression/test.json @@ -18,8 +18,8 @@ "result": "error", "errors": [ { - "key": "1.4", - "error": "Unknown function FAKE_EXPRESSION" + "key": "1.4.0", + "error": "Unknown expression \"FAKE_EXPRESSION\"" } ] }