├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── rollup.config.js ├── scripts ├── .eslintrc.json └── build-operator-list.js ├── src ├── expression-operators.js ├── expression-to-formula.js ├── formula-to-expression.js ├── handle-syntax-errors.js └── index.js └── test ├── .eslintrc.json ├── expression-to-formula.test.js └── formula-to-expression.test.js /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "browser": false, 5 | "es6": true 6 | }, 7 | "parserOptions": { 8 | "sourceType": "module" 9 | }, 10 | "rules": { 11 | "no-undef": "warn", 12 | "no-console": "off" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 14 4 | cache: 5 | directories: 6 | - node_modules 7 | before_install: 8 | - npm i -g npm@latest 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.5.0 4 | 5 | - Enable support for object literals. 6 | - Updates style spec version to `13.21.0` and removes block list predicated on object support. 7 | - Fix handling of the `literal` expression, which now supports any literal value (arrays, objects, strings, etc), in accordance with the style spec. 8 | 9 | ## 0.4.1 10 | 11 | - Fix handling of nested arrays that are *not* expressions (e.g. in `["match", ["get", "rank"], [1, 2], "a", [3, 4], "b", "c"]) by only treated arrays as expressions if they start with a whitelisted expression operator. 12 | - Treat `!` as a prefix operator instead of an infix operator when transforming expressions to formulas, since `!` has only one operand. 13 | 14 | ## 0.4.0 15 | 16 | - **[BREAKING]** Reject unquoted literal strings as passed in as formulas to `formulaToExpression`. 17 | 18 | ## 0.3.2 19 | 20 | - Fix handling of symbolic decision operators, like `<=` and `!`. 21 | 22 | ## 0.3.1 23 | 24 | - Fix handling of the `literal` expression, whose argument can be an array whose items are primitives and arrays of primitives. 25 | 26 | ## 0.3.0 27 | 28 | - Fix handling of `^` (exponentiation) and `%` (remainder) expression operators. 29 | - Fix handling of empty input to `formulaToExpression`. It now returns `undefined`. 30 | - Fix handling of hyphenated expression operators. 31 | 32 | ## 0.2.0 33 | 34 | - **[BREAKING]** Export 2 functions: `expressionToFormula` and `formulaToExpression`, so the transformation can go both ways. 35 | 36 | ## 0.1.0 37 | 38 | - Initial release. 39 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in 2 | # the repo. 3 | * @dasulit @samanpwbb @jseppi 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. 6 | 7 | Examples of unacceptable behavior by participants include: 8 | 9 | - The use of sexualized language or imagery 10 | - Personal attacks 11 | - Trolling or insulting/derogatory comments 12 | - Public or private harassment 13 | - Publishing other's private information, such as physical or electronic addresses, without explicit permission 14 | - Other unethical or unprofessional conduct 15 | 16 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. 17 | 18 | This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. 19 | 20 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 21 | 22 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.2.0, available at 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Mapbox 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @mapbox/expression-jamsession 2 | 3 | [![Build Status](https://travis-ci.com/mapbox/expression-jamsession.svg?token=SEyDg5xudiyx521kB7Cy&branch=master)](https://travis-ci.com/mapbox/expression-jamsession) 4 | 5 | Write [Mapbox GL expressions](https://www.mapbox.com/mapbox-gl-js/style-spec/#expressions) in a more familiar, handwritable, spreadsheet-like, programming-like syntax. 6 | This library translates these handwritten formulas into valid spec-compliant Mapbox GL expressions that you can use in a Mapbox style. 7 | 8 | 9 | ## Formula syntax features 10 | 11 | - Most expressions are represented like function invocations in programming, e.g. `get("population")`, `log2(get("population"))`. 12 | - Symbolic math operators (`+ - * / %`) and parentheses work like in high school math, e.g. `((3 + 4) * 2) / 7`. 13 | That is, the formula should contain `3 + 4` instead of `+(3, 4)`. 14 | - Symbolic decision operators also work with operands on both sides, instead of like function invocations. 15 | So `get("foo") != 4` instead of `!=(get("foo"), 4)`. 16 | - Strings must always be wrapped in quotation marks, e.g. `concat("egg", "s")` not `concat(egg, s)`. 17 | - `&` operator concatenates strings, as in spreadsheet programs. 18 | 19 | ```js 20 | // Input 21 | 2 + 2 22 | 23 | // Output 24 | ["+", 2, 2] 25 | ``` 26 | 27 | ```js 28 | // Input 29 | max(3, log2(6)) 30 | 31 | // Output 32 | ["max", 3, ["log2", 6]] 33 | ``` 34 | 35 | ```js 36 | // Input 37 | ((3 + get("num")) * 2) / 7 38 | 39 | // Output 40 | ["/", ["*", ["+", 3, get("num")], 2], 7] 41 | ``` 42 | 43 | ```js 44 | // Input 45 | "name: " & get("name") 46 | 47 | // Output 48 | ["concat", ["name ", ["get", "name"]]] 49 | ``` 50 | 51 | ## Usage 52 | 53 | The module exports two functions so you can transform in both directions: 54 | 55 | - `formulaToExpression` transforms (string) formulas to (array) expressions. 56 | - `expressionToFormula` transforms expressions to formulas. 57 | 58 | ```js 59 | import jamsession from '@mapbox/expression-jamsession'; 60 | 61 | jamsession.formulaToExpression("3 + 4"); // ["+", 3, 4] 62 | 63 | jamsession.expressionToFormula(["+", 3, 4]); // "3 + 4" 64 | ``` 65 | 66 | ## Browser compatibility 67 | 68 | This library should work in IE11+. It uses a `Set`, so you might get it working in older browsers by adding a polyfill. 69 | 70 | ## Caveats 71 | 72 | - You can use this library to create expressions that are syntactically acceptable but invalid as Mapbox GL expressions, e.g. `get(true)` outputs `["get", true]`, which fails. 73 | - You cannot use JSON object literal arguments to [the `literal` expression](https://www.mapbox.com/mapbox-gl-js/style-spec/#expressions-types-literal). 74 | This is allowed in the spec; but objects are not supported by jsep and the use case for this type of expression is kind of an edge case — so it's probably not worth trying to adjust the parser to support this edge case. 75 | If you disagree, please consider filing an issue and/or PR. 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mapbox/expression-jamsession", 3 | "version": "0.5.0", 4 | "description": "Write Mapbox GL expressions in a more familiar, handwritable, spreadsheet-like, programming-like syntax.", 5 | "main": "dist/index.js", 6 | "files": [ 7 | "dist" 8 | ], 9 | "scripts": { 10 | "test": "jest --maxWorkers=4", 11 | "lint": "eslint .", 12 | "pretest": "npm run lint", 13 | "precommit": "lint-staged", 14 | "format": "prettier --write '{,src/**/,test/**/}*.js'", 15 | "build": "scripts/build-operator-list.js && rm -rf dist && rollup -c", 16 | "prepublishOnly": "npm run build" 17 | }, 18 | "lint-staged": { 19 | "**/*.js": [ 20 | "eslint", 21 | "prettier --write", 22 | "git add" 23 | ] 24 | }, 25 | "prettier": { 26 | "singleQuote": true 27 | }, 28 | "jest": { 29 | "testEnvironment": "node", 30 | "clearMocks": true 31 | }, 32 | "babel": { 33 | "env": { 34 | "test": { 35 | "presets": [ 36 | [ 37 | "@babel/preset-env" 38 | ] 39 | ] 40 | }, 41 | "development": { 42 | "presets": [ 43 | [ 44 | "@babel/preset-env", 45 | { 46 | "modules": false 47 | } 48 | ] 49 | ] 50 | } 51 | } 52 | }, 53 | "repository": { 54 | "type": "git", 55 | "url": "git+https://github.com/mapbox/expression-jamsession.git" 56 | }, 57 | "author": "Mapbox", 58 | "license": "BSD-2-Clause", 59 | "bugs": { 60 | "url": "https://github.com/mapbox/expression-jamsession/issues" 61 | }, 62 | "homepage": "https://github.com/mapbox/expression-jamsession#readme", 63 | "devDependencies": { 64 | "@babel/preset-env": "^7.15.6", 65 | "@mapbox/mapbox-gl-style-spec": "^13.21.0", 66 | "@rollup/plugin-babel": "^5.3.0", 67 | "@rollup/plugin-commonjs": "^20.0.0", 68 | "@rollup/plugin-node-resolve": "^13.0.4", 69 | "eslint": "^4.14.0", 70 | "eslint-plugin-node": "^6.0.1", 71 | "husky": "^7.0.2", 72 | "jest": "^27.2.1", 73 | "lint-staged": "^6.0.0", 74 | "prettier": "^1.9.2", 75 | "rollup": "^2.56.3" 76 | }, 77 | "dependencies": { 78 | "@jsep-plugin/object": "^1.0.1", 79 | "jsep": "^1.0.3" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { babel } from '@rollup/plugin-babel'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import resolve from '@rollup/plugin-node-resolve'; 4 | 5 | export default { 6 | input: 'src/index.js', 7 | output: { 8 | file: 'dist/index.js', 9 | format: 'cjs' 10 | }, 11 | external: ['jsep'], 12 | plugins: [ 13 | babel({ 14 | exclude: 'node_modules/**' 15 | }), 16 | commonjs(), 17 | resolve() 18 | ] 19 | }; 20 | -------------------------------------------------------------------------------- /scripts/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "node": true, 5 | "es6": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 2018, 9 | "sourceType": "script" 10 | }, 11 | "plugins": ["node"], 12 | "rules": { 13 | "strict": "error", 14 | "no-console": "off", 15 | "node/no-missing-require": "error", 16 | "node/no-unsupported-features": ["error", { "version": 4 }] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /scripts/build-operator-list.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | const styleSpec = require('@mapbox/mapbox-gl-style-spec'); 7 | 8 | const expressions = Object.keys(styleSpec.v8.expression_name.values); 9 | 10 | const fileContent = `// DO NOT MODIFY DIRECTLY! 11 | // This file is generated by scripts/build-operator-list.js 12 | // from the Mapbox GL Style Spec. 13 | 14 | const operators = new Set(); 15 | 16 | ${expressions.map(x => `operators.add('${x}');`).join('\n')} 17 | 18 | export default operators; 19 | `; 20 | 21 | fs.writeFileSync( 22 | path.join(__dirname, '../src/expression-operators.js'), 23 | fileContent 24 | ); 25 | -------------------------------------------------------------------------------- /src/expression-operators.js: -------------------------------------------------------------------------------- 1 | // DO NOT MODIFY DIRECTLY! 2 | // This file is generated by scripts/build-operator-list.js 3 | // from the Mapbox GL Style Spec. 4 | 5 | const operators = new Set(); 6 | 7 | operators.add('let'); 8 | operators.add('var'); 9 | operators.add('literal'); 10 | operators.add('array'); 11 | operators.add('at'); 12 | operators.add('in'); 13 | operators.add('index-of'); 14 | operators.add('slice'); 15 | operators.add('case'); 16 | operators.add('match'); 17 | operators.add('coalesce'); 18 | operators.add('step'); 19 | operators.add('interpolate'); 20 | operators.add('interpolate-hcl'); 21 | operators.add('interpolate-lab'); 22 | operators.add('ln2'); 23 | operators.add('pi'); 24 | operators.add('e'); 25 | operators.add('typeof'); 26 | operators.add('string'); 27 | operators.add('number'); 28 | operators.add('boolean'); 29 | operators.add('object'); 30 | operators.add('collator'); 31 | operators.add('format'); 32 | operators.add('image'); 33 | operators.add('number-format'); 34 | operators.add('to-string'); 35 | operators.add('to-number'); 36 | operators.add('to-boolean'); 37 | operators.add('to-rgba'); 38 | operators.add('to-color'); 39 | operators.add('rgb'); 40 | operators.add('rgba'); 41 | operators.add('get'); 42 | operators.add('has'); 43 | operators.add('length'); 44 | operators.add('properties'); 45 | operators.add('feature-state'); 46 | operators.add('geometry-type'); 47 | operators.add('id'); 48 | operators.add('zoom'); 49 | operators.add('heatmap-density'); 50 | operators.add('line-progress'); 51 | operators.add('sky-radial-progress'); 52 | operators.add('accumulated'); 53 | operators.add('+'); 54 | operators.add('*'); 55 | operators.add('-'); 56 | operators.add('/'); 57 | operators.add('%'); 58 | operators.add('^'); 59 | operators.add('sqrt'); 60 | operators.add('log10'); 61 | operators.add('ln'); 62 | operators.add('log2'); 63 | operators.add('sin'); 64 | operators.add('cos'); 65 | operators.add('tan'); 66 | operators.add('asin'); 67 | operators.add('acos'); 68 | operators.add('atan'); 69 | operators.add('min'); 70 | operators.add('max'); 71 | operators.add('round'); 72 | operators.add('abs'); 73 | operators.add('ceil'); 74 | operators.add('floor'); 75 | operators.add('distance'); 76 | operators.add('=='); 77 | operators.add('!='); 78 | operators.add('>'); 79 | operators.add('<'); 80 | operators.add('>='); 81 | operators.add('<='); 82 | operators.add('all'); 83 | operators.add('any'); 84 | operators.add('!'); 85 | operators.add('within'); 86 | operators.add('is-supported-script'); 87 | operators.add('upcase'); 88 | operators.add('downcase'); 89 | operators.add('concat'); 90 | operators.add('resolved-locale'); 91 | 92 | export default operators; 93 | -------------------------------------------------------------------------------- /src/expression-to-formula.js: -------------------------------------------------------------------------------- 1 | import expressionOperators from './expression-operators'; 2 | 3 | const insertSpaceAfter = /("(?:[^\\"]|\\.)*")|[:,]/g; 4 | 5 | function stringifyLiteralArray(arr) { 6 | const items = arr.map(item => { 7 | if (Array.isArray(item)) return stringifyLiteralArray(item); 8 | return JSON.stringify(item); 9 | }); 10 | return `[${items.join(', ')}]`; 11 | } 12 | 13 | function isInfixOperator(operator) { 14 | if (operator === '!') return false; 15 | return /^[^a-zA-Z]/.test(operator); 16 | } 17 | 18 | export default function expressionToFormula(expression) { 19 | if (!Array.isArray(expression)) { 20 | throw new Error('Input must be an array'); 21 | } 22 | 23 | const operator = expression[0]; 24 | 25 | if (!expressionOperators.has(operator)) { 26 | return stringifyLiteralArray(expression); 27 | } 28 | 29 | if (operator === 'literal') { 30 | const arg = expression[1]; 31 | if (Array.isArray(arg)) { 32 | return `${operator}(${stringifyLiteralArray(arg)})`; 33 | } 34 | } 35 | 36 | const args = expression.slice(1).map(arg => { 37 | if (typeof arg === 'string') { 38 | return `"${arg}"`; 39 | } 40 | 41 | const isArray = Array.isArray(arg); 42 | if (typeof arg === 'object' && !!arg && !isArray) { 43 | // Insert spaces after commas/colons for better inline display. 44 | const pretty = JSON.stringify(arg).replace( 45 | insertSpaceAfter, 46 | (match, literal) => literal || match + ' ' 47 | ); 48 | return pretty; 49 | } 50 | if (!isArray) { 51 | return arg; 52 | } 53 | const argOperator = arg[0]; 54 | const argFormula = expressionToFormula(arg); 55 | if ( 56 | // Use parentheses to deal with operator precedence. 57 | (/^[+-]$/.test(argOperator) && /^[*/%]$/.test(operator)) || 58 | operator === '^' 59 | ) { 60 | return `(${argFormula})`; 61 | } 62 | return argFormula; 63 | }); 64 | 65 | if (operator === '^') { 66 | return `${args.join(operator)}`; 67 | } 68 | 69 | if (isInfixOperator(operator)) { 70 | return `${args.join(` ${operator} `)}`; 71 | } 72 | 73 | if (operator === 'concat') { 74 | return args.join(' & '); 75 | } 76 | 77 | return `${operator}(${args.join(', ')})`; 78 | } 79 | -------------------------------------------------------------------------------- /src/formula-to-expression.js: -------------------------------------------------------------------------------- 1 | import jsep from 'jsep'; 2 | import jsepObjectPlugin from '@jsep-plugin/object'; 3 | import handleSyntaxErrors from './handle-syntax-errors'; 4 | 5 | // GL expressions use ^ for exponentiation, while JS uses **. 6 | // 15 is precedence of ** operator in JS. 7 | jsep.addBinaryOp('^', 15); 8 | 9 | jsep.plugins.register(jsepObjectPlugin); 10 | 11 | function handleLiteralArgument(arg) { 12 | switch (arg.type) { 13 | case 'Literal': 14 | return arg.value; 15 | case 'ArrayExpression': 16 | return arg.elements.map(handleLiteralArgument); 17 | case 'ObjectExpression': { 18 | const object = {}; 19 | arg.properties.forEach(property => { 20 | const k = handleLiteralArgument(property.key); 21 | const v = handleLiteralArgument(property.value); 22 | object[k] = v; 23 | }); 24 | return object; 25 | } 26 | default: 27 | throw handleSyntaxErrors(new Error('Invalid syntax')); 28 | } 29 | } 30 | 31 | function astToExpression(input) { 32 | if (input.type === 'Identifier') { 33 | throw handleSyntaxErrors(new Error('Unexpected identifier')); 34 | } 35 | 36 | if (input.value !== undefined) return input.value; 37 | if (input.name !== undefined) return input.name; 38 | 39 | let expressionOperator; 40 | let expressionArguments = []; 41 | 42 | if (input.type === 'ArrayExpression') { 43 | return input.elements.map(astToExpression); 44 | } 45 | 46 | // Collect all properties into a single object 47 | if (input.type === 'ObjectExpression') { 48 | const object = {}; 49 | input.properties.forEach(property => { 50 | const k = astToExpression(property.key); 51 | const v = astToExpression(property.value); 52 | object[k] = v; 53 | }); 54 | return object; 55 | } 56 | 57 | if (input.type === 'UnaryExpression') { 58 | expressionOperator = input.operator; 59 | expressionArguments.push(astToExpression(input.argument)); 60 | } 61 | 62 | if (input.type === 'BinaryExpression') { 63 | expressionOperator = input.operator === '&' ? 'concat' : input.operator; 64 | 65 | // Collapse concat arguments, in case the & operator was used to join 66 | // more than two successive strings. 67 | const addBinaryArgument = arg => { 68 | if ( 69 | expressionOperator === 'concat' && 70 | Array.isArray(arg) && 71 | arg[0] === 'concat' 72 | ) { 73 | expressionArguments = expressionArguments.concat(arg.slice(1)); 74 | } else { 75 | expressionArguments.push(arg); 76 | } 77 | }; 78 | addBinaryArgument(astToExpression(input.left)); 79 | addBinaryArgument(astToExpression(input.right)); 80 | } 81 | 82 | if (input.type === 'CallExpression') { 83 | expressionOperator = input.callee.name; 84 | 85 | if (expressionOperator === 'literal') { 86 | expressionArguments = expressionArguments.concat( 87 | input.arguments.map(handleLiteralArgument) 88 | ); 89 | } else { 90 | input.arguments.forEach(i => { 91 | expressionArguments.push(astToExpression(i)); 92 | }); 93 | } 94 | } 95 | 96 | // Change undescores in expression operators to hyphens, reversing the 97 | // transformation below. 98 | if (/[a-z]+_[a-z]+/.test(expressionOperator)) { 99 | expressionOperator = expressionOperator.replace( 100 | /([a-z]+)_([a-z]+)/, 101 | '$1-$2' 102 | ); 103 | } 104 | 105 | return [expressionOperator].concat(expressionArguments); 106 | } 107 | 108 | function formulaToExpression(input) { 109 | if (input === '' || input === undefined) { 110 | return; 111 | } 112 | 113 | if (typeof input !== 'string') { 114 | throw new Error('input must be a string'); 115 | } 116 | 117 | // Change hyphens in expression operators to underscores. This allows JS 118 | // parsing to work, but then needs to be reversed above. 119 | input = input.replace(/([a-z]+)-([a-z]+)\(/, '$1_$2('); 120 | 121 | let ast; 122 | try { 123 | ast = jsep(input); 124 | } catch (syntaxError) { 125 | throw handleSyntaxErrors(syntaxError, input); 126 | } 127 | 128 | const expression = astToExpression(ast); 129 | return expression; 130 | } 131 | 132 | export default formulaToExpression; 133 | -------------------------------------------------------------------------------- /src/handle-syntax-errors.js: -------------------------------------------------------------------------------- 1 | function handleSyntaxErrors(error, input = '') { 2 | const newError = new Error('Syntax error'); 3 | newError.type = 'SyntaxError'; 4 | newError.index = error.index; 5 | newError.description = error.description || error.message; 6 | 7 | if (/expression/.test(newError.description)) { 8 | newError.description = newError.description.replace('expression', 'value'); 9 | } 10 | 11 | if (newError.description === 'Unexpected ') { 12 | newError.description = 'Unexpected input'; 13 | } 14 | 15 | if (/literal\(\s*{/.test(input)) { 16 | newError.description = 17 | 'Only array arguments are supported for the literal expression'; 18 | } 19 | 20 | return newError; 21 | } 22 | 23 | export default handleSyntaxErrors; 24 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import formulaToExpression from './formula-to-expression'; 2 | import expressionToFormula from './expression-to-formula'; 3 | 4 | export default { 5 | formulaToExpression, 6 | expressionToFormula 7 | }; 8 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/expression-to-formula.test.js: -------------------------------------------------------------------------------- 1 | import expressionToFormula from '../src/expression-to-formula'; 2 | 3 | test('3 + 4', () => { 4 | const actual = expressionToFormula(['+', 3, 4]); 5 | expect(actual).toBe('3 + 4'); 6 | }); 7 | 8 | test('(3 + 4) / 7', () => { 9 | const actual = expressionToFormula(['/', ['+', 3, 4], 7]); 10 | expect(actual).toBe('(3 + 4) / 7'); 11 | }); 12 | 13 | test('(3 + 4) * 2 / 7', () => { 14 | const actual = expressionToFormula(['/', ['*', ['+', 3, 4], 2], 7]); 15 | expect(actual).toBe('(3 + 4) * 2 / 7'); 16 | }); 17 | 18 | test('log2(3)', () => { 19 | const actual = expressionToFormula(['log2', 3]); 20 | expect(actual).toBe('log2(3)'); 21 | }); 22 | 23 | test('log2((3 + 4) / 7)', () => { 24 | const actual = expressionToFormula(['log2', ['/', ['+', 3, 4], 7]]); 25 | expect(actual).toBe('log2((3 + 4) / 7)'); 26 | }); 27 | 28 | test('min(2, 4, 6)', () => { 29 | const actual = expressionToFormula(['min', 2, 4, 6]); 30 | expect(actual).toBe('min(2, 4, 6)'); 31 | }); 32 | 33 | test('max(2, 4, 6)', () => { 34 | const actual = expressionToFormula(['max', 2, 4, 6]); 35 | expect(actual).toBe('max(2, 4, 6)'); 36 | }); 37 | 38 | test('max(2, 4, (3 + 4) / 7)', () => { 39 | const actual = expressionToFormula(['max', 2, 4, ['/', ['+', 3, 4], 7]]); 40 | expect(actual).toBe('max(2, 4, (3 + 4) / 7)'); 41 | }); 42 | 43 | test('max(3, log2(6))', () => { 44 | const actual = expressionToFormula(['max', 3, ['log2', 6]]); 45 | expect(actual).toBe('max(3, log2(6))'); 46 | }); 47 | 48 | test(`"there are " & get("population") & " people " & upcase("here " & "not there")`, () => { 49 | const actual = expressionToFormula([ 50 | 'concat', 51 | 'there are ', 52 | ['get', 'population'], 53 | ' people ', 54 | ['upcase', ['concat', 'here ', 'not there']] 55 | ]); 56 | expect(actual).toBe( 57 | `"there are " & get("population") & " people " & upcase("here " & "not there")` 58 | ); 59 | }); 60 | 61 | test('3 % 2', () => { 62 | const actual = expressionToFormula(['%', 3, 2]); 63 | expect(actual).toBe('3 % 2'); 64 | }); 65 | 66 | test('(3 + 2) % 2', () => { 67 | const actual = expressionToFormula(['%', ['+', 3, 2], 2]); 68 | expect(actual).toBe('(3 + 2) % 2'); 69 | }); 70 | 71 | test('3^2', () => { 72 | const actual = expressionToFormula(['^', 3, 2]); 73 | expect(actual).toBe('3^2'); 74 | }); 75 | 76 | test('3^2 + 3', () => { 77 | const actual = expressionToFormula(['+', ['^', 3, 2], 1]); 78 | expect(actual).toBe('3^2 + 1'); 79 | }); 80 | 81 | test('3^(2 + 1)', () => { 82 | const actual = expressionToFormula(['^', 3, ['+', 2, 1]]); 83 | expect(actual).toBe('3^(2 + 1)'); 84 | }); 85 | 86 | test('to-number(get("miles")) & " miles"', () => { 87 | const actual = expressionToFormula([ 88 | 'concat', 89 | ['to-number', ['get', 'miles']], 90 | ' miles' 91 | ]); 92 | expect(actual).toBe('to-number(get("miles")) & " miles"'); 93 | }); 94 | 95 | test('3 * length(get("len"))', () => { 96 | const actual = expressionToFormula(['*', 3, ['length', ['get', 'len']]]); 97 | expect(actual).toBe('3 * length(get("len"))'); 98 | }); 99 | 100 | test('literal([1, 2])', () => { 101 | const actual = expressionToFormula(['literal', [1, 2]]); 102 | expect(actual).toBe('literal([1, 2])'); 103 | }); 104 | 105 | test('literal([1, [2, 3]])', () => { 106 | const actual = expressionToFormula(['literal', [1, [2, 3]]]); 107 | expect(actual).toBe('literal([1, [2, 3]])'); 108 | }); 109 | 110 | test('literal(["foo", "bar"])', () => { 111 | const actual = expressionToFormula(['literal', ['foo', 'bar']]); 112 | expect(actual).toBe('literal(["foo", "bar"])'); 113 | }); 114 | 115 | test('coalesce(literal(["foo", ["bar", "baz"]]))', () => { 116 | const actual = expressionToFormula([ 117 | 'coalesce', 118 | ['literal', ['foo', ['bar', 'baz']]] 119 | ]); 120 | expect(actual).toBe('coalesce(literal(["foo", ["bar", "baz"]]))'); 121 | }); 122 | 123 | test('literal({foo: 1, bar: 2})', () => { 124 | const actual = expressionToFormula(['literal', { foo: 1, bar: 2 }]); 125 | expect(actual).toBe('literal({"foo": 1, "bar": 2})'); 126 | }); 127 | 128 | test('literal({"boolean": true, "string": "false"})', () => { 129 | const actual = expressionToFormula([ 130 | 'literal', { boolean: true, string: 'false' } 131 | ]); 132 | expect(actual).toBe('literal({"boolean": true, "string": "false"})'); 133 | }); 134 | 135 | test('literal({"nested": {"also-nested": "bees"}})', () => { 136 | const actual = expressionToFormula([ 137 | 'literal', { nested: { 'also-nested': 'bees' } } 138 | ]); 139 | expect(actual).toBe('literal({"nested": {"also-nested": "bees"}})'); 140 | }); 141 | 142 | test('literal({",quoted:,separators": ":,}{"})', () => { 143 | const actual = expressionToFormula([ 144 | 'literal', { ',quoted:,separators': ':,}{' } 145 | ]); 146 | expect(actual).toBe('literal({",quoted:,separators": ":,}{"})'); 147 | }); 148 | 149 | test('literal("hsl(") & literal("235") & literal(", 75%, 50%)")', () => { 150 | const actual = expressionToFormula([ 151 | 'concat', 152 | ['literal', 'hsl('], 153 | ['literal', '235'], 154 | ['literal', ',75%,50%)'] 155 | ]); 156 | expect(actual).toBe('literal("hsl(") & literal("235") & literal(",75%,50%)")'); 157 | }); 158 | 159 | test('literal("国立競技場")', () => { 160 | const actual = expressionToFormula(["literal", "国立競技場"]); 161 | expect(actual).toBe('literal("国立競技場")'); 162 | }); 163 | 164 | test('3 != 4', () => { 165 | const actual = expressionToFormula(['!=', 3, 4]); 166 | expect(actual).toEqual('3 != 4'); 167 | }); 168 | 169 | test('3 * 4 + 1 != 4 - 3 / 2', () => { 170 | const actual = expressionToFormula([ 171 | '!=', 172 | ['+', ['*', 3, 4], 1], 173 | ['-', 4, ['/', 3, 2]] 174 | ]); 175 | expect(actual).toBe('3 * 4 + 1 != 4 - 3 / 2'); 176 | }); 177 | 178 | test('(3 != 4) == true', () => { 179 | const actual = expressionToFormula(['==', ['!=', 3, 4], true]); 180 | expect(actual).toBe('3 != 4 == true'); 181 | }); 182 | 183 | test('!(get("x"))', () => { 184 | const actual = expressionToFormula(['!', ['get', 'x']]); 185 | expect(actual).toBe('!(get("x"))'); 186 | }); 187 | 188 | test('case(get("foo") <= 4, 6, 2 == 2, 3, 1)', () => { 189 | const actual = expressionToFormula([ 190 | 'case', 191 | ['<=', ['get', 'foo'], 4], 192 | 6, 193 | ['==', 2, 2], 194 | 3, 195 | 1 196 | ]); 197 | expect(actual).toEqual('case(get("foo") <= 4, 6, 2 == 2, 3, 1)'); 198 | }); 199 | 200 | test('match(get("scalerank"), [1, 2], 13, [3, 4], 11, 9)', () => { 201 | const actual = expressionToFormula([ 202 | 'match', 203 | ['get', 'scalerank'], 204 | [1, 2], 205 | 13, 206 | [3, 4], 207 | 11, 208 | 9 209 | ]); 210 | expect(actual).toEqual('match(get("scalerank"), [1, 2], 13, [3, 4], 11, 9)'); 211 | }); 212 | 213 | test('["what", "is", "this", [1, 2]]', () => { 214 | const actual = expressionToFormula(['what', 'is', 'this', [1, 2]]); 215 | expect(actual).toEqual('["what", "is", "this", [1, 2]]'); 216 | }); 217 | -------------------------------------------------------------------------------- /test/formula-to-expression.test.js: -------------------------------------------------------------------------------- 1 | import formulaToExpression from '../src/formula-to-expression'; 2 | 3 | test('empty input', () => { 4 | expect(formulaToExpression()).toBeUndefined(); 5 | expect(formulaToExpression('')).toBeUndefined(); 6 | }); 7 | 8 | describe('literals', () => { 9 | test('7', () => { 10 | const actual = formulaToExpression('7'); 11 | expect(actual).toBe(7); 12 | }); 13 | 14 | test('77.323', () => { 15 | const actual = formulaToExpression('77.323'); 16 | expect(actual).toBe(77.323); 17 | }); 18 | 19 | test('"sing"', () => { 20 | const actual = formulaToExpression('"sing"'); 21 | expect(actual).toBe('sing'); 22 | }); 23 | 24 | test('true', () => { 25 | const actual = formulaToExpression('true'); 26 | expect(actual).toBe(true); 27 | }); 28 | 29 | test('false', () => { 30 | const actual = formulaToExpression('false'); 31 | expect(actual).toBe(false); 32 | }); 33 | }); 34 | 35 | describe('formulas', () => { 36 | test('-3', () => { 37 | const actual = formulaToExpression('-3'); 38 | expect(actual).toEqual(['-', 3]); 39 | }); 40 | 41 | test('-(3 + 4)', () => { 42 | const actual = formulaToExpression('-(3 + 4)'); 43 | expect(actual).toEqual(['-', ['+', 3, 4]]); 44 | }); 45 | 46 | test('3 + 4', () => { 47 | const actual = formulaToExpression('3 + 4'); 48 | expect(actual).toEqual(['+', 3, 4]); 49 | }); 50 | 51 | test('(3 + 4) / 7', () => { 52 | const actual = formulaToExpression('(3 + 4) / 7'); 53 | expect(actual).toEqual(['/', ['+', 3, 4], 7]); 54 | }); 55 | 56 | test('((3 + 4) * 2) / 7', () => { 57 | const actual = formulaToExpression('((3 + 4) * 2) / 7'); 58 | expect(actual).toEqual(['/', ['*', ['+', 3, 4], 2], 7]); 59 | }); 60 | 61 | test('sing()', () => { 62 | const actual = formulaToExpression('sing()'); 63 | expect(actual).toEqual(['sing']); 64 | }); 65 | 66 | test('log2(3)', () => { 67 | const actual = formulaToExpression('log2(3)'); 68 | expect(actual).toEqual(['log2', 3]); 69 | }); 70 | 71 | test('log2((3 + 4) / 7)', () => { 72 | const actual = formulaToExpression('log2((3 + 4) / 7)'); 73 | expect(actual).toEqual(['log2', ['/', ['+', 3, 4], 7]]); 74 | }); 75 | 76 | test('min(2, 4, 6)', () => { 77 | const actual = formulaToExpression('min(2, 4, 6)'); 78 | expect(actual).toEqual(['min', 2, 4, 6]); 79 | }); 80 | 81 | test('max(2, 4, 6)', () => { 82 | const actual = formulaToExpression('max(2, 4, 6)'); 83 | expect(actual).toEqual(['max', 2, 4, 6]); 84 | }); 85 | 86 | test('max(2, 4, ((3 + 4) / 7))', () => { 87 | const actual = formulaToExpression('max(2, 4, ((3 + 4) / 7))'); 88 | expect(actual).toEqual(['max', 2, 4, ['/', ['+', 3, 4], 7]]); 89 | }); 90 | 91 | test('max(3, log2(6))', () => { 92 | const actual = formulaToExpression('max(3, log2(6))'); 93 | expect(actual).toEqual(['max', 3, ['log2', 6]]); 94 | }); 95 | 96 | test(`"there are " & get("population") & " people " & upper("here " & "not there")`, () => { 97 | const actual = formulaToExpression( 98 | `"there are " & get("population") & " people " & upper("here " & "not there")` 99 | ); 100 | expect(actual).toEqual([ 101 | 'concat', 102 | 'there are ', 103 | ['get', 'population'], 104 | ' people ', 105 | ['upper', ['concat', 'here ', 'not there']] 106 | ]); 107 | }); 108 | 109 | test(`concat("there are ", get("population"), " people ", upper(concat("here ", "not there")))`, () => { 110 | const actual = formulaToExpression( 111 | `concat("there are ", get("population"), " people ", upper(concat("here ", "not there")))` 112 | ); 113 | expect(actual).toEqual([ 114 | 'concat', 115 | 'there are ', 116 | ['get', 'population'], 117 | ' people ', 118 | ['upper', ['concat', 'here ', 'not there']] 119 | ]); 120 | }); 121 | 122 | test('3 % 2', () => { 123 | const actual = formulaToExpression('3 % 2'); 124 | expect(actual).toEqual(['%', 3, 2]); 125 | }); 126 | 127 | test('(3 + 2) % 2', () => { 128 | const actual = formulaToExpression('(3 + 2) % 2'); 129 | expect(actual).toEqual(['%', ['+', 3, 2], 2]); 130 | }); 131 | 132 | test('3^2', () => { 133 | const actual = formulaToExpression('3^2'); 134 | expect(actual).toEqual(['^', 3, 2]); 135 | }); 136 | 137 | test('3^2 + 1', () => { 138 | const actual = formulaToExpression('3^2 + 1'); 139 | expect(actual).toEqual(['+', ['^', 3, 2], 1]); 140 | }); 141 | 142 | test('3^(2 + 1)', () => { 143 | const actual = formulaToExpression('3^(2 + 1)'); 144 | expect(actual).toEqual(['^', 3, ['+', 2, 1]]); 145 | }); 146 | 147 | test('to-number(get("miles")) & " miles"', () => { 148 | const actual = formulaToExpression('to-number(get("miles")) & " miles"'); 149 | expect(actual).toEqual([ 150 | 'concat', 151 | ['to-number', ['get', 'miles']], 152 | ' miles' 153 | ]); 154 | }); 155 | 156 | test('3 * length(get("len"))', () => { 157 | const actual = formulaToExpression('3 * length(get("len"))'); 158 | expect(actual).toEqual(['*', 3, ['length', ['get', 'len']]]); 159 | }); 160 | 161 | test('literal("hello")', () => { 162 | const actual = formulaToExpression('literal("hello")'); 163 | expect(actual).toEqual(['literal', 'hello']); 164 | }); 165 | 166 | test('literal(1000)', () => { 167 | const actual = formulaToExpression('literal(1000)'); 168 | expect(actual).toEqual(['literal', 1000]); 169 | }); 170 | 171 | test('literal(false)', () => { 172 | const actual = formulaToExpression('literal(false)'); 173 | expect(actual).toEqual(['literal', false]); 174 | }); 175 | 176 | test('literal([1, 2])', () => { 177 | const actual = formulaToExpression('literal([1, 2])'); 178 | expect(actual).toEqual(['literal', [1, 2]]); 179 | }); 180 | 181 | test('literal([1, [2, 3]])', () => { 182 | const actual = formulaToExpression('literal([1, [2, 3]])'); 183 | expect(actual).toEqual(['literal', [1, [2, 3]]]); 184 | }); 185 | 186 | test('literal(["foo", "bar"])', () => { 187 | const actual = formulaToExpression('literal(["foo", "bar"])'); 188 | expect(actual).toEqual(['literal', ['foo', 'bar']]); 189 | }); 190 | 191 | test('coalesce(literal(["foo", ["bar", "baz"]]))', () => { 192 | const actual = formulaToExpression( 193 | 'coalesce(literal(["foo", ["bar", "baz"]]))' 194 | ); 195 | expect(actual).toEqual(['coalesce', ['literal', ['foo', ['bar', 'baz']]]]); 196 | }); 197 | 198 | test('literal({ "foo": 1, "bar": 2 })', () => { 199 | const actual = formulaToExpression('literal({ "foo": 1, "bar": 2 })'); 200 | expect(actual).toEqual(['literal', { foo: 1, bar: 2 }]); 201 | }); 202 | 203 | test('literal("hsl(") & literal("235") & literal(",75%,50%)")', () => { 204 | const actual = formulaToExpression( 205 | 'literal("hsl(") & literal("235") & literal(",75%,50%)")' 206 | ); 207 | expect(actual).toEqual([ 208 | 'concat', 209 | ['literal', 'hsl('], 210 | ['literal', '235'], 211 | ['literal', ',75%,50%)'] 212 | ]); 213 | }); 214 | 215 | test('3 != 4', () => { 216 | const actual = formulaToExpression('3 != 4'); 217 | expect(actual).toEqual(['!=', 3, 4]); 218 | }); 219 | 220 | test('3 * 4 + 1 != 4 - 3 / 2', () => { 221 | const actual = formulaToExpression('3 * 4 + 1 != 4 - 3 / 2'); 222 | expect(actual).toEqual([ 223 | '!=', 224 | ['+', ['*', 3, 4], 1], 225 | ['-', 4, ['/', 3, 2]] 226 | ]); 227 | }); 228 | 229 | test('(3 != 4) == true', () => { 230 | const actual = formulaToExpression('(3 != 4) == true'); 231 | expect(actual).toEqual(['==', ['!=', 3, 4], true]); 232 | }); 233 | 234 | test('!(get("x"))', () => { 235 | const actual = formulaToExpression('!(get("x"))'); 236 | expect(actual).toEqual(['!', ['get', 'x']]); 237 | }); 238 | 239 | test('case(get("foo") <= 4, 6, 2 == 2, 3, 1)', () => { 240 | const actual = formulaToExpression( 241 | 'case(get("foo") <= 4, 6, 2 == 2, 3, 1)' 242 | ); 243 | expect(actual).toEqual([ 244 | 'case', 245 | ['<=', ['get', 'foo'], 4], 246 | 6, 247 | ['==', 2, 2], 248 | 3, 249 | 1 250 | ]); 251 | }); 252 | 253 | test('match(get("scalerank"), [1, 2], 13, [3, 4], 11, 9)', () => { 254 | const actual = formulaToExpression( 255 | 'match(get("scalerank"), [1, 2], 13, [3, 4], 11, 9)' 256 | ); 257 | expect(actual).toEqual([ 258 | 'match', 259 | ['get', 'scalerank'], 260 | [1, 2], 261 | 13, 262 | [3, 4], 263 | 11, 264 | 9 265 | ]); 266 | }); 267 | 268 | test('number-format(1.005, {"max-fraction-digits": 2, "min-fraction-digits": 2})', () => { 269 | const actual = formulaToExpression( 270 | 'number-format(1.005, {"max-fraction-digits": 2, "min-fraction-digits": 2})' 271 | ); 272 | expect(actual).toEqual([ 273 | 'number-format', 274 | 1.005, 275 | { 276 | 'max-fraction-digits': 2, 277 | 'min-fraction-digits': 2 278 | } 279 | ]); 280 | }); 281 | 282 | test('[{"nested": get("population"), "nestedLiteral": literal([1, 2, 3])}]', () => { 283 | const actual = formulaToExpression( 284 | '[{"nested": get("population"), "nestedLiteral": literal([1, 2, 3])}]' 285 | ); 286 | expect(actual).toEqual([{ 287 | nested: ['get', 'population'], 288 | nestedLiteral: ['literal', [1, 2, 3]] 289 | }]); 290 | }); 291 | 292 | test('{"nested": {"also-nested": "bees"}}', () => { 293 | const actual = formulaToExpression('{"nested": {"also-nested": "bees"}}'); 294 | expect(actual).toEqual({ 295 | nested: { 296 | 'also-nested': 'bees' 297 | } 298 | }); 299 | }); 300 | 301 | test('["what", "is", "this", [1, 2]]', () => { 302 | const actual = formulaToExpression('["what", "is", "this", [1, 2]]'); 303 | expect(actual).toEqual(['what', 'is', 'this', [1, 2]]); 304 | }); 305 | }); 306 | 307 | describe('syntax errors', () => { 308 | test('3 +', () => { 309 | const actual = () => { 310 | formulaToExpression('3 +'); 311 | }; 312 | expect(actual).toThrow('Syntax error'); 313 | 314 | expect.hasAssertions(); 315 | try { 316 | formulaToExpression('3 +'); 317 | } catch (error) { 318 | expect(error.type).toBe('SyntaxError'); 319 | expect(error.index).toBe(3); 320 | expect(error.description).toBe('Expected value after +'); 321 | } 322 | }); 323 | 324 | test('e.', () => { 325 | const actual = () => { 326 | formulaToExpression('e.'); 327 | }; 328 | expect(actual).toThrow('Syntax error'); 329 | 330 | expect.hasAssertions(); 331 | try { 332 | formulaToExpression('e.'); 333 | } catch (error) { 334 | expect(error.type).toBe('SyntaxError'); 335 | expect(error.index).toBe(2); 336 | expect(error.description).toBe('Unexpected input'); 337 | } 338 | }); 339 | 340 | test('sing', () => { 341 | const actual = () => { 342 | formulaToExpression('sing'); 343 | }; 344 | expect(actual).toThrow('Syntax error'); 345 | 346 | expect.hasAssertions(); 347 | try { 348 | formulaToExpression('sing'); 349 | } catch (error) { 350 | expect(error.type).toBe('SyntaxError'); 351 | expect(error.index).toBe(undefined); 352 | expect(error.description).toBe('Unexpected identifier'); 353 | } 354 | }); 355 | 356 | test('concat(foo, bar)', () => { 357 | const actual = () => { 358 | formulaToExpression('concat(foo, bar)'); 359 | }; 360 | expect(actual).toThrow('Syntax error'); 361 | 362 | expect.hasAssertions(); 363 | try { 364 | formulaToExpression('concat(foo, bar)'); 365 | } catch (error) { 366 | expect(error.type).toBe('SyntaxError'); 367 | expect(error.index).toBe(undefined); 368 | expect(error.description).toBe('Unexpected identifier'); 369 | } 370 | }); 371 | }); 372 | --------------------------------------------------------------------------------