From 676fed7f24c0c65e86f43ae3c99afa0689bec231 Mon Sep 17 00:00:00 2001 From: N1ebieski Date: Tue, 16 Sep 2025 22:02:27 +0000 Subject: [PATCH 1/6] Add a "Go to implementation" action for scopes Fixes N1ebieski/vs-code-extension#59 --- php-templates/models.php | 8 +++- src/extension.ts | 5 +++ src/features/model.ts | 87 ++++++++++++++++++++++++++++++++++++++ src/hoverAction/support.ts | 30 +++++++++++++ src/index.d.ts | 9 +++- src/repositories/models.ts | 6 +++ src/support/docblocks.ts | 17 +++++--- src/templates/models.ts | 8 +++- src/types.ts | 8 ++++ 9 files changed, 166 insertions(+), 12 deletions(-) create mode 100644 src/features/model.ts create mode 100644 src/hoverAction/support.ts diff --git a/php-templates/models.php b/php-templates/models.php index 3946d402..2e1693e8 100644 --- a/php-templates/models.php +++ b/php-templates/models.php @@ -155,8 +155,12 @@ protected function getInfo($className) ->toArray(); $data['scopes'] = collect($reflection->getMethods()) - ->filter(fn($method) =>!$method->isStatic() && ($method->getAttributes(\Illuminate\Database\Eloquent\Attributes\Scope::class) || ($method->isPublic() && str_starts_with($method->name, 'scope')))) - ->map(fn($method) => str($method->name)->replace('scope', '')->lcfirst()->toString()) + ->filter(fn(\ReflectionMethod $method) =>!$method->isStatic() && ($method->getAttributes(\Illuminate\Database\Eloquent\Attributes\Scope::class) || ($method->isPublic() && str_starts_with($method->name, 'scope')))) + ->map(fn(\ReflectionMethod $method) => [ + "name" => str($method->name)->replace('scope', '')->lcfirst()->toString(), + "uri" => $method->getFileName(), + "start_line" => $method->getStartLine() + ]) ->values() ->toArray(); diff --git a/src/extension.ts b/src/extension.ts index 96f645c7..a215f84b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -28,6 +28,7 @@ import { wrapSelectionCommand, wrapWithHelperCommands, } from "./commands/wrapWithHelper"; +import { ScopeHoverProvider } from "./features/model"; import { configAffected } from "./support/config"; import { collectDebugInfo } from "./support/debug"; import { @@ -210,6 +211,10 @@ export async function activate(context: vscode.ExtensionContext) { ...hoverProviders.map((provider) => vscode.languages.registerHoverProvider(LANGUAGES, provider), ), + vscode.languages.registerHoverProvider( + PHP_LANGUAGE, + new ScopeHoverProvider(), + ), // ...testRunnerCommands, // testController, vscode.languages.registerCodeActionsProvider( diff --git a/src/features/model.ts b/src/features/model.ts new file mode 100644 index 00000000..ce43dcad --- /dev/null +++ b/src/features/model.ts @@ -0,0 +1,87 @@ +import { HoverActions } from "@src/hoverAction/support"; +import { getModelByClassname } from "@src/repositories/models"; +import { detect } from "@src/support/parser"; +import { AutocompleteParsingResult } from "@src/types"; +import * as vscode from "vscode"; + +const isInHoverRange = ( + range: vscode.Range, + method: AutocompleteParsingResult.MethodCall, +): boolean => { + if (!method.start || !method.end) { + return false; + } + + // vs-code-php-parser-cli returns us the position of the entire node, for example: + // + // Example::popular()->active(); + // + // but we need only the position of the method name, for example: active(), + // so we cannot check the position by the exact equations, but this should be enough + return ( + method.end.line === range.end.line && + method.start.column <= range.start.character && + method.end.column >= range.end.character + ); +}; + +export class ScopeHoverProvider implements vscode.HoverProvider { + provideHover( + document: vscode.TextDocument, + position: vscode.Position, + ): vscode.ProviderResult { + const range = document.getWordRangeAtPosition(position); + + if (!range) { + return null; + } + + const scopeName = document.getText(range); + + return detect(document).then((results) => { + if (!results) { + return null; + } + + const result = results + .filter((result) => result.type === "methodCall") + .find( + (result) => + isInHoverRange(range, result) && + result.methodName === scopeName, + ); + + if (!result || !result.className) { + return null; + } + + const model = getModelByClassname(result.className); + + if (!model || !model.uri) { + return null; + } + + const scope = model.scopes.find( + (scope) => scope.name === scopeName, + ); + + if (!scope || !scope.uri) { + return null; + } + + const hoverActions = new HoverActions([ + { + title: "Go to implementation", + command: "laravel.open", + arguments: [ + vscode.Uri.file(scope.uri), + scope.start_line, + 0, + ], + }, + ]); + + return new vscode.Hover(hoverActions.getAsMarkdownString(), range); + }); + } +} diff --git a/src/hoverAction/support.ts b/src/hoverAction/support.ts new file mode 100644 index 00000000..9745e4af --- /dev/null +++ b/src/hoverAction/support.ts @@ -0,0 +1,30 @@ +import * as vscode from "vscode"; + +export class HoverActions { + private readonly commands: vscode.Command[]; + + constructor(commands: vscode.Command[] = []) { + this.commands = commands; + } + + public push(command: vscode.Command): this { + this.commands.push(command); + + return this; + } + + public getAsMarkdownString(): vscode.MarkdownString { + let string = ""; + + this.commands.forEach((command) => { + string += `[${command.title}](command:${command.command}?${encodeURIComponent(JSON.stringify(command.arguments))})`; + string += `   `; + }); + + const markdownString = new vscode.MarkdownString(string); + markdownString.supportHtml = true; + markdownString.isTrusted = true; + + return markdownString; + } +} diff --git a/src/index.d.ts b/src/index.d.ts index dbc537e8..0b0cd3c7 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -72,8 +72,15 @@ declare namespace Eloquent { [key: string]: Model; } + interface Scope { + name: string; + uri: string | false; + start_line: number | false; + } + interface Model { class: string; + uri: string | false; database: string; table: string; policy: string | null; @@ -81,7 +88,7 @@ declare namespace Eloquent { relations: Relation[]; events: Event[]; observers: Observer[]; - scopes: string[]; + scopes: Scope[]; extends: string | null; } diff --git a/src/repositories/models.ts b/src/repositories/models.ts index 3d045a3c..c3f6e7b2 100644 --- a/src/repositories/models.ts +++ b/src/repositories/models.ts @@ -22,6 +22,12 @@ const load = () => { }); }; +export const getModelByClassname = ( + className: string, +): Eloquent.Model | undefined => { + return getModels().items[className]; +}; + export const getModels = repository({ load, pattern: modelPaths diff --git a/src/support/docblocks.ts b/src/support/docblocks.ts index e4f0dc99..7dad3e12 100644 --- a/src/support/docblocks.ts +++ b/src/support/docblocks.ts @@ -114,13 +114,16 @@ const getBlocks = ( return model.attributes .map((attr) => getAttributeBlocks(attr, className)) .concat( - [...model.scopes, "newModelQuery", "newQuery", "query"].map( - (method) => { - return `@method static ${modelBuilderType( - className, - )} ${method}()`; - }, - ), + [ + ...model.scopes.map((scope) => scope.name), + "newModelQuery", + "newQuery", + "query", + ].map((method) => { + return `@method static ${modelBuilderType( + className, + )} ${method}()`; + }), ) .concat(model.relations.map((relation) => getRelationBlocks(relation))) .flat() diff --git a/src/templates/models.ts b/src/templates/models.ts index 6d0c5d9e..4a5350ff 100644 --- a/src/templates/models.ts +++ b/src/templates/models.ts @@ -155,8 +155,12 @@ $models = new class($factory) { ->toArray(); $data['scopes'] = collect($reflection->getMethods()) - ->filter(fn($method) =>!$method->isStatic() && ($method->getAttributes(\\Illuminate\\Database\\Eloquent\\Attributes\\Scope::class) || ($method->isPublic() && str_starts_with($method->name, 'scope')))) - ->map(fn($method) => str($method->name)->replace('scope', '')->lcfirst()->toString()) + ->filter(fn(\\ReflectionMethod $method) =>!$method->isStatic() && ($method->getAttributes(\\Illuminate\\Database\\Eloquent\\Attributes\\Scope::class) || ($method->isPublic() && str_starts_with($method->name, 'scope')))) + ->map(fn(\\ReflectionMethod $method) => [ + "name" => str($method->name)->replace('scope', '')->lcfirst()->toString(), + "uri" => $method->getFileName(), + "start_line" => $method->getStartLine() + ]) ->values() ->toArray(); diff --git a/src/types.ts b/src/types.ts index a4419bab..91417966 100644 --- a/src/types.ts +++ b/src/types.ts @@ -93,6 +93,14 @@ export namespace AutocompleteParsingResult { methodName: string | null; className: string | null; arguments: Arguments; + start?: { + line: number; + column: number; + }; + end?: { + line: number; + column: number; + }; } export interface MethodDefinition { From c5491048ead5114b63f62fc9ca4977871986387b Mon Sep 17 00:00:00 2001 From: N1ebieski Date: Wed, 17 Sep 2025 00:36:19 +0000 Subject: [PATCH 2/6] isInHoverRange checks if one range contains another one --- src/features/model.ts | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/features/model.ts b/src/features/model.ts index ce43dcad..d042ee52 100644 --- a/src/features/model.ts +++ b/src/features/model.ts @@ -12,17 +12,20 @@ const isInHoverRange = ( return false; } + const nodeRange = new vscode.Range( + new vscode.Position(method.start.line, method.start.column), + new vscode.Position(method.end.line, method.end.column), + ); + // vs-code-php-parser-cli returns us the position of the entire node, for example: // - // Example::popular()->active(); + // Example::popular() + // ->traitScope() + // ->active(); // - // but we need only the position of the method name, for example: active(), - // so we cannot check the position by the exact equations, but this should be enough - return ( - method.end.line === range.end.line && - method.start.column <= range.start.character && - method.end.column >= range.end.character - ); + // so we don't have to check equality of the start and end positions. + // It's enough to check if the node range contains the scope range + return nodeRange.contains(range); }; export class ScopeHoverProvider implements vscode.HoverProvider { @@ -47,21 +50,15 @@ export class ScopeHoverProvider implements vscode.HoverProvider { .filter((result) => result.type === "methodCall") .find( (result) => - isInHoverRange(range, result) && - result.methodName === scopeName, + result.methodName === scopeName && + isInHoverRange(range, result), ); if (!result || !result.className) { return null; } - const model = getModelByClassname(result.className); - - if (!model || !model.uri) { - return null; - } - - const scope = model.scopes.find( + const scope = getModelByClassname(result.className)?.scopes?.find( (scope) => scope.name === scopeName, ); From bfd2341100a574a08bf0a7e4feed18eb32d1922a Mon Sep 17 00:00:00 2001 From: N1ebieski Date: Wed, 17 Sep 2025 10:21:52 +0000 Subject: [PATCH 3/6] refactoring --- src/features/model.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/model.ts b/src/features/model.ts index d042ee52..3fa1b2d8 100644 --- a/src/features/model.ts +++ b/src/features/model.ts @@ -54,7 +54,7 @@ export class ScopeHoverProvider implements vscode.HoverProvider { isInHoverRange(range, result), ); - if (!result || !result.className) { + if (!result?.className) { return null; } @@ -62,7 +62,7 @@ export class ScopeHoverProvider implements vscode.HoverProvider { (scope) => scope.name === scopeName, ); - if (!scope || !scope.uri) { + if (!scope?.uri) { return null; } From 76f1896bed9ba8ecc849cd0b4ceca418b5cecdd0 Mon Sep 17 00:00:00 2001 From: N1ebieski Date: Fri, 10 Oct 2025 07:33:03 +0000 Subject: [PATCH 4/6] replace uri with $method->getFileName() with LaravelVsCode::relativePath --- php-templates/models.php | 4 ++-- src/templates/models.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/php-templates/models.php b/php-templates/models.php index 2e1693e8..cd1123b4 100644 --- a/php-templates/models.php +++ b/php-templates/models.php @@ -155,10 +155,10 @@ protected function getInfo($className) ->toArray(); $data['scopes'] = collect($reflection->getMethods()) - ->filter(fn(\ReflectionMethod $method) =>!$method->isStatic() && ($method->getAttributes(\Illuminate\Database\Eloquent\Attributes\Scope::class) || ($method->isPublic() && str_starts_with($method->name, 'scope')))) + ->filter(fn(\ReflectionMethod $method) => !$method->isStatic() && ($method->getAttributes(\Illuminate\Database\Eloquent\Attributes\Scope::class) || ($method->isPublic() && str_starts_with($method->name, 'scope')))) ->map(fn(\ReflectionMethod $method) => [ "name" => str($method->name)->replace('scope', '')->lcfirst()->toString(), - "uri" => $method->getFileName(), + "path" => $method->getFileName() ? LaravelVsCode::relativePath($method->getFileName()) : null, "start_line" => $method->getStartLine() ]) ->values() diff --git a/src/templates/models.ts b/src/templates/models.ts index 4a5350ff..e4ebf242 100644 --- a/src/templates/models.ts +++ b/src/templates/models.ts @@ -155,10 +155,10 @@ $models = new class($factory) { ->toArray(); $data['scopes'] = collect($reflection->getMethods()) - ->filter(fn(\\ReflectionMethod $method) =>!$method->isStatic() && ($method->getAttributes(\\Illuminate\\Database\\Eloquent\\Attributes\\Scope::class) || ($method->isPublic() && str_starts_with($method->name, 'scope')))) + ->filter(fn(\\ReflectionMethod $method) => !$method->isStatic() && ($method->getAttributes(\\Illuminate\\Database\\Eloquent\\Attributes\\Scope::class) || ($method->isPublic() && str_starts_with($method->name, 'scope')))) ->map(fn(\\ReflectionMethod $method) => [ "name" => str($method->name)->replace('scope', '')->lcfirst()->toString(), - "uri" => $method->getFileName(), + "path" => $method->getFileName() ? LaravelVsCode::relativePath($method->getFileName()) : null, "start_line" => $method->getStartLine() ]) ->values() From f3c7d6e2a3b1b93cebc7a9507cc9b51efcd0139c Mon Sep 17 00:00:00 2001 From: N1ebieski Date: Fri, 10 Oct 2025 07:34:17 +0000 Subject: [PATCH 5/6] replace uri $method->getFileName() with LaravelVsCode::relativePath --- src/features/model.ts | 5 +++-- src/index.d.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/features/model.ts b/src/features/model.ts index 3fa1b2d8..ec06ebe7 100644 --- a/src/features/model.ts +++ b/src/features/model.ts @@ -1,6 +1,7 @@ import { HoverActions } from "@src/hoverAction/support"; import { getModelByClassname } from "@src/repositories/models"; import { detect } from "@src/support/parser"; +import { projectPath } from "@src/support/project"; import { AutocompleteParsingResult } from "@src/types"; import * as vscode from "vscode"; @@ -62,7 +63,7 @@ export class ScopeHoverProvider implements vscode.HoverProvider { (scope) => scope.name === scopeName, ); - if (!scope?.uri) { + if (!scope?.path) { return null; } @@ -71,7 +72,7 @@ export class ScopeHoverProvider implements vscode.HoverProvider { title: "Go to implementation", command: "laravel.open", arguments: [ - vscode.Uri.file(scope.uri), + vscode.Uri.file(projectPath(scope.path)), scope.start_line, 0, ], diff --git a/src/index.d.ts b/src/index.d.ts index 0b0cd3c7..7a30cb27 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -74,7 +74,7 @@ declare namespace Eloquent { interface Scope { name: string; - uri: string | false; + path: string | null; start_line: number | false; } From d4b29f598d454406bcdf9be0222e4f0907552c4c Mon Sep 17 00:00:00 2001 From: N1ebieski Date: Fri, 10 Oct 2025 07:34:34 +0000 Subject: [PATCH 6/6] increase font --- src/hoverAction/support.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hoverAction/support.ts b/src/hoverAction/support.ts index 9745e4af..551fc4a8 100644 --- a/src/hoverAction/support.ts +++ b/src/hoverAction/support.ts @@ -17,7 +17,7 @@ export class HoverActions { let string = ""; this.commands.forEach((command) => { - string += `[${command.title}](command:${command.command}?${encodeURIComponent(JSON.stringify(command.arguments))})`; + string += `[${command.title}](command:${command.command}?${encodeURIComponent(JSON.stringify(command.arguments))})`; string += `   `; });