329 lines
10 KiB
TypeScript
329 lines
10 KiB
TypeScript
import { Token, TokenType, TokenizerError } from './tokenizer';
|
|
|
|
/**
|
|
* AST Node types for EFTL
|
|
*/
|
|
export interface AstNode {
|
|
type: string;
|
|
line: number;
|
|
column: number;
|
|
children?: AstNode[];
|
|
}
|
|
|
|
export interface ParserError {
|
|
message: string;
|
|
line: number;
|
|
column: number;
|
|
length: number;
|
|
}
|
|
|
|
/**
|
|
* EFTL Parser - validates structure and reports errors
|
|
*/
|
|
export class EftlParser {
|
|
private tokens: Token[];
|
|
private pos: number = 0;
|
|
private errors: ParserError[] = [];
|
|
private blockStack: { type: TokenType; token: Token }[] = [];
|
|
|
|
constructor(tokens: Token[]) {
|
|
this.tokens = tokens;
|
|
}
|
|
|
|
parse(): { errors: ParserError[] } {
|
|
this.pos = 0;
|
|
this.errors = [];
|
|
this.blockStack = [];
|
|
|
|
while (!this.isAtEnd()) {
|
|
this.parseTopLevel();
|
|
}
|
|
|
|
// Check for unclosed blocks
|
|
for (const block of this.blockStack) {
|
|
this.errors.push({
|
|
message: `Unclosed block: ${block.token.value}`,
|
|
line: block.token.line,
|
|
column: block.token.column,
|
|
length: block.token.length
|
|
});
|
|
}
|
|
|
|
return { errors: this.errors };
|
|
}
|
|
|
|
private parseTopLevel(): void {
|
|
const token = this.current();
|
|
|
|
switch (token.type) {
|
|
case TokenType.EFTL_OPEN:
|
|
this.pushBlock(token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.EFTL_CLOSE:
|
|
this.popBlock(TokenType.EFTL_OPEN, token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.VAR_OPEN:
|
|
this.pushBlock(token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.VAR_CLOSE:
|
|
this.popBlock(TokenType.VAR_OPEN, token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.IF_OPEN:
|
|
this.pushBlock(token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.IF_CLOSE:
|
|
this.popBlock(TokenType.IF_OPEN, token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.CONDITION_OPEN:
|
|
this.pushBlock(token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.CONDITION_CLOSE:
|
|
this.popBlock(TokenType.CONDITION_OPEN, token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.THEN_OPEN:
|
|
this.pushBlock(token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.THEN_CLOSE:
|
|
this.popBlock(TokenType.THEN_OPEN, token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.ELSE_OPEN:
|
|
this.pushBlock(token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.ELSE_CLOSE:
|
|
this.popBlock(TokenType.ELSE_OPEN, token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.ELSE_IF_OPEN:
|
|
this.pushBlock(token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.ELSE_IF_CLOSE:
|
|
this.popBlock(TokenType.ELSE_IF_OPEN, token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.WHILE_OPEN:
|
|
this.pushBlock(token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.WHILE_CLOSE:
|
|
this.popBlock(TokenType.WHILE_OPEN, token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.DO_OPEN:
|
|
this.pushBlock(token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.DO_CLOSE:
|
|
this.popBlock(TokenType.DO_OPEN, token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.TAG_OPEN:
|
|
this.pushBlock(token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.TAG_CLOSE:
|
|
this.popBlock(TokenType.TAG_OPEN, token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.SPLIT_OPEN:
|
|
this.pushBlock(token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.SPLIT_CLOSE:
|
|
this.popBlock(TokenType.SPLIT_OPEN, token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.TRIM_OPEN:
|
|
this.pushBlock(token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.TRIM_CLOSE:
|
|
this.popBlock(TokenType.TRIM_OPEN, token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.CONTAINS_OPEN:
|
|
this.pushBlock(token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.CONTAINS_CLOSE:
|
|
this.popBlock(TokenType.CONTAINS_OPEN, token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.FORMAT_OPEN:
|
|
this.pushBlock(token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.FORMAT_CLOSE:
|
|
this.popBlock(TokenType.FORMAT_OPEN, token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.EXPR_OPEN:
|
|
case TokenType.EXPR_OUTPUT_OPEN:
|
|
this.pushBlock(token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.EXPR_CLOSE:
|
|
// Can close either EXPR_OPEN or EXPR_OUTPUT_OPEN
|
|
const exprBlock = this.blockStack.pop();
|
|
if (!exprBlock || (exprBlock.type !== TokenType.EXPR_OPEN && exprBlock.type !== TokenType.EXPR_OUTPUT_OPEN)) {
|
|
this.errors.push({
|
|
message: `Unexpected closing: ${token.value}`,
|
|
line: token.line,
|
|
column: token.column,
|
|
length: token.length
|
|
});
|
|
if (exprBlock) {
|
|
this.blockStack.push(exprBlock);
|
|
}
|
|
}
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.COMMENT_OPEN:
|
|
this.pushBlock(token);
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.COMMENT_CLOSE:
|
|
this.popBlock(TokenType.COMMENT_OPEN, token);
|
|
this.advance();
|
|
break;
|
|
|
|
// Self-closing tags - no block needed
|
|
case TokenType.VALUE_OF:
|
|
case TokenType.SIZE_OF:
|
|
case TokenType.HEADER:
|
|
if (!token.value.endsWith('/]')) {
|
|
this.pushBlock(token);
|
|
}
|
|
this.advance();
|
|
break;
|
|
|
|
case TokenType.TEXT:
|
|
case TokenType.EOF:
|
|
this.advance();
|
|
break;
|
|
|
|
default:
|
|
// Unknown token, skip
|
|
this.advance();
|
|
break;
|
|
}
|
|
}
|
|
|
|
private pushBlock(token: Token): void {
|
|
if (token.value.endsWith('/]')) {
|
|
return; // It's self-closing, don't push to stack
|
|
}
|
|
this.blockStack.push({ type: token.type, token });
|
|
}
|
|
|
|
private popBlock(expectedOpenType: TokenType, closeToken: Token): void {
|
|
if (this.blockStack.length === 0) {
|
|
this.errors.push({
|
|
message: `Unexpected closing tag: ${closeToken.value} (no matching opening tag)`,
|
|
line: closeToken.line,
|
|
column: closeToken.column,
|
|
length: closeToken.length
|
|
});
|
|
return;
|
|
}
|
|
|
|
const top = this.blockStack[this.blockStack.length - 1];
|
|
if (top.type !== expectedOpenType) {
|
|
// Find the expected closing tag name
|
|
const expectedClose = this.getMatchingClose(top.type);
|
|
this.errors.push({
|
|
message: `Mismatched closing tag: expected ${expectedClose}, got ${closeToken.value}`,
|
|
line: closeToken.line,
|
|
column: closeToken.column,
|
|
length: closeToken.length
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.blockStack.pop();
|
|
}
|
|
|
|
private getMatchingClose(openType: TokenType): string {
|
|
const closeMap: { [key: string]: string } = {
|
|
[TokenType.EFTL_OPEN]: '[/EFTL]',
|
|
[TokenType.VAR_OPEN]: '[/VAR]',
|
|
[TokenType.IF_OPEN]: '[/IF]',
|
|
[TokenType.CONDITION_OPEN]: '[/CONDITION]',
|
|
[TokenType.THEN_OPEN]: '[/THEN]',
|
|
[TokenType.ELSE_OPEN]: '[/ELSE]',
|
|
[TokenType.ELSE_IF_OPEN]: '[/ELSE IF]',
|
|
[TokenType.WHILE_OPEN]: '[/WHILE]',
|
|
[TokenType.DO_OPEN]: '[/DO]',
|
|
[TokenType.TAG_OPEN]: '[/TAG]',
|
|
[TokenType.SPLIT_OPEN]: '[/SPLIT]',
|
|
[TokenType.TRIM_OPEN]: '[/TRIM]',
|
|
[TokenType.CONTAINS_OPEN]: '[/CONTAINS]',
|
|
[TokenType.FORMAT_OPEN]: '[/FORMAT]',
|
|
[TokenType.VALUE_OF]: '[/VALUE_OF]',
|
|
[TokenType.SIZE_OF]: '[/SIZE_OF]',
|
|
[TokenType.HEADER]: '[/HEADER]',
|
|
[TokenType.EXPR_OPEN]: '%]',
|
|
[TokenType.EXPR_OUTPUT_OPEN]: '%]',
|
|
[TokenType.COMMENT_OPEN]: '--]'
|
|
};
|
|
return closeMap[openType] || 'unknown';
|
|
}
|
|
|
|
private current(): Token {
|
|
return this.tokens[this.pos] || { type: TokenType.EOF, value: '', line: 0, column: 0, length: 0 };
|
|
}
|
|
|
|
private advance(): Token {
|
|
if (!this.isAtEnd()) {
|
|
this.pos++;
|
|
}
|
|
return this.tokens[this.pos - 1];
|
|
}
|
|
|
|
private isAtEnd(): boolean {
|
|
return this.current().type === TokenType.EOF;
|
|
}
|
|
}
|