232 lines
9.7 KiB
TypeScript
232 lines
9.7 KiB
TypeScript
import * as vscode from 'vscode';
|
|
import { HAClient, HAEntity } from './haClient';
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Completion Provider
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
export class EntityCompletionProvider implements vscode.CompletionItemProvider {
|
|
private cachedDomains: Set<string> = 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');
|
|
}
|