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:
@@ -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.
|
||||
@@ -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 };
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user