@@ -88,6 +88,11 @@ impl LanguageServer for Backend {
8888 ..Default::default()
8989 }),
9090 ),
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),
95+ ),
9196 ..Default::default()
9297 },
9398 ..Default::default()
@@ -311,6 +316,63 @@ impl LanguageServer for Backend {
311316 data: tokens,
312317 })))
313318 }
319+
320+ async fn inlay_hint(
321+ &self,
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),
329+ };
330+ drop(docs);
331+ let hints = inlay_hints(&text);
332+ Ok(if hints.is_empty() { None } else { Some(hints) })
333+ }
334+
335+ #[allow(deprecated)]
336+ async fn symbol(
337+ &self,
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();
347+ if query.is_empty()
348+ || sym.name.to_lowercase().contains(&query)
349+ {
350+ all_symbols.push(sym);
351+ }
352+ }
353+ }
354+ drop(docs);
355+ Ok(if all_symbols.is_empty() {
356+ None
357+ } else {
358+ Some(all_symbols)
359+ })
360+ }
361+
362+ async fn linked_editing_range(
363+ &self,
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),
372+ };
373+ drop(docs);
374+ Ok(linked_editing_ranges(&text, pos))
375+ }
314376}
315377
316378// ---------------------------------------------------------------------------
@@ -345,7 +407,7 @@ fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
345407 // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
346408 if let Some(colon) = current_word.find(':') {
347409 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") {
349411 return state_attr_completions(prefix, edit_range);
350412 }
351413 }
@@ -482,6 +544,25 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
482544 ("@progress", "Progress bar (value, max attributes)"),
483545 ("@meter", "Meter/gauge element (value, min, max)"),
484546 ("@fragment", "Group children without a wrapper element"),
547+ // Dialog & interactive
548+ ("@dialog", "Dialog/modal element (dialog)"),
549+ // Definition lists
550+ ("@dl", "Description list (dl)"),
551+ ("@dt", "Description term (dt)"),
552+ ("@dd", "Description details (dd)"),
553+ // Form grouping
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)"),
559+ // Inline semantics
560+ ("@time", "Date/time element (time)"),
561+ ("@mark", "Highlighted/marked text (mark)"),
562+ ("@kbd", "Keyboard input (kbd)"),
563+ ("@abbr", "Abbreviation (abbr)"),
564+ // Datalist
565+ ("@datalist", "Predefined options for @input (datalist)"),
485566 ]
486567 .iter()
487568 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
@@ -512,6 +593,9 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
512593 ("@debug", "Print debug message during compilation", "@debug "),
513594 ("@lang", "Set document language (html lang attribute)", "@lang "),
514595 ("@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 "),
515599 ]
516600 .iter()
517601 .map(|(name, detail, insert)| {
@@ -728,9 +812,39 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
728812 ("md:", "Style at 768px+ (medium)", false),
729813 ("lg:", "Style at 1024px+ (large)", false),
730814 ("xl:", "Style at 1280px+ (extra large)", false),
815+ // Additional responsive prefixes
816+ ("2xl:", "Style at 1536px+ (2x extra large)", false),
817+ // Motion prefixes
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),
731823 // Media prefixes
732824 ("dark:", "Style in dark color scheme", false),
733825 ("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),
830+ // Writing mode
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),
835+ // Text
836+ ("text-indent", "First-line text indentation", true),
837+ ("hyphens", "Hyphenation behavior (none/manual/auto)", true),
838+ // Flex item sizing
839+ ("flex-grow", "Flex grow factor", true),
840+ ("flex-shrink", "Flex shrink factor", true),
841+ ("flex-basis", "Flex basis (initial main size)", true),
842+ // Stacking context
843+ ("isolation", "Create stacking context (isolate/auto)", true),
844+ // Grid/flex placement
845+ ("place-content", "Shorthand for align-content + justify-content", true),
846+ // Background image
847+ ("background-image", "Background image (url or gradient)", true),
734848 ]
735849 .iter()
736850 .map(|(name, detail, takes_value)| {
@@ -997,6 +1111,24 @@ fn hover_builtin(word: &str) -> Option<String> {
9971111 (Some("odd"), rest)
9981112 } else if let Some(rest) = word.strip_prefix("even:") {
9991113 (Some("even"), rest)
1114+ } else if let Some(rest) = word.strip_prefix("sm:") {
1115+ (Some("sm"), rest)
1116+ } else if let Some(rest) = word.strip_prefix("md:") {
1117+ (Some("md"), rest)
1118+ } else if let Some(rest) = word.strip_prefix("lg:") {
1119+ (Some("lg"), rest)
1120+ } else if let Some(rest) = word.strip_prefix("xl:") {
1121+ (Some("xl"), rest)
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)
10001132 } else {
10011133 (None, word)
10021134 };
@@ -1066,6 +1198,20 @@ fn hover_builtin(word: &str) -> Option<String> {
10661198 "@progress" => "**@progress** \u{2014} Progress bar\n\nRenders as `<progress>`.\n\nUsage: `@progress [value 70, max 100]`",
10671199 "@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]`",
10681200 "@fragment" => "**@fragment** \u{2014} Fragment\n\nGroups children without emitting a wrapper element. Renders children directly in the parent.",
1201+ // New elements
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]`",
10691215 // Directives
10701216 "@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```",
10711217 "@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> {
10741220 "@debug" => "**@debug** \u{2014} Debug message\n\nPrint a debug message to stderr during compilation.\n\nUsage: `@debug Theme is $theme`",
10751221 "@lang" => "**@lang** \u{2014} Document language\n\nSets the `lang` attribute on the `<html>` element.\n\nUsage: `@lang en`",
10761222 "@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`",
10771226 // Attributes
10781227 "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
10791228 "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> {
12211370 "place-self" => "**place-self** `<value>` \u{2014} Shorthand for `align-self` and `justify-self`.",
12221371 "scroll-behavior" => "**scroll-behavior** `<value>` \u{2014} Scroll behavior (`smooth`, `auto`).",
12231372 "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).",
12241388 _ => return None,
12251389 };
12261390
@@ -1887,7 +2051,8 @@ fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
18872051 "@page" | "@let" | "@define" | "@fn" | "@if" | "@else" | "@each"
18882052 | "@include" | "@import" | "@meta" | "@head" | "@style" | "@keyframes"
18892053 | "@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
18912056 _ => {
18922057 // Check if it's a user function call (starts with @ but not a builtin element)
18932058 if is_builtin_element(word) { 0 } else { 2 } // function
@@ -1921,6 +2086,8 @@ fn is_builtin_element(word: &str) -> bool {
19212086 | "@video" | "@audio" | "@form" | "@details" | "@summary"
19222087 | "@blockquote" | "@cite" | "@code" | "@pre" | "@hr" | "@divider"
19232088 | "@figure" | "@figcaption" | "@progress" | "@meter" | "@fragment"
2089+ | "@dialog" | "@dl" | "@dt" | "@dd" | "@fieldset" | "@legend"
2090+ | "@picture" | "@source" | "@time" | "@mark" | "@kbd" | "@abbr" | "@datalist"
19242091 )
19252092}
19262093
@@ -1950,6 +2117,128 @@ fn push_token(
19502117 *prev_start = start;
19512118}
19522119
2120+// ---------------------------------------------------------------------------
2121+// Inlay hints
2122+// ---------------------------------------------------------------------------
2123+
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());
2132+ }
2133+ }
2134+ }
2135+
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 ") {
2141+ continue;
2142+ }
2143+ let bytes = line.as_bytes();
2144+ let mut i = 0;
2145+ while i < bytes.len() {
2146+ if bytes[i] == b'$' {
2147+ let start = i;
2148+ i += 1;
2149+ while i < bytes.len()
2150+ && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
2151+ {
2152+ i += 1;
2153+ }
2154+ if i > start + 1 {
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)),
2160+ kind: None,
2161+ text_edits: None,
2162+ tooltip: None,
2163+ padding_left: Some(false),
2164+ padding_right: Some(true),
2165+ data: None,
2166+ });
2167+ }
2168+ }
2169+ } else {
2170+ i += 1;
2171+ }
2172+ }
2173+ }
2174+ hints
2175+}
2176+
2177+// ---------------------------------------------------------------------------
2178+// Linked editing ranges
2179+// ---------------------------------------------------------------------------
2180+
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());
2185+
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'_') {
2190+ start -= 1;
2191+ }
2192+ let mut end = col;
2193+ while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'$' || bytes[end] == b'-' || bytes[end] == b'_') {
2194+ end += 1;
2195+ }
2196+ if start == end {
2197+ return None;
2198+ }
2199+
2200+ let word = &line[start..end];
2201+ if !word.starts_with('$') {
2202+ return None;
2203+ }
2204+
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'_'
2216+ };
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'_'
2221+ };
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),
2226+ ));
2227+ }
2228+ offset = abs_pos + word.len();
2229+ }
2230+ }
2231+
2232+ if ranges.len() < 2 {
2233+ return None;
2234+ }
2235+
2236+ Some(LinkedEditingRanges {
2237+ ranges,
2238+ word_pattern: None,
2239+ })
2240+}
2241+
19532242// ---------------------------------------------------------------------------
19542243// Entry point
19552244// ---------------------------------------------------------------------------