UNPKG

jora

Version:

JavaScript object query engine

358 lines (299 loc) 10.9 kB
import { TOLERANT_TOKEN_PAIRS } from './tokenizer-tolerant-token-pairs.js'; import { TOKEN_NUMBER, TOKEN_STRING, TOKEN_REGEXP, TOKEN_LITERAL, TOKEN_IDENT, TOKEN_$IDENT, TOKEN_$, TOKEN_$$, TOKEN_METHOD_OPEN, TOKEN_$METHOD_OPEN, TOKEN_TEMPLATE, TOKEN_TPL_START, TOKEN_TPL_CONTINUE, TOKEN_TPL_END, TOKEN_DIVIDE, TOKEN_BAD, TOKEN_EOF, BALANCE_TOKEN_PAIR, KEYWORDS, STR_TO_TOKEN, PREVENT_PRIMITIVE, PREVENT_KEYWORD, KEYWORD_TOKENS, tokenNames } from './tokens.js'; // Predefined regex for efficient identifier reading const COMMENT_RX = /\/\/.*?(?:\n|\r\n?|\u2028|\u2029|$)|\/\*(?:.|\s)*?(?:\*\/|$)/y; const WHITESPACE_RX = /\s+/y; const REST_TOKENS_RX = new RegExp(Object.keys(STR_TO_TOKEN).map(s => s.replace(/[\[\]{}()^$|?*+.]/g, '\\$&')).join('|'), 'y'); const IDENTIFIER_RX = /(?:(?:[a-zA-Z_]|\\u[0-9a-fA-F]{4})(?:[a-zA-Z_$0-9]|\\u[0-9a-fA-F]{4})*)\b/y; const KEYWORD_RX = /(?:has no|not in|and|or|not|has|is|in|no|(?:asc|desc)(?:NA?|AN?)?)\b/y; const REGEXP_RX = /\/(?:\\.|[^/])+\/[gimsu]*/y; const NUMBER_RX = /(?:[_\d]*\.)?[_\d]+(?:[eE][-+]?[_\d]+)?\b|0[xX][_0-9a-fA-F]+\b/y; // RegExps are relaxed to allow broken strings; strings are validated during conversion into literals. // This provides better error messages, as it allows locating the exact problem position. const STRING_RX = /"(?:\\.|[^"])*"|'(?:\\.|[^'])*'/sy; const TEMPLATE_START_RX = /`(?:\\.|[^`\\$]|\$(?!\{))*(\$\{|`)/sy; const TEMPLATE_CONT_END_RX = /\}(?:\\.|[^`\\$]|\$(?!\{))*(\$\{|`)/sy; const NOOP = () => {}; // Literal value lookup export const LITERALS = new Map([ ['true', true], ['false', false], ['null', null], ['undefined', undefined], ['Infinity', Infinity], ['NaN', NaN] ]); export class Token { #input; #value; constructor(type, start, end, input, value = null) { this.type = type; this.start = start; this.end = end; this.#input = input; this.#value = value; } get name() { return tokenNames[this.type]; } get value() { return this.#value ?? (this.#value = this.#input.slice(this.start, this.end)); } } export function* tokenize(input, tolerant = false) { const nextToken = createTokenizer(input, tolerant); let token; while (token = nextToken()) { yield token; } } export function tokenizeSync(input, tolerant = false) { const nextToken = createTokenizer(input, tolerant); const tokens = []; let token; while (token = nextToken()) { tokens.push(token); } return tokens; } export function createTokenizer(input, options) { const { tolerant: tolerantMode = false, onComment: onCommentHandler } = typeof options === 'boolean' ? { tolerant: options } : options || {}; const onComment = typeof onCommentHandler === 'function' ? onCommentHandler : Array.isArray(onCommentHandler) ? (start, end) => onCommentHandler.push([start, end]) : NOOP; const length = input.length; let pos = 0; let done = false; let bracketStack = []; let preventPrimitive = false; let preventKeyword = false; let pendingToken = null; let prevTokenType = null; let prevTokenEnd = 0; function advance(count = 1) { pos = Math.min(pos + count, length); } function match(regexp) { regexp.lastIndex = pos; const match = regexp.exec(input); if (match) { const value = match[0]; advance(value.length); return value; } return null; } function peek() { return pos < length ? input[pos] : ''; } function peekNext() { const index = pos + 1; return index < length ? input[index] : ''; } function isIdentStart(ch) { return ( (ch >= 'A' && ch <= 'Z') || // A-Z (ch >= 'a' && ch <= 'z') || // a-z (ch === '_') || /* _ */ (ch === '\\' && peekNext() === 'u') ); } function isNumberStart(ch) { if (ch >= '0' && ch <= '9') { // 0-9 return true; } if (ch === '.') { const next = peekNext(); return (next >= '0' && next <= '9') || next === '_'; } return false; } function trackBracketBalance(tokenType) { if (BALANCE_TOKEN_PAIR[tokenType] !== -1) { bracketStack.push(BALANCE_TOKEN_PAIR[tokenType]); } else if (bracketBalanceTop() === tokenType) { bracketStack.pop(); } } function bracketBalanceTop() { return bracketStack.length > 0 ? bracketStack[bracketStack.length - 1] : -1; } // Tolerant mode tokenization wrapper function nextTokenTolerant(token) { // Check if we need to insert an empty IDENT in tolerant mode const shouldInsertEmptyIdent = TOLERANT_TOKEN_PAIRS.get(prevTokenType)?.[token.type]; if (shouldInsertEmptyIdent) { // Store new token as pending pendingToken = token; // Determine empty IDENT range based on surrounding token types: // - After keyword or before keyword: zero-width range // - Otherwise (after dot/operator, before operator/EOF): spans the gap let emptyStart; let emptyEnd; if (prevTokenType === null) { // First injected ident (no previous token): starts at input beginning emptyStart = 0; emptyEnd = KEYWORD_TOKENS[token.type] ? Math.max(0, token.start - 1) : token.start; } else if (KEYWORD_TOKENS[prevTokenType]) { // Previous is a keyword: zero-width at next token emptyStart = token.start; emptyEnd = token.start; } else if (KEYWORD_TOKENS[token.type]) { // Next token is a keyword: span from previous token's end // to just before the keyword (absorb whitespace gap) emptyStart = prevTokenEnd; emptyEnd = Math.max(prevTokenEnd, token.start - 1); } else { // Normal case: span from previous token's end to next token's start emptyStart = prevTokenEnd; emptyEnd = token.start; } return new Token(TOKEN_IDENT, emptyStart, emptyEnd, input, ''); } // Normal token processing prevTokenType = token.type; prevTokenEnd = token.end; return token; } // Strict mode tokenization function nextTokenStrict() { if (pos >= length) { done = true; return TOKEN_EOF; } const start = pos; const ch = input[pos]; // Numbers if (isNumberStart(ch) && match(NUMBER_RX)) { return TOKEN_NUMBER; } // Strings if ((ch === '"' || ch === "'") && match(STRING_RX)) { return TOKEN_STRING; } // Template literal/template start if (ch === '`' && match(TEMPLATE_START_RX)) { return input[pos - 1] == '`' ? TOKEN_TEMPLATE : TOKEN_TPL_START; } // Template continuation/end if (ch === '}' && bracketBalanceTop() === TOKEN_TPL_END && match(TEMPLATE_CONT_END_RX)) { return input[pos - 1] === '`' ? TOKEN_TPL_END : TOKEN_TPL_CONTINUE; } // Regular expressions (context-aware using state) if (ch === '/') { // If preventPrimitive state is active, treat as division if (!preventPrimitive && match(REGEXP_RX)) { return TOKEN_REGEXP; } // Treat as division if it doesn't look like regex advance(); return TOKEN_DIVIDE; } // Identifiers and method calls if (isIdentStart(ch)) { // Keywords if (!preventKeyword && match(KEYWORD_RX)) { return KEYWORDS[input.slice(start, pos)]; } if (match(IDENTIFIER_RX)) { // Check for literals if (!preventKeyword && LITERALS.has(input.slice(start, pos))) { return TOKEN_LITERAL; } // Check for method call if (peek() === '(') { advance(); // consume the ( return TOKEN_METHOD_OPEN; } return TOKEN_IDENT; } } // Variable references and special symbols if (ch === '$') { advance(); if (peek() === '$') { advance(); return TOKEN_$$; } if (match(IDENTIFIER_RX)) { // Check for $method( if (peek() === '(') { advance(); // consume the ( return TOKEN_$METHOD_OPEN; } return TOKEN_$IDENT; } return TOKEN_$; } // Rest tokens check if (match(REST_TOKENS_RX)) { return STR_TO_TOKEN[input.slice(start, pos)]; } pos++; return TOKEN_BAD; } function scanComment() { const start = pos; // Both single-line and multi-line comments are starting with '/', // so we can check for it first before running the more expensive regex match. // This optimization is especially beneficial because comments are rare in typical queries, // but checking each position with regex would be costly. if (peek() === '/' && match(COMMENT_RX)) { onComment(start, pos, input); return true; } return false; } return function nextToken() { // Check for pending token first if (pendingToken) { // Get and clear pending token const token = pendingToken; pendingToken = null; prevTokenType = token.type; prevTokenEnd = token.end; return token; } if (done) { return null; } // Skip whitespace and comments first do { if (match(WHITESPACE_RX)) { preventKeyword = false; } } while (scanComment()); // Read next token const start = pos; const nextTokenType = nextTokenStrict(); const token = new Token(nextTokenType, start, pos, input); preventPrimitive = PREVENT_PRIMITIVE[nextTokenType]; preventKeyword = PREVENT_KEYWORD[nextTokenType]; trackBracketBalance(nextTokenType); return tolerantMode ? nextTokenTolerant(token) : token; }; }