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
+1 -1
View File
@@ -57,7 +57,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
- Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands. req: ceremony/005 req: ceremony/006
- The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition.
- The stable public `.heml` authoring surface lives in `docs/hemplate-syntax.md`; Hemlate examples must use that real hemplate syntax, not Vue/Handlebars sketches.
- Optional `.heml` editor overlays must share authority with `hemx-build` diagnostics and `docs/hemplate-syntax.md`; do not create a second template language, selector model, formatter, or custom editor framework. req: diagnostics/004
- Optional `.heml` editor overlays must share authority with `hemx-build` diagnostics and `docs/hemplate-syntax.md`; `hemx-lsp` owns editor protocol glue for diagnostics/completion/hover and derive-known template facts, while VS Code/Cursor/Neovim keep normal HTML/tree-sitter tooling. Do not create a second template language, selector model, formatter, Rust type system, or custom editor framework. req: diagnostics/004 req: diagnostics/005 req: diagnostics/006
- JS runtime changes must preserve root-scoped lookup and avoid selectors, VDOM, expressions, and per-node listeners.
- Host capability adapters must stay at the `hemx-host` boundary: they may call host APIs and return host events, but they must not mutate DOM or own app/domain state. req: host/002
- Local/offline app behavior should be commands/events/projections; do not add `hemx-local`, stored DOM patches, or stored `EffectBatch` truth without a proven reusable contract. req: local/001 req: local/002
Generated
+10
View File
@@ -509,6 +509,8 @@ version = "0.1.0"
dependencies = [
"hemplate-core",
"hemx-core",
"quote",
"syn",
]
[[package]]
@@ -558,6 +560,14 @@ dependencies = [
"tokio",
]
[[package]]
name = "hemx-lsp"
version = "0.1.0"
dependencies = [
"hemx-build",
"serde_json",
]
[[package]]
name = "hemx-saas-example"
version = "0.1.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-xtask", "examples/v0", "examples/kanban", "examples/techdemo", "examples/saas", "examples/workout"]
members = ["hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-lsp", "hemx-xtask", "examples/v0", "examples/kanban", "examples/techdemo", "examples/saas", "examples/workout"]
[workspace.package]
version = "0.1.0"
+4
View File
@@ -13,6 +13,10 @@ The v1 story now has tutorial, recipe, diagnostics, and stability docs; see
Template authoring: `.heml` is HTML plus a small hemplate overlay for escaped
text, trusted HTML, dynamic attributes, Rust-shaped control directives, generated
slots/forms/handles, and keyed partial targets. See `docs/hemplate-syntax.md`.
Editor setup for VS Code, Cursor, and Neovim lives in `docs/editor-support.md`;
VS Code/Cursor share the repo extension in `editors/vscode-hemx`, while all
editors keep normal HTML/tree-sitter highlighting and layer `hemx-build`
diagnostics on top.
Local checkout note: until the hemplate crates are published, this repository
expects `hemplate` checked out next to `hemx` as `../hemplate/hemplate`. The app
+6
View File
@@ -678,6 +678,12 @@ what a valid business email is.
### req: diag/004
004 Optional `.heml` editor overlays must treat `hemx-build` diagnostics and the documented `.heml` syntax surface as authority; they may present compiler-shaped diagnostics, completion, hover, and navigation, but must not own a second template language, formatter, selector model, or custom editor framework. [north_star]
### req: diag/005
005 `.heml` editor startup for VS Code, Cursor, and Neovim must preserve normal HTML or tree-sitter HTML highlighting while using repo-owned `hemx-build` diagnostics through `hemx-lsp` as the shared authority for hemplate-specific feedback. [north_star]
### req: diag/006
006 `hemx-lsp` completion and hover for `.heml` Rust-shaped expressions must use hemx-owned compiler/build facts for derive-known template context fields and simple `h-for` locals; missing or stale facts must fall back to syntax/document completions without proxying rust-analyzer or owning a second Rust type system. [north_star]
---
## test
+142
View File
@@ -0,0 +1,142 @@
# `.heml` editor support
`.heml` authoring should feel like HTML first: keep normal HTML highlighting,
formatting, tag matching, and tree-sitter queries, then layer hemx compiler
feedback on top. The shared authority is `hemx-build` diagnostics plus
`docs/hemplate-syntax.md`; editors must not carry separate parser rules for the
hemplate language. req: diagnostics/004 req: diagnostics/005
## Shared language service
`hemx-lsp` owns editor protocol behavior; `hemx-xtask` stays a project workflow
runner, not the language-service home.
From the repo, run the stdio language service:
```sh
cargo run -p hemx-lsp -- lsp
```
Or install the same binary and run it directly:
```sh
cargo install --path hemx-lsp
hemx-lsp lsp
```
It speaks standard LSP framing over stdin/stdout. Today it supports open/change/save
text synchronization, compiler-backed `textDocument/publishDiagnostics`, and
small completion/hover entries for documented `.heml` constructs from
`docs/hemplate-syntax.md`. Generated targets discovered by `hemx-build` in an
open document are offered as `ui::target` completions. For derive-known template
contexts, `self.` field completion/hover and simple `h-for` locals such as
`exercise in &self.plan` come from hemx-owned Rust struct facts, not an editor
parser or rust-analyzer proxy. It intentionally does not format templates, parse
JavaScript, parse arbitrary Rust expressions, or replace HTML tooling.
For scripts and editor wrappers that only need one-shot diagnostics, run:
```sh
cargo run -p hemx-lsp -- diagnostics path/to/file.heml
```
The one-shot command prints a JSON object shaped like LSP
`textDocument/publishDiagnostics` parameters:
```json
{
"uri": "file:///absolute/path/to/file.heml",
"diagnostics": [
{
"range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 } },
"severity": 1,
"source": "hemx-build",
"code": "unkeyed-generated-target",
"message": "data-hemx-slot=\"todo_row\" is inside h-for=\"todo in &self.todos\" without h-key",
"data": {
"directive": "data-hemx-slot",
"target": "todo_row",
"expected": "a stable template h-key on h-for=\"todo in &self.todos\" so generated keyed helpers such as ui::todo_row.replace(row) can target this partial",
"repair": "add h-key=\"todo.id\" to that h-for; dynamic +data-key on the child is rendered HTML, not the template fact hemx uses for generated targets"
}
}
]
}
```
The diagnostic payload comes from `hemx-build`; editor integrations should display
it as-is instead of recreating the rule.
## VS Code and Cursor
Use the shared repo extension in `editors/vscode-hemx` for VS Code and Cursor.
It sets `.heml` to the built-in HTML language mode, starts `hemx-lsp`, and maps
LSP diagnostics/completion/hover into the editor without adding a separate grammar.
req: diagnostics/005
When the workspace root is this repository, the extension starts:
```sh
cargo run -p hemx-lsp -- lsp
```
In app workspaces, install `hemx-lsp` and the extension starts:
```sh
hemx-lsp lsp
```
If you do not use the extension, keep the same HTML association manually so HTML
syntax highlighting, completion, folding, and tag matching keep working:
```json
{
"files.associations": {
"*.heml": "html"
}
}
```
Use the one-shot diagnostics command only as a fallback task if your editor cannot
launch a stdio LSP server. Do not copy hemplate syntax into a VS Code/Cursor-only
grammar.
## Neovim
Use HTML filetype and tree-sitter HTML highlighting for `.heml`:
```lua
vim.filetype.add({ extension = { heml = "html" } })
```
If you use nvim-treesitter, this keeps `.heml` on the HTML parser. Start the
shared LSP service with Neovim's built-in client:
```lua
vim.lsp.start({
name = "hemx-heml",
cmd = { "cargo", "run", "-p", "hemx-lsp", "--", "lsp" },
root_dir = vim.fs.root(0, { "Cargo.toml", ".git" }) or vim.fn.getcwd(),
})
```
Use `cargo run -p hemx-lsp -- diagnostics %` only as a fallback if LSP is
unavailable. Do not add a separate `.heml` tree-sitter grammar unless HTML
injection can no longer represent the documented syntax in
`docs/hemplate-syntax.md`.
## Known limits and boundary
If `hemx-lsp` is missing, crashes, or cannot be started by the editor, `.heml`
files should still open as HTML and keep normal highlighting/tag tooling; use the
one-shot diagnostics command until the service is available.
This foundation intentionally supports diagnostics, completion, and hover/help.
It does not yet implement broad go-to-definition/reference navigation, formatting,
refactoring, semantic Rust analysis, arbitrary Rust expression parsing, or a
`.heml` tree-sitter parser fork.
Editor support may add startup glue, diagnostics display, completion, hover/help,
and navigation over documented `.heml` facts. It must not add a second template
language, editor-owned formatter, selector targeting model, JavaScript expression
layer, or editor-specific diagnostics that disagree with `hemx-build`.
+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."
}
}
}
}
}
+2
View File
@@ -607,6 +607,7 @@ impl WorkoutEvent {
#[derive(Hemplate)]
pub struct Workout {
pub plan: Vec<ExercisePlan>,
pub next_action: String,
pub primary_action: String,
pub status: String,
@@ -664,6 +665,7 @@ pub fn view(state: &WorkoutState) -> Workout {
),
};
Workout {
plan: state.plan.clone(),
next_action: state.projection.next_action.clone(),
primary_action: state.projection.primary_action.clone(),
status: state.projection.progress.clone(),
+10
View File
@@ -6,6 +6,16 @@
<p class="session-status" data-hemx-slot="status">{+ self.status +}</p>
</section>
<section class="plan-card" aria-labelledby="plan-heading">
<h2 id="plan-heading">Today&apos;s plan</h2>
<ul>
<li h-for="exercise in &self.plan" h-key="exercise.name">
<span>{+ exercise.name +}</span>
<span>{+ exercise.target_sets +} × {+ exercise.reps +} @ {+ exercise.kg +}kg</span>
</li>
</ul>
</section>
<section class="action-rail" aria-label="Current workout action">
<button class="primary-action" type="button" data-hemx-handle="complete_set">
<span data-hemx-slot="primary_action">{+ self.primary_action +}</span>
+2
View File
@@ -9,3 +9,5 @@ path = "src/lib.rs"
[dependencies]
hemplate-core = { path = "../../hemplate/hemplate-core", features = ["surface"] }
hemx-core = { path = "../hemx-core" }
quote = "1"
syn = { version = "2", features = ["full"] }
+466 -12
View File
@@ -1,10 +1,11 @@
use hemplate_core::ast::build_ast;
use hemplate_core::surface::{
extract_surface, AttributeOrigin, ControlKind, ScopeId, ScopeKind, SurfaceAttribute,
SurfaceDocument, SurfaceNodeKind,
SurfaceDocument, SurfaceNodeKind, SurfaceScope,
};
use hemx_core::{EFFECT_BATCH_ABI_VERSION, RUNTIME_ABI_VERSION, SURFACE_SCHEMA_VERSION};
use std::collections::{BTreeMap, BTreeSet};
use quote::ToTokens;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -58,6 +59,114 @@ pub struct AppBuilder {
surfaces: Vec<(PathBuf, SurfaceDocument)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeneratedTarget {
pub kind: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemplateContextFacts {
pub context_type: String,
pub self_fields: Vec<TemplateFieldFact>,
pub locals: Vec<TemplateLocalFact>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemplateFieldFact {
pub name: String,
pub type_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemplateLocalFact {
pub name: String,
pub type_name: String,
pub fields: Vec<TemplateFieldFact>,
}
#[derive(Debug, Clone)]
struct RustStructFact {
fields: Vec<TemplateFieldFact>,
derives_hemplate: bool,
}
pub fn diagnostics_for_heml_file(path: impl AsRef<Path>) -> io::Result<Vec<Diagnostic>> {
let path = path.as_ref();
let source = std::fs::read_to_string(path)?;
diagnostics_for_heml_source(path, source)
}
pub fn diagnostics_for_heml_source(
path: impl AsRef<Path>,
source: impl Into<String>,
) -> io::Result<Vec<Diagnostic>> {
let path = path.as_ref();
let surface = surface_for_heml_source(path, source.into())?;
Ok(unkeyed_generated_target_diagnostics(path, &surface))
}
pub fn generated_targets_for_heml_source(
path: impl AsRef<Path>,
source: impl Into<String>,
) -> io::Result<Vec<GeneratedTarget>> {
let path = path.as_ref();
let surface = surface_for_heml_source(path, source.into())?;
Ok(generated_targets(&surface))
}
pub fn template_context_facts_for_heml_file(
path: impl AsRef<Path>,
) -> io::Result<Option<TemplateContextFacts>> {
let path = path.as_ref();
let source = std::fs::read_to_string(path)?;
template_context_facts_for_heml_source(path, source)
}
pub fn template_context_facts_for_heml_source(
path: impl AsRef<Path>,
source: impl Into<String>,
) -> io::Result<Option<TemplateContextFacts>> {
let path = path.as_ref();
let source = source.into();
let Some(context_type) = context_type_for_heml_path(path) else {
return Ok(None);
};
let Some(root) = nearest_dir_with(path, "Cargo.toml") else {
return Ok(None);
};
let structs = rust_struct_facts_in(&root)?;
let Some(context) = structs.get(&context_type) else {
return Ok(None);
};
if !context.derives_hemplate {
return Ok(None);
}
let surface = surface_for_heml_source(path, source)?;
let locals = loop_locals_for_surface(&surface, &context.fields, &structs);
Ok(Some(TemplateContextFacts {
context_type,
self_fields: context.fields.clone(),
locals,
}))
}
fn surface_for_heml_source(path: &Path, source: String) -> io::Result<SurfaceDocument> {
let doc = build_ast(Arc::new(source)).map_err(|err| parse_error(path, err))?;
let Some(doc) = doc else {
return Ok(SurfaceDocument {
nodes: Vec::new(),
scopes: vec![SurfaceScope {
parent: None,
kind: ScopeKind::Root,
}],
forms: Vec::new(),
});
};
Ok(extract_surface(&doc))
}
impl AppBuilder {
pub fn out_dir(mut self, out_dir: impl Into<PathBuf>) -> Self {
self.out_dir = Some(out_dir.into());
@@ -1575,28 +1684,285 @@ fn invalid_hemx_value(path: &Path, attr: &str, value: &str, expectation: &str) -
fn reject_unkeyed_loop(
surface: &SurfaceDocument,
mut scope: ScopeId,
scope: ScopeId,
path: &Path,
kind: &str,
name: &str,
) -> io::Result<()> {
if let Some(diagnostic) =
unkeyed_generated_target_diagnostic_for_scope(surface, scope, path, kind, name)
{
Err(diagnostic.to_io_error())
} else {
Ok(())
}
}
fn context_type_for_heml_path(path: &Path) -> Option<String> {
let stem = path.file_stem()?.to_str()?;
let mut out = String::new();
let mut upper_next = true;
for ch in stem.chars() {
if ch == '_' || ch == '-' {
upper_next = true;
continue;
}
if upper_next {
out.extend(ch.to_uppercase());
upper_next = false;
} else {
out.push(ch);
}
}
(!out.is_empty()).then_some(out)
}
fn nearest_dir_with(path: &Path, file_name: &str) -> Option<PathBuf> {
for dir in path.ancestors().filter(|path| path.is_dir()) {
if dir.join(file_name).is_file() {
return Some(dir.to_path_buf());
}
}
None
}
fn rust_struct_facts_in(root: &Path) -> io::Result<HashMap<String, RustStructFact>> {
let mut facts = HashMap::new();
collect_rust_struct_facts(&root.join("src"), &mut facts)?;
Ok(facts)
}
fn collect_rust_struct_facts(
dir: &Path,
facts: &mut HashMap<String, RustStructFact>,
) -> io::Result<()> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Ok(());
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_rust_struct_facts(&path, facts)?;
} else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
collect_rust_struct_facts_from_file(&path, facts)?;
}
}
Ok(())
}
fn collect_rust_struct_facts_from_file(
path: &Path,
facts: &mut HashMap<String, RustStructFact>,
) -> io::Result<()> {
let source = std::fs::read_to_string(path)?;
let file =
syn::parse_file(&source).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
collect_rust_struct_facts_from_items(&file.items, facts);
Ok(())
}
fn collect_rust_struct_facts_from_items(
items: &[syn::Item],
facts: &mut HashMap<String, RustStructFact>,
) {
for item in items {
match item {
syn::Item::Struct(item) => {
if let syn::Fields::Named(fields) = &item.fields {
facts.insert(
item.ident.to_string(),
RustStructFact {
fields: fields
.named
.iter()
.filter_map(|field| {
Some(TemplateFieldFact {
name: field.ident.as_ref()?.to_string(),
type_name: compact_tokens(&field.ty),
})
})
.collect(),
derives_hemplate: derives_hemplate(&item.attrs),
},
);
}
}
syn::Item::Mod(item) => {
if let Some((_, items)) = &item.content {
collect_rust_struct_facts_from_items(items, facts);
}
}
_ => {}
}
}
}
fn derives_hemplate(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|attr| {
attr.path()
.segments
.last()
.is_some_and(|segment| segment.ident == "derive")
&& attr
.meta
.require_list()
.ok()
.is_some_and(|list| list.tokens.to_string().contains("Hemplate"))
})
}
fn compact_tokens(tokens: &impl ToTokens) -> String {
tokens
.to_token_stream()
.to_string()
.replace(" :: ", "::")
.replace(" < ", "<")
.replace(" >", ">")
.replace(" ,", ",")
.replace(" & ", "&")
.replace("& ", "&")
}
fn loop_locals_for_surface(
surface: &SurfaceDocument,
self_fields: &[TemplateFieldFact],
structs: &HashMap<String, RustStructFact>,
) -> Vec<TemplateLocalFact> {
let mut locals = Vec::new();
for node in &surface.nodes {
for attr in &node.attrs {
if attr.name != "h-for" {
continue;
}
let Some(value) = &attr.value else {
continue;
};
let Some((local, field)) = h_for_local_and_self_field(value) else {
continue;
};
let Some(self_field) = self_fields.iter().find(|candidate| candidate.name == field)
else {
continue;
};
let Some(type_name) = vec_element_type(&self_field.type_name) else {
continue;
};
let fields = structs
.get(&type_name)
.map(|fact| fact.fields.clone())
.unwrap_or_default();
if !locals
.iter()
.any(|existing: &TemplateLocalFact| existing.name == local)
{
locals.push(TemplateLocalFact {
name: local,
type_name,
fields,
});
}
}
}
locals
}
fn h_for_local_and_self_field(value: &str) -> Option<(String, String)> {
let (local, expr) = value.split_once(" in ")?;
let local = local.trim();
if local.is_empty() || local.contains(['(', ',', ' ']) {
return None;
}
let expr = expr.trim().strip_prefix('&').unwrap_or(expr.trim()).trim();
let field = expr
.strip_prefix("self.")?
.split(['.', '(', '['])
.next()?
.trim();
(!field.is_empty()).then(|| (local.to_owned(), field.to_owned()))
}
fn vec_element_type(type_name: &str) -> Option<String> {
let inner = type_name
.strip_prefix("Vec<")
.or_else(|| type_name.strip_prefix("std::vec::Vec<"))?
.strip_suffix('>')?;
Some(inner.trim().to_owned())
}
fn unkeyed_generated_target_diagnostics(path: &Path, surface: &SurfaceDocument) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for (node_scope, target) in generated_targets_with_scope(surface) {
if let Some(diagnostic) = unkeyed_generated_target_diagnostic_for_scope(
surface,
node_scope,
path,
&target.kind,
&target.name,
) {
diagnostics.push(diagnostic);
}
}
diagnostics
}
fn generated_targets(surface: &SurfaceDocument) -> Vec<GeneratedTarget> {
let mut targets = Vec::new();
for (_, target) in generated_targets_with_scope(surface) {
if !targets.iter().any(|existing: &GeneratedTarget| {
existing.kind == target.kind && existing.name == target.name
}) {
targets.push(target);
}
}
targets
}
fn generated_targets_with_scope(surface: &SurfaceDocument) -> Vec<(ScopeId, GeneratedTarget)> {
let mut targets = Vec::new();
for node in &surface.nodes {
for attr in &node.attrs {
let Some(kind) = attr.name.strip_prefix("data-hemx-") else {
continue;
};
if !matches!(kind, "slot" | "form" | "handle") {
continue;
}
let Some(name) = &attr.value else {
continue;
};
targets.push((
node.scope,
GeneratedTarget {
kind: kind.to_owned(),
name: name.to_owned(),
},
));
}
}
targets
}
fn unkeyed_generated_target_diagnostic_for_scope(
surface: &SurfaceDocument,
mut scope: ScopeId,
path: &Path,
kind: &str,
name: &str,
) -> Option<Diagnostic> {
loop {
let Some(current) = surface.scopes.get(scope.0 as usize) else {
return Ok(());
};
let current = surface.scopes.get(scope.0 as usize)?;
if let ScopeKind::For {
pattern,
expr,
key_expr: None,
} = &current.kind
{
return Err(
unkeyed_generated_target_diagnostic(path, kind, name, pattern, expr).to_io_error(),
);
return Some(unkeyed_generated_target_diagnostic(
path, kind, name, pattern, expr,
));
}
let Some(parent) = current.parent else {
return Ok(());
};
let parent = current.parent?;
scope = parent;
}
}
@@ -2117,6 +2483,94 @@ fn main() {{
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn diagnostics_for_heml_file_reports_unkeyed_generated_target() {
// req: diagnostics/002 req: diagnostics/004
let dir = test_dir("diagnostics_for_heml_file_reports_unkeyed_generated_target");
std::fs::create_dir_all(&dir).expect("create test dir");
let template = dir.join("todo.heml");
std::fs::write(
&template,
r#"<main data-hemx-root="todos"><template h-for="todo in &self.todos"><li data-hemx-slot="todo_row">{+ todo.title +}</li></template></main>"#,
)
.expect("write template");
let diagnostics = diagnostics_for_heml_file(&template).expect("diagnostics");
assert_eq!(diagnostics.len(), 1);
let diagnostic = &diagnostics[0];
assert_eq!(diagnostic.code, DiagnosticCode::UnkeyedGeneratedTarget);
assert_eq!(diagnostic.directive, "data-hemx-slot");
assert_eq!(diagnostic.target, "todo_row");
assert!(diagnostic.repair.contains("h-key=\"todo.id\""));
}
#[test]
fn generated_targets_for_heml_source_uses_surface_facts() {
// req: diagnostics/004 req: diagnostics/005
let targets = generated_targets_for_heml_source(
"inline.heml",
r#"<main data-hemx-root="todos"><section data-hemx-slot="summary"></section><form data-hemx-form="save"><button data-hemx-handle="submit">Save</button></form></main>"#,
)
.expect("generated targets");
assert_eq!(targets.len(), 3);
assert!(targets
.iter()
.any(|target| target.kind == "slot" && target.name == "summary"));
assert!(targets
.iter()
.any(|target| target.kind == "form" && target.name == "save"));
assert!(targets
.iter()
.any(|target| target.kind == "handle" && target.name == "submit"));
}
#[test]
fn template_context_facts_include_self_fields_and_h_for_local_fields() {
// req: diagnostics/006
let dir = test_dir("template_context_facts_include_self_fields_and_h_for_local_fields");
std::fs::create_dir_all(dir.join("src")).expect("create src dir");
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"facts\"\nversion = \"0.1.0\"\n",
)
.expect("write manifest");
std::fs::write(
dir.join("src/lib.rs"),
r#"
#[derive(Clone)]
pub struct ExercisePlan { pub name: String, pub kg: f32 }
#[derive(Hemplate)]
pub struct Workout { pub plan: Vec<ExercisePlan>, pub progress: String }
"#,
)
.expect("write lib");
let template = dir.join("workout.heml");
let source = r#"<main><p>{+ self.progress +}</p><li h-for="exercise in &self.plan">{+ exercise.name +}</li></main>"#;
std::fs::write(&template, source).expect("write heml");
let facts = template_context_facts_for_heml_source(&template, source)
.expect("facts")
.expect("derive facts");
assert_eq!(facts.context_type, "Workout");
assert!(facts
.self_fields
.iter()
.any(|field| field.name == "progress" && field.type_name == "String"));
let local = facts
.locals
.iter()
.find(|local| local.name == "exercise")
.expect("exercise local");
assert_eq!(local.type_name, "ExercisePlan");
assert!(local
.fields
.iter()
.any(|field| field.name == "name" && field.type_name == "String"));
}
#[test]
fn unkeyed_generated_target_diagnostic_is_structured() {
// req: diagnostics/002
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "hemx-lsp"
version.workspace = true
edition.workspace = true
[dependencies]
hemx-build = { path = "../hemx-build" }
serde_json = "1"
+831
View File
@@ -0,0 +1,831 @@
use std::collections::HashMap;
use std::env;
use std::io::{self, BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use hemx_build::{Diagnostic, DiagnosticCode, DiagnosticSeverity};
fn main() -> ExitCode {
let mut args = env::args().skip(1);
match args.next().as_deref() {
None | Some("lsp") | Some("serve") => run_lsp(args.collect::<Vec<_>>().as_slice()),
Some("diagnostics") | Some("check") => run_diagnostics(args.collect::<Vec<_>>().as_slice()),
Some("help") | Some("--help") | Some("-h") => {
print_help();
ExitCode::SUCCESS
}
Some(command) => {
eprintln!("unknown hemx-lsp command `{command}`");
print_help();
ExitCode::from(2)
}
}
}
fn print_help() {
println!(
"usage:\n hemx-lsp lsp\n hemx-lsp diagnostics FILE.heml\n\nrepo usage:\n cargo run -p hemx-lsp -- lsp\n cargo run -p hemx-lsp -- diagnostics FILE.heml"
);
}
fn run_diagnostics(operands: &[String]) -> ExitCode {
let [file] = operands else {
eprintln!("usage: hemx-lsp diagnostics FILE.heml");
return ExitCode::from(2);
};
match hemx_build::diagnostics_for_heml_file(file) {
Ok(diagnostics) => {
let path = canonical_path(file);
let payload = publish_diagnostics_payload(&file_uri(&path), &diagnostics);
println!(
"{}",
serde_json::to_string_pretty(&payload).expect("json diagnostics")
);
if diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
{
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
Err(err) => {
eprintln!("{file}: {err}");
ExitCode::FAILURE
}
}
}
fn run_lsp(operands: &[String]) -> ExitCode {
if !operands.is_empty() {
eprintln!("usage: hemx-lsp lsp");
return ExitCode::from(2);
}
let stdin = io::stdin();
let mut reader = BufReader::new(stdin.lock());
let stdout = io::stdout();
let mut writer = stdout.lock();
match serve_lsp(&mut reader, &mut writer) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("hemx-lsp: {err}");
ExitCode::FAILURE
}
}
}
fn serve_lsp<R: BufRead, W: Write>(reader: &mut R, writer: &mut W) -> io::Result<()> {
let mut open_documents = HashMap::<String, String>::new();
while let Some(message) = read_lsp_message(reader)? {
let method = message
.get("method")
.and_then(|method| method.as_str())
.unwrap_or_default();
match (message.get("id"), method) {
(Some(id), "initialize") => write_lsp_message(
writer,
&serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"capabilities": {
"textDocumentSync": {
"openClose": true,
"change": 1,
"save": true
},
"completionProvider": {
"triggerCharacters": ["h", "+", "d", "="]
},
"hoverProvider": true
},
"serverInfo": {
"name": "hemx-lsp",
"version": env!("CARGO_PKG_VERSION")
}
}
}),
)?,
(Some(id), "shutdown") => {
write_lsp_message(
writer,
&serde_json::json!({"jsonrpc": "2.0", "id": id, "result": null}),
)?;
}
(Some(id), "textDocument/completion") => {
let uri = text_document_uri(&message).unwrap_or_default();
let text = open_documents
.get(&uri)
.map(String::as_str)
.unwrap_or_default();
let position = text_document_position(&message);
write_lsp_message(
writer,
&serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"isIncomplete": false,
"items": hemplate_completion_items(&uri, text, position)
}
}),
)?;
}
(Some(id), "textDocument/hover") => {
let uri = text_document_uri(&message).unwrap_or_default();
let text = open_documents
.get(&uri)
.map(String::as_str)
.unwrap_or_default();
let position = text_document_position(&message);
write_lsp_message(
writer,
&serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"result": hemplate_hover(&uri, text, position)
}),
)?;
}
(Some(id), unknown) => write_lsp_message(
writer,
&serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": -32601, "message": format!("unknown hemx LSP request `{unknown}`") }
}),
)?,
(None, "textDocument/didOpen") => {
if let Some((uri, text)) = did_open_document(&message) {
open_documents.insert(uri.clone(), text.clone());
publish_lsp_diagnostics(writer, &uri, diagnostics_for_uri_source(&uri, text)?)?;
}
}
(None, "textDocument/didChange") => {
if let Some((uri, text)) = did_change_document(&message) {
open_documents.insert(uri.clone(), text.clone());
publish_lsp_diagnostics(writer, &uri, diagnostics_for_uri_source(&uri, text)?)?;
}
}
(None, "textDocument/didSave") => {
if let Some(uri) = text_document_uri(&message) {
let diagnostics = if let Some(text) = did_save_text(&message) {
diagnostics_for_uri_source(&uri, text)?
} else if let Some(text) = open_documents.get(&uri) {
diagnostics_for_uri_source(&uri, text.clone())?
} else {
let path = path_from_file_uri(&uri);
hemx_build::diagnostics_for_heml_file(&path)?
};
publish_lsp_diagnostics(writer, &uri, diagnostics)?;
}
}
(None, "textDocument/didClose") => {
if let Some(uri) = text_document_uri(&message) {
open_documents.remove(&uri);
publish_lsp_diagnostics(writer, &uri, Vec::new())?;
}
}
(None, "exit") => break,
(None, "initialized") | (None, "") => {}
(None, _) => {}
}
}
Ok(())
}
fn read_lsp_message<R: BufRead>(reader: &mut R) -> io::Result<Option<serde_json::Value>> {
let mut content_length = None;
let mut saw_header = false;
loop {
let mut line = String::new();
let bytes = reader.read_line(&mut line)?;
if bytes == 0 {
return Ok(None);
}
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed.is_empty() {
break;
}
saw_header = true;
if let Some((name, value)) = trimmed.split_once(':') {
if name.eq_ignore_ascii_case("content-length") {
content_length = Some(value.trim().parse::<usize>().map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("bad Content-Length: {err}"),
)
})?);
}
}
}
if !saw_header {
return Ok(None);
}
let len = content_length.ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "missing Content-Length header")
})?;
let mut body = vec![0; len];
reader.read_exact(&mut body)?;
serde_json::from_slice(&body)
.map(Some)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
}
fn write_lsp_message<W: Write>(writer: &mut W, message: &serde_json::Value) -> io::Result<()> {
let body = serde_json::to_vec(message).expect("serialize LSP message");
write!(writer, "Content-Length: {}\r\n\r\n", body.len())?;
writer.write_all(&body)?;
writer.flush()
}
fn publish_lsp_diagnostics<W: Write>(
writer: &mut W,
uri: &str,
diagnostics: Vec<Diagnostic>,
) -> io::Result<()> {
write_lsp_message(
writer,
&serde_json::json!({
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": publish_diagnostics_payload(uri, &diagnostics),
}),
)
}
fn publish_diagnostics_payload(uri: &str, diagnostics: &[Diagnostic]) -> serde_json::Value {
serde_json::json!({
"uri": uri,
"diagnostics": diagnostics
.iter()
.map(lsp_diagnostic)
.collect::<Vec<_>>()
})
}
fn lsp_diagnostic(diagnostic: &Diagnostic) -> serde_json::Value {
serde_json::json!({
"range": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 0 }
},
"severity": diagnostic_lsp_severity(diagnostic.severity),
"source": "hemx-build",
"code": diagnostic_code(diagnostic.code),
"message": diagnostic.message,
"data": {
"file": diagnostic.file.display().to_string(),
"directive": diagnostic.directive,
"target": diagnostic.target,
"expected": diagnostic.expected,
"repair": diagnostic.repair,
}
})
}
fn did_open_document(message: &serde_json::Value) -> Option<(String, String)> {
let doc = message.get("params")?.get("textDocument")?;
Some((
doc.get("uri")?.as_str()?.to_owned(),
doc.get("text")?.as_str()?.to_owned(),
))
}
fn did_change_document(message: &serde_json::Value) -> Option<(String, String)> {
let uri = text_document_uri(message)?;
let text = message
.get("params")?
.get("contentChanges")?
.as_array()?
.last()?
.get("text")?
.as_str()?
.to_owned();
Some((uri, text))
}
fn did_save_text(message: &serde_json::Value) -> Option<String> {
message
.get("params")?
.get("text")?
.as_str()
.map(str::to_owned)
}
fn text_document_uri(message: &serde_json::Value) -> Option<String> {
message
.get("params")?
.get("textDocument")?
.get("uri")?
.as_str()
.map(str::to_owned)
}
fn text_document_position(message: &serde_json::Value) -> Option<(usize, usize)> {
let position = message.get("params")?.get("position")?;
Some((
position.get("line")?.as_u64()? as usize,
position.get("character")?.as_u64()? as usize,
))
}
fn diagnostics_for_uri_source(uri: &str, source: String) -> io::Result<Vec<Diagnostic>> {
hemx_build::diagnostics_for_heml_source(path_from_file_uri(uri), source)
}
fn canonical_path(path: &str) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| PathBuf::from(path))
}
fn file_uri(path: &Path) -> String {
format!("file://{}", path.display())
}
fn path_from_file_uri(uri: &str) -> PathBuf {
PathBuf::from(uri.strip_prefix("file://").unwrap_or(uri))
}
fn hemplate_completion_items(
uri: &str,
text: &str,
position: Option<(usize, usize)>,
) -> Vec<serde_json::Value> {
let mut items = vec![
completion_item(
"h-for",
"h-for=\"item in &self.items\"",
"Repeat children using a Rust-shaped iterator expression; add `h-key` when generated targets are inside the loop.",
),
completion_item(
"h-key",
"h-key=\"item.id\"",
"Stable template key for generated targets inside `h-for` loops.",
),
completion_item(
"h-if",
"h-if=\"self.ready\"",
"Render an element when a Rust-shaped condition is true.",
),
completion_item(
"h-match",
"h-match=\"&self.state\"",
"Match a Rust-shaped value with child `h-case` arms.",
),
completion_item(
"h-case",
"h-case=\"State::Ready(value)\"",
"Arm for `h-match`; use `h-case=\"_\"` for the default arm.",
),
completion_item(
"+attr",
"+class=\"self.class_name()\"",
"Dynamic HTML attribute expression.",
),
completion_item(
"{+ expr +}",
"{+ self.title +}",
"Escaped text expression.",
),
completion_item(
"{+= expr =+}",
"{+= trusted_html =+}",
"Trusted/rendered HTML expression; prefer escaped `{+ expr +}` for user content.",
),
completion_item(
"data-hemx-slot",
"data-hemx-slot=\"content\"",
"Generated partial target, e.g. `ui::content.replace(value)`.",
),
completion_item(
"data-hemx-form",
"data-hemx-form=\"save\"",
"Generated form target wired through hemx build output.",
),
completion_item(
"data-hemx-handle",
"data-hemx-handle=\"button\"",
"Generated handle target wired through hemx build output.",
),
];
if let Ok(targets) =
hemx_build::generated_targets_for_heml_source(path_from_file_uri(uri), text)
{
for target in targets {
items.push(completion_item(
&format!("ui::{}", target.name),
&format!("ui::{}", target.name),
"Generated hemx target discovered by hemx-build in the open `.heml` document.",
));
}
}
if let Ok(Some(facts)) =
hemx_build::template_context_facts_for_heml_source(path_from_file_uri(uri), text)
{
let prefix = position
.and_then(|position| line_prefix(text, position))
.unwrap_or_default();
if prefix.ends_with("self.") {
for field in &facts.self_fields {
items.push(field_completion_item(&field.name, &field.type_name, "self"));
}
}
for local in &facts.locals {
if prefix.ends_with(&format!("{}.", local.name))
&& position.is_some_and(|position| active_h_for_local(text, position, &local.name))
{
for field in &local.fields {
items.push(field_completion_item(
&field.name,
&field.type_name,
&local.name,
));
}
}
}
}
items
}
fn field_completion_item(name: &str, type_name: &str, owner: &str) -> serde_json::Value {
serde_json::json!({
"label": name,
"kind": 5,
"detail": format!("{owner}.{name}: {type_name}"),
"insertText": name,
"documentation": {
"kind": "markdown",
"value": format!("`{owner}.{name}: {type_name}`\n\nSource: hemx-build template context facts.")
}
})
}
fn completion_item(label: &str, insert_text: &str, detail: &str) -> serde_json::Value {
serde_json::json!({
"label": label,
"kind": 10,
"detail": detail,
"insertText": insert_text,
"documentation": {
"kind": "markdown",
"value": format!("{detail}\n\nSource: `docs/hemplate-syntax.md` and `hemx-build`.")
}
})
}
fn hemplate_hover(uri: &str, text: &str, position: Option<(usize, usize)>) -> serde_json::Value {
if let Some((owner, field)) = position.and_then(|position| dotted_name_at(text, position)) {
if let Ok(Some(facts)) =
hemx_build::template_context_facts_for_heml_source(path_from_file_uri(uri), text)
{
if owner == "self" {
if let Some(fact) = facts.self_fields.iter().find(|fact| fact.name == field) {
return hover_markdown(&format!(
"`self.{}: {}`\n\nSource: hemx-build template context facts for `{}`.",
fact.name, fact.type_name, facts.context_type
));
}
}
if position.is_some_and(|position| active_h_for_local(text, position, &owner)) {
if let Some(local) = facts.locals.iter().find(|local| local.name == owner) {
if let Some(fact) = local.fields.iter().find(|fact| fact.name == field) {
return hover_markdown(&format!(
"`{}.{}: {}`\n\nSource: hemx-build `h-for` local fact from `{}`.",
local.name, fact.name, fact.type_name, local.type_name
));
}
}
}
}
}
let line = position
.and_then(|(line, _)| text.lines().nth(line))
.unwrap_or_default();
let value = if line.contains("data-hemx-slot") {
Some("`data-hemx-slot` declares a generated partial target. Inside `h-for`, add template `h-key`; compiler diagnostics come from `hemx-build`.")
} else if line.contains("data-hemx-form") {
Some("`data-hemx-form` declares a generated form target wired through hemx build output.")
} else if line.contains("data-hemx-handle") {
Some("`data-hemx-handle` declares a generated handle target wired through hemx build output.")
} else if line.contains("h-key") {
Some("`h-key` is the stable template key used by generated targets inside `h-for` loops.")
} else if line.contains("h-for") {
Some("`h-for` repeats children from a Rust-shaped iterator expression; generated targets inside the loop require `h-key`.")
} else if line.contains("h-if") {
Some("`h-if` renders an element when a Rust-shaped condition is true.")
} else if line.contains("h-match") || line.contains("h-case") {
Some("`h-match`/`h-case` use Rust-shaped pattern arms; `h-case=\"_\"` is the default arm.")
} else if line.contains("{+=") {
Some("`{+= expr =+}` inserts trusted/rendered HTML. Prefer escaped `{+ expr +}` for user content.")
} else if line.contains("{+") {
Some("`{+ expr +}` inserts escaped text.")
} else if line.contains('+') {
Some("`+attr=\"expr\"` evaluates a dynamic HTML attribute expression.")
} else {
None
};
match value {
Some(value) => hover_markdown(&format!(
"{value}\n\nSource: `docs/hemplate-syntax.md` and `hemx-build`."
)),
None => serde_json::Value::Null,
}
}
fn hover_markdown(value: &str) -> serde_json::Value {
serde_json::json!({
"contents": {
"kind": "markdown",
"value": value
}
})
}
fn line_prefix(text: &str, position: (usize, usize)) -> Option<String> {
let line = text.lines().nth(position.0)?;
Some(line.chars().take(position.1).collect())
}
fn dotted_name_at(text: &str, position: (usize, usize)) -> Option<(String, String)> {
let line = text.lines().nth(position.0)?;
let chars = line.chars().collect::<Vec<_>>();
let cursor = position.1.min(chars.len());
let mut start = cursor;
while start > 0 && is_ident_or_dot(chars[start - 1]) {
start -= 1;
}
let mut end = cursor;
while end < chars.len() && is_ident_or_dot(chars[end]) {
end += 1;
}
let token = chars[start..end].iter().collect::<String>();
let (owner, field) = token.split_once('.')?;
if owner.is_empty() || field.is_empty() || field.contains('.') {
return None;
}
Some((owner.to_owned(), field.to_owned()))
}
fn is_ident_or_dot(ch: char) -> bool {
ch == '_' || ch == '.' || ch.is_ascii_alphanumeric()
}
fn active_h_for_local(text: &str, position: (usize, usize), local_name: &str) -> bool {
let Some(offset) = byte_offset_for_position(text, position) else {
return false;
};
let mut search_from = 0;
while let Some(relative) = text[search_from..].find("h-for=\"") {
let attr_start = search_from + relative;
let value_start = attr_start + "h-for=\"".len();
let Some(value_end) = text[value_start..].find('"').map(|end| value_start + end) else {
return false;
};
if let Some((local, _)) = h_for_local_and_self_field(&text[value_start..value_end]) {
if local == local_name && h_for_attribute_scope_contains(text, attr_start, offset) {
return true;
}
}
search_from = value_end + 1;
}
false
}
fn h_for_attribute_scope_contains(text: &str, attr_start: usize, offset: usize) -> bool {
let Some(tag_start) = text[..attr_start].rfind('<') else {
return false;
};
if text[tag_start..].starts_with("</") {
return false;
}
let Some(open_end) = text[attr_start..].find('>').map(|end| attr_start + end) else {
return false;
};
if offset < tag_start || offset < open_end {
return offset >= tag_start && offset <= open_end;
}
if text[..=open_end].ends_with("/>") {
return false;
}
let Some(tag_name) = tag_name_at(text, tag_start) else {
return false;
};
let close = format!("</{tag_name}>");
let Some(close_start) = text[open_end + 1..]
.find(&close)
.map(|relative| open_end + 1 + relative)
else {
return false;
};
offset <= close_start + close.len()
}
fn tag_name_at(text: &str, tag_start: usize) -> Option<String> {
let rest = text[tag_start + 1..].trim_start();
let name = rest
.chars()
.take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '-' || *ch == ':')
.collect::<String>();
(!name.is_empty()).then_some(name)
}
fn byte_offset_for_position(text: &str, position: (usize, usize)) -> Option<usize> {
let mut offset = 0;
for (line_index, line) in text.split_inclusive('\n').enumerate() {
let line_without_newline = line.strip_suffix('\n').unwrap_or(line);
if line_index == position.0 {
let column_offset = line_without_newline
.char_indices()
.map(|(index, _)| index)
.chain(std::iter::once(line_without_newline.len()))
.nth(position.1)?;
return Some(offset + column_offset);
}
offset += line.len();
}
if position.0 == text.lines().count() {
return Some(text.len());
}
None
}
fn h_for_local_and_self_field(value: &str) -> Option<(String, String)> {
let (local, expr) = value.split_once(" in ")?;
let local = local.trim();
if local.is_empty() || local.contains(['(', ',', ' ']) {
return None;
}
let expr = expr.trim().strip_prefix('&').unwrap_or(expr.trim()).trim();
let field = expr
.strip_prefix("self.")?
.split(['.', '(', '['])
.next()?
.trim();
(!field.is_empty()).then(|| (local.to_owned(), field.to_owned()))
}
fn diagnostic_code(code: DiagnosticCode) -> &'static str {
match code {
DiagnosticCode::UnkeyedGeneratedTarget => "unkeyed-generated-target",
}
}
fn diagnostic_lsp_severity(severity: DiagnosticSeverity) -> u8 {
match severity {
DiagnosticSeverity::Error => 1,
}
}
#[cfg(test)]
mod tests {
use super::{hemplate_completion_items, hemplate_hover, serve_lsp};
#[test]
fn completion_and_hover_use_template_context_facts() {
// req: diagnostics/006
let dir =
std::env::temp_dir().join(format!("hemx-lsp-context-facts-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("src")).expect("create src dir");
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"facts\"\nversion = \"0.1.0\"\n",
)
.expect("write manifest");
std::fs::write(
dir.join("src/lib.rs"),
r#"
pub struct ExercisePlan { pub name: String, pub kg: f32 }
#[derive(Hemplate)]
pub struct Workout { pub plan: Vec<ExercisePlan>, pub progress: String }
"#,
)
.expect("write lib");
let template = dir.join("workout.heml");
let text = r#"<main><p>{+ self. +}</p><li h-for="exercise in &self.plan">{+ exercise. +}{+ exercise.name +}</li><p>{+ self.progress +} {+ exercise. +} {+ exercise.name +}</p></main>"#;
std::fs::write(&template, text).expect("write heml");
let uri = format!("file://{}", template.display());
let self_items = hemplate_completion_items(
&uri,
text,
Some((0, text.find("self.").unwrap() + "self.".len())),
);
assert!(self_items
.iter()
.any(|item| item["label"] == "progress" && item["detail"] == "self.progress: String"));
let exercise_cursor = text.find("exercise. +").unwrap() + "exercise.".len();
let exercise_items = hemplate_completion_items(&uri, text, Some((0, exercise_cursor)));
assert!(exercise_items
.iter()
.any(|item| item["label"] == "name" && item["detail"] == "exercise.name: String"));
let progress_hover =
hemplate_hover(&uri, text, Some((0, text.find("progress").unwrap() + 1)));
assert!(progress_hover.to_string().contains("self.progress: String"));
let inside_name = text.find("exercise.name").unwrap() + "exercise.".len() + 1;
let name_hover = hemplate_hover(&uri, text, Some((0, inside_name)));
assert!(name_hover.to_string().contains("exercise.name: String"));
let outside_cursor = text.rfind("exercise. +").unwrap() + "exercise.".len();
let outside_items = hemplate_completion_items(&uri, text, Some((0, outside_cursor)));
assert!(!outside_items
.iter()
.any(|item| item["detail"] == "exercise.name: String"));
let outside_hover = hemplate_hover(&uri, text, Some((0, text.rfind("name").unwrap() + 1)));
assert!(!outside_hover.to_string().contains("exercise.name: String"));
}
#[test]
fn workout_template_fields_are_available_from_repo_facts() {
// req: diagnostics/006
let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap();
let template = repo.join("examples/workout/templates/workout.heml");
let text = std::fs::read_to_string(&template).expect("workout template");
let uri = format!("file://{}", template.display());
let self_line = text
.lines()
.position(|line| line.contains("self.progress"))
.expect("self expression line");
let self_column =
text.lines().nth(self_line).unwrap().find("self.").unwrap() + "self.".len();
let self_items = hemplate_completion_items(&uri, &text, Some((self_line, self_column)));
assert!(self_items.iter().any(|item| item["label"] == "progress"));
let exercise_line = text
.lines()
.position(|line| line.contains("exercise.name"))
.expect("exercise local line");
let exercise_column = text
.lines()
.nth(exercise_line)
.unwrap()
.find("exercise.")
.unwrap()
+ "exercise.".len();
let exercise_items =
hemplate_completion_items(&uri, &text, Some((exercise_line, exercise_column)));
assert!(exercise_items
.iter()
.any(|item| item["detail"] == "exercise.name: &'static str"));
let hover = hemplate_hover(&uri, &text, Some((exercise_line, exercise_column + 1)));
assert!(hover.to_string().contains("exercise.name: &'static str"));
}
#[test]
fn lsp_serves_compiler_diagnostics_and_completion() {
// req: diagnostics/004 req: diagnostics/005
let bad_heml = r#"<main data-hemx-root="todos"><template h-for="todo in &self.todos"><li data-hemx-slot="todo_row">{+ todo.title +}</li></template></main>"#;
let input = [
lsp_message(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})),
lsp_message(serde_json::json!({
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": {"textDocument": {"uri": "file:///tmp/todo.heml", "languageId": "heml", "version": 1, "text": bad_heml}}
})),
lsp_message(serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "textDocument/completion",
"params": {"textDocument": {"uri": "file:///tmp/todo.heml"}, "position": {"line": 0, "character": 1}}
})),
lsp_message(serde_json::json!({
"jsonrpc": "2.0",
"id": 3,
"method": "textDocument/hover",
"params": {"textDocument": {"uri": "file:///tmp/todo.heml"}, "position": {"line": 0, "character": 80}}
})),
lsp_message(serde_json::json!({"jsonrpc": "2.0", "method": "exit"})),
]
.join("");
let mut reader = std::io::BufReader::new(input.as_bytes());
let mut output = Vec::new();
serve_lsp(&mut reader, &mut output).expect("serve LSP");
let output = String::from_utf8(output).expect("utf8 output");
assert!(output.contains("completionProvider"));
assert!(output.contains("hoverProvider"));
assert!(output.contains("textDocument/publishDiagnostics"));
assert!(output.contains("unkeyed-generated-target"));
assert!(output.contains("add h-key"));
assert!(output.contains("ui::todo_row"));
assert!(output.contains("docs/hemplate-syntax.md"));
}
fn lsp_message(message: serde_json::Value) -> String {
let body = serde_json::to_string(&message).expect("lsp json");
format!("Content-Length: {}\r\n\r\n{}", body.len(), body)
}
}