/** * RSpade Definition Provider - "Go to Definition" for RSX Classes, Routes, and Components * * RESOLUTION TYPE PRIORITY MATRIX * ================================ * * This provider determines what to navigate to when you click "Go to Definition" on various * identifiers across different file types. The resolution logic uses CSV type lists sent to * the server endpoint `/_ide/service/resolve_class?type=X,Y,Z` which tries each type in order. * * FILE TYPE HANDLERS & RESOLUTION RULES: * * 1. ROUTE PATTERNS (all files) * Pattern: Rsx::Route('Controller') or Rsx.Route('Controller', 'method') * Type: 'php_class' * Reason: Routes always point to PHP controllers (server-side) * * 2. HREF PATTERNS (Blade, jqhtml) * Pattern: href="/" * Type: 'php_class' * Reason: Resolves URL to controller, always PHP * * 3. JQHTML EXTENDS ATTRIBUTE (jqhtml only) * Pattern: * Type: 'jqhtml_class,js_class' * Reason: Component inheritance - try jqhtml component first, then JS class * * 4. JQHTML $xxx ATTRIBUTES (jqhtml only) * Pattern: $data_source=Frontend_Controller.fetch_data * Type: 'js_class,php_class' * Reason: Try JS class first (for components), then PHP (for controllers/models) * * Pattern: $handler=this.on_click * Type: 'jqhtml_class_method' * Special: Resolves to current component's method * * 5. THIS REFERENCES (jqhtml only) * Pattern: <%= this.data.users %> * Type: 'jqhtml_class_method' * Reason: Always current component's method/property * * 6. JAVASCRIPT CLASS REFERENCES (JS, jqhtml) * Pattern: class My_Component extends DataGrid_Abstract * Pattern: User_Controller.fetch_all() * Type: 'js_class,php_class' * Reason: Try JS first (component inheritance), then PHP (controllers/models) * Note: JS stub files (auto-generated from PHP) are client-side only, not in manifest * * 7. PHP CLASS REFERENCES (PHP, Blade) * Pattern: class Contacts_DataGrid extends DataGrid_Abstract * Pattern: use Rsx\Lib\DataGrid_QueryBuilder; * Type: 'php_class' * Reason: In PHP files, class references are always PHP (not JavaScript) * * 8. BUNDLE ALIASES (PHP only) * Pattern: 'include' => ['jqhtml', 'frontend'] * Type: 'bundle_alias' * Reason: Resolves to bundle class definition * * 9. VIEW REFERENCES (PHP, Blade) * Pattern: @rsx_extends('frontend.layout') * Pattern: rsx_view('frontend.dashboard') * Type: 'view' * Reason: Resolves to Blade view template files * * METHOD RESOLUTION: * When a pattern includes a method (e.g., Controller.method), the server attempts to find * the specific method in the class. If the method isn't found but the class is, it returns * the class location as a fallback. * * IMPORTANT: The server endpoint supports CSV type lists for priority ordering. * Example: type='php_class,js_class' tries PHP first, then JavaScript. */ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; import { IdeBridgeClient } from './ide_bridge_client'; interface JqhtmlExtensionAPI { findComponent(name: string): { uri: vscode.Uri; position: vscode.Position; name: string; line: string; } | undefined; getAllComponentNames(): string[]; reindexWorkspace(): Promise; } export class RspadeDefinitionProvider implements vscode.DefinitionProvider { private readonly ide_bridge: IdeBridgeClient; private status_bar_item: vscode.StatusBarItem | undefined; private readonly jqhtml_api: JqhtmlExtensionAPI | undefined; constructor(jqhtml_api: JqhtmlExtensionAPI | undefined) { // Create output channel and IDE bridge client const output_channel = vscode.window.createOutputChannel('RSpade Framework'); this.ide_bridge = new IdeBridgeClient(output_channel); this.jqhtml_api = jqhtml_api; } /** * Find the RSpade project root folder (contains system/app/RSpade/) * Works in both single-folder and multi-root workspace modes */ private find_rspade_root(): string | undefined { if (!vscode.workspace.workspaceFolders) { return undefined; } // Check each workspace folder for system/app/RSpade/ for (const folder of vscode.workspace.workspaceFolders) { const app_rspade = path.join(folder.uri.fsPath, 'system', 'app', 'RSpade'); if (fs.existsSync(app_rspade)) { return folder.uri.fsPath; } } return undefined; } private show_error_status(message: string) { // Create status bar item if it doesn't exist if (!this.status_bar_item) { this.status_bar_item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0); this.status_bar_item.command = 'workbench.action.output.toggleOutput'; this.status_bar_item.tooltip = 'Click to view RSpade output'; } // Set error message with icon this.status_bar_item.text = `$(error) RSpade: ${message}`; this.status_bar_item.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground'); this.status_bar_item.show(); // Auto-hide after 5 seconds setTimeout(() => { this.clear_status_bar(); }, 5000); } public clear_status_bar() { if (this.status_bar_item) { this.status_bar_item.hide(); } } async provideDefinition( document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken ): Promise { const languageId = document.languageId; const fileName = document.fileName; // Check for Route() pattern first - works in all file types const routeResult = await this.handleRoutePattern(document, position); if (routeResult) { return routeResult; } // Check for config() pattern - works in PHP and Blade files if (['php', 'blade', 'html'].includes(languageId) || fileName.endsWith('.php') || fileName.endsWith('.blade.php')) { const configResult = await this.handleConfigPattern(document, position); if (configResult) { return configResult; } } // Check for href="/" pattern in Blade/Jqhtml files if (fileName.endsWith('.blade.php') || fileName.endsWith('.jqhtml')) { const hrefResult = await this.handleHrefPattern(document, position); if (hrefResult) { return hrefResult; } } // Handle jqhtml-specific patterns if (fileName.endsWith('.jqhtml')) { // Check for extends="ClassName" attribute const extendsResult = await this.handleJqhtmlExtends(document, position); if (extendsResult) { return extendsResult; } // Check for $xxx=... attributes (must come before handleThisReference) const attrResult = await this.handleJqhtmlAttribute(document, position); if (attrResult) { return attrResult; } // Handle "this.xxx" references in template expressions const thisResult = await this.handleThisReference(document, position); if (thisResult) { return thisResult; } } // Handle jqhtml component tags in .blade.php and .jqhtml files // TEMPORARILY DISABLED FOR .jqhtml FILES: jqhtml extension now provides this feature // Re-enable by uncommenting: || fileName.endsWith('.jqhtml') if (fileName.endsWith('.blade.php') /* || fileName.endsWith('.jqhtml') */) { const componentResult = await this.handleJqhtmlComponent(document, position); if (componentResult) { return componentResult; } } // Handle JavaScript/TypeScript files and .js/.jqhtml files if (['javascript', 'typescript'].includes(languageId) || fileName.endsWith('.js') || fileName.endsWith('.jqhtml')) { const result = await this.handleJavaScriptDefinition(document, position); if (result) { return result; } } // Handle PHP and Blade files (RSX view references and class references) if (['php', 'blade', 'html'].includes(languageId) || fileName.endsWith('.php') || fileName.endsWith('.blade.php')) { const result = await this.handlePhpBladeDefinition(document, position); if (result) { return result; } } // As a fallback, check if the cursor is in a string that is a valid file path return this.handleFilePathInString(document, position); } /** * Handle Route() pattern for both PHP and JavaScript * Detects patterns like: * - Rsx::Route('Controller') (defaults to 'index') * - Rsx::Route('Controller::method') (explicit method via ::) * - Rsx::Route('Spa_Action_Name') (SPA action) * - Rsx.Route('Controller') (JavaScript) * - Rsx.Route('Controller::method') (JavaScript) * * Resolution: Routes can point to PHP controllers OR SPA actions (JavaScript) * Type: 'php_class,js_class' (try PHP first for controllers, then JS for SPA actions) */ private async handleRoutePattern( document: vscode.TextDocument, position: vscode.Position ): Promise { const line = document.lineAt(position.line).text; // Match Route() with single string parameter // Matches: Rsx::Route('Something') or Rsx.Route("Something") or Rsx::Route('Class::method') const routePattern = /(?:Rsx::Route|Rsx\.Route)\s*\(\s*['"]([A-Z][A-Za-z0-9_:]+)['"].*?\)/; const match = line.match(routePattern); if (match) { const [fullMatch, action] = match; const matchStart = line.indexOf(fullMatch); const matchEnd = matchStart + fullMatch.length; // Check if cursor is within the Route() call if (position.character >= matchStart && position.character <= matchEnd) { // Parse the action string for :: delimiter let controller: string; let method: string | undefined; if (action.includes('::')) { // Format: "Controller::method" or "Spa_Action::method" [controller, method] = action.split('::', 2); } else { // Format: "Controller" or "Spa_Action_Name" - defaults to 'index' controller = action; method = 'index'; } // Try to resolve as PHP controller first, then JS SPA action try { const result = await this.queryIdeHelper(controller, method, 'php_class,js_class'); return this.createLocationFromResult(result); } catch (error) { // If method lookup fails, try just the class try { const result = await this.queryIdeHelper(controller, undefined, 'php_class,js_class'); return this.createLocationFromResult(result); } catch (error2) { console.error('Error querying IDE helper for route:', error); } } } } return undefined; } /** * Handle config() pattern in PHP and Blade files * Detects patterns like: * - config('rsx.default_user.email') * - config("app.name") * * Searches in both system/config/ and rsx/resource/config/ * (rsx/resource/config/ takes precedence) */ private async handleConfigPattern( document: vscode.TextDocument, position: vscode.Position ): Promise { const line = document.lineAt(position.line).text; // Match config('key.path') or config("key.path") const configPattern = /config\s*\(\s*(['"])([a-zA-Z0-9_.]+)\1\s*\)/g; let match; while ((match = configPattern.exec(line)) !== null) { const fullMatch = match[0]; const configKey = match[2]; // e.g., "rsx.default_user.email" const keyStart = match.index + match[0].indexOf(configKey); const keyEnd = keyStart + configKey.length; // Check if cursor is on the config key if (position.character >= keyStart && position.character < keyEnd) { // Parse the config key const keyParts = configKey.split('.'); const configFile = keyParts[0]; // e.g., "rsx" const nestedPath = keyParts.slice(1); // e.g., ["default_user", "email"] // Get the RSpade project root const rspade_root = this.find_rspade_root(); if (!rspade_root) { return undefined; } // Search for config file (prioritize rsx/resource/config/) const rsxConfigPath = path.join(rspade_root, 'rsx', 'resource', 'config', `${configFile}.php`); const systemConfigPath = path.join(rspade_root, 'system', 'config', `${configFile}.php`); let configFilePath: string | undefined; // Check rsx/resource/config first if (fs.existsSync(rsxConfigPath)) { configFilePath = rsxConfigPath; } else if (fs.existsSync(systemConfigPath)) { configFilePath = systemConfigPath; } if (!configFilePath) { return undefined; } // Read the config file try { const configContent = fs.readFileSync(configFilePath, 'utf8'); // Find the line containing the nested key const location = this.findConfigKeyInFile(configContent, nestedPath, configFilePath); if (location) { this.clear_status_bar(); return location; } // If we can't find the specific key, just return the file const fileUri = vscode.Uri.file(configFilePath); const filePosition = new vscode.Position(0, 0); this.clear_status_bar(); return new vscode.Location(fileUri, filePosition); } catch (error) { console.error('Error reading config file:', error); return undefined; } } } return undefined; } /** * Find a nested config key in a PHP config file * Returns the location of the key definition if found */ private findConfigKeyInFile( content: string, nestedPath: string[], filePath: string ): vscode.Location | undefined { if (nestedPath.length === 0) { // No nested path, return start of file const fileUri = vscode.Uri.file(filePath); return new vscode.Location(fileUri, new vscode.Position(0, 0)); } // Split content into lines const lines = content.split('\n'); // Search for the key in the file // For nested keys like ["default_user", "email"], we need to find: // 1. First, find 'default_user' => [ // 2. Then find 'email' => value // Simple approach: search for the last key in quotes const targetKey = nestedPath[nestedPath.length - 1]; const keyPattern = new RegExp(`['"]${targetKey}['"]\\s*=>`, 'i'); for (let i = 0; i < lines.length; i++) { if (keyPattern.test(lines[i])) { const fileUri = vscode.Uri.file(filePath); const position = new vscode.Position(i, 0); return new vscode.Location(fileUri, position); } } return undefined; } /** * Handle href="/" pattern in Blade/Jqhtml files * Detects when cursor is on "/" within href attribute * Resolves to the controller action that handles the root URL */ private async handleHrefPattern( document: vscode.TextDocument, position: vscode.Position ): Promise { const line = document.lineAt(position.line).text; // Match href="/" or href='/' const hrefPattern = /href\s*=\s*(['"])\/\1/g; let match; while ((match = hrefPattern.exec(line)) !== null) { const matchStart = match.index + match[0].indexOf('/'); const matchEnd = matchStart + 1; // Just the "/" character // Check if cursor is on the "/" if (position.character >= matchStart && position.character <= matchEnd) { try { // Query IDE bridge to resolve "/" URL to route const result = await this.ide_bridge.queryUrl('/'); if (result && result.found && result.controller && result.method) { // Resolved to controller/method - navigate to it (always PHP) const phpResult = await this.queryIdeHelper(result.controller, result.method, 'php_class'); return this.createLocationFromResult(phpResult); } } catch (error) { console.error('Error resolving href="/" to route:', error); } } } return undefined; } /** * Handle jqhtml extends="" attribute * Detects patterns like: * - * * Resolution: Try jqhtml component first, then JS class * Type: 'jqhtml_class,js_class' */ private async handleJqhtmlExtends( document: vscode.TextDocument, position: vscode.Position ): Promise { const line = document.lineAt(position.line).text; // Match extends="ClassName" or extends='ClassName' const extendsPattern = /extends\s*=\s*(['"])([A-Z][A-Za-z0-9_]*)\1/g; let match; while ((match = extendsPattern.exec(line)) !== null) { const className = match[2]; const classStart = match.index + match[0].indexOf(className); const classEnd = classStart + className.length; // Check if cursor is on the class name if (position.character >= classStart && position.character < classEnd) { try { // Try jqhtml component first, then JS class const result = await this.queryIdeHelper(className, undefined, 'jqhtml_class,js_class'); return this.createLocationFromResult(result); } catch (error) { console.error('Error resolving jqhtml extends:', error); } } } return undefined; } /** * Handle jqhtml $xxx=... attributes * Detects patterns like: * - $data_source=Frontend_Controller.fetch_data * - $on_click=this.handle_click * * Resolution logic: * - If starts with "this.", resolve to current component's jqhtml class methods * - Otherwise, resolve like JS class references: 'js_class,php_class' */ private async handleJqhtmlAttribute( document: vscode.TextDocument, position: vscode.Position ): Promise { const line = document.lineAt(position.line).text; // Match $attribute=Value or $attribute=this.method or $attribute=Class.method // Pattern: $word=(this.)?(Word)(.word)? const attrPattern = /\$[a-z_][a-z0-9_]*\s*=\s*(this\.)?([A-Z][A-Za-z0-9_]*)(?:\.([a-z_][a-z0-9_]*))?/gi; let match; while ((match = attrPattern.exec(line)) !== null) { const hasThis = !!match[1]; // "this." prefix const className = match[2]; const methodName = match[3]; // Optional method after dot const classStart = match.index + match[0].indexOf(className); const classEnd = classStart + className.length; // Check if cursor is on the class name if (position.character >= classStart && position.character < classEnd) { if (hasThis) { // this.method - resolve to current component's methods // Get the component name from the file let componentName: string | undefined; const fullText = document.getText(); const defineMatch = fullText.match(/ part.charAt(0).toUpperCase() + part.slice(1).toLowerCase() ).join('_'); } } if (!componentName) { return undefined; } try { // The className here is actually the method name after "this." // We need to use the component name as the identifier const result = await this.queryIdeHelper(componentName, className.toLowerCase(), 'jqhtml_class_method'); return this.createLocationFromResult(result); } catch (error) { console.error('Error resolving jqhtml this reference:', error); } } else { // Class.method or Class - resolve like JS class references try { const result = await this.queryIdeHelper(className, methodName, 'js_class,php_class'); return this.createLocationFromResult(result); } catch (error) { console.error('Error resolving jqhtml attribute class:', error); } } } } return undefined; } /** * Handle "this.xxx" references in .jqhtml files * Only handles patterns where cursor is on a word after "this." * Resolves to JavaScript class method if it exists */ private async handleThisReference( document: vscode.TextDocument, position: vscode.Position ): Promise { const line = document.lineAt(position.line).text; const fileName = document.fileName; // Check if cursor is on a word after "this." // Get the word at cursor position const wordRange = document.getWordRangeAtPosition(position); if (!wordRange) { return undefined; } const word = document.getText(wordRange); // Check if "this." appears before this word const beforeWord = line.substring(0, wordRange.start.character); if (!beforeWord.endsWith('this.')) { return undefined; } // Get the component name from the file let componentName: string | undefined; const fullText = document.getText(); const defineMatch = fullText.match(/ User_Card const baseName = path.basename(fileName, '.jqhtml'); if (baseName) { // Convert snake_case to PascalCase with underscores componentName = baseName.split('_').map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase() ).join('_'); } } if (!componentName) { return undefined; } try { // Try to find the JavaScript class and method // First try jqhtml_class type to find the JS file const result = await this.queryIdeHelper(componentName, word, 'jqhtml_class_method'); if (result && result.found) { return this.createLocationFromResult(result); } } catch (error) { // Method not found, try just the class try { const result = await this.queryIdeHelper(componentName, undefined, 'jqhtml_class'); return this.createLocationFromResult(result); } catch (error2) { console.error('Error querying IDE helper for this reference:', error2); } } return undefined; } /** * Handle jqhtml component tags in .blade.php and .jqhtml files * Detects uppercase HTML-like tags such as: * - * - content * - content */ private async handleJqhtmlComponent( document: vscode.TextDocument, position: vscode.Position ): Promise { console.log('[JQHTML Component] Entry point - checking component navigation'); // If JQHTML API not available, skip if (!this.jqhtml_api) { console.log('[JQHTML Component] JQHTML API not available - skipping'); return undefined; } console.log('[JQHTML Component] JQHTML API available'); // 1. Get the word at cursor position (component name pattern) const word_range = document.getWordRangeAtPosition(position, /[A-Z][A-Za-z0-9_]*/); if (!word_range) { console.log('[JQHTML Component] No word range found at cursor position'); return undefined; } const component_name = document.getText(word_range); console.log('[JQHTML Component] Found word at cursor:', component_name); // 2. Verify it's a component reference (starts with uppercase) if (!/^[A-Z]/.test(component_name)) { console.log('[JQHTML Component] Word does not start with uppercase - not a component'); return undefined; } console.log('[JQHTML Component] Word starts with uppercase - valid component name pattern'); // 3. Check if cursor is in a tag context const line = document.lineAt(position.line).text; const before_word = line.substring(0, word_range.start.character); console.log('[JQHTML Component] Line text:', line); console.log('[JQHTML Component] Text before word:', before_word); // Check for opening tags: { const line = document.lineAt(position.line).text; // Try to match a class name first (uppercase with underscores) let wordRange = document.getWordRangeAtPosition(position, /[A-Z][A-Za-z0-9_]*/); if (wordRange) { const word = document.getText(wordRange); // Check if this looks like an RSX class name (contains underscore and starts with capital) if (word.includes('_') && /^[A-Z]/.test(word)) { // Check if we're on a method call (look for a dot and method name after the class) let method_name: string | undefined; const wordEnd = wordRange.end.character; // Look for pattern like "ClassName.methodName" const methodMatch = line.substring(wordEnd).match(/^\.([a-z_][a-z0-9_]*)/i); if (methodMatch) { method_name = methodMatch[1]; } // Query the IDE helper endpoint // Try JS classes first (component inheritance), then PHP (controllers/models) try { const result = await this.queryIdeHelper(word, method_name, 'js_class,php_class'); return this.createLocationFromResult(result); } catch (error) { console.error('Error querying IDE helper:', error); } return undefined; } } // Try to match a method name (lowercase with underscores) wordRange = document.getWordRangeAtPosition(position, /[a-z_][a-z0-9_]*/); if (wordRange) { const method_name = document.getText(wordRange); const wordStart = wordRange.start.character; // Look backwards for "ClassName." or "ClassName::" pattern before the method const beforeMethod = line.substring(0, wordStart); const classMatch = beforeMethod.match(/([A-Z][A-Za-z0-9_]*)(?:\.|::)$/); if (classMatch) { const class_name = classMatch[1]; // Check if the class name looks like an RSX class (contains underscore) if (class_name.includes('_')) { // Query the IDE helper for this class and method try { const result = await this.queryIdeHelper(class_name, method_name, 'js_class,php_class'); return this.createLocationFromResult(result); } catch (error) { console.error('Error querying IDE helper for method:', error); } } } } return undefined; } private async handlePhpBladeDefinition( document: vscode.TextDocument, position: vscode.Position ): Promise { const line = document.lineAt(position.line).text; const charPosition = position.character; // Pattern 1: @rsx_extends('View_Name') or @rsx_include('View_Name') // Pattern 2: rsx_view('View_Name') // Pattern 3: Class references like Demo_Controller // Check if we're inside a string literal let inString = false; let stringStart = -1; let stringEnd = -1; let quoteChar = ''; // Find string boundaries around cursor position for (let i = 0; i < line.length; i++) { const char = line[i]; if ((char === '"' || char === "'") && (i === 0 || line[i - 1] !== '\\')) { if (!inString) { inString = true; stringStart = i; quoteChar = char; } else if (char === quoteChar) { stringEnd = i; if (charPosition > stringStart && charPosition <= stringEnd) { // Cursor is inside this string break; } inString = false; stringStart = -1; stringEnd = -1; } } } // If we're inside a string, extract the identifier if (stringStart >= 0) { const stringContent = line.substring(stringStart + 1, stringEnd >= 0 ? stringEnd : line.length); // Check what context this string is in const beforeString = line.substring(0, stringStart); // Check if we're in a bundle 'include' array // Look for patterns like 'include' => [ or "include" => [ // We need to check previous lines too for context let inBundleInclude = false; // Check current line for include array if (/['"]include['"]\s*=>\s*\[/.test(line)) { inBundleInclude = true; } else { // Check previous lines for context (up to 10 lines back) for (let i = Math.max(0, position.line - 10); i < position.line; i++) { const prevLine = document.lineAt(i).text; if (/['"]include['"]\s*=>\s*\[/.test(prevLine)) { // Check if we haven't closed the array yet let openBrackets = 0; for (let j = i; j <= position.line; j++) { const checkLine = document.lineAt(j).text; openBrackets += (checkLine.match(/\[/g) || []).length; openBrackets -= (checkLine.match(/\]/g) || []).length; } if (openBrackets > 0) { inBundleInclude = true; break; } } } } // If we're in a bundle include array and the string looks like a bundle alias if (inBundleInclude && /^[a-z0-9]+$/.test(stringContent)) { try { const result = await this.queryIdeHelper(stringContent, undefined, 'bundle_alias'); if (result && result.found) { return this.createLocationFromResult(result); } } catch (error) { console.error('Error querying IDE helper for bundle alias:', error); } } // Check for RSX blade directives or function calls const rsxPatterns = [ /@rsx_extends\s*\(\s*$/, /@rsx_include\s*\(\s*$/, /@rsx_layout\s*\(\s*$/, /@rsx_component\s*\(\s*$/, /rsx_view\s*\(\s*$/, /rsx_include\s*\(\s*$/ ]; let isRsxView = false; for (const pattern of rsxPatterns) { if (pattern.test(beforeString)) { isRsxView = true; break; } } if (isRsxView && stringContent) { // Query as a view const result = await this.queryIdeHelper(stringContent, undefined, 'view'); return this.createLocationFromResult(result); } } // If not in a string, check for class references (like in PHP files) // But skip this if we're inside a Route() call const routePattern = /(?:Rsx::Route|Rsx\.Route)\s*\([^)]*\)/g; let isInRoute = false; let routeMatch; while ((routeMatch = routePattern.exec(line)) !== null) { const matchStart = routeMatch.index; const matchEnd = matchStart + routeMatch[0].length; if (position.character >= matchStart && position.character <= matchEnd) { isInRoute = true; break; } } if (!isInRoute) { const wordRange = document.getWordRangeAtPosition(position, /[A-Z][A-Za-z0-9_]*/); if (wordRange) { const word = document.getText(wordRange); // Check if this looks like an RSX class name if (word.includes('_') && /^[A-Z]/.test(word)) { try { // When resolving from PHP files, only look for PHP classes // This prevents jumping to JavaScript files when clicking on PHP class references const result = await this.queryIdeHelper(word, undefined, 'php_class'); return this.createLocationFromResult(result); } catch (error) { console.error('Error querying IDE helper for class:', error); } } } } return undefined; } private createLocationFromResult(result: any): vscode.Location | undefined { if (result && result.found) { // Get the RSpade project root const rspade_root = this.find_rspade_root(); if (!rspade_root) { return undefined; } // Construct the full file path const filePath = path.join(rspade_root, result.file); const fileUri = vscode.Uri.file(filePath); // Create a position for the definition const position = new vscode.Position(result.line - 1, 0); // VS Code uses 0-based line numbers // Clear any error status on successful navigation this.clear_status_bar(); return new vscode.Location(fileUri, position); } return undefined; } /** * Handle file paths in strings - allows "Go to Definition" on file path strings * This is a fallback handler that only runs if other definitions aren't found */ private async handleFilePathInString( document: vscode.TextDocument, position: vscode.Position ): Promise { const line = document.lineAt(position.line).text; const charPosition = position.character; // Check if we're inside a string literal let inString = false; let stringStart = -1; let stringEnd = -1; let quoteChar = ''; // Find string boundaries around cursor position for (let i = 0; i < line.length; i++) { const char = line[i]; if ((char === '"' || char === "'") && (i === 0 || line[i - 1] !== '\\')) { if (!inString) { inString = true; stringStart = i; quoteChar = char; } else if (char === quoteChar) { stringEnd = i; if (charPosition > stringStart && charPosition <= stringEnd) { // Cursor is inside this string break; } inString = false; stringStart = -1; stringEnd = -1; } } } // If we're not inside a string, return undefined if (stringStart < 0) { return undefined; } // Extract the string content const stringContent = line.substring(stringStart + 1, stringEnd >= 0 ? stringEnd : line.length); // Check if the string looks like a file path (contains forward slashes or dots) if (!stringContent.includes('/') && !stringContent.includes('.')) { return undefined; } // Get the RSpade project root const rspade_root = this.find_rspade_root(); if (!rspade_root) { return undefined; } // Try to resolve the path relative to the workspace const possiblePath = path.join(rspade_root, stringContent); // Check if the file exists try { const stat = fs.statSync(possiblePath); if (stat.isFile()) { // Create a location for the file const fileUri = vscode.Uri.file(possiblePath); const position = new vscode.Position(0, 0); // Go to start of file return new vscode.Location(fileUri, position); } } catch (error) { // File doesn't exist, that's ok - just return undefined } return undefined; } private async queryIdeHelper(identifier: string, methodName?: string, type?: string): Promise { const params: any = { identifier }; if (methodName) { params.method = methodName; } if (type) { params.type = type; } try { const result = await this.ide_bridge.request('/_ide/service/resolve_class', params); return result; } catch (error: any) { this.show_error_status('IDE helper request failed'); throw error; } } }