Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | // src/indexer/PhpAttributeIndexer.ts // Recognises PHP 8 native attributes used by Laravel and indexes their semantics import * as vscode from 'vscode'; import type { CacheManager } from '../cache/CacheManager'; import type { ModelDefinition, ScopeDefinition } from '../types'; // ─── Known Laravel Attributes ───────────────────────────────────────────── export const LARAVEL_ATTRIBUTES: Array<{ name: string; fqn: string; doc: string; appliesTo: string }> = [ // Eloquent { name: 'Scope', fqn: 'Illuminate\\Database\\Eloquent\\Attributes\\Scope', doc: 'Marks a method as an Eloquent query scope (replaces scopeXxx prefix)', appliesTo: 'method' }, { name: 'Computed', fqn: 'Illuminate\\Database\\Eloquent\\Attributes\\Computed', doc: 'Marks a method as a computed Eloquent attribute (cached)', appliesTo: 'method' }, { name: 'Locked', fqn: 'Illuminate\\Database\\Eloquent\\Attributes\\Locked', doc: 'Prevents a property from being mass-assigned or mutated', appliesTo: 'property' }, { name: 'ObservedBy', fqn: 'Illuminate\\Database\\Eloquent\\Attributes\\ObservedBy', doc: 'Binds an Observer class to this model', appliesTo: 'class' }, { name: 'ScopedBy', fqn: 'Illuminate\\Database\\Eloquent\\Attributes\\ScopedBy', doc: 'Applies global scopes to this model', appliesTo: 'class' }, { name: 'UseEloquentBuilder', fqn: 'Illuminate\\Database\\Eloquent\\Attributes\\UseEloquentBuilder', doc: 'Declares a custom Eloquent Builder class for this model', appliesTo: 'class' }, { name: 'UseFactory', fqn: 'Illuminate\\Database\\Eloquent\\Attributes\\UseFactory', doc: 'Binds a specific Factory class to this model', appliesTo: 'class' }, { name: 'UsePolicy', fqn: 'Illuminate\\Database\\Eloquent\\Attributes\\UsePolicy', doc: 'Binds a specific Policy class to this model', appliesTo: 'class' }, // Validation { name: 'Rule', fqn: 'Illuminate\\Validation\\Attributes\\Rule', doc: 'Apply a validation rule directly as an attribute', appliesTo: 'property' }, // Livewire { name: 'On', fqn: 'Livewire\\Attributes\\On', doc: 'Listen for a Livewire/Alpine event', appliesTo: 'method' }, { name: 'Validate', fqn: 'Livewire\\Attributes\\Validate', doc: 'Add validation rules to a Livewire property', appliesTo: 'property' }, { name: 'Session', fqn: 'Livewire\\Attributes\\Session', doc: 'Persist a Livewire property in the session', appliesTo: 'property' }, { name: 'Cookie', fqn: 'Livewire\\Attributes\\Cookie', doc: 'Persist a Livewire property in a cookie', appliesTo: 'property' }, { name: 'Url', fqn: 'Livewire\\Attributes\\Url', doc: 'Bind a Livewire property to the URL query string', appliesTo: 'property' }, { name: 'Layout', fqn: 'Livewire\\Attributes\\Layout', doc: 'Set the layout for a full-page Livewire component', appliesTo: 'class' }, { name: 'Title', fqn: 'Livewire\\Attributes\\Title', doc: 'Set the page title for a full-page Livewire component', appliesTo: 'method' }, { name: 'Lazy', fqn: 'Livewire\\Attributes\\Lazy', doc: 'Load the component lazily', appliesTo: 'class' }, { name: 'Isolate', fqn: 'Livewire\\Attributes\\Isolate', doc: 'Isolate network request batching for this component', appliesTo: 'class' }, { name: 'Renderless', fqn: 'Livewire\\Attributes\\Renderless', doc: 'Skip rendering for this action method', appliesTo: 'method' }, { name: 'Locked', fqn: 'Livewire\\Attributes\\Locked', doc: 'Prevent client from modifying this property', appliesTo: 'property' }, // Pennant { name: 'Feature', fqn: 'Laravel\\Pennant\\Attribute\\Feature', doc: 'Declare a Pennant feature flag', appliesTo: 'class' }, // Sanctum { name: 'TokenAbility', fqn: 'Laravel\\Sanctum\\Attributes\\TokenAbility', doc: 'Require specific token abilities on a route', appliesTo: 'class' }, ]; // ─── Attribute Completion Provider ──────────────────────────────────────── export class PhpAttributeCompletionProvider implements vscode.CompletionItemProvider { constructor(private readonly cache: CacheManager) {} provideCompletionItems( document: vscode.TextDocument, position: vscode.Position ): vscode.CompletionItem[] { const lineText = document.lineAt(position).text; // Only trigger inside #[...] attribute syntax if (!/#\[\s*\w*$/.test(lineText.substring(0, position.character))) { return []; } const items: vscode.CompletionItem[] = []; // Built-in Laravel attributes for (const attr of LARAVEL_ATTRIBUTES) { const item = new vscode.CompletionItem(attr.name, vscode.CompletionItemKind.Interface); item.detail = attr.fqn; item.documentation = new vscode.MarkdownString( `**PHP Attribute:** \`#[${attr.name}]\`\n\n${attr.doc}\n\nApplies to: \`${attr.appliesTo}\`` ); item.insertText = new vscode.SnippetString(`${attr.name}($1)`); item.sortText = `0_${attr.name}`; items.push(item); } // Custom attributes from the project's AST const index = this.cache.getIndex(); for (const [, cls] of index.classes) { if (cls.attributes?.some((a) => a.name === 'Attribute')) { const item = new vscode.CompletionItem(cls.name, vscode.CompletionItemKind.Interface); item.detail = cls.fullyQualifiedName; item.documentation = new vscode.MarkdownString(`Custom attribute: \`${cls.fullyQualifiedName}\``); item.sortText = `1_${cls.name}`; items.push(item); } } return items; } } // ─── Attribute Hover Provider ───────────────────────────────────────────── export class PhpAttributeHoverProvider implements vscode.HoverProvider { provideHover( document: vscode.TextDocument, position: vscode.Position ): vscode.Hover | undefined { const lineText = document.lineAt(position).text; if (!lineText.trim().startsWith('#[')) { return undefined; } const wordRange = document.getWordRangeAtPosition(position, /\b[A-Z]\w+\b/); if (!wordRange) { return undefined; } const word = document.getText(wordRange); const attr = LARAVEL_ATTRIBUTES.find((a) => a.name === word); if (!attr) { return undefined; } const md = new vscode.MarkdownString(); md.isTrusted = true; md.appendMarkdown(`### \`#[${attr.name}]\`\n\n`); md.appendMarkdown(`${attr.doc}\n\n`); md.appendMarkdown(`**FQN:** \`${attr.fqn}\`\n\n`); md.appendMarkdown(`**Applies to:** \`${attr.appliesTo}\``); return new vscode.Hover(md, wordRange); } } // ─── Attribute-aware scope enrichment ──────────────────────────────────── /** * Called by StaticAstIndexer to augment models with #[Scope]-attributed methods. * In Laravel 12+, scopes no longer need the "scope" prefix — they use #[Scope] instead. */ export function extractAttributeScopes(source: string, model: ModelDefinition): ScopeDefinition[] { const scopes: ScopeDefinition[] = [...model.scopes]; // Match: #[Scope] followed by public function methodName const re = /#\[\\?Scope(?:\([^)]*\))?\]\s*(?:public\s+)?function\s+(\w+)\s*\(([^)]*)\)/g; let m: RegExpExecArray | null; while ((m = re.exec(source)) !== null) { const name = m[1]; // Skip if already in scopes (detected via scope prefix) if (scopes.some((s) => s.name === name)) { continue; } const paramStr = m[2] ?? ''; const params = paramStr .split(',') .map((p) => p.trim().replace(/.*\$/, '')) .filter(Boolean); scopes.push({ name, parameters: params }); } return scopes; } |