'use strict'; const cp = require('child_process'); const fs = require('fs'); const path = require('path'); const vscode = require('vscode'); let client; let diagnostics; function activate(context) { diagnostics = vscode.languages.createDiagnosticCollection('hemx-build'); context.subscriptions.push(diagnostics); client = new HemxLspClient(context, diagnostics); context.subscriptions.push({ dispose: () => client.dispose() }); client.start(); const selector = [ { scheme: 'file', pattern: '**/*.heml' }, { scheme: 'untitled', pattern: '**/*.heml' } ]; context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(doc => client.didOpen(doc))); context.subscriptions.push(vscode.workspace.onDidChangeTextDocument(event => client.didChange(event.document))); context.subscriptions.push(vscode.workspace.onDidSaveTextDocument(doc => client.didSave(doc))); context.subscriptions.push(vscode.workspace.onDidCloseTextDocument(doc => client.didClose(doc))); context.subscriptions.push(vscode.languages.registerCompletionItemProvider(selector, { provideCompletionItems(document, position) { return client.completion(document, position); } }, 'h', '+', 'd', '=')); context.subscriptions.push(vscode.languages.registerHoverProvider(selector, { provideHover(document, position) { return client.hover(document, position); } })); for (const doc of vscode.workspace.textDocuments) { client.didOpen(doc); } } function deactivate() { if (client) { client.dispose(); } } class HemxLspClient { constructor(context, diagnosticCollection) { this.context = context; this.diagnosticCollection = diagnosticCollection; this.proc = undefined; this.buffer = Buffer.alloc(0); this.nextId = 1; this.pending = new Map(); this.opened = new Set(); this.ready = Promise.resolve(false); this.warned = false; } start() { const spec = lspCommandSpec(); try { this.proc = cp.spawn(spec.command, spec.args, { cwd: spec.cwd, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); } catch (err) { this.warnOnce(`failed to start hemx-lsp: ${err.message}`); this.ready = Promise.resolve(false); return; } this.proc.on('error', err => this.warnOnce(`failed to start hemx-lsp: ${err.message}`)); this.proc.stderr.on('data', data => { const text = data.toString('utf8').trim(); if (text) { console.error(`[hemx-lsp] ${text}`); } }); this.proc.stdout.on('data', data => this.readMessages(data)); this.proc.on('exit', code => { if (code !== 0 && code !== null) { this.warnOnce(`hemx-lsp exited with status ${code}; .heml files keep normal HTML support`); } }); this.ready = this.request('initialize', { processId: process.pid, rootUri: workspaceRootUri(), capabilities: {} }).then(() => { this.notify('initialized', {}); return true; }).catch(err => { this.warnOnce(`hemx-lsp initialize failed: ${err.message}`); return false; }); } dispose() { this.diagnosticCollection.clear(); if (this.proc && !this.proc.killed) { this.request('shutdown', {}).catch(() => undefined).finally(() => { this.notify('exit', {}); this.proc.kill(); }); } } async didOpen(document) { if (!isHeml(document)) return; if (!await this.ready) return; this.opened.add(document.uri.toString()); this.notify('textDocument/didOpen', { textDocument: textDocumentItem(document) }); } async didChange(document) { if (!isHeml(document)) return; if (!await this.ready) return; if (!this.opened.has(document.uri.toString())) { return this.didOpen(document); } this.notify('textDocument/didChange', { textDocument: versionedTextDocumentIdentifier(document), contentChanges: [{ text: document.getText() }] }); } async didSave(document) { if (!isHeml(document)) return; if (!await this.ready) return; this.notify('textDocument/didSave', { textDocument: textDocumentIdentifier(document), text: document.getText() }); } async didClose(document) { if (!isHeml(document)) return; this.opened.delete(document.uri.toString()); this.diagnosticCollection.delete(document.uri); if (!await this.ready) return; this.notify('textDocument/didClose', { textDocument: textDocumentIdentifier(document) }); } async completion(document, position) { if (!isHeml(document) || !await this.ready) return undefined; const response = await this.request('textDocument/completion', { textDocument: textDocumentIdentifier(document), position: lspPosition(position) }); const items = Array.isArray(response) ? response : response && response.items; if (!Array.isArray(items)) return undefined; return items.map(toCompletionItem); } async hover(document, position) { if (!isHeml(document) || !await this.ready) return undefined; const response = await this.request('textDocument/hover', { textDocument: textDocumentIdentifier(document), position: lspPosition(position) }); if (!response || response === null || !response.contents) return undefined; return new vscode.Hover(markdownFromLsp(response.contents)); } request(method, params) { const id = this.nextId++; this.send({ jsonrpc: '2.0', id, method, params }); return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); }); } notify(method, params) { this.send({ jsonrpc: '2.0', method, params }); } send(message) { if (!this.proc || !this.proc.stdin.writable) return; const body = Buffer.from(JSON.stringify(message), 'utf8'); this.proc.stdin.write(`Content-Length: ${body.length}\r\n\r\n`); this.proc.stdin.write(body); } readMessages(data) { this.buffer = Buffer.concat([this.buffer, data]); while (true) { const headerEnd = this.buffer.indexOf('\r\n\r\n'); if (headerEnd < 0) return; const header = this.buffer.slice(0, headerEnd).toString('ascii'); const match = /content-length:\s*(\d+)/i.exec(header); if (!match) { this.buffer = this.buffer.slice(headerEnd + 4); continue; } const length = Number(match[1]); const start = headerEnd + 4; const end = start + length; if (this.buffer.length < end) return; const body = this.buffer.slice(start, end).toString('utf8'); this.buffer = this.buffer.slice(end); this.handleMessage(JSON.parse(body)); } } handleMessage(message) { if (message.id !== undefined && this.pending.has(message.id)) { const pending = this.pending.get(message.id); this.pending.delete(message.id); if (message.error) pending.reject(new Error(message.error.message || 'LSP request failed')); else pending.resolve(message.result); return; } if (message.method === 'textDocument/publishDiagnostics') { this.publishDiagnostics(message.params || {}); } } publishDiagnostics(params) { const uri = vscode.Uri.parse(params.uri); const mapped = (params.diagnostics || []).map(diag => { const range = new vscode.Range( diag.range.start.line, diag.range.start.character, diag.range.end.line, diag.range.end.character ); const item = new vscode.Diagnostic(range, diag.message, toDiagnosticSeverity(diag.severity)); item.source = diag.source || 'hemx-build'; item.code = diag.code; return item; }); this.diagnosticCollection.set(uri, mapped); } warnOnce(message) { if (this.warned) return; this.warned = true; vscode.window.showWarningMessage(message); } } function isHeml(document) { return document.uri.scheme === 'file' && document.fileName.endsWith('.heml'); } function lspCommandSpec() { const config = vscode.workspace.getConfiguration('hemx.heml'); const configuredCommand = config.get('lspCommand', ''); const configuredArgs = config.get('lspArgs', []); const folder = vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0]; const cwd = folder ? folder.uri.fsPath : process.cwd(); if (configuredCommand) { return { command: configuredCommand, args: configuredArgs, cwd }; } if (fs.existsSync(path.join(cwd, 'hemx-lsp', 'Cargo.toml'))) { return { command: 'cargo', args: ['run', '-p', 'hemx-lsp', '--', 'lsp'], cwd }; } return { command: 'hemx-lsp', args: ['lsp'], cwd }; } function workspaceRootUri() { const folder = vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0]; return folder ? folder.uri.toString() : null; } function textDocumentItem(document) { return { uri: document.uri.toString(), languageId: document.languageId, version: document.version, text: document.getText() }; } function textDocumentIdentifier(document) { return { uri: document.uri.toString() }; } function versionedTextDocumentIdentifier(document) { return { uri: document.uri.toString(), version: document.version }; } function lspPosition(position) { return { line: position.line, character: position.character }; } function toCompletionItem(item) { const completion = new vscode.CompletionItem(item.label, vscode.CompletionItemKind.Property); completion.detail = item.detail; completion.insertText = item.insertText || item.label; if (item.documentation) { completion.documentation = markdownFromLsp(item.documentation); } return completion; } function markdownFromLsp(contents) { if (typeof contents === 'string') return new vscode.MarkdownString(contents); if (contents && typeof contents.value === 'string') return new vscode.MarkdownString(contents.value); if (Array.isArray(contents)) return new vscode.MarkdownString(contents.map(part => typeof part === 'string' ? part : part.value || '').join('\n\n')); return new vscode.MarkdownString(''); } function toDiagnosticSeverity(severity) { return severity === 1 ? vscode.DiagnosticSeverity.Error : vscode.DiagnosticSeverity.Warning; } module.exports = { activate, deactivate };