456 lines
22 KiB
TypeScript
456 lines
22 KiB
TypeScript
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
|
|
}));
|
|
}
|
|
}
|