import * as vscode from 'vscode'; import { HAClient, HAEntity } from './haClient'; // ────────────────────────────────────────────────────────────────────────────── // Completion Provider // ────────────────────────────────────────────────────────────────────────────── export class EntityCompletionProvider implements vscode.CompletionItemProvider { private cachedDomains: Set = new Set(); constructor(private haClient: HAClient) { this.rebuildDomainCache(); haClient.onEntitiesUpdated(() => this.rebuildDomainCache()); } private rebuildDomainCache() { this.cachedDomains = new Set(this.haClient.getDomains()); } provideCompletionItems( document: vscode.TextDocument, position: vscode.Position, _token: vscode.CancellationToken, context: vscode.CompletionContext ): vscode.CompletionList | undefined { const entities = this.haClient.getEntities(); if (entities.length === 0) { return undefined; } const lineText = document.lineAt(position).text; const textBefore = lineText.substring(0, position.character); // ── Case 1: "domain.partial" — user typed a dot after a domain name ── const dotMatch = textBefore.match(/\b([a-z][a-z_]*)\.([a-z0-9_]*)$/); if (dotMatch) { const domain = dotMatch[1]; const partial = dotMatch[2]; if (this.cachedDomains.has(domain)) { // Range covers "domain.partial" so the full entity_id replaces it const replaceStart = position.translate(0, -(domain.length + 1 + partial.length)); const replaceRange = new vscode.Range(replaceStart, position); const items = this.buildDomainItems(entities, domain, partial, replaceRange); if (items.length > 0) { return new vscode.CompletionList(items, false); } } } // ── Case 2: Trigger was "." but domain is unknown — skip ───────────── if (context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter && context.triggerCharacter === '.') { return undefined; } // ── Case 3: YAML value context — broad entity search ───────────────── if (document.languageId === 'yaml') { const valueMatch = textBefore.match(/(?::\s+|:\s*['"]|[-]\s+|[-]\s*['"])([a-z][a-z0-9_.]*)?$/); if (valueMatch) { const partial = valueMatch[1] || ''; return new vscode.CompletionList( this.buildAllItems(entities, partial), partial.length < 3 ); } } // ── Case 4: Python string context — inside quotes ──────────────────── if (document.languageId === 'python') { const stringMatch = textBefore.match(/['"]([a-z][a-z0-9_.]*)$/); if (stringMatch) { const partial = stringMatch[1]; return new vscode.CompletionList( this.buildAllItems(entities, partial), partial.length < 3 ); } } return undefined; } private buildDomainItems( entities: HAEntity[], domain: string, partial: string, replaceRange: vscode.Range ): vscode.CompletionItem[] { const items: vscode.CompletionItem[] = []; const prefix = `${domain}.`; for (const entity of entities) { if (!entity.entity_id.startsWith(prefix)) { continue; } if (partial && !entity.entity_id.substring(prefix.length).includes(partial)) { continue; } const item = new vscode.CompletionItem( entity.entity_id, vscode.CompletionItemKind.Value ); const friendly = entity.attributes?.friendly_name as string | undefined; item.detail = friendly ? `${friendly} — ${entity.state}` : entity.state; item.documentation = new vscode.MarkdownString(formatEntityMarkdown(entity)); item.filterText = entity.entity_id; item.sortText = entity.entity_id; item.range = replaceRange; items.push(item); } return items; } private buildAllItems(entities: HAEntity[], partial: string): vscode.CompletionItem[] { const items: vscode.CompletionItem[] = []; const lowerPartial = partial.toLowerCase(); for (const entity of entities) { if (lowerPartial && !entity.entity_id.includes(lowerPartial)) { const friendly = (entity.attributes?.friendly_name as string || '').toLowerCase(); if (!friendly.includes(lowerPartial)) { continue; } } const item = new vscode.CompletionItem( entity.entity_id, vscode.CompletionItemKind.Value ); const friendly = entity.attributes?.friendly_name as string | undefined; item.detail = friendly ? `${friendly} — ${entity.state}` : entity.state; item.documentation = new vscode.MarkdownString(formatEntityMarkdown(entity)); item.filterText = entity.entity_id; item.sortText = entity.entity_id; items.push(item); } return items; } } // ────────────────────────────────────────────────────────────────────────────── // Hover Provider // ────────────────────────────────────────────────────────────────────────────── const ENTITY_ID_PATTERN = /[a-z][a-z_]*\.[a-z0-9][a-z0-9_]*/; export class EntityHoverProvider implements vscode.HoverProvider { constructor(private haClient: HAClient) {} provideHover( document: vscode.TextDocument, position: vscode.Position ): vscode.Hover | undefined { const range = document.getWordRangeAtPosition(position, ENTITY_ID_PATTERN); if (!range) { return undefined; } const word = document.getText(range); const entity = this.haClient.getEntity(word); if (!entity) { return undefined; } const md = new vscode.MarkdownString(); md.supportHtml = true; md.isTrusted = true; const friendly = entity.attributes?.friendly_name as string | undefined; md.appendMarkdown(`### ${friendly || entity.entity_id}\n\n`); md.appendMarkdown(`| | |\n|---|---|\n`); md.appendMarkdown(`| **Entity** | \`${entity.entity_id}\` |\n`); md.appendMarkdown(`| **State** | **\`${entity.state}\`** |\n`); if (entity.attributes) { const skip = new Set(['friendly_name', 'icon', 'entity_picture', 'supported_features', 'supported_color_modes']); const attrs = Object.entries(entity.attributes) .filter(([k]) => !skip.has(k)) .slice(0, 12); for (const [key, value] of attrs) { const display = typeof value === 'object' ? JSON.stringify(value) : String(value); const truncated = display.length > 60 ? display.substring(0, 57) + '...' : display; const safeKey = key.replace(/\|/g, '\\|'); const safeVal = truncated.replace(/\|/g, '\\|'); md.appendMarkdown(`| ${safeKey} | \`${safeVal}\` |\n`); } } md.appendMarkdown(`\n*Last changed: ${entity.last_changed}*`); return new vscode.Hover(md, range); } } // ────────────────────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────────────────────── function formatEntityMarkdown(entity: HAEntity): string { const lines: string[] = []; const friendly = entity.attributes?.friendly_name as string | undefined; lines.push(`**${friendly || entity.entity_id}**\n`); lines.push(`- **State:** \`${entity.state}\``); lines.push(`- **Entity ID:** \`${entity.entity_id}\``); if (entity.attributes) { const skip = new Set(['friendly_name', 'icon', 'entity_picture']); const attrs = Object.entries(entity.attributes) .filter(([k]) => !skip.has(k)) .slice(0, 8); if (attrs.length > 0) { lines.push('\n**Attributes:**'); for (const [key, value] of attrs) { const val = typeof value === 'object' ? JSON.stringify(value) : String(value); lines.push(`- ${key}: \`${val}\``); } } } lines.push(`\n*Last changed: ${entity.last_changed}*`); return lines.join('\n'); }