Skip to content

Support for autocompletion component attributes in blade files #366

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 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion generatable.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"features": [
"link",
"completion",
"completion_attribute",
"hover"
]
},
Expand All @@ -58,7 +59,8 @@
"label": "Livewire components",
"features": [
"link",
"completion"
"completion",
"completion_attribute"
]
},
{
Expand Down
1 change: 1 addition & 0 deletions generate-config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
];
Expand Down
12 changes: 12 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
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());
20 changes: 18 additions & 2 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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(),
Expand Down
51 changes: 50 additions & 1 deletion src/features/bladeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<vscode.CompletionItem[]> {
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(/<x-([^\s>]+)[^<]*:$/)];

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,
Expand Down
Loading