Files
rspade_system/app/RSpade/resource/vscode_extension/src/definition_provider.ts
root 9ebcc359ae Fix code quality violations and enhance ROUTE-EXISTS-01 rule
Implement JQHTML function cache ID system and fix bundle compilation
Implement underscore prefix for system tables
Fix JS syntax linter to support decorators and grant exception to Task system
SPA: Update planning docs and wishlists with remaining features
SPA: Document Navigation API abandonment and future enhancements
Implement SPA browser integration with History API (Phase 1)
Convert contacts view page to SPA action
Convert clients pages to SPA actions and document conversion procedure
SPA: Merge GET parameters and update documentation
Implement SPA route URL generation in JavaScript and PHP
Implement SPA bootstrap controller architecture
Add SPA routing manual page (rsx:man spa)
Add SPA routing documentation to CLAUDE.md
Phase 4 Complete: Client-side SPA routing implementation
Update get_routes() consumers for unified route structure
Complete SPA Phase 3: PHP-side route type detection and is_spa flag
Restore unified routes structure and Manifest_Query class
Refactor route indexing and add SPA infrastructure
Phase 3 Complete: SPA route registration in manifest
Implement SPA Phase 2: Extract router code and test decorators
Rename Jqhtml_Component to Component and complete SPA foundation setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 17:48:15 +00:00

1055 lines
42 KiB
TypeScript
Executable File

/**
* 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: <Define:My_Component extends="DataGrid_Abstract">
* 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<void>;
}
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<vscode.Definition | undefined> {
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<vscode.Definition | undefined> {
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<vscode.Definition | undefined> {
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<vscode.Definition | undefined> {
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:
* - <Define:My_Component extends="DataGrid_Abstract">
*
* Resolution: Try jqhtml component first, then JS class
* Type: 'jqhtml_class,js_class'
*/
private async handleJqhtmlExtends(
document: vscode.TextDocument,
position: vscode.Position
): Promise<vscode.Definition | undefined> {
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<vscode.Definition | undefined> {
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(/<Define:([A-Z][A-Za-z0-9_]*)/);
if (defineMatch) {
componentName = defineMatch[1];
} else {
// If no Define tag, try to get component name from filename
const fileName = document.fileName;
const baseName = fileName.split('/').pop()?.replace('.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 {
// 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<vscode.Definition | undefined> {
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(/<Define:([A-Z][A-Za-z0-9_]*)/);
if (defineMatch) {
componentName = defineMatch[1];
} else {
// If no Define tag, try to get component name from filename
// e.g., user_card.jqhtml -> 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:
* - <User_Card ... />
* - <User_Card ...>content</User_Card>
* - <Foo>content</Foo>
*/
private async handleJqhtmlComponent(
document: vscode.TextDocument,
position: vscode.Position
): Promise<vscode.Definition | undefined> {
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: <ComponentName or closing tags: </ComponentName
const is_in_tag_context =
before_word.match(/<\s*$/) !== null ||
before_word.match(/<\/\s*$/) !== null;
console.log('[JQHTML Component] Is in tag context:', is_in_tag_context);
if (!is_in_tag_context) {
console.log('[JQHTML Component] Not in tag context - skipping');
return undefined;
}
// 4. Look up component using JQHTML API
console.log('[JQHTML Component] Calling JQHTML API findComponent for:', component_name);
const component_def = this.jqhtml_api.findComponent(component_name);
console.log('[JQHTML Component] JQHTML API result:', component_def);
if (!component_def) {
console.log('[JQHTML Component] Component not found in JQHTML index');
return undefined;
}
// 5. Return the location
console.log('[JQHTML Component] Returning location:', component_def.uri.fsPath, 'at position', component_def.position);
return new vscode.Location(component_def.uri, component_def.position);
}
/**
* Handle JavaScript class references in .js and .jqhtml files
* Detects patterns like:
* - class My_Component extends DataGrid_Abstract
* - User_Controller.fetch_all()
* - await Product_Model.fetch(123)
*
* Resolution: Try JS classes first (for component inheritance), then PHP classes (for controllers/models)
* Type: 'js_class,php_class'
*
* Note: JS stub files (auto-generated from PHP) are client-side only and not in the manifest,
* so there's no conflict - the server will correctly return PHP classes when they exist.
*/
private async handleJavaScriptDefinition(
document: vscode.TextDocument,
position: vscode.Position
): Promise<vscode.Definition | undefined> {
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." 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<vscode.Definition | undefined> {
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<vscode.Definition | undefined> {
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<any> {
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;
}
}
}