428 lines
16 KiB
TypeScript
428 lines
16 KiB
TypeScript
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<void> {
|
|
const start = Date.now();
|
|
const metricsMeter = new Metrics();
|
|
const entries = fs.readdirSync(this.options.sourcePath, { recursive: true, withFileTypes: true });
|
|
const parsedFiles: Map<string, ParsedFile> = 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<string, ParsedFile>,
|
|
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<string> = new Set();
|
|
const depth : number = 1;
|
|
this.logger.writeLine('Dependency Tree <root>:');
|
|
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<string> = 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<string> = 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}`);
|
|
}
|
|
} |