All files / src/providers LivewireEnhancedProvider.ts

0% Statements 0/139
0% Branches 0/68
0% Functions 0/12
0% Lines 0/128

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 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               
// src/providers/LivewireEnhancedProvider.ts
// Full Livewire 3 & 4 intelligence beyond the base LivewireProvider
import * as vscode from 'vscode';
import type { CacheManager } from '../cache/CacheManager';
import type { LivewireComponent } from '../types';
 
// ─── Livewire 3/4 wire:* attribute completions ────────────────────────────
 
const WIRE_DIRECTIVES = [
  // Two-way binding
  { attr: 'wire:model',         doc: 'Two-way data binding to a public property', snippet: 'wire:model="${1:property}"' },
  { attr: 'wire:model.live',    doc: 'Real-time binding on every keystroke', snippet: 'wire:model.live="${1:property}"' },
  { attr: 'wire:model.blur',    doc: 'Bind on blur event', snippet: 'wire:model.blur="${1:property}"' },
  { attr: 'wire:model.lazy',    doc: 'Bind on change (Livewire 2 compat)', snippet: 'wire:model.lazy="${1:property}"' },
  { attr: 'wire:model.debounce.300ms', doc: 'Debounced binding 300ms', snippet: 'wire:model.debounce.300ms="${1:property}"' },
  // Events
  { attr: 'wire:click',         doc: 'Call action on click', snippet: 'wire:click="${1:action}"' },
  { attr: 'wire:click.prevent', doc: 'Call action, prevent default', snippet: 'wire:click.prevent="${1:action}"' },
  { attr: 'wire:click.stop',    doc: 'Call action, stop propagation', snippet: 'wire:click.stop="${1:action}"' },
  { attr: 'wire:submit',        doc: 'Call action on form submit', snippet: 'wire:submit="${1:action}"' },
  { attr: 'wire:submit.prevent',doc: 'Submit with prevent default', snippet: 'wire:submit.prevent="${1:action}"' },
  { attr: 'wire:keydown',       doc: 'Call action on keydown', snippet: 'wire:keydown="${1:action}"' },
  { attr: 'wire:keydown.enter', doc: 'Call action on Enter key', snippet: 'wire:keydown.enter="${1:action}"' },
  { attr: 'wire:keydown.escape',doc: 'Call action on Escape key', snippet: 'wire:keydown.escape="${1:action}"' },
  { attr: 'wire:keyup',         doc: 'Call action on keyup', snippet: 'wire:keyup="${1:action}"' },
  { attr: 'wire:input',         doc: 'Call action on input', snippet: 'wire:input="${1:action}"' },
  { attr: 'wire:change',        doc: 'Call action on change', snippet: 'wire:change="${1:action}"' },
  { attr: 'wire:focus',         doc: 'Call action on focus', snippet: 'wire:focus="${1:action}"' },
  { attr: 'wire:blur',          doc: 'Call action on blur', snippet: 'wire:blur="${1:action}"' },
  { attr: 'wire:mouseenter',    doc: 'Call action on mouse enter', snippet: 'wire:mouseenter="${1:action}"' },
  // DOM/Lifecycle
  { attr: 'wire:init',          doc: 'Call action when component initializes', snippet: 'wire:init="${1:action}"' },
  { attr: 'wire:loading',       doc: 'Show element while loading', snippet: 'wire:loading' },
  { attr: 'wire:loading.remove',doc: 'Remove element while loading', snippet: 'wire:loading.remove' },
  { attr: 'wire:loading.class', doc: 'Add class while loading', snippet: 'wire:loading.class="${1:class}"' },
  { attr: 'wire:loading.attr',  doc: 'Add attribute while loading', snippet: 'wire:loading.attr="${1:disabled}"' },
  { attr: 'wire:loading.delay', doc: 'Show after delay to avoid flash', snippet: 'wire:loading.delay' },
  { attr: 'wire:loading.target',doc: 'Target specific action', snippet: 'wire:loading.target="${1:action}"' },
  { attr: 'wire:dirty',         doc: 'Apply when model value has changed', snippet: 'wire:dirty' },
  { attr: 'wire:dirty.class',   doc: 'Add class when dirty', snippet: 'wire:dirty.class="${1:class}"' },
  { attr: 'wire:offline',       doc: 'Show when connection is offline', snippet: 'wire:offline' },
  { attr: 'wire:stream',        doc: 'Stream content to this element', snippet: 'wire:stream="${1:name}"' },
  // Navigation
  { attr: 'wire:navigate',      doc: 'Navigate using Livewire SPA routing', snippet: 'wire:navigate' },
  { attr: 'wire:navigate.hover',doc: 'Prefetch on hover', snippet: 'wire:navigate.hover' },
  // Refs & Keys
  { attr: 'wire:key',           doc: 'Unique key for list rendering', snippet: 'wire:key="${1:key}"' },
  { attr: 'wire:ignore',        doc: 'Ignore element during DOM diffing', snippet: 'wire:ignore' },
  { attr: 'wire:ignore.self',   doc: 'Ignore element but not children', snippet: 'wire:ignore.self' },
  { attr: 'wire:target',        doc: 'Target element for loading state', snippet: 'wire:target="${1:action}"' },
  { attr: 'wire:poll',          doc: 'Poll server every N seconds', snippet: 'wire:poll.${1:2000}ms' },
  { attr: 'wire:poll.keep-alive',doc: 'Poll without re-rendering', snippet: 'wire:poll.keep-alive.${1:2000}ms' },
  { attr: 'wire:confirm',       doc: 'Show confirm dialog before action', snippet: 'wire:confirm="${1:Are you sure?}"' },
  { attr: 'wire:transition',    doc: 'Animate with Alpine transitions', snippet: 'wire:transition' },
  { attr: 'wire:current',       doc: 'Apply class if URL matches (Livewire 4)', snippet: 'wire:current="${1:active-class}"' },
  { attr: 'wire:cloak',         doc: 'Hide until component is initialized', snippet: 'wire:cloak' },
];
 
// ─── Livewire 3 Blade Directives ──────────────────────────────────────────
 
const LIVEWIRE_DIRECTIVES = [
  { name: 'livewire',     snippet: "@livewire('${1:component}')", doc: 'Render a Livewire component' },
  { name: 'livewireStyles', snippet: '@livewireStyles', doc: 'Include Livewire styles (Livewire 2)' },
  { name: 'livewireScripts', snippet: '@livewireScripts', doc: 'Include Livewire scripts (Livewire 2)' },
  { name: 'livewire:persist', snippet: "@livewire:persist\n\t${1}\n@endlivewire:persist", doc: 'Persist component across navigation' },
  { name: 'teleport',     snippet: "@teleport('${1:#body}')\n\t${2}\n@endteleport", doc: 'Teleport content to another DOM element' },
  { name: 'entangle',     snippet: "@entangle('${1:property}')", doc: 'Entangle Alpine property with Livewire' },
  { name: 'js',           snippet: "@js(${1:\\$variable})", doc: 'Echo a JS-safe JSON value' },
  { name: 'volt',         snippet: "@volt('${1:component-name}')\n${2}\n@endvolt", doc: 'Define an inline Volt component' },
];
 
// ─── Livewire Enhanced Completion Provider ────────────────────────────────
 
export class LivewireEnhancedProvider implements vscode.CompletionItemProvider {
  constructor(private readonly cache: CacheManager) {}
 
  provideCompletionItems(
    document: vscode.TextDocument,
    position: vscode.Position
  ): vscode.CompletionItem[] {
    const lineText = document.lineAt(position).text;
    const textBefore = lineText.substring(0, position.character);
    const items: vscode.CompletionItem[] = [];
    const index = this.cache.getIndex();
 
    // ── 1. wire: attribute completions in HTML/Blade ──────────────────
    if (/\bwire:[\w.]*$/.test(textBefore) || /<[\w-]+\s+[^>]*wire:[\w.]*$/.test(textBefore)) {
      for (const dir of WIRE_DIRECTIVES) {
        const item = new vscode.CompletionItem(dir.attr, vscode.CompletionItemKind.Property);
        item.detail = dir.doc;
        item.insertText = new vscode.SnippetString(dir.snippet);
        item.documentation = new vscode.MarkdownString(`**${dir.attr}**\n\n${dir.doc}`);
        item.filterText = dir.attr;
        items.push(item);
      }
      return items;
    }
 
    // ── 2. wire:model="| " — property name completion ─────────────────
    const wireModelMatch = /wire:model(?:\.\w+)*\s*=\s*["']([^"']*)$/.exec(textBefore);
    if (wireModelMatch) {
      const partial = wireModelMatch[1];
      const comp = this.findComponentForDocument(document, index.livewireComponents);
      if (comp) {
        for (const prop of comp.properties) {
          if (!prop.name.startsWith(partial)) { continue; }
          const item = new vscode.CompletionItem(prop.name, vscode.CompletionItemKind.Property);
          item.detail = `${prop.type ?? 'mixed'} — public $${prop.name}`;
          item.documentation = new vscode.MarkdownString(`Livewire public property on \`${comp.className}\``);
          items.push(item);
        }
      }
      return items;
    }
 
    // ── 3. wire:click="| " — action/method completion ─────────────────
    const wireClickMatch = /wire:(?:click|submit|init|change|input|keydown|keyup|focus|blur)(?:\.[^"']*)*\s*=\s*["']([^"']*)$/.exec(textBefore);
    if (wireClickMatch) {
      const partial = wireClickMatch[1];
      const comp = this.findComponentForDocument(document, index.livewireComponents);
      if (comp) {
        for (const method of comp.methods) {
          if (!method.isAction) { continue; }
          if (!method.name.startsWith(partial)) { continue; }
          const item = new vscode.CompletionItem(method.name, vscode.CompletionItemKind.Method);
          item.detail = `Action — ${method.parameters.length > 0 ? method.parameters.join(', ') : 'void'}`;
          item.insertText = method.parameters.length > 0
            ? new vscode.SnippetString(`${method.name}(${method.parameters.map((p, i) => `\${${i + 1}:${p}}`).join(', ')})`)
            : method.name;
          items.push(item);
        }
      }
      return items;
    }
 
    // ── 4. @livewire / @volt / @entangle Blade directives ─────────────
    if (/@[\w.]*$/.test(textBefore)) {
      for (const dir of LIVEWIRE_DIRECTIVES) {
        const item = new vscode.CompletionItem(`@${dir.name}`, vscode.CompletionItemKind.Keyword);
        item.insertText = new vscode.SnippetString(dir.snippet);
        item.detail = dir.doc;
        item.documentation = new vscode.MarkdownString(`**Livewire Directive:** ${dir.doc}`);
        items.push(item);
      }
    }
 
    // ── 5. @livewire('|') — component name completion ──────────────────
    if (/@livewire\s*\(\s*['"][^'"]*$/.test(textBefore) || /<livewire:[\w.-]*$/.test(textBefore)) {
      for (const [, comp] of index.livewireComponents) {
        const kebab = toKebab(comp.className);
        const item = new vscode.CompletionItem(kebab, vscode.CompletionItemKind.Class);
        item.detail = comp.namespace;
        item.documentation = new vscode.MarkdownString(
          `**Livewire Component:** \`${comp.fullyQualifiedName}\`\n\n` +
          `Properties: ${comp.properties.map((p) => `\`${p.name}\``).join(', ') || '—'}`
        );
        items.push(item);
      }
      return items;
    }
 
    // ── 6. $this->property inside Livewire component class ────────────
    if (/\$this\s*->\s*\w*$/.test(textBefore)) {
      const comp = this.findComponentForDocument(document, index.livewireComponents);
      if (comp) {
        for (const prop of comp.properties) {
          const item = new vscode.CompletionItem(prop.name, vscode.CompletionItemKind.Property);
          item.detail = prop.type ?? 'mixed';
          item.documentation = new vscode.MarkdownString(`Livewire public property`);
          items.push(item);
        }
        for (const method of comp.methods) {
          const item = new vscode.CompletionItem(method.name, vscode.CompletionItemKind.Method);
          item.detail = method.isLifecycle ? 'Lifecycle hook' : method.isAction ? 'Action' : 'Method';
          items.push(item);
        }
        for (const cp of comp.computedProperties) {
          const item = new vscode.CompletionItem(cp, vscode.CompletionItemKind.Property);
          item.detail = 'Computed property';
          item.documentation = new vscode.MarkdownString(`Cached \`#[Computed]\` property`);
          items.push(item);
        }
      }
    }
 
    // ── 7. $this->dispatch / $this->dispatchTo inside component ───────
    if (/\$this\s*->\s*dispatch(?:To)?\s*\(\s*['"][^'"]*$/.test(textBefore)) {
      // Suggest known Livewire events across components
      for (const [, comp] of index.livewireComponents) {
        for (const method of comp.methods) {
          if (method.isAction) {
            const item = new vscode.CompletionItem(
              `${toKebab(comp.className)}.${method.name}`,
              vscode.CompletionItemKind.Event
            );
            item.detail = `Event from ${comp.className}`;
            items.push(item);
          }
        }
      }
    }
 
    // ── 8. Volt: state([| ]) ───────────────────────────────────────────
    if (/\bstate\s*\(\s*\[([^\]]*)?$/.test(textBefore)) {
      const voltHelpers = [
        { f: 'state', d: 'Declare reactive state' },
        { f: 'computed', d: 'Declare computed property' },
        { f: 'mount', d: 'Component mount callback' },
        { f: 'action', d: 'Declare an action method' },
        { f: 'on', d: 'Listen for an event' },
        { f: 'usesPagination', d: 'Enable pagination' },
        { f: 'usesFileUploads', d: 'Enable file uploads' },
        { f: 'title', d: 'Set page title' },
        { f: 'layout', d: 'Set layout view' },
        { f: 'url', d: 'Bind property to URL query string' },
        { f: 'locked', d: 'Lock property from client updates' },
        { f: 'session', d: 'Persist property in session' },
        { f: 'lazy', d: 'Lazy-load the component' },
      ];
      for (const { f, d } of voltHelpers) {
        const item = new vscode.CompletionItem(f, vscode.CompletionItemKind.Function);
        item.detail = d;
        item.documentation = new vscode.MarkdownString(`**Volt helper:** \`${f}\`\n\n${d}`);
        items.push(item);
      }
    }
 
    return items;
  }
 
  // ─── Helpers ──────────────────────────────────────────────────────────
 
  private findComponentForDocument(
    document: vscode.TextDocument,
    components: Map<string, LivewireComponent>
  ): LivewireComponent | undefined {
    for (const [, comp] of components) {
      if (comp.filePath === document.uri.fsPath) { return comp; }
    }
    return undefined;
  }
}
 
// ─── Livewire Rename Handler (file rename → update @livewire calls) ────────
 
export class LivewireRenameHandler implements vscode.Disposable {
  private readonly disposables: vscode.Disposable[] = [];
 
  constructor(
    private readonly cache: CacheManager,
    private readonly workspaceRoot: string
  ) {
    // Listen for file renames
    this.disposables.push(
      vscode.workspace.onDidRenameFiles((e) => {
        void this.handleRename(e);
      })
    );
  }
 
  private async handleRename(e: vscode.FileRenameEvent): Promise<void> {
    for (const { oldUri, newUri } of e.files) {
      if (!oldUri.fsPath.endsWith('.php')) { continue; }
      const index = this.cache.getIndex();
 
      // Check if renamed file was a Livewire component
      let oldComp: LivewireComponent | undefined;
      for (const [, comp] of index.livewireComponents) {
        if (comp.filePath === oldUri.fsPath) { oldComp = comp; break; }
      }
      if (!oldComp) { continue; }
 
      const oldKebab = toKebab(oldComp.className);
      const newClassName = newUri.fsPath.split(/[/\\]/).pop()?.replace('.php', '') ?? '';
      const newKebab = toKebab(newClassName);
 
      if (oldKebab === newKebab) { continue; }
 
      const bladeFiles = await vscode.workspace.findFiles(
        new vscode.RelativePattern(this.workspaceRoot, '**/*.blade.php'),
        '{vendor/**,node_modules/**}'
      );
 
      const edit = new vscode.WorkspaceEdit();
      let renamed = 0;
 
      for (const fileUri of bladeFiles) {
        const doc = await vscode.workspace.openTextDocument(fileUri);
        const text = doc.getText();
        const escaped = oldKebab.replace(/[-]/g, '[\\-]');
        const re = new RegExp(`(@livewire\\s*\\(\\s*['"])${escaped}(['"])|(\\blivewire:)${escaped}(\\b)`, 'g');
        let m: RegExpExecArray | null;
        while ((m = re.exec(text)) !== null) {
          const start = doc.positionAt(m.index + (m[1]?.length ?? m[3]?.length ?? 0));
          const end = doc.positionAt(m.index + m[0].length - (m[2]?.length ?? m[4]?.length ?? 0));
          edit.replace(fileUri, new vscode.Range(start, end), newKebab);
          renamed++;
        }
      }
 
      if (renamed > 0) {
        await vscode.workspace.applyEdit(edit);
        vscode.window.showInformationMessage(
          `[Maestro] Renamed Livewire component: ${oldKebab} → ${newKebab} (${renamed} usages updated)`
        );
      }
    }
  }
 
  dispose(): void {
    for (const d of this.disposables) { d.dispose(); }
  }
}
 
// ─── Utility ──────────────────────────────────────────────────────────────
 
function toKebab(str: string): string {
  return str.replace(/([A-Z])/g, (_, c: string) => `-${c.toLowerCase()}`).replace(/^-/, '');
}