jora
Version:
JavaScript object query engine
1,226 lines (1,022 loc) • 38.1 kB
JavaScript
import { toNumberLiteral, toRegExpLiteral, toStringLiteral } from './convert-to-literal.js';
import { offsetToLoc, showPosition } from './error.js';
import { LITERALS, tokenizeSync } from './tokenizer.js';
import {
TOKEN_NUMBER, TOKEN_STRING, TOKEN_REGEXP, TOKEN_LITERAL, TOKEN_IDENT, TOKEN_$IDENT,
TOKEN_AT, TOKEN_HASH, TOKEN_$, TOKEN_$$, TOKEN_AND, TOKEN_OR, TOKEN_NOT, TOKEN_NO,
TOKEN_IS, TOKEN_IN, TOKEN_NOTIN, TOKEN_HAS, TOKEN_HASNO, TOKEN_ORDER, TOKEN_METHOD_OPEN,
TOKEN_$METHOD_OPEN, TOKEN_TEMPLATE, TOKEN_TPL_START, TOKEN_TPL_CONTINUE, TOKEN_TPL_END,
TOKEN_DOT, TOKEN_DOT_DOT, TOKEN_DOT_DOT_DOT, TOKEN_DOT_OPEN_PAREN, TOKEN_DOT_OPEN_BRACKET,
TOKEN_DOT_DOT_OPEN_PAREN, TOKEN_PIPE, TOKEN_ARROW, TOKEN_EQUALS, TOKEN_NOT_EQUALS,
TOKEN_MATCH, TOKEN_LESS_THAN, TOKEN_LESS_THAN_EQUALS, TOKEN_GREATER_THAN,
TOKEN_GREATER_THAN_EQUALS, TOKEN_PLUS, TOKEN_MINUS, TOKEN_MULTIPLY, TOKEN_DIVIDE,
TOKEN_MODULO, TOKEN_NULLISH_COALESCING, TOKEN_QUESTION, TOKEN_OPEN_PAREN, TOKEN_CLOSE_PAREN,
TOKEN_OPEN_BRACKET, TOKEN_CLOSE_BRACKET, TOKEN_OPEN_BRACE, TOKEN_CLOSE_BRACE, TOKEN_COMMA,
TOKEN_COLON, TOKEN_SEMICOLON, TOKEN_EOF, TOKEN_BAD,
tokenNames, createTokenTable, createTokenSet
} from './tokens.js';
const RECOVERABLE_ERROR = Symbol('RECOVERABLE_ERROR');
const TOKEN_ANY = -1;
const SPREAD_ARRAY = true;
const SPREAD_OBJECT = false;
// --- Operator precedence ---------------------------------------------------
// Numeric precedence values (higher number = binds tighter). Non-operator
// tokens implicitly have precedence -1 (see createTokenTable implementation),
// allowing a single numeric comparison to both detect an operator and decide
// whether it should bind at the current recursion depth (precedence climbing).
const RIGHT_ASSOCIATIVE = createTokenSet(TOKEN_QUESTION);
const PRECEDENCE = createTokenTable(
TOKEN_ORDER, 1, // ORDER has lower precedence than PIPE
TOKEN_PIPE, 2, // So pipeline binds tighter: `foo | bar desc` === `(foo | bar) desc`
TOKEN_QUESTION, 3,
TOKEN_IS, 4,
TOKEN_OR, 5,
TOKEN_AND, 6,
TOKEN_NULLISH_COALESCING, 7,
TOKEN_NOT, 8,
TOKEN_NO, 8,
TOKEN_IN, 9,
TOKEN_NOTIN, 9,
TOKEN_HAS, 9,
TOKEN_HASNO, 9,
TOKEN_EQUALS, 10,
TOKEN_NOT_EQUALS, 10,
TOKEN_MATCH, 10,
TOKEN_LESS_THAN, 11,
TOKEN_LESS_THAN_EQUALS, 11,
TOKEN_GREATER_THAN, 11,
TOKEN_GREATER_THAN_EQUALS, 11,
TOKEN_PLUS, 12,
TOKEN_MINUS, 12,
TOKEN_MULTIPLY, 12,
TOKEN_DIVIDE, 12,
TOKEN_MODULO, 12
);
// ----------------------------------------------------------------------------
// Hand‑written recursive descent + precedence climbing parser that consumes a
// token stream produced by the tokenizer. Whitespace and comments are
// currently skipped at tokenization time; range tracking therefore relies on
// previous meaningful token's end (see endRange()).
// FIXME: This is a parity-oriented implementation mirroring legacy quirks
// (notably some range spans and Block wrappers) before subsequent cleanups.
export function parse(input, options) {
const tokens = tokenizeSync(input, options);
let index = 0;
let current = tokens[index];
let recoverable = false;
let result = null;
try {
return result = parseBlock();
} finally {
// Ensure nothing left after parsing
if (result) {
advance(TOKEN_EOF);
}
}
// ---- Error helpers ------------------------------------------------------
function throwError(rawMessage, details) {
if (recoverable) {
// console.log(current, Error().stack+'');
throw RECOVERABLE_ERROR;
}
let start = current.start;
let end = current.end;
details ??= {};
if (Array.isArray(details?.inside)) {
start += details.inside[0];
end = start + details.inside[1];
}
// FIXME: To match legacy error reporting
if (current.type === TOKEN_BAD) {
rawMessage = `Bad input on line ${offsetToLoc(input, start).line} column ${offsetToLoc(input, start).column}`;
}
const startLoc = offsetToLoc(input, start);
const message = current.type === TOKEN_BAD || details?.inside
? [ // FIXME: To match legacy error reporting
rawMessage,
'',
showPosition(input, start)
]
: [
`Parse error on line ${startLoc.line}:`,
'',
showPosition(input, start),
...rawMessage ? ['', rawMessage] : []
];
const expected = current.type === TOKEN_BAD || !Array.isArray(details?.expected) ? null : [...new Set([].concat(
...details.expected
).map(t => `'${tokenNames[t]}'`))];
if (expected) {
message.push(
'',
'Expecting ' + expected.join(', ') + ' got ' + tokenNames[current.type]
);
}
throw Object.assign(new SyntaxError(message.join('\n')), {
details: {
rawMessage,
text: current.value,
token: current.name,
expected,
loc: {
range: [start, end],
start: startLoc,
end: offsetToLoc(input, end)
}
}
});
}
function throwExpected(...expected) {
throwError(null, {
expected
});
}
function throwIfNoMatch(expectedType = TOKEN_ANY) {
if (expectedType !== TOKEN_ANY && current.type !== expectedType) {
throwExpected(expectedType);
}
}
function disableRecovery(fn) {
const savedRecoverable = recoverable;
recoverable = false;
const result = fn();
recoverable = savedRecoverable;
return result;
}
function maybe(fn, ...args) {
const savedRecoverable = recoverable;
const savedIndex = index;
try {
recoverable = true;
return fn(...args);
} catch (error) {
if (error !== RECOVERABLE_ERROR) {
throw error;
}
index = savedIndex;
current = tokens[index];
return null;
} finally {
recoverable = savedRecoverable;
}
}
// ---- Token consumption helpers -----------------------------------------
function advance(expectedType) {
const token = current;
// assert expected type
throwIfNoMatch(expectedType);
// Stay on EOF if at end
index = Math.min(index + 1, tokens.length - 1);
current = tokens[index];
return token;
}
function advanceIfMatch(type) {
return match(type)
? advance()
: null;
}
function getValueAndAdvance(expectedType) {
const value = current.value;
advance(expectedType);
return value;
}
function getRangeAndAdvance(expectedType) {
const range = [current.start, current.end];
advance(expectedType);
return range;
}
function consumeSurrounded(typeOpen, fn, typeClose) {
advance(typeOpen);
const value = fn();
advance(typeClose);
return value;
}
// ---- Lookahead helpers --------------------------------------------------
function match(type) {
return current.type === type;
}
function nextMatch(type) {
const nextTokenType = index < tokens.length - 1
? tokens[index + 1].type
: TOKEN_EOF;
return nextTokenType === type;
}
// ---- Range tracking -----------------------------------------------------
function startRange() {
return current.start;
}
function endRange(start) {
// Use the previous token's end, because we iterate over meaningful tokens,
// i.e. skipping whitespace and comment tokens.
// There may be skipped tokens between meaningful ones, so we need to end the range
// at the previous token's end, excluding the skipped whitespace/comment tokens.
//
// FIXME: Tokenizer should emit all the tokens, including whitespaces and comments,
// this will allow to avoid hacks with previous token's end
const end = index > 0 ? tokens[index - 1].end : 0;
// When no tokens were consumed within a construct (empty block/expression),
// start may exceed end due to whitespace skipping. Collapse to [end, end].
if (start > end) {
return [end, end];
}
return [start, end];
}
// Legacy quirk: postfix constructs (property access, map, filter, etc.) inherit
// the left expression's start for their own range. This inflates node ranges.
// Retained strictly for parity; will be removed once range normalization lands.
//
// FIXME: Postfix nodes should own only their syntactic span.
function legacyPrefixStartRange(node) {
return node?.range[0] ?? startRange();
}
// Minimal helpers for small repeated constructions. These remain until
// structural changes eliminate their few remaining multi-call sites.
function createPlaceholder() {
// Use the end of the last consumed token as the placeholder position.
// When no tokens have been consumed (index=0), use 0.
const pos = index > 0 ? tokens[index - 1].end : 0;
return {
type: 'Placeholder',
range: [pos, pos]
};
}
function maybeBlock(definitions, body, start) {
return definitions !== null
? createBlock(definitions, body, endRange(start))
: body;
}
function createBlock(definitions, body, range) {
return {
type: 'Block',
definitions,
body,
range
};
}
function createMethod(reference, args, range) {
return {
type: 'Method',
reference,
arguments: args,
range
};
}
// ============================= PARSER ====================================
// Grammar (simplified / informal):
// Query -> Block
// Block -> Definition* Expression? (wrapped in Block node)
// Definition -> Declarator (':' Expression)? ';'
// Expression -> precedence climbing over binary / pipeline / order / ternary
// Primary -> literals | references | special refs | arrays | objects | fn | templates | parentheses
// Postfix chain -> property / method / map / mapRecursive / filter / slice / bracket access (left associative)
// Assertion -> ( 'not'? ( IDENT | LITERAL | '(' Assertion (('and'|'or') Assertion )* ')' | $ident-as-empty-call ) )
// The parser intentionally preserves certain legacy AST shapes (e.g., Block
// wrappers inside Parentheses / Pipeline when definitions precede an expr).
// TODO: Remove those wrappers after downstream tooling migrates.
function parseBlock() {
const start = startRange();
const definitions = parseDefinitions() || [];
const body = parseExpression() || createPlaceholder();
return createBlock(
definitions,
body,
endRange(start)
);
}
function parseDefinitions() {
let definitions = null;
for (let definition; definition = maybeDefinition();) {
if (definitions === null) {
definitions = [definition];
} else {
definitions.push(definition);
}
}
return definitions;
}
function maybeDefinition() {
// Fast check for definition start. This allow to avoid maybe() calls
// which involve try/catch/rollback for non-definition expressions and downgrades performance.
if (match(TOKEN_$) || match(TOKEN_$IDENT)) {
return maybe(parseDefinition);
}
return null;
}
function parseDefinition() {
const start = startRange();
const declarator = parseDeclarator();
const value = advanceIfMatch(TOKEN_COLON)
? disableRecovery(parseExpression)
: null;
advance(TOKEN_SEMICOLON);
return {
type: 'Definition',
declarator,
value,
range: endRange(start)
};
}
function parseDeclarator() {
const start = startRange();
const name = match(TOKEN_$IDENT)
? getValueAndAdvance().slice(1)
: advanceIfMatch(TOKEN_$)
? null
: throwExpected(TOKEN_$IDENT, TOKEN_$);
return {
type: 'Declarator',
name,
range: endRange(start)
};
}
function parseIdentifier(preserveDollar = false) {
const { start, end, type, value } = current;
let name = getValueAndAdvance();
if (!preserveDollar) {
// Remove $ prefix
if (type === TOKEN_$IDENT || type === TOKEN_$METHOD_OPEN) {
name = name.slice(1);
}
}
// Remove "(" suffix and adjust range to exclude the parenthesis
if (type === TOKEN_METHOD_OPEN || type === TOKEN_$METHOD_OPEN) {
name = name.slice(0, -1);
}
return {
type: 'Identifier',
name,
range: [start, end - !value.endsWith(name)]
};
}
function parseReference() {
const name = parseIdentifier();
return {
type: 'Reference',
name,
range: name.range.slice()
};
}
function parseSpecialReference() {
switch (current.type) {
case TOKEN_AT:
return {
type: 'Data',
range: getRangeAndAdvance()
};
case TOKEN_HASH:
return {
type: 'Context',
range: getRangeAndAdvance()
};
case TOKEN_$:
return {
type: 'Current',
range: getRangeAndAdvance()
};
case TOKEN_$$:
return {
type: 'Arg1',
range: getRangeAndAdvance()
};
default:
throwExpected(TOKEN_AT, TOKEN_HASH, TOKEN_$, TOKEN_$$);
}
}
// FIXME: Should not convert to literals on parse, but this is needed for parity with legacy parser
function parseLiteralValue() {
const start = startRange();
let value;
switch (current.type) {
case TOKEN_NUMBER:
value = toNumberLiteral(current.value, throwError);
break;
case TOKEN_STRING:
value = toStringLiteral(current.value, false, 1, throwError);
break;
case TOKEN_TEMPLATE:
case TOKEN_TPL_END:
value = toStringLiteral(current.value, true, 1, throwError);
break;
case TOKEN_TPL_START:
case TOKEN_TPL_CONTINUE:
value = toStringLiteral(current.value, true, 2, throwError);
break;
case TOKEN_REGEXP:
value = toRegExpLiteral(current.value, throwError);
break;
case TOKEN_LITERAL:
value = LITERALS.get(current.value);
break;
default:
throwExpected(
TOKEN_NUMBER, TOKEN_STRING, TOKEN_REGEXP, TOKEN_LITERAL,
TOKEN_TEMPLATE, TOKEN_TPL_START, TOKEN_TPL_CONTINUE, TOKEN_TPL_END
);
}
advance();
return {
type: 'Literal',
value,
range: endRange(start)
};
}
function parseExpression(minPrec = 0) {
let left = parseUnary();
// Precedence climbing loop. For non-operators PRECEDENCE[...] is -1, so the
// condition fails quickly without extra operator-type checks.
while (PRECEDENCE[current.type] >= minPrec && !match(TOKEN_EOF)) {
// Associativity tweak: left-associative ops parse RHS with (prec + 1)
// so same-precedence operators group left; right-associative (currently '?')
// reuse the same precedence value for right grouping.
const prec = PRECEDENCE[current.type] + !RIGHT_ASSOCIATIVE[current.type];
switch (current.type) {
case TOKEN_QUESTION:
left = parseTernaryConditional(left, prec);
break;
case TOKEN_PIPE:
left = parsePipeline(left);
break;
case TOKEN_ORDER:
left = parseCompareFunction(left);
break;
default:
left = parseBinaryOperator(left, prec);
}
}
return left;
}
function parseUnary() {
switch (current.type) {
case TOKEN_NOT:
case TOKEN_NO:
case TOKEN_PLUS:
case TOKEN_MINUS:
case TOKEN_IS:
return parseUnaryPrefix();
default:
return parsePostfix();
}
}
function parseUnaryPrefix() {
const start = startRange();
const prec = PRECEDENCE[current.type];
const operator = getValueAndAdvance();
const argument = operator === 'is'
? parseAssertion() // 'is <assertion>' is syntactically distinct
: parseExpression(prec + 1); // Avoid self-binding by raising minimal precedence
return {
type: 'Prefix',
operator,
argument,
range: endRange(start)
};
}
function parsePostfix() {
let expr = parsePrimary();
// Postfix chain: consume as long as the next token starts a postfix form.
while (!match(TOKEN_EOF)) {
switch (current.type) {
case TOKEN_IS:
expr = parseAssertionPostfix(expr);
break;
case TOKEN_DOT:
expr = nextMatch(TOKEN_IDENT)
? parseGetProperty(expr, true)
: nextMatch(TOKEN_METHOD_OPEN) || nextMatch(TOKEN_$METHOD_OPEN)
? parseMethodCall(expr, true)
: throwExpected(TOKEN_IDENT, TOKEN_METHOD_OPEN, TOKEN_$METHOD_OPEN);
break;
case TOKEN_DOT_OPEN_PAREN:
expr = parseMap(expr);
break;
case TOKEN_DOT_OPEN_BRACKET:
expr = parseFilter(expr);
break;
case TOKEN_DOT_DOT:
case TOKEN_DOT_DOT_OPEN_PAREN:
expr = parseMapRecursive(expr);
break;
case TOKEN_OPEN_BRACKET:
// Ambiguity: either slice notation or bracket access. Try slice
// first (backtracks on failure) otherwise treat as access.
expr = maybe(parseSliceNotation, expr) || parseBracketAccess(expr);
break;
default:
return expr;
}
}
return expr;
}
function parsePrimary() {
switch (current.type) {
case TOKEN_NUMBER:
case TOKEN_STRING:
case TOKEN_REGEXP:
case TOKEN_LITERAL:
return parseLiteralValue();
case TOKEN_TEMPLATE:
case TOKEN_TPL_START:
return parseTemplate();
case TOKEN_AT:
case TOKEN_HASH:
case TOKEN_$:
case TOKEN_$$:
return parseSpecialReference();
case TOKEN_$IDENT:
return maybeFunction() || parseReference();
case TOKEN_IDENT:
return parseGetProperty();
case TOKEN_METHOD_OPEN:
case TOKEN_$METHOD_OPEN:
return parseMethodCall();
case TOKEN_OPEN_BRACKET:
return maybe(parseSliceNotation) || parseArray();
case TOKEN_OPEN_BRACE:
return parseObject();
case TOKEN_OPEN_PAREN:
return maybeFunction() || parseParentheses();
case TOKEN_ARROW:
return parseFunction();
default:
return null;
}
}
function parseAssertionPostfix(argument) {
const start = legacyPrefixStartRange(argument);
advance(TOKEN_IS);
return {
type: 'Postfix',
operator: parseAssertion(),
argument,
range: endRange(start)
};
}
function parseAssertion() {
const start = startRange();
const negation = advanceIfMatch(TOKEN_NOT) !== null;
let assertion = [];
// Assertion grammar allows nested parenthesized boolean combinations
// joined by 'and' / 'or', plus identifiers, literals, and a legacy quirk
// where $ident becomes an empty Method call (FIXME below).
switch (current.type) {
case TOKEN_OPEN_PAREN:
advance(TOKEN_OPEN_PAREN);
do {
if (assertion.length) {
assertion.push(
match(TOKEN_AND) || match(TOKEN_OR)
? getValueAndAdvance()
: throwExpected(TOKEN_AND, TOKEN_OR)
);
}
assertion.push(parseAssertion());
} while (!advanceIfMatch(TOKEN_CLOSE_PAREN));
break;
case TOKEN_IDENT:
case TOKEN_LITERAL:
assertion = parseIdentifier();
break;
case TOKEN_$IDENT:
// Legacy: $variable in assertion context -> Method node with no args.
// FIXME: Should be a simple Reference; adjust after parity phase.
assertion = createMethod(parseReference(), [], endRange(start));
break;
default:
throwExpected(TOKEN_OPEN_PAREN, TOKEN_IDENT, TOKEN_LITERAL, TOKEN_$IDENT);
}
return {
type: 'Assertion',
negation,
assertion,
range: endRange(start)
};
}
function parseGetProperty(value = null, prefixed = false) {
const start = legacyPrefixStartRange(value);
if (prefixed) {
advance(TOKEN_DOT);
}
const property = parseIdentifier();
return {
type: 'GetProperty',
value,
property,
range: endRange(start)
};
}
function parseMethod() {
const start = startRange();
const methodRef = match(TOKEN_METHOD_OPEN)
? parseIdentifier()
: match(TOKEN_$METHOD_OPEN)
? parseReference()
: throwExpected(TOKEN_METHOD_OPEN, TOKEN_$METHOD_OPEN);
const args = [];
if (!match(TOKEN_CLOSE_PAREN)) {
do {
args.push(parseExpression());
} while (advanceIfMatch(TOKEN_COMMA));
}
advance(TOKEN_CLOSE_PAREN);
return createMethod(methodRef, args, endRange(start));
}
function parseMethodCall(value = null, prefixed = false) {
const start = legacyPrefixStartRange(value);
if (prefixed) {
advance(TOKEN_DOT);
}
const method = parseMethod();
return {
type: 'MethodCall',
value,
method,
range: endRange(start)
};
}
function parseTemplate() {
const start = startRange();
const values = [];
if (match(TOKEN_TEMPLATE)) {
values.push(parseLiteralValue());
} else {
// Token sequence: TPL_START ( expr TPL_CONTINUE )* TPL_END
values.push(parseLiteralValue(TOKEN_TPL_START));
while (true) {
values.push(parseExpression());
if (!match(TOKEN_TPL_CONTINUE)) {
break;
}
values.push(parseLiteralValue(TOKEN_TPL_CONTINUE));
}
values.push(parseLiteralValue(TOKEN_TPL_END));
}
return {
type: 'Template',
values,
range: endRange(start)
};
}
function maybeFunction() {
// Fast check for function start. This allow to avoid maybe() calls
// which involve try/catch/rollback for non-definition expressions and downgrades performance.
// Here we suppose that function start already matched to TOKEN_$IDENT or TOKEN_OPEN_PAREN,
// so we just check possible continuation tokens before falling back to maybe(parseFunction).
if (match(TOKEN_OPEN_PAREN)) {
if (!nextMatch(TOKEN_$IDENT) && !nextMatch(TOKEN_ARROW) && !nextMatch(TOKEN_CLOSE_PAREN)) {
return null;
}
} else if (!nextMatch(TOKEN_ARROW)) {
return null;
}
return maybe(parseFunction);
}
function parseFunction() {
const start = startRange();
const args = [];
if (advanceIfMatch(TOKEN_OPEN_PAREN)) {
// Parameter list (standard form)
if (!match(TOKEN_CLOSE_PAREN)) {
do {
// Disallow any identifiers except for $ident
throwIfNoMatch(TOKEN_$IDENT);
args.push(parseIdentifier());
} while (advanceIfMatch(TOKEN_COMMA));
}
advance(TOKEN_CLOSE_PAREN);
} else if (match(TOKEN_$IDENT)) {
args.push(parseIdentifier());
}
advance(TOKEN_ARROW);
const body = parseExpression() || throwError('Expected non-empty expression');
return {
type: 'Function',
arguments: args,
body,
range: endRange(start)
};
}
function parseCompareFunction(expr) {
const start = legacyPrefixStartRange(expr);
const compares = [parseCompare(expr)];
while (advanceIfMatch(TOKEN_COMMA)) {
// Ensure we don't nest CompareFunction nodes by parsing RHS with
// tighter precedence than ORDER itself.
compares.push(
parseCompare(parseExpression(PRECEDENCE[TOKEN_ORDER] + 1))
);
}
return {
type: 'CompareFunction',
compares,
range: endRange(start)
};
}
function parseCompare(query) {
const start = legacyPrefixStartRange(query);
const order = getValueAndAdvance(TOKEN_ORDER);
return {
type: 'Compare',
query,
order,
range: endRange(start)
};
}
function parseParentheses() {
const start = startRange();
advance(TOKEN_OPEN_PAREN);
const definitions = parseDefinitions();
const expression = parseExpression() || createPlaceholder();
advance(TOKEN_CLOSE_PAREN);
// Definitions inside parentheses produce a Block node wrapper when definitions are present.
// FIXME: The block wrapper should be removed
return {
type: 'Parentheses',
body: maybeBlock(definitions, expression, start),
range: endRange(start)
};
}
function parseArray() {
const start = startRange();
const elements = [];
advance(TOKEN_OPEN_BRACKET);
if (!advanceIfMatch(TOKEN_CLOSE_BRACKET)) {
do {
if (match(TOKEN_COMMA)) {
throwError('Expected expression before comma');
}
elements.push(
match(TOKEN_DOT_DOT_DOT)
? parseSpread(SPREAD_ARRAY)
: parseExpression()
);
} while (advanceIfMatch(TOKEN_COMMA) && !match(TOKEN_CLOSE_BRACKET));
advance(TOKEN_CLOSE_BRACKET);
}
return {
type: 'Array',
elements,
range: endRange(start)
};
}
// Slice notation: [ e : e (: e)? ]
function parseSliceNotation(expr = null) {
const start = legacyPrefixStartRange(expr);
const args = [
consumeSurrounded( // first expression before ':'
TOKEN_OPEN_BRACKET,
parseExpression,
TOKEN_COLON
),
parseExpression() // "e"
];
// Optional third expression
if (advanceIfMatch(TOKEN_COLON)) { // ":"
args.push(parseExpression()); // "e"
}
advance(TOKEN_CLOSE_BRACKET); // "]"
return {
type: 'SliceNotation',
value: expr,
arguments: args,
range: endRange(start)
};
}
function parseObject() {
const start = startRange();
advance(TOKEN_OPEN_BRACE);
// Allow leading local definitions (mirrors Block behavior)
const definitions = parseDefinitions();
const properties = [];
if (!match(TOKEN_CLOSE_BRACE)) {
// Allow trailing comma
do {
properties.push(
match(TOKEN_DOT_DOT_DOT)
? parseSpread(SPREAD_OBJECT)
: parseObjectEntry()
);
} while (advanceIfMatch(TOKEN_COMMA) && !match(TOKEN_CLOSE_BRACE));
}
advance(TOKEN_CLOSE_BRACE);
const object = {
type: 'Object',
properties, // FIXME: rename to entries
range: endRange(start)
};
// If we have definitions, wrap in a Block like legacy parser does
// FIXME: The block wrapper should be removed
return maybeBlock(definitions, object, start);
}
// FIXME: The shape of ObjectEntry should be changed to allow string values as key
// and pipeline property to avoid building Pipeline nodes
function parseObjectEntry() {
const start = startRange();
let key;
let value = null;
let pipeline = null;
switch (current.type) {
case TOKEN_METHOD_OPEN:
case TOKEN_$METHOD_OPEN: {
// Shorthand method entry: { size() } or { $length() }
const method = parseMethod();
// FIXME: Parity with legacy parser
key = {
type: 'Literal',
value: method.reference.type === 'Identifier'
? method.reference.name
: method.reference.name.name,
range: undefined
};
value = method;
// Check for continuation: { size() bool() } or { size().(expr) }
if (!match(TOKEN_COMMA) && !match(TOKEN_CLOSE_BRACE) && !match(TOKEN_EOF)) {
pipeline = value;
// FIXME: Parity with legacy parser
value = parsePipeline(pipeline, true);
}
break;
}
case TOKEN_IDENT: {
if (nextMatch(TOKEN_COLON) || nextMatch(TOKEN_COMMA) || nextMatch(TOKEN_CLOSE_BRACE) || nextMatch(TOKEN_EOF)) {
key = parseIdentifier();
} else {
// Shorthand with continuation: { foo .bar } or { foo .[x] }
pipeline = parseGetProperty(null, false);
key = pipeline.property;
// FIXME: Parity with legacy parser
value = parsePipeline(pipeline, true);
}
break;
}
case TOKEN_$IDENT: {
if (nextMatch(TOKEN_COLON)) {
key = parseIdentifier(true);
} else {
key = parseReference();
if (!match(TOKEN_COMMA) && !match(TOKEN_CLOSE_BRACE) && !match(TOKEN_EOF)) {
// Shorthand with continuation: { $var.size() }
pipeline = key;
key = key.name;
// FIXME: Parity with legacy parser
value = parsePipeline(pipeline, true);
}
}
break;
}
case TOKEN_LITERAL:
case TOKEN_STRING:
case TOKEN_NUMBER:
key = parseLiteralValue();
break;
case TOKEN_$:
key = parseSpecialReference();
break;
case TOKEN_OPEN_BRACKET: // Computed property name: [expression]
key = consumeSurrounded(
TOKEN_OPEN_BRACKET,
parseExpression,
TOKEN_CLOSE_BRACKET
);
advance(TOKEN_COLON);
value = parseExpression();
break;
default:
throwExpected(
TOKEN_IDENT, TOKEN_LITERAL, TOKEN_STRING, TOKEN_NUMBER,
TOKEN_$, TOKEN_$IDENT, TOKEN_OPEN_BRACKET,
TOKEN_METHOD_OPEN, TOKEN_$METHOD_OPEN
);
}
if (!pipeline && !value) {
value = advanceIfMatch(TOKEN_COLON) ? parseExpression() : null;
}
return {
type: 'ObjectEntry',
key,
value,
range: endRange(start)
};
}
function parseSpread(array) {
const start = startRange();
const query = advance(TOKEN_DOT_DOT_DOT) && parseExpression();
return {
type: 'Spread',
query,
array,
range: endRange(start)
};
}
function parseBracketAccess(value) {
const start = legacyPrefixStartRange(value);
const getter = consumeSurrounded(
TOKEN_OPEN_BRACKET,
parseExpression,
TOKEN_CLOSE_BRACKET
);
return {
type: 'Pick',
value,
getter,
range: endRange(start)
};
}
function parseMap(value) {
const start = legacyPrefixStartRange(value);
const query = consumeSurrounded(
TOKEN_DOT_OPEN_PAREN,
parseBlock,
TOKEN_CLOSE_PAREN
);
return {
type: 'Map',
value,
query,
range: endRange(start)
};
}
function parseMapRecursive(value) {
const start = legacyPrefixStartRange(value);
// Two syntactic forms:
// ..ident / ..method(...)
// ..( <block> )
const query = advanceIfMatch(TOKEN_DOT_DOT)
? match(TOKEN_IDENT)
? parseGetProperty()
: parseMethodCall()
: consumeSurrounded(
TOKEN_DOT_DOT_OPEN_PAREN,
parseBlock,
TOKEN_CLOSE_PAREN
);
return {
type: 'MapRecursive',
value,
query,
range: endRange(start)
};
}
function parseFilter(value) {
const start = legacyPrefixStartRange(value);
const query = consumeSurrounded(
TOKEN_DOT_OPEN_BRACKET,
parseBlock,
TOKEN_CLOSE_BRACKET
);
return {
type: 'Filter',
value,
query,
range: endRange(start)
};
}
function parsePipeline(left, implicit = false) {
const start = legacyPrefixStartRange(left);
if (implicit) {
advanceIfMatch(TOKEN_PIPE);
} else {
advance(TOKEN_PIPE);
}
// Right side may start with local definitions (mirrors top-level Block).
const definitions = !implicit ? parseDefinitions() : null;
const body = !implicit
? parseExpression(PRECEDENCE[TOKEN_PIPE] + 1) || createPlaceholder()
: parseExpression();
// If we have definitions, wrap in a Block like legacy parser does
// FIXME: The block wrapper should be removed
const right = maybeBlock(definitions, body, start);
return {
type: 'Pipeline',
left,
right,
range: endRange(start)
};
}
function parseTernaryConditional(test, prec) {
const start = legacyPrefixStartRange(test);
advance(TOKEN_QUESTION);
const consequent = parseExpression(prec) || createPlaceholder();
const alternate = advanceIfMatch(TOKEN_COLON)
? parseExpression(prec) || createPlaceholder() // '? a :' -> Placeholder
: null; // '? a' -> null
return {
type: 'Conditional',
test,
consequent,
alternate,
range: endRange(start)
};
}
function parseBinaryOperator(left, prec) {
const start = legacyPrefixStartRange(left);
const operator = getValueAndAdvance();
const right = parseExpression(prec) || throwError('Expected expression');
return {
type: 'Binary',
operator,
left,
right,
range: endRange(start)
};
}
}