add first working version
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import * as path from 'path';
|
||||
import { ExtensionContext } from 'vscode';
|
||||
import {
|
||||
LanguageClient,
|
||||
LanguageClientOptions,
|
||||
ServerOptions,
|
||||
TransportKind
|
||||
} from 'vscode-languageclient/node';
|
||||
|
||||
let client: LanguageClient;
|
||||
|
||||
export function activate(context: ExtensionContext) {
|
||||
// The server is implemented in node
|
||||
const serverModule = context.asAbsolutePath(path.join('out', 'server.js'));
|
||||
|
||||
// Server options - run the server in Node
|
||||
const serverOptions: ServerOptions = {
|
||||
run: { module: serverModule, transport: TransportKind.ipc },
|
||||
debug: {
|
||||
module: serverModule,
|
||||
transport: TransportKind.ipc,
|
||||
options: { execArgv: ['--nolazy', '--inspect=6009'] }
|
||||
}
|
||||
};
|
||||
|
||||
// Options to control the language client
|
||||
const clientOptions: LanguageClientOptions = {
|
||||
// Register the server for EFTL documents
|
||||
documentSelector: [{ scheme: 'file', language: 'eftl' }]
|
||||
};
|
||||
|
||||
// Create the language client and start the client
|
||||
client = new LanguageClient(
|
||||
'eftlLanguageServer',
|
||||
'EFTL Language Server',
|
||||
serverOptions,
|
||||
clientOptions
|
||||
);
|
||||
|
||||
// Start the client. This will also launch the server
|
||||
client.start();
|
||||
}
|
||||
|
||||
export function deactivate(): Thenable<void> | undefined {
|
||||
if (!client) {
|
||||
return undefined;
|
||||
}
|
||||
return client.stop();
|
||||
}
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
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:
|
||||
this.advance();
|
||||
break;
|
||||
|
||||
case TokenType.TEXT:
|
||||
case TokenType.EOF:
|
||||
this.advance();
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown token, skip
|
||||
this.advance();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private pushBlock(token: Token): void {
|
||||
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.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;
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
createConnection,
|
||||
TextDocuments,
|
||||
Diagnostic,
|
||||
DiagnosticSeverity,
|
||||
ProposedFeatures,
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
TextDocumentSyncKind,
|
||||
Location,
|
||||
Range,
|
||||
Position,
|
||||
DefinitionParams
|
||||
} from 'vscode-languageserver/node';
|
||||
|
||||
import { TextDocument } from 'vscode-languageserver-textdocument';
|
||||
import { EftlTokenizer, TokenType } from './tokenizer';
|
||||
import { EftlParser } from './parser';
|
||||
|
||||
// Create a connection for the server
|
||||
const connection = createConnection(ProposedFeatures.all);
|
||||
|
||||
// Create a document manager
|
||||
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
|
||||
|
||||
// Store variable definitions per document
|
||||
interface VariableDefinition {
|
||||
name: string;
|
||||
line: number;
|
||||
column: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
const documentVariables: Map<string, VariableDefinition[]> = new Map();
|
||||
|
||||
connection.onInitialize((params: InitializeParams): InitializeResult => {
|
||||
return {
|
||||
capabilities: {
|
||||
textDocumentSync: TextDocumentSyncKind.Incremental,
|
||||
definitionProvider: true
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Handle "Go to Definition" requests
|
||||
connection.onDefinition((params: DefinitionParams): Location | null => {
|
||||
const document = documents.get(params.textDocument.uri);
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = document.getText();
|
||||
const offset = document.offsetAt(params.position);
|
||||
|
||||
// Find the word at the current position
|
||||
const wordRange = getWordRangeAtPosition(text, offset);
|
||||
if (!wordRange) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const word = text.substring(wordRange.start, wordRange.end);
|
||||
|
||||
// Look for variable definition
|
||||
const variables = documentVariables.get(params.textDocument.uri) || [];
|
||||
const variable = variables.find(v => v.name === word);
|
||||
|
||||
if (variable) {
|
||||
return {
|
||||
uri: params.textDocument.uri,
|
||||
range: {
|
||||
start: { line: variable.line - 1, character: variable.column - 1 },
|
||||
end: { line: variable.line - 1, character: variable.column - 1 + variable.length }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
function getWordRangeAtPosition(text: string, offset: number): { start: number; end: number } | null {
|
||||
// Find word boundaries (alphanumeric and underscore)
|
||||
let start = offset;
|
||||
let end = offset;
|
||||
|
||||
// Move start backwards
|
||||
while (start > 0 && /[a-zA-Z0-9_]/.test(text[start - 1])) {
|
||||
start--;
|
||||
}
|
||||
|
||||
// Move end forwards
|
||||
while (end < text.length && /[a-zA-Z0-9_]/.test(text[end])) {
|
||||
end++;
|
||||
}
|
||||
|
||||
if (start === end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
// Validate documents when they change
|
||||
documents.onDidChangeContent(change => {
|
||||
validateDocument(change.document);
|
||||
});
|
||||
|
||||
async function validateDocument(textDocument: TextDocument): Promise<void> {
|
||||
const text = textDocument.getText();
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
|
||||
// Tokenize
|
||||
const tokenizer = new EftlTokenizer(text);
|
||||
const { tokens, errors: tokenErrors } = tokenizer.tokenize();
|
||||
|
||||
// Valid types for VAR
|
||||
const validTypes = ['string', 'boolean', 'number', 'date', 'object', 'iterable'];
|
||||
|
||||
// Extract variable definitions and validate types
|
||||
const variables: VariableDefinition[] = [];
|
||||
for (const token of tokens) {
|
||||
if (token.type === TokenType.VAR_OPEN) {
|
||||
// Parse name="varName" from the token value
|
||||
const nameMatch = token.value.match(/name="([^"]+)"/);
|
||||
if (nameMatch) {
|
||||
variables.push({
|
||||
name: nameMatch[1],
|
||||
line: token.line,
|
||||
column: token.column,
|
||||
length: token.length
|
||||
});
|
||||
}
|
||||
|
||||
// Validate type attribute
|
||||
const typeMatch = token.value.match(/type="([^"]*)"/);
|
||||
if (typeMatch) {
|
||||
const typeValue = typeMatch[1];
|
||||
if (!validTypes.includes(typeValue)) {
|
||||
// Find position of the type value in the token
|
||||
const typeIndex = token.value.indexOf(`type="${typeValue}"`);
|
||||
const typeValueStart = typeIndex + 6; // 'type="'.length
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: { line: token.line - 1, character: token.column - 1 + typeValueStart },
|
||||
end: { line: token.line - 1, character: token.column - 1 + typeValueStart + typeValue.length }
|
||||
},
|
||||
message: `Invalid type "${typeValue}". Must be one of: ${validTypes.join(', ')}`,
|
||||
source: 'eftl'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
documentVariables.set(textDocument.uri, variables);
|
||||
|
||||
// Add tokenizer errors
|
||||
for (const error of tokenErrors) {
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: { line: error.line - 1, character: error.column - 1 },
|
||||
end: { line: error.line - 1, character: error.column - 1 + error.length }
|
||||
},
|
||||
message: error.message,
|
||||
source: 'eftl'
|
||||
});
|
||||
}
|
||||
|
||||
// Parse
|
||||
const parser = new EftlParser(tokens);
|
||||
const { errors: parseErrors } = parser.parse();
|
||||
|
||||
// Add parser errors
|
||||
for (const error of parseErrors) {
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: { line: error.line - 1, character: error.column - 1 },
|
||||
end: { line: error.line - 1, character: error.column - 1 + error.length }
|
||||
},
|
||||
message: error.message,
|
||||
source: 'eftl'
|
||||
});
|
||||
}
|
||||
|
||||
// Send diagnostics to VS Code
|
||||
connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
|
||||
}
|
||||
|
||||
// Clean up when documents are closed
|
||||
documents.onDidClose(e => {
|
||||
documentVariables.delete(e.document.uri);
|
||||
});
|
||||
|
||||
// Make the text document manager listen on the connection
|
||||
documents.listen(connection);
|
||||
|
||||
// Listen on the connection
|
||||
connection.listen();
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* EFTL Token Types
|
||||
*/
|
||||
export enum TokenType {
|
||||
// Block delimiters
|
||||
EFTL_OPEN = 'EFTL_OPEN', // [EFTL]
|
||||
EFTL_CLOSE = 'EFTL_CLOSE', // [/EFTL]
|
||||
|
||||
// Variable
|
||||
VAR_OPEN = 'VAR_OPEN', // [VAR ...]
|
||||
VAR_CLOSE = 'VAR_CLOSE', // [/VAR]
|
||||
|
||||
// Control structures
|
||||
IF_OPEN = 'IF_OPEN', // [IF]
|
||||
IF_CLOSE = 'IF_CLOSE', // [/IF]
|
||||
CONDITION_OPEN = 'CONDITION_OPEN', // [CONDITION]
|
||||
CONDITION_CLOSE = 'CONDITION_CLOSE', // [/CONDITION]
|
||||
THEN_OPEN = 'THEN_OPEN', // [THEN]
|
||||
THEN_CLOSE = 'THEN_CLOSE', // [/THEN]
|
||||
ELSE_OPEN = 'ELSE_OPEN', // [ELSE]
|
||||
ELSE_CLOSE = 'ELSE_CLOSE', // [/ELSE]
|
||||
ELSE_IF_OPEN = 'ELSE_IF_OPEN', // [ELSE IF]
|
||||
ELSE_IF_CLOSE = 'ELSE_IF_CLOSE', // [/ELSE IF]
|
||||
WHILE_OPEN = 'WHILE_OPEN', // [WHILE]
|
||||
WHILE_CLOSE = 'WHILE_CLOSE', // [/WHILE]
|
||||
DO_OPEN = 'DO_OPEN', // [DO]
|
||||
DO_CLOSE = 'DO_CLOSE', // [/DO]
|
||||
|
||||
// Functions
|
||||
TAG_OPEN = 'TAG_OPEN', // [TAG]
|
||||
TAG_CLOSE = 'TAG_CLOSE', // [/TAG]
|
||||
SPLIT_OPEN = 'SPLIT_OPEN', // [SPLIT ...]
|
||||
SPLIT_CLOSE = 'SPLIT_CLOSE', // [/SPLIT]
|
||||
TRIM_OPEN = 'TRIM_OPEN', // [TRIM]
|
||||
TRIM_CLOSE = 'TRIM_CLOSE', // [/TRIM]
|
||||
CONTAINS_OPEN = 'CONTAINS_OPEN', // [CONTAINS ...]
|
||||
CONTAINS_CLOSE = 'CONTAINS_CLOSE', // [/CONTAINS]
|
||||
FORMAT_OPEN = 'FORMAT_OPEN', // [FORMAT ...]
|
||||
FORMAT_CLOSE = 'FORMAT_CLOSE', // [/FORMAT]
|
||||
VALUE_OF = 'VALUE_OF', // [VALUE_OF ... /]
|
||||
SIZE_OF = 'SIZE_OF', // [SIZE_OF ... /]
|
||||
HEADER = 'HEADER', // [HEADER ... /]
|
||||
|
||||
// Expressions
|
||||
EXPR_OPEN = 'EXPR_OPEN', // [%
|
||||
EXPR_CLOSE = 'EXPR_CLOSE', // %]
|
||||
EXPR_OUTPUT_OPEN = 'EXPR_OUTPUT_OPEN', // [%=
|
||||
|
||||
// Comments
|
||||
COMMENT_OPEN = 'COMMENT_OPEN', // [!--
|
||||
COMMENT_CLOSE = 'COMMENT_CLOSE', // --]
|
||||
|
||||
// Content
|
||||
TEXT = 'TEXT', // Plain text
|
||||
IDENTIFIER = 'IDENTIFIER', // Variable names
|
||||
STRING = 'STRING', // "..."
|
||||
NUMBER = 'NUMBER', // 123, 45.67
|
||||
|
||||
// Other
|
||||
EOF = 'EOF',
|
||||
ERROR = 'ERROR'
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
type: TokenType;
|
||||
value: string;
|
||||
line: number;
|
||||
column: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
export interface TokenizerError {
|
||||
message: string;
|
||||
line: number;
|
||||
column: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* EFTL Tokenizer - converts source text into tokens
|
||||
*/
|
||||
export class EftlTokenizer {
|
||||
private source: string;
|
||||
private pos: number = 0;
|
||||
private line: number = 1;
|
||||
private column: number = 1;
|
||||
private tokens: Token[] = [];
|
||||
private errors: TokenizerError[] = [];
|
||||
|
||||
constructor(source: string) {
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
tokenize(): { tokens: Token[]; errors: TokenizerError[] } {
|
||||
this.tokens = [];
|
||||
this.errors = [];
|
||||
this.pos = 0;
|
||||
this.line = 1;
|
||||
this.column = 1;
|
||||
|
||||
while (this.pos < this.source.length) {
|
||||
this.scanToken();
|
||||
}
|
||||
|
||||
this.tokens.push({
|
||||
type: TokenType.EOF,
|
||||
value: '',
|
||||
line: this.line,
|
||||
column: this.column,
|
||||
length: 0
|
||||
});
|
||||
|
||||
return { tokens: this.tokens, errors: this.errors };
|
||||
}
|
||||
|
||||
private scanToken(): void {
|
||||
const startLine = this.line;
|
||||
const startColumn = this.column;
|
||||
|
||||
// Check for EFTL constructs starting with [
|
||||
if (this.peek() === '[') {
|
||||
if (this.match('[EFTL]')) {
|
||||
this.addToken(TokenType.EFTL_OPEN, '[EFTL]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[/EFTL]')) {
|
||||
this.addToken(TokenType.EFTL_CLOSE, '[/EFTL]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[!--')) {
|
||||
this.addToken(TokenType.COMMENT_OPEN, '[!--', startLine, startColumn);
|
||||
this.scanComment();
|
||||
return;
|
||||
}
|
||||
if (this.match('[%=')) {
|
||||
this.addToken(TokenType.EXPR_OUTPUT_OPEN, '[%=', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[%')) {
|
||||
this.addToken(TokenType.EXPR_OPEN, '[%', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[VAR')) {
|
||||
this.scanVarOpen(startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[/VAR]')) {
|
||||
this.addToken(TokenType.VAR_CLOSE, '[/VAR]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[IF]')) {
|
||||
this.addToken(TokenType.IF_OPEN, '[IF]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[/IF]')) {
|
||||
this.addToken(TokenType.IF_CLOSE, '[/IF]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[CONDITION]')) {
|
||||
this.addToken(TokenType.CONDITION_OPEN, '[CONDITION]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[/CONDITION]')) {
|
||||
this.addToken(TokenType.CONDITION_CLOSE, '[/CONDITION]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[THEN]')) {
|
||||
this.addToken(TokenType.THEN_OPEN, '[THEN]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[/THEN]')) {
|
||||
this.addToken(TokenType.THEN_CLOSE, '[/THEN]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[ELSE IF]')) {
|
||||
this.addToken(TokenType.ELSE_IF_OPEN, '[ELSE IF]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[/ELSE IF]')) {
|
||||
this.addToken(TokenType.ELSE_IF_CLOSE, '[/ELSE IF]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[ELSE]')) {
|
||||
this.addToken(TokenType.ELSE_OPEN, '[ELSE]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[/ELSE]')) {
|
||||
this.addToken(TokenType.ELSE_CLOSE, '[/ELSE]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[WHILE]')) {
|
||||
this.addToken(TokenType.WHILE_OPEN, '[WHILE]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[/WHILE]')) {
|
||||
this.addToken(TokenType.WHILE_CLOSE, '[/WHILE]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[DO]')) {
|
||||
this.addToken(TokenType.DO_OPEN, '[DO]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[/DO]')) {
|
||||
this.addToken(TokenType.DO_CLOSE, '[/DO]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[TAG]')) {
|
||||
this.addToken(TokenType.TAG_OPEN, '[TAG]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[/TAG]')) {
|
||||
this.addToken(TokenType.TAG_CLOSE, '[/TAG]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[SPLIT')) {
|
||||
this.scanTagWithAttributes(TokenType.SPLIT_OPEN, 'SPLIT', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[/SPLIT]')) {
|
||||
this.addToken(TokenType.SPLIT_CLOSE, '[/SPLIT]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[TRIM]')) {
|
||||
this.addToken(TokenType.TRIM_OPEN, '[TRIM]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[/TRIM]')) {
|
||||
this.addToken(TokenType.TRIM_CLOSE, '[/TRIM]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[CONTAINS')) {
|
||||
this.scanTagWithAttributes(TokenType.CONTAINS_OPEN, 'CONTAINS', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[/CONTAINS]')) {
|
||||
this.addToken(TokenType.CONTAINS_CLOSE, '[/CONTAINS]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[FORMAT')) {
|
||||
this.scanTagWithAttributes(TokenType.FORMAT_OPEN, 'FORMAT', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[/FORMAT]')) {
|
||||
this.addToken(TokenType.FORMAT_CLOSE, '[/FORMAT]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[VALUE_OF')) {
|
||||
this.scanSelfClosingTag(TokenType.VALUE_OF, 'VALUE_OF', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[SIZE_OF')) {
|
||||
this.scanSelfClosingTag(TokenType.SIZE_OF, 'SIZE_OF', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
if (this.match('[HEADER')) {
|
||||
this.scanSelfClosingTag(TokenType.HEADER, 'HEADER', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for expression close
|
||||
if (this.match('%]')) {
|
||||
this.addToken(TokenType.EXPR_CLOSE, '%]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for comment close
|
||||
if (this.match('--]')) {
|
||||
this.addToken(TokenType.COMMENT_CLOSE, '--]', startLine, startColumn);
|
||||
return;
|
||||
}
|
||||
|
||||
// Plain text - consume until we hit a special character
|
||||
this.scanText(startLine, startColumn);
|
||||
}
|
||||
|
||||
private scanComment(): void {
|
||||
const startLine = this.line;
|
||||
const startColumn = this.column;
|
||||
let content = '';
|
||||
|
||||
while (this.pos < this.source.length) {
|
||||
if (this.match('--]')) {
|
||||
this.addToken(TokenType.COMMENT_CLOSE, '--]', this.line, this.column - 3);
|
||||
return;
|
||||
}
|
||||
content += this.advance();
|
||||
}
|
||||
|
||||
// Unclosed comment
|
||||
this.errors.push({
|
||||
message: 'Unclosed comment: expected "--]"',
|
||||
line: startLine,
|
||||
column: startColumn,
|
||||
length: content.length
|
||||
});
|
||||
}
|
||||
|
||||
private scanVarOpen(startLine: number, startColumn: number): void {
|
||||
let value = '[VAR';
|
||||
// Scan until we find ] or ]
|
||||
while (this.pos < this.source.length && this.peek() !== ']') {
|
||||
value += this.advance();
|
||||
}
|
||||
if (this.peek() === ']') {
|
||||
value += this.advance();
|
||||
}
|
||||
this.addToken(TokenType.VAR_OPEN, value, startLine, startColumn);
|
||||
}
|
||||
|
||||
private scanTagWithAttributes(type: TokenType, tagName: string, startLine: number, startColumn: number): void {
|
||||
let value = '[' + tagName;
|
||||
while (this.pos < this.source.length && this.peek() !== ']') {
|
||||
value += this.advance();
|
||||
}
|
||||
if (this.peek() === ']') {
|
||||
value += this.advance();
|
||||
}
|
||||
this.addToken(type, value, startLine, startColumn);
|
||||
}
|
||||
|
||||
private scanSelfClosingTag(type: TokenType, tagName: string, startLine: number, startColumn: number): void {
|
||||
let value = '[' + tagName;
|
||||
while (this.pos < this.source.length) {
|
||||
const ch = this.advance();
|
||||
value += ch;
|
||||
if (value.endsWith('/]')) {
|
||||
break;
|
||||
}
|
||||
if (ch === ']') {
|
||||
// Not self-closing, error
|
||||
this.errors.push({
|
||||
message: `Expected self-closing tag: [${tagName} ... /]`,
|
||||
line: startLine,
|
||||
column: startColumn,
|
||||
length: value.length
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.addToken(type, value, startLine, startColumn);
|
||||
}
|
||||
|
||||
private scanText(startLine: number, startColumn: number): void {
|
||||
let text = '';
|
||||
while (this.pos < this.source.length) {
|
||||
const ch = this.peek();
|
||||
// Stop at special characters
|
||||
if (ch === '[' || (ch === '%' && this.peekNext() === ']') || (ch === '-' && this.peekNext() === '-' && this.peekAt(2) === ']')) {
|
||||
break;
|
||||
}
|
||||
text += this.advance();
|
||||
}
|
||||
if (text.length > 0) {
|
||||
this.addToken(TokenType.TEXT, text, startLine, startColumn);
|
||||
}
|
||||
}
|
||||
|
||||
private peek(): string {
|
||||
return this.source[this.pos] || '\0';
|
||||
}
|
||||
|
||||
private peekNext(): string {
|
||||
return this.source[this.pos + 1] || '\0';
|
||||
}
|
||||
|
||||
private peekAt(offset: number): string {
|
||||
return this.source[this.pos + offset] || '\0';
|
||||
}
|
||||
|
||||
private advance(): string {
|
||||
const ch = this.source[this.pos++];
|
||||
if (ch === '\n') {
|
||||
this.line++;
|
||||
this.column = 1;
|
||||
} else {
|
||||
this.column++;
|
||||
}
|
||||
return ch;
|
||||
}
|
||||
|
||||
private match(expected: string): boolean {
|
||||
if (this.source.substring(this.pos, this.pos + expected.length) === expected) {
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
this.advance();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private addToken(type: TokenType, value: string, line: number, column: number): void {
|
||||
this.tokens.push({
|
||||
type,
|
||||
value,
|
||||
line,
|
||||
column,
|
||||
length: value.length
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user