// @flow strict import { syntaxError } from '../error/syntaxError'; import type { Source } from './source'; import type { TokenKindEnum } from './tokenKind'; import { Token } from './ast'; import { TokenKind } from './tokenKind'; import { dedentBlockStringValue } from './blockString'; /** * Given a Source object, creates a Lexer for that source. * A Lexer is a stateful stream generator in that every time * it is advanced, it returns the next token in the Source. Assuming the * source lexes, the final Token emitted by the lexer will be of kind * EOF, after which the lexer will repeatedly return the same EOF token * whenever called. */ export class Lexer { source: Source; /** * The previously focused non-ignored token. */ lastToken: Token; /** * The currently focused non-ignored token. */ token: Token; /** * The (1-indexed) line containing the current token. */ line: number; /** * The character offset at which the current line begins. */ lineStart: number; constructor(source: Source) { const startOfFileToken = new Token(TokenKind.SOF, 0, 0, 0, 0, null); this.source = source; this.lastToken = startOfFileToken; this.token = startOfFileToken; this.line = 1; this.lineStart = 0; } /** * Advances the token stream to the next non-ignored token. */ advance(): Token { this.lastToken = this.token; const token = (this.token = this.lookahead()); return token; } /** * Looks ahead and returns the next non-ignored token, but does not change * the state of Lexer. */ lookahead(): Token { let token = this.token; if (token.kind !== TokenKind.EOF) { do { // Note: next is only mutable during parsing, so we cast to allow this. token = token.next ?? ((token: any).next = readToken(this, token)); } while (token.kind === TokenKind.COMMENT); } return token; } } /** * @internal */ export function isPunctuatorTokenKind(kind: TokenKindEnum): boolean %checks { return ( kind === TokenKind.BANG || kind === TokenKind.DOLLAR || kind === TokenKind.AMP || kind === TokenKind.PAREN_L || kind === TokenKind.PAREN_R || kind === TokenKind.SPREAD || kind === TokenKind.COLON || kind === TokenKind.EQUALS || kind === TokenKind.AT || kind === TokenKind.BRACKET_L || kind === TokenKind.BRACKET_R || kind === TokenKind.BRACE_L || kind === TokenKind.PIPE || kind === TokenKind.BRACE_R ); } function printCharCode(code: number): string { return ( // NaN/undefined represents access beyond the end of the file. isNaN(code) ? TokenKind.EOF : // Trust JSON for ASCII. code < 0x007f ? JSON.stringify(String.fromCharCode(code)) : // Otherwise print the escaped form. `"\\u${('00' + code.toString(16).toUpperCase()).slice(-4)}"` ); } /** * Gets the next token from the source starting at the given position. * * This skips over whitespace until it finds the next lexable token, then lexes * punctuators immediately or calls the appropriate helper function for more * complicated tokens. */ function readToken(lexer: Lexer, prev: Token): Token { const source = lexer.source; const body = source.body; const bodyLength = body.length; let pos = prev.end; while (pos < bodyLength) { const code = body.charCodeAt(pos); const line = lexer.line; const col = 1 + pos - lexer.lineStart; // SourceCharacter switch (code) { case 0xfeff: // case 9: // \t case 32: // case 44: // , ++pos; continue; case 10: // \n ++pos; ++lexer.line; lexer.lineStart = pos; continue; case 13: // \r if (body.charCodeAt(pos + 1) === 10) { pos += 2; } else { ++pos; } ++lexer.line; lexer.lineStart = pos; continue; case 33: // ! return new Token(TokenKind.BANG, pos, pos + 1, line, col, prev); case 35: // # return readComment(source, pos, line, col, prev); case 36: // $ return new Token(TokenKind.DOLLAR, pos, pos + 1, line, col, prev); case 38: // & return new Token(TokenKind.AMP, pos, pos + 1, line, col, prev); case 40: // ( return new Token(TokenKind.PAREN_L, pos, pos + 1, line, col, prev); case 41: // ) return new Token(TokenKind.PAREN_R, pos, pos + 1, line, col, prev); case 46: // . if ( body.charCodeAt(pos + 1) === 46 && body.charCodeAt(pos + 2) === 46 ) { return new Token(TokenKind.SPREAD, pos, pos + 3, line, col, prev); } break; case 58: // : return new Token(TokenKind.COLON, pos, pos + 1, line, col, prev); case 61: // = return new Token(TokenKind.EQUALS, pos, pos + 1, line, col, prev); case 64: // @ return new Token(TokenKind.AT, pos, pos + 1, line, col, prev); case 91: // [ return new Token(TokenKind.BRACKET_L, pos, pos + 1, line, col, prev); case 93: // ] return new Token(TokenKind.BRACKET_R, pos, pos + 1, line, col, prev); case 123: // { return new Token(TokenKind.BRACE_L, pos, pos + 1, line, col, prev); case 124: // | return new Token(TokenKind.PIPE, pos, pos + 1, line, col, prev); case 125: // } return new Token(TokenKind.BRACE_R, pos, pos + 1, line, col, prev); case 34: // " if ( body.charCodeAt(pos + 1) === 34 && body.charCodeAt(pos + 2) === 34 ) { return readBlockString(source, pos, line, col, prev, lexer); } return readString(source, pos, line, col, prev); case 45: // - case 48: // 0 case 49: // 1 case 50: // 2 case 51: // 3 case 52: // 4 case 53: // 5 case 54: // 6 case 55: // 7 case 56: // 8 case 57: // 9 return readNumber(source, pos, code, line, col, prev); case 65: // A case 66: // B case 67: // C case 68: // D case 69: // E case 70: // F case 71: // G case 72: // H case 73: // I case 74: // J case 75: // K case 76: // L case 77: // M case 78: // N case 79: // O case 80: // P case 81: // Q case 82: // R case 83: // S case 84: // T case 85: // U case 86: // V case 87: // W case 88: // X case 89: // Y case 90: // Z case 95: // _ case 97: // a case 98: // b case 99: // c case 100: // d case 101: // e case 102: // f case 103: // g case 104: // h case 105: // i case 106: // j case 107: // k case 108: // l case 109: // m case 110: // n case 111: // o case 112: // p case 113: // q case 114: // r case 115: // s case 116: // t case 117: // u case 118: // v case 119: // w case 120: // x case 121: // y case 122: // z return readName(source, pos, line, col, prev); } throw syntaxError(source, pos, unexpectedCharacterMessage(code)); } const line = lexer.line; const col = 1 + pos - lexer.lineStart; return new Token(TokenKind.EOF, bodyLength, bodyLength, line, col, prev); } /** * Report a message that an unexpected character was encountered. */ function unexpectedCharacterMessage(code: number): string { if (code < 0x0020 && code !== 0x0009 && code !== 0x000a && code !== 0x000d) { return `Cannot contain the invalid character ${printCharCode(code)}.`; } if (code === 39) { // ' return 'Unexpected single quote character (\'), did you mean to use a double quote (")?'; } return `Cannot parse the unexpected character ${printCharCode(code)}.`; } /** * Reads a comment token from the source file. * * #[\u0009\u0020-\uFFFF]* */ function readComment( source: Source, start: number, line: number, col: number, prev: Token | null, ): Token { const body = source.body; let code; let position = start; do { code = body.charCodeAt(++position); } while ( !isNaN(code) && // SourceCharacter but not LineTerminator (code > 0x001f || code === 0x0009) ); return new Token( TokenKind.COMMENT, start, position, line, col, prev, body.slice(start + 1, position), ); } /** * Reads a number token from the source file, either a float * or an int depending on whether a decimal point appears. * * Int: -?(0|[1-9][0-9]*) * Float: -?(0|[1-9][0-9]*)(\.[0-9]+)?((E|e)(+|-)?[0-9]+)? */ function readNumber( source: Source, start: number, firstCode: number, line: number, col: number, prev: Token | null, ): Token { const body = source.body; let code = firstCode; let position = start; let isFloat = false; if (code === 45) { // - code = body.charCodeAt(++position); } if (code === 48) { // 0 code = body.charCodeAt(++position); if (code >= 48 && code <= 57) { throw syntaxError( source, position, `Invalid number, unexpected digit after 0: ${printCharCode(code)}.`, ); } } else { position = readDigits(source, position, code); code = body.charCodeAt(position); } if (code === 46) { // . isFloat = true; code = body.charCodeAt(++position); position = readDigits(source, position, code); code = body.charCodeAt(position); } if (code === 69 || code === 101) { // E e isFloat = true; code = body.charCodeAt(++position); if (code === 43 || code === 45) { // + - code = body.charCodeAt(++position); } position = readDigits(source, position, code); code = body.charCodeAt(position); } // Numbers cannot be followed by . or NameStart if (code === 46 || isNameStart(code)) { throw syntaxError( source, position, `Invalid number, expected digit but got: ${printCharCode(code)}.`, ); } return new Token( isFloat ? TokenKind.FLOAT : TokenKind.INT, start, position, line, col, prev, body.slice(start, position), ); } /** * Returns the new position in the source after reading digits. */ function readDigits(source: Source, start: number, firstCode: number): number { const body = source.body; let position = start; let code = firstCode; if (code >= 48 && code <= 57) { // 0 - 9 do { code = body.charCodeAt(++position); } while (code >= 48 && code <= 57); // 0 - 9 return position; } throw syntaxError( source, position, `Invalid number, expected digit but got: ${printCharCode(code)}.`, ); } /** * Reads a string token from the source file. * * "([^"\\\u000A\u000D]|(\\(u[0-9a-fA-F]{4}|["\\/bfnrt])))*" */ function readString( source: Source, start: number, line: number, col: number, prev: Token | null, ): Token { const body = source.body; let position = start + 1; let chunkStart = position; let code = 0; let value = ''; while ( position < body.length && !isNaN((code = body.charCodeAt(position))) && // not LineTerminator code !== 0x000a && code !== 0x000d ) { // Closing Quote (") if (code === 34) { value += body.slice(chunkStart, position); return new Token( TokenKind.STRING, start, position + 1, line, col, prev, value, ); } // SourceCharacter if (code < 0x0020 && code !== 0x0009) { throw syntaxError( source, position, `Invalid character within String: ${printCharCode(code)}.`, ); } ++position; if (code === 92) { // \ value += body.slice(chunkStart, position - 1); code = body.charCodeAt(position); switch (code) { case 34: value += '"'; break; case 47: value += '/'; break; case 92: value += '\\'; break; case 98: value += '\b'; break; case 102: value += '\f'; break; case 110: value += '\n'; break; case 114: value += '\r'; break; case 116: value += '\t'; break; case 117: { // uXXXX const charCode = uniCharCode( body.charCodeAt(position + 1), body.charCodeAt(position + 2), body.charCodeAt(position + 3), body.charCodeAt(position + 4), ); if (charCode < 0) { const invalidSequence = body.slice(position + 1, position + 5); throw syntaxError( source, position, `Invalid character escape sequence: \\u${invalidSequence}.`, ); } value += String.fromCharCode(charCode); position += 4; break; } default: throw syntaxError( source, position, `Invalid character escape sequence: \\${String.fromCharCode( code, )}.`, ); } ++position; chunkStart = position; } } throw syntaxError(source, position, 'Unterminated string.'); } /** * Reads a block string token from the source file. * * """("?"?(\\"""|\\(?!=""")|[^"\\]))*""" */ function readBlockString( source: Source, start: number, line: number, col: number, prev: Token | null, lexer: Lexer, ): Token { const body = source.body; let position = start + 3; let chunkStart = position; let code = 0; let rawValue = ''; while (position < body.length && !isNaN((code = body.charCodeAt(position)))) { // Closing Triple-Quote (""") if ( code === 34 && body.charCodeAt(position + 1) === 34 && body.charCodeAt(position + 2) === 34 ) { rawValue += body.slice(chunkStart, position); return new Token( TokenKind.BLOCK_STRING, start, position + 3, line, col, prev, dedentBlockStringValue(rawValue), ); } // SourceCharacter if ( code < 0x0020 && code !== 0x0009 && code !== 0x000a && code !== 0x000d ) { throw syntaxError( source, position, `Invalid character within String: ${printCharCode(code)}.`, ); } if (code === 10) { // new line ++position; ++lexer.line; lexer.lineStart = position; } else if (code === 13) { // carriage return if (body.charCodeAt(position + 1) === 10) { position += 2; } else { ++position; } ++lexer.line; lexer.lineStart = position; } else if ( // Escape Triple-Quote (\""") code === 92 && body.charCodeAt(position + 1) === 34 && body.charCodeAt(position + 2) === 34 && body.charCodeAt(position + 3) === 34 ) { rawValue += body.slice(chunkStart, position) + '"""'; position += 4; chunkStart = position; } else { ++position; } } throw syntaxError(source, position, 'Unterminated string.'); } /** * Converts four hexadecimal chars to the integer that the * string represents. For example, uniCharCode('0','0','0','f') * will return 15, and uniCharCode('0','0','f','f') returns 255. * * Returns a negative number on error, if a char was invalid. * * This is implemented by noting that char2hex() returns -1 on error, * which means the result of ORing the char2hex() will also be negative. */ function uniCharCode(a: number, b: number, c: number, d: number): number { return ( (char2hex(a) << 12) | (char2hex(b) << 8) | (char2hex(c) << 4) | char2hex(d) ); } /** * Converts a hex character to its integer value. * '0' becomes 0, '9' becomes 9 * 'A' becomes 10, 'F' becomes 15 * 'a' becomes 10, 'f' becomes 15 * * Returns -1 on error. */ function char2hex(a: number): number { return a >= 48 && a <= 57 ? a - 48 // 0-9 : a >= 65 && a <= 70 ? a - 55 // A-F : a >= 97 && a <= 102 ? a - 87 // a-f : -1; } /** * Reads an alphanumeric + underscore name from the source. * * [_A-Za-z][_0-9A-Za-z]* */ function readName( source: Source, start: number, line: number, col: number, prev: Token | null, ): Token { const body = source.body; const bodyLength = body.length; let position = start + 1; let code = 0; while ( position !== bodyLength && !isNaN((code = body.charCodeAt(position))) && (code === 95 || // _ (code >= 48 && code <= 57) || // 0-9 (code >= 65 && code <= 90) || // A-Z (code >= 97 && code <= 122)) // a-z ) { ++position; } return new Token( TokenKind.NAME, start, position, line, col, prev, body.slice(start, position), ); } // _ A-Z a-z function isNameStart(code: number): boolean { return ( code === 95 || (code >= 65 && code <= 90) || (code >= 97 && code <= 122) ); }