Skip to content

Support for hover provider for livewire components #352

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a85cf72
Add a repository for Livewire components
N1ebieski Mar 27, 2025
8263d64
Support for hoverProvider for Livewire components
N1ebieski Mar 27, 2025
9e40eec
fix extension for volt components
N1ebieski Mar 27, 2025
27077b5
Merge branch 'Add-a-repository-for-Livewire-components-#29' into Supp…
N1ebieski Mar 27, 2025
3dcb681
fix duplicate props and refactoring
N1ebieski Mar 27, 2025
e5ad2dd
Merge branch 'Add-a-repository-for-Livewire-components-#29' into Supp…
N1ebieski Mar 27, 2025
0d9fc18
fix
N1ebieski Mar 27, 2025
21a307f
Merge branch 'Add-a-repository-for-Livewire-components-#29' into Supp…
N1ebieski Mar 27, 2025
a8e0b2c
Support for hoverProvider for Livewire components
N1ebieski Mar 27, 2025
ccb10fc
fix paths
N1ebieski Mar 27, 2025
45535ac
refactoring
N1ebieski Mar 27, 2025
fd9f181
refactoring
N1ebieski Mar 28, 2025
d7e2fec
Merge branch 'Add-a-repository-for-Livewire-components-#29' into Supp…
N1ebieski Mar 28, 2025
5be4df4
replace default values to string
N1ebieski Mar 28, 2025
ced4cb6
add isOptional to method parameters
N1ebieski Mar 28, 2025
c9a4410
Merge branch 'Add-a-repository-for-Livewire-components-#29' into Supp…
N1ebieski Mar 28, 2025
28f626d
rename isOptional to hasDefault
N1ebieski Mar 28, 2025
830ef90
rename isOptional to hasDefault
N1ebieski Mar 28, 2025
533bedd
Merge branch 'Add-a-repository-for-Livewire-components-#29' into Supp…
N1ebieski Mar 28, 2025
07787d6
add hasDefault to props
N1ebieski Mar 28, 2025
0ef6524
fix docblock
N1ebieski Mar 28, 2025
ce6d18d
Merge branch 'Add-a-repository-for-Livewire-components-#29' into Supp…
N1ebieski Mar 28, 2025
574cdf7
remove project path
N1ebieski Mar 28, 2025
c566ee5
Add a repository for Livewire components
N1ebieski Mar 28, 2025
250b5e9
Merge branch 'Add-a-repository-for-Livewire-components-#29' into Supp…
N1ebieski Mar 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion generatable.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
"label": "Livewire components",
"features": [
"link",
"completion"
"completion",
"hover"
]
},
{
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
222 changes: 222 additions & 0 deletions php-templates/livewire-components.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
<?php

$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<int, array{path: string, isVendor: string, key: string}>
*/
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<int, array{name: string, type: string, hasDefault: bool, default: mixed}>
*/
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());
45 changes: 44 additions & 1 deletion src/features/livewireComponent.ts
Original file line number Diff line number Diff line change
@@ -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[] = [];
Expand Down Expand Up @@ -69,3 +71,44 @@ export const completionProvider: vscode.CompletionItemProvider = {
);
},
};

export const hoverProvider: HoverProvider = (
doc: vscode.TextDocument,
pos: vscode.Position,
): vscode.ProviderResult<vscode.Hover> => {
const components = getLivewireComponents().items;
const regex = new RegExp(/<livewire:([^\s>]+)/);

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")));
};
2 changes: 2 additions & 0 deletions src/hover/HoverProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -34,6 +35,7 @@ const allProviders: Partial<Record<GeneratedConfigKey, ProviderFunc>> = {
"translation.hover": translation,
"view.hover": view,
"bladeComponent.hover": bladeComponent,
"livewireComponent.hover": livewireComponent,
};

export const hoverProviders: HoverProvider[] = Object.entries(allProviders).map(
Expand Down
4 changes: 4 additions & 0 deletions src/repositories/configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Config[]>({
load: () => {
return runInLaravel<Config[]>(template("configs"), "Configs").then(
Expand Down
50 changes: 50 additions & 0 deletions src/repositories/livewireComponents.ts
Original file line number Diff line number Diff line change
@@ -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<LivewireComponents>(template("livewireComponents"));
};

export const getLivewireComponents = repository<LivewireComponents>({
load,
pattern: () =>
waitForValue(() => livewirePaths).then((paths) => {
if (paths === null || paths.length === 0) {
return null;
}

return paths.map(path => path + "/{*,**/*}");
}),
itemsDefault: {
components: {},
},
fileWatcherEvents: ["create", "delete"],
});
2 changes: 1 addition & 1 deletion src/support/generated-config.ts
Original file line number Diff line number Diff line change
@@ -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';
3 changes: 3 additions & 0 deletions src/support/str.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const lcfirst = (str: string): string => {
return str.charAt(0).toLowerCase() + str.slice(1);
};
Loading