656a821a94
Migrate expressions, workflow-parser, and languageservice packages to use proper ESM imports with .js extensions that work with node16 moduleResolution. Changes: - Update tsconfig.build.json in each package to use module: node16 and moduleResolution: node16 - Add .js extensions to all relative import paths (Option B approach) - Fix yaml internal type imports in workflow-parser by defining local types - Add skipLibCheck to handle @types/node compatibility issues - Add TypeScript 5.8.3 override in root package.json - Add ESM migration plan documentation The languageserver package is deferred due to test hang issues that need further investigation. Related #154 - Upgrade moduleResolution from node to node16 or nodenext Related #110 - Published ESM code has imports without file extensions Related #64 - expressions: ERR_MODULE_NOT_FOUND attempting to run example Related #146 - Can not import @actions/workflow-parser Test results: - expressions: 1068 tests passed - workflow-parser: 292 tests passed - languageservice: 452 tests passed * docs: update ESM migration plan with findings - Update languageserver blocker: vscode-languageserver v8.0.2 lacks ESM exports (not a test hang issue) - Document that Option B (manual .js extensions) was chosen over Option A due to ts-jest compatibility issues - Add workaround for yaml package internal types (LinePos, NodeBase) - Update migration status table with accurate reason for deferral - Add skipLibCheck note for @types/node compatibility
370 lines
9.0 KiB
TypeScript
370 lines
9.0 KiB
TypeScript
import {
|
|
Binary,
|
|
ContextAccess,
|
|
Expr,
|
|
FunctionCall,
|
|
Grouping,
|
|
IndexAccess,
|
|
Literal,
|
|
Logical,
|
|
Star,
|
|
Unary
|
|
} from "./ast.js";
|
|
import * as data from "./data/index.js";
|
|
import {ErrorType, ExpressionError, MAX_PARSER_DEPTH} from "./errors.js";
|
|
import {ParseContext, validateFunction} from "./funcs.js";
|
|
import {FunctionInfo} from "./funcs/info.js";
|
|
import {Token, TokenType} from "./lexer.js";
|
|
|
|
export class Parser {
|
|
private extContexts: Map<string, boolean>;
|
|
private extFuncs: Map<string, FunctionInfo>;
|
|
|
|
private offset = 0;
|
|
private depth = 0;
|
|
|
|
private context: ParseContext;
|
|
|
|
/**
|
|
* Constructs a new parser for the given tokens
|
|
*
|
|
* @param tokens Tokens to build a parse tree from
|
|
* @param extensionContexts Available context names
|
|
* @param extensionFunctions Available functions (beyond the built-in ones)
|
|
*/
|
|
constructor(private tokens: Token[], extensionContexts: string[], extensionFunctions: FunctionInfo[]) {
|
|
this.extContexts = new Map<string, boolean>();
|
|
this.extFuncs = new Map();
|
|
|
|
for (const contextName of extensionContexts) {
|
|
this.extContexts.set(contextName.toLowerCase(), true);
|
|
}
|
|
|
|
for (const {name, func} of extensionFunctions.map(x => ({
|
|
name: x.name,
|
|
func: x
|
|
}))) {
|
|
this.extFuncs.set(name.toLowerCase(), func);
|
|
}
|
|
|
|
this.context = {
|
|
allowUnknownKeywords: false,
|
|
extensionContexts: this.extContexts,
|
|
extensionFunctions: this.extFuncs
|
|
};
|
|
}
|
|
|
|
public parse(): Expr {
|
|
// eslint-disable-next-line prefer-const
|
|
let result!: Expr;
|
|
|
|
// No tokens
|
|
if (this.atEnd()) {
|
|
return result;
|
|
}
|
|
|
|
result = this.expression();
|
|
|
|
if (!this.atEnd()) {
|
|
throw this.buildError(ErrorType.ErrorUnexpectedSymbol, this.peek());
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private expression(): Expr {
|
|
this.incrDepth();
|
|
|
|
try {
|
|
return this.logicalOr();
|
|
} finally {
|
|
this.decrDepth();
|
|
}
|
|
}
|
|
|
|
private logicalOr(): Expr {
|
|
// && is higher precedence than ||
|
|
let expr = this.logicalAnd();
|
|
|
|
if (this.check(TokenType.OR)) {
|
|
// Track depth
|
|
this.incrDepth();
|
|
|
|
try {
|
|
const logical = new Logical(this.peek(), [expr]);
|
|
expr = logical;
|
|
|
|
while (this.match(TokenType.OR)) {
|
|
const right = this.logicalAnd();
|
|
logical.args.push(right);
|
|
}
|
|
} finally {
|
|
this.decrDepth();
|
|
}
|
|
}
|
|
|
|
return expr;
|
|
}
|
|
|
|
private logicalAnd(): Expr {
|
|
// == and != are higher precedence than &&
|
|
let expr = this.equality();
|
|
|
|
if (this.check(TokenType.AND)) {
|
|
// Track depth
|
|
this.incrDepth();
|
|
|
|
try {
|
|
const logical = new Logical(this.peek(), [expr]);
|
|
expr = logical;
|
|
|
|
while (this.match(TokenType.AND)) {
|
|
const right = this.equality();
|
|
logical.args.push(right);
|
|
}
|
|
} finally {
|
|
this.decrDepth();
|
|
}
|
|
}
|
|
|
|
return expr;
|
|
}
|
|
|
|
private equality(): Expr {
|
|
// >, >=, <, <= are higher precedence than == and !=
|
|
let expr = this.comparison();
|
|
|
|
while (this.match(TokenType.BANG_EQUAL, TokenType.EQUAL_EQUAL)) {
|
|
const operator = this.previous();
|
|
const right = this.comparison();
|
|
|
|
expr = new Binary(expr, operator, right);
|
|
}
|
|
|
|
return expr;
|
|
}
|
|
|
|
private comparison(): Expr {
|
|
// ! is higher precedence than >, >=, <, <=
|
|
let expr = this.unary();
|
|
|
|
while (this.match(TokenType.GREATER, TokenType.GREATER_EQUAL, TokenType.LESS, TokenType.LESS_EQUAL)) {
|
|
const operator = this.previous();
|
|
const right = this.unary();
|
|
|
|
expr = new Binary(expr, operator, right);
|
|
}
|
|
|
|
return expr;
|
|
}
|
|
|
|
private unary(): Expr {
|
|
if (this.match(TokenType.BANG)) {
|
|
// Track depth
|
|
this.incrDepth();
|
|
|
|
const operator = this.previous();
|
|
const unary = this.unary();
|
|
|
|
try {
|
|
return new Unary(operator, unary);
|
|
} finally {
|
|
this.decrDepth();
|
|
}
|
|
}
|
|
|
|
return this.index();
|
|
}
|
|
|
|
private index(): Expr {
|
|
let expr = this.call();
|
|
|
|
let depthIncreased = 0;
|
|
|
|
if (expr instanceof Grouping || expr instanceof FunctionCall || expr instanceof ContextAccess) {
|
|
let cont = true;
|
|
while (cont) {
|
|
switch (true) {
|
|
case this.match(TokenType.LEFT_BRACKET): {
|
|
let indexExpr: Expr;
|
|
if (this.match(TokenType.STAR)) {
|
|
indexExpr = new Star();
|
|
} else {
|
|
indexExpr = this.expression();
|
|
}
|
|
|
|
this.consume(TokenType.RIGHT_BRACKET, ErrorType.ErrorUnexpectedSymbol);
|
|
|
|
// Track depth
|
|
this.incrDepth();
|
|
depthIncreased++;
|
|
expr = new IndexAccess(expr, indexExpr);
|
|
break;
|
|
}
|
|
|
|
case this.match(TokenType.DOT):
|
|
// Track depth
|
|
this.incrDepth();
|
|
depthIncreased++;
|
|
|
|
if (this.match(TokenType.IDENTIFIER)) {
|
|
const property = this.previous();
|
|
expr = new IndexAccess(expr, new Literal(new data.StringData(property.lexeme), property));
|
|
} else if (this.match(TokenType.STAR)) {
|
|
expr = new IndexAccess(expr, new Star());
|
|
} else {
|
|
throw this.buildError(ErrorType.ErrorUnexpectedSymbol, this.peek());
|
|
}
|
|
|
|
break;
|
|
|
|
default:
|
|
cont = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < depthIncreased; i++) {
|
|
this.decrDepth();
|
|
}
|
|
|
|
return expr;
|
|
}
|
|
|
|
private call(): Expr {
|
|
if (!this.check(TokenType.IDENTIFIER)) {
|
|
return this.primary();
|
|
}
|
|
|
|
const identifier = this.next();
|
|
|
|
if (!this.match(TokenType.LEFT_PAREN)) {
|
|
if (!this.extContexts.has(identifier.lexeme.toLowerCase())) {
|
|
throw this.buildError(ErrorType.ErrorUnrecognizedContext, identifier);
|
|
}
|
|
return new ContextAccess(identifier);
|
|
}
|
|
|
|
// Function call
|
|
const args: Expr[] = [];
|
|
|
|
// Arguments
|
|
while (!this.match(TokenType.RIGHT_PAREN)) {
|
|
const aexp = this.expression();
|
|
|
|
args.push(aexp);
|
|
|
|
if (!this.check(TokenType.RIGHT_PAREN)) {
|
|
this.consume(TokenType.COMMA, ErrorType.ErrorUnexpectedSymbol);
|
|
}
|
|
}
|
|
|
|
validateFunction(this.context, identifier, args.length);
|
|
|
|
return new FunctionCall(identifier, args);
|
|
}
|
|
|
|
private primary(): Expr {
|
|
switch (true) {
|
|
case this.match(TokenType.FALSE):
|
|
return new Literal(new data.BooleanData(false), this.previous());
|
|
|
|
case this.match(TokenType.TRUE):
|
|
return new Literal(new data.BooleanData(true), this.previous());
|
|
|
|
case this.match(TokenType.NULL):
|
|
return new Literal(new data.Null(), this.previous());
|
|
|
|
case this.match(TokenType.NUMBER):
|
|
return new Literal(new data.NumberData(this.previous().value as number), this.previous());
|
|
|
|
case this.match(TokenType.STRING):
|
|
return new Literal(new data.StringData(this.previous().value as string), this.previous());
|
|
|
|
case this.match(TokenType.LEFT_PAREN): {
|
|
const expr = this.expression();
|
|
|
|
if (this.atEnd()) {
|
|
throw this.buildError(ErrorType.ErrorUnexpectedEndOfExpression, this.previous()); // Back up to get the last token before the EOF
|
|
}
|
|
|
|
this.consume(TokenType.RIGHT_PAREN, ErrorType.ErrorUnexpectedSymbol);
|
|
return new Grouping(expr);
|
|
}
|
|
case this.atEnd():
|
|
throw this.buildError(ErrorType.ErrorUnexpectedEndOfExpression, this.previous()); // Back up to get the last token before the EOF
|
|
}
|
|
|
|
throw this.buildError(ErrorType.ErrorUnexpectedSymbol, this.peek());
|
|
}
|
|
|
|
// match consumes the next token if it matches any of the given types
|
|
private match(...tokenTypes: TokenType[]): boolean {
|
|
for (const tokenType of tokenTypes) {
|
|
if (this.check(tokenType)) {
|
|
this.next();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// check peeks whether the next token is of the given type
|
|
private check(tokenType: TokenType): boolean {
|
|
if (this.atEnd()) {
|
|
return false;
|
|
}
|
|
|
|
return this.peek().type == tokenType;
|
|
}
|
|
|
|
// atEnd peeks whether the next token is EOF
|
|
private atEnd(): boolean {
|
|
return this.peek().type == TokenType.EOF;
|
|
}
|
|
|
|
private next(): Token {
|
|
if (!this.atEnd()) {
|
|
this.offset++;
|
|
}
|
|
|
|
return this.previous();
|
|
}
|
|
|
|
private peek(): Token {
|
|
return this.tokens[this.offset];
|
|
}
|
|
|
|
// previous returns the previous token
|
|
private previous(): Token {
|
|
return this.tokens[this.offset - 1];
|
|
}
|
|
|
|
// consume attempts to consume the next token if it matches the given type. It returns an error of
|
|
// the given ParseErrorKind otherwise.
|
|
private consume(tokenType: TokenType, errorType: ErrorType) {
|
|
if (this.check(tokenType)) {
|
|
this.next();
|
|
return;
|
|
}
|
|
|
|
throw this.buildError(errorType, this.peek());
|
|
}
|
|
|
|
private incrDepth() {
|
|
this.depth++;
|
|
if (this.depth > MAX_PARSER_DEPTH) {
|
|
throw this.buildError(ErrorType.ErrorExceededMaxDepth, this.peek());
|
|
}
|
|
}
|
|
|
|
private decrDepth() {
|
|
this.depth--;
|
|
}
|
|
|
|
private buildError(errType: ErrorType, token: Token): Error {
|
|
return new ExpressionError(errType, token);
|
|
}
|
|
}
|