import * as fs from 'fs'; import * as path from 'path'; import { LuaFile, BlockType, CodeBlock, RequireBlock, ReturnBlock, FunctionBlock, TableBlock } from './CodeBlocks'; import { CompilationError, CompilationErrorType } from './CompilationError'; export interface ScriptCompilerOptions { sourcePath: string, outputPath: string, outputFileName?: string, minify: boolean, onError?: (error: CompilationError) => void } export interface ICompilationLogger { info(message: string): void; error(message: string): void; writeLine(message: string): void; } export { CompilationError, CompilationErrorType }; class Metrics { public totalLinesRead : number = 0; public totalLinesWritten: number = 0; public readTimeMs : number = 0; public writeTimeMs : number = 0; public totalTimeMs : number = 0; public filesRead : number = 0; public filesWritten : number = 0; log(logger: ICompilationLogger){ logger.writeLine("Compilation Metrics: ") logger.writeLine(`=====================`) logger.writeLine(`Lines Read: ${this.totalLinesRead}`) logger.writeLine(`Lines Written: ${this.totalLinesWritten}`) logger.writeLine(`Files Processed: ${this.filesRead} | ${this.filesWritten}`) logger.writeLine(`=====================`) logger.writeLine(`Read Time (ms): ${this.readTimeMs} ms`) logger.writeLine(`Write Time (ms): ${this.writeTimeMs} ms`) logger.writeLine(`Total Time (ms): ${this.totalTimeMs} ms`) } } const LUA_SCRIPT_GLOBAL_KEYWORD = 'ScriptGlobals'; export class ScriptCompiler { constructor(private options: ScriptCompilerOptions, private logger: ICompilationLogger) { if (options.outputFileName === undefined) { this.options.outputFileName = 'compiled.lua'; } } public async compile(includeDevScript: boolean): Promise { const start = Date.now(); const metricsMeter = new Metrics(); const entries = fs.readdirSync(this.options.sourcePath, { recursive: true, withFileTypes: true }); const parsedFiles: Map = new Map(); const readStart = Date.now(); for (const entry of entries) { if (entry.isFile() && entry.name.endsWith('.lua')) { const fullPath = path.join(entry.parentPath, entry.name); const relativePath = path.relative(this.options.sourcePath, fullPath); const luaFile = LuaFile.createFromFile(fullPath, this.options.onError); const luaReference = fileReferenceToLuaVariable(relativePath); const key = luaReference.replace(LUA_SCRIPT_GLOBAL_KEYWORD + '.', '').toLowerCase(); if(parsedFiles.has(key)){ this.options.onError?.({ filePath: fullPath, line: 0, message: `Duplicate file key detected: ${key}. This can happen if two files have different capitalization. Lua is case sensitive, but the compiler treats file keys as case insensitive.`, type: CompilationErrorType.Semantic }); continue; } const parsedFile = new ParsedFile(key, luaFile, fullPath); parsedFiles.set(parsedFile.fileKey, parsedFile); metricsMeter.filesRead++; } } const readEnd = Date.now(); metricsMeter.readTimeMs = readEnd - readStart; const writer = new Writer( path.join(this.options.outputPath, this.options.outputFileName!), parsedFiles, this.logger, this.options.onError ); writer.logDependencyTree(); const writeStart = Date.now(); writer.write(includeDevScript, metricsMeter); const writeEnd = Date.now(); metricsMeter.writeTimeMs = (writeEnd - writeStart); // writer.logDependencyTree(); this.logger.info(`Compilation complete. Output written to ${path.join(this.options.outputPath, this.options.outputFileName!)}`); const end = Date.now(); metricsMeter.totalTimeMs = (end-start); metricsMeter.log(this.logger); } } class Dependency { public readonly fileKey: string; public readonly luaReference: string; constructor( public readonly requiredModule: string, public readonly requiredAtLine: number, public readonly charStart: number, public readonly charEnd: number ) { this.luaReference = fileReferenceToLuaVariable(requiredModule); this.fileKey = this.luaReference.replace(LUA_SCRIPT_GLOBAL_KEYWORD + '.', '').toLowerCase(); } } function fileReferenceToLuaVariable(fileReference: string): string { // Remove .lua extension if (fileReference.endsWith('.lua')) { fileReference = fileReference.substring(0, fileReference.length - 4); } // Remove leading ./ if (fileReference.startsWith('./')) { fileReference = fileReference.substring(2); } // Normalize path separators fileReference = fileReference.replace(/\\/g, '/').replace(/\./g, '/'); // Split by / to get path parts const parts = fileReference.split('/'); // Convert to ScriptGlobals.folder.FileName format let result = LUA_SCRIPT_GLOBAL_KEYWORD; for (let i = 0; i < parts.length; i++) { result += '.' + parts[i].toLowerCase(); } return result; } class ParsedFile { public readonly dependencies: Dependency[] = [] constructor( public readonly fileKey: string, public readonly luaFile: LuaFile, public readonly fullPath: string ){ this.dependencies = ParsedFile.parseDependencies(luaFile); } private static parseDependencies(luaFile: CodeBlock): Dependency[] { // Recursively search for RequireBlocks in the LuaFile and its child blocks const dependencies: Dependency[] = []; const searchBlock = (block: CodeBlock) => { if (block instanceof RequireBlock) { const requiredString = block.getRequiredString(); if (requiredString) { dependencies.push(new Dependency(requiredString.toLowerCase(), block.sourceLineNumber ?? 0, block.charStart ?? 0, block.charEnd ?? 0)); } } for (const child of block.getChildren()) { searchBlock(child); } } searchBlock(luaFile); return dependencies; } public replaceRequireWithGlobal(): void { function replaceRequireInBlock(block: CodeBlock) { if (block instanceof RequireBlock) { const requiredString = block.getRequiredString(); if (requiredString) { const luaReference = fileReferenceToLuaVariable(requiredString); block.toLines = () => [`${luaReference}\n`]; } } for (const child of block.getChildren()) { replaceRequireInBlock(child); } } for (const child of this.luaFile.getChildren()) { replaceRequireInBlock(child); } } public replaceModuleReturnWithGlobalAssignement(): void { const replaceReturnInBlock = (block: CodeBlock) => { if (block instanceof ReturnBlock) { const parent = block.getParentBlock(); if (parent) { const luaReference = fileReferenceToLuaVariable(this.fileKey); const resultLines : string[] = []; const splitCount = luaReference.split('.').length; for(let i = 2; i <= splitCount -1 ; i++){ const partialReference = luaReference.split('.').slice(0, i).join('.'); resultLines.push(`if not ${partialReference} then ${partialReference} = {} end`); } resultLines.push(`${luaReference} = ${block.returnParams.join(', ')}`); block.toLines = () => resultLines.map(line => line + '\n'); } } if(block instanceof FunctionBlock || block instanceof TableBlock){ return; // Do not traverse into FunctionBlock or TableBlock } for (const child of block.getChildren()) { replaceReturnInBlock(child); } } for (const child of this.luaFile.getChildren()) { replaceReturnInBlock(child); } } } class Writer { constructor( public location: string, public files: Map, private readonly logger: ICompilationLogger, public onError?: (error: CompilationError) => void ) {} private getStartLines(): string[] { return [ `-- Transpiled at (UTC): ${new Date().toISOString()}\n`, `local ${LUA_SCRIPT_GLOBAL_KEYWORD} = {}\n` ]; } logDependencyTree(): void { const visited: Set = new Set(); const depth : number = 1; this.logger.writeLine('Dependency Tree :'); const logFileRecursive = (parsedFile: ParsedFile, currentDepth: number) => { if(currentDepth === 0 && visited.has(parsedFile.fileKey)) { return; } visited.add(parsedFile.fileKey); let padding = ' '.repeat(currentDepth * 2); if (currentDepth > 0) { padding += '└─>'; } const printable = parsedFile.fileKey.replace(LUA_SCRIPT_GLOBAL_KEYWORD + '.', ''); const lastPart = printable.split('.').pop(); this.logger.writeLine(`${padding} ${lastPart} ${' '.repeat(Math.max(0, 64 - padding.length - (lastPart ? lastPart.length : 0)))} ${printable}`); for (const dep of parsedFile.dependencies) { const depFile = this.files.get(dep.fileKey); if (depFile) { logFileRecursive(depFile, currentDepth + 1); } } } for (const parsedFile of this.files.values()) { logFileRecursive(parsedFile, depth); } }; write(includeDevScript: boolean, metrics: Metrics): void { const writtenFiles: Set = new Set(); const outputLines: string[] = []; const startLines = this.getStartLines(); metrics.totalLinesWritten+=startLines.length; outputLines.push(...startLines); //Check for circular dependencies before writing const visited: Set = new Set(); for (const fileKey of this.files.keys()) { const stack: string[] = []; const checkCircular = (key: string): boolean => { if (stack.includes(key)) { const cycleStart = stack.indexOf(key); const cycle = [...stack.slice(cycleStart), key]; const file = this.files.get(stack[stack.length - 1]); if (file && this.onError) { this.onError({ filePath: file.fullPath, line: 0, message: `Circular dependency detected: ${cycle.map(x => x.split('.').pop()).join(' -> ')}`, type: CompilationErrorType.DependencyCircular }); } return true; } if (visited.has(key)) { return false; } visited.add(key); stack.push(key); const file = this.files.get(key); if (file) { for (const dep of file.dependencies) { if (checkCircular(dep.fileKey)) { return true; } } } stack.pop(); return false; }; if (checkCircular(fileKey)) { throw new Error(`Compilation failed due to circular dependencies`); } } const writeFileRecursive = (parsedFile: ParsedFile) => { if (writtenFiles.has(parsedFile.fileKey)) { return; } // Write dependencies first for (const dep of parsedFile.dependencies) { const depFile = this.files.get(dep.fileKey); if (depFile) { writeFileRecursive(depFile); } else { this.onError?.({ filePath: parsedFile.fullPath, line: dep.requiredAtLine, charStart: dep.charStart, charEnd: dep.charEnd, message: `Missing dependency: ${dep.fileKey}`, type: CompilationErrorType.DependencyNotFound, metaData: new Map( [ ["dependency", dep.fileKey], ] ) }); } } parsedFile.replaceRequireWithGlobal(); parsedFile.replaceModuleReturnWithGlobalAssignement(); const lines = parsedFile.luaFile.toLines(); const newLineFilteredLines : string[] = []; function trimEndPreserveNewlines(str: string): string { return str.replace(/[ \t]+$/gm, ''); } let wasLastEmpty = false; let lastEndedWithNewline = false; for(const line of lines){ if(trimEndPreserveNewlines(line) !== ''){ if(line.trim() === ''){ if(!wasLastEmpty && !lastEndedWithNewline){ newLineFilteredLines.push(line); wasLastEmpty = true; } } else { newLineFilteredLines.push(line); wasLastEmpty = false; lastEndedWithNewline = line.endsWith('\n'); } } } outputLines.push("do -- " + parsedFile.fileKey + "\n"); metrics.totalLinesWritten += newLineFilteredLines.length; outputLines.push(...newLineFilteredLines); outputLines.push("\nend -- " + parsedFile.fileKey + "\n"); writtenFiles.add(parsedFile.fileKey); metrics.filesWritten++; }; // Write all files in dependency order for (const parsedFile of this.files.values()) { writeFileRecursive(parsedFile); } fs.mkdirSync(path.dirname(this.location), { recursive: true }); fs.writeFileSync(this.location, outputLines.join(''), 'utf-8'); if(includeDevScript) { const devFileLocation = this.location.replace('.lua', '.dev.lua'); this.writeDevScript(devFileLocation, this.location); } } private writeDevScript(devFileLocation: string, actualFileLocation: string){ const devLines = [ `-- DEV SCRIPT - NOT FOR PRODUCTION USE`, `-- This script can be referenced in DCS. Compiled script will then be loaded dynamically.`, `-- This way you can test the compiled output without having to re-import the script into the mission every time.`, `-- This file will only have to be re-imported when the name or location of the compiled script file changes.`, `assert(loadfile([[${actualFileLocation}]]))()` ]; fs.writeFileSync(devFileLocation, devLines.join('\n'), 'utf-8'); this.logger.info(`Development script written to ${devFileLocation}`); } }