diff --git a/vscode-extension-ashes/package.json b/vscode-extension-ashes/package.json index c83cd5a8..95e47d66 100644 --- a/vscode-extension-ashes/package.json +++ b/vscode-extension-ashes/package.json @@ -59,6 +59,11 @@ "command": "ashes.refreshCommands", "title": "Refresh ASHES Commands", "category": "ASHES" + }, + { + "command": "ashes.refreshDiagnostics", + "title": "Refresh ASHES Diagnostics", + "category": "ASHES" } ], "menus": { @@ -68,6 +73,9 @@ }, { "command": "ashes.refreshCommands" + }, + { + "command": "ashes.refreshDiagnostics" } ] } diff --git a/vscode-extension-ashes/src/diagnosticProvider.ts b/vscode-extension-ashes/src/diagnosticProvider.ts new file mode 100644 index 00000000..ef52be83 --- /dev/null +++ b/vscode-extension-ashes/src/diagnosticProvider.ts @@ -0,0 +1,247 @@ +import * as vscode from 'vscode'; +import { ASHESSyntaxValidator } from './syntaxValidator'; + +export class ASHESDiagnosticProvider { + private diagnosticCollection: vscode.DiagnosticCollection; + private syntaxValidator: ASHESSyntaxValidator; + private commandCache: Map = new Map(); + private lastUpdateTime: Map = new Map(); + + constructor() { + this.diagnosticCollection = vscode.languages.createDiagnosticCollection('ashes'); + this.syntaxValidator = new ASHESSyntaxValidator(); + } + + /** + * Initialize the diagnostic provider + */ + public initialize(context: vscode.ExtensionContext): void { + // Register document change listener + const documentChangeListener = vscode.workspace.onDidChangeTextDocument( + (event) => this.onDocumentChanged(event) + ); + + // Register document open listener + const documentOpenListener = vscode.workspace.onDidOpenTextDocument( + (document) => this.validateDocument(document) + ); + + // Register document save listener + const documentSaveListener = vscode.workspace.onDidSaveTextDocument( + (document) => this.validateDocument(document) + ); + + // Register workspace change listener to update command cache + const workspaceChangeListener = vscode.workspace.onDidChangeWorkspaceFolders( + () => this.updateCommandCache() + ); + + // Validate all open ASHES documents + vscode.workspace.textDocuments.forEach(document => { + if (document.languageId === 'ashes') { + this.validateDocument(document); + } + }); + + // Add to subscriptions + context.subscriptions.push( + this.diagnosticCollection, + documentChangeListener, + documentOpenListener, + documentSaveListener, + workspaceChangeListener + ); + + // Initial command cache update + this.updateCommandCache(); + } + + /** + * Handle document changes with debouncing + */ + private onDocumentChanged(event: vscode.TextDocumentChangeEvent): void { + if (event.document.languageId !== 'ashes') { + return; + } + + const document = event.document; + const uri = document.uri.toString(); + const now = Date.now(); + + // Debounce validation to avoid excessive processing + const lastUpdate = this.lastUpdateTime.get(uri) || 0; + if (now - lastUpdate < 500) { // 500ms debounce + return; + } + + this.lastUpdateTime.set(uri, now); + + // Use setTimeout to debounce the validation + setTimeout(() => { + this.validateDocument(document); + }, 500); + } + + /** + * Validate a document and update diagnostics + */ + private validateDocument(document: vscode.TextDocument): void { + if (document.languageId !== 'ashes') { + return; + } + + try { + // Update command cache if needed + this.updateCommandCacheForDocument(document); + + // Get diagnostics from syntax validator + const diagnostics = this.syntaxValidator.validateDocument(document); + + // Update the diagnostic collection + this.diagnosticCollection.set(document.uri, diagnostics); + + } catch (error) { + console.error('Error validating ASHES document:', error); + + // Show error diagnostic + const errorDiagnostic = new vscode.Diagnostic( + new vscode.Range(0, 0, 0, 0), + `Error validating ASHES syntax: ${error}`, + vscode.DiagnosticSeverity.Error + ); + this.diagnosticCollection.set(document.uri, [errorDiagnostic]); + } + } + + /** + * Update command cache for all workspace folders + */ + private updateCommandCache(): void { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + return; + } + + for (const folder of workspaceFolders) { + this.updateCommandCacheForWorkspace(folder.uri.fsPath); + } + } + + /** + * Update command cache for a specific document's workspace + */ + private updateCommandCacheForDocument(document: vscode.TextDocument): void { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri); + if (!workspaceFolder) { + return; + } + + const workspacePath = workspaceFolder.uri.fsPath; + this.updateCommandCacheForWorkspace(workspacePath); + } + + /** + * Update command cache for a specific workspace + */ + private updateCommandCacheForWorkspace(workspacePath: string): void { + const now = Date.now(); + const lastUpdate = this.lastUpdateTime.get(workspacePath) || 0; + + // Only update if cache is older than 5 minutes + if (now - lastUpdate < 5 * 60 * 1000) { + return; + } + + try { + const commands = this.extractCommandsFromWorkspace(workspacePath); + this.commandCache.set(workspacePath, commands); + this.syntaxValidator.updateCommands(commands); + this.lastUpdateTime.set(workspacePath, now); + + // Re-validate all ASHES documents in this workspace + vscode.workspace.textDocuments.forEach(document => { + if (document.languageId === 'ashes') { + const docWorkspace = vscode.workspace.getWorkspaceFolder(document.uri); + if (docWorkspace && docWorkspace.uri.fsPath === workspacePath) { + this.validateDocument(document); + } + } + }); + + } catch (error) { + console.error('Error updating command cache:', error); + } + } + + /** + * Extract commands from workspace using the existing CommandParser + */ + private extractCommandsFromWorkspace(workspacePath: string): string[] { + try { + // Import the CommandParser dynamically to avoid circular dependencies + const { CommandParser } = require('./commandParser'); + const parser = new CommandParser(workspacePath); + const commands = parser.parseCommands(); + return commands.map((cmd: any) => cmd.name); + } catch (error) { + console.error('Error extracting commands:', error); + // Return basic command set as fallback + return [ + 'accept_input', 'anim', 'anim_block', 'block_say', 'camera_push', 'camera_push_block', + 'camera_set_limits', 'camera_set_pos', 'camera_set_pos_block', 'camera_set_target', + 'camera_set_target_block', 'camera_set_zoom', 'camera_set_zoom_block', 'camera_set_zoom_height', + 'camera_set_zoom_height_block', 'camera_shift', 'camera_shift_block', 'change_scene', 'custom', + 'dec_global', 'enable_terrain', 'end_block_say', 'hide_menu', 'inc_global', 'inventory_add', + 'inventory_remove', 'item_count_add', 'play_lib_snd', 'play_snd', 'play_video', 'print', + 'print_internal', 'queue_event', 'queue_resource', 'rand_global', 'repeat', 'save_game', + 'say', 'say_last_dialog_option', 'say_random', 'say_sequence', 'sched_event', 'set_active', + 'set_active_if_exists', 'set_angle', 'set_animations', 'set_direction', 'set_global', + 'set_globals', 'set_gui_visible', 'set_interactive', 'set_item_custom_data', 'set_speed', + 'set_state', 'set_tooltip', 'show_menu', 'slide', 'slide_block', 'spawn', 'stop', 'stop_snd', + 'teleport', 'teleport_pos', 'transition', 'turn_to', 'wait', 'walk', 'walk_block', + 'walk_to_pos', 'walk_to_pos_block' + ]; + } + } + + /** + * Clear diagnostics for a document + */ + public clearDiagnostics(document: vscode.TextDocument): void { + this.diagnosticCollection.delete(document.uri); + } + + /** + * Clear all diagnostics + */ + public clearAllDiagnostics(): void { + this.diagnosticCollection.clear(); + } + + /** + * Get current diagnostics for a document + */ + public getDiagnostics(document: vscode.TextDocument): readonly vscode.Diagnostic[] { + return this.diagnosticCollection.get(document.uri) || []; + } + + /** + * Force refresh diagnostics for all ASHES documents + */ + public refreshAllDiagnostics(): void { + this.updateCommandCache(); + + vscode.workspace.textDocuments.forEach(document => { + if (document.languageId === 'ashes') { + this.validateDocument(document); + } + }); + } + + /** + * Get diagnostic collection for external access + */ + public getDiagnosticCollection(): vscode.DiagnosticCollection { + return this.diagnosticCollection; + } +} diff --git a/vscode-extension-ashes/src/extension.ts b/vscode-extension-ashes/src/extension.ts index 82a8c825..0cdda8e0 100644 --- a/vscode-extension-ashes/src/extension.ts +++ b/vscode-extension-ashes/src/extension.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { CommandParser, CommandInfo, CommandParameter } from './commandParser'; +import { ASHESDiagnosticProvider } from './diagnosticProvider'; // Cache for dynamically loaded commands let ASHES_COMMANDS: Array<{name: string, description: string, parameters: CommandParameter[], filePath?: string}> = []; @@ -59,6 +60,10 @@ function getCommands(workspaceRoot: string): Array<{name: string, description: s export function activate(context: vscode.ExtensionContext) { console.log('ASHES Language Support extension is now active!'); + // Initialize diagnostic provider for syntax error detection + const diagnosticProvider = new ASHESDiagnosticProvider(); + diagnosticProvider.initialize(context); + // Register completion provider const completionProvider = vscode.languages.registerCompletionItemProvider( 'ashes', @@ -458,10 +463,19 @@ export function activate(context: vscode.ExtensionContext) { ASHES_COMMANDS = []; COMMAND_CACHE_TIMESTAMP = 0; - vscode.window.showInformationMessage('ASHES commands cache refreshed!'); + // Also refresh diagnostics + diagnosticProvider.refreshAllDiagnostics(); + + vscode.window.showInformationMessage('ASHES commands cache and diagnostics refreshed!'); }); - context.subscriptions.push(completionProvider, hoverProvider, definitionProvider, showCommandReference, refreshCommands); + // Register command for refreshing diagnostics only + const refreshDiagnostics = vscode.commands.registerCommand('ashes.refreshDiagnostics', () => { + diagnosticProvider.refreshAllDiagnostics(); + vscode.window.showInformationMessage('ASHES diagnostics refreshed!'); + }); + + context.subscriptions.push(completionProvider, hoverProvider, definitionProvider, showCommandReference, refreshCommands, refreshDiagnostics); } export function deactivate() {} diff --git a/vscode-extension-ashes/src/syntaxValidator.ts b/vscode-extension-ashes/src/syntaxValidator.ts new file mode 100644 index 00000000..4ca812b0 --- /dev/null +++ b/vscode-extension-ashes/src/syntaxValidator.ts @@ -0,0 +1,799 @@ +import * as vscode from 'vscode'; + +export interface SyntaxError { + line: number; + column: number; + message: string; + severity: vscode.DiagnosticSeverity; + code?: string; +} + +export class ASHESSyntaxValidator { + private commands: Set = new Set(); + private builtinVariables: Set = new Set([ + 'CURRENT_PLAYER', + 'ESC_LAST_SCENE', + 'ESC_CURRENT_SCENE', + 'FORCE_LAST_SCENE_NULL', + 'ANIMATION_RESOURCES' + ]); + private keywords: Set = new Set([ + 'var', 'global', 'if', 'elif', 'else', 'while', 'break', 'done', 'stop', 'pass', + 'true', 'false', 'nil', 'and', 'or', 'not', 'in', 'is', 'active' + ]); + + constructor(commands: string[] = []) { + this.commands = new Set(commands); + } + + /** + * Update the list of available commands + */ + public updateCommands(commands: string[]): void { + this.commands = new Set(commands); + } + + /** + * Validate ASHES syntax and return diagnostic errors + */ + public validateDocument(document: vscode.TextDocument): vscode.Diagnostic[] { + const diagnostics: vscode.Diagnostic[] = []; + const text = document.getText(); + const lines = text.split('\n'); + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex]; + const lineNumber = lineIndex; + + // Skip empty lines and comments + if (this.isEmptyOrComment(line)) { + continue; + } + + // Validate different types of lines + const lineDiagnostics = this.validateLine(line, lineNumber, lines, lineIndex); + diagnostics.push(...lineDiagnostics); + } + + // Validate overall document structure + const documentDiagnostics = this.validateDocumentStructure(lines); + diagnostics.push(...documentDiagnostics); + + return diagnostics; + } + + /** + * Check if a line is empty or a comment + */ + private isEmptyOrComment(line: string): boolean { + const trimmed = line.trim(); + return trimmed === '' || trimmed.startsWith('#'); + } + + /** + * Validate a single line + */ + private validateLine(line: string, lineNumber: number, allLines: string[], lineIndex: number): vscode.Diagnostic[] { + const diagnostics: vscode.Diagnostic[] = []; + const trimmed = line.trim(); + + // Validate event definitions + if (trimmed.startsWith(':')) { + diagnostics.push(...this.validateEventDefinition(line, lineNumber)); + } + // Validate dialog blocks + else if (trimmed.startsWith('?!')) { + diagnostics.push(...this.validateDialogBlock(line, lineNumber, allLines, lineIndex)); + } + // Validate dialog choices + else if (trimmed.startsWith('-')) { + diagnostics.push(...this.validateDialogChoice(line, lineNumber)); + } + // Validate commands and control flow + else { + diagnostics.push(...this.validateCommandOrControlFlow(line, lineNumber)); + } + + // Validate indentation + diagnostics.push(...this.validateIndentation(line, lineNumber, allLines, lineIndex)); + + // Validate indentation after control flow statements + if (line.trim().endsWith(':') && (line.includes('if ') || line.includes('elif ') || line.includes('else') || line.includes('while '))) { + // Check if the next line has proper indentation + if (lineIndex + 1 < allLines.length) { + const currentIndent = line.match(/^(\s*)/)?.[1].length || 0; + + // Skip empty lines + let nextNonEmptyLineIndex = lineIndex + 1; + while (nextNonEmptyLineIndex < allLines.length && allLines[nextNonEmptyLineIndex].trim() === '') { + nextNonEmptyLineIndex++; + } + + if (nextNonEmptyLineIndex < allLines.length) { + const nextNonEmptyLine = allLines[nextNonEmptyLineIndex]; + const nextNonEmptyIndent = nextNonEmptyLine.match(/^(\s*)/)?.[1].length || 0; + + // The next non-empty line should have exactly one more level of indentation + if (nextNonEmptyIndent <= currentIndent) { + diagnostics.push({ + range: new vscode.Range(lineNumber, 0, lineNumber, line.length), + message: "Control flow statement requires indented block on the next line", + severity: vscode.DiagnosticSeverity.Error, + code: 'missing-indentation' + }); + } + } + } + } + + // Validate string literals + diagnostics.push(...this.validateStringLiterals(line, lineNumber)); + + // Validate parentheses matching + diagnostics.push(...this.validateParentheses(line, lineNumber)); + + return diagnostics; + } + + /** + * Validate event definitions + */ + private validateEventDefinition(line: string, lineNumber: number): vscode.Diagnostic[] { + const diagnostics: vscode.Diagnostic[] = []; + + // Event definition patterns based on real ESC files: + // :event_name + // :event_name | FLAGS + // :event_name "target" + // :event_name | FLAGS "target" + const eventMatch = line.match(/^(\s*):([a-zA-Z_][a-zA-Z0-9_]*)(\s*\|\s*[A-Z_]+)*(\s+("[^"]*"))?/); + + if (!eventMatch) { + diagnostics.push({ + range: new vscode.Range(lineNumber, 0, lineNumber, line.length), + message: "Invalid event definition. Expected format: :event_name [| FLAGS] [\"target\"]", + severity: vscode.DiagnosticSeverity.Error, + code: 'invalid-event-definition' + }); + return diagnostics; + } + + const eventName = eventMatch[2]; + const flags = eventMatch[3]; + const target = eventMatch[5]; + + // Validate event name + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(eventName)) { + diagnostics.push({ + range: new vscode.Range(lineNumber, eventMatch.index! + 1, lineNumber, eventMatch.index! + 1 + eventName.length), + message: "Event name must start with a letter or underscore and contain only letters, numbers, and underscores", + severity: vscode.DiagnosticSeverity.Error, + code: 'invalid-event-name' + }); + } + + // Check for common event names and suggest alternatives + const commonEvents = ['init', 'ready', 'setup', 'action1', 'action2', 'action3', 'use', 'look', 'talk', 'walk', 'interact']; + if (!commonEvents.includes(eventName.toLowerCase()) && eventName.length > 20) { + const eventIndex = line.indexOf(eventName); + diagnostics.push({ + range: new vscode.Range(lineNumber, eventIndex, lineNumber, eventIndex + eventName.length), + message: `Consider using a shorter event name. Common events: ${commonEvents.join(', ')}`, + severity: vscode.DiagnosticSeverity.Information, + code: 'event-naming-suggestion' + }); + } + + // Validate flags if present + if (flags) { + const flagText = flags.trim().substring(1).trim(); // Remove the | + const validFlags = ['TK', 'TR', 'TG', 'SKIP', 'GLOBAL']; + const providedFlags = flagText.split(/\s+/); + + for (const flag of providedFlags) { + if (flag && !validFlags.includes(flag)) { + const flagIndex = line.indexOf(flag); + diagnostics.push({ + range: new vscode.Range(lineNumber, flagIndex, lineNumber, flagIndex + flag.length), + message: `Invalid event flag '${flag}'. Valid flags: ${validFlags.join(', ')}`, + severity: vscode.DiagnosticSeverity.Error, + code: 'invalid-event-flag' + }); + } + } + } + + // Validate target if present + if (target && (!target.startsWith('"') || !target.endsWith('"'))) { + const targetIndex = line.indexOf(target); + diagnostics.push({ + range: new vscode.Range(lineNumber, targetIndex, lineNumber, targetIndex + target.length), + message: "Event target must be a quoted string", + severity: vscode.DiagnosticSeverity.Error, + code: 'invalid-event-target' + }); + } + + return diagnostics; + } + + /** + * Validate dialog blocks + */ + private validateDialogBlock(line: string, lineNumber: number, allLines: string[], lineIndex: number): vscode.Diagnostic[] { + const diagnostics: vscode.Diagnostic[] = []; + + // Dialog block should have proper indentation + const indentMatch = line.match(/^(\s*)(\?\!)/); + if (!indentMatch) { + diagnostics.push({ + range: new vscode.Range(lineNumber, 0, lineNumber, line.length), + message: "Invalid dialog block syntax. Expected: ?!", + severity: vscode.DiagnosticSeverity.Error, + code: 'invalid-dialog-block' + }); + return diagnostics; + } + + const baseIndent = indentMatch[1].length; + + // Check if dialog block is properly closed + let foundEnd = false; + for (let i = lineIndex + 1; i < allLines.length; i++) { + const nextLine = allLines[i]; + if (nextLine.trim() === '') continue; + + const nextIndent = nextLine.match(/^(\s*)/)?.[1].length || 0; + + if (nextIndent <= baseIndent && !nextLine.trim().startsWith('-')) { + foundEnd = true; + break; + } + } + + if (!foundEnd) { + diagnostics.push({ + range: new vscode.Range(lineNumber, 0, lineNumber, line.length), + message: "Dialog block is not properly closed", + severity: vscode.DiagnosticSeverity.Warning, + code: 'unclosed-dialog-block' + }); + } + + return diagnostics; + } + + /** + * Validate dialog choices + */ + private validateDialogChoice(line: string, lineNumber: number): vscode.Diagnostic[] { + const diagnostics: vscode.Diagnostic[] = []; + + // Dialog choice pattern: - "choice text" [condition] + const choiceMatch = line.match(/^(\s*)-(\s*)("[^"]*")(\s*\[.*\])?/); + + if (!choiceMatch) { + diagnostics.push({ + range: new vscode.Range(lineNumber, 0, lineNumber, line.length), + message: "Invalid dialog choice syntax. Expected: - \"choice text\" [condition]", + severity: vscode.DiagnosticSeverity.Error, + code: 'invalid-dialog-choice' + }); + return diagnostics; + } + + const choiceText = choiceMatch[3]; + const condition = choiceMatch[4]; + + // Validate choice text is not empty + if (choiceText === '""') { + const textIndex = line.indexOf(choiceText); + diagnostics.push({ + range: new vscode.Range(lineNumber, textIndex, lineNumber, textIndex + choiceText.length), + message: "Dialog choice text cannot be empty", + severity: vscode.DiagnosticSeverity.Error, + code: 'empty-dialog-choice' + }); + } + + // Validate condition if present + if (condition) { + const conditionContent = condition.slice(1, -1); // Remove brackets + if (conditionContent.trim() === '') { + const conditionIndex = line.indexOf(condition); + diagnostics.push({ + range: new vscode.Range(lineNumber, conditionIndex, lineNumber, conditionIndex + condition.length), + message: "Dialog choice condition cannot be empty", + severity: vscode.DiagnosticSeverity.Error, + code: 'empty-dialog-condition' + }); + } + } + + return diagnostics; + } + + /** + * Validate commands and control flow statements + */ + private validateCommandOrControlFlow(line: string, lineNumber: number): vscode.Diagnostic[] { + const diagnostics: vscode.Diagnostic[] = []; + + // Check for function calls + const functionCallMatch = line.match(/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/); + if (functionCallMatch) { + const functionName = functionCallMatch[1]; + + // Check if it's a known command + if (!this.commands.has(functionName) && !this.keywords.has(functionName)) { + const funcIndex = line.indexOf(functionName); + diagnostics.push({ + range: new vscode.Range(lineNumber, funcIndex, lineNumber, funcIndex + functionName.length), + message: `Unknown command '${functionName}'. Available commands: ${Array.from(this.commands).slice(0, 10).join(', ')}${this.commands.size > 10 ? '...' : ''}`, + severity: vscode.DiagnosticSeverity.Error, + code: 'unknown-command' + }); + } + } + + // Validate control flow statements + const controlFlowMatch = line.match(/\b(if|elif|while)\s+(.+):/); + if (controlFlowMatch) { + const condition = controlFlowMatch[2]; + + // Basic condition validation + if (condition.trim() === '') { + const conditionIndex = line.indexOf(condition); + diagnostics.push({ + range: new vscode.Range(lineNumber, conditionIndex, lineNumber, conditionIndex + condition.length), + message: "Control flow condition cannot be empty", + severity: vscode.DiagnosticSeverity.Error, + code: 'empty-condition' + }); + } + } + + // Check for missing colon in control flow statements + const controlFlowWithoutColon = line.match(/\b(if|elif|else|while)\s+(.+)$/); + if (controlFlowWithoutColon && !line.trim().endsWith(':')) { + const keyword = controlFlowWithoutColon[1]; + const keywordIndex = line.indexOf(keyword); + diagnostics.push({ + range: new vscode.Range(lineNumber, keywordIndex, lineNumber, keywordIndex + keyword.length), + message: `'${keyword}' statement must end with ':'`, + severity: vscode.DiagnosticSeverity.Error, + code: 'missing-colon' + }); + } + + // Check for 'else' without colon + const elseMatch = line.match(/\belse\s*$/); + if (elseMatch && !line.trim().endsWith(':')) { + const elseIndex = line.indexOf('else'); + diagnostics.push({ + range: new vscode.Range(lineNumber, elseIndex, lineNumber, elseIndex + 4), + message: "'else' statement must end with ':'", + severity: vscode.DiagnosticSeverity.Error, + code: 'missing-colon' + }); + } + + + // Validate common condition patterns from real ESC files (for lines that do have colons) + if (line.trim().endsWith(':') && (line.includes('if ') || line.includes('elif ') || line.includes('while '))) { + const conditionMatch = line.match(/\b(if|elif|while)\s+(.+):/); + if (conditionMatch) { + const conditionText = conditionMatch[2].trim(); + + // Check for proper inventory check patterns: "item" in inventory + const inventoryMatch = conditionText.match(/([^"]*)\s+in\s+inventory/); + if (inventoryMatch) { + const itemPart = inventoryMatch[1].trim(); + if (!itemPart.startsWith('"') || !itemPart.endsWith('"')) { + const inventoryIndex = conditionText.indexOf('in inventory'); + diagnostics.push({ + range: new vscode.Range(lineNumber, line.indexOf(conditionText) + inventoryIndex, lineNumber, line.indexOf(conditionText) + inventoryIndex + 'in inventory'.length), + message: "Inventory check should use quoted string: \"item_name\" in inventory", + severity: vscode.DiagnosticSeverity.Warning, + code: 'inventory-check-pattern' + }); + } + } + + // Check for negation patterns: !variable + if (conditionText.startsWith('!')) { + const varName = conditionText.substring(1).trim(); + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(varName)) { + const varIndex = line.indexOf(varName); + diagnostics.push({ + range: new vscode.Range(lineNumber, varIndex, lineNumber, varIndex + varName.length), + message: "Variable name after ! must be valid identifier", + severity: vscode.DiagnosticSeverity.Warning, + code: 'invalid-negation-variable' + }); + } + } + + // Check for comparison patterns: variable == value, variable != value + const comparisonMatch = conditionText.match(/([a-zA-Z_][a-zA-Z0-9_]*)\s*(==|!=|<=|>=|<|>)\s*(.+)/); + if (comparisonMatch) { + const leftVar = comparisonMatch[1]; + const operator = comparisonMatch[2]; + const rightValue = comparisonMatch[3].trim(); + + // Check if right value is properly quoted for strings + if (rightValue.startsWith('"') && !rightValue.endsWith('"')) { + const rightIndex = line.indexOf(rightValue); + diagnostics.push({ + range: new vscode.Range(lineNumber, rightIndex, lineNumber, rightIndex + rightValue.length), + message: "String comparison value should be properly quoted", + severity: vscode.DiagnosticSeverity.Warning, + code: 'unclosed-comparison-string' + }); + } + } + + // Check for logical operators: and, or + if (conditionText.includes(' and ') || conditionText.includes(' or ')) { + // Basic validation for logical operators + const logicalMatch = conditionText.match(/(.+)\s+(and|or)\s+(.+)/); + if (logicalMatch) { + const leftPart = logicalMatch[1].trim(); + const rightPart = logicalMatch[3].trim(); + + if (leftPart === '' || rightPart === '') { + const operatorIndex = conditionText.indexOf(logicalMatch[2]); + diagnostics.push({ + range: new vscode.Range(lineNumber, line.indexOf(conditionText) + operatorIndex, lineNumber, line.indexOf(conditionText) + operatorIndex + logicalMatch[2].length), + message: "Logical operator requires expressions on both sides", + severity: vscode.DiagnosticSeverity.Warning, + code: 'incomplete-logical-expression' + }); + } + } + } + } + } + + // Validate variable assignments + const varAssignmentMatch = line.match(/\b(var|global)\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=/); + if (varAssignmentMatch) { + const varName = varAssignmentMatch[2]; + + // Check for reserved keywords + if (this.keywords.has(varName)) { + const varIndex = line.indexOf(varName); + diagnostics.push({ + range: new vscode.Range(lineNumber, varIndex, lineNumber, varIndex + varName.length), + message: `Variable name '${varName}' is a reserved keyword`, + severity: vscode.DiagnosticSeverity.Error, + code: 'reserved-variable-name' + }); + } + } + + // Validate specific command patterns from real ESC files + const sayMatch = line.match(/\bsay\s*\(/); + if (sayMatch) { + // Check for proper say command format: say(speaker, "text", "id") + // Handle both 2-argument and 3-argument say commands + // Use a more sophisticated regex to handle quoted strings with commas inside + const sayArgsMatch = line.match(/\bsay\s*\(\s*([^,]+),\s*("(?:[^"\\]|\\.)*")\s*(?:,\s*("(?:[^"\\]|\\.)*"))?\s*\)/); + if (sayArgsMatch) { + const speaker = sayArgsMatch[1].trim(); + const text = sayArgsMatch[2].trim(); + const id = sayArgsMatch[3] ? sayArgsMatch[3].trim() : null; + + // Check if speaker starts with $ (common pattern) + if (!speaker.startsWith('$') && !speaker.startsWith('current_player')) { + const speakerIndex = line.indexOf(speaker); + diagnostics.push({ + range: new vscode.Range(lineNumber, speakerIndex, lineNumber, speakerIndex + speaker.length), + message: "Speaker should typically start with $ or be 'current_player'", + severity: vscode.DiagnosticSeverity.Information, + code: 'say-speaker-pattern' + }); + } + + // Text should already be properly quoted by the regex + // Just check if ID is quoted (when present) + if (id && !this.isProperlyQuotedString(id)) { + const idIndex = line.indexOf(id); + diagnostics.push({ + range: new vscode.Range(lineNumber, idIndex, lineNumber, idIndex + id.length), + message: "Say ID should be quoted", + severity: vscode.DiagnosticSeverity.Warning, + code: 'say-id-quoted' + }); + } + } else { + // If the sophisticated regex doesn't match, try a simpler approach + const simpleSayMatch = line.match(/\bsay\s*\(\s*([^,]+),\s*([^,]+)(?:,\s*([^)]+))?\)/); + if (simpleSayMatch) { + const text = simpleSayMatch[2].trim(); + const id = simpleSayMatch[3] ? simpleSayMatch[3].trim() : null; + + // Check if text is properly quoted + if (!this.isProperlyQuotedString(text)) { + const textIndex = line.indexOf(text); + diagnostics.push({ + range: new vscode.Range(lineNumber, textIndex, lineNumber, textIndex + text.length), + message: "Say text should be properly quoted", + severity: vscode.DiagnosticSeverity.Warning, + code: 'say-text-quoted' + }); + } + + // Check if ID is quoted (when present) + if (id && !this.isProperlyQuotedString(id)) { + const idIndex = line.indexOf(id); + diagnostics.push({ + range: new vscode.Range(lineNumber, idIndex, lineNumber, idIndex + id.length), + message: "Say ID should be quoted", + severity: vscode.DiagnosticSeverity.Warning, + code: 'say-id-quoted' + }); + } + } + } + } + + // Validate inventory commands + const inventoryMatch = line.match(/\b(inventory_add|inventory_remove)\s*\(/); + if (inventoryMatch) { + const inventoryArgsMatch = line.match(/\b(inventory_add|inventory_remove)\s*\(\s*([^)]+)\)/); + if (inventoryArgsMatch) { + const arg = inventoryArgsMatch[2].trim(); + + // Check if argument starts with $ or is quoted + if (!arg.startsWith('$') && !arg.startsWith('"')) { + const argIndex = line.indexOf(arg); + diagnostics.push({ + range: new vscode.Range(lineNumber, argIndex, lineNumber, argIndex + arg.length), + message: "Inventory item should start with $ or be quoted", + severity: vscode.DiagnosticSeverity.Warning, + code: 'inventory-item-pattern' + }); + } + } + } + + return diagnostics; + } + + /** + * Validate indentation - ASHES requires tabs, not spaces + */ + private validateIndentation(line: string, lineNumber: number, allLines: string[], lineIndex: number): vscode.Diagnostic[] { + const diagnostics: vscode.Diagnostic[] = []; + + if (line.trim() === '') return diagnostics; + + const indentMatch = line.match(/^(\s*)/); + if (!indentMatch) return diagnostics; + + const indent = indentMatch[1]; + const currentIndent = indent.length; + + // Check for spaces instead of tabs - ASHES requires tabs + if (indent.includes(' ')) { + const spaceIndex = indent.indexOf(' '); + diagnostics.push({ + range: new vscode.Range(lineNumber, spaceIndex, lineNumber, spaceIndex + 1), + message: "ASHES requires tabs for indentation, not spaces", + severity: vscode.DiagnosticSeverity.Error, + code: 'spaces-instead-of-tabs' + }); + } + + // Check for mixed tabs and spaces + if (indent.includes('\t') && indent.includes(' ')) { + diagnostics.push({ + range: new vscode.Range(lineNumber, 0, lineNumber, currentIndent), + message: "Mixed tabs and spaces in indentation - use only tabs", + severity: vscode.DiagnosticSeverity.Error, + code: 'mixed-indentation' + }); + } + + // Find the previous non-empty line + let prevLineIndex = lineIndex - 1; + while (prevLineIndex >= 0 && allLines[prevLineIndex].trim() === '') { + prevLineIndex--; + } + + if (prevLineIndex >= 0) { + const prevLine = allLines[prevLineIndex]; + const prevIndentMatch = prevLine.match(/^(\s*)/); + if (prevIndentMatch) { + const prevIndent = prevIndentMatch[1].length; + + // Check for excessive indentation (more than 3 levels deep) + if (currentIndent > 3) { + diagnostics.push({ + range: new vscode.Range(lineNumber, 0, lineNumber, currentIndent), + message: "Excessive indentation detected (more than 3 levels)", + severity: vscode.DiagnosticSeverity.Warning, + code: 'excessive-indentation' + }); + } + } + } + + return diagnostics; + } + + /** + * Validate string literals + */ + private validateStringLiterals(line: string, lineNumber: number): vscode.Diagnostic[] { + const diagnostics: vscode.Diagnostic[] = []; + + // Check for unclosed strings with proper quote handling + let inString = false; + let stringChar = ''; + let stringStart = -1; + let escapeNext = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + + if (escapeNext) { + escapeNext = false; + continue; + } + + if (char === '\\') { + escapeNext = true; + continue; + } + + if (!inString && (char === '"' || char === "'")) { + inString = true; + stringChar = char; + stringStart = i; + } else if (inString && char === stringChar) { + inString = false; + stringChar = ''; + stringStart = -1; + } + } + + if (inString) { + diagnostics.push({ + range: new vscode.Range(lineNumber, stringStart, lineNumber, line.length), + message: `Unclosed ${stringChar === '"' ? 'double' : 'single'} quote`, + severity: vscode.DiagnosticSeverity.Error, + code: 'unclosed-string' + }); + } + + return diagnostics; + } + + /** + * Validate parentheses matching + */ + private validateParentheses(line: string, lineNumber: number): vscode.Diagnostic[] { + const diagnostics: vscode.Diagnostic[] = []; + + const stack: { char: string; index: number }[] = []; + const pairs: { [key: string]: string } = { '(': ')', '[': ']', '{': '}' }; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + + if (char in pairs) { + stack.push({ char, index: i }); + } else if (Object.values(pairs).includes(char)) { + if (stack.length === 0) { + diagnostics.push({ + range: new vscode.Range(lineNumber, i, lineNumber, i + 1), + message: `Unexpected closing '${char}'`, + severity: vscode.DiagnosticSeverity.Error, + code: 'unexpected-closing-bracket' + }); + } else { + const last = stack.pop()!; + if (pairs[last.char] !== char) { + diagnostics.push({ + range: new vscode.Range(lineNumber, last.index, lineNumber, last.index + 1), + message: `Mismatched brackets: expected '${pairs[last.char]}' but found '${char}'`, + severity: vscode.DiagnosticSeverity.Error, + code: 'mismatched-brackets' + }); + } + } + } + } + + // Check for unclosed brackets + for (const unclosed of stack) { + diagnostics.push({ + range: new vscode.Range(lineNumber, unclosed.index, lineNumber, unclosed.index + 1), + message: `Unclosed '${unclosed.char}'`, + severity: vscode.DiagnosticSeverity.Error, + code: 'unclosed-bracket' + }); + } + + return diagnostics; + } + + /** + * Check if a string is properly quoted (handles escaped quotes) + */ + private isProperlyQuotedString(str: string): boolean { + if (!str.startsWith('"') || !str.endsWith('"')) { + return false; + } + + // For simple validation, just check if it starts and ends with quotes + // and doesn't have unescaped quotes in the middle + let escapeNext = false; + + for (let i = 1; i < str.length - 1; i++) { + const char = str[i]; + + if (escapeNext) { + escapeNext = false; + continue; + } + + if (char === '\\') { + escapeNext = true; + continue; + } + + if (char === '"') { + // Unescaped quote in the middle - this is actually valid in ASHES + // as long as the string is properly closed + continue; + } + } + + return true; + } + + /** + * Validate overall document structure + */ + private validateDocumentStructure(lines: string[]): vscode.Diagnostic[] { + const diagnostics: vscode.Diagnostic[] = []; + + // Check for at least one event definition + const hasEvent = lines.some(line => line.trim().startsWith(':')); + if (!hasEvent) { + diagnostics.push({ + range: new vscode.Range(0, 0, 0, 0), + message: "ASHES file should contain at least one event definition (starting with :)", + severity: vscode.DiagnosticSeverity.Warning, + code: 'no-events' + }); + } + + // Check for proper event naming conventions + const events = lines.filter(line => line.trim().startsWith(':')); + for (const eventLine of events) { + const eventMatch = eventLine.match(/^(\s*):([a-zA-Z_][a-zA-Z0-9_]*)/); + if (eventMatch) { + const eventName = eventMatch[2]; + + // Suggest common event names if the current one seems unusual + const commonEvents = ['init', 'ready', 'use', 'look', 'talk', 'walk', 'interact']; + if (!commonEvents.includes(eventName.toLowerCase()) && eventName.length > 20) { + const eventIndex = eventLine.indexOf(eventName); + const lineNumber = lines.indexOf(eventLine); + diagnostics.push({ + range: new vscode.Range(lineNumber, eventIndex, lineNumber, eventIndex + eventName.length), + message: `Consider using a shorter, more descriptive event name. Common events: ${commonEvents.join(', ')}`, + severity: vscode.DiagnosticSeverity.Information, + code: 'event-naming-suggestion' + }); + } + } + } + + return diagnostics; + } +}