feat(diagnostics): add compiler-backed heml language service

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
This commit is contained in:
slhx agent
2026-06-22 22:06:42 +02:00
parent 4bca046ed1
commit 797132647f
15 changed files with 1886 additions and 14 deletions
+39
View File
@@ -0,0 +1,39 @@
# Hemx HEML for VS Code and Cursor
This extension keeps `.heml` files in VS Code's HTML language mode and layers the
shared `hemx-lsp` service on top for diagnostics, completion, and hover. It does
not define a separate grammar, formatter, selector model, or editor-only parser.
req: diagnostics/004 req: diagnostics/005
## Run from a hemx checkout
Open the repository in VS Code/Cursor and use this extension from source. The
extension detects `hemx-lsp/Cargo.toml` at the workspace root and starts:
```sh
cargo run -p hemx-lsp -- lsp
```
## Run with an installed binary
Install the shared service and open any app workspace:
```sh
cargo install --path hemx-lsp
```
The extension then starts:
```sh
hemx-lsp lsp
```
If your binary lives elsewhere, set `hemx.heml.lspCommand` and
`hemx.heml.lspArgs` in VS Code/Cursor settings.
## Behavior
- `.heml` defaults to VS Code's `html` language mode.
- Diagnostics are displayed from `hemx-build` via `hemx-lsp`.
- Completion and hover come from `hemx-lsp` and `docs/hemplate-syntax.md`.
- If the language service cannot start, normal HTML highlighting still works.
+320
View File
@@ -0,0 +1,320 @@
'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 };
+44
View File
@@ -0,0 +1,44 @@
{
"name": "hemx-heml",
"displayName": "Hemx HEML",
"description": "Compiler-backed .heml diagnostics, completion, and hover while preserving VS Code HTML tooling.",
"version": "0.1.0",
"publisher": "hemx",
"engines": {
"vscode": "^1.80.0"
},
"categories": [
"Programming Languages"
],
"activationEvents": [
"workspaceContains:**/*.heml",
"onLanguage:html",
"onLanguage:heml"
],
"main": "./extension.js",
"contributes": {
"configurationDefaults": {
"files.associations": {
"*.heml": "html"
}
},
"configuration": {
"title": "Hemx HEML",
"properties": {
"hemx.heml.lspCommand": {
"type": "string",
"default": "",
"description": "Command used to start hemx-lsp. Empty means: use `cargo run -p hemx-lsp -- lsp` inside the hemx repo, otherwise `hemx-lsp lsp`."
},
"hemx.heml.lspArgs": {
"type": "array",
"default": [],
"items": {
"type": "string"
},
"description": "Arguments for hemx.heml.lspCommand. Leave empty to use the automatic repo/installed-binary defaults."
}
}
}
}
}