@@ -88,6 +88,11 @@ impl LanguageServer for Backend {
88 ..Default::default()
88 ..Default::default()
91+ inlay_hint_provider: Some(OneOf::Left(true)),
92+ workspace_symbol_provider: Some(OneOf::Left(true)),
93+ linked_editing_range_provider: Some(
94+ LinkedEditingRangeServerCapabilities::Simple(true),
91 ..Default::default()
96 ..Default::default()
93 ..Default::default()
98 ..Default::default()
@@ -311,6 +316,63 @@ impl LanguageServer for Backend {
311 data: tokens,
316 data: tokens,
320+ async fn inlay_hint(
322+ params: InlayHintParams,
323+ ) -> Result<Option<Vec<InlayHint>>> {
324+ let uri = ¶ms.text_document.uri;
325+ let docs = self.documents.read().await;
326+ let text = match docs.get(uri) {
327+ Some(t) => t.clone(),
328+ None => return Ok(None),
331+ let hints = inlay_hints(&text);
332+ Ok(if hints.is_empty() { None } else { Some(hints) })
335+ #[allow(deprecated)]
338+ params: WorkspaceSymbolParams,
339+ ) -> Result<Option<Vec<SymbolInformation>>> {
340+ let query = params.query.to_lowercase();
341+ let docs = self.documents.read().await;
342+ let mut all_symbols = Vec::new();
343+ for (uri, text) in docs.iter() {
344+ let symbols = document_symbols(text);
345+ for mut sym in symbols {
346+ sym.location.uri = uri.clone();
348+ || sym.name.to_lowercase().contains(&query)
350+ all_symbols.push(sym);
355+ Ok(if all_symbols.is_empty() {
362+ async fn linked_editing_range(
364+ params: LinkedEditingRangeParams,
365+ ) -> Result<Option<LinkedEditingRanges>> {
366+ let uri = ¶ms.text_document_position_params.text_document.uri;
367+ let pos = params.text_document_position_params.position;
368+ let docs = self.documents.read().await;
369+ let text = match docs.get(uri) {
370+ Some(t) => t.clone(),
371+ None => return Ok(None),
374+ Ok(linked_editing_ranges(&text, pos))
316// ---------------------------------------------------------------------------
378// ---------------------------------------------------------------------------
@@ -345,7 +407,7 @@ fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
345 // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
407 // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
346 if let Some(colon) = current_word.find(':') {
408 if let Some(colon) = current_word.find(':') {
347 let prefix = ¤t_word[..colon];
409 let prefix = ¤t_word[..colon];
348− if matches!(prefix, "hover" | "active" | "focus" | "focus-visible" | "focus-within" | "disabled" | "checked" | "placeholder" | "first" | "last" | "odd" | "even" | "dark" | "print") {
410+ 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") {
349 return state_attr_completions(prefix, edit_range);
411 return state_attr_completions(prefix, edit_range);
@@ -482,6 +544,25 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
482 ("@progress", "Progress bar (value, max attributes)"),
544 ("@progress", "Progress bar (value, max attributes)"),
483 ("@meter", "Meter/gauge element (value, min, max)"),
545 ("@meter", "Meter/gauge element (value, min, max)"),
484 ("@fragment", "Group children without a wrapper element"),
546 ("@fragment", "Group children without a wrapper element"),
547+ // Dialog & interactive
548+ ("@dialog", "Dialog/modal element (dialog)"),
550+ ("@dl", "Description list (dl)"),
551+ ("@dt", "Description term (dt)"),
552+ ("@dd", "Description details (dd)"),
554+ ("@fieldset", "Fieldset group (fieldset)"),
555+ ("@legend", "Legend for @fieldset (legend)"),
556+ // Picture/responsive images
557+ ("@picture", "Responsive image container (picture)"),
558+ ("@source", "Media source for @picture/@video/@audio (source)"),
560+ ("@time", "Date/time element (time)"),
561+ ("@mark", "Highlighted/marked text (mark)"),
562+ ("@kbd", "Keyboard input (kbd)"),
563+ ("@abbr", "Abbreviation (abbr)"),
565+ ("@datalist", "Predefined options for @input (datalist)"),
487 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
568 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
@@ -512,6 +593,9 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
512 ("@debug", "Print debug message during compilation", "@debug "),
593 ("@debug", "Print debug message during compilation", "@debug "),
513 ("@lang", "Set document language (html lang attribute)", "@lang "),
594 ("@lang", "Set document language (html lang attribute)", "@lang "),
514 ("@favicon", "Set favicon (inlined as base64 data URI)", "@favicon "),
595 ("@favicon", "Set favicon (inlined as base64 data URI)", "@favicon "),
596+ ("@unless", "Inverse conditional (renders when false)", "@unless "),
597+ ("@og", "Add Open Graph meta tag", "@og "),
598+ ("@breakpoint", "Define custom responsive breakpoint", "@breakpoint "),
517 .map(|(name, detail, insert)| {
601 .map(|(name, detail, insert)| {
@@ -728,9 +812,39 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
728 ("md:", "Style at 768px+ (medium)", false),
812 ("md:", "Style at 768px+ (medium)", false),
729 ("lg:", "Style at 1024px+ (large)", false),
813 ("lg:", "Style at 1024px+ (large)", false),
730 ("xl:", "Style at 1280px+ (extra large)", false),
814 ("xl:", "Style at 1280px+ (extra large)", false),
815+ // Additional responsive prefixes
816+ ("2xl:", "Style at 1536px+ (2x extra large)", false),
818+ ("motion-safe:", "Style when motion is allowed", false),
819+ ("motion-reduce:", "Style when reduced motion preferred", false),
820+ // Orientation prefixes
821+ ("landscape:", "Style in landscape orientation", false),
822+ ("portrait:", "Style in portrait orientation", false),
731 // Media prefixes
823 // Media prefixes
732 ("dark:", "Style in dark color scheme", false),
824 ("dark:", "Style in dark color scheme", false),
733 ("print:", "Style for print media", false),
825 ("print:", "Style for print media", false),
826+ // Clipping & blending
827+ ("clip-path", "Clip path (circle, polygon, etc.)", true),
828+ ("mix-blend-mode", "Blend mode (multiply, screen, overlay, etc.)", true),
829+ ("background-blend-mode", "Background blend mode", true),
831+ ("writing-mode", "Writing mode (horizontal-tb, vertical-rl, etc.)", true),
832+ // Multi-column layout
833+ ("column-count", "Number of columns in multi-column layout", true),
834+ ("column-gap", "Gap between columns", true),
836+ ("text-indent", "First-line text indentation", true),
837+ ("hyphens", "Hyphenation behavior (none/manual/auto)", true),
839+ ("flex-grow", "Flex grow factor", true),
840+ ("flex-shrink", "Flex shrink factor", true),
841+ ("flex-basis", "Flex basis (initial main size)", true),
843+ ("isolation", "Create stacking context (isolate/auto)", true),
844+ // Grid/flex placement
845+ ("place-content", "Shorthand for align-content + justify-content", true),
847+ ("background-image", "Background image (url or gradient)", true),
736 .map(|(name, detail, takes_value)| {
850 .map(|(name, detail, takes_value)| {
@@ -997,6 +1111,24 @@ fn hover_builtin(word: &str) -> Option<String> {
997 (Some("odd"), rest)
1111 (Some("odd"), rest)
998 } else if let Some(rest) = word.strip_prefix("even:") {
1112 } else if let Some(rest) = word.strip_prefix("even:") {
999 (Some("even"), rest)
1113 (Some("even"), rest)
1114+ } else if let Some(rest) = word.strip_prefix("sm:") {
1116+ } else if let Some(rest) = word.strip_prefix("md:") {
1118+ } else if let Some(rest) = word.strip_prefix("lg:") {
1120+ } else if let Some(rest) = word.strip_prefix("xl:") {
1122+ } else if let Some(rest) = word.strip_prefix("2xl:") {
1123+ (Some("2xl"), rest)
1124+ } else if let Some(rest) = word.strip_prefix("motion-safe:") {
1125+ (Some("motion-safe"), rest)
1126+ } else if let Some(rest) = word.strip_prefix("motion-reduce:") {
1127+ (Some("motion-reduce"), rest)
1128+ } else if let Some(rest) = word.strip_prefix("landscape:") {
1129+ (Some("landscape"), rest)
1130+ } else if let Some(rest) = word.strip_prefix("portrait:") {
1131+ (Some("portrait"), rest)
1000 } else {
1132 } else {
1001 (None, word)
1133 (None, word)
@@ -1066,6 +1198,20 @@ fn hover_builtin(word: &str) -> Option<String> {
1066 "@progress" => "**@progress** \u{2014} Progress bar\n\nRenders as `<progress>`.\n\nUsage: `@progress [value 70, max 100]`",
1198 "@progress" => "**@progress** \u{2014} Progress bar\n\nRenders as `<progress>`.\n\nUsage: `@progress [value 70, max 100]`",
1067 "@meter" => "**@meter** \u{2014} Meter\n\nRenders as `<meter>`. Gauge for scalar measurement.\n\nUsage: `@meter [value 0.7, min 0, max 1, low 0.3, high 0.8]`",
1199 "@meter" => "**@meter** \u{2014} Meter\n\nRenders as `<meter>`. Gauge for scalar measurement.\n\nUsage: `@meter [value 0.7, min 0, max 1, low 0.3, high 0.8]`",
1068 "@fragment" => "**@fragment** \u{2014} Fragment\n\nGroups children without emitting a wrapper element. Renders children directly in the parent.",
1200 "@fragment" => "**@fragment** \u{2014} Fragment\n\nGroups children without emitting a wrapper element. Renders children directly in the parent.",
1202+ "@dialog" => "**@dialog** \u{2014} Dialog\n\nRenders as `<dialog>`. Modal or non-modal dialog box.\n\nUsage: `@dialog [open] Dialog content`",
1203+ "@dl" => "**@dl** \u{2014} Description list\n\nRenders as `<dl>`. Contains `@dt` and `@dd` pairs.",
1204+ "@dt" => "**@dt** \u{2014} Description term\n\nRenders as `<dt>`. Term in a `@dl` description list.",
1205+ "@dd" => "**@dd** \u{2014} Description details\n\nRenders as `<dd>`. Details for a `@dt` term.",
1206+ "@fieldset" => "**@fieldset** \u{2014} Fieldset\n\nRenders as `<fieldset>`. Groups related form elements.\n\nUse `@legend` for a caption.",
1207+ "@legend" => "**@legend** \u{2014} Legend\n\nRenders as `<legend>`. Caption for a `@fieldset`.",
1208+ "@picture" => "**@picture** \u{2014} Picture\n\nRenders as `<picture>`. Container for responsive image sources.\n\nUse `@source` children for different media queries.",
1209+ "@source" => "**@source** \u{2014} Source\n\nRenders as `<source>`. Media source for `@picture`, `@video`, or `@audio`.\n\nUsage: `@source [src image.webp, type image/webp]`",
1210+ "@time" => "**@time** \u{2014} Time\n\nRenders as `<time>`. Machine-readable date/time.\n\nUsage: `@time [datetime 2024-01-15] January 15`",
1211+ "@mark" => "**@mark** \u{2014} Mark\n\nRenders as `<mark>`. Highlighted or marked text.",
1212+ "@kbd" => "**@kbd** \u{2014} Keyboard input\n\nRenders as `<kbd>`. Represents keyboard input.\n\nUsage: `@kbd Ctrl+C`",
1213+ "@abbr" => "**@abbr** \u{2014} Abbreviation\n\nRenders as `<abbr>`. Abbreviation with optional title.\n\nUsage: `@abbr [title Hypertext Markup Language] HTML`",
1214+ "@datalist" => "**@datalist** \u{2014} Datalist\n\nRenders as `<datalist>`. Provides predefined options for `@input`.\n\nUsage: `@datalist [id colors]`",
1069 // Directives
1215 // Directives
1070 "@match" => "**@match** \u{2014} Pattern matching\n\nMatch a value against cases.\n\n```\n@match $theme\n @case dark\n @el [background #333]\n @case light\n @el [background white]\n @default\n @el [background gray]\n```",
1216 "@match" => "**@match** \u{2014} Pattern matching\n\nMatch a value against cases.\n\n```\n@match $theme\n @case dark\n @el [background #333]\n @case light\n @el [background white]\n @default\n @el [background gray]\n```",
1071 "@case" => "**@case** \u{2014} Match case\n\nA case inside `@match`. Matches when the value equals the case value.",
1217 "@case" => "**@case** \u{2014} Match case\n\nA case inside `@match`. Matches when the value equals the case value.",
@@ -1074,6 +1220,9 @@ fn hover_builtin(word: &str) -> Option<String> {
1074 "@debug" => "**@debug** \u{2014} Debug message\n\nPrint a debug message to stderr during compilation.\n\nUsage: `@debug Theme is $theme`",
1220 "@debug" => "**@debug** \u{2014} Debug message\n\nPrint a debug message to stderr during compilation.\n\nUsage: `@debug Theme is $theme`",
1075 "@lang" => "**@lang** \u{2014} Document language\n\nSets the `lang` attribute on the `<html>` element.\n\nUsage: `@lang en`",
1221 "@lang" => "**@lang** \u{2014} Document language\n\nSets the `lang` attribute on the `<html>` element.\n\nUsage: `@lang en`",
1076 "@favicon" => "**@favicon** \u{2014} Favicon\n\nInlines a favicon as a base64 data URI in the `<head>`.\n\nUsage: `@favicon favicon.png`",
1222 "@favicon" => "**@favicon** \u{2014} Favicon\n\nInlines a favicon as a base64 data URI in the `<head>`.\n\nUsage: `@favicon favicon.png`",
1223+ "@unless" => "**@unless** \u{2014} Inverse conditional\n\nRenders children when the condition is false (opposite of `@if`).\n\nUsage: `@unless $debug`",
1224+ "@og" => "**@og** \u{2014} Open Graph meta tag\n\nAdds an Open Graph `<meta>` tag to `<head>`.\n\nUsage: `@og title My Page Title`",
1225+ "@breakpoint" => "**@breakpoint** \u{2014} Custom breakpoint\n\nDefines a custom responsive breakpoint.\n\nUsage: `@breakpoint tablet 600`",
1077 // Attributes
1226 // Attributes
1078 "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
1227 "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
1079 "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding. Supports CSS units. Accepts 1\u{2013}4 values.",
1228 "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding. Supports CSS units. Accepts 1\u{2013}4 values.",
@@ -1221,6 +1370,21 @@ fn hover_builtin(word: &str) -> Option<String> {
1221 "place-self" => "**place-self** `<value>` \u{2014} Shorthand for `align-self` and `justify-self`.",
1370 "place-self" => "**place-self** `<value>` \u{2014} Shorthand for `align-self` and `justify-self`.",
1222 "scroll-behavior" => "**scroll-behavior** `<value>` \u{2014} Scroll behavior (`smooth`, `auto`).",
1371 "scroll-behavior" => "**scroll-behavior** `<value>` \u{2014} Scroll behavior (`smooth`, `auto`).",
1223 "resize" => "**resize** `<value>` \u{2014} Resize behavior (`none`, `both`, `horizontal`, `vertical`).",
1372 "resize" => "**resize** `<value>` \u{2014} Resize behavior (`none`, `both`, `horizontal`, `vertical`).",
1373+ // New CSS attributes
1374+ "clip-path" => "**clip-path** `<value>` \u{2014} Clip path (`circle()`, `polygon()`, `inset()`, `url()`).",
1375+ "mix-blend-mode" => "**mix-blend-mode** `<value>` \u{2014} Blend mode (`multiply`, `screen`, `overlay`, `darken`, `lighten`).",
1376+ "background-blend-mode" => "**background-blend-mode** `<value>` \u{2014} Background blend mode for layered backgrounds.",
1377+ "writing-mode" => "**writing-mode** `<value>` \u{2014} Writing direction (`horizontal-tb`, `vertical-rl`, `vertical-lr`).",
1378+ "column-count" => "**column-count** `<value>` \u{2014} Number of columns in multi-column layout.",
1379+ "column-gap" => "**column-gap** `<value>` \u{2014} Gap between columns. Supports CSS units.",
1380+ "text-indent" => "**text-indent** `<value>` \u{2014} Indentation of the first line of text.",
1381+ "hyphens" => "**hyphens** `<value>` \u{2014} Hyphenation behavior (`none`, `manual`, `auto`).",
1382+ "flex-grow" => "**flex-grow** `<value>` \u{2014} Flex grow factor (number). Controls how much an item grows.",
1383+ "flex-shrink" => "**flex-shrink** `<value>` \u{2014} Flex shrink factor (number). Controls how much an item shrinks.",
1384+ "flex-basis" => "**flex-basis** `<value>` \u{2014} Initial main size of a flex item (e.g., `200px`, `auto`, `0`).",
1385+ "isolation" => "**isolation** `<value>` \u{2014} Creates a new stacking context (`isolate`, `auto`).",
1386+ "place-content" => "**place-content** `<value>` \u{2014} Shorthand for `align-content` and `justify-content`.",
1387+ "background-image" => "**background-image** `<value>` \u{2014} Background image (`url()` or gradient function).",
1224 _ => return None,
1388 _ => return None,
@@ -1887,7 +2051,8 @@ fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
1887 "@page" | "@let" | "@define" | "@fn" | "@if" | "@else" | "@each"
2051 "@page" | "@let" | "@define" | "@fn" | "@if" | "@else" | "@each"
1888 | "@include" | "@import" | "@meta" | "@head" | "@style" | "@keyframes"
2052 | "@include" | "@import" | "@meta" | "@head" | "@style" | "@keyframes"
1889 | "@match" | "@case" | "@default" | "@slot" | "@children"
2053 | "@match" | "@case" | "@default" | "@slot" | "@children"
1890− | "@warn" | "@debug" | "@lang" | "@favicon" | "@fragment" => 0, // keyword
2054+ | "@warn" | "@debug" | "@lang" | "@favicon" | "@fragment"
2055+ | "@unless" | "@og" | "@breakpoint" => 0, // keyword
1892 // Check if it's a user function call (starts with @ but not a builtin element)
2057 // Check if it's a user function call (starts with @ but not a builtin element)
1893 if is_builtin_element(word) { 0 } else { 2 } // function
2058 if is_builtin_element(word) { 0 } else { 2 } // function
@@ -1921,6 +2086,8 @@ fn is_builtin_element(word: &str) -> bool {
1921 | "@video" | "@audio" | "@form" | "@details" | "@summary"
2086 | "@video" | "@audio" | "@form" | "@details" | "@summary"
1922 | "@blockquote" | "@cite" | "@code" | "@pre" | "@hr" | "@divider"
2087 | "@blockquote" | "@cite" | "@code" | "@pre" | "@hr" | "@divider"
1923 | "@figure" | "@figcaption" | "@progress" | "@meter" | "@fragment"
2088 | "@figure" | "@figcaption" | "@progress" | "@meter" | "@fragment"
2089+ | "@dialog" | "@dl" | "@dt" | "@dd" | "@fieldset" | "@legend"
2090+ | "@picture" | "@source" | "@time" | "@mark" | "@kbd" | "@abbr" | "@datalist"
@@ -1950,6 +2117,128 @@ fn push_token(
1950 *prev_start = start;
2117 *prev_start = start;
2120+// ---------------------------------------------------------------------------
2122+// ---------------------------------------------------------------------------
2124+fn inlay_hints(text: &str) -> Vec<InlayHint> {
2125+ // Build variable map: name -> value
2126+ let mut vars: HashMap<&str, &str> = HashMap::new();
2127+ for line in text.lines() {
2128+ let trimmed = line.trim();
2129+ if let Some(rest) = trimmed.strip_prefix("@let ") {
2130+ if let Some((name, value)) = rest.trim().split_once(' ') {
2131+ vars.insert(name, value.trim());
2136+ let mut hints = Vec::new();
2137+ for (line_idx, line) in text.lines().enumerate() {
2138+ let trimmed = line.trim();
2139+ // Skip @let definition lines — the value is already visible there
2140+ if trimmed.starts_with("@let ") {
2143+ let bytes = line.as_bytes();
2145+ while i < bytes.len() {
2146+ if bytes[i] == b'$' {
2149+ while i < bytes.len()
2150+ && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
2155+ let var_name = &line[start + 1..i];
2156+ if let Some(value) = vars.get(var_name) {
2157+ hints.push(InlayHint {
2158+ position: Position::new(line_idx as u32, i as u32),
2159+ label: InlayHintLabel::String(format!(" \u{2192} {}", value)),
2163+ padding_left: Some(false),
2164+ padding_right: Some(true),
2177+// ---------------------------------------------------------------------------
2178+// Linked editing ranges
2179+// ---------------------------------------------------------------------------
2181+fn linked_editing_ranges(text: &str, position: Position) -> Option<LinkedEditingRanges> {
2182+ let lines: Vec<&str> = text.lines().collect();
2183+ let line = lines.get(position.line as usize)?;
2184+ let col = (position.character as usize).min(line.len());
2186+ // Find the $variable at the cursor
2187+ let bytes = line.as_bytes();
2188+ let mut start = col;
2189+ while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'$' || bytes[start - 1] == b'-' || bytes[start - 1] == b'_') {
2193+ while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'$' || bytes[end] == b'-' || bytes[end] == b'_') {
2200+ let word = &line[start..end];
2201+ if !word.starts_with('$') {
2205+ // Find all occurrences of this $variable in the document
2206+ let mut ranges = Vec::new();
2207+ for (line_idx, line) in text.lines().enumerate() {
2208+ let line_bytes = line.as_bytes();
2209+ let mut offset = 0;
2210+ while let Some(pos) = line[offset..].find(word) {
2211+ let abs_pos = offset + pos;
2212+ // Check it's a whole word match
2213+ let before_ok = abs_pos == 0 || {
2214+ let c = line_bytes[abs_pos - 1];
2215+ !c.is_ascii_alphanumeric() && c != b'-' && c != b'_'
2217+ let after_end = abs_pos + word.len();
2218+ let after_ok = after_end >= line.len() || {
2219+ let c = line_bytes[after_end];
2220+ !c.is_ascii_alphanumeric() && c != b'-' && c != b'_'
2222+ if before_ok && after_ok {
2223+ ranges.push(Range::new(
2224+ Position::new(line_idx as u32, abs_pos as u32),
2225+ Position::new(line_idx as u32, after_end as u32),
2228+ offset = abs_pos + word.len();
2232+ if ranges.len() < 2 {
2236+ Some(LinkedEditingRanges {
2238+ word_pattern: None,
1953// ---------------------------------------------------------------------------
2242// ---------------------------------------------------------------------------
1954// Entry point
2243// Entry point
1955// ---------------------------------------------------------------------------
2244// ---------------------------------------------------------------------------