@@ -93,6 +93,13 @@ impl LanguageServer for Backend {
93 linked_editing_range_provider: Some(
93 linked_editing_range_provider: Some(
94 LinkedEditingRangeServerCapabilities::Simple(true),
94 LinkedEditingRangeServerCapabilities::Simple(true),
96+ document_formatting_provider: Some(OneOf::Left(true)),
97+ references_provider: Some(OneOf::Left(true)),
98+ signature_help_provider: Some(SignatureHelpOptions {
99+ trigger_characters: Some(vec!["[".into(), ",".into()]),
100+ retrigger_characters: Some(vec![",".into()]),
101+ work_done_progress_options: Default::default(),
96 ..Default::default()
103 ..Default::default()
98 ..Default::default()
105 ..Default::default()
@@ -385,6 +392,60 @@ impl LanguageServer for Backend {
385 drop(docs);
392 drop(docs);
386 Ok(linked_editing_ranges(&text, pos))
393 Ok(linked_editing_ranges(&text, pos))
396+ async fn formatting(
398+ params: DocumentFormattingParams,
399+ ) -> Result<Option<Vec<TextEdit>>> {
400+ let uri = ¶ms.text_document.uri;
401+ let docs = self.documents.read().await;
402+ let text = match docs.get(uri) {
403+ Some(t) => t.clone(),
404+ None => return Ok(None),
407+ let formatted = htmlang::fmt::format(&text);
408+ if formatted == text {
411+ let last_line = text.lines().count().saturating_sub(1) as u32;
412+ let last_col = text.lines().last().map_or(0, |l| l.len()) as u32;
413+ Ok(Some(vec![TextEdit {
414+ range: Range::new(Position::new(0, 0), Position::new(last_line, last_col)),
415+ new_text: formatted,
419+ async fn references(
421+ params: ReferenceParams,
422+ ) -> Result<Option<Vec<Location>>> {
423+ let uri = params.text_document_position.text_document.uri.clone();
424+ let pos = params.text_document_position.position;
425+ let docs = self.documents.read().await;
426+ let text = match docs.get(&uri) {
427+ Some(t) => t.clone(),
428+ None => return Ok(None),
431+ let refs = find_references(&text, pos, &uri);
432+ Ok(if refs.is_empty() { None } else { Some(refs) })
435+ async fn signature_help(
437+ params: SignatureHelpParams,
438+ ) -> Result<Option<SignatureHelp>> {
439+ let uri = ¶ms.text_document_position_params.text_document.uri;
440+ let pos = params.text_document_position_params.position;
441+ let docs = self.documents.read().await;
442+ let text = match docs.get(uri) {
443+ Some(t) => t.clone(),
444+ None => return Ok(None),
447+ Ok(get_signature_help(&text, pos))
390// ---------------------------------------------------------------------------
451// ---------------------------------------------------------------------------
@@ -419,7 +480,7 @@ fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
419 // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
480 // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
420 if let Some(colon) = current_word.find(':') {
481 if let Some(colon) = current_word.find(':') {
421 let prefix = ¤t_word[..colon];
482 let prefix = ¤t_word[..colon];
422− if matches!(prefix, "hover" | "active" | "focus" | "focus-visible" | "focus-within" | "disabled" | "checked" | "placeholder" | "first" | "last" | "odd" | "even" | "dark" | "print" | "sm" | "md" | "lg" | "xl" | "2xl" | "motion-safe" | "motion-reduce" | "landscape" | "portrait") {
483+ if matches!(prefix, "hover" | "active" | "focus" | "focus-visible" | "focus-within" | "disabled" | "checked" | "placeholder" | "first" | "last" | "odd" | "even" | "before" | "after" | "dark" | "print" | "sm" | "md" | "lg" | "xl" | "2xl" | "motion-safe" | "motion-reduce" | "landscape" | "portrait") {
423 return state_attr_completions(prefix, edit_range);
484 return state_attr_completions(prefix, edit_range);
@@ -576,6 +637,10 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
576 ("@abbr", "Abbreviation (abbr)"),
637 ("@abbr", "Abbreviation (abbr)"),
577 // Datalist
638 // Datalist
578 ("@datalist", "Predefined options for @input (datalist)"),
639 ("@datalist", "Predefined options for @input (datalist)"),
641+ ("@iframe", "Embedded external page (iframe)"),
642+ ("@output", "Form calculation result (output)"),
643+ ("@canvas", "Drawing surface for scripts (canvas)"),
581 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
646 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
@@ -639,6 +704,16 @@ fn snippet_completions(range: Range) -> Vec<CompletionItem> {
639 "Hero section with title and subtitle",
704 "Hero section with title and subtitle",
640 "@column [padding 80, center-x, center-y, min-height 60vh]\n @text [bold, size 48] ${1:Title}\n @paragraph [size 18, color #666] ${2:Subtitle}",
705 "@column [padding 80, center-x, center-y, min-height 60vh]\n @text [bold, size 48] ${1:Title}\n @paragraph [size 18, color #666] ${2:Subtitle}",
709+ "Loop with empty-state fallback",
710+ "@each \\$${1:item} in ${2:list}\n @text \\$${1:item}\n@else\n @text [color #888] No items found.",
713+ "button with hover",
714+ "Interactive button with hover effect",
715+ "@el [padding 12 24, background ${1:#3b82f6}, hover:background ${2:#2563eb}, rounded 8, cursor pointer, transition all 0.15s ease] > @link ${3:url}\n @text [color white, bold] ${4:Click me}",
@@ -942,6 +1017,26 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
942 ("place-content", "Shorthand for align-content + justify-content", true),
1017 ("place-content", "Shorthand for align-content + justify-content", true),
943 // Background image
1018 // Background image
944 ("background-image", "Background image (url or gradient)", true),
1019 ("background-image", "Background image (url or gradient)", true),
1020+ // New CSS properties
1021+ ("font-weight", "Font weight (100-900, bold, lighter, bolder)", true),
1022+ ("font-style", "Font style (normal/italic/oblique)", true),
1023+ ("text-wrap", "Text wrapping (balance/pretty/nowrap)", true),
1024+ ("will-change", "Performance hint for animations (transform, opacity)", true),
1025+ ("touch-action", "Touch behavior (none/pan-x/pan-y/manipulation)", true),
1026+ ("vertical-align", "Vertical alignment (middle/top/bottom/baseline)", true),
1027+ ("contain", "CSS containment (layout/paint/content/strict)", true),
1028+ ("content-visibility", "Content visibility (auto/visible/hidden)", true),
1029+ ("scroll-margin", "Scroll margin (for scroll-snap and anchor offsets)", true),
1030+ ("scroll-margin-top", "Scroll margin top", true),
1031+ ("scroll-padding", "Scroll padding (for scroll-snap containers)", true),
1032+ ("scroll-padding-top", "Scroll padding top", true),
1033+ // Pseudo-element content
1034+ ("content", "Content for ::before/::after (use with before:/after: prefix)", true),
1035+ // Iframe/form/link attributes
1036+ ("sandbox", "Iframe sandbox restrictions", true),
1037+ ("allow", "Iframe permissions policy", true),
1038+ ("allowfullscreen", "Allow iframe fullscreen", false),
1039+ ("target", "Link/form target (_blank/_self/_parent/_top)", true),
947 .map(|(name, detail, takes_value)| {
1042 .map(|(name, detail, takes_value)| {
@@ -1208,6 +1303,10 @@ fn hover_builtin(word: &str) -> Option<String> {
1208 (Some("odd"), rest)
1303 (Some("odd"), rest)
1209 } else if let Some(rest) = word.strip_prefix("even:") {
1304 } else if let Some(rest) = word.strip_prefix("even:") {
1210 (Some("even"), rest)
1305 (Some("even"), rest)
1306+ } else if let Some(rest) = word.strip_prefix("before:") {
1307+ (Some("before"), rest)
1308+ } else if let Some(rest) = word.strip_prefix("after:") {
1309+ (Some("after"), rest)
1211 } else if let Some(rest) = word.strip_prefix("sm:") {
1310 } else if let Some(rest) = word.strip_prefix("sm:") {
1212 (Some("sm"), rest)
1311 (Some("sm"), rest)
1213 } else if let Some(rest) = word.strip_prefix("md:") {
1312 } else if let Some(rest) = word.strip_prefix("md:") {
@@ -1482,6 +1581,22 @@ fn hover_builtin(word: &str) -> Option<String> {
1482 "isolation" => "**isolation** `<value>` \u{2014} Creates a new stacking context (`isolate`, `auto`).",
1581 "isolation" => "**isolation** `<value>` \u{2014} Creates a new stacking context (`isolate`, `auto`).",
1483 "place-content" => "**place-content** `<value>` \u{2014} Shorthand for `align-content` and `justify-content`.",
1582 "place-content" => "**place-content** `<value>` \u{2014} Shorthand for `align-content` and `justify-content`.",
1484 "background-image" => "**background-image** `<value>` \u{2014} Background image (`url()` or gradient function).",
1583 "background-image" => "**background-image** `<value>` \u{2014} Background image (`url()` or gradient function).",
1584+ // New CSS properties (batch 2)
1585+ "font-weight" => "**font-weight** `<value>` \u{2014} Font weight (`100`\u{2013}`900`, `bold`, `lighter`, `bolder`). More precise than `bold`.",
1586+ "font-style" => "**font-style** `<value>` \u{2014} Font style (`normal`, `italic`, `oblique`). More precise than `italic`.",
1587+ "text-wrap" => "**text-wrap** `<value>` \u{2014} Text wrapping behavior (`balance`, `pretty`, `nowrap`, `wrap`). `balance` is great for headings.",
1588+ "will-change" => "**will-change** `<value>` \u{2014} Performance hint for upcoming changes (`transform`, `opacity`, `scroll-position`).",
1589+ "touch-action" => "**touch-action** `<value>` \u{2014} Touch behavior for mobile (`none`, `pan-x`, `pan-y`, `manipulation`, `auto`).",
1590+ "vertical-align" => "**vertical-align** `<value>` \u{2014} Vertical alignment for inline elements (`middle`, `top`, `bottom`, `baseline`, `text-top`).",
1591+ "contain" => "**contain** `<value>` \u{2014} CSS containment for performance (`layout`, `paint`, `content`, `strict`, `size`).",
1592+ "content-visibility" => "**content-visibility** `<value>` \u{2014} Content rendering optimization (`auto`, `visible`, `hidden`). `auto` enables lazy rendering.",
1593+ "scroll-margin" => "**scroll-margin** `<value>` \u{2014} Scroll margin (for scroll-snap and anchor link offsets). Supports CSS units.",
1594+ "scroll-padding" => "**scroll-padding** `<value>` \u{2014} Scroll padding for scroll-snap containers. Supports CSS units.",
1595+ "content" => "**content** `<value>` \u{2014} CSS content property. Use with `before:` or `after:` prefix.\n\nExample: `before:content \u{2192}, before:color red`",
1597+ "@iframe" => "**@iframe** \u{2014} Embedded page\n\nRenders as `<iframe>`.\n\nUsage: `@iframe [width fill, height 400] https://example.com`\n\nAttributes: `sandbox`, `allow`, `allowfullscreen`",
1598+ "@output" => "**@output** \u{2014} Form output\n\nRenders as `<output>`. Displays calculation results in forms.\n\nUsage: `@output [for a b] Result`",
1599+ "@canvas" => "**@canvas** \u{2014} Drawing surface\n\nRenders as `<canvas>`. Use with `@raw` JavaScript for drawing.\n\nUsage: `@canvas [width 400, height 300, id myCanvas]`",
1485 _ => return None,
1600 _ => return None,
@@ -1964,6 +2079,120 @@ fn code_actions(
2083+ // Quick-fix: remove unused @let variable
2084+ if msg.contains("unused variable") {
2085+ let line = diag.range.start.line as usize;
2086+ let lines: Vec<&str> = text.lines().collect();
2087+ if let Some(source_line) = lines.get(line) {
2088+ let trimmed = source_line.trim_start();
2089+ if trimmed.starts_with("@let ") {
2090+ let edit = TextEdit {
2092+ Position::new(diag.range.start.line, 0),
2093+ Position::new(diag.range.start.line + 1, 0),
2095+ new_text: String::new(),
2097+ let mut changes = HashMap::new();
2098+ changes.insert(uri.clone(), vec![edit]);
2099+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2100+ title: "Remove unused variable".into(),
2101+ kind: Some(CodeActionKind::QUICKFIX),
2102+ diagnostics: Some(vec![diag.clone()]),
2103+ edit: Some(WorkspaceEdit {
2104+ changes: Some(changes),
2105+ ..Default::default()
2107+ ..Default::default()
2113+ // Quick-fix: remove unused @define
2114+ if msg.contains("unused define") {
2115+ let line = diag.range.start.line as usize;
2116+ let lines: Vec<&str> = text.lines().collect();
2117+ if let Some(source_line) = lines.get(line) {
2118+ let trimmed = source_line.trim_start();
2119+ if trimmed.starts_with("@define ") {
2120+ let edit = TextEdit {
2122+ Position::new(diag.range.start.line, 0),
2123+ Position::new(diag.range.start.line + 1, 0),
2125+ new_text: String::new(),
2127+ let mut changes = HashMap::new();
2128+ changes.insert(uri.clone(), vec![edit]);
2129+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2130+ title: "Remove unused define".into(),
2131+ kind: Some(CodeActionKind::QUICKFIX),
2132+ diagnostics: Some(vec![diag.clone()]),
2133+ edit: Some(WorkspaceEdit {
2134+ changes: Some(changes),
2135+ ..Default::default()
2137+ ..Default::default()
2143+ // Quick-fix: add alt attribute to @image
2144+ if msg.contains("@image should have") && msg.contains("alt") {
2145+ let line = diag.range.start.line as usize;
2146+ let lines: Vec<&str> = text.lines().collect();
2147+ if let Some(source_line) = lines.get(line) {
2148+ if let Some(bracket_pos) = source_line.find('[') {
2149+ let insert_pos = bracket_pos + 1;
2150+ let edit = TextEdit {
2152+ Position::new(diag.range.start.line, insert_pos as u32),
2153+ Position::new(diag.range.start.line, insert_pos as u32),
2155+ new_text: "alt , ".into(),
2157+ let mut changes = HashMap::new();
2158+ changes.insert(uri.clone(), vec![edit]);
2159+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2160+ title: "Add alt attribute".into(),
2161+ kind: Some(CodeActionKind::QUICKFIX),
2162+ diagnostics: Some(vec![diag.clone()]),
2163+ edit: Some(WorkspaceEdit {
2164+ changes: Some(changes),
2165+ ..Default::default()
2167+ ..Default::default()
2169+ } else if source_line.contains("@image") {
2170+ // No brackets yet, add them
2171+ if let Some(img_pos) = source_line.find("@image") {
2172+ let after_image = img_pos + "@image".len();
2173+ let edit = TextEdit {
2175+ Position::new(diag.range.start.line, after_image as u32),
2176+ Position::new(diag.range.start.line, after_image as u32),
2178+ new_text: " [alt ]".into(),
2180+ let mut changes = HashMap::new();
2181+ changes.insert(uri.clone(), vec![edit]);
2182+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2183+ title: "Add alt attribute".into(),
2184+ kind: Some(CodeActionKind::QUICKFIX),
2185+ diagnostics: Some(vec![diag.clone()]),
2186+ edit: Some(WorkspaceEdit {
2187+ changes: Some(changes),
2188+ ..Default::default()
2190+ ..Default::default()
@@ -2358,6 +2587,160 @@ fn linked_editing_ranges(text: &str, position: Position) -> Option<LinkedEditing
2590+// ---------------------------------------------------------------------------
2592+// ---------------------------------------------------------------------------
2594+fn find_references(text: &str, position: Position, uri: &Url) -> Vec<Location> {
2595+ let lines: Vec<&str> = text.lines().collect();
2596+ let line = match lines.get(position.line as usize) {
2598+ None => return vec![],
2600+ let col = (position.character as usize).min(line.len());
2601+ let bytes = line.as_bytes();
2603+ // Find the word at cursor
2604+ let mut start = col;
2605+ while start > 0 && is_word_byte(bytes[start - 1]) {
2609+ while end < bytes.len() && is_word_byte(bytes[end]) {
2615+ let word = &line[start..end];
2617+ // Determine the search pattern
2618+ let search = if word.starts_with('$') || word.starts_with('@') {
2620+ } else if start > 0 && bytes[start - 1] == b'$' {
2621+ format!("${}", word)
2622+ } else if start > 0 && bytes[start - 1] == b'@' {
2623+ format!("@{}", word)
2628+ let mut locations = Vec::new();
2629+ for (line_idx, line_text) in text.lines().enumerate() {
2630+ let mut offset = 0;
2631+ while let Some(pos) = line_text[offset..].find(&search) {
2632+ let abs_pos = offset + pos;
2633+ let after = abs_pos + search.len();
2634+ // Ensure word boundary
2635+ let before_ok = abs_pos == 0 || {
2636+ let c = line_text.as_bytes()[abs_pos - 1];
2637+ !c.is_ascii_alphanumeric() && c != b'_' && c != b'-'
2639+ let after_ok = after >= line_text.len() || {
2640+ let c = line_text.as_bytes()[after];
2641+ !c.is_ascii_alphanumeric() && c != b'_' && c != b'-'
2643+ if before_ok && after_ok {
2644+ locations.push(Location {
2647+ Position::new(line_idx as u32, abs_pos as u32),
2648+ Position::new(line_idx as u32, after as u32),
2659+// ---------------------------------------------------------------------------
2661+// ---------------------------------------------------------------------------
2663+fn get_signature_help(text: &str, position: Position) -> Option<SignatureHelp> {
2664+ let lines: Vec<&str> = text.lines().collect();
2665+ let line = lines.get(position.line as usize)?;
2666+ let col = (position.character as usize).min(line.len());
2667+ let before = &line[..col];
2669+ // Check if we're inside a function call: @funcname [...
2670+ let trimmed = before.trim_start();
2671+ if !trimmed.starts_with('@') {
2675+ // Extract the function name
2676+ let after_at = &trimmed[1..];
2677+ let name_end = after_at.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
2678+ .unwrap_or(after_at.len());
2679+ let fn_name = &after_at[..name_end];
2681+ // Must be inside brackets
2682+ if !in_brackets(before) {
2686+ // Find the @fn definition
2687+ for (line_idx, line_text) in text.lines().enumerate() {
2688+ let t = line_text.trim_start();
2689+ if let Some(rest) = t.strip_prefix("@fn ") {
2690+ let rest = rest.trim();
2691+ let def_name_end = rest.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
2692+ .unwrap_or(rest.len());
2693+ let def_name = &rest[..def_name_end];
2694+ if def_name != fn_name {
2697+ let params_str = &rest[def_name_end..].trim();
2698+ let params: Vec<&str> = params_str
2699+ .split_whitespace()
2700+ .filter(|p| p.starts_with('$'))
2703+ if params.is_empty() {
2707+ let param_labels: Vec<ParameterInformation> = params.iter().map(|p| {
2708+ let name = p.trim_start_matches('$');
2709+ let (label, doc) = if let Some((n, default)) = name.split_once('=') {
2710+ (n.to_string(), Some(format!("Default: {}", default)))
2712+ (name.to_string(), None)
2714+ ParameterInformation {
2715+ label: ParameterLabel::Simple(label),
2716+ documentation: doc.map(|d| Documentation::String(d)),
2720+ let sig_label = format!("@{} {}", fn_name, params.join(" "));
2722+ // Determine active parameter by counting commas before cursor inside brackets
2723+ let bracket_start = before.rfind('[').unwrap_or(0);
2724+ let inside = &before[bracket_start..];
2725+ let active_param = inside.matches(',').count() as u32;
2727+ return Some(SignatureHelp {
2728+ signatures: vec![SignatureInformation {
2730+ documentation: Some(Documentation::String(
2731+ format!("Defined at line {}", line_idx + 1),
2733+ parameters: Some(param_labels),
2734+ active_parameter: Some(active_param),
2736+ active_signature: Some(0),
2737+ active_parameter: Some(active_param),
2361// ---------------------------------------------------------------------------
2744// ---------------------------------------------------------------------------
2362// Entry point
2745// Entry point
2363// ---------------------------------------------------------------------------
2746// ---------------------------------------------------------------------------