import * as fs from 'fs'; import * as path from 'path'; export interface CommandParameter { name: string; type: string; required: boolean; defaultValue?: string; } export interface CommandInfo { name: string; description: string; parameters: CommandParameter[]; example?: string; filePath?: string; } export class CommandParser { private projectRoot: string; constructor(projectRoot: string) { this.projectRoot = projectRoot; } /** * Parse project.godot file to extract command directories */ private parseProjectGodot(): string[] { const projectGodotPath = path.join(this.projectRoot, 'project.godot'); if (!fs.existsSync(projectGodotPath)) { console.warn('project.godot not found, using default command directories'); return [ 'res://addons/escoria-core/game/core-scripts/esc/commands', 'res://addons/escoria-ui-return-monkey-island/esc/commands', 'res://addons/escoria-ui-return-monkey-island-dialog-simple/commands' ]; } const content = fs.readFileSync(projectGodotPath, 'utf8'); const lines = content.split('\n'); let inEscoriaSection = false; let commandDirectories: string[] = []; for (const line of lines) { const trimmedLine = line.trim(); if (trimmedLine === '[escoria]') { inEscoriaSection = true; continue; } if (inEscoriaSection && trimmedLine.startsWith('[')) { break; // End of escoria section } if (inEscoriaSection && trimmedLine.startsWith('main/command_directories=')) { // Parse the array format: ["path1", "path2", "path3"] const arrayMatch = trimmedLine.match(/main\/command_directories=\[(.*)\]/); if (arrayMatch) { const pathsString = arrayMatch[1]; // Extract quoted paths const pathMatches = pathsString.match(/"([^"]+)"/g); if (pathMatches) { commandDirectories = pathMatches.map(match => match.slice(1, -1)); // Remove quotes } } break; } } return commandDirectories.length > 0 ? commandDirectories : [ 'res://addons/escoria-core/game/core-scripts/esc/commands', 'res://addons/escoria-ui-return-monkey-island/esc/commands', 'res://addons/escoria-ui-return-monkey-island-dialog-simple/commands' ]; } /** * Convert res:// path to actual filesystem path */ private resPathToFsPath(resPath: string): string { if (resPath.startsWith('res://')) { return path.join(this.projectRoot, resPath.substring(6)); } return path.join(this.projectRoot, resPath); } /** * Parse a single command file to extract command information */ private parseCommandFile(filePath: string): CommandInfo | null { try { const content = fs.readFileSync(filePath, 'utf8'); const lines = content.split('\n'); // Extract command name from filename const fileName = path.basename(filePath, '.gd'); const commandName = fileName; // Parse description from comments let description = ''; let parameters: CommandParameter[] = []; let example = ''; let signatureParams: string[] = []; let inParametersSection = false; let inExampleSection = false; let foundFirstDescription = false; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); // Skip empty lines if (!line) { continue; } // Extract main description (first comment block) and parameter names from signature if (line.startsWith('# `') && line.includes('`')) { const match = line.match(/# `([^`]+)`/); if (match) { description = match[1] + '\n\n'; foundFirstDescription = true; // Extract parameter names from command signature // e.g., "anim object name [reverse]" -> ["object", "name", "reverse"] const signature = match[1]; const paramMatches = signature.match(/\b\w+\b/g); if (paramMatches && paramMatches.length > 1) { // Skip the first match (command name) and extract parameter names signatureParams = paramMatches.slice(1); } } continue; } // Look for description in subsequent comment lines (before parameters section) if (line.startsWith('#') && !inParametersSection && !inExampleSection) { const cleanLine = line.replace(/^#\s*/, ''); // Skip lines that are clearly not part of the main description if (cleanLine.startsWith('**') || cleanLine.startsWith('@') || cleanLine.startsWith('Example:') || cleanLine.startsWith('e.g.') || cleanLine.includes('*') && cleanLine.includes(':') || cleanLine.includes('Constructor') || cleanLine.includes('Use look-ahead') || cleanLine.includes('Return the descriptor') || cleanLine.includes('Validate whether') || cleanLine.includes('Run the command') || cleanLine.includes('Function called when')) { continue; } // If we haven't found the first description yet, this might be it if (!foundFirstDescription && cleanLine) { description += cleanLine; foundFirstDescription = true; continue; } // If we have a description and this line looks like continuation if (foundFirstDescription && cleanLine && !cleanLine.includes('*')) { // Stop if we hit code-related content if (cleanLine.includes('Constructor') || cleanLine.includes('Use look-ahead') || cleanLine.includes('Return the descriptor') || cleanLine.includes('Validate whether') || cleanLine.includes('Run the command') || cleanLine.includes('Function called when')) { break; } description += '\n' + cleanLine; } continue; } // Check for parameters section if (line.includes('**Parameters**')) { inParametersSection = true; inExampleSection = false; continue; } // Check for example section if (line.includes('Example:')) { inParametersSection = false; inExampleSection = true; const exampleMatch = line.match(/Example:\s*(.+)/); if (exampleMatch) { example = exampleMatch[1]; } continue; } // Parse parameters (support both # - *param*: and # * *param*: formats) if (inParametersSection && (line.includes('# - *') || line.includes('# * *'))) { const paramMatch = line.match(/#\s*[-*]\s*\*([^*]+)\*:\s*(.+)/); if (paramMatch) { const paramName = paramMatch[1].trim(); const paramDesc = paramMatch[2].trim(); // Try to determine if parameter is required const isRequired = !paramDesc.includes('default:') && !paramDesc.includes('(default:'); // Extract default value if present let defaultValue: string | undefined; const defaultMatch = paramDesc.match(/\(default:\s*([^)]+)\)/); if (defaultMatch) { defaultValue = defaultMatch[1].trim(); } // Determine parameter type from description with more comprehensive detection let paramType = 'string'; const descLower = paramDesc.toLowerCase(); if (descLower.includes('boolean') || descLower.includes('true') || descLower.includes('false') || descLower.includes('bool') || descLower.includes('flag')) { paramType = 'boolean'; } else if (descLower.includes('number') || descLower.includes('int') || descLower.includes('float') || descLower.includes('integer') || descLower.includes('numeric')) { paramType = 'number'; } else if (descLower.includes('object') || descLower.includes('node') || descLower.includes('item')) { paramType = 'object'; } else if (descLower.includes('scene') || descLower.includes('room')) { paramType = 'scene'; } else if (descLower.includes('animation') || descLower.includes('anim')) { paramType = 'animation'; } parameters.push({ name: paramName, type: paramType, required: isRequired, defaultValue: defaultValue }); } continue; } // Parse configure() method to get more accurate parameter info if (line.includes('func configure()') && i + 5 < lines.length) { // Look for ESCCommandArgumentDescriptor in the next few lines for (let j = i + 1; j < Math.min(i + 15, lines.length); j++) { const configLine = lines[j].trim(); if (configLine.includes('ESCCommandArgumentDescriptor.new(')) { // Parse the descriptor parameters - look for the number on the next line let minArgs = 0; for (let k = j + 1; k < Math.min(j + 5, lines.length); k++) { const nextLine = lines[k].trim(); const numberMatch = nextLine.match(/^(\d+),?$/); if (numberMatch) { minArgs = parseInt(numberMatch[1]); break; } } if (minArgs > 0) { // Look for type arrays, defaults, and required flags in subsequent lines let types: string[] = []; let defaults: string[] = []; let requiredFlags: boolean[] = []; for (let k = j + 1; k < Math.min(j + 10, lines.length); k++) { const typeLine = lines[k].trim(); // Extract types array if (typeLine.includes('TYPE_') && typeLine.includes('[')) { const typeMatch = typeLine.match(/\[([^\]]+)\]/); if (typeMatch) { types = typeMatch[1].split(',').map(t => t.trim()); } } // Extract defaults array (first array that doesn't contain TYPE_) if (typeLine.includes('[') && !typeLine.includes('TYPE_') && !defaults.length) { const defaultMatch = typeLine.match(/\[([^\]]+)\]/); if (defaultMatch) { defaults = defaultMatch[1].split(',').map(d => d.trim()); } } // Extract required flags array (second array that doesn't contain TYPE_ and has true/false) if (typeLine.includes('[') && !typeLine.includes('TYPE_') && defaults.length > 0 && (typeLine.includes('true') || typeLine.includes('false'))) { const requiredMatch = typeLine.match(/\[([^\]]+)\]/); if (requiredMatch) { requiredFlags = requiredMatch[1].split(',').map(f => f.trim() === 'true'); } } } // Update or create parameters with accurate information const maxParams = Math.max(types.length, defaults.length); const newParameters: CommandParameter[] = []; // Store original parameters from comments for name preservation const originalParams = [...parameters]; // Use the total number of types, not just minArgs const totalParams = types.length; for (let p = 0; p < totalParams; p++) { const type = types[p] || 'TYPE_STRING'; const defaultValue = defaults[p] || 'null'; // If requiredFlags array is not provided, use minArgs to determine required parameters const isRequired = p < minArgs || (requiredFlags[p] !== undefined ? requiredFlags[p] : p < minArgs); // Convert Godot types to readable types with more comprehensive mapping let paramType = 'string'; if (type.includes('TYPE_BOOL')) { paramType = 'boolean'; } else if (type.includes('TYPE_INT') || type.includes('TYPE_FLOAT')) { paramType = 'number'; } else if (type.includes('TYPE_STRING')) { paramType = 'string'; } else if (type.includes('TYPE_VECTOR2')) { paramType = 'vector2'; } else if (type.includes('TYPE_VECTOR3')) { paramType = 'vector3'; } else if (type.includes('TYPE_ARRAY')) { paramType = 'array'; } else if (type.includes('TYPE_DICTIONARY')) { paramType = 'dictionary'; } else if (type.includes('TYPE_OBJECT')) { paramType = 'object'; } // Try to get parameter name from comments if available, otherwise use signature or generate one let paramName = `param${p + 1}`; if (p < originalParams.length && originalParams[p] && originalParams[p].name) { paramName = originalParams[p].name; } else if (p < signatureParams.length && signatureParams[p]) { paramName = signatureParams[p]; } newParameters.push({ name: paramName, type: paramType, required: isRequired, defaultValue: defaultValue !== 'null' ? defaultValue : undefined }); } // Replace parameters with the new ones from configure method parameters = newParameters; } break; } } break; } } // If we couldn't parse parameters from comments, try to infer from configure method if (parameters.length === 0) { // Look for configure method and try to extract parameter count const configureMatch = content.match(/func configure\(\)[^}]*ESCCommandArgumentDescriptor\.new\(\s*(\d+)/); if (configureMatch) { const paramCount = parseInt(configureMatch[1]); for (let i = 0; i < paramCount; i++) { // Use signature parameter name if available, otherwise generate one const paramName = i < signatureParams.length ? signatureParams[i] : `param${i + 1}`; parameters.push({ name: paramName, type: 'string', required: true }); } } } // Clean up description - remove extra whitespace and fix common issues if (description) { description = description.trim(); // Remove extra whitespace but preserve newlines description = description.replace(/[ \t]+/g, ' ').replace(/\n\s+/g, '\n'); // Truncate if too long (keep first sentence or first 200 chars) const firstSentence = description.split('.')[0]; if (firstSentence.length < 200 && firstSentence.length > 20) { description = firstSentence + '.'; } else if (description.length > 200) { description = description.substring(0, 200).trim() + '...'; } // If description is just the command name, try to get a better one if (description === commandName || description === `${commandName} command`) { description = `${commandName} command`; } } return { name: commandName, description: description || `${commandName} command`, parameters: parameters, example: example, filePath: filePath }; } catch (error) { console.error(`Error parsing command file ${filePath}:`, error); return null; } } /** * Parse all command files from the specified directories */ public parseCommands(): CommandInfo[] { const commandDirectories = this.parseProjectGodot(); const commands: CommandInfo[] = []; for (const dir of commandDirectories) { const fsPath = this.resPathToFsPath(dir); if (!fs.existsSync(fsPath)) { console.warn(`Command directory not found: ${fsPath}`); continue; } try { const files = fs.readdirSync(fsPath); const gdFiles = files.filter(file => file.endsWith('.gd') && !file.endsWith('.gd.uid')); for (const file of gdFiles) { const filePath = path.join(fsPath, file); const commandInfo = this.parseCommandFile(filePath); if (commandInfo) { commands.push(commandInfo); } } } catch (error) { console.error(`Error reading directory ${fsPath}:`, error); } } // Sort commands alphabetically commands.sort((a, b) => a.name.localeCompare(b.name)); return commands; } /** * Get commands in the format expected by the VSCode extension */ public getCommandsForExtension(): Array<{name: string, description: string, parameters: CommandParameter[], filePath?: string}> { const commands = this.parseCommands(); return commands.map(cmd => ({ name: cmd.name, description: cmd.description, parameters: cmd.parameters, filePath: cmd.filePath })); } }