diff --git a/generatable.json b/generatable.json index fe1a9b6f..d104967a 100644 --- a/generatable.json +++ b/generatable.json @@ -58,7 +58,8 @@ "label": "Livewire components", "features": [ "link", - "completion" + "completion", + "hover" ] }, { diff --git a/package.json b/package.json index cf77b4ec..86cab259 100644 --- a/package.json +++ b/package.json @@ -326,6 +326,12 @@ "generated": true, "description": "Enable completion for Livewire components." }, + "Laravel.livewireComponent.hover": { + "type": "boolean", + "default": true, + "generated": true, + "description": "Enable hover information for Livewire components." + }, "Laravel.middleware.diagnostics": { "type": "boolean", "default": true, diff --git a/php-templates/livewire-components.php b/php-templates/livewire-components.php new file mode 100644 index 00000000..16230fcd --- /dev/null +++ b/php-templates/livewire-components.php @@ -0,0 +1,222 @@ +getStandardClasses(), + $this->getStandardViews() + ))->groupBy('key')->map(fn (\Illuminate\Support\Collection $items) => [ + 'isVendor' => $items->first()['isVendor'], + 'paths' => $items->pluck('path')->values(), + 'props' => $items->pluck('props')->values()->filter()->flatMap(fn ($i) => $i), + ]); + + return [ + 'components' => $components + ]; + } + + /** + * @return array + */ + protected function findFiles(string $path, string $extension, \Closure $keyCallback): array + { + if (! is_dir($path)) { + return []; + } + + $files = \Symfony\Component\Finder\Finder::create() + ->files() + ->name("*." . $extension) + ->in($path); + $components = []; + $pathRealPath = realpath($path); + + foreach ($files as $file) { + $realPath = $file->getRealPath(); + + $key = str($realPath) + ->replace($pathRealPath, '') + ->ltrim('/\\') + ->replace('.' . $extension, '') + ->replace(['/', '\\'], '.') + ->pipe(fn (string $str): string => $str); + + $components[] = [ + "path" => LaravelVsCode::relativePath($realPath), + "isVendor" => LaravelVsCode::isVendor($realPath), + "key" => $keyCallback ? $keyCallback($key) : $key, + ]; + } + + return $components; + } + + protected function getStandardClasses(): array + { + /** @var string|null $classNamespace */ + $classNamespace = config('livewire.class_namespace'); + + if (! $classNamespace) { + return []; + } + + $path = str($classNamespace) + ->replace('\\', DIRECTORY_SEPARATOR) + ->lcfirst() + ->toString(); + + $items = $this->findFiles( + $path, + 'php', + fn (\Illuminate\Support\Stringable $key): string => $key->explode('.') + ->map(fn (string $p): string => \Illuminate\Support\Str::kebab($p)) + ->implode('.'), + ); + + return collect($items) + ->map(function ($item) { + $class = str($item['path']) + ->replace('.php', '') + ->replace(DIRECTORY_SEPARATOR, '\\') + ->ucfirst() + ->toString(); + + if (! class_exists($class)) { + return null; + } + + $reflection = new \ReflectionClass($class); + + if (! $reflection->isSubclassOf('Livewire\Component')) { + return null; + } + + return [ + ...$item, + 'props' => $this->getComponentProps($reflection), + ]; + }) + ->filter() + ->values() + ->all(); + } + + protected function getStandardViews(): array + { + /** @var string|null $viewPath */ + $path = config('livewire.view_path'); + + if (! $path) { + return []; + } + + $items = $this->findFiles( + $path, + 'blade.php', + fn (\Illuminate\Support\Stringable $key): string => $key->explode('.') + ->map(fn(string $p): string => \Illuminate\Support\Str::kebab($p)) + ->implode('.'), + ); + + $previousClass = null; + + return collect($items) + ->map(function ($item) use (&$previousClass) { + // This is ugly, I know, but I don't have better idea how to get + // anonymous classes from Volt components + ob_start(); + + try { + require_once $item['path']; + } catch (\Throwable $e) { + return $item; + } + + ob_clean(); + + $declaredClasses = get_declared_classes(); + $class = end($declaredClasses); + + if ($previousClass === $class) { + return $item; + } + + $previousClass = $class; + + if (! \Illuminate\Support\Str::contains($class, '@anonymous')) { + return $item; + } + + $reflection = new \ReflectionClass($class); + + if (! $reflection->isSubclassOf('Livewire\Volt\Component')) { + return $item; + } + + return [ + ...$item, + 'props' => $this->getComponentProps($reflection), + ]; + }) + ->all(); + } + + /** + * @return array + */ + protected function getComponentProps(ReflectionClass $reflection): array + { + $props = collect(); + + // Firstly we need to get the mount method parameters. Remember that + // Livewire components can have multiple mount methods in traits. + + $methods = $reflection->getMethods(); + + $mountMethods = array_filter( + $methods, + fn (\ReflectionMethod $method): bool => + \Illuminate\Support\Str::startsWith($method->getName(), 'mount') + ); + + foreach ($mountMethods as $method) { + $parameters = $method->getParameters(); + + $parameters = collect($parameters) + ->map(fn (\ReflectionParameter $p): array => [ + 'name' => \Illuminate\Support\Str::kebab($p->getName()), + 'type' => (string) ($p->getType() ?? 'mixed'), + // We need to add hasDefault, because null can be also a default value, + // it can't be a flag of no default + 'hasDefault' => $p->isDefaultValueAvailable(), + 'default' => $p->isOptional() ? $p->getDefaultValue() : null + ]) + ->all(); + + $props = $props->merge($parameters); + } + + // Then we need to get the public properties + + $properties = collect($reflection->getProperties()) + ->filter(fn (\ReflectionProperty $p): bool => + $p->isPublic() && $p->getDeclaringClass()->getName() === $reflection->getName() + ) + ->map(fn (\ReflectionProperty $p): array => [ + 'name' => \Illuminate\Support\Str::kebab($p->getName()), + 'type' => (string) ($p->getType() ?? 'mixed'), + 'hasDefault' => $p->hasDefaultValue(), + 'default' => $p->getDefaultValue() + ]) + ->all(); + + return $props + ->merge($properties) + ->unique('name') // Mount parameters always overwrite public properties + ->all(); + } +}; + +echo json_encode($components->all()); diff --git a/src/features/livewireComponent.ts b/src/features/livewireComponent.ts index f05dcf6b..eddac9ad 100644 --- a/src/features/livewireComponent.ts +++ b/src/features/livewireComponent.ts @@ -1,8 +1,10 @@ +import { getLivewireComponents } from "@src/repositories/livewireComponents"; import { getViews } from "@src/repositories/views"; import { config } from "@src/support/config"; import { projectPath } from "@src/support/project"; +import { defaultToString } from "@src/support/util"; import * as vscode from "vscode"; -import { LinkProvider } from ".."; +import { HoverProvider, LinkProvider } from ".."; export const linkProvider: LinkProvider = (doc: vscode.TextDocument) => { const links: vscode.DocumentLink[] = []; @@ -69,3 +71,44 @@ export const completionProvider: vscode.CompletionItemProvider = { ); }, }; + +export const hoverProvider: HoverProvider = ( + doc: vscode.TextDocument, + pos: vscode.Position, +): vscode.ProviderResult => { + const components = getLivewireComponents().items; + const regex = new RegExp(/]+)/); + + const linkRange = doc.getWordRangeAtPosition(pos, regex); + + if (!linkRange) { + return null; + } + + const match = doc + .getText(linkRange) + .replace("<", "") + .replace("livewire:", ""); + + const component = components.components[match]; + + if (!component) { + return null; + } + + const lines = component.paths.map( + (path) => `[${path}](${vscode.Uri.file(projectPath(path))})`, + ); + + lines.push( + ...component.props.map((prop) => + [ + "`" + prop.type + "` ", + "`" + prop.name + "`", + prop.hasDefault ? ` = ${defaultToString(prop.default)}` : "", + ].join(""), + ), + ); + + return new vscode.Hover(new vscode.MarkdownString(lines.join("\n\n"))); +}; diff --git a/src/hover/HoverProvider.ts b/src/hover/HoverProvider.ts index 401a3c6f..25ded547 100644 --- a/src/hover/HoverProvider.ts +++ b/src/hover/HoverProvider.ts @@ -6,6 +6,7 @@ import { hoverProvider as bladeComponent } from "@src/features/bladeComponent"; import { hoverProvider as config } from "@src/features/config"; import { hoverProvider as env } from "@src/features/env"; import { hoverProvider as inertia } from "@src/features/inertia"; +import { hoverProvider as livewireComponent } from "@src/features/livewireComponent"; import { hoverProvider as middleware } from "@src/features/middleware"; import { hoverProvider as mix } from "@src/features/mix"; import { hoverProvider as route } from "@src/features/route"; @@ -34,6 +35,7 @@ const allProviders: Partial> = { "translation.hover": translation, "view.hover": view, "bladeComponent.hover": bladeComponent, + "livewireComponent.hover": livewireComponent, }; export const hoverProviders: HoverProvider[] = Object.entries(allProviders).map( diff --git a/src/repositories/configs.ts b/src/repositories/configs.ts index cea56b83..69c2c426 100644 --- a/src/repositories/configs.ts +++ b/src/repositories/configs.ts @@ -2,6 +2,10 @@ import { repository } from "."; import { Config } from ".."; import { runInLaravel, template } from "../support/php"; +export const getConfigByName = (name: string): Config | undefined => { + return getConfigs().items.find((item) => item.name === name); +}; + export const getConfigs = repository({ load: () => { return runInLaravel(template("configs"), "Configs").then( diff --git a/src/repositories/livewireComponents.ts b/src/repositories/livewireComponents.ts new file mode 100644 index 00000000..7e57f1a0 --- /dev/null +++ b/src/repositories/livewireComponents.ts @@ -0,0 +1,50 @@ +import { runInLaravel, template } from "@src/support/php"; +import { relativePath } from "@src/support/project"; +import { lcfirst } from "@src/support/str"; +import { waitForValue } from "@src/support/util"; +import { repository } from "."; +import { getConfigByName, getConfigs } from "./configs"; + +let livewirePaths: string[] | null = null; + +export interface LivewireComponents { + components: { + [key: string]: { + paths: string[]; + isVendor: boolean; + props: { + name: string; + type: string; + hasDefault: boolean; + default: string | null; + }[]; + }; + }; +} + +const load = () => { + getConfigs().whenLoaded(() => { + livewirePaths = [ + lcfirst(getConfigByName('livewire.class_namespace')?.value?.replace(/\\/g, '/') ?? 'app/Livewire'), + relativePath(getConfigByName('livewire.view_path')?.value ?? 'resources/views/livewire') + ]; + }); + + return runInLaravel(template("livewireComponents")); +}; + +export const getLivewireComponents = repository({ + load, + pattern: () => + waitForValue(() => livewirePaths).then((paths) => { + if (paths === null || paths.length === 0) { + return null; + } + + return paths.map(path => path + "/{*,**/*}"); + }), + itemsDefault: { + components: {}, + }, + fileWatcherEvents: ["create", "delete"], +}); diff --git a/src/support/generated-config.ts b/src/support/generated-config.ts index f5758a9d..dd8b1d09 100644 --- a/src/support/generated-config.ts +++ b/src/support/generated-config.ts @@ -1 +1 @@ -export type GeneratedConfigKey = 'appBinding.diagnostics' | 'appBinding.hover' | 'appBinding.link' | 'appBinding.completion' | 'asset.diagnostics' | 'asset.hover' | 'asset.link' | 'asset.completion' | 'auth.diagnostics' | 'auth.hover' | 'auth.link' | 'auth.completion' | 'bladeComponent.link' | 'bladeComponent.completion' | 'bladeComponent.hover' | 'config.diagnostics' | 'config.hover' | 'config.link' | 'config.completion' | 'controllerAction.diagnostics' | 'controllerAction.hover' | 'controllerAction.link' | 'controllerAction.completion' | 'env.diagnostics' | 'env.hover' | 'env.link' | 'env.completion' | 'inertia.diagnostics' | 'inertia.hover' | 'inertia.link' | 'inertia.completion' | 'livewireComponent.link' | 'livewireComponent.completion' | 'middleware.diagnostics' | 'middleware.hover' | 'middleware.link' | 'middleware.completion' | 'mix.diagnostics' | 'mix.hover' | 'mix.link' | 'mix.completion' | 'paths.link' | 'route.diagnostics' | 'route.hover' | 'route.link' | 'route.completion' | 'storage.link' | 'storage.completion' | 'storage.diagnostics' | 'translation.diagnostics' | 'translation.hover' | 'translation.link' | 'translation.completion' | 'view.diagnostics' | 'view.hover' | 'view.link' | 'view.completion'; +export type GeneratedConfigKey = 'appBinding.diagnostics' | 'appBinding.hover' | 'appBinding.link' | 'appBinding.completion' | 'asset.diagnostics' | 'asset.hover' | 'asset.link' | 'asset.completion' | 'auth.diagnostics' | 'auth.hover' | 'auth.link' | 'auth.completion' | 'bladeComponent.link' | 'bladeComponent.completion' | 'bladeComponent.hover' | 'config.diagnostics' | 'config.hover' | 'config.link' | 'config.completion' | 'controllerAction.diagnostics' | 'controllerAction.hover' | 'controllerAction.link' | 'controllerAction.completion' | 'env.diagnostics' | 'env.hover' | 'env.link' | 'env.completion' | 'inertia.diagnostics' | 'inertia.hover' | 'inertia.link' | 'inertia.completion' | 'livewireComponent.link' | 'livewireComponent.completion' | 'livewireComponent.hover' | 'middleware.diagnostics' | 'middleware.hover' | 'middleware.link' | 'middleware.completion' | 'mix.diagnostics' | 'mix.hover' | 'mix.link' | 'mix.completion' | 'paths.link' | 'route.diagnostics' | 'route.hover' | 'route.link' | 'route.completion' | 'storage.link' | 'storage.completion' | 'storage.diagnostics' | 'translation.diagnostics' | 'translation.hover' | 'translation.link' | 'translation.completion' | 'view.diagnostics' | 'view.hover' | 'view.link' | 'view.completion'; diff --git a/src/support/str.ts b/src/support/str.ts new file mode 100644 index 00000000..ac64063e --- /dev/null +++ b/src/support/str.ts @@ -0,0 +1,3 @@ +export const lcfirst = (str: string): string => { + return str.charAt(0).toLowerCase() + str.slice(1); +}; \ No newline at end of file diff --git a/src/support/util.ts b/src/support/util.ts index 72325cc3..94dd7252 100644 --- a/src/support/util.ts +++ b/src/support/util.ts @@ -146,3 +146,24 @@ export const createIndexMapping = ( }, }; }; + +export const defaultToString = (value: any): string => { + switch (typeof value) { + case "object": + if (value === null) { + return "null"; + } + + const json: string = JSON.stringify(value, null, 2); + + if (json.length > 1000) { + return "object"; + } + + return json; + case "function": + return "function"; + default: + return value.toString(); + } +}; \ No newline at end of file diff --git a/src/templates/index.ts b/src/templates/index.ts index 4b0572f3..40617165 100644 --- a/src/templates/index.ts +++ b/src/templates/index.ts @@ -5,6 +5,7 @@ import bladeDirectives from "./blade-directives"; import bootstrapLaravel from "./bootstrap-laravel"; import configs from "./configs"; import inertia from "./inertia"; +import livewireComponents from "./livewire-components"; import middleware from "./middleware"; import models from "./models"; import routes from "./routes"; @@ -14,6 +15,7 @@ import views from "./views"; const templates = { app, auth, + livewireComponents, bladeComponents, bladeDirectives, bootstrapLaravel, diff --git a/src/templates/livewire-components.ts b/src/templates/livewire-components.ts new file mode 100644 index 00000000..f708577b --- /dev/null +++ b/src/templates/livewire-components.ts @@ -0,0 +1,223 @@ +// This file was generated from php-templates/livewire-components.php, do not edit directly +export default ` +$components = new class { + public function all(): array + { + $components = collect(array_merge( + $this->getStandardClasses(), + $this->getStandardViews() + ))->groupBy('key')->map(fn (\\Illuminate\\Support\\Collection $items) => [ + 'isVendor' => $items->first()['isVendor'], + 'paths' => $items->pluck('path')->values(), + 'props' => $items->pluck('props')->values()->filter()->flatMap(fn ($i) => $i), + ]); + + return [ + 'components' => $components + ]; + } + + /** + * @return array + */ + protected function findFiles(string $path, string $extension, \\Closure $keyCallback): array + { + if (! is_dir($path)) { + return []; + } + + $files = \\Symfony\\Component\\Finder\\Finder::create() + ->files() + ->name("*." . $extension) + ->in($path); + $components = []; + $pathRealPath = realpath($path); + + foreach ($files as $file) { + $realPath = $file->getRealPath(); + + $key = str($realPath) + ->replace($pathRealPath, '') + ->ltrim('/\\\\') + ->replace('.' . $extension, '') + ->replace(['/', '\\\\'], '.') + ->pipe(fn (string $str): string => $str); + + $components[] = [ + "path" => LaravelVsCode::relativePath($realPath), + "isVendor" => LaravelVsCode::isVendor($realPath), + "key" => $keyCallback ? $keyCallback($key) : $key, + ]; + } + + return $components; + } + + protected function getStandardClasses(): array + { + /** @var string|null $classNamespace */ + $classNamespace = config('livewire.class_namespace'); + + if (! $classNamespace) { + return []; + } + + $path = str($classNamespace) + ->replace('\\\\', DIRECTORY_SEPARATOR) + ->lcfirst() + ->toString(); + + $items = $this->findFiles( + $path, + 'php', + fn (\\Illuminate\\Support\\Stringable $key): string => $key->explode('.') + ->map(fn (string $p): string => \\Illuminate\\Support\\Str::kebab($p)) + ->implode('.'), + ); + + return collect($items) + ->map(function ($item) { + $class = str($item['path']) + ->replace('.php', '') + ->replace(DIRECTORY_SEPARATOR, '\\\\') + ->ucfirst() + ->toString(); + + if (! class_exists($class)) { + return null; + } + + $reflection = new \\ReflectionClass($class); + + if (! $reflection->isSubclassOf('Livewire\\Component')) { + return null; + } + + return [ + ...$item, + 'props' => $this->getComponentProps($reflection), + ]; + }) + ->filter() + ->values() + ->all(); + } + + protected function getStandardViews(): array + { + /** @var string|null $viewPath */ + $path = config('livewire.view_path'); + + if (! $path) { + return []; + } + + $items = $this->findFiles( + $path, + 'blade.php', + fn (\\Illuminate\\Support\\Stringable $key): string => $key->explode('.') + ->map(fn(string $p): string => \\Illuminate\\Support\\Str::kebab($p)) + ->implode('.'), + ); + + $previousClass = null; + + return collect($items) + ->map(function ($item) use (&$previousClass) { + // This is ugly, I know, but I don't have better idea how to get + // anonymous classes from Volt components + ob_start(); + + try { + require_once $item['path']; + } catch (\\Throwable $e) { + return $item; + } + + ob_clean(); + + $declaredClasses = get_declared_classes(); + $class = end($declaredClasses); + + if ($previousClass === $class) { + return $item; + } + + $previousClass = $class; + + if (! \\Illuminate\\Support\\Str::contains($class, '@anonymous')) { + return $item; + } + + $reflection = new \\ReflectionClass($class); + + if (! $reflection->isSubclassOf('Livewire\\Volt\\Component')) { + return $item; + } + + return [ + ...$item, + 'props' => $this->getComponentProps($reflection), + ]; + }) + ->all(); + } + + /** + * @return array + */ + protected function getComponentProps(ReflectionClass $reflection): array + { + $props = collect(); + + // Firstly we need to get the mount method parameters. Remember that + // Livewire components can have multiple mount methods in traits. + + $methods = $reflection->getMethods(); + + $mountMethods = array_filter( + $methods, + fn (\\ReflectionMethod $method): bool => + \\Illuminate\\Support\\Str::startsWith($method->getName(), 'mount') + ); + + foreach ($mountMethods as $method) { + $parameters = $method->getParameters(); + + $parameters = collect($parameters) + ->map(fn (\\ReflectionParameter $p): array => [ + 'name' => \\Illuminate\\Support\\Str::kebab($p->getName()), + 'type' => (string) ($p->getType() ?? 'mixed'), + // We need to add hasDefault, because null can be also a default value, + // it can't be a flag of no default + 'hasDefault' => $p->isDefaultValueAvailable(), + 'default' => $p->isOptional() ? $p->getDefaultValue() : null + ]) + ->all(); + + $props = $props->merge($parameters); + } + + // Then we need to get the public properties + + $properties = collect($reflection->getProperties()) + ->filter(fn (\\ReflectionProperty $p): bool => + $p->isPublic() && $p->getDeclaringClass()->getName() === $reflection->getName() + ) + ->map(fn (\\ReflectionProperty $p): array => [ + 'name' => \\Illuminate\\Support\\Str::kebab($p->getName()), + 'type' => (string) ($p->getType() ?? 'mixed'), + 'hasDefault' => $p->hasDefaultValue(), + 'default' => $p->getDefaultValue() + ]) + ->all(); + + return $props + ->merge($properties) + ->unique('name') // Mount parameters always overwrite public properties + ->all(); + } +}; + +echo json_encode($components->all()); +`; \ No newline at end of file