600 lines
22 KiB
TypeScript
600 lines
22 KiB
TypeScript
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 '';
|
|
}
|
|
} |