feat(diagnostics): add compiler-backed heml language service
Add hemx-lsp for stdio LSP diagnostics, completion, and hover while preserving HTML editor tooling for .heml files. Teach hemx-build to expose generated target and derive-known template context facts, including simple h-for locals, so editor help comes from build-owned facts instead of editor-only parsers. Wire VS Code/Cursor and Neovim documentation and extend the Workout exemplar with a real h-for plan loop for end-to-end proof. req: diag/004 req: diag/005 req: diag/006
This commit is contained in:
@@ -0,0 +1,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