diff --git a/generatable.json b/generatable.json index fe1a9b6f..3f55ff96 100644 --- a/generatable.json +++ b/generatable.json @@ -50,6 +50,7 @@ "features": [ "link", "completion", + "completion_attribute", "hover" ] }, @@ -58,7 +59,8 @@ "label": "Livewire components", "features": [ "link", - "completion" + "completion", + "completion_attribute" ] }, { diff --git a/generate-config.php b/generate-config.php index b87ce7cf..073e05df 100644 --- a/generate-config.php +++ b/generate-config.php @@ -24,6 +24,7 @@ 'hover' => "Enable hover information for {$label}.", 'link' => "Enable linking for {$label}.", 'completion' => "Enable completion for {$label}.", + 'completion_attribute' => "Enable completion for {$label} attributes.", default => null, }, ]; diff --git a/package.json b/package.json index 5a57eadd..4e9dfc05 100644 --- a/package.json +++ b/package.json @@ -212,6 +212,12 @@ "generated": true, "description": "Enable completion for Blade components." }, + "Laravel.bladeComponent.completion_attribute": { + "type": "boolean", + "default": true, + "generated": true, + "description": "Enable completion for Blade components attributes." + }, "Laravel.bladeComponent.hover": { "type": "boolean", "default": true, @@ -326,6 +332,12 @@ "generated": true, "description": "Enable completion for Livewire components." }, + "Laravel.livewireComponent.completion_attribute": { + "type": "boolean", + "default": true, + "generated": true, + "description": "Enable completion for Livewire components attributes." + }, "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/extension.ts b/src/extension.ts index 0cb41b68..28de8613 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -58,8 +58,14 @@ export async function activate(context: vscode.ExtensionContext) { { Eloquent: EloquentCompletion }, { Validation: ValidationCompletion }, { Blade: BladeCompletion }, - { completionProvider: bladeComponentCompletion }, - { completionProvider: livewireComponentCompletion }, + { + completionComponentProvider: bladeComponentCompletion, + completionAttributeProvider: bladeComponentAttributeCompletion + }, + { + completionComponentProvider: livewireComponentCompletion, + completionAttributeProvider: livewireComponentAttributeCompletion + }, { CodeActionProvider }, { updateDiagnostics }, { viteEnvCodeActionProvider }, @@ -151,11 +157,21 @@ export async function activate(context: vscode.ExtensionContext) { "x", "-", ), + vscode.languages.registerCompletionItemProvider( + BLADE_LANGUAGES, + bladeComponentAttributeCompletion, + ":", + ), vscode.languages.registerCompletionItemProvider( BLADE_LANGUAGES, livewireComponentCompletion, ":", ), + vscode.languages.registerCompletionItemProvider( + BLADE_LANGUAGES, + livewireComponentAttributeCompletion, + ":", + ), vscode.languages.registerCompletionItemProvider( BLADE_LANGUAGES, new BladeCompletion(), diff --git a/src/features/bladeComponent.ts b/src/features/bladeComponent.ts index cefe279a..573ab56e 100644 --- a/src/features/bladeComponent.ts +++ b/src/features/bladeComponent.ts @@ -53,7 +53,56 @@ export const linkProvider: LinkProvider = (doc: vscode.TextDocument) => { return Promise.resolve(links); }; -export const completionProvider: vscode.CompletionItemProvider = { +export const completionAttributeProvider: vscode.CompletionItemProvider = { + provideCompletionItems( + doc: vscode.TextDocument, + pos: vscode.Position, + ): vscode.ProviderResult { + if (!config("bladeComponent.completion_attribute", true)) { + return undefined; + } + + const components = getBladeComponents().items; + const text = doc.getText(new vscode.Range(new vscode.Position(0, 0), pos)); + + const regexes = [new RegExp(/]+)[^<]*:$/)]; + + if (components.prefixes.length > 0) { + regexes.push( + new RegExp(`<((${components.prefixes.join("|")})\\:[^\\s>]+)[^<]*:$`), + ); + } + + for (const regex of regexes) { + const match = text.match(regex); + + if (!match || match.index === undefined) { + continue; + } + + const component = components.components[match[1]]; + + if (!component) { + return undefined; + } + + return Object.entries(component.props).map(([, value]) => { + let completeItem = new vscode.CompletionItem( + value.name, + vscode.CompletionItemKind.Property, + ); + + completeItem.detail = value.type; + + return completeItem; + }); + } + + return undefined; + } +}; + +export const completionComponentProvider: vscode.CompletionItemProvider = { provideCompletionItems( doc: vscode.TextDocument, pos: vscode.Position, diff --git a/src/features/livewireComponent.ts b/src/features/livewireComponent.ts index f05dcf6b..03d0ce61 100644 --- a/src/features/livewireComponent.ts +++ b/src/features/livewireComponent.ts @@ -1,3 +1,4 @@ +import { getLivewireComponents } from "@src/repositories/livewireComponents"; import { getViews } from "@src/repositories/views"; import { config } from "@src/support/config"; import { projectPath } from "@src/support/project"; @@ -40,7 +41,46 @@ export const linkProvider: LinkProvider = (doc: vscode.TextDocument) => { return Promise.resolve(links); }; -export const completionProvider: vscode.CompletionItemProvider = { +export const completionAttributeProvider: vscode.CompletionItemProvider = { + provideCompletionItems( + doc: vscode.TextDocument, + pos: vscode.Position, + ): vscode.ProviderResult { + if (!config("livewireComponent.completion_attribute", true)) { + return undefined; + } + + const components = getLivewireComponents().items; + const text = doc.getText(new vscode.Range(new vscode.Position(0, 0), pos)); + + const regex = new RegExp(/]+)[^<]*:$/); + + const match = text.match(regex); + + if (!match || match.index === undefined) { + return undefined; + } + + const component = components.components[match[1]]; + + if (!component) { + return undefined; + } + + return Object.entries(component.props).map(([, value]) => { + let completeItem = new vscode.CompletionItem( + value.name, + vscode.CompletionItemKind.Property, + ); + + completeItem.detail = value.type; + + return completeItem; + }); + } +}; + +export const completionComponentProvider: vscode.CompletionItemProvider = { provideCompletionItems( doc: vscode.TextDocument, pos: vscode.Position, 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..115d5fba 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.completion_attribute' | '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.completion_attribute' | '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/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