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 16 commits into
base: main
Choose a base branch
from
Open
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
@@ -50,6 +50,7 @@
"features": [
"link",
"completion",
"completion_attribute",
"hover"
]
},
@@ -58,7 +59,8 @@
"label": "Livewire components",
"features": [
"link",
"completion"
"completion",
"completion_attribute"
]
},
{
1 change: 1 addition & 0 deletions generate-config.php
Original file line number Diff line number Diff line change
@@ -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,
},
];
12 changes: 12 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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,
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
@@ -14,9 +14,15 @@ import EloquentCompletion from "./completion/Eloquent";
import Registry from "./completion/Registry";
import ValidationCompletion from "./completion/Validation";
import { updateDiagnostics } from "./diagnostic/diagnostic";
import { completionProvider as bladeComponentCompletion } from "./features/bladeComponent";
import {
completionAttributeProvider as bladeComponentAttributeCompletion,
completionComponentProvider as bladeComponentCompletion
} from "./features/bladeComponent";
import { viteEnvCodeActionProvider } from "./features/env";
import { completionProvider as livewireComponentCompletion } from "./features/livewireComponent";
import {
completionAttributeProvider as livewireComponentAttributeCompletion,
completionComponentProvider as livewireComponentCompletion
} from "./features/livewireComponent";
import { hoverProviders } from "./hover/HoverProvider";
import { linkProviders } from "./link/LinkProvider";
import { configAffected } from "./support/config";
@@ -129,11 +135,21 @@ export 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(),
51 changes: 50 additions & 1 deletion src/features/bladeComponent.ts
Original file line number Diff line number Diff line change
@@ -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,
Loading