@@ -0,0 +1,610 @@
1+use std::collections::HashMap;
2+use std::sync::Arc;
3+
4+use tokio::sync::RwLock;
5+use tower_lsp::jsonrpc::Result;
6+use tower_lsp::lsp_types::*;
7+use tower_lsp::{Client, LanguageServer, LspService, Server};
8+
9+// ---------------------------------------------------------------------------
10+// Backend
11+// ---------------------------------------------------------------------------
12+
13+struct Backend {
14+ client: Client,
15+ documents: Arc<RwLock<HashMap<Url, String>>>,
16+}
17+
18+impl Backend {
19+ async fn on_change(&self, uri: Url, text: String) {
20+ let diags = match htmlang::parser::parse(&text) {
21+ Ok(_) => vec![],
22+ Err(e) => {
23+ let line = e.line.saturating_sub(1) as u32;
24+ vec![Diagnostic {
25+ range: Range::new(Position::new(line, 0), Position::new(line, 1000)),
26+ severity: Some(DiagnosticSeverity::ERROR),
27+ source: Some("htmlang".into()),
28+ message: e.message,
29+ ..Default::default()
30+ }]
31+ }
32+ };
33+ self.client
34+ .publish_diagnostics(uri.clone(), diags, None)
35+ .await;
36+ self.documents.write().await.insert(uri, text);
37+ }
38+}
39+
40+#[tower_lsp::async_trait]
41+impl LanguageServer for Backend {
42+ async fn initialize(&self, _: InitializeParams) -> Result<InitializeResult> {
43+ Ok(InitializeResult {
44+ capabilities: ServerCapabilities {
45+ text_document_sync: Some(TextDocumentSyncCapability::Kind(
46+ TextDocumentSyncKind::FULL,
47+ )),
48+ completion_provider: Some(CompletionOptions {
49+ trigger_characters: Some(vec![
50+ "@".into(),
51+ "$".into(),
52+ "[".into(),
53+ ",".into(),
54+ ]),
55+ ..Default::default()
56+ }),
57+ hover_provider: Some(HoverProviderCapability::Simple(true)),
58+ ..Default::default()
59+ },
60+ ..Default::default()
61+ })
62+ }
63+
64+ async fn initialized(&self, _: InitializedParams) {}
65+
66+ async fn shutdown(&self) -> Result<()> {
67+ Ok(())
68+ }
69+
70+ async fn did_open(&self, params: DidOpenTextDocumentParams) {
71+ self.on_change(params.text_document.uri, params.text_document.text)
72+ .await;
73+ }
74+
75+ async fn did_change(&self, params: DidChangeTextDocumentParams) {
76+ if let Some(change) = params.content_changes.into_iter().last() {
77+ self.on_change(params.text_document.uri, change.text).await;
78+ }
79+ }
80+
81+ async fn did_close(&self, params: DidCloseTextDocumentParams) {
82+ self.documents
83+ .write()
84+ .await
85+ .remove(¶ms.text_document.uri);
86+ self.client
87+ .publish_diagnostics(params.text_document.uri, vec![], None)
88+ .await;
89+ }
90+
91+ async fn completion(
92+ &self,
93+ params: CompletionParams,
94+ ) -> Result<Option<CompletionResponse>> {
95+ let uri = ¶ms.text_document_position.text_document.uri;
96+ let pos = params.text_document_position.position;
97+ let docs = self.documents.read().await;
98+ let text = match docs.get(uri) {
99+ Some(t) => t.clone(),
100+ None => return Ok(None),
101+ };
102+ drop(docs);
103+
104+ let items = completions(&text, pos);
105+ Ok(if items.is_empty() {
106+ None
107+ } else {
108+ Some(CompletionResponse::Array(items))
109+ })
110+ }
111+
112+ async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
113+ let uri = ¶ms.text_document_position_params.text_document.uri;
114+ let pos = params.text_document_position_params.position;
115+ let docs = self.documents.read().await;
116+ let text = match docs.get(uri) {
117+ Some(t) => t.clone(),
118+ None => return Ok(None),
119+ };
120+ drop(docs);
121+
122+ Ok(hover_at(&text, pos))
123+ }
124+}
125+
126+// ---------------------------------------------------------------------------
127+// Completions
128+// ---------------------------------------------------------------------------
129+
130+fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
131+ let lines: Vec<&str> = text.lines().collect();
132+ let line = match lines.get(position.line as usize) {
133+ Some(l) => *l,
134+ None => return vec![],
135+ };
136+
137+ let col = (position.character as usize).min(line.len());
138+ let before = &line[..col];
139+
140+ let word_start = find_word_start(before);
141+ let edit_range = Range::new(
142+ Position::new(position.line, word_start as u32),
143+ position,
144+ );
145+
146+ // Inside attribute brackets?
147+ if in_brackets(before) {
148+ let current_word = &before[word_start..];
149+
150+ // $ variable/define reference
151+ if current_word.starts_with('$') {
152+ return variable_completions(text, edit_range);
153+ }
154+
155+ // State prefix (hover:, active:, focus:)
156+ if let Some(colon) = current_word.find(':') {
157+ let prefix = ¤t_word[..colon];
158+ if matches!(prefix, "hover" | "active" | "focus") {
159+ return state_attr_completions(prefix, edit_range);
160+ }
161+ }
162+
163+ return attr_completions(edit_range);
164+ }
165+
166+ // $ variable reference outside brackets
167+ let current_word = &before[word_start..];
168+ if current_word.starts_with('$') {
169+ return variable_completions(text, edit_range);
170+ }
171+
172+ // @ element/directive or start of line
173+ let trimmed = before.trim_start();
174+ if trimmed.is_empty() || trimmed.starts_with('@') {
175+ let mut items = element_completions(edit_range);
176+ items.extend(directive_completions(edit_range));
177+ items.extend(function_completions(text, edit_range));
178+ return items;
179+ }
180+
181+ vec![]
182+}
183+
184+fn find_word_start(text: &str) -> usize {
185+ let bytes = text.as_bytes();
186+ let mut i = bytes.len();
187+ while i > 0 {
188+ let c = bytes[i - 1];
189+ if c.is_ascii_alphanumeric()
190+ || c == b'@'
191+ || c == b'$'
192+ || c == b'-'
193+ || c == b'_'
194+ || c == b':'
195+ {
196+ i -= 1;
197+ } else {
198+ break;
199+ }
200+ }
201+ i
202+}
203+
204+fn in_brackets(text: &str) -> bool {
205+ let mut depth: i32 = 0;
206+ for ch in text.chars() {
207+ if ch == '[' {
208+ depth += 1;
209+ } else if ch == ']' {
210+ depth -= 1;
211+ }
212+ }
213+ depth > 0
214+}
215+
216+fn item(
217+ label: &str,
218+ kind: CompletionItemKind,
219+ detail: &str,
220+ insert: &str,
221+ range: Range,
222+) -> CompletionItem {
223+ CompletionItem {
224+ label: label.to_string(),
225+ kind: Some(kind),
226+ detail: Some(detail.to_string()),
227+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
228+ range,
229+ new_text: insert.to_string(),
230+ })),
231+ ..Default::default()
232+ }
233+}
234+
235+fn element_completions(range: Range) -> Vec<CompletionItem> {
236+ [
237+ ("@row", "Horizontal layout (flexbox row)"),
238+ ("@column", "Vertical layout (flexbox column)"),
239+ ("@col", "Vertical layout (short for @column)"),
240+ ("@el", "Generic container"),
241+ ("@text", "Styled inline text (span)"),
242+ ("@paragraph", "Flowing text block (p)"),
243+ ("@p", "Flowing text block (short for @paragraph)"),
244+ ("@image", "Image element"),
245+ ("@img", "Image element (short for @image)"),
246+ ("@link", "Anchor/link element"),
247+ ("@raw", "Raw HTML escape hatch"),
248+ ("@children", "Slot for caller's children (inside @fn)"),
249+ ]
250+ .iter()
251+ .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
252+ .collect()
253+}
254+
255+fn directive_completions(range: Range) -> Vec<CompletionItem> {
256+ [
257+ ("@page", "Set HTML page title", "@page "),
258+ ("@let", "Define a variable", "@let "),
259+ ("@define", "Define an attribute bundle", "@define "),
260+ ("@fn", "Define a reusable function", "@fn "),
261+ ]
262+ .iter()
263+ .map(|(name, detail, insert)| {
264+ item(name, CompletionItemKind::SNIPPET, detail, insert, range)
265+ })
266+ .collect()
267+}
268+
269+fn attr_completions(range: Range) -> Vec<CompletionItem> {
270+ [
271+ // Layout
272+ ("spacing", "Gap between children (px)", true),
273+ ("padding", "Inner padding (1/2/4 values, px)", true),
274+ ("padding-x", "Horizontal padding (px)", true),
275+ ("padding-y", "Vertical padding (px)", true),
276+ // Sizing
277+ ("width", "Width (px | fill | shrink)", true),
278+ ("height", "Height (px | fill | shrink)", true),
279+ ("min-width", "Minimum width (px)", true),
280+ ("max-width", "Maximum width (px)", true),
281+ ("min-height", "Minimum height (px)", true),
282+ ("max-height", "Maximum height (px)", true),
283+ // Alignment
284+ ("center-x", "Center horizontally", false),
285+ ("center-y", "Center vertically", false),
286+ ("align-left", "Align left", false),
287+ ("align-right", "Align right", false),
288+ ("align-top", "Align top", false),
289+ ("align-bottom", "Align bottom", false),
290+ // Style
291+ ("background", "Background color/value", true),
292+ ("color", "Text color", true),
293+ ("border", "Border (width [color])", true),
294+ ("rounded", "Border radius (px)", true),
295+ ("bold", "Bold text", false),
296+ ("italic", "Italic text", false),
297+ ("underline", "Underlined text", false),
298+ ("size", "Font size (px)", true),
299+ ("font", "Font family", true),
300+ ("transition", "CSS transition", true),
301+ ("cursor", "CSS cursor type", true),
302+ ("opacity", "Opacity (0-1)", true),
303+ // Flow
304+ ("wrap", "Enable flex-wrap", false),
305+ // Identity
306+ ("id", "HTML id attribute", true),
307+ ("class", "HTML class attribute", true),
308+ // State prefixes
309+ ("hover:", "Style on hover", false),
310+ ("active:", "Style on active/click", false),
311+ ("focus:", "Style on focus", false),
312+ ]
313+ .iter()
314+ .map(|(name, detail, takes_value)| {
315+ let insert = if *takes_value {
316+ format!("{} ", name)
317+ } else {
318+ name.to_string()
319+ };
320+ item(name, CompletionItemKind::PROPERTY, detail, &insert, range)
321+ })
322+ .collect()
323+}
324+
325+fn state_attr_completions(prefix: &str, range: Range) -> Vec<CompletionItem> {
326+ [
327+ ("background", "Background color/value", true),
328+ ("color", "Text color", true),
329+ ("border", "Border (width [color])", true),
330+ ("rounded", "Border radius (px)", true),
331+ ("bold", "Bold text", false),
332+ ("italic", "Italic text", false),
333+ ("underline", "Underlined text", false),
334+ ("size", "Font size (px)", true),
335+ ("opacity", "Opacity (0-1)", true),
336+ ("cursor", "CSS cursor type", true),
337+ ]
338+ .iter()
339+ .map(|(name, detail, takes_value)| {
340+ let full = format!("{}:{}", prefix, name);
341+ let insert = if *takes_value {
342+ format!("{} ", full)
343+ } else {
344+ full.clone()
345+ };
346+ item(&full, CompletionItemKind::PROPERTY, detail, &insert, range)
347+ })
348+ .collect()
349+}
350+
351+fn variable_completions(text: &str, range: Range) -> Vec<CompletionItem> {
352+ let mut items = Vec::new();
353+
354+ for line in text.lines() {
355+ let trimmed = line.trim();
356+ if let Some(rest) = trimmed.strip_prefix("@let ") {
357+ if let Some((name, value)) = rest.trim().split_once(' ') {
358+ items.push(item(
359+ &format!("${}", name),
360+ CompletionItemKind::VARIABLE,
361+ &format!("= {}", value.trim()),
362+ &format!("${}", name),
363+ range,
364+ ));
365+ }
366+ } else if let Some(rest) = trimmed.strip_prefix("@define ") {
367+ if let Some(bracket) = rest.find('[') {
368+ let name = rest[..bracket].trim();
369+ items.push(item(
370+ &format!("${}", name),
371+ CompletionItemKind::CONSTANT,
372+ "Attribute bundle",
373+ &format!("${}", name),
374+ range,
375+ ));
376+ }
377+ }
378+ }
379+
380+ items
381+}
382+
383+fn function_completions(text: &str, range: Range) -> Vec<CompletionItem> {
384+ let mut items = Vec::new();
385+
386+ for line in text.lines() {
387+ let trimmed = line.trim();
388+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
389+ let parts: Vec<&str> = rest.split_whitespace().collect();
390+ if let Some(name) = parts.first() {
391+ let params = parts[1..].join(" ");
392+ let detail = if params.is_empty() {
393+ "Function".to_string()
394+ } else {
395+ format!("Function({})", params)
396+ };
397+ items.push(item(
398+ &format!("@{}", name),
399+ CompletionItemKind::FUNCTION,
400+ &detail,
401+ &format!("@{}", name),
402+ range,
403+ ));
404+ }
405+ }
406+ }
407+
408+ items
409+}
410+
411+// ---------------------------------------------------------------------------
412+// Hover
413+// ---------------------------------------------------------------------------
414+
415+fn hover_at(text: &str, position: Position) -> Option<Hover> {
416+ let lines: Vec<&str> = text.lines().collect();
417+ let line = lines.get(position.line as usize)?;
418+ let col = (position.character as usize).min(line.len());
419+ let word = word_at(line, col)?;
420+
421+ let doc = if word.starts_with('$') {
422+ hover_variable(text, &word[1..])
423+ } else if word.starts_with('@') {
424+ hover_user_fn(text, &word[1..]).or_else(|| hover_builtin(&word))
425+ } else {
426+ hover_builtin(&word)
427+ }?;
428+
429+ Some(Hover {
430+ contents: HoverContents::Markup(MarkupContent {
431+ kind: MarkupKind::Markdown,
432+ value: doc,
433+ }),
434+ range: None,
435+ })
436+}
437+
438+fn word_at(line: &str, col: usize) -> Option<String> {
439+ let bytes = line.as_bytes();
440+ let mut start = col;
441+ while start > 0 && is_word_byte(bytes[start - 1]) {
442+ start -= 1;
443+ }
444+ let mut end = col;
445+ while end < bytes.len() && is_word_byte(bytes[end]) {
446+ end += 1;
447+ }
448+ if start == end {
449+ return None;
450+ }
451+ Some(line[start..end].to_string())
452+}
453+
454+fn is_word_byte(c: u8) -> bool {
455+ c.is_ascii_alphanumeric() || c == b'@' || c == b'$' || c == b'-' || c == b'_' || c == b':'
456+}
457+
458+fn hover_variable(text: &str, name: &str) -> Option<String> {
459+ for line in text.lines() {
460+ let trimmed = line.trim();
461+ if let Some(rest) = trimmed.strip_prefix("@let ") {
462+ if let Some((n, v)) = rest.trim().split_once(' ') {
463+ if n == name {
464+ return Some(format!("**${}** = `{}`", name, v.trim()));
465+ }
466+ }
467+ }
468+ }
469+
470+ for line in text.lines() {
471+ let trimmed = line.trim();
472+ if let Some(rest) = trimmed.strip_prefix("@define ") {
473+ let rest = rest.trim();
474+ if let Some(bracket) = rest.find('[') {
475+ if rest[..bracket].trim() == name {
476+ return Some(format!(
477+ "**${}** \u{2014} Attribute bundle\n\n`{}`",
478+ name, trimmed
479+ ));
480+ }
481+ }
482+ }
483+ }
484+
485+ for line in text.lines() {
486+ let trimmed = line.trim();
487+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
488+ let parts: Vec<&str> = rest.split_whitespace().collect();
489+ if let Some(fn_name) = parts.first() {
490+ for param in &parts[1..] {
491+ let p = param.strip_prefix('$').unwrap_or(param);
492+ if p == name {
493+ return Some(format!(
494+ "**${}** \u{2014} Parameter of `@{}`",
495+ name, fn_name
496+ ));
497+ }
498+ }
499+ }
500+ }
501+ }
502+
503+ None
504+}
505+
506+fn hover_user_fn(text: &str, name: &str) -> Option<String> {
507+ for line in text.lines() {
508+ let trimmed = line.trim();
509+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
510+ let parts: Vec<&str> = rest.split_whitespace().collect();
511+ if parts.first() == Some(&name) {
512+ let params = &parts[1..];
513+ let params_str = if params.is_empty() {
514+ String::new()
515+ } else {
516+ format!("\n\nParameters: {}", params.join(", "))
517+ };
518+ return Some(format!(
519+ "**@{}** \u{2014} User function{}",
520+ name, params_str
521+ ));
522+ }
523+ }
524+ }
525+ None
526+}
527+
528+fn hover_builtin(word: &str) -> Option<String> {
529+ // Strip state prefix for attribute lookup
530+ let (state, base) = if let Some(rest) = word.strip_prefix("hover:") {
531+ (Some("hover"), rest)
532+ } else if let Some(rest) = word.strip_prefix("active:") {
533+ (Some("active"), rest)
534+ } else if let Some(rest) = word.strip_prefix("focus:") {
535+ (Some("focus"), rest)
536+ } else {
537+ (None, word)
538+ };
539+
540+ let doc = match base {
541+ // Elements
542+ "@row" => "**@row** \u{2014} Horizontal layout\n\nRenders as `<div>` with `display: flex; flex-direction: row`.\n\nChildren are laid out left-to-right.",
543+ "@column" | "@col" => "**@column** \u{2014} Vertical layout\n\nRenders as `<div>` with `display: flex; flex-direction: column`.\n\nChildren are laid out top-to-bottom.",
544+ "@el" => "**@el** \u{2014} Generic container\n\nRenders as `<div>` with column flex layout.",
545+ "@text" => "**@text** \u{2014} Inline text\n\nRenders as `<span>`.\n\nUsage: `@text [bold, size 24] Hello world`",
546+ "@paragraph" | "@p" => "**@paragraph** \u{2014} Text block\n\nRenders as `<p>`.\n\nSupports inline elements: `{@text [bold] word}`",
547+ "@image" | "@img" => "**@image** \u{2014} Image\n\nRenders as `<img>`.\n\nUsage: `@image [width 200] https://example.com/photo.jpg`",
548+ "@link" => "**@link** \u{2014} Hyperlink\n\nRenders as `<a>`.\n\nUsage: `@link [color blue] https://example.com Link text`",
549+ "@raw" => "**@raw** \u{2014} Raw HTML\n\nPasses content through without processing.\n\nUsage: `@raw \"\"\"<div>custom html</div>\"\"\"`",
550+ "@page" => "**@page** \u{2014} Page title\n\nSets the HTML `<title>` and wraps output in a full document.\n\nUsage: `@page My Page Title`",
551+ "@let" => "**@let** \u{2014} Variable\n\nDefines a variable for `$name` substitution.\n\nUsage: `@let primary #3b82f6`",
552+ "@define" => "**@define** \u{2014} Attribute bundle\n\nDefines a reusable set of attributes.\n\nUsage: `@define card-style [padding 20, rounded 8]`\n\nApply with `$card-style` in attribute lists.",
553+ "@fn" => "**@fn** \u{2014} Function\n\nDefines a reusable component.\n\n```\n@fn card $title\n @el [padding 20]\n @text [bold] $title\n @children\n```",
554+ "@children" => "**@children** \u{2014} Children slot\n\nPlaceholder inside `@fn` body replaced with the caller's children.",
555+ // Attributes
556+ "spacing" => "**spacing** `<value>`\n\nGap between children in pixels. Maps to CSS `gap`.",
557+ "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <r> <b> <l>`\n\nInner padding in pixels. Accepts 1, 2, or 4 values.",
558+ "padding-x" => "**padding-x** `<value>`\n\nHorizontal padding (left + right) in pixels.",
559+ "padding-y" => "**padding-y** `<value>`\n\nVertical padding (top + bottom) in pixels.",
560+ "width" => "**width** `<px>` | `fill` | `shrink`\n\n- Number: fixed width in pixels\n- `fill`: expand to fill parent\n- `shrink`: prevent flex shrinking",
561+ "height" => "**height** `<px>` | `fill` | `shrink`\n\n- Number: fixed height in pixels\n- `fill`: expand to fill parent\n- `shrink`: prevent flex shrinking",
562+ "min-width" => "**min-width** `<value>` \u{2014} Minimum width in pixels.",
563+ "max-width" => "**max-width** `<value>` \u{2014} Maximum width in pixels.",
564+ "min-height" => "**min-height** `<value>` \u{2014} Minimum height in pixels.",
565+ "max-height" => "**max-height** `<value>` \u{2014} Maximum height in pixels.",
566+ "center-x" => "**center-x**\n\nCenter horizontally.\n\nIn column parent: `align-self: center`\nOtherwise: auto margins.",
567+ "center-y" => "**center-y**\n\nCenter vertically.\n\nIn row parent: `align-self: center`\nOtherwise: auto margins.",
568+ "align-left" => "**align-left** \u{2014} Align to the left edge.",
569+ "align-right" => "**align-right** \u{2014} Align to the right edge.",
570+ "align-top" => "**align-top** \u{2014} Align to the top edge.",
571+ "align-bottom" => "**align-bottom** \u{2014} Align to the bottom edge.",
572+ "background" => "**background** `<color>` \u{2014} Background color or CSS background value.",
573+ "color" => "**color** `<color>` \u{2014} Text color.",
574+ "border" => "**border** `<width> [color]`\n\nBorder. Width in pixels, color defaults to `currentColor`.",
575+ "rounded" => "**rounded** `<value>` \u{2014} Border radius in pixels.",
576+ "bold" => "**bold** \u{2014} Bold text (`font-weight: bold`).",
577+ "italic" => "**italic** \u{2014} Italic text (`font-style: italic`).",
578+ "underline" => "**underline** \u{2014} Underlined text.",
579+ "size" => "**size** `<value>` \u{2014} Font size in pixels.",
580+ "font" => "**font** `<family>` \u{2014} Font family.",
581+ "transition" => "**transition** `<value>` \u{2014} CSS transition (e.g., `all 0.15s ease`).",
582+ "cursor" => "**cursor** `<value>` \u{2014} CSS cursor (e.g., `pointer`).",
583+ "opacity" => "**opacity** `<value>` \u{2014} Opacity from 0 to 1.",
584+ "wrap" => "**wrap** \u{2014} Enable flex-wrap for children.",
585+ "id" => "**id** `<value>` \u{2014} HTML id attribute.",
586+ "class" => "**class** `<value>` \u{2014} HTML class attribute.",
587+ _ => return None,
588+ };
589+
590+ Some(if let Some(state) = state {
591+ format!("*({} state)* {}", state, doc)
592+ } else {
593+ doc.to_string()
594+ })
595+}
596+
597+// ---------------------------------------------------------------------------
598+// Entry point
599+// ---------------------------------------------------------------------------
600+
601+#[tokio::main]
602+async fn main() {
603+ let stdin = tokio::io::stdin();
604+ let stdout = tokio::io::stdout();
605+ let (service, socket) = LspService::new(|client| Backend {
606+ client,
607+ documents: Arc::new(RwLock::new(HashMap::new())),
608+ });
609+ Server::new(stdin, stdout, socket).serve(service).await;
610+}