diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 1243b10..ed11a21 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,8 +3,6 @@ // for the documentation about the extensions.json format "recommendations": [ "dbaeumer.vscode-eslint", - "connor4312.esbuild-problem-matchers", - "ms-vscode.extension-test-runner", "sumneko.lua" ] } diff --git a/.vscode/launch.json b/.vscode/launch.json index ef6e1f2..6c17764 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -16,7 +16,7 @@ "${workspaceFolder}/dutchies-dcs-scripting-tools/dist/**/*.js", "${workspaceFolder}/compiler/dist/**/*.js" ], - "preLaunchTask": "${defaultBuildTask}" + "preLaunchTask": "watch" } ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 01673cc..93f3e5a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,70 +4,30 @@ "version": "2.0.0", "tasks": [ { - "label": "watch", - "dependsOn": [ - "npm: watch:tsc", - "npm: watch:esbuild" - ], - "presentation": { - "reveal": "never" - }, - "group": { - "kind": "build", - "isDefault": true - }, - "options": { - "cwd": "${workspaceFolder}/dutchies-dcs-scripting-tools" - } - }, - { - "type": "npm", - "script": "watch:esbuild", - "group": "build", - "problemMatcher": "$esbuild-watch", - "isBackground": true, - "label": "npm: watch:esbuild", - "presentation": { - "group": "watch", - "reveal": "never" - }, - "options": { - "cwd": "${workspaceFolder}/dutchies-dcs-scripting-tools" - } - }, - { - "type": "npm", - "script": "watch:tsc", - "group": "build", - "problemMatcher": "$tsc-watch", - "isBackground": true, - "label": "npm: watch:tsc", - "presentation": { - "group": "watch", - "reveal": "never" - }, - "options": { - "cwd": "${workspaceFolder}/dutchies-dcs-scripting-tools" - } - }, + "label": "compile", + "type": "npm", + "script": "compile", + "group": { + "kind": "build", + "isDefault": true + }, + "options": { + "cwd": "${workspaceFolder}/dutchies-dcs-scripting-tools" + } + }, { + "label": "watch", "type": "npm", - "script": "watch-tests", - "problemMatcher": "$tsc-watch", + "script": "watch", "isBackground": true, "presentation": { - "reveal": "never", - "group": "watchers" + "reveal": "never" }, - "group": "build" - }, - { - "label": "tasks: watch-tests", - "dependsOn": [ - "npm: watch", - "npm: watch-tests" - ], - "problemMatcher": [] + "group": "build", + "problemMatcher": "$tsc-watch", + "options": { + "cwd": "${workspaceFolder}/dutchies-dcs-scripting-tools" + } } ] } diff --git a/compiler/src/CodeBlocks.ts b/compiler/src/CodeBlocks.ts new file mode 100644 index 0000000..a3ca17e --- /dev/null +++ b/compiler/src/CodeBlocks.ts @@ -0,0 +1,579 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { CompilationError } 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 lineCounter = 0; + const file: LuaFile = new LuaFile(); + let currentBlock: CodeBlock = file; + + 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 === '\n') { + lineCounter++; + } + + 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') { + lineCounter++; + } + leftCursor++; + } + leftCursor += 2; // Skip the closing ]] + } else { + // Comment line, skip to end of line + while (leftCursor < fileContent.length && fileContent[leftCursor] !== '\n') { + leftCursor++; + } + if (fileContent[leftCursor] === '\n') { + lineCounter++; + } + } + currentWord = ''; + continue; + } + else if (currentChar === '"' || currentChar === "'") { + // String literal, skip to closing quote + const quoteType = currentChar; + leftCursor++; + while (leftCursor < fileContent.length && fileContent[leftCursor] !== quoteType) { + // Add string but don't process it for keywords + currentBlockString += fileContent[leftCursor]; + leftCursor++; + } + currentBlockString += quoteType; // Add the closing quote + leftCursor++; // 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 = ''; + } + } + 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 = ''; + + 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; + leftCursor++; + 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++; + } + leftCursor++; + } + + // Find newline or other character + let tempCursor = leftCursor; + while (tempCursor < fileContent.length && fileContent[tempCursor] !== '\n' && fileContent[tempCursor].trim() === '') { + currentBlockString += fileContent[tempCursor]; + tempCursor++; + } + + if (tempCursor < fileContent.length && fileContent[tempCursor] === '\n') { + currentBlockString += '\n'; + lineCounter++; + } else if (tempCursor > leftCursor) { + // We found whitespace but no newline, so position cursor at last whitespace + leftCursor = tempCursor - 1; + } + // 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, leftCursor - currentWord.length, leftCursor); + 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" + }); + } else { + currentBlock = parent; + } + } + + if (blockToAdd) { + currentBlock.childBlocks.push(blockToAdd); + currentBlock = blockToAdd; + } + + currentWord = ''; + } + leftCursor++; + } + // 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 readonly 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 ''; + } +} \ No newline at end of file diff --git a/compiler/src/CompilationError.ts b/compiler/src/CompilationError.ts new file mode 100644 index 0000000..3a440af --- /dev/null +++ b/compiler/src/CompilationError.ts @@ -0,0 +1,9 @@ + + +export interface CompilationError { + filePath: string; + line: number; + charStart?: number; + charEnd?: number; + message: string; +} \ No newline at end of file diff --git a/compiler/src/ScriptCompiler.ts b/compiler/src/ScriptCompiler.ts index 9ff433d..556f954 100644 --- a/compiler/src/ScriptCompiler.ts +++ b/compiler/src/ScriptCompiler.ts @@ -1,13 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; - -export interface CompilationError { - filePath: string; - line: number; - charStart?: number; - charEnd?: number; - message: string; -} +import { LuaFile, BlockType, CodeBlock, RequireBlock, ReturnBlock, FunctionBlock, TableBlock } from './CodeBlocks'; +import { CompilationError } from './CompilationError'; export interface ScriptCompilerOptions { sourcePath: string, @@ -23,6 +17,8 @@ export interface ICompilationLogger { writeLine(message: string): void; } +export { CompilationError }; + class Metrics { public totalLinesRead : number = 0; public totalLinesWritten: number = 0; @@ -66,9 +62,21 @@ export class ScriptCompiler { if (entry.isFile() && entry.name.endsWith('.lua')) { const fullPath = path.join(entry.parentPath, entry.name); const relativePath = path.relative(this.options.sourcePath, fullPath); - const content = fs.readFileSync(fullPath, 'utf-8'); - - const parsedFile = this.parseFile(relativePath, content, fullPath, metricsMeter); + 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.` + }); + continue; + } + + const parsedFile = new ParsedFile(key, luaFile, fullPath); + parsedFiles.set(parsedFile.fileKey, parsedFile); metricsMeter.filesRead++; } @@ -83,6 +91,8 @@ export class ScriptCompiler { this.options.onError ); + writer.logDependencyTree(); + const writeStart = Date.now(); writer.write(includeDevScript, metricsMeter); const writeEnd = Date.now(); @@ -95,203 +105,11 @@ export class ScriptCompiler { metricsMeter.totalTimeMs = (end-start); metricsMeter.log(this.logger); } - - private reportError(filePath: string, line: number, charStart: number | undefined, charEnd: number | undefined, message: string): void { - if (this.options.onError) { - this.options.onError({ filePath, line, charStart, charEnd, message }); - } - } - - private parseFile(filePath: string, content: string, fullPath: string, metricsMeter: Metrics): ParsedFile { - const dependencies: Dependency[] = []; - const newLines: string[] = [`do --${filePath}`]; - - content = stripLuaMultilineComments(content); - const lines = content.split('\n').map(line => line.replace(/--.*$/, '')); - - const blockStack : string[] = []; - let isInFunction = false; - let foundModuleLevelReturn = false; - let expectingDo = false; - - const blockFound = (blockType: string): void => { - blockStack.push(blockType); - if (blockType === 'function') { - isInFunction = true; - } - }; - - const blockClosed = (): void => { - const closedBlock = blockStack.pop(); - if (closedBlock === 'function') { - isInFunction = blockStack.includes('function'); - } - }; - - for (let i = 0; i < lines.length; i++) { - metricsMeter.totalLinesRead++; - let line = lines[i]; - const trimmedLine = line.trim(); - - // Skip comments and blank lines - if (trimmedLine === '' || trimmedLine.startsWith('--')) { - continue; - } - - // If we found module-level return, only allow 'end' statements after it - if (foundModuleLevelReturn) { - // Check for 'end' keyword - if (/\bend\b/.test(trimmedLine)) { - const endMatches = trimmedLine.match(/\bend\b/g); - if (endMatches) { - for (let j = 0; j < endMatches.length; j++) { - blockClosed(); - } - } - newLines.push(line); - } else { - this.reportError(fullPath, i, undefined, undefined, `Code found after module-level return: ${trimmedLine}`); - newLines.push(line); // Continue processing despite error - } - continue; - } - - // Handle require statements - const requireMatch = line.match(/require\(['"](.+?)['"]\)/); - if (requireMatch) { - const textMatch = requireMatch[1]; - const requiredModule = fileReferenceToLuaVariable(textMatch); - - dependencies.push(new Dependency(textMatch, i, requireMatch.index ?? 0, (requireMatch.index ?? 0) + requireMatch[0].length)); - line = line.replace(requireMatch[0], requiredModule); - } - - // Track block keywords AND returns - need to process in order they appear - const keywords = [ - { regex: /\bfunction\b/, type: 'function' }, - { regex: /\bif\b/, type: 'if' }, - { regex: /\bfor\b/, type: 'for' }, - { regex: /\bwhile\b/, type: 'while' }, - { regex: /\bdo\b/, type: 'do' }, - { regex: /\bend\b/, type: 'end' }, - { regex: /\breturn\b/, type: 'return' } // Add return to the list! - ]; - - // Find positions of all keywords in the line - const foundKeywords: Array<{ position: number, type: string }> = []; - for (const kw of keywords) { - const matches = [...trimmedLine.matchAll(new RegExp(kw.regex, 'g'))]; - for (const match of matches) { - if (match.index !== undefined) { - foundKeywords.push({ position: match.index, type: kw.type }); - } - } - } - - // Sort by position to process in order - foundKeywords.sort((a, b) => a.position - b.position); - - // Process keywords in order - for (const kw of foundKeywords) { - if (kw.type === 'end') { - blockClosed(); - expectingDo = false; - } else if (kw.type === 'for' || kw.type === 'while') { - blockFound(kw.type); - expectingDo = true; - } else if (kw.type === 'do') { - if (!expectingDo) { - // Standalone do block - blockFound('do'); - } - expectingDo = false; - } else if (kw.type === 'return') { - // Handle return in sequence - if (!isInFunction && !foundModuleLevelReturn) { - // Extract the return value (everything after 'return') - const afterReturnPos = kw.position + 6; // 'return' is 6 chars - const afterReturn = trimmedLine.substring(afterReturnPos).trim(); - - if (afterReturn === '') { - this.reportError(fullPath, i, afterReturnPos, afterReturnPos, 'Empty return statement at module level'); - continue; - } - - // Check for multiple return values (commas outside of parentheses/braces/brackets) - let parenDepth = 0; - let braceDepth = 0; - let bracketDepth = 0; - let inString = false; - let stringChar = ''; - let hasMultipleValues = false; - - for (let j = 0; j < afterReturn.length; j++) { - const char = afterReturn[j]; - - if (!inString) { - if (char === '"' || char === "'") { - inString = true; - stringChar = char; - } else if (char === '(') { - parenDepth++; - } else if (char === ')') { - parenDepth--; - } else if (char === '{') { - braceDepth++; - } else if (char === '}') { - braceDepth--; - } else if (char === '[') { - bracketDepth++; - } else if (char === ']') { - bracketDepth--; - } else if (char === ',' && parenDepth === 0 && braceDepth === 0 && bracketDepth === 0) { - hasMultipleValues = true; - break; - } - } else { - if (char === stringChar && afterReturn[j - 1] !== '\\') { - inString = false; - } - } - } - - if (hasMultipleValues) { - this.reportError(fullPath, i, afterReturnPos, afterReturnPos + afterReturn.length, `Multiple return values not supported: ${trimmedLine}`); - } else { - // Replace return with assignment - const moduleVariable = fileReferenceToLuaVariable(filePath); - const parts = moduleVariable.split('.'); - for (let p = 1; p < parts.length; p++) { - const path = parts.slice(0, p + 1).join('.'); - newLines.push(`if not ${path} then ${path} = {} end`); - } - - line = line.replace(/\breturn\b/, moduleVariable + ' ='); - foundModuleLevelReturn = true; - } - } - } else { - // function or if - blockFound(kw.type); - expectingDo = false; - } - } - - newLines.push(line); - } - - newLines.push(`end --${filePath}`); - return new ParsedFile(filePath, fullPath, newLines, dependencies); - } -} - -function stripLuaMultilineComments(content: string): string { - // Matches --[[...]], --[=[...]=], --[==[...]==], etc. - return content.replace(/--\[(=*)\[[\s\S]*?\]\1\]/g, ''); } class Dependency { public readonly fileKey: string; + public readonly luaReference: string; constructor( public readonly requiredModule: string, @@ -300,22 +118,11 @@ class Dependency { public readonly charEnd: number ) { - this.fileKey = fileReferenceToLuaVariable(requiredModule); + this.luaReference = fileReferenceToLuaVariable(requiredModule); + this.fileKey = this.luaReference.replace(LUA_SCRIPT_GLOBAL_KEYWORD + '.', '').toLowerCase(); } } -class ParsedFile { - public readonly fileKey: string; - - constructor( - public readonly filePath: string, - public readonly fullPath: string, - public readonly lines: string[], - public readonly dependencies: Dependency[] - ) { - this.fileKey = fileReferenceToLuaVariable(filePath); - } -} function fileReferenceToLuaVariable(fileReference: string): string { // Remove .lua extension @@ -333,21 +140,104 @@ function fileReferenceToLuaVariable(fileReference: string): string { // 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++) { - if (i === parts.length - 1) { - // Capitalize first letter of filename - result += '.' + parts[i].charAt(0).toUpperCase() + parts[i].slice(1); - } else { - // Folder names stay lowercase - result += '.' + parts[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, @@ -358,8 +248,8 @@ class Writer { private getStartLines(): string[] { return [ - `-- Transpiled at (UTC): ${new Date().toISOString()}`, - `local ${LUA_SCRIPT_GLOBAL_KEYWORD} = {}` + `-- Transpiled at (UTC): ${new Date().toISOString()}\n`, + `local ${LUA_SCRIPT_GLOBAL_KEYWORD} = {}\n` ]; } @@ -462,8 +352,42 @@ class Writer { }); } } - metrics.totalLinesWritten += parsedFile.lines.length; - outputLines.push(...parsedFile.lines); + + 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++; }; @@ -474,7 +398,7 @@ class Writer { } fs.mkdirSync(path.dirname(this.location), { recursive: true }); - fs.writeFileSync(this.location, outputLines.join('\n'), 'utf-8'); + fs.writeFileSync(this.location, outputLines.join(''), 'utf-8'); if(includeDevScript) { const devFileLocation = this.location.replace('.lua', '.dev.lua'); diff --git a/dutchies-dcs-scripting-tools/esbuild.js b/dutchies-dcs-scripting-tools/esbuild.js index a5117bb..ad31280 100644 --- a/dutchies-dcs-scripting-tools/esbuild.js +++ b/dutchies-dcs-scripting-tools/esbuild.js @@ -47,6 +47,7 @@ async function main() { }); if (watch) { await ctx.watch(); + console.log('[watch] watching for changes...'); } else { await ctx.rebuild(); await ctx.dispose(); diff --git a/dutchies-dcs-scripting-tools/src/extension.ts b/dutchies-dcs-scripting-tools/src/extension.ts index 6a9a872..4fcc20f 100644 --- a/dutchies-dcs-scripting-tools/src/extension.ts +++ b/dutchies-dcs-scripting-tools/src/extension.ts @@ -138,6 +138,7 @@ async function compileLuaScripts() { await compiler.compile(includeDevScript); } catch (err) { vscode.window.showErrorMessage('Compilation failed: ' + (err as Error).message); + logger.error('Compilation failed: ' + (err as Error).message + '\n' + (err as Error).stack); } // Update diagnostics