Files
DcsMissionScriptingTools/compiler/src/CodeBlocks.ts
T

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 '';
}
}