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:
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
+465
-11
@@ -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<()> {
|
||||
loop {
|
||||
let Some(current) = surface.scopes.get(scope.0 as usize) else {
|
||||
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 current = surface.scopes.get(scope.0 as usize)?;
|
||||
if let ScopeKind::For {
|
||||
pattern,
|
||||
expr,
|
||||
key_expr: None,
|
||||
} = ¤t.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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "hemx-lsp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
hemx-build = { path = "../hemx-build" }
|
||||
serde_json = "1"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user