797132647f
Add hemx-lsp for stdio LSP diagnostics, completion, and hover while preserving HTML editor tooling for .heml files. Teach hemx-build to expose generated target and derive-known template context facts, including simple h-for locals, so editor help comes from build-owned facts instead of editor-only parsers. Wire VS Code/Cursor and Neovim documentation and extend the Workout exemplar with a real h-for plan loop for end-to-end proof. req: diag/004 req: diag/005 req: diag/006
321 lines
10 KiB
JavaScript
321 lines
10 KiB
JavaScript
'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 };
|