diff --git a/AGENTS.md b/AGENTS.md index 253e2f8..1679023 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index b23de17..41cf9ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index eb83fb0..bdf512e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 41f5bfb..d92665a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 6a85cb7..fbef867 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -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 diff --git a/docs/editor-support.md b/docs/editor-support.md new file mode 100644 index 0000000..938ba6a --- /dev/null +++ b/docs/editor-support.md @@ -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`. diff --git a/editors/vscode-hemx/README.md b/editors/vscode-hemx/README.md new file mode 100644 index 0000000..3cccc63 --- /dev/null +++ b/editors/vscode-hemx/README.md @@ -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. diff --git a/editors/vscode-hemx/extension.js b/editors/vscode-hemx/extension.js new file mode 100644 index 0000000..e30e9f9 --- /dev/null +++ b/editors/vscode-hemx/extension.js @@ -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 }; diff --git a/editors/vscode-hemx/package.json b/editors/vscode-hemx/package.json new file mode 100644 index 0000000..dbac440 --- /dev/null +++ b/editors/vscode-hemx/package.json @@ -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." + } + } + } + } +} diff --git a/examples/workout/src/lib.rs b/examples/workout/src/lib.rs index 04ac4a9..02f086c 100644 --- a/examples/workout/src/lib.rs +++ b/examples/workout/src/lib.rs @@ -607,6 +607,7 @@ impl WorkoutEvent { #[derive(Hemplate)] pub struct Workout { + pub plan: Vec, 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(), diff --git a/examples/workout/templates/workout.heml b/examples/workout/templates/workout.heml index a4d640e..f19107d 100644 --- a/examples/workout/templates/workout.heml +++ b/examples/workout/templates/workout.heml @@ -6,6 +6,16 @@

{+ self.status +}

+
+

Today's plan

+ +
+
"#, + ) + .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, pub progress: String } + "#, + ) + .expect("write lib"); + let template = dir.join("workout.heml"); + let source = r#"

{+ self.progress +}

  • {+ exercise.name +}
  • "#; + 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 diff --git a/hemx-lsp/Cargo.toml b/hemx-lsp/Cargo.toml new file mode 100644 index 0000000..7319d44 --- /dev/null +++ b/hemx-lsp/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "hemx-lsp" +version.workspace = true +edition.workspace = true + +[dependencies] +hemx-build = { path = "../hemx-build" } +serde_json = "1" diff --git a/hemx-lsp/src/main.rs b/hemx-lsp/src/main.rs new file mode 100644 index 0000000..cadd4da --- /dev/null +++ b/hemx-lsp/src/main.rs @@ -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::>().as_slice()), + Some("diagnostics") | Some("check") => run_diagnostics(args.collect::>().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(reader: &mut R, writer: &mut W) -> io::Result<()> { + let mut open_documents = HashMap::::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(reader: &mut R) -> io::Result> { + 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::().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(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( + writer: &mut W, + uri: &str, + diagnostics: Vec, +) -> 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::>() + }) +} + +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 { + message + .get("params")? + .get("text")? + .as_str() + .map(str::to_owned) +} + +fn text_document_uri(message: &serde_json::Value) -> Option { + 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> { + 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 { + 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 { + 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::>(); + 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::(); + 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("').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!(""); + 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 { + let rest = text[tag_start + 1..].trim_start(); + let name = rest + .chars() + .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '-' || *ch == ':') + .collect::(); + (!name.is_empty()).then_some(name) +} + +fn byte_offset_for_position(text: &str, position: (usize, usize)) -> Option { + 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, pub progress: String } + "#, + ) + .expect("write lib"); + let template = dir.join("workout.heml"); + let text = r#"

    {+ self. +}

  • {+ exercise. +}{+ exercise.name +}
  • {+ self.progress +} {+ exercise. +} {+ exercise.name +}

    "#; + 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#"
    "#; + 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) + } +}