diff --git a/src/ast.js b/src/ast.js index cdaad4ea..ce95227c 100644 --- a/src/ast.js +++ b/src/ast.js @@ -104,13 +104,13 @@ export class BindingBehavior extends Expression { } export class ValueConverter extends Expression { - constructor(expression, name, args, allArgs) { + constructor(expression, name, args) { super(); this.expression = expression; this.name = name; this.args = args; - this.allArgs = allArgs; + this.allArgs = [expression].concat(args); } evaluate(scope, lookupFunctions) { diff --git a/src/lexer.js b/src/lexer.js deleted file mode 100644 index 447c1228..00000000 --- a/src/lexer.js +++ /dev/null @@ -1,425 +0,0 @@ -export class Token { - constructor(index, text) { - this.index = index; - this.text = text; - } - - withOp(op) { - this.opKey = op; - return this; - } - - withGetterSetter(key) { - this.key = key; - return this; - } - - withValue(value) { - this.value = value; - return this; - } - - toString() { - return `Token(${this.text})`; - } -} - -export class Lexer { - lex(text) { - let scanner = new Scanner(text); - let tokens = []; - let token = scanner.scanToken(); - - while (token) { - tokens.push(token); - token = scanner.scanToken(); - } - - return tokens; - } -} - -export class Scanner { - constructor(input) { - this.input = input; - this.length = input.length; - this.peek = 0; - this.index = -1; - - this.advance(); - } - - scanToken() { - // Skip whitespace. - while (this.peek <= $SPACE) { - if (++this.index >= this.length) { - this.peek = $EOF; - return null; - } - - this.peek = this.input.charCodeAt(this.index); - } - - // Handle identifiers and numbers. - if (isIdentifierStart(this.peek)) { - return this.scanIdentifier(); - } - - if (isDigit(this.peek)) { - return this.scanNumber(this.index); - } - - let start = this.index; - - switch (this.peek) { - case $PERIOD: - this.advance(); - return isDigit(this.peek) ? this.scanNumber(start) : new Token(start, '.'); - case $LPAREN: - case $RPAREN: - case $LBRACE: - case $RBRACE: - case $LBRACKET: - case $RBRACKET: - case $COMMA: - case $COLON: - case $SEMICOLON: - return this.scanCharacter(start, String.fromCharCode(this.peek)); - case $SQ: - case $DQ: - return this.scanString(); - case $PLUS: - case $MINUS: - case $STAR: - case $SLASH: - case $PERCENT: - case $CARET: - case $QUESTION: - return this.scanOperator(start, String.fromCharCode(this.peek)); - case $LT: - case $GT: - case $BANG: - case $EQ: - return this.scanComplexOperator(start, $EQ, String.fromCharCode(this.peek), '='); - case $AMPERSAND: - return this.scanComplexOperator(start, $AMPERSAND, '&', '&'); - case $BAR: - return this.scanComplexOperator(start, $BAR, '|', '|'); - case $NBSP: - while (isWhitespace(this.peek)) { - this.advance(); - } - - return this.scanToken(); - // no default - } - - let character = String.fromCharCode(this.peek); - this.error(`Unexpected character [${character}]`); - return null; - } - - scanCharacter(start, text) { - assert(this.peek === text.charCodeAt(0)); - this.advance(); - return new Token(start, text); - } - - scanOperator(start, text) { - assert(this.peek === text.charCodeAt(0)); - assert(OPERATORS[text] === 1); - this.advance(); - return new Token(start, text).withOp(text); - } - - scanComplexOperator(start, code, one, two) { - assert(this.peek === one.charCodeAt(0)); - this.advance(); - - let text = one; - - if (this.peek === code) { - this.advance(); - text += two; - } - - if (this.peek === code) { - this.advance(); - text += two; - } - - assert(OPERATORS[text] === 1); - - return new Token(start, text).withOp(text); - } - - scanIdentifier() { - assert(isIdentifierStart(this.peek)); - let start = this.index; - - this.advance(); - - while (isIdentifierPart(this.peek)) { - this.advance(); - } - - let text = this.input.substring(start, this.index); - let result = new Token(start, text); - - // TODO(kasperl): Deal with null, undefined, true, and false in - // a cleaner and faster way. - if (OPERATORS[text] === 1) { - result.withOp(text); - } else { - result.withGetterSetter(text); - } - - return result; - } - - scanNumber(start) { - assert(isDigit(this.peek)); - let simple = (this.index === start); - this.advance(); // Skip initial digit. - - while (true) { // eslint-disable-line no-constant-condition - if (!isDigit(this.peek)) { - if (this.peek === $PERIOD) { - simple = false; - } else if (isExponentStart(this.peek)) { - this.advance(); - - if (isExponentSign(this.peek)) { - this.advance(); - } - - if (!isDigit(this.peek)) { - this.error('Invalid exponent', -1); - } - - simple = false; - } else { - break; - } - } - - this.advance(); - } - - let text = this.input.substring(start, this.index); - let value = simple ? parseInt(text, 10) : parseFloat(text); - return new Token(start, text).withValue(value); - } - - scanString() { - assert(this.peek === $SQ || this.peek === $DQ); - - let start = this.index; - let quote = this.peek; - - this.advance(); // Skip initial quote. - - let buffer; - let marker = this.index; - - while (this.peek !== quote) { - if (this.peek === $BACKSLASH) { - if (!buffer) { - buffer = []; - } - - buffer.push(this.input.substring(marker, this.index)); - this.advance(); - - let unescaped; - - if (this.peek === $u) { - // TODO(kasperl): Check bounds? Make sure we have test - // coverage for this. - let hex = this.input.substring(this.index + 1, this.index + 5); - - if (!/[A-Z0-9]{4}/.test(hex)) { - this.error(`Invalid unicode escape [\\u${hex}]`); - } - - unescaped = parseInt(hex, 16); - - for (let i = 0; i < 5; ++i) { - this.advance(); - } - } else { - unescaped = unescape(this.peek); - this.advance(); - } - - buffer.push(String.fromCharCode(unescaped)); - marker = this.index; - } else if (this.peek === $EOF) { - this.error('Unterminated quote'); - } else { - this.advance(); - } - } - - let last = this.input.substring(marker, this.index); - this.advance(); // Skip terminating quote. - let text = this.input.substring(start, this.index); - - // Compute the unescaped string value. - let unescaped = last; - - if (buffer !== null && buffer !== undefined) { - buffer.push(last); - unescaped = buffer.join(''); - } - - return new Token(start, text).withValue(unescaped); - } - - advance() { - if (++this.index >= this.length) { - this.peek = $EOF; - } else { - this.peek = this.input.charCodeAt(this.index); - } - } - - error(message, offset = 0) { - // TODO(kasperl): Try to get rid of the offset. It is only used to match - // the error expectations in the lexer tests for numbers with exponents. - let position = this.index + offset; - throw new Error(`Lexer Error: ${message} at column ${position} in expression [${this.input}]`); - } -} - -const OPERATORS = { - 'undefined': 1, - 'null': 1, - 'true': 1, - 'false': 1, - '+': 1, - '-': 1, - '*': 1, - '/': 1, - '%': 1, - '^': 1, - '=': 1, - '==': 1, - '===': 1, - '!=': 1, - '!==': 1, - '<': 1, - '>': 1, - '<=': 1, - '>=': 1, - '&&': 1, - '||': 1, - '&': 1, - '|': 1, - '!': 1, - '?': 1 -}; - -const $EOF = 0; -const $TAB = 9; -const $LF = 10; -const $VTAB = 11; -const $FF = 12; -const $CR = 13; -const $SPACE = 32; -const $BANG = 33; -const $DQ = 34; -const $$ = 36; -const $PERCENT = 37; -const $AMPERSAND = 38; -const $SQ = 39; -const $LPAREN = 40; -const $RPAREN = 41; -const $STAR = 42; -const $PLUS = 43; -const $COMMA = 44; -const $MINUS = 45; -const $PERIOD = 46; -const $SLASH = 47; -const $COLON = 58; -const $SEMICOLON = 59; -const $LT = 60; -const $EQ = 61; -const $GT = 62; -const $QUESTION = 63; - -const $0 = 48; -const $9 = 57; - -const $A = 65; -const $E = 69; -const $Z = 90; - -const $LBRACKET = 91; -const $BACKSLASH = 92; -const $RBRACKET = 93; -const $CARET = 94; -const $_ = 95; - -const $a = 97; -const $e = 101; -const $f = 102; -const $n = 110; -const $r = 114; -const $t = 116; -const $u = 117; -const $v = 118; -const $z = 122; - -const $LBRACE = 123; -const $BAR = 124; -const $RBRACE = 125; -const $NBSP = 160; - -function isWhitespace(code) { - return (code >= $TAB && code <= $SPACE) || (code === $NBSP); -} - -function isIdentifierStart(code) { - return ($a <= code && code <= $z) - || ($A <= code && code <= $Z) - || (code === $_) - || (code === $$); -} - -function isIdentifierPart(code) { - return ($a <= code && code <= $z) - || ($A <= code && code <= $Z) - || ($0 <= code && code <= $9) - || (code === $_) - || (code === $$); -} - -function isDigit(code) { - return ($0 <= code && code <= $9); -} - -function isExponentStart(code) { - return (code === $e || code === $E); -} - -function isExponentSign(code) { - return (code === $MINUS || code === $PLUS); -} - -function unescape(code) { - switch (code) { - case $n: return $LF; - case $f: return $FF; - case $r: return $CR; - case $t: return $TAB; - case $v: return $VTAB; - default: return code; - } -} - -function assert(condition, message) { - if (!condition) { - throw message || 'Assertion failed'; - } -} diff --git a/src/parser.js b/src/parser.js index b1f05b09..b1b16b10 100644 --- a/src/parser.js +++ b/src/parser.js @@ -1,4 +1,3 @@ -import {Lexer, Token} from './lexer'; import { Chain, ValueConverter, Assign, Conditional, AccessThis, AccessScope, AccessMember, AccessKeyed, @@ -7,54 +6,56 @@ import { LiteralPrimitive, LiteralArray, LiteralObject, LiteralString } from './ast'; -let EOF = new Token(-1, null); - export class Parser { constructor() { - this.cache = {}; - this.lexer = new Lexer(); + this.cache = Object.create(null); } parse(input) { input = input || ''; return this.cache[input] - || (this.cache[input] = new ParserImplementation(this.lexer, input).parseChain()); + || (this.cache[input] = new ParserImplementation(input).parseChain()); } } export class ParserImplementation { - constructor(lexer, input) { - this.index = 0; - this.input = input; - this.tokens = lexer.lex(input); + get hasNext() { + return this.index < this.length; + } + get tokenRaw() { + return this.input.slice(this.startIndex, this.index); } - get peek() { - return (this.index < this.tokens.length) ? this.tokens[this.index] : EOF; + constructor(input) { + this.index = 0; + this.startIndex = 0; + this.lastIndex = 0; + this.input = input; + this.length = input.length; + this.currentToken = T_EOF; + this.tokenValue = undefined; + this.currentChar = input.charCodeAt(0); } parseChain() { - let isChain = false; + this.nextToken(); + let expressions = []; - while (this.optional(';')) { - isChain = true; - } + while (this.currentToken !== T_EOF) { + if (this.optional(T_Semicolon)) { + this.error('Multiple expressions are not allowed.'); + } - while (this.index < this.tokens.length) { - if (this.peek.text === ')' || this.peek.text === '}' || this.peek.text === ']') { - this.error(`Unconsumed token ${this.peek.text}`); + if ((this.currentToken & T_ClosingToken) === T_ClosingToken) { + this.error(`Unconsumed token ${this.tokenRaw}`); } - let expr = this.parseBindingBehavior(); + const expr = this.parseBindingBehavior(); expressions.push(expr); - while (this.optional(';')) { - isChain = true; - } - - if (isChain) { + if (this.optional(T_Semicolon)) { this.error('Multiple expressions are not allowed.'); } } @@ -65,13 +66,13 @@ export class ParserImplementation { parseBindingBehavior() { let result = this.parseValueConverter(); - while (this.optional('&')) { - let name = this.peek.text; + while (this.optional(T_Ampersand)) { + let name = this.tokenValue; let args = []; - this.advance(); + this.nextToken(); - while (this.optional(':')) { + while (this.optional(T_Colon)) { args.push(this.parseExpression()); } @@ -84,36 +85,33 @@ export class ParserImplementation { parseValueConverter() { let result = this.parseExpression(); - while (this.optional('|')) { - let name = this.peek.text; // TODO(kasperl): Restrict to identifier? + while (this.optional(T_Bar)) { + let name = this.tokenValue; let args = []; - this.advance(); + this.nextToken(); - while (this.optional(':')) { - // TODO(kasperl): Is this really supposed to be expressions? + while (this.optional(T_Colon)) { args.push(this.parseExpression()); } - result = new ValueConverter(result, name, args, [result].concat(args)); + result = new ValueConverter(result, name, args); } return result; } parseExpression() { - let start = this.peek.index; let result = this.parseConditional(); - while (this.peek.text === '=') { + while (this.currentToken === T_Eq) { if (!result.isAssignable) { - let end = (this.index < this.tokens.length) ? this.peek.index : this.input.length; - let expression = this.input.substring(start, end); + let expression = this.input.slice(this.lastIndex, this.startIndex); this.error(`Expression ${expression} is not assignable`); } - this.expect('='); + this.expect(T_Eq); result = new Assign(result, this.parseConditional()); } @@ -121,15 +119,15 @@ export class ParserImplementation { } parseConditional() { - let start = this.peek.index; - let result = this.parseLogicalOr(); + let start = this.index; + let result = this.parseBinary(0); - if (this.optional('?')) { + if (this.optional(T_Question)) { let yes = this.parseExpression(); - if (!this.optional(':')) { - let end = (this.index < this.tokens.length) ? this.peek.index : this.input.length; - let expression = this.input.substring(start, end); + if (!this.optional(T_Colon)) { + let end = (this.index < this.length) ? this.index : this.length; + let expression = this.input.slice(start, end); this.error(`Conditional expression ${expression} requires all 3 expressions`); } @@ -141,101 +139,38 @@ export class ParserImplementation { return result; } - parseLogicalOr() { - let result = this.parseLogicalAnd(); - - while (this.optional('||')) { - result = new Binary('||', result, this.parseLogicalAnd()); - } - - return result; - } - - parseLogicalAnd() { - let result = this.parseEquality(); - - while (this.optional('&&')) { - result = new Binary('&&', result, this.parseEquality()); - } - - return result; - } - - parseEquality() { - let result = this.parseRelational(); + parseBinary(minPrecedence) { + let left = this.parseUnary(); - while (true) { // eslint-disable-line no-constant-condition - if (this.optional('==')) { - result = new Binary('==', result, this.parseRelational()); - } else if (this.optional('!=')) { - result = new Binary('!=', result, this.parseRelational()); - } else if (this.optional('===')) { - result = new Binary('===', result, this.parseRelational()); - } else if (this.optional('!==')) { - result = new Binary('!==', result, this.parseRelational()); - } else { - return result; - } + if ((this.currentToken & T_BinaryOperator) !== T_BinaryOperator) { + return left; } - } - - parseRelational() { - let result = this.parseAdditive(); - while (true) { // eslint-disable-line no-constant-condition - if (this.optional('<')) { - result = new Binary('<', result, this.parseAdditive()); - } else if (this.optional('>')) { - result = new Binary('>', result, this.parseAdditive()); - } else if (this.optional('<=')) { - result = new Binary('<=', result, this.parseAdditive()); - } else if (this.optional('>=')) { - result = new Binary('>=', result, this.parseAdditive()); - } else { - return result; + while ((this.currentToken & T_BinaryOperator) === T_BinaryOperator) { + const opToken = this.currentToken; + const precedence = opToken & T_Precedence; + if (precedence < minPrecedence) { + break; } + this.nextToken(); + left = new Binary(TokenValues[opToken & T_TokenMask], left, this.parseBinary(precedence)); } + return left; } - parseAdditive() { - let result = this.parseMultiplicative(); - - while (true) { // eslint-disable-line no-constant-condition - if (this.optional('+')) { - result = new Binary('+', result, this.parseMultiplicative()); - } else if (this.optional('-')) { - result = new Binary('-', result, this.parseMultiplicative()); - } else { - return result; + parseUnary() { + const opToken = this.currentToken; + if ((opToken & T_UnaryOperator) === T_UnaryOperator) { + this.nextToken(); + switch(opToken) { + case T_Plus: + return this.parseUnary(); + case T_Minus: + return new Binary('-', new LiteralPrimitive(0), this.parseUnary()); + case T_Bang: + return new PrefixNot('!', this.parseUnary()); } } - } - - parseMultiplicative() { - let result = this.parsePrefix(); - - while (true) { // eslint-disable-line no-constant-condition - if (this.optional('*')) { - result = new Binary('*', result, this.parsePrefix()); - } else if (this.optional('%')) { - result = new Binary('%', result, this.parsePrefix()); - } else if (this.optional('/')) { - result = new Binary('/', result, this.parsePrefix()); - } else { - return result; - } - } - } - - parsePrefix() { - if (this.optional('+')) { - return this.parsePrefix(); // TODO(kasperl): This is different than the original parser. - } else if (this.optional('-')) { - return new Binary('-', new LiteralPrimitive(0), this.parsePrefix()); - } else if (this.optional('!')) { - return new PrefixNot('!', this.parsePrefix()); - } - return this.parseAccessOrCallMember(); } @@ -243,14 +178,17 @@ export class ParserImplementation { let result = this.parsePrimary(); while (true) { // eslint-disable-line no-constant-condition - if (this.optional('.')) { - let name = this.peek.text; // TODO(kasperl): Check that this is an identifier. Are keywords okay? + if (this.optional(T_Period)) { + if ((this.currentToken ^ T_IdentifierOrKeyword) === T_IdentifierOrKeyword) { + this.error(`Unexpected token ${this.tokenRaw}`); + } + let name = this.tokenValue; - this.advance(); + this.nextToken(); - if (this.optional('(')) { - let args = this.parseExpressionList(')'); - this.expect(')'); + if (this.optional(T_LParen)) { + let args = this.parseExpressionList(T_RParen); + this.expect(T_RParen); if (result instanceof AccessThis) { result = new CallScope(name, args, result.ancestor); } else { @@ -263,13 +201,13 @@ export class ParserImplementation { result = new AccessMember(result, name); } } - } else if (this.optional('[')) { + } else if (this.optional(T_LBracket)) { let key = this.parseExpression(); - this.expect(']'); + this.expect(T_RBracket); result = new AccessKeyed(result, key); - } else if (this.optional('(')) { - let args = this.parseExpressionList(')'); - this.expect(')'); + } else if (this.optional(T_LParen)) { + let args = this.parseExpressionList(T_RParen); + this.expect(T_RParen); result = new CallFunction(result, args); } else { return result; @@ -278,70 +216,77 @@ export class ParserImplementation { } parsePrimary() { - if (this.optional('(')) { - let result = this.parseExpression(); - this.expect(')'); - return result; - } else if (this.optional('null')) { - return new LiteralPrimitive(null); - } else if (this.optional('undefined')) { - return new LiteralPrimitive(undefined); - } else if (this.optional('true')) { - return new LiteralPrimitive(true); - } else if (this.optional('false')) { - return new LiteralPrimitive(false); - } else if (this.optional('[')) { - let elements = this.parseExpressionList(']'); - this.expect(']'); - return new LiteralArray(elements); - } else if (this.peek.text === '{') { - return this.parseObject(); - } else if (this.peek.key !== null && this.peek.key !== undefined) { - return this.parseAccessOrCallScope(); - } else if (this.peek.value !== null && this.peek.value !== undefined) { - let value = this.peek.value; - this.advance(); - return value instanceof String || typeof value === 'string' ? new LiteralString(value) : new LiteralPrimitive(value); - } else if (this.index >= this.tokens.length) { - throw new Error(`Unexpected end of expression: ${this.input}`); - } else { - this.error(`Unexpected token ${this.peek.text}`); + const token = this.currentToken; + switch (token) { + case T_Identifier: + case T_ParentScope: + return this.parseAccessOrCallScope(); + case T_ThisScope: + this.nextToken(); + return new AccessThis(0); + case T_LParen: + this.nextToken(); + const result = this.parseExpression(); + this.expect(T_RParen); + return result; + case T_LBracket: + this.nextToken(); + const elements = this.parseExpressionList(T_RBracket); + this.expect(T_RBracket); + return new LiteralArray(elements); + case T_LBrace : + return this.parseObject(); + case T_StringLiteral: + { + const value = this.tokenValue; + this.nextToken(); + return new LiteralString(value); + } + case T_NumericLiteral: + { + const value = this.tokenValue; + this.nextToken(); + return new LiteralPrimitive(value); + } + case T_NullKeyword: + case T_UndefinedKeyword: + case T_TrueKeyword: + case T_FalseKeyword: + this.nextToken(); + return new LiteralPrimitive(TokenValues[token & T_TokenMask]); + default: + if (this.index >= this.length) { + throw new Error(`Unexpected end of expression at column ${this.index} of ${this.input}`); + } else { + this.error(`Unexpected token ${this.tokenRaw}`); + } } } - parseAccessOrCallScope() { - let name = this.peek.key; - - this.advance(); - - if (name === '$this') { - return new AccessThis(0); + parseAccessOrCallScope(name, token) { + if (!(name && token)) { + name = this.tokenValue; + token = this.currentToken; + this.nextToken(); } let ancestor = 0; - while (name === '$parent') { + while (token === T_ParentScope) { ancestor++; - if (this.optional('.')) { - name = this.peek.key; - this.advance(); - } else if (this.peek === EOF - || this.peek.text === '(' - || this.peek.text === ')' - || this.peek.text === '[' - || this.peek.text === '}' - || this.peek.text === ',' - || this.peek.text === '|' - || this.peek.text === '&' - ) { + if (this.optional(T_Period)) { + name = this.tokenValue; + token = this.currentToken; + this.nextToken(); + } else if ((this.currentToken & T_AccessScopeTerminal) === T_AccessScopeTerminal) { return new AccessThis(ancestor); } else { - this.error(`Unexpected token ${this.peek.text}`); + this.error(`Unexpected token ${this.tokenRaw}`); } } - if (this.optional('(')) { - let args = this.parseExpressionList(')'); - this.expect(')'); + if (this.optional(T_LParen)) { + let args = this.parseExpressionList(T_RParen); + this.expect(T_RParen); return new CallScope(name, args, ancestor); } @@ -352,28 +297,46 @@ export class ParserImplementation { let keys = []; let values = []; - this.expect('{'); - - if (this.peek.text !== '}') { - do { - // TODO(kasperl): Stricter checking. Only allow identifiers - // and strings as keys. Maybe also keywords? - let peek = this.peek; - let value = peek.value; - keys.push(typeof value === 'string' ? value : peek.text); - - this.advance(); - if (peek.key && (this.peek.text === ',' || this.peek.text === '}')) { - --this.index; - values.push(this.parseAccessOrCallScope()); - } else { - this.expect(':'); + this.expect(T_LBrace); + let isComputed = false; + + while (this.currentToken !== T_RBrace) { + const token = this.currentToken; + const name = this.tokenValue; + + switch(token) { + case T_Identifier: + // Treat keywords and predefined strings like identifiers + case T_FalseKeyword: + case T_TrueKeyword: + case T_NullKeyword: + case T_UndefinedKeyword: + case T_ThisScope: + case T_ParentScope: + keys.push(name); + this.nextToken(); + if (this.optional(T_Colon)) { + values.push(this.parseExpression()); + } else { + values.push(this.parseAccessOrCallScope(name, token)); + } + break; + case T_StringLiteral: + case T_NumericLiteral: + keys.push(name); + this.nextToken(); + this.expect(T_Colon); values.push(this.parseExpression()); - } - } while (this.optional(',')); + break; + default: + this.error(`Unexpected token ${this.tokenRaw}`); + } + if (this.currentToken !== T_RBrace) { + this.expect(T_Comma); + } } - - this.expect('}'); + + this.expect(T_RBrace); return new LiteralObject(keys, values); } @@ -381,41 +344,517 @@ export class ParserImplementation { parseExpressionList(terminator) { let result = []; - if (this.peek.text !== terminator) { + if (this.currentToken !== terminator) { do { result.push(this.parseExpression()); - } while (this.optional(',')); + } while (this.optional(T_Comma)); } return result; } - optional(text) { - if (this.peek.text === text) { - this.advance(); + nextToken() { + return this.currentToken = this.scanToken(); + } + + nextChar() { + return this.currentChar = this.input.charCodeAt(++this.index); + } + + scanToken() { + while (this.hasNext) { + // skip whitespace. + if (this.currentChar <= $SPACE) { + this.nextChar(); + continue; + } + + this.lastIndex = this.startIndex; + this.startIndex = this.index; + + // handle identifiers and numbers. + if (isIdentifierStart(this.currentChar)) { + return this.scanIdentifier(); + } + + if (isDigit(this.currentChar)) { + return this.scanNumber(); + + } + switch (this.currentChar) { + case $PERIOD: + { + const nextChar = this.input.charCodeAt(this.index + 1); + if (isDigit(nextChar)) { + return this.scanNumber(); + } + this.nextChar(); + return T_Period; + } + case $LPAREN: + this.nextChar(); + return T_LParen; + case $RPAREN: + this.nextChar(); + return T_RParen; + case $LBRACE: + this.nextChar(); + return T_LBrace; + case $RBRACE: + this.nextChar(); + return T_RBrace; + case $LBRACKET: + this.nextChar(); + return T_LBracket; + case $RBRACKET: + this.nextChar(); + return T_RBracket; + case $COMMA: + this.nextChar(); + return T_Comma; + case $COLON: + this.nextChar(); + return T_Colon; + case $SEMICOLON: + this.nextChar(); + return T_Semicolon; + case $SQ: + case $DQ: + return this.scanString(); + case $PLUS: + this.nextChar(); + return T_Plus; + case $MINUS: + this.nextChar(); + return T_Minus; + case $STAR: + this.nextChar(); + return T_Star; + case $SLASH: + this.nextChar(); + return T_Slash; + case $PERCENT: + this.nextChar(); + return T_Percent; + case $CARET: + this.nextChar(); + return T_Caret; + case $QUESTION: + this.nextChar(); + return T_Question; + case $LT: + { + this.nextChar(); + if (this.currentChar === $EQ) { + this.nextChar(); + return T_LtEq; + } + return T_Lt; + } + case $GT: + { + this.nextChar(); + if (this.currentChar === $EQ) { + this.nextChar(); + return T_GtEq; + } + return T_Gt; + } + case $BANG: + { + this.nextChar(); + if (this.currentChar === $EQ) { + this.nextChar(); + if (this.currentChar === $EQ) { + this.nextChar(); + return T_BangEqEq; + } + return T_BangEq; + } + return T_Bang; + } + case $EQ: + { + this.nextChar(); + if (this.currentChar === $EQ) { + this.nextChar(); + if (this.currentChar === $EQ) { + this.nextChar(); + return T_EqEqEq; + } + return T_EqEq; + } + return T_Eq; + } + case $AMPERSAND: + { + this.nextChar(); + if (this.currentChar === $AMPERSAND) { + this.nextChar(); + return T_AmpersandAmpersand; + } + return T_Ampersand; + } + case $BAR: + { + this.nextChar(); + if (this.currentChar === $BAR) { + this.nextChar(); + return T_BarBar; + } + return T_Bar; + } + case $NBSP: + this.nextChar(); + continue; + // no default + } + + this.error(`Unexpected character [${String.fromCharCode(this.currentChar)}]`); + return null; + } + + return T_EOF; + } + + scanIdentifier() { + this.nextChar(); + + while (isIdentifierPart(this.currentChar)) { + this.nextChar(); + } + + this.tokenValue = this.tokenRaw; + + // true/null have length 4, undefined has length 9 + if (4 <= this.tokenValue.length && this.tokenValue.length <= 9) { + const token = KeywordLookup[this.tokenValue]; + if (token !== undefined) { + return token; + } + } + + return T_Identifier; + } + + scanNumber() { + let isFloat = false; + let value = 0; + + while (isDigit(this.currentChar)) { + value = value * 10 + (this.currentChar - $0); + this.nextChar(); + } + + if (this.currentChar === $PERIOD) { + this.nextChar(); + + let decimalValue = 0; + let decimalPlaces = 0; + + while (isDigit(this.currentChar)) { + decimalValue = decimalValue * 10 + (this.currentChar - $0); + decimalPlaces++; + this.nextChar(); + } + + value += (decimalValue / Math.pow(10, decimalPlaces)); + } + + const nonDigitStart = this.index; + if (this.currentChar === $e || this.currentChar === $E) { + isFloat = true; + const exponentStart = this.index; // for error reporting in case the exponent is invalid + this.nextChar(); + + if (this.currentChar === $PLUS || this.currentChar === $MINUS) { + this.nextChar(); + } + + if (!isDigit(this.currentChar)) { + this.index = exponentStart; + this.error('Invalid exponent'); + } + + while (isDigit(this.currentChar)) { + this.nextChar(); + } + } + + if (!isFloat) { + this.tokenValue = value; + return T_NumericLiteral; + } + + const text = value + this.input.slice(nonDigitStart, this.index); + this.tokenValue = parseFloat(text); + return T_NumericLiteral; + } + + scanString() { + let quote = this.currentChar; + this.nextChar(); // Skip initial quote. + + let buffer; + let marker = this.index; + + while (this.currentChar !== quote) { + if (this.currentChar === $BACKSLASH) { + if (!buffer) { + buffer = []; + } + + buffer.push(this.input.slice(marker, this.index)); + + this.nextChar(); + + let unescaped; + + if (this.currentChar === $u) { + this.nextChar(); + + if (this.index + 4 < this.length) { + let hex = this.input.slice(this.index, this.index + 4); + + if (!/[A-Z0-9]{4}/i.test(hex)) { + this.error(`Invalid unicode escape [\\u${hex}]`); + } + + unescaped = parseInt(hex, 16); + this.index += 4; + this.currentChar = this.input.charCodeAt(this.index); + } else { + this.error(`Unexpected token ${this.tokenRaw}`); + } + } else { + unescaped = unescape(this.currentChar); + this.nextChar(); + } + + buffer.push(String.fromCharCode(unescaped)); + marker = this.index; + } else if (this.currentChar === $EOF) { + this.error('Unterminated quote'); + } else { + this.nextChar(); + } + } + + let last = this.input.slice(marker, this.index); + this.nextChar(); // Skip terminating quote. + + // Compute the unescaped string value. + let unescaped = last; + + if (buffer !== null && buffer !== undefined) { + buffer.push(last); + unescaped = buffer.join(''); + } + + this.tokenValue = unescaped; + return T_StringLiteral; + } + + error(message) { + throw new Error(`Parser Error: ${message} at column ${this.startIndex} in expression [${this.input}]`); + } + + optional(type) { + if (this.currentToken === type) { + this.nextToken(); return true; } return false; } - expect(text) { - if (this.peek.text === text) { - this.advance(); + expect(token) { + if (this.currentToken === token) { + this.nextToken(); } else { - this.error(`Missing expected ${text}`); + this.error(`Missing expected token ${TokenValues[token & T_TokenMask]}`); } } +} - advance() { - this.index++; - } +const $EOF = 0; +const $TAB = 9; +const $LF = 10; +const $VTAB = 11; +const $FF = 12; +const $CR = 13; +const $SPACE = 32; +const $BANG = 33; +const $DQ = 34; +const $$ = 36; +const $PERCENT = 37; +const $AMPERSAND = 38; +const $SQ = 39; +const $LPAREN = 40; +const $RPAREN = 41; +const $STAR = 42; +const $PLUS = 43; +const $COMMA = 44; +const $MINUS = 45; +const $PERIOD = 46; +const $SLASH = 47; +const $COLON = 58; +const $SEMICOLON = 59; +const $LT = 60; +const $EQ = 61; +const $GT = 62; +const $QUESTION = 63; + +const $0 = 48; +const $9 = 57; + +const $A = 65; +const $E = 69; +const $Z = 90; + +const $LBRACKET = 91; +const $BACKSLASH = 92; +const $RBRACKET = 93; +const $CARET = 94; +const $_ = 95; + +const $a = 97; +const $e = 101; +const $f = 102; +const $n = 110; +const $r = 114; +const $t = 116; +const $u = 117; +const $v = 118; +const $z = 122; + +const $LBRACE = 123; +const $BAR = 124; +const $RBRACE = 125; +const $NBSP = 160; + +function isIdentifierStart(code) { + return ($a <= code && code <= $z) + || ($A <= code && code <= $Z) + || (code === $_) + || (code === $$); +} - error(message) { - let location = (this.index < this.tokens.length) - ? `at column ${this.tokens[this.index].index + 1} in` - : 'at the end of the expression'; +function isIdentifierPart(code) { + return ($a <= code && code <= $z) + || ($A <= code && code <= $Z) + || ($0 <= code && code <= $9) + || (code === $_) + || (code === $$); +} + +function isDigit(code) { + return ($0 <= code && code <= $9); +} - throw new Error(`Parser Error: ${message} ${location} [${this.input}]`); +function unescape(code) { + switch (code) { + case $n: return $LF; + case $f: return $FF; + case $r: return $CR; + case $t: return $TAB; + case $v: return $VTAB; + default: return code; } } + +/* Performing a bitwise and (&) with this value (63) will return only the + * token bit, which corresponds to the index of the token's value in the + * TokenValues array */ +const T_TokenMask = (1 << 6) - 1; + +/* Shifting 6 bits to the left gives us a step size of 64 in a range of + * 64 (1 << 6) to 448 (7 << 6) for our precedence bit + * This is the lowest value which does not overlap with the token bits 0-38. */ +const T_PrecedenceShift = 6; + +/* Performing a bitwise and (&) with this value will return only the + * precedence bit, which is used to determine the parsing order of binary + * expressions */ +const T_Precedence = 7 << T_PrecedenceShift; + +/** ')' | '}' | ']' */ +const T_ClosingToken = 1 << 9; +/** EndOfSource | '(' | '}' | ')' | ',' | '[' | '&' | '|' */ +const T_AccessScopeTerminal = 1 << 10; +const T_EOF = 1 << 11 | T_AccessScopeTerminal; +const T_Identifier = 1 << 12 | T_IdentifierOrKeyword; +const T_NumericLiteral = 1 << 13; +const T_StringLiteral = 1 << 14; +const T_BinaryOperator = 1 << 15; +const T_UnaryOperator = 1 << 16; +const T_IdentifierOrKeyword = 1 << 17; + +/** false */ const T_FalseKeyword = 0 | T_IdentifierOrKeyword; +/** true */ const T_TrueKeyword = 1 | T_IdentifierOrKeyword; +/** null */ const T_NullKeyword = 2 | T_IdentifierOrKeyword; +/** undefined */ const T_UndefinedKeyword = 3 | T_IdentifierOrKeyword; +/** '$this' */ const T_ThisScope = 4 | T_IdentifierOrKeyword; +/** '$parent' */ const T_ParentScope = 5 | T_IdentifierOrKeyword; + +/** '(' */const T_LParen = 6 | T_AccessScopeTerminal; +/** '{' */const T_LBrace = 7; +/** '.' */const T_Period = 8; +/** '}' */const T_RBrace = 9 | T_AccessScopeTerminal | T_ClosingToken; +/** ')' */const T_RParen = 10 | T_AccessScopeTerminal | T_ClosingToken; +/** ';' */const T_Semicolon = 11; +/** ',' */const T_Comma = 12 | T_AccessScopeTerminal; +/** '[' */const T_LBracket = 13 | T_AccessScopeTerminal; +/** ']' */const T_RBracket = 14 | T_ClosingToken; +/** ':' */const T_Colon = 15; +/** '?' */const T_Question = 16; +/** ''' */const T_SQ = 17; +/** '"' */const T_DQ = 18; + +// Operator precedence: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence#Table + +/** '&' */ const T_Ampersand = 19 | T_AccessScopeTerminal; +/** '|' */ const T_Bar = 20 | T_AccessScopeTerminal; +/** '||' */ const T_BarBar = 21 | 1 << T_PrecedenceShift | T_BinaryOperator; +/** '&&' */ const T_AmpersandAmpersand = 22 | 2 << T_PrecedenceShift | T_BinaryOperator; +/** '^' */ const T_Caret = 23 | 3 << T_PrecedenceShift | T_BinaryOperator; +/** '==' */ const T_EqEq = 24 | 4 << T_PrecedenceShift | T_BinaryOperator; +/** '!=' */ const T_BangEq = 25 | 4 << T_PrecedenceShift | T_BinaryOperator; +/** '===' */const T_EqEqEq = 26 | 4 << T_PrecedenceShift | T_BinaryOperator; +/** '!== '*/const T_BangEqEq = 27 | 4 << T_PrecedenceShift | T_BinaryOperator; +/** '<' */ const T_Lt = 28 | 5 << T_PrecedenceShift | T_BinaryOperator; +/** '>' */ const T_Gt = 29 | 5 << T_PrecedenceShift | T_BinaryOperator; +/** '<=' */ const T_LtEq = 30 | 5 << T_PrecedenceShift | T_BinaryOperator; +/** '>=' */ const T_GtEq = 31 | 5 << T_PrecedenceShift | T_BinaryOperator; +/** '+' */ const T_Plus = 32 | 6 << T_PrecedenceShift | T_BinaryOperator | T_UnaryOperator; +/** '-' */ const T_Minus = 33 | 6 << T_PrecedenceShift | T_BinaryOperator | T_UnaryOperator; +/** '*' */ const T_Star = 34 | 7 << T_PrecedenceShift | T_BinaryOperator; +/** '%' */ const T_Percent = 35 | 7 << T_PrecedenceShift | T_BinaryOperator; +/** '/' */ const T_Slash = 36 | 7 << T_PrecedenceShift | T_BinaryOperator; +/** '=' */ const T_Eq = 37; +/** '!' */ const T_Bang = 38 | T_UnaryOperator; + +const KeywordLookup = Object.create(null, { + true: {value: T_TrueKeyword}, + null: {value: T_NullKeyword}, + false: {value: T_FalseKeyword}, + undefined: {value: T_UndefinedKeyword}, + $this: {value: T_ThisScope}, + $parent: {value: T_ParentScope} +}); + +/** + * Array for mapping tokens to token values. The indices of the values + * correspond to the token bits 0-38. + * For this to work properly, the values in the array must be kept in + * the same order as the token bits. + * Usage: TokenValues[token & T_TokenMask] + */ +const TokenValues = [ + false, true, null, undefined, '$this', '$parent', + + '(', '{', '.', '}', ')', ';', ',', '[', ']', ':', '?', '\'', '"', + + '&', '|', '||', '&&', '^', '==', '!=', '===', '!==', '<', '>', + '<=', '>=', '+', '-', '*', '%', '/', '=', '!' +]; diff --git a/test/parser.spec.js b/test/parser.spec.js index 7b7d6b1a..5756d41c 100644 --- a/test/parser.spec.js +++ b/test/parser.spec.js @@ -13,9 +13,21 @@ import { CallFunction, AccessThis, AccessAncestor, - Assign + Assign, + Conditional, + Binary, + Expression, + PrefixNot } from '../src/ast'; +const operators = [ + '&&', '||', + '==', '!=', '===', '!==', + '<', '>', '<=', '>=', + '+', '-', + '*', '%', '/' +]; + describe('Parser', () => { let parser; @@ -23,157 +35,498 @@ describe('Parser', () => { parser = new Parser(); }); - it('parses literal primitives', () => { + describe('parses literal primitive', () => { // http://es5.github.io/x7.html#x7.8.4 - let tests = [ - { expression: '\'foo\'', value: 'foo', type: LiteralString }, - { expression: '\'\\\\\'', value: '\\', type: LiteralString }, - { expression: '\'\\\'\'', value: '\'', type: LiteralString }, - { expression: '\'"\'', value: '"', type: LiteralString }, - { expression: '\'\\f\'', value: '\f', type: LiteralString }, - { expression: '\'\\n\'', value: '\n', type: LiteralString }, - { expression: '\'\\r\'', value: '\r', type: LiteralString }, - { expression: '\'\\t\'', value: '\t', type: LiteralString }, - { expression: '\'\\v\'', value: '\v', type: LiteralString }, - { expression: 'true', value: true, type: LiteralPrimitive }, - { expression: 'false', value: false, type: LiteralPrimitive }, - { expression: 'null', value: null, type: LiteralPrimitive }, - { expression: 'undefined', value: undefined, type: LiteralPrimitive }, - { expression: '0', value: 0, type: LiteralPrimitive }, - { expression: '1', value: 1, type: LiteralPrimitive }, - { expression: '2.2', value: 2.2, type: LiteralPrimitive } + const tests = [ + { expression: '\'foo\'', expected: new LiteralString('foo') }, + { expression: `\'${unicodeEscape('äöüÄÖÜß')}\'`, expected: new LiteralString('äöüÄÖÜß') }, + { expression: `\'${unicodeEscape('ಠ_ಠ')}\'`, expected: new LiteralString('ಠ_ಠ') }, + { expression: '\'\\\\\'', expected: new LiteralString('\\') }, + { expression: '\'\\\'\'', expected: new LiteralString('\'') }, + { expression: '\'"\'', expected: new LiteralString('"') }, + { expression: '\'\\f\'', expected: new LiteralString('\f') }, + { expression: '\'\\n\'', expected: new LiteralString('\n') }, + { expression: '\'\\r\'', expected: new LiteralString('\r') }, + { expression: '\'\\t\'', expected: new LiteralString('\t') }, + { expression: '\'\\v\'', expected: new LiteralString('\v') }, + { expression: '\'\\v\'', expected: new LiteralString('\v') }, + { expression: 'true', expected: new LiteralPrimitive(true) }, + { expression: 'false', expected: new LiteralPrimitive(false) }, + { expression: 'null', expected: new LiteralPrimitive(null) }, + { expression: 'undefined', expected: new LiteralPrimitive(undefined) }, + { expression: '0', expected: new LiteralPrimitive(0) }, + { expression: '1', expected: new LiteralPrimitive(1) }, + { expression: '-1', expected: new Binary('-', new LiteralPrimitive(0), new LiteralPrimitive(1)) }, + { expression: '(-1)', expected: new Binary('-', new LiteralPrimitive(0), new LiteralPrimitive(1)) }, + { expression: '-(-1)', expected: new Binary('-', new LiteralPrimitive(0), new Binary('-', new LiteralPrimitive(0), new LiteralPrimitive(1))) }, + { expression: '+(-1)', expected: new Binary('-', new LiteralPrimitive(0), new LiteralPrimitive(1)) }, + { expression: '-(+1)', expected: new Binary('-', new LiteralPrimitive(0), new LiteralPrimitive(1)) }, + { expression: '+(+1)', expected: new LiteralPrimitive(1) }, + { expression: '9007199254740992', expected: new LiteralPrimitive(9007199254740992) }, // Number.MAX_SAFE_INTEGER + 1 + { expression: '1.7976931348623157e+308', expected: new LiteralPrimitive(1.7976931348623157e+308) }, // Number.MAX_VALUE + { expression: '1.7976931348623157E+308', expected: new LiteralPrimitive(1.7976931348623157e+308) }, // Number.MAX_VALUE + { expression: '-9007199254740992', expected: new Binary('-', new LiteralPrimitive(0), new LiteralPrimitive(9007199254740992)) }, // Number.MIN_SAFE_INTEGER - 1 + { expression: '5e-324', expected: new LiteralPrimitive(5e-324) }, // Number.MIN_VALUE + { expression: '5E-324', expected: new LiteralPrimitive(5e-324) }, // Number.MIN_VALUE + { expression: '2.2', expected: new LiteralPrimitive(2.2) }, + { expression: '2.2e2', expected: new LiteralPrimitive(2.2e2) }, + { expression: '.42', expected: new LiteralPrimitive(.42) }, + { expression: '0.42', expected: new LiteralPrimitive(.42) }, + { expression: '.42E10', expected: new LiteralPrimitive(.42e10) } ]; - for (let i = 0; i < tests.length; i++) { - let test = tests[i]; - let expression = parser.parse(test.expression); - expect(expression instanceof test.type).toBe(true); - expect(expression.value).toEqual(test.value); + for (const test of tests) { + it(test.expression, () => { + let expression = parser.parse(test.expression); + verifyEqual(expression, test.expected); + }); + } + }); + + it('parses conditional', () => { + let expression = parser.parse('foo ? bar : baz'); + verifyEqual(expression, + new Conditional( + new AccessScope('foo', 0), + new AccessScope('bar', 0), + new AccessScope('baz', 0) + ) + ); + }); + + it('parses nested conditional', () => { + let expression = parser.parse('foo ? bar : foo1 ? bar1 : baz'); + verifyEqual(expression, + new Conditional( + new AccessScope('foo', 0), + new AccessScope('bar', 0), + new Conditional( + new AccessScope('foo1', 0), + new AccessScope('bar1', 0), + new AccessScope('baz', 0) + ) + ) + ); + }); + + it('parses conditional with assign', () => { + let expression = parser.parse('foo ? bar : baz = qux'); + verifyEqual(expression, + new Conditional( + new AccessScope('foo', 0), + new AccessScope('bar', 0), + new Assign( + new AccessScope('baz', 0), + new AccessScope('qux', 0) + ) + ) + ); + }); + + describe('parses binary', () => { + for (let op of operators) { + it(`\"${op}\"`, () => { + let expression = parser.parse(`foo ${op} bar`); + verifyEqual(expression, + new Binary( + op, + new AccessScope('foo', 0), + new AccessScope('bar', 0) + ) + ); + }); } }); - it('parses binding behaviors', () => { + it('parses binary in the correct order', () => { + const expression = parser.parse('a || b && c ^ d == e != f === g !== h < i > j <= k >= l + m - n * o % p / !q'); + verifyEqual( + expression, + new Binary( + '||', + new AccessScope('a', 0), + new Binary( + '&&', + new AccessScope('b', 0), + new Binary( + '^', + new AccessScope('c', 0), + new Binary( + '==', + new AccessScope('d', 0), + new Binary( + '!=', + new AccessScope('e', 0), + new Binary( + '===', + new AccessScope('f', 0), + new Binary( + '!==', + new AccessScope('g', 0), + new Binary( + '<', + new AccessScope('h', 0), + new Binary( + '>', + new AccessScope('i', 0), + new Binary( + '<=', + new AccessScope('j', 0), + new Binary( + '>=', + new AccessScope('k', 0), + new Binary( + '+', + new AccessScope('l', 0), + new Binary( + '-', + new AccessScope('m', 0), + new Binary( + '*', + new AccessScope('n', 0), + new Binary( + '%', + new AccessScope('o', 0), + new Binary( + '/', + new AccessScope('p', 0), + new PrefixNot( + '!', + new AccessScope('q', 0) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + }); + + it('reorders binary expression', () => { + const expression = parser.parse('a * b || c === d / e + f && g'); + verifyEqual( + expression, + new Binary( + '||', + new Binary( + '*', + new AccessScope('a', 0), + new AccessScope('b', 0) + ), + new Binary( + '&&', + new Binary( + '===', + new AccessScope('c', 0), + new Binary( + '+', + new Binary( + '/', + new AccessScope('d', 0), + new AccessScope('e', 0) + ), + new AccessScope('f', 0) + ), + ), + new AccessScope('g', 0) + ) + ) + ) + }); + + it('parses binding behavior', () => { let expression = parser.parse('foo & bar'); - expect(expression instanceof BindingBehavior).toBe(true); - expect(expression.name).toBe('bar'); - expect(expression.expression instanceof AccessScope).toBe(true); + verifyEqual(expression, + new BindingBehavior(new AccessScope('foo', 0), 'bar', []) + ); + }); - expression = parser.parse('foo & bar:x:y:z & baz:a:b:c'); - expect(expression instanceof BindingBehavior).toBe(true); - expect(expression.name).toBe('baz'); - expect(expression.args).toEqual([new AccessScope('a', 0), new AccessScope('b', 0), new AccessScope('c', 0)]) - expect(expression.expression instanceof BindingBehavior).toBe(true); - expect(expression.expression.name).toBe('bar'); - expect(expression.expression.args).toEqual([new AccessScope('x', 0), new AccessScope('y', 0), new AccessScope('z', 0)]); - expect(expression.expression.expression instanceof AccessScope).toBe(true); + it('parses chained binding behaviors', () => { + let expression = parser.parse('foo & bar:x:y:z & baz:a:b:c'); + verifyEqual(expression, + new BindingBehavior( + new BindingBehavior( + new AccessScope('foo', 0), + 'bar', + [ + new AccessScope('x', 0), + new AccessScope('y', 0), + new AccessScope('z', 0) + ] + ), + 'baz', + [ + new AccessScope('a', 0), + new AccessScope('b', 0), + new AccessScope('c', 0) + ] + ) + ); }); - it('parses value converters', () => { + it('parses value converter', () => { let expression = parser.parse('foo | bar'); - expect(expression instanceof ValueConverter).toBe(true); - expect(expression.name).toBe('bar'); - expect(expression.expression instanceof AccessScope).toBe(true); + verifyEqual(expression, + new ValueConverter(new AccessScope('foo', 0), 'bar', []) + ); + }); - expression = parser.parse('foo | bar:x:y:z | baz:a:b:c'); - expect(expression instanceof ValueConverter).toBe(true); - expect(expression.name).toBe('baz'); - expect(expression.args).toEqual([new AccessScope('a', 0), new AccessScope('b', 0), new AccessScope('c', 0)]); - expect(expression.expression instanceof ValueConverter).toBe(true); - expect(expression.expression.name).toBe('bar'); - expect(expression.expression.args).toEqual([new AccessScope('x', 0), new AccessScope('y', 0), new AccessScope('z', 0)]); - expect(expression.expression.expression instanceof AccessScope).toBe(true); + it('parses chained value converters', () => { + let expression = parser.parse('foo | bar:x:y:z | baz:a:b:c'); + verifyEqual(expression, + new ValueConverter( + new ValueConverter( + new AccessScope('foo', 0), + 'bar', + [ + new AccessScope('x', 0), + new AccessScope('y', 0), + new AccessScope('z', 0) + ] + ), + 'baz', + [ + new AccessScope('a', 0), + new AccessScope('b', 0), + new AccessScope('c', 0) + ] + ) + ); }); - it('parses value converters and binding behaviors', () => { + it('parses chained value converters and binding behaviors', () => { let expression = parser.parse('foo | bar:x:y:z & baz:a:b:c'); - expect(expression instanceof BindingBehavior).toBe(true); - expect(expression.name).toBe('baz'); - expect(expression.args).toEqual([new AccessScope('a', 0), new AccessScope('b', 0), new AccessScope('c', 0)]) - expect(expression.expression instanceof ValueConverter).toBe(true); - expect(expression.expression.name).toBe('bar'); - expect(expression.expression.args).toEqual([new AccessScope('x', 0), new AccessScope('y', 0), new AccessScope('z', 0)]); - expect(expression.expression.expression instanceof AccessScope).toBe(true); + verifyEqual(expression, + new BindingBehavior( + new ValueConverter( + new AccessScope('foo', 0), + 'bar', + [ + new AccessScope('x', 0), + new AccessScope('y', 0), + new AccessScope('z', 0) + ] + ), + 'baz', + [ + new AccessScope('a', 0), + new AccessScope('b', 0), + new AccessScope('c', 0) + ] + ) + ); + }); + + it('parses value converter with Conditional argument', () => { + let expression = parser.parse('foo | bar : foo ? bar : baz'); + verifyEqual(expression, + new ValueConverter( + new AccessScope('foo', 0), + 'bar', + [ + new Conditional( + new AccessScope('foo', 0), + new AccessScope('bar', 0), + new AccessScope('baz', 0) + ) + ]) + ); + }); + + it('parses value converter with Assign argument', () => { + let expression = parser.parse('foo | bar : foo = bar'); + verifyEqual(expression, + new ValueConverter( + new AccessScope('foo', 0), + 'bar', + [ + new Assign( + new AccessScope('foo', 0), + new AccessScope('bar', 0) + ) + ]) + ); + }); + + describe('parses value converter with Binary argument', () => { + for (let op of operators) { + it(`\"${op}\"`, () => { + let expression = parser.parse(`foo | bar : foo ${op} bar`); + verifyEqual(expression, + new ValueConverter( + new AccessScope('foo', 0), + 'bar', + [ + new Binary( + op, + new AccessScope('foo', 0), + new AccessScope('bar', 0) + ) + ]) + ); + }); + } }); it('parses AccessScope', () => { let expression = parser.parse('foo'); - expect(expression instanceof AccessScope).toBe(true); - expect(expression.name).toBe('foo'); + verifyEqual(expression, new AccessScope('foo', 0)); }); it('parses AccessMember', () => { let expression = parser.parse('foo.bar'); - expect(expression instanceof AccessMember).toBe(true); - expect(expression.name).toBe('bar'); - expect(expression.object instanceof AccessScope).toBe(true); - expect(expression.object.name).toBe('foo'); + verifyEqual(expression, + new AccessMember(new AccessScope('foo', 0), 'bar') + ); + }); + + it('parses AccessMember with indexed string property', () => { + let expression = parser.parse('foo["bar"].baz'); + verifyEqual(expression, + new AccessMember( + new AccessKeyed( + new AccessScope('foo', 0), + new LiteralString('bar') + ), + 'baz' + ) + ); + }); + + it('parses AccessMember with indexed numeric property', () => { + let expression = parser.parse('foo[42].baz'); + verifyEqual(expression, + new AccessMember( + new AccessKeyed( + new AccessScope('foo', 0), + new LiteralPrimitive(42) + ), + 'baz' + ) + ); }); it('parses Assign', () => { let expression = parser.parse('foo = bar'); - expect(expression instanceof Assign).toBe(true); - expect(expression.target instanceof AccessScope).toBe(true); - expect(expression.target.name).toBe('foo'); - expect(expression.value instanceof AccessScope).toBe(true); - expect(expression.value.name).toBe('bar'); - - expression = parser.parse('foo = bar = baz'); - expect(expression instanceof Assign).toBe(true); - expect(expression.target instanceof Assign).toBe(true); - expect(expression.target.target instanceof AccessScope).toBe(true); - expect(expression.target.target.name).toBe('foo'); - expect(expression.target.value instanceof AccessScope).toBe(true); - expect(expression.target.value.name).toBe('bar'); - expect(expression.value instanceof AccessScope).toBe(true); - expect(expression.value.name).toBe('baz'); + verifyEqual(expression, + new Assign( + new AccessScope('foo', 0), + new AccessScope('bar', 0) + ) + ); + }); + + it('parses Assign to ignored Unary', () => { + let expression = parser.parse('+foo = bar'); + verifyEqual(expression, + new Assign( + new AccessScope('foo', 0), + new AccessScope('bar', 0) + ) + ); + }); + + it('parses chained Assign', () => { + let expression = parser.parse('foo = bar = baz'); + verifyEqual(expression, + new Assign( + new Assign( + new AccessScope('foo', 0), + new AccessScope('bar', 0) + ), + new AccessScope('baz', 0) + ) + ); }); it('parses CallScope', () => { let expression = parser.parse('foo(x)'); - expect(expression instanceof CallScope).toBe(true); - expect(expression.name).toBe('foo'); - expect(expression.args).toEqual([new AccessScope('x', 0)]); + verifyEqual(expression, + new CallScope('foo', [new AccessScope('x', 0)], 0) + ); + }); + + it('parses nested CallScope', () => { + let expression = parser.parse('foo(bar(x), y)'); + verifyEqual(expression, + new CallScope( + 'foo', + [ + new CallScope( + 'bar', + [new AccessScope('x', 0)], + 0), + new AccessScope('y', 0) + ], 0) + ); }); it('parses CallMember', () => { let expression = parser.parse('foo.bar(x)'); - expect(expression instanceof CallMember).toBe(true); - expect(expression.name).toBe('bar'); - expect(expression.args).toEqual([new AccessScope('x', 0)]); - expect(expression.object instanceof AccessScope).toBe(true); - expect(expression.object.name).toBe('foo'); + verifyEqual(expression, + new CallMember( + new AccessScope('foo', 0), + 'bar', + [new AccessScope('x', 0)] + ) + ); + }); + + it('parses nested CallMember', () => { + let expression = parser.parse('foo.bar.baz(x)'); + verifyEqual(expression, + new CallMember( + new AccessMember( + new AccessScope('foo', 0), + 'bar' + ), + 'baz', + [new AccessScope('x', 0)] + ) + ); }); it('parses $this', () => { let expression = parser.parse('$this'); - expect(expression instanceof AccessThis).toBe(true); + verifyEqual(expression, new AccessThis(0)); }); it('translates $this.member to AccessScope', () => { let expression = parser.parse('$this.foo'); - expect(expression instanceof AccessScope).toBe(true); - expect(expression.name).toBe('foo'); + verifyEqual(expression, + new AccessScope('foo', 0) + ); }); it('translates $this() to CallFunction', () => { let expression = parser.parse('$this()'); - expect(expression instanceof CallFunction).toBe(true); - expect(expression.func instanceof AccessThis).toBe(true); + verifyEqual(expression, + new CallFunction(new AccessThis(0), [])); }); it('translates $this.member() to CallScope', () => { let expression = parser.parse('$this.foo(x)'); - expect(expression instanceof CallScope).toBe(true); - expect(expression.name).toBe('foo'); - expect(expression.args).toEqual([new AccessScope('x', 0)]); + verifyEqual(expression, + new CallScope('foo', [new AccessScope('x', 0)], 0) + ); }); it('parses $parent', () => { let s = '$parent'; for (let i = 1; i < 10; i++) { let expression = parser.parse(s); - expect(expression instanceof AccessThis).toBe(true); - expect(expression.ancestor).toBe(i); + verifyEqual(expression, new AccessThis(i)); s += '.$parent'; } }); @@ -183,10 +536,9 @@ describe('Parser', () => { for (let i = 1; i < 10; i++) { let s = `$parent${child} | foo`; let expression = parser.parse(s); - expect(expression instanceof ValueConverter).toBe(true); - expect(expression.name).toBe('foo'); - expect(expression.expression instanceof AccessThis).toBe(true); - expect(expression.expression.ancestor).toBe(i); + verifyEqual(expression, + new ValueConverter(new AccessThis(i), 'foo', []) + ); child += '.$parent'; } }); @@ -196,11 +548,9 @@ describe('Parser', () => { for (let i = 1; i < 10; i++) { let s = `$parent${child}.bar | foo`; let expression = parser.parse(s); - expect(expression instanceof ValueConverter).toBe(true); - expect(expression.name).toBe('foo'); - expect(expression.expression instanceof AccessScope).toBe(true); - expect(expression.expression.name).toBe('bar'); - expect(expression.expression.ancestor).toBe(i); + verifyEqual(expression, + new ValueConverter(new AccessScope('bar', i), 'foo', []) + ); child += '.$parent'; } }); @@ -210,10 +560,9 @@ describe('Parser', () => { for (let i = 1; i < 10; i++) { let s = `$parent${child} & foo`; let expression = parser.parse(s); - expect(expression instanceof BindingBehavior).toBe(true); - expect(expression.name).toBe('foo'); - expect(expression.expression instanceof AccessThis).toBe(true); - expect(expression.expression.ancestor).toBe(i); + verifyEqual(expression, + new BindingBehavior(new AccessThis(i), 'foo', []) + ); child += '.$parent'; } }); @@ -223,11 +572,9 @@ describe('Parser', () => { for (let i = 1; i < 10; i++) { let s = `$parent${child}.bar & foo`; let expression = parser.parse(s); - expect(expression instanceof BindingBehavior).toBe(true); - expect(expression.name).toBe('foo'); - expect(expression.expression instanceof AccessScope).toBe(true); - expect(expression.expression.name).toBe('bar'); - expect(expression.expression.ancestor).toBe(i); + verifyEqual(expression, + new BindingBehavior(new AccessScope('bar', i), 'foo', []) + ); child += '.$parent'; } }); @@ -236,9 +583,9 @@ describe('Parser', () => { let s = '$parent.foo'; for (let i = 1; i < 10; i++) { let expression = parser.parse(s); - expect(expression instanceof AccessScope).toBe(true); - expect(expression.name).toBe('foo'); - expect(expression.ancestor).toBe(i); + verifyEqual(expression, + new AccessScope('foo', i) + ); s = '$parent.' + s; } }); @@ -247,9 +594,9 @@ describe('Parser', () => { let s = '$parent.foo()'; for (let i = 1; i < 10; i++) { let expression = parser.parse(s); - expect(expression instanceof CallScope).toBe(true); - expect(expression.name).toBe('foo'); - expect(expression.ancestor).toBe(i); + verifyEqual(expression, + new CallScope('foo', [], i) + ); s = '$parent.' + s; } }); @@ -258,9 +605,9 @@ describe('Parser', () => { let s = '$parent()'; for (let i = 1; i < 10; i++) { let expression = parser.parse(s); - expect(expression instanceof CallFunction).toBe(true); - expect(expression.func instanceof AccessThis).toBe(true); - expect(expression.func.ancestor).toBe(i); + verifyEqual(expression, + new CallFunction(new AccessThis(i), []) + ); s = '$parent.' + s; } }); @@ -269,68 +616,275 @@ describe('Parser', () => { let s = '$parent[0]'; for (let i = 1; i < 10; i++) { let expression = parser.parse(s); - expect(expression instanceof AccessKeyed).toBe(true); - expect(expression.object instanceof AccessThis).toBe(true); - expect(expression.object.ancestor).toBe(i); - expect(expression.key instanceof LiteralPrimitive).toBe(true); - expect(expression.key.value).toBe(0); + verifyEqual(expression, + new AccessKeyed( + new AccessThis(i), + new LiteralPrimitive(0) + ) + ); s = '$parent.' + s; } }); it('handles $parent inside CallMember', () => { let expression = parser.parse('matcher.bind($parent)'); - expect(expression instanceof CallMember).toBe(true); - expect(expression.name).toBe('bind'); - expect(expression.args.length).toBe(1); - expect(expression.args[0] instanceof AccessThis).toBe(true); - expect(expression.args[0].ancestor).toBe(1); + verifyEqual(expression, + new CallMember( + new AccessScope('matcher', 0), + 'bind', + [new AccessThis(1)] + ) + ); }); it('parses $parent in LiteralObject', () => { let expression = parser.parse('{parent: $parent}'); - expect(expression instanceof LiteralObject).toBe(true); - expect(expression.keys.length).toBe(1); - expect(expression.keys).toEqual(['parent']); - expect(expression.values.length).toBe(1); - expect(expression.values[0] instanceof AccessThis).toBe(true); - expect(expression.values[0].ancestor).toBe(1); - - expression = parser.parse('{parent: $parent, foo: bar}'); - expect(expression instanceof LiteralObject).toBe(true); - expect(expression.keys.length).toBe(2); - expect(expression.keys).toEqual(['parent', 'foo']); - expect(expression.values.length).toBe(2); - expect(expression.values[0] instanceof AccessThis).toBe(true); - expect(expression.values[0].ancestor).toBe(1); - expect(expression.values[1] instanceof AccessScope).toBe(true); - expect(expression.values[1].name).toBe('bar'); - }); - - it('parses es6 shorthand LiteralObject', () => { + verifyEqual(expression, + new LiteralObject( + ['parent'], + [new AccessThis(1)] + ) + ); + }); + + it('parses $parent and foo in LiteralObject', () => { + let expression = parser.parse('{parent: $parent, foo: bar}'); + verifyEqual(expression, + new LiteralObject( + [ + 'parent', + 'foo' + ], + [ + new AccessThis(1), + new AccessScope('bar', 0) + ] + ) + ); + }); + + it('parses es6 shorthand LiteralObject with one property', () => { + let expression = parser.parse('{foo}'); + verifyEqual(expression, + new LiteralObject( + ['foo'], + [new AccessScope('foo', 0)] + ) + ); + }); + + it('parses es6 shorthand LiteralObject with two properties', () => { let expression = parser.parse('{ foo, bar }'); - expect(expression instanceof LiteralObject).toBe(true); - expect(expression.keys.length).toBe(2); - expect(expression.values.length).toBe(2); + verifyEqual(expression, + new LiteralObject( + [ + 'foo', + 'bar' + ], + [ + new AccessScope('foo', 0), + new AccessScope('bar', 0) + ] + ) + ); + }); - expect(expression.values[0] instanceof AccessScope).toBe(true); - expect(expression.values[0].name).toBe('foo'); - expect(expression.values[1] instanceof AccessScope).toBe(true); - expect(expression.values[1].name).toBe('bar'); + it('parses empty LiteralObject', () => { + let expression = parser.parse('{}'); + verifyEqual(expression, + new LiteralObject([], []) + ); }); - it('does not parse invalid shorthand properties', () => { - let pass = false; - try { - parser.parse('{ foo.bar, bar.baz }'); - pass = true; - } catch (e) { pass = false; } - expect(pass).toBe(false); + it('parses LiteralObject with string literal property', () => { + let expression = parser.parse('{"foo": "bar"}'); + verifyEqual(expression, + new LiteralObject( + ['foo'], + [new LiteralString('bar')] + ) + ); + }); - try { - parser.parse('{ "foo.bar" }'); - pass = true; - } catch (e) { pass = false; } - expect(pass).toBe(false); + it('parses LiteralObject with numeric literal property', () => { + let expression = parser.parse('{42: "foo"}'); + verifyEqual(expression, + new LiteralObject( + [42], + [new LiteralString('foo')] + ) + ); }); + + describe('does not parse LiteralObject with computed property', () => { + const expressions = [ + '{ []: "foo" }', + '{ [42]: "foo" }', + '{ ["foo"]: "bar" }', + '{ [foo]: "bar" }' + ]; + + for (const expr of expressions) { + it(expr, () => { + verifyError(expr, 'Unexpected token ['); + }); + } + }); + + describe('does not parse invalid shorthand properties', () => { + const expressions = [ + '{ foo.bar }', + '{ foo.bar, bar.baz }', + '{ "foo" }', + '{ "foo.bar" }', + '{ 42 }', + '{ 42, 42 }', + '{ [foo] }', + '{ ["foo"] }', + '{ [42] }' + ]; + + for (const expr of expressions) { + it(expr, () => { + verifyError(expr, 'expected'); + }); + } + }); + + describe('does not parse multiple expressions', () => { + const expressions = [ + ';', + 'foo;', + ';foo', + 'foo&bar;baz|qux' + ]; + + for (const expr of expressions) { + it(expr, () => { + verifyError(expr, 'Multiple expressions are not allowed'); + }); + } + }); + + describe('throw on extra closing token', () => { + const tests = [ + { expr: ')', token: ')' }, + { expr: ']', token: ']' }, + { expr: '}', token: '}' }, + { expr: 'foo())', token: ')' }, + { expr: 'foo[x]]', token: ']' }, + { expr: '{foo}}', token: '}' } + ]; + + for (const test of tests) { + it(test.expr, () => { + verifyError(test.expr, `Unconsumed token ${test.token}`); + }); + } + }); + + describe('throw on missing expected token', () => { + const tests = [ + { expr: '(foo', token: ')' }, + { expr: '[foo', token: ']' }, + { expr: '{foo', token: ',' }, + { expr: 'foo(bar', token: ')' }, + { expr: 'foo[bar', token: ']' }, + { expr: 'foo.bar(baz', token: ')' }, + { expr: 'foo.bar[baz', token: ']' } + ]; + + for (const test of tests) { + it(test.expr, () => { + verifyError(test.expr, `Missing expected token ${test.token}`); + }); + } + }); + + describe('throw on assigning unassignable', () => { + const expressions = [ + '(foo ? bar : baz) = qux', + '$this = foo', + 'foo() = bar', + 'foo.bar() = baz', + '!foo = bar', + '-foo = bar', + '\'foo\' = bar', + '42 = foo', + '[] = foo', + '{} = foo' + ].concat(operators.map(op => `foo ${op} bar = baz`)); + + for (const expr of expressions) { + it(expr, () => { + verifyError(expr, 'is not assignable'); + }); + } + }); + + it('throw on incomplete conditional', () => { + verifyError('foo ? bar', 'requires all 3 expressions'); + }); + + describe('throw on invalid primary expression', () => { + const expressions = ['.', ',', '&', '|', '=', '<', '>', '*', '%', '/']; + expressions.push(...expressions.map(e => e + ' ')); + for (const expr of expressions) { + it(expr, () => { + if (expr.length === 1) { + verifyError(expr, `Unexpected end of expression`); + } else { + verifyError(expr, `Unexpected token ${expr.slice(0, 0)}`); + } + }); + } + }); + + describe('throw on invalid exponent', () => { + const expressions = [ + '1e', + '1ee', + '1e.' + ]; + + for (const expr of expressions) { + it(expr, () => { + verifyError(expr, 'Invalid exponent'); + }); + } + }); + + function verifyError(expression, errorMessage) { + let error = null; + try { + parser.parse(expression); + } catch(e) { + error = e; + } + + expect(error).not.toBeNull(); + expect(error.message).toContain(errorMessage); + } + }); + +function verifyEqual(actual, expected) { + if (typeof expected !== 'object' || expected === null || expected === undefined) { + expect(actual).toEqual(expected); + return; + } + if (expected instanceof Array) { + for (let i = 0; i < expected.length; i++) { + verifyEqual(actual[i], expected[i]); + } + return; + } + + expect(actual).toEqual(jasmine.any(expected.constructor)); + for (const prop of Object.keys(expected)) { + verifyEqual(actual[prop], expected[prop]); + } +} +function unicodeEscape(str) { + return str.replace(/[\s\S]/g, c => `\\u${('0000' + c.charCodeAt().toString(16)).slice(-4)}`); +}