@@ -93,6 +93,13 @@ impl LanguageServer for Backend {
9393 linked_editing_range_provider: Some(
9494 LinkedEditingRangeServerCapabilities::Simple(true),
9595 ),
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(),
102+ }),
96103 ..Default::default()
97104 },
98105 ..Default::default()
@@ -385,6 +392,60 @@ impl LanguageServer for Backend {
385392 drop(docs);
386393 Ok(linked_editing_ranges(&text, pos))
387394 }
395+
396+ async fn formatting(
397+ &self,
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),
405+ };
406+ drop(docs);
407+ let formatted = htmlang::fmt::format(&text);
408+ if formatted == text {
409+ return Ok(None);
410+ }
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,
416+ }]))
417+ }
418+
419+ async fn references(
420+ &self,
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),
429+ };
430+ drop(docs);
431+ let refs = find_references(&text, pos, &uri);
432+ Ok(if refs.is_empty() { None } else { Some(refs) })
433+ }
434+
435+ async fn signature_help(
436+ &self,
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),
445+ };
446+ drop(docs);
447+ Ok(get_signature_help(&text, pos))
448+ }
388449}
389450
390451// ---------------------------------------------------------------------------
@@ -419,7 +480,7 @@ fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
419480 // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
420481 if let Some(colon) = current_word.find(':') {
421482 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") {
423484 return state_attr_completions(prefix, edit_range);
424485 }
425486 }
@@ -576,6 +637,10 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
576637 ("@abbr", "Abbreviation (abbr)"),
577638 // Datalist
578639 ("@datalist", "Predefined options for @input (datalist)"),
640+ // New elements
641+ ("@iframe", "Embedded external page (iframe)"),
642+ ("@output", "Form calculation result (output)"),
643+ ("@canvas", "Drawing surface for scripts (canvas)"),
579644 ]
580645 .iter()
581646 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
@@ -639,6 +704,16 @@ fn snippet_completions(range: Range) -> Vec<CompletionItem> {
639704 "Hero section with title and subtitle",
640705 "@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}",
641706 ),
707+ (
708+ "each with else",
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.",
711+ ),
712+ (
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}",
716+ ),
642717 ];
643718
644719 snippets
@@ -942,6 +1017,26 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
9421017 ("place-content", "Shorthand for align-content + justify-content", true),
9431018 // Background image
9441019 ("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),
9451040 ]
9461041 .iter()
9471042 .map(|(name, detail, takes_value)| {
@@ -1208,6 +1303,10 @@ fn hover_builtin(word: &str) -> Option<String> {
12081303 (Some("odd"), rest)
12091304 } else if let Some(rest) = word.strip_prefix("even:") {
12101305 (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)
12111310 } else if let Some(rest) = word.strip_prefix("sm:") {
12121311 (Some("sm"), rest)
12131312 } else if let Some(rest) = word.strip_prefix("md:") {
@@ -1482,6 +1581,22 @@ fn hover_builtin(word: &str) -> Option<String> {
14821581 "isolation" => "**isolation** `<value>` \u{2014} Creates a new stacking context (`isolate`, `auto`).",
14831582 "place-content" => "**place-content** `<value>` \u{2014} Shorthand for `align-content` and `justify-content`.",
14841583 "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`",
1596+ // New elements
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]`",
14851600 _ => return None,
14861601 };
14871602
@@ -1964,6 +2079,120 @@ fn code_actions(
19642079 }
19652080 }
19662081 }
2082+
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 {
2091+ range: Range::new(
2092+ Position::new(diag.range.start.line, 0),
2093+ Position::new(diag.range.start.line + 1, 0),
2094+ ),
2095+ new_text: String::new(),
2096+ };
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()
2106+ }),
2107+ ..Default::default()
2108+ }));
2109+ }
2110+ }
2111+ }
2112+
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 {
2121+ range: Range::new(
2122+ Position::new(diag.range.start.line, 0),
2123+ Position::new(diag.range.start.line + 1, 0),
2124+ ),
2125+ new_text: String::new(),
2126+ };
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()
2136+ }),
2137+ ..Default::default()
2138+ }));
2139+ }
2140+ }
2141+ }
2142+
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 {
2151+ range: Range::new(
2152+ Position::new(diag.range.start.line, insert_pos as u32),
2153+ Position::new(diag.range.start.line, insert_pos as u32),
2154+ ),
2155+ new_text: "alt , ".into(),
2156+ };
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()
2166+ }),
2167+ ..Default::default()
2168+ }));
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 {
2174+ range: Range::new(
2175+ Position::new(diag.range.start.line, after_image as u32),
2176+ Position::new(diag.range.start.line, after_image as u32),
2177+ ),
2178+ new_text: " [alt ]".into(),
2179+ };
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()
2189+ }),
2190+ ..Default::default()
2191+ }));
2192+ }
2193+ }
2194+ }
2195+ }
19672196 }
19682197
19692198 actions
@@ -2358,6 +2587,160 @@ fn linked_editing_ranges(text: &str, position: Position) -> Option<LinkedEditing
23582587 })
23592588}
23602589
2590+// ---------------------------------------------------------------------------
2591+// Find references
2592+// ---------------------------------------------------------------------------
2593+
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) {
2597+ Some(l) => *l,
2598+ None => return vec![],
2599+ };
2600+ let col = (position.character as usize).min(line.len());
2601+ let bytes = line.as_bytes();
2602+
2603+ // Find the word at cursor
2604+ let mut start = col;
2605+ while start > 0 && is_word_byte(bytes[start - 1]) {
2606+ start -= 1;
2607+ }
2608+ let mut end = col;
2609+ while end < bytes.len() && is_word_byte(bytes[end]) {
2610+ end += 1;
2611+ }
2612+ if start == end {
2613+ return vec![];
2614+ }
2615+ let word = &line[start..end];
2616+
2617+ // Determine the search pattern
2618+ let search = if word.starts_with('$') || word.starts_with('@') {
2619+ word.to_string()
2620+ } else if start > 0 && bytes[start - 1] == b'$' {
2621+ format!("${}", word)
2622+ } else if start > 0 && bytes[start - 1] == b'@' {
2623+ format!("@{}", word)
2624+ } else {
2625+ return vec![];
2626+ };
2627+
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'-'
2638+ };
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'-'
2642+ };
2643+ if before_ok && after_ok {
2644+ locations.push(Location {
2645+ uri: uri.clone(),
2646+ range: Range::new(
2647+ Position::new(line_idx as u32, abs_pos as u32),
2648+ Position::new(line_idx as u32, after as u32),
2649+ ),
2650+ });
2651+ }
2652+ offset = after;
2653+ }
2654+ }
2655+
2656+ locations
2657+}
2658+
2659+// ---------------------------------------------------------------------------
2660+// Signature help
2661+// ---------------------------------------------------------------------------
2662+
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];
2668+
2669+ // Check if we're inside a function call: @funcname [...
2670+ let trimmed = before.trim_start();
2671+ if !trimmed.starts_with('@') {
2672+ return None;
2673+ }
2674+
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];
2680+
2681+ // Must be inside brackets
2682+ if !in_brackets(before) {
2683+ return None;
2684+ }
2685+
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 {
2695+ continue;
2696+ }
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('$'))
2701+ .collect();
2702+
2703+ if params.is_empty() {
2704+ return None;
2705+ }
2706+
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)))
2711+ } else {
2712+ (name.to_string(), None)
2713+ };
2714+ ParameterInformation {
2715+ label: ParameterLabel::Simple(label),
2716+ documentation: doc.map(|d| Documentation::String(d)),
2717+ }
2718+ }).collect();
2719+
2720+ let sig_label = format!("@{} {}", fn_name, params.join(" "));
2721+
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;
2726+
2727+ return Some(SignatureHelp {
2728+ signatures: vec![SignatureInformation {
2729+ label: sig_label,
2730+ documentation: Some(Documentation::String(
2731+ format!("Defined at line {}", line_idx + 1),
2732+ )),
2733+ parameters: Some(param_labels),
2734+ active_parameter: Some(active_param),
2735+ }],
2736+ active_signature: Some(0),
2737+ active_parameter: Some(active_param),
2738+ });
2739+ }
2740+ }
2741+ None
2742+}
2743+
23612744// ---------------------------------------------------------------------------
23622745// Entry point
23632746// ---------------------------------------------------------------------------