import * as fs from 'fs'; import * as path from 'path'; import { CompilationError, CompilationErrorType } from './CompilationError'; /* Block types. Require is a special case as it's technically a function call, but it's a special use case. */ export enum BlockType { CodeTextBlock, Function, If, While, For, Do, Return, Require, Table, File } const keywords = ['if', 'while', 'for', 'do', 'return', 'function', 'require', 'end', 'local', 'else', 'elseif']; function isKeyWord(word: string): boolean { return keywords.includes(word); } function isIdentifierCharacter(char: string): boolean { return /[A-Za-z0-9_]/.test(char); } /** * Trims trailing spaces and tabs, but preserves newlines */ function trimEndPreserveNewlines(str: string): string { return str.replace(/[ \t]+$/gm, ''); } export abstract class CodeBlock { protected childBlocks: CodeBlock[] = []; private parentBlock?: CodeBlock; private blockType: BlockType; public readonly sourceLineNumber?: number; constructor(sourceLineNumber: number, blockType: BlockType, parentBlock?: CodeBlock) { this.sourceLineNumber = sourceLineNumber; this.parentBlock = parentBlock; this.blockType = blockType; } public getChildren(): CodeBlock[] { return this.childBlocks; } abstract toLines(): string[]; getParentBlock(): CodeBlock | undefined { return this.parentBlock; } static createFromFile(luaFilePath: string, onError?: (error: CompilationError) => void): LuaFile { if (!fs.existsSync(luaFilePath)) { throw new Error(`File not found: ${luaFilePath}`); } const fileContent = fs.readFileSync(luaFilePath, 'utf-8'); let leftCursor = 0; let lineCursor = 0; let lineCounter = 0; const file: LuaFile = new LuaFile(); let currentBlock: CodeBlock = file; function advanceCursor(number: number = 1) { leftCursor += number; lineCursor += number; } function newLine() { lineCounter++; lineCursor = 0; } let currentWord = ''; let currentBlockString = ''; while (leftCursor < fileContent.length) { const currentChar: string = fileContent[leftCursor]; if (isIdentifierCharacter(currentChar)) { currentWord += currentChar; } else { currentWord = ''; } currentBlockString += currentChar; const nextChar = leftCursor < fileContent.length - 1 ? fileContent[leftCursor + 1] : ''; if (currentChar === '-' && nextChar === '-') { currentBlockString = currentBlockString.slice(0, -1); // Remove the '-' from the block string const nextNextChar = leftCursor < fileContent.length - 2 ? fileContent[leftCursor + 2] : ''; if (nextNextChar === '[') { // Multiline comment, skip to closing ]] leftCursor += 3; // Skip the --[ while (leftCursor < fileContent.length && !(fileContent[leftCursor] === ']' && fileContent[leftCursor + 1] === ']')) { if (fileContent[leftCursor] === '\n') { newLine(); } advanceCursor() } advanceCursor(2); // Skip the closing ]] } else { // Comment line, skip to end of line while (leftCursor < fileContent.length && fileContent[leftCursor] !== '\n') { advanceCursor(); } if (fileContent[leftCursor] === '\n') { newLine(); } advanceCursor(); } currentWord = ''; continue; } else if (currentChar === '"' || currentChar === "'") { // String literal, skip to closing quote const quoteType = currentChar; advanceCursor(); while (leftCursor < fileContent.length && fileContent[leftCursor] !== quoteType) { // Add string but don't process it for keywords currentBlockString += fileContent[leftCursor]; advanceCursor(); } currentBlockString += quoteType; // Add the closing quote advanceCursor(); // Skip the closing quote currentWord = ''; continue; } // In return block, read till the end of the return block // else if (currentBlock.blockType === BlockType.Return) { // //Continue reading until there's at least a new word // } else if (currentChar === "\n") { // Process line currentBlockString = trimEndPreserveNewlines(currentBlockString); // Remove trailing spaces/tabs //currentBlockString += '\n'; // Add the newline back for the line block if (currentBlockString !== '') { const lineBlock = new CodeTextBlock(lineCounter, currentBlockString, currentBlock); currentBlock.childBlocks.push(lineBlock); currentBlockString = ''; currentWord = ''; } newLine(); advanceCursor(); continue; } else if (currentChar === ")" && currentBlock.blockType === BlockType.Require) { currentBlockString = trimEndPreserveNewlines(currentBlockString); // Remove trailing spaces/tabs //currentBlockString += '\n'; // Add the newline back for the line block as it will be skipped otherwise const lineBlock = new CodeTextBlock(lineCounter, currentBlockString, currentBlock); currentBlock.childBlocks.push(lineBlock); currentBlockString = ''; // Update charEnd to include the closing parenthesis (currentBlock as RequireBlock).charEnd = lineCursor + 1; currentBlock = currentBlock.getParentBlock()!; } //Table blocks else if (currentChar === "{") { // Only create LineBlock if there's content before the brace if (trimEndPreserveNewlines(currentBlockString) !== '' && trimEndPreserveNewlines(currentBlockString) !== '{') { const currentLineBlock = new CodeTextBlock(lineCounter, trimEndPreserveNewlines(currentBlockString.slice(0, -1)), currentBlock); currentBlock.childBlocks.push(currentLineBlock); } let leadingWhiteSpace = ''; const braceIndex = currentBlockString.lastIndexOf('{'); if (braceIndex > 0) { let wsStart = braceIndex - 1; while (wsStart >= 0 && /[ \t]/.test(currentBlockString[wsStart])) { wsStart--; } leadingWhiteSpace = currentBlockString.substring(wsStart + 1, braceIndex); } currentBlockString = leadingWhiteSpace + '{'; // Start the new block string with the opening brace const tableBlock = new TableBlock(lineCounter, currentBlock); currentBlock.childBlocks.push(tableBlock); currentBlock = tableBlock; let braceCounter = 1; advanceCursor(); while (leftCursor < fileContent.length && braceCounter > 0) { const char = fileContent[leftCursor]; currentBlockString += char; if (char === '{') { braceCounter++; } else if (char === '}') { braceCounter--; } if (char === '\n') { let block = trimEndPreserveNewlines(currentBlockString); const lineBlock = new CodeTextBlock(lineCounter, block, currentBlock); currentBlock.childBlocks.push(lineBlock); currentBlockString = ''; lineCounter++; lineCursor = 0; } advanceCursor(); } // Find newline or other character let tempCursor = leftCursor; let tempLineCursor = lineCursor; while (tempCursor < fileContent.length && fileContent[tempCursor] !== '\n' && fileContent[tempCursor].trim() === '') { currentBlockString += fileContent[tempCursor]; tempCursor++; tempLineCursor++; } if (tempCursor < fileContent.length && fileContent[tempCursor] === '\n') { currentBlockString += '\n'; lineCounter++; lineCursor = 0; leftCursor = tempCursor + 1; } else if (tempCursor > leftCursor) { // We found whitespace but no newline, so position cursor at last whitespace leftCursor = tempCursor; lineCursor = tempLineCursor; } // else: no whitespace after table, leave leftCursor where it is if (trimEndPreserveNewlines(currentBlockString) !== '') { const lineBlock = new CodeTextBlock(lineCounter, currentBlockString, currentBlock); currentBlock.childBlocks.push(lineBlock); currentBlockString = ''; } currentBlock = currentBlock.getParentBlock()!; currentWord = ''; // Reset word accumulator after table block continue; // Skip the leftCursor++ at the end of the loop since we already incremented it } else if (currentWord !== '' && !isIdentifierCharacter(nextChar)) { // End of a word, check for keywords const trimmedWord = currentWord.trim(); if (currentBlock.blockType === BlockType.Return && isKeyWord(trimmedWord) && trimmedWord !== 'return') { // We're in a ReturnBlock and hit a keyword at the statement boundary // Extract return params and exit the block (currentBlock as ReturnBlock).extractReturnParams(); currentBlock = currentBlock.getParentBlock()!; // Don't clear currentBlockString - let normal flow handle the newline finalization // Continue to process this keyword normally } let blockToAdd: CodeBlock | null = null; if (trimmedWord === 'if') { blockToAdd = new IfBlock(lineCounter, currentBlock); } else if (trimmedWord === 'while') { blockToAdd = new WhileBlock(lineCounter, currentBlock); } else if (trimmedWord === 'for') { blockToAdd = new ForBlock(lineCounter, currentBlock); } else if (trimmedWord === 'do') { const isForOrWhile = currentBlock.blockType === BlockType.While || currentBlock.blockType === BlockType.For; if(isForOrWhile && (currentBlock as WhileBlock | ForBlock).passedDoStatement == false) { // If we're in a While or For block and we've not yet passed a 'do' statement, mark it as passed and don't create a new block (currentBlock as WhileBlock | ForBlock).passedDoStatement = true; } else { blockToAdd = new DoBlock(lineCounter, currentBlock); } } else if (trimmedWord === 'return') { // Add any text before 'return' to parent block, but preserve newlines const beforeReturn = currentBlockString.slice(0, -trimmedWord.length); if (beforeReturn.trim()) { const line = new CodeTextBlock(lineCounter, beforeReturn, currentBlock); currentBlock.childBlocks.push(line); } blockToAdd = new ReturnBlock(lineCounter, currentBlock); // Start the return block content with 'return' keyword currentBlockString = trimmedWord; } else if (trimmedWord === 'function') { blockToAdd = new FunctionBlock(lineCounter, currentBlock); } else if (trimmedWord === 'require') { const beforeRequire = currentBlockString.slice(0, -trimmedWord.length); if (beforeRequire.trim()) { const line = new CodeTextBlock(lineCounter, beforeRequire, currentBlock); currentBlock.childBlocks.push(line); } blockToAdd = new RequireBlock(lineCounter, currentBlock, Math.max(0, lineCursor - trimmedWord.length), lineCursor); currentBlockString = trimmedWord; // Start the require block content with 'require' keyword } else if (trimmedWord === 'end') { const parent = currentBlock.getParentBlock(); if (!parent) { onError?.({ filePath: luaFilePath, line: fileContent.substring(0, leftCursor).split('\n').length - 1, message: "Unexpected 'end' without matching block start", type: CompilationErrorType.Syntax }); } else { currentBlock = parent; } } if (blockToAdd) { currentBlock.childBlocks.push(blockToAdd); currentBlock = blockToAdd; } currentWord = ''; } advanceCursor(); } // Handle case where file ends while in a ReturnBlock if (currentBlock.blockType === BlockType.Return) { if (trimEndPreserveNewlines(currentBlockString) !== '') { const lineBlock = new CodeTextBlock(lineCounter, currentBlockString, currentBlock); currentBlock.childBlocks.push(lineBlock); } (currentBlock as ReturnBlock).extractReturnParams(); } return file; } } export class CodeTextBlock extends CodeBlock { constructor(sourceLineNumber: number, private line: string, parent?: CodeBlock) { super(sourceLineNumber, BlockType.CodeTextBlock, parent); } toLines(): string[] { return [this.line]; } } export class IfBlock extends CodeBlock { constructor(sourceLineNumber: number, parent: CodeBlock) { super(sourceLineNumber, BlockType.If, parent); } toLines(): string[] { const lines: string[] = []; for (const child of this.childBlocks) { lines.push(...child.toLines()); } return lines; } } export class WhileBlock extends CodeBlock { public passedDoStatement: boolean = false; constructor(sourceLineNumber: number, parent: CodeBlock) { super(sourceLineNumber, BlockType.While, parent); } toLines(): string[] { const lines: string[] = []; for (const child of this.childBlocks) { lines.push(...child.toLines()); } return lines; } } export class ForBlock extends CodeBlock { public passedDoStatement: boolean = false; constructor(sourceLineNumber: number, parent: CodeBlock) { super(sourceLineNumber, BlockType.For, parent); } toLines(): string[] { const lines: string[] = []; for (const child of this.childBlocks) { lines.push(...child.toLines()); } return lines; } } export class LuaFile extends CodeBlock { constructor() { super(0, BlockType.File); } toLines(): string[] { const lines: string[] = []; for (const child of this.childBlocks) { lines.push(...child.toLines()); } return lines; } } export class DoBlock extends CodeBlock { constructor(sourceLineNumber: number, parent: CodeBlock) { super(sourceLineNumber, BlockType.Do, parent); } toLines(): string[] { const lines: string[] = []; for (const child of this.childBlocks) { lines.push(...child.toLines()); } return lines; } } export class ReturnBlock extends CodeBlock { public readonly returnParams: string[] = []; constructor(sourceLineNumber: number, parent: CodeBlock) { super(sourceLineNumber, BlockType.Return, parent); } toLines(): string[] { const lines: string[] = []; for (const child of this.childBlocks) { lines.push(...child.toLines()); } return lines; } /** * Extracts return parameters from the return statement. * Splits by commas while respecting nesting of {} and (). * Populates the returnParams array. */ extractReturnParams(): void { // Reconstruct the return content from all children let content = this.childBlocks .map(child => { if (child instanceof CodeTextBlock) { return child.toLines()[0]; } else if (child instanceof TableBlock) { // For tables, use their full content return child.toLines().join(''); } else { // For other block types, use their full content return child.toLines().join(''); } }) .join('') .trim(); if (!content) { this.returnParams.length = 0; return; } content = content.replace(/^return\s+/, '').trim(); // Remove the 'return' keyword if present // Split by commas while respecting nesting const params: string[] = []; let currentParam = ''; let braceDepth = 0; let parenDepth = 0; for (let i = 0; i < content.length; i++) { const char = content[i]; const nextChar = i < content.length - 1 ? content[i + 1] : ''; // Skip strings to avoid counting delimiters inside them if (char === '"' || char === "'") { const quoteType = char; currentParam += char; i++; while (i < content.length && content[i] !== quoteType) { if (content[i] === '\\' && i + 1 < content.length) { currentParam += content[i]; i++; currentParam += content[i]; } else { currentParam += content[i]; } i++; } if (i < content.length) { currentParam += content[i]; } continue; } // Track nesting depth if (char === '{') { braceDepth++; } else if (char === '}') { braceDepth--; } else if (char === '(') { parenDepth++; } else if (char === ')') { parenDepth--; } else if (char === ',' && braceDepth === 0 && parenDepth === 0) { // This is a param separator const param = currentParam.trim(); if (param) { params.push(param); } currentParam = ''; continue; } currentParam += char; } // Add the last parameter const lastParam = currentParam.trim(); if (lastParam) { params.push(lastParam); } this.returnParams.length = 0; this.returnParams.push(...params); } } export class TableBlock extends CodeBlock { constructor(sourceLineNumber: number, parent: CodeBlock) { super(sourceLineNumber, BlockType.Table, parent); } toLines(): string[] { const lines: string[] = []; for (const child of this.childBlocks) { lines.push(...child.toLines()); } return lines; } } export class FunctionBlock extends CodeBlock { constructor(sourceLineNumber: number, parent: CodeBlock) { super(sourceLineNumber, BlockType.Function, parent); } toLines(): string[] { const lines: string[] = []; for (const child of this.childBlocks) { lines.push(...child.toLines()); } return lines; } } export class RequireBlock extends CodeBlock { constructor(sourceLineNumber: number, parent: CodeBlock, public readonly charStart?: number, public charEnd?: number) { super(sourceLineNumber, BlockType.Require, parent); } toLines(): string[] { const lines: string[] = []; for (const child of this.childBlocks) { lines.push(...child.toLines()); } return lines; } getRequiredString(): string { if (this.childBlocks.length === 0) { return ''; } const firstChild = this.childBlocks[0]; if (firstChild instanceof CodeTextBlock) { // local module = require("module") -> module // local module = require('module') -> module const line = firstChild.toLines()[0]; const requireMatch = line.match(/require\s*\(\s*["']([^"']+)["']\s*\)/); if (requireMatch && requireMatch[1]) { return requireMatch[1]; } } return ''; } }