@airtasker/form-schema-compiler
Version:
a form schema compiler
412 lines (370 loc) • 10.5 kB
JavaScript
/* eslint-disable no-use-before-define */
import flow from "lodash/flow";
import {
OPERATORS,
PRECEDENCE,
PUNCTUATIONS,
TYPES,
IF_KEYWORDS
} from "../const";
import * as utils from "./utils";
/**
* transpiling token stream to abstract syntax tree
* abstract syntax tree https://en.wikipedia.org/wiki/Abstract_syntax_tree
* a recursive descent parser https://en.wikipedia.org/wiki/Recursive_descent_parser
* There are a lot of ways to write parser, like LL parser LR parser.
* recursive descent parser is the easiest way to write.
* Because we are building a very simple parser, and need it run in browser, so we chosen using recursive descent way to write.
*
* e.g.
* [{type: operator, value: '-'}, {type: numeric, value: 1}]
* to
* {type: UnaryExpression, operator: '-', argument: {type: numeric, value: 1}}
* @param tokenStream
*/
const parseExpressionTokenStream = tokenStream => {
const isKeyword = keyword => utils.isKeyword(tokenStream.peek(), keyword);
const isPunctuation = paren => utils.isPunctuation(tokenStream.peek(), paren);
const isOperator = operator => utils.isOperator(tokenStream.peek(), operator);
const isUnary = () =>
isOperator(OPERATORS.Not) ||
isOperator(OPERATORS.Add) ||
isOperator(OPERATORS.Subtract);
const atomTokenTypes = [
TYPES.Identifier,
TYPES.Numeric,
TYPES.Null,
TYPES.String,
TYPES.RegExp,
TYPES.Boolean
];
/**
* skip next punctuation, if next char is not punctuation, throw error
* @param ch
*/
const skipPunctuation = ch => {
if (!isPunctuation(ch)) {
tokenStream.croak(`Unexpected token: "${JSON.stringify(tokenStream.peek())}, "Expecting punctuation: "${ch}"`);
}
tokenStream.next();
};
const skipKeyword = keyword => {
if (!isKeyword(keyword)) {
tokenStream.croak(`Unexpected token: "${JSON.stringify(tokenStream.peek())}, Expecting keyword: "${keyword}"`);
}
tokenStream.next();
};
/**
* delimited
* parse tokens between start, stop, end.
* if char is not match start, stop or end, throw an error.
* @param start start char, throw exception when not match
* @param stop stop char, throw exception when not match
* @param separator parser separator throw exception when not match
* @param parser parser
* @returns {Array}
*/
const delimited = (start, stop, separator, parser) => {
const args = [];
let first = true;
skipPunctuation(start);
while (!tokenStream.eof()) {
if (isPunctuation(stop)) {
break;
}
if (first) {
first = false;
} else {
skipPunctuation(separator);
}
if (isPunctuation(stop)) {
break;
}
args.push(parser());
}
skipPunctuation(stop);
return args;
};
const maybeCallOrMember = flow(
maybeCall,
maybeMember
);
/**
* return a call expression if next token is '('
* @param callee
* @returns {*}
*/
function maybeCall(callee) {
if (isPunctuation(PUNCTUATIONS.Parentheses[0])) {
return maybeCallOrMember(parseCall(callee));
}
return callee;
}
/**
* return a member expression if next token is '['
* @param object
*/
function maybeMember(object) {
if (isPunctuation(PUNCTUATIONS.SquareBrackets[0])) {
return maybeCallOrMember(parseMember(object));
}
return object;
}
/**
* return an unary expression if current token is -+!
* @param expr
* @returns {*}
*/
function maybeUnary(expr) {
const token = isUnary();
if (token) {
tokenStream.next();
return {
type: TYPES.UnaryExpression,
operator: token.value,
argument: maybeUnary(expr)
};
}
return expr();
}
/**
* return binary expression if next token is an operator
* @param left
* @param leftOpPrec
* @returns {*}
*/
function maybeBinary(left, leftOpPrec = 0) {
const token = isOperator();
if (token) {
const rightOpPrec = PRECEDENCE[token.value];
const isAssign = token.value === OPERATORS.Assign;
if (rightOpPrec > leftOpPrec) {
if (isAssign && left.type !== TYPES.Identifier) {
tokenStream.croak(
`You can only assign to an identifier "${JSON.stringify(left)}"`
);
}
tokenStream.next();
const right = maybeBinary(parseAtom(), rightOpPrec);
const binary = {
type: isAssign ? TYPES.AssignExpression : TYPES.BinaryExpression,
operator: token.value,
left,
right
};
return maybeBinary(binary, leftOpPrec);
}
}
return left;
}
/**
* parse call with arguments
* @param callee
* @returns {{type: 'CallExpression', callee: callee, arguments: [...]}}
*/
function parseCall(callee) {
return {
type: TYPES.CallExpression,
callee,
arguments: delimited(
PUNCTUATIONS.Parentheses[0], // (
PUNCTUATIONS.Parentheses[1], // )
PUNCTUATIONS.Separator, // ,
parseExpression
)
};
}
/**
* parse object
* @returns {{type: 'ObjectExpression', properties: [...]}}
*/
function parseObject() {
return {
type: TYPES.ObjectExpression,
properties: delimited(
PUNCTUATIONS.Braces[0], // {
PUNCTUATIONS.Braces[1], // }
PUNCTUATIONS.Separator, // ,
parseObjectProperty // should have property key : value
)
};
}
function parseObjectProperty() {
const key = parseAtom();
if (![TYPES.Identifier, TYPES.String, TYPES.Numeric].includes(key.type)) {
tokenStream.croak(
`Object key should only be identifier, string or number, instead of "${
key.value
}:${key.type}"`
);
}
skipPunctuation(PUNCTUATIONS.Colon);
return {
key,
value: parseExpression()
};
}
/**
* parse array
* @returns {{type: 'ArrayExpression', elements: [...]}}
*/
function parseArray() {
return {
type: TYPES.ArrayExpression,
elements: delimited(
PUNCTUATIONS.SquareBrackets[0], // [
PUNCTUATIONS.SquareBrackets[1], // ]
PUNCTUATIONS.Separator, // ,
parseExpression // should have property key : value
)
};
}
function parseMember(object) {
skipPunctuation(PUNCTUATIONS.SquareBrackets[0]);
const property = parseExpression();
skipPunctuation(PUNCTUATIONS.SquareBrackets[1]);
return {
type: TYPES.MemberExpression,
object,
property
};
}
function checkIfNeedAddDummyQuasis(expressions, quasis) {
if (quasis.length <= expressions.length) {
quasis.push({
type: TYPES.String,
value: ""
});
}
}
function parseTemplateLiteral() {
skipPunctuation(PUNCTUATIONS.BackQuote);
const expressions = [];
const quasis = [];
while (!isPunctuation(PUNCTUATIONS.BackQuote)) {
if (isPunctuation(PUNCTUATIONS.Braces[0])) {
skipPunctuation(PUNCTUATIONS.Braces[0]);
checkIfNeedAddDummyQuasis(expressions, quasis);
expressions.push(parseExpression());
skipPunctuation(PUNCTUATIONS.Braces[1]);
} else {
quasis.push(tokenStream.next());
}
}
checkIfNeedAddDummyQuasis(expressions, quasis);
skipPunctuation(PUNCTUATIONS.BackQuote);
return {
type: TYPES.TemplateLiteral,
expressions,
quasis
};
}
/**
* parse next expression
* @returns {Expression}
*/
function parseExpression() {
return maybeBinary(parseAtom(), 0);
}
/**
* parse single expression, could be a call expression, an unary expression, an identifier or an expression inside a parentheses
* @returns {Expression}
*/
function parseAtom(skipUnaryCheck = false) {
return maybeUnary(() => maybeCallOrMember(parseSimpleAtom()));
}
function parseProgram() {
const body = [];
while (!tokenStream.eof()) {
body.push(parseExpression());
if (tokenStream.eof()) {
// skip semi colon check if tokenStream is end;
break;
}
skipPunctuation(PUNCTUATIONS.SemiColon);
}
return {
type: TYPES.Program,
body
};
}
function parseBlockStatement() {
return {
type: TYPES.BlockStatement,
body: delimited(
PUNCTUATIONS.Braces[0], // {
PUNCTUATIONS.Braces[1], // }
PUNCTUATIONS.SemiColon, // ;
parseExpression
)
};
}
function parseIfStatement() {
skipKeyword(IF_KEYWORDS.If);
const test = parseExpression();
let consequent = null;
let alternate = null;
if (isKeyword(IF_KEYWORDS.Then)) {
skipKeyword(IF_KEYWORDS.Then);
if (isPunctuation(PUNCTUATIONS.Braces[0])) {
consequent = parseBlockStatement();
} else {
consequent = parseExpression();
}
}
if (isKeyword(IF_KEYWORDS.Else)) {
skipKeyword(IF_KEYWORDS.Else);
if (isPunctuation(PUNCTUATIONS.Braces[0])) {
alternate = parseBlockStatement();
} else {
alternate = parseExpression();
}
}
return {
type: TYPES.IfStatement,
test,
consequent,
alternate
};
}
/**
* parse a simple atom, e.g identifier, number, string, object, array, boolean, etc
* @returns {Expression}
*/
function parseSimpleAtom() {
if (isPunctuation(PUNCTUATIONS.Parentheses[0])) {
// if it reads parentheses, then will parse the expression inside the parentheses
tokenStream.next();
const exp = parseExpression();
skipPunctuation(PUNCTUATIONS.Parentheses[1]);
return exp;
}
if (isPunctuation(PUNCTUATIONS.Braces[0])) {
// if it reads braces start, then it's an Object
return parseObject();
}
if (isPunctuation(PUNCTUATIONS.SquareBrackets[0])) {
// if it reads square brackets start, then it's an Array
return parseArray();
}
if (isPunctuation(PUNCTUATIONS.BackQuote)) {
// if it reads back quote, then it's a Template Literal
return parseTemplateLiteral();
}
if (isKeyword(IF_KEYWORDS.If)) {
return parseIfStatement();
}
const token = tokenStream.next();
if (atomTokenTypes.includes(token.type)) {
return token;
}
unexpected(token);
}
function unexpected(token = undefined) {
tokenStream.croak(
`Unexpected token: ${JSON.stringify(token || tokenStream.peek())}`
);
}
return parseProgram();
};
export default parseExpressionTokenStream;