diff --git a/generatable.json b/generatable.json index fe1a9b6..f548e34 100644 --- a/generatable.json +++ b/generatable.json @@ -68,5 +68,12 @@ "completion", "diagnostics" ] + }, + { + "type": "model", + "features": [ + "completion_attribute", + "completion" + ] } ] diff --git a/generate-config.php b/generate-config.php index b87ce7c..073e05d 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 d021e5d..727145c 100644 --- a/package.json +++ b/package.json @@ -374,6 +374,18 @@ "generated": true, "description": "Enable completion for mix." }, + "Laravel.model.completion_attribute": { + "type": "boolean", + "default": true, + "generated": true, + "description": "Enable completion for model attributes." + }, + "Laravel.model.completion": { + "type": "boolean", + "default": true, + "generated": true, + "description": "Enable completion for model." + }, "Laravel.paths.link": { "type": "boolean", "default": true, diff --git a/php-templates/models.php b/php-templates/models.php index 3946d40..fdd8865 100644 --- a/php-templates/models.php +++ b/php-templates/models.php @@ -144,11 +144,20 @@ protected function getInfo($className) $data["extends"] = $this->getParentClass($reflection); + $name = str($className)->afterLast('\\'); + + $data['name_cases'] = array_merge(...array_map( + null, + $this->getNameCases($name), + $this->getNameCases($name->plural()) + )); + $existingProperties = $this->collectExistingProperties($reflection); $data['attributes'] = collect($data['attributes']) ->map(fn($attrs) => array_merge($attrs, [ 'title_case' => str($attrs['name'])->title()->replace('_', '')->toString(), + 'name_cases' => $this->getNameCases(str($attrs['name'])), 'documented' => $existingProperties->contains($attrs['name']), 'cast' => $this->getCastReturnType($attrs['cast']) ])) @@ -166,6 +175,20 @@ protected function getInfo($className) $className => $data, ]; } + + /** + * @return array + */ + private function getNameCases(\Illuminate\Support\Stringable $name): array + { + return collect([ + $name->camel()->toString(), + $name->toString(), + $name->snake()->toString(), + $name->studly()->toString(), + $name->studly()->lower()->toString(), + ])->unique()->values()->toArray(); + } }; $builder = new class($docblocks) { diff --git a/src/completion/Registry.ts b/src/completion/Registry.ts index aa42a9a..9a0b680 100644 --- a/src/completion/Registry.ts +++ b/src/completion/Registry.ts @@ -82,6 +82,18 @@ export default class Registry implements vscode.CompletionItemProvider { ); }; + const hasName = (names: FeatureTagParam["name"]) => { + if (typeof names === "undefined" || names === null) { + return parseResult.name() === null; + } + + if (typeof names === "string") { + return names === parseResult.name(); + } + + return names.find((fn) => fn === parseResult.name()) !== undefined; + }; + const isArgumentIndex = ( argumentIndex: number | number[] | undefined, ) => { @@ -117,6 +129,7 @@ export default class Registry implements vscode.CompletionItemProvider { (tag) => hasClass(tag.class) && hasFunc(tag.method) && + hasName(tag.name) && isArgumentIndex(tag.argumentIndex) && isNamedArg(tag.argumentName), ); diff --git a/src/extension.ts b/src/extension.ts index 9be7f98..60ff058 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -17,6 +17,7 @@ import { updateDiagnostics } from "./diagnostic/diagnostic"; import { completionProvider as bladeComponentCompletion } from "./features/bladeComponent"; import { viteEnvCodeActionProvider } from "./features/env"; import { completionProvider as livewireComponentCompletion } from "./features/livewireComponent"; +import { completionAttributeProvider, completionModelProvider } from "./features/model"; import { hoverProviders } from "./hover/HoverProvider"; import { linkProviders } from "./link/LinkProvider"; import { configAffected } from "./support/config"; @@ -113,6 +114,16 @@ export function activate(context: vscode.ExtensionContext) { // documentSelector, // new BladeFormattingEditProvider(), // ), + vscode.languages.registerCompletionItemProvider( + BLADE_LANGUAGES, + new Registry(completionAttributeProvider), + ">", + ), + vscode.languages.registerCompletionItemProvider( + BLADE_LANGUAGES, + completionModelProvider, + "$", + ), vscode.languages.registerCompletionItemProvider( LANGUAGES, delegatedRegistry, diff --git a/src/features/model.ts b/src/features/model.ts new file mode 100644 index 0000000..57175aa --- /dev/null +++ b/src/features/model.ts @@ -0,0 +1,74 @@ +import AutocompleteResult from "@src/parser/AutocompleteResult"; +import { getModelByName, getModels } from "@src/repositories/models"; +import { config } from "@src/support/config"; +import * as vscode from "vscode"; +import { + CompletionProvider, + Eloquent, + FeatureTag, +} from ".."; + +export const completionModelProvider: vscode.CompletionItemProvider = { + provideCompletionItems(): vscode.ProviderResult { + if (!config("model.completion", true)) { + return undefined; + } + + return Object.entries(getModels().items).flatMap(([, value]) => { + return value.name_cases.slice(0, 2).map((name) => { + return new vscode.CompletionItem(name, vscode.CompletionItemKind.Variable); + }); + }); + }, +}; + +export const completionAttributeProvider: CompletionProvider = { + tags() { + return Object.values(getModels().items).flatMap(model => { + return [ + { + method: [...model.name_cases], + }, + { + name: [...model.name_cases] + } + ]; + }).filter(item => item !== null) as FeatureTag; + }, + + provideCompletionItems( + result: AutocompleteResult, + ): vscode.CompletionItem[] { + if (!config("model.completion_attribute", true)) { + return []; + } + + const name = result.name() ?? result.func(); + + if (!name) { + return []; + } + + const model = getModelByName(name); + + if (!model) { + return []; + } + + const createCompleteItem = (item: Eloquent.Attribute | Eloquent.Relation) => { + let completeItem = new vscode.CompletionItem( + item.name, + vscode.CompletionItemKind.Property, + ); + + if (item.type) { + completeItem.detail = item.type; + } + + return completeItem; + }; + + return model.attributes.map(createCompleteItem) + .concat(model.relations.map(createCompleteItem)); + }, +}; \ No newline at end of file diff --git a/src/index.d.ts b/src/index.d.ts index dbc537e..8b2b7f5 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -48,6 +48,7 @@ type LinkProvider = ( interface FeatureTagParam { class?: string | string[] | null; method?: string | string[] | null; + name?: string | string[] | null; argumentName?: string | string[]; classDefinition?: string; methodDefinition?: string; @@ -83,6 +84,7 @@ declare namespace Eloquent { observers: Observer[]; scopes: string[]; extends: string | null; + name_cases: string[]; } interface Attribute { @@ -97,6 +99,7 @@ declare namespace Eloquent { appended: null; cast: string | null; title_case: string; + name_cases: string[]; documented: boolean; } diff --git a/src/parser/AutocompleteResult.ts b/src/parser/AutocompleteResult.ts index d7c251b..7d5b3be 100644 --- a/src/parser/AutocompleteResult.ts +++ b/src/parser/AutocompleteResult.ts @@ -40,6 +40,19 @@ export default class AutocompleteResult { return this.param()?.autocompletingValue ?? false; } + public name() { + // @ts-ignore + return this.result.name ?? null; + } + + public isName(name: string | string[]) { + if (Array.isArray(name)) { + return name.includes(this.name()); + } + + return this.name() === name; + } + public class() { // @ts-ignore return this.result.className ?? null; diff --git a/src/repositories/models.ts b/src/repositories/models.ts index 3d045a3..411618c 100644 --- a/src/repositories/models.ts +++ b/src/repositories/models.ts @@ -22,6 +22,14 @@ const load = () => { }); }; +export const getModelByName = (name: string): Eloquent.Model | undefined => { + const model = Object.entries(getModels().items).find(([, value]) => { + return value.name_cases.includes(name); + }); + + return model?.[1]; +}; + export const getModels = repository({ load, pattern: modelPaths diff --git a/src/support/generated-config.ts b/src/support/generated-config.ts index f5758a9..580ad31 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' | 'middleware.diagnostics' | 'middleware.hover' | 'middleware.link' | 'middleware.completion' | 'mix.diagnostics' | 'mix.hover' | 'mix.link' | 'mix.completion' | 'model.completion_attribute' | 'model.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/templates/models.ts b/src/templates/models.ts index 158b495..2d97992 100644 --- a/src/templates/models.ts +++ b/src/templates/models.ts @@ -144,11 +144,20 @@ $models = new class($factory) { $data["extends"] = $this->getParentClass($reflection); + $name = str($className)->afterLast('\\\\'); + + $data['name_cases'] = array_merge(...array_map( + null, + $this->getNameCases($name), + $this->getNameCases($name->plural()) + )); + $existingProperties = $this->collectExistingProperties($reflection); $data['attributes'] = collect($data['attributes']) ->map(fn($attrs) => array_merge($attrs, [ 'title_case' => str($attrs['name'])->title()->replace('_', '')->toString(), + 'name_cases' => $this->getNameCases(str($attrs['name'])), 'documented' => $existingProperties->contains($attrs['name']), 'cast' => $this->getCastReturnType($attrs['cast']) ])) @@ -166,6 +175,20 @@ $models = new class($factory) { $className => $data, ]; } + + /** + * @return array + */ + private function getNameCases(\\Illuminate\\Support\\Stringable $name): array + { + return collect([ + $name->camel()->toString(), + $name->toString(), + $name->snake()->toString(), + $name->studly()->toString(), + $name->studly()->lower()->toString(), + ])->unique()->values()->toArray(); + } }; $builder = new class($docblocks) { diff --git a/src/types.ts b/src/types.ts index a4419ba..c38f4ba 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,7 +15,8 @@ export namespace AutocompleteParsingResult { | Parameter | ParameterValue | Parameters - | StringValue; + | StringValue + | Variable; export interface Argument { type: "argument"; @@ -143,4 +144,10 @@ export namespace AutocompleteParsingResult { column: number; }; } -} + + export interface Variable { + type: "variable"; + parent: ContextValue | null; + name: string | null; + } +} \ No newline at end of file