export interface ErrorObject {
file: string;
line: number;
message: string;
stack: string[];
type: 'script_error' | 'parse_error' | 'runtime_error' | 'assertion_failed';
}
export class ParserGodot4 {
public parseFirstError(logOutput: string): ErrorObject | null {
const lines = logOutput.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Parse script errors (most common)
const scriptError = this.parseScriptError(line);
if (scriptError) {
scriptError.stack = this.extractStack(lines, i);
return scriptError;
}
// Parse parser errors
const parseError = this.parseParseError(line);
if (parseError) {
parseError.stack = this.extractStack(lines, i);
return parseError;
}
// Parse runtime errors
const runtimeError = this.parseRuntimeError(line);
if (runtimeError) {
runtimeError.stack = this.extractStack(lines, i);
return runtimeError;
}
// Parse assertion failures
const assertionError = this.parseAssertionError(line);
if (assertionError) {
assertionError.stack = this.extractStack(lines, i);
return assertionError;
}
}
return null;
}
private parseScriptError(line: string): ErrorObject | null {
// Pattern: ERROR: Invalid call. Nonexistent function 'some_function' in base 'Node'.
// At: res://scripts/combat/fighter.gd:123 @ _ready()
const errorMatch = line.match(/ERROR:\s*(.+)/);
if (!errorMatch) return null;
const message = errorMatch[1];
// Look for "At: " line which usually follows
const atMatch = message.match(/At:\s*([^:]+):(\d+)/);
if (atMatch) {
return {
file: this.normalizeFilePath(atMatch[1]),
line: parseInt(atMatch[2]),
message: message.replace(/At:\s*[^:]+:\d+.*$/, '').trim(),
stack: [],
type: 'script_error'
};
}
// Alternative pattern: ERROR: res://path/file.gd:123 - Some error message
const altMatch = line.match(/ERROR:\s*([^:]+):(\d+)\s*[-–]\s*(.+)/);
if (altMatch) {
return {
file: this.normalizeFilePath(altMatch[1]),
line: parseInt(altMatch[2]),
message: altMatch[3],
stack: [],
type: 'script_error'
};
}
return null;
}
private parseParseError(line: string): ErrorObject | null {
// Pattern: SCRIPT ERROR: Parse Error at line 123: Expected ')' after expression
// Path: res://scripts/combat/fighter.gd
const parseMatch = line.match(/SCRIPT ERROR:\s*Parse Error at line (\d+):\s*(.+)/);
if (!parseMatch) return null;
return {
file: '', // Will be filled from next line typically
line: parseInt(parseMatch[1]),
message: `Parse Error: ${parseMatch[2]}`,
stack: [],
type: 'parse_error'
};
}
private parseRuntimeError(line: string): ErrorObject | null {
// Pattern: SCRIPT ERROR: Invalid get index 'some_property' (on base: 'null instance')
// At: res://scripts/combat/fighter.gd:123 @ some_function()
const runtimeMatch = line.match(/SCRIPT ERROR:\s*(.+)/);
if (!runtimeMatch) return null;
const message = runtimeMatch[1];
const atMatch = message.match(/At:\s*([^:]+):(\d+)/);
if (atMatch) {
return {
file: this.normalizeFilePath(atMatch[1]),
line: parseInt(atMatch[2]),
message: message.replace(/At:\s*[^:]+:\d+.*$/, '').trim(),
stack: [],
type: 'runtime_error'
};
}
return null;
}
private parseAssertionError(line: string): ErrorObject | null {
// Pattern: ASSERTION FAILED: condition_name
// At: res://scripts/combat/fighter.gd:123
const assertMatch = line.match(/ASSERTION FAILED:\s*(.+)/);
if (!assertMatch) return null;
return {
file: '', // Will need to be extracted from context
line: 0, // Will need to be extracted from context
message: `Assertion failed: ${assertMatch[1]}`,
stack: [],
type: 'assertion_failed'
};
}
private extractStack(lines: string[], startIndex: number): string[] {
const stack: string[] = [];
// Look ahead for stack trace information
for (let i = startIndex; i < Math.min(startIndex + 10, lines.length); i++) {
const line = lines[i].trim();
// Stack frame patterns
if (line.match(/^\s*at\s+/i) ||
line.match(/^[^:]+:\d+/)) {
stack.push(line);
}
// Function call patterns
if (line.includes(' @ ') || line.includes('()')) {
const funcMatch = line.match(/([^@]+@[^(]+\([^)]*\))/);
if (funcMatch) {
stack.push(funcMatch[1]);
}
}
// Stop at next error or empty line
if ((line.startsWith('ERROR:') || line.startsWith('SCRIPT ERROR:')) && i > startIndex) {
break;
}
if (line.length === 0 && stack.length > 0) {
break;
}
}
return stack;
}
private normalizeFilePath(filePath: string): string {
// Convert absolute paths to res:// format
if (filePath.startsWith('/') && !filePath.startsWith('res://')) {
// Try to find project root indicator
const resIndex = filePath.indexOf('/res/');
if (resIndex !== -1) {
return 'res://' + filePath.substring(resIndex + 5);
}
}
// Ensure res:// prefix
if (!filePath.startsWith('res://') && !filePath.startsWith('/')) {
return 'res://' + filePath;
}
return filePath;
}
public parseAllErrors(logOutput: string): ErrorObject[] {
const errors: ErrorObject[] = [];
const lines = logOutput.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const error = this.parseScriptError(line) ||
this.parseParseError(line) ||
this.parseRuntimeError(line) ||
this.parseAssertionError(line);
if (error) {
error.stack = this.extractStack(lines, i);
errors.push(error);
}
}
return errors;
}
public isTestFailure(logOutput: string): boolean {
const lowerOutput = logOutput.toLowerCase();
return lowerOutput.includes('failed') ||
lowerOutput.includes('error') ||
lowerOutput.includes('assertion');
}
public extractTestResults(logOutput: string): {
passed: number;
failed: number;
total: number;
suites: string[];
} {
const results = {
passed: 0,
failed: 0,
total: 0,
suites: [] as string[]
};
// Parse gdUnit4 output patterns
const passedMatch = logOutput.match(/(\d+)\s+passed/i);
if (passedMatch) {
results.passed = parseInt(passedMatch[1]);
}
const failedMatch = logOutput.match(/(\d+)\s+failed/i);
if (failedMatch) {
results.failed = parseInt(failedMatch[1]);
}
results.total = results.passed + results.failed;
// Extract test suite names
const suiteMatches = logOutput.matchAll(/Running\s+([A-Za-z0-9_]+Test)/g);
for (const match of suiteMatches) {
results.suites.push(match[1]);
}
return results;
}
}