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
|
- 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 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.
|
- 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.
|
- 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
|
- 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
|
- 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 = [
|
dependencies = [
|
||||||
"hemplate-core",
|
"hemplate-core",
|
||||||
"hemx-core",
|
"hemx-core",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -558,6 +560,14 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hemx-lsp"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"hemx-build",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hemx-saas-example"
|
name = "hemx-saas-example"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
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]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
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
|
Template authoring: `.heml` is HTML plus a small hemplate overlay for escaped
|
||||||
text, trusted HTML, dynamic attributes, Rust-shaped control directives, generated
|
text, trusted HTML, dynamic attributes, Rust-shaped control directives, generated
|
||||||
slots/forms/handles, and keyed partial targets. See `docs/hemplate-syntax.md`.
|
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
|
Local checkout note: until the hemplate crates are published, this repository
|
||||||
expects `hemplate` checked out next to `hemx` as `../hemplate/hemplate`. The app
|
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
|
### 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]
|
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
|
## 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)]
|
#[derive(Hemplate)]
|
||||||
pub struct Workout {
|
pub struct Workout {
|
||||||
|
pub plan: Vec<ExercisePlan>,
|
||||||
pub next_action: String,
|
pub next_action: String,
|
||||||
pub primary_action: String,
|
pub primary_action: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
@@ -664,6 +665,7 @@ pub fn view(state: &WorkoutState) -> Workout {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
Workout {
|
Workout {
|
||||||
|
plan: state.plan.clone(),
|
||||||
next_action: state.projection.next_action.clone(),
|
next_action: state.projection.next_action.clone(),
|
||||||
primary_action: state.projection.primary_action.clone(),
|
primary_action: state.projection.primary_action.clone(),
|
||||||
status: state.projection.progress.clone(),
|
status: state.projection.progress.clone(),
|
||||||
|
|||||||
@@ -6,6 +6,16 @@
|
|||||||
<p class="session-status" data-hemx-slot="status">{+ self.status +}</p>
|
<p class="session-status" data-hemx-slot="status">{+ self.status +}</p>
|
||||||
</section>
|
</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">
|
<section class="action-rail" aria-label="Current workout action">
|
||||||
<button class="primary-action" type="button" data-hemx-handle="complete_set">
|
<button class="primary-action" type="button" data-hemx-handle="complete_set">
|
||||||
<span data-hemx-slot="primary_action">{+ self.primary_action +}</span>
|
<span data-hemx-slot="primary_action">{+ self.primary_action +}</span>
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ path = "src/lib.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
hemplate-core = { path = "../../hemplate/hemplate-core", features = ["surface"] }
|
hemplate-core = { path = "../../hemplate/hemplate-core", features = ["surface"] }
|
||||||
hemx-core = { path = "../hemx-core" }
|
hemx-core = { path = "../hemx-core" }
|
||||||
|
quote = "1"
|
||||||
|
syn = { version = "2", features = ["full"] }
|
||||||
|
|||||||
+466
-12
@@ -1,10 +1,11 @@
|
|||||||
use hemplate_core::ast::build_ast;
|
use hemplate_core::ast::build_ast;
|
||||||
use hemplate_core::surface::{
|
use hemplate_core::surface::{
|
||||||
extract_surface, AttributeOrigin, ControlKind, ScopeId, ScopeKind, SurfaceAttribute,
|
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 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::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -58,6 +59,114 @@ pub struct AppBuilder {
|
|||||||
surfaces: Vec<(PathBuf, SurfaceDocument)>,
|
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 {
|
impl AppBuilder {
|
||||||
pub fn out_dir(mut self, out_dir: impl Into<PathBuf>) -> Self {
|
pub fn out_dir(mut self, out_dir: impl Into<PathBuf>) -> Self {
|
||||||
self.out_dir = Some(out_dir.into());
|
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(
|
fn reject_unkeyed_loop(
|
||||||
surface: &SurfaceDocument,
|
surface: &SurfaceDocument,
|
||||||
mut scope: ScopeId,
|
scope: ScopeId,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
kind: &str,
|
kind: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> io::Result<()> {
|
) -> 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 {
|
loop {
|
||||||
let Some(current) = surface.scopes.get(scope.0 as usize) else {
|
let current = surface.scopes.get(scope.0 as usize)?;
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
if let ScopeKind::For {
|
if let ScopeKind::For {
|
||||||
pattern,
|
pattern,
|
||||||
expr,
|
expr,
|
||||||
key_expr: None,
|
key_expr: None,
|
||||||
} = ¤t.kind
|
} = ¤t.kind
|
||||||
{
|
{
|
||||||
return Err(
|
return Some(unkeyed_generated_target_diagnostic(
|
||||||
unkeyed_generated_target_diagnostic(path, kind, name, pattern, expr).to_io_error(),
|
path, kind, name, pattern, expr,
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
let Some(parent) = current.parent else {
|
let parent = current.parent?;
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
scope = parent;
|
scope = parent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2117,6 +2483,94 @@ fn main() {{
|
|||||||
let _ = std::fs::remove_dir_all(&base);
|
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]
|
#[test]
|
||||||
fn unkeyed_generated_target_diagnostic_is_structured() {
|
fn unkeyed_generated_target_diagnostic_is_structured() {
|
||||||
// req: diagnostics/002
|
// 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