@@ -1,5 +1,9 @@
1+use htmlang::ast::ElementKind;
1use tower_lsp::lsp_types::*;
3use tower_lsp::lsp_types::*;
3pub(crate) fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
7pub(crate) fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
4 let lines: Vec<&str> = text.lines().collect();
8 let lines: Vec<&str> = text.lines().collect();
5 let line = match lines.get(position.line as usize) {
9 let line = match lines.get(position.line as usize) {
@@ -22,37 +26,11 @@ pub(crate) fn completions(text: &str, position: Position) -> Vec<CompletionItem>
22 return variable_completions(text, edit_range);
26 return variable_completions(text, edit_range);
25− // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
29+ // After a state/media prefix (`hover:`, `md:`, `nth:2n:`), offer
30+ // the styles it can apply to.
26− if let Some(colon) = current_word.find(':') {
31+ if let Some(colon) = current_word.rfind(':') {
27− let prefix = ¤t_word[..colon];
32+ let prefix = ¤t_word[..=colon];
29− prefix,
33+ if vocab::is_prefixed(prefix) {
56 return state_attr_completions(prefix, edit_range);
34 return state_attr_completions(prefix, edit_range);
@@ -122,44 +100,6 @@ pub(crate) fn in_brackets(text: &str) -> bool {
122 depth > 0
100 depth > 0
125−/// Extract design tokens from any `@theme` blocks in the source.
127−/// `@theme` opens a block whose indented children are `name value` pairs.
128−/// Each pair becomes both a `$variable` reference and a CSS custom property
129−/// at codegen time. We surface them as completions and hover targets.
130−pub(crate) fn collect_theme_tokens(text: &str) -> Vec<(String, String)> {
131− let mut out = Vec::new();
132− let lines: Vec<&str> = text.lines().collect();
134− while i < lines.len() {
135− let line = lines[i];
136− let trimmed = line.trim_start();
137− if trimmed == "@theme" || trimmed.starts_with("@theme ") {
138− let header_indent = line.len() - trimmed.len();
140− while j < lines.len() {
142− if l.trim().is_empty() {
146− let indent = l.len() - l.trim_start().len();
147− if indent <= header_indent {
150− let body = l.trim();
151− if let Some((name, value)) = body.split_once(char::is_whitespace) {
152− out.push((name.trim().to_string(), value.trim().to_string()));
165 label: &str,
105 label: &str,
@@ -180,205 +120,42 @@ fn item(
123+/// Elements (from the compiler) and standard-library components.
183fn element_completions(range: Range) -> Vec<CompletionItem> {
124fn element_completions(range: Range) -> Vec<CompletionItem> {
184− [
125+ let elements = ElementKind::all_names().map(|name| {
185− ("@row", "Horizontal layout (flexbox row)"),
126+ let detail = docs::element_summary(name).unwrap_or_default();
186− ("@column", "Vertical layout (flexbox column)"),
127+ (name, detail)
187− ("@el", "Generic container"),
128+ });
188− ("@text", "Styled inline text (span)"),
129+ let components = docs::COMPONENTS
189− ("@paragraph", "Flowing text block (p)"),
190− ("@image", "Image element"),
191− ("@link", "Anchor/link element"),
192− ("@input", "Form input element (self-closing)"),
193− ("@button", "Button element"),
194− ("@select", "Select dropdown element"),
195− ("@textarea", "Multi-line text input"),
196− ("@option", "Option inside @select"),
197− ("@label", "Label element"),
198− ("@raw", "Raw HTML escape hatch"),
199− ("@children", "Slot for caller's children (inside component)"),
200− ("@slot", "Named slot inside component (e.g., @slot header)"),
201− // Semantic elements
202− ("@nav", "Navigation container (nav)"),
203− ("@header", "Page/section header (header)"),
204− ("@footer", "Page/section footer (footer)"),
205− ("@main", "Main content area (main)"),
206− ("@section", "Thematic section (section)"),
207− ("@article", "Self-contained content (article)"),
208− ("@aside", "Sidebar/tangential content (aside)"),
210− ("@list", "List container (ul/ol, use [ordered] for ol)"),
211− ("@item", "List item (li)"),
213− ("@table", "Table element"),
214− ("@thead", "Table head group"),
215− ("@tbody", "Table body group"),
216− ("@tr", "Table row"),
217− ("@td", "Table cell"),
218− ("@th", "Table header cell"),
220− ("@video", "Video element"),
221− ("@audio", "Audio element"),
222− // Additional elements
223− ("@form", "Form container (form)"),
224− ("@details", "Disclosure widget (details)"),
225− ("@summary", "Summary for @details"),
226− ("@blockquote", "Block quotation"),
227− ("@cite", "Citation/source reference"),
228− ("@code", "Inline code (monospace)"),
229− ("@pre", "Preformatted text block"),
230− ("@hr", "Horizontal rule/divider"),
231− ("@figure", "Figure with optional caption"),
232− ("@figcaption", "Caption for @figure"),
233− ("@progress", "Progress bar (value, max attributes)"),
234− ("@meter", "Meter/gauge element (value, min, max)"),
235− ("@fragment", "Group children without a wrapper element"),
236− // Dialog & interactive
237− ("@dialog", "Dialog/modal element (dialog)"),
239− ("@dl", "Description list (dl)"),
240− ("@dt", "Description term (dt)"),
241− ("@dd", "Description details (dd)"),
243− ("@fieldset", "Fieldset group (fieldset)"),
244− ("@legend", "Legend for @fieldset (legend)"),
245− // Picture/responsive images
246− ("@picture", "Responsive image container (picture)"),
249− "Media source for @picture/@video/@audio (source)",
252− ("@h1", "Heading level 1 (h1)"),
253− ("@h2", "Heading level 2 (h2)"),
254− ("@h3", "Heading level 3 (h3)"),
255− ("@h4", "Heading level 4 (h4)"),
256− ("@h5", "Heading level 5 (h5)"),
257− ("@h6", "Heading level 6 (h6)"),
259− ("@time", "Date/time element (time)"),
260− ("@mark", "Highlighted/marked text (mark)"),
261− ("@kbd", "Keyboard input (kbd)"),
262− ("@abbr", "Abbreviation (abbr)"),
264− ("@datalist", "Predefined options for @input (datalist)"),
266− ("@iframe", "Embedded external page (iframe)"),
267− ("@output", "Form calculation result (output)"),
268− ("@canvas", "Drawing surface for scripts (canvas)"),
269− ("@script", "Inline script element (script)"),
272− "Fallback content when scripts disabled (noscript)",
274− ("@address", "Contact information (address)"),
275− ("@search", "Search section (search)"),
276− ("@breadcrumb", "Breadcrumb navigation (nav with aria)"),
277− // Overlay positioning
280− "Overlay layer rendered on top of the parent (absolute, inset 0)",
284− "Background layer rendered behind the parent's content (absolute, inset 0, z-index -1)",
131+ .map(|doc| (doc.name, format!("{} (standard library)", doc.summary)));
134+ .map(|(name, detail)| {
135+ let label = format!("@{}", name);
288− .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
136+ item(&label, CompletionItemKind::KEYWORD, &detail, &label, range)
289− .collect()
138+ .collect()
141+/// Directives, from the parser's directive list.
292fn directive_completions(range: Range) -> Vec<CompletionItem> {
142fn directive_completions(range: Range) -> Vec<CompletionItem> {
293− [
143+ htmlang::parser::known_directives()
294− ("@page", "Set HTML page title", "@page "),
297− "Define a variable, attribute bundle, or component",
300− ("@keyframes", "Define a CSS animation", "@keyframes "),
301− ("@if", "Conditional rendering", "@if "),
302− ("@else if", "Else-if branch", "@else if "),
303− ("@else", "Else branch", "@else"),
306− "Loop over values (@each $var, $i in list)",
311− "Include another .hl file (DOM + definitions)",
316− "Import definitions only (no DOM nodes)",
319− ("@meta", "Add a <meta> tag to <head>", "@meta "),
320− ("@head", "Add raw content to <head>", "@head"),
321− ("@style", "Add raw CSS to stylesheet", "@style"),
324− "Named slot in component for caller content",
327− ("@match", "Pattern matching on a value", "@match "),
328− ("@case", "Match case (inside @match)", "@case "),
329− ("@default", "Default case (inside @match)", "@default"),
330− ("@warn", "Emit a compile-time warning", "@warn "),
333− "Define custom responsive breakpoint",
338− "Define design tokens (colors, spacing, fonts)",
343− "Mark next @let component as deprecated",
348− "Inherit a layout template and fill @slot blocks",
351− ("@font-face", "Define a custom font face", "@font-face"),
354− "Add JSON-LD structured data to head",
359− "Compile-time assertion for variable values",
364− "Load JSON data file into template variables",
367− ("@env", "Access compile-time environment variable", "@env "),
368− ("@fetch", "Fetch data from URL at compile time", "@fetch "),
369− ("@svg", "Inline SVG file with optional attributes", "@svg "),
146+ let label = format!("@{}", name);
147+ let detail = docs::directive(name).map_or("Directive", |d| d.summary);
372− .map(|(name, detail, insert)| item(name, CompletionItemKind::SNIPPET, detail, insert, range))
148+ item(&label, CompletionItemKind::SNIPPET, detail, &format!("{} ", label), range)
373− .collect()
150+ .collect()
376fn snippet_completions(range: Range) -> Vec<CompletionItem> {
153fn snippet_completions(range: Range) -> Vec<CompletionItem> {
377 let snippets: &[(&str, &str, &str)] = &[
154 let snippets: &[(&str, &str, &str)] = &[
379− "card component",
156+ "function",
380− "Reusable card with title and content",
157+ "Define a reusable function",
381− "@let card \\$title\n @el [padding 20, background white, rounded 8]\n @text [bold] \\$title\n @children",
158+ "@let ${1:name} \\$${2:param}\n @el [${3:padding 16}]\n @children",
384 "responsive layout",
161 "responsive layout",
@@ -390,85 +167,30 @@ fn snippet_completions(range: Range) -> Vec<CompletionItem> {
390 "Navigation bar with horizontal items",
167 "Navigation bar with horizontal items",
391 "@nav [padding 16, background #1a1a2e]\n @row [spacing 20, align-items center]",
168 "@nav [padding 16, background #1a1a2e]\n @row [spacing 20, align-items center]",
395− "Hero section with title and subtitle",
396− "@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}",
399 "each with else",
171 "each with else",
400 "Loop with empty-state fallback",
172 "Loop with empty-state fallback",
401 "@each \\$${1:item} in ${2:list}\n @text \\$${1:item}\n@else\n @text [color #888] No items found.",
173 "@each \\$${1:item} in ${2:list}\n @text \\$${1:item}\n@else\n @text [color #888] No items found.",
404− "button with hover",
176+ "if / else",
405− "Interactive button with hover effect",
406− "@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}",
410− "Form with labeled inputs and submit button",
411− "@form [spacing 16]\n @label ${1:Name}\n @input [type text, name ${2:name}, placeholder ${3:Enter name}, required]\n @label ${4:Email}\n @input [type email, name ${5:email}, placeholder ${6:Enter email}, required]\n @button [type submit, padding 12 24, background ${7:#3b82f6}, color white, rounded 8, bold, cursor pointer] Submit",
415− "Responsive grid with columns",
416− "@grid [grid-cols ${1:3}, gap ${2:20}]\n @el [padding 20, background ${3:#f3f4f6}, rounded 8]\n ${4:Item 1}\n @el [padding 20, background ${3:#f3f4f6}, rounded 8]\n ${5:Item 2}\n @el [padding 20, background ${3:#f3f4f6}, rounded 8]\n ${6:Item 3}",
420− "Footer with columns and copyright",
421− "@footer [padding 40, background ${1:#1a1a2e}, color ${2:#ccc}]\n @row [spacing 40, wrap]\n @column [spacing 10, width fill]\n @text [bold, color white] ${3:Company}\n @link ${4:#} ${5:About}\n @link ${6:#} ${7:Contact}\n @column [spacing 10, width fill]\n @text [bold, color white] ${8:Resources}\n @link ${9:#} ${10:Documentation}\n @text [size 14, color #888, center-x] \\u00a9 2026 ${11:Company Name}",
424− "avatar with image",
425− "Circular avatar with fallback",
426− "@avatar [width ${1:48}, height ${1:48}, background ${2:#e5e7eb}]\n @image [width ${1:48}, height ${1:48}, object-fit cover, alt ${3:avatar}] ${4:url}",
429− "carousel horizontal",
430− "Scroll-snap horizontal carousel",
431− "@carousel [gap ${1:16}, padding ${2:16}]\n @el [width ${3:300}, padding 20, background ${4:#f3f4f6}, rounded 8]\n ${5:Slide 1}\n @el [width ${3:300}, padding 20, background ${4:#f3f4f6}, rounded 8]\n ${6:Slide 2}\n @el [width ${3:300}, padding 20, background ${4:#f3f4f6}, rounded 8]\n ${7:Slide 3}",
435− "Element with light/dark mode styles",
436− "@el [padding 20, background ${1:white}, dark:background ${2:#1a1a2e}, color ${3:#333}, dark:color ${4:#eee}, rounded 8, transition all 0.2s ease]\n ${5:Content}",
440− "Text with ellipsis overflow",
441− "@text [max-width ${1:200}, truncate] ${2:Long text that will be truncated...}",
445− "Define a reusable component",
446− "@let ${1:name} \\$${2:param}\n @el [${3:padding 16}]\n @children",
450− "Iterate over a list of items",
451− "@each \\$${1:item} in ${2:items}\n @text \\$${1:item}",
455 "Conditional rendering block",
177 "Conditional rendering block",
456 "@if ${1:condition}\n ${2:content}\n@else\n ${3:fallback}",
178 "@if ${1:condition}\n ${2:content}\n@else\n ${3:fallback}",
459− "@match pattern",
181+ "form with inputs",
460− "Pattern matching block",
182+ "Form with a labeled input and a submit button",
461− "@match \\$${1:value}\n @case ${2:option1}\n ${3:content}\n @default\n ${4:fallback}",
183+ "@form [spacing 16] ${1:/submit}\n @label [for=${2:email}] ${3:Email}\n @input [type=email, name=$2, id=$2, required]\n @button [type=submit] Submit",
464− "@data JSON load",
186+ "grid layout",
465− "Load variables from a JSON file",
187+ "Grid with equal columns",
466− "@data ${1:data.json}",
188+ "@grid [grid-cols ${1:3}, spacing ${2:20}]\n @el [padding 20]\n ${3:Item}",
469− "@component scoped",
191+ "dark mode",
470− "Define a scoped component with styles",
192+ "Element with light and dark styles",
471− "@component ${1:name} \\$${2:param}\n @style\n .inner { ${3:padding: 16px;} }\n @el [class inner]\n @children",
193+ "@el [background ${1:white}, dark:background ${2:#1a1a2e}, color ${3:#333}, dark:color ${4:#eee}]\n ${5:Content}",
@@ -617,7 +339,7 @@ fn element_specific_attrs(element: &str) -> &'static [&'static str] {
618 "accept",
340 "accept",
620− "button" | "btn" => &[
342+ "button" => &[
622 "disabled",
344 "disabled",
@@ -641,7 +363,7 @@ fn element_specific_attrs(element: &str) -> &'static [&'static str] {
641 "autofocus",
363 "autofocus",
642 "spellcheck",
364 "spellcheck",
644− "option" | "opt" => &["value", "selected", "disabled", "label"],
366+ "option" => &["value", "selected", "disabled", "label"],
645 "form" => &[
367 "form" => &[
646 "action",
368 "action",
647 "method",
369 "method",
@@ -651,7 +373,7 @@ fn element_specific_attrs(element: &str) -> &'static [&'static str] {
651 "enctype",
373 "enctype",
654− "image" | "img" => &[
376+ "image" => &[
@@ -707,19 +429,14 @@ fn element_specific_attrs(element: &str) -> &'static [&'static str] {
707fn attr_value_completions(before: &str, range: Range) -> Option<Vec<CompletionItem>> {
429fn attr_value_completions(before: &str, range: Range) -> Option<Vec<CompletionItem>> {
708 let bracket_content = before.rsplit('[').next()?;
430 let bracket_content = before.rsplit('[').next()?;
709 let segment = bracket_content.rsplit(',').next()?.trim_start();
431 let segment = bracket_content.rsplit(',').next()?.trim_start();
710− let attr_end = segment
432+ // A style (`cursor `) or an HTML attribute (`type=`) with no value typed yet.
711− .find(|c: char| c.is_whitespace())
433+ let attr = segment
712− .unwrap_or(segment.len());
434+ .strip_suffix('=')
713− let attr = &segment[..attr_end];
435+ .filter(|a| !a.contains(char::is_whitespace))
714− let after_attr = &segment[attr_end..];
436+ .or_else(|| {
715− if !after_attr.starts_with(' ') {
437+ let (attr, rest) = segment.split_once(' ')?;
716− return None;
438+ rest.trim().is_empty().then_some(attr)
718− // Only fire when no value has been started yet.
719− let typed = after_attr.trim_start();
720− if !typed.is_empty() {
724 // Strip state prefix to find the base attribute.
441 // Strip state prefix to find the base attribute.
725 let base_attr = if let Some(pos) = attr.rfind(':') {
442 let base_attr = if let Some(pos) = attr.rfind(':') {
@@ -903,644 +620,75 @@ fn attr_value_completions(before: &str, range: Range) -> Option<Vec<CompletionIt
623+/// Attributes, from the compiler's vocabulary: htmlang attributes and CSS
624+/// properties (`key value`), HTML attributes (`key=value`, or bare for
625+/// booleans), and state/media prefixes. Attributes the owning element
626+/// specifically uses sort first.
906fn attr_completions(range: Range, element: Option<&str>) -> Vec<CompletionItem> {
627fn attr_completions(range: Range, element: Option<&str>) -> Vec<CompletionItem> {
907− [
628+ let boosted = element.map_or(&[][..], element_specific_attrs);
908− // Layout
629+ let mut items = Vec::new();
909− ("spacing", "Gap between children (supports CSS units)", true),
630+ let mut push = |label: String, insert: String, detail: &str, rank: &str, name: &str| {
910− ("gap", "Gap between children (alias for spacing)", true),
631+ let mut completion = item(&label, CompletionItemKind::PROPERTY, detail, &insert, range);
911− (
632+ let rank = if boosted.contains(&name) { "0" } else { rank };
912− "padding",
633+ completion.sort_text = Some(format!("{}_{}", rank, label));
913− "Inner padding (1/2/3/4 values, supports CSS units)",
634+ items.push(completion);
915− ),
636+ for name in vocab::HTMLANG_ATTRIBUTES {
916− ("padding-x", "Horizontal padding", true),
637+ let doc = docs::attribute(name);
917− ("padding-y", "Vertical padding", true),
638+ let insert = if doc.is_none_or(|d| d.takes_value()) {
918− // Sizing
639+ format!("{} ", name)
919− ("width", "Width (px/% | fill | shrink)", true),
920− ("height", "Height (px/% | fill | shrink)", true),
921− ("min-width", "Minimum width", true),
922− ("max-width", "Maximum width", true),
923− ("min-height", "Minimum height", true),
924− ("max-height", "Maximum height", true),
926− ("center-x", "Center horizontally", false),
927− ("center-y", "Center vertically", false),
928− ("align-left", "Align left", false),
929− ("align-right", "Align right", false),
930− ("align-top", "Align top", false),
931− ("align-bottom", "Align bottom", false),
933− ("background", "Background color/value", true),
934− ("color", "Text color", true),
935− ("border", "Border (width [color])", true),
936− ("border-top", "Top border (width [color])", true),
937− ("border-bottom", "Bottom border (width [color])", true),
938− ("border-left", "Left border (width [color])", true),
939− ("border-right", "Right border (width [color])", true),
940− ("rounded", "Border radius", true),
941− ("bold", "Bold text", false),
942− ("italic", "Italic text", false),
943− ("underline", "Underlined text", false),
944− ("size", "Font size", true),
945− ("font", "Font family", true),
946− ("transition", "CSS transition", true),
947− ("cursor", "CSS cursor type", true),
948− ("opacity", "Opacity (0-1)", true),
952− "Text alignment (left/center/right/justify)",
955− ("line-height", "Line height (unitless or px)", true),
956− ("letter-spacing", "Letter spacing", true),
959− "Text transform (uppercase/lowercase/capitalize)",
964− "White-space behavior (nowrap/pre/normal)",
967− // Overflow & positioning
968− ("overflow", "Overflow behavior (hidden/scroll/auto)", true),
971− "Position type (relative/absolute/fixed/sticky)",
974− ("top", "Top offset (for positioned elements)", true),
975− ("right", "Right offset (for positioned elements)", true),
976− ("bottom", "Bottom offset (for positioned elements)", true),
977− ("left", "Left offset (for positioned elements)", true),
978− ("z-index", "Stack order (integer)", true),
979− // Display & visibility
982− "Display mode (none/block/inline/flex/grid)",
985− ("visibility", "Visibility (visible/hidden)", true),
986− // Transform & filters
987− ("transform", "CSS transform (e.g., rotate(45deg))", true),
990− "Backdrop filter (e.g., blur(10px))",
994− ("shadow", "Box shadow (CSS value)", true),
996− ("wrap", "Enable flex-wrap", false),
997− ("gap-x", "Horizontal gap between children", true),
998− ("gap-y", "Vertical gap between children", true),
1000− ("grid", "Enable CSS grid layout", false),
1001− ("grid-cols", "Grid columns (number or template)", true),
1002− ("grid-rows", "Grid rows (number or template)", true),
1003− ("col-span", "Span columns in grid", true),
1004− ("row-span", "Span rows in grid", true),
1005− // Container queries
1006− ("container", "Enable container queries (inline-size)", false),
1009− "Container name for @container queries",
1014− "Container type (inline-size/size/normal)",
1018− ("id", "HTML id attribute", true),
1019− ("class", "HTML class attribute", true),
1021− ("animation", "CSS animation (e.g., name 0.3s ease)", true),
1023− ("type", "Input type (text/email/password/submit/...)", true),
1024− ("placeholder", "Placeholder text", true),
1025− ("name", "Form field name", true),
1026− ("value", "Form field value", true),
1027− ("disabled", "Disable the element", false),
1028− ("required", "Mark field as required", false),
1029− ("checked", "Checkbox/radio checked state", false),
1030− ("for", "Label target (id of associated input)", true),
1031− ("action", "Form action URL", true),
1032− ("method", "Form method (get/post)", true),
1033− ("rows", "Textarea rows", true),
1034− ("cols", "Textarea columns", true),
1035− ("maxlength", "Maximum input length", true),
1036− ("multiple", "Allow multiple selections", false),
1038− ("alt", "Alternative text (for images)", true),
1039− ("role", "ARIA role", true),
1040− ("tabindex", "Tab order", true),
1041− ("title", "Tooltip text", true),
1042− ("aria-label", "Accessible label", true),
1043− ("aria-hidden", "Hide from assistive tech (true/false)", true),
1044− ("data-", "Custom data attribute", true),
1045− // CSS: aspect-ratio, outline, logical properties, scroll-snap
1046− ("aspect-ratio", "CSS aspect ratio (e.g., 16/9, 1)", true),
1047− ("outline", "Outline (width [color])", true),
1050− "Inline (horizontal) padding for i18n",
1053− ("padding-block", "Block (vertical) padding for i18n", true),
1054− ("margin-inline", "Inline (horizontal) margin for i18n", true),
1055− ("margin-block", "Block (vertical) margin for i18n", true),
1057− "scroll-snap-type",
1058− "Scroll snap behavior (x/y mandatory/proximity)",
1062− "scroll-snap-align",
1063− "Snap alignment (start/center/end)",
1066− // Media attributes
1069− "Show media controls (for @video, @audio)",
1072− ("autoplay", "Auto-play media", false),
1073− ("loop", "Loop media playback", false),
1074− ("muted", "Mute media", false),
1075− ("poster", "Video poster image URL", true),
1076− ("preload", "Media preload hint (auto/metadata/none)", true),
1077− ("loading", "Loading behavior (lazy/eager)", true),
1078− ("decoding", "Image decoding (async/sync/auto)", true),
1080− ("ordered", "Use ordered list (ol instead of ul)", false),
1082− ("src", "Source URL for media elements", true),
1084− ("margin", "Outer margin (1/2/3/4 values)", true),
1085− ("margin-x", "Horizontal margin", true),
1086− ("margin-y", "Vertical margin", true),
1090− "CSS filter (blur, brightness, grayscale, etc.)",
1095− "Object fit for images (cover/contain/fill)",
1098− ("object-position", "Object position within container", true),
1100− ("text-shadow", "Text shadow (CSS value)", true),
1101− ("text-overflow", "Text overflow (ellipsis/clip)", true),
1103− ("pointer-events", "Pointer events (none/auto)", true),
1104− ("user-select", "User selection (none/text/all)", true),
1105− // Flexbox/grid alignment
1108− "Main axis alignment (center/space-between/etc.)",
1113− "Cross axis alignment (center/baseline/etc.)",
1117− ("order", "Flex/grid item order", true),
1118− // Background extras
1121− "Background size (cover/contain/auto)",
1125− "background-position",
1126− "Background position (center/top/etc.)",
1130− "background-repeat",
1131− "Background repeat (no-repeat/repeat/etc.)",
1137− "Word break behavior (break-all/keep-all)",
1140− ("overflow-wrap", "Overflow wrap (break-word/anywhere)", true),
1141− // New element attrs
1142− ("open", "Details initially open", false),
1143− ("novalidate", "Disable form validation", false),
1144− ("low", "Meter low threshold", true),
1145− ("high", "Meter high threshold", true),
1146− ("optimum", "Meter optimum value", true),
1147− ("colspan", "Table cell column span", true),
1148− ("rowspan", "Table cell row span", true),
1151− "Table header scope (col/row/colgroup/rowgroup)",
1154− ("inline", "Inline SVG images into output", false),
1156− ("hidden", "Hide element (display:none)", false),
1157− // Overflow directional
1160− "Horizontal overflow (hidden/scroll/auto)",
1163− ("overflow-y", "Vertical overflow (hidden/scroll/auto)", true),
1165− ("inset", "Shorthand for top/right/bottom/left", true),
1166− // Modern form theming
1167− ("accent-color", "Accent color for form controls", true),
1168− ("caret-color", "Text cursor color", true),
1171− "Color scheme preference (light/dark/light dark)",
1176− "Form element appearance (none to reset)",
1182− "Make element a popover (HTML Popover API)",
1185− ("popovertarget", "ID of popover to toggle", true),
1187− "popovertargetaction",
1188− "Popover action (toggle/show/hide)",
1194− "Virtual keyboard type (numeric/email/search/tel/url)",
1199− "Enter key label (done/go/next/search/send)",
1204− "Resource fetch priority (high/low/auto)",
1209− "Whether element should be translated (yes/no)",
1212− ("spellcheck", "Spell check mode (true/false)", true),
1216− "List style type (disc/circle/square/none)",
1222− "Border collapse mode (collapse/separate)",
1225− ("border-spacing", "Spacing between table cell borders", true),
1229− "Text decoration (underline/overline/line-through)",
1232− ("text-decoration-color", "Text decoration color", true),
1234− "text-decoration-thickness",
1235− "Text decoration thickness",
1239− "text-decoration-style",
1240− "Text decoration style (solid/dashed/dotted/wavy)",
1243− // Grid/flex placement
1246− "Shorthand for align-items + justify-items",
1251− "Shorthand for align-self + justify-self",
1255− ("scroll-behavior", "Scroll behavior (smooth/auto)", true),
1259− "Resize behavior (none/both/horizontal/vertical)",
1263− ("hover:", "Style on hover", false),
1264− ("active:", "Style on active/click", false),
1265− ("focus:", "Style on focus", false),
1266− // New pseudo-state prefixes
1267− ("focus-visible:", "Style on keyboard focus", false),
1268− ("focus-within:", "Style when child has focus", false),
1269− ("disabled:", "Style when disabled", false),
1270− ("checked:", "Style when checked", false),
1271− ("placeholder:", "Style placeholder text", false),
1273− ("first:", "Style first child", false),
1274− ("last:", "Style last child", false),
1275− ("odd:", "Style odd children (1st, 3rd, ...)", false),
1276− ("even:", "Style even children (2nd, 4th, ...)", false),
1277− // Responsive prefixes
1278− ("sm:", "Style at 640px+ (small)", false),
1279− ("md:", "Style at 768px+ (medium)", false),
1280− ("lg:", "Style at 1024px+ (large)", false),
1281− ("xl:", "Style at 1280px+ (extra large)", false),
1282− // Additional responsive prefixes
1283− ("2xl:", "Style at 1536px+ (2x extra large)", false),
1285− ("motion-safe:", "Style when motion is allowed", false),
1288− "Style when reduced motion preferred",
1291− // Orientation prefixes
1292− ("landscape:", "Style in landscape orientation", false),
1293− ("portrait:", "Style in portrait orientation", false),
1295− ("dark:", "Style in dark color scheme", false),
1296− ("print:", "Style for print media", false),
1297− // Clipping & blending
1298− ("clip-path", "Clip path (circle, polygon, etc.)", true),
1301− "Blend mode (multiply, screen, overlay, etc.)",
1304− ("background-blend-mode", "Background blend mode", true),
1308− "Writing mode (horizontal-tb, vertical-rl, etc.)",
1311− // Multi-column layout
1314− "Number of columns in multi-column layout",
1317− ("column-gap", "Gap between columns", true),
1319− ("text-indent", "First-line text indentation", true),
1320− ("hyphens", "Hyphenation behavior (none/manual/auto)", true),
1321− // Flex item sizing
1322− ("flex-grow", "Flex grow factor", true),
1323− ("flex-shrink", "Flex shrink factor", true),
1324− ("flex-basis", "Flex basis (initial main size)", true),
1325− // Stacking context
1326− ("isolation", "Create stacking context (isolate/auto)", true),
1327− // Grid/flex placement
1330− "Shorthand for align-content + justify-content",
1333− // Background image
1335− "background-image",
1336− "Background image (url or gradient)",
1339− // New CSS properties
1342− "Font weight (100-900, bold, lighter, bolder)",
1345− ("font-style", "Font style (normal/italic/oblique)", true),
1346− ("text-wrap", "Text wrapping (balance/pretty/nowrap)", true),
1349− "Performance hint for animations (transform, opacity)",
1354− "Touch behavior (none/pan-x/pan-y/manipulation)",
1359− "Vertical alignment (middle/top/bottom/baseline)",
1364− "CSS containment (layout/paint/content/strict)",
1368− "content-visibility",
1369− "Content visibility (auto/visible/hidden)",
1374− "Scroll margin (for scroll-snap and anchor offsets)",
1377− ("scroll-margin-top", "Scroll margin top", true),
1380− "Scroll padding (for scroll-snap containers)",
1383− ("scroll-padding-top", "Scroll padding top", true),
1384− // Pseudo-element content
1387− "Content for ::before/::after (use with before:/after: prefix)",
1390− // Iframe/form/link attributes
1391− ("sandbox", "Iframe sandbox restrictions", true),
1392− ("allow", "Iframe permissions policy", true),
1393− ("allowfullscreen", "Allow iframe fullscreen", false),
1396− "Link/form target (_blank/_self/_parent/_top)",
1402− "Truncate text with ellipsis (single line)",
1405− ("line-clamp", "Clamp text to N lines with ellipsis", true),
1406− ("blur", "Apply blur filter (px)", true),
1407− ("backdrop-blur", "Apply backdrop blur filter (px)", true),
1409− ("direction", "Text direction (ltr/rtl)", true),
1410− // Container query prefixes
1411− ("cq-sm:", "Container query at 640px+", false),
1412− ("cq-md:", "Container query at 768px+", false),
1413− ("cq-lg:", "Container query at 1024px+", false),
1415− ("before:", "Style ::before pseudo-element", false),
1416− ("after:", "Style ::after pseudo-element", false),
1417− ("selection:", "Style text selection", false),
1420− "grid-template-areas",
1421− "Define named grid areas (quoted string)",
1424− ("grid-area", "Place element in a named grid area", true),
1425− // View transitions
1427− "view-transition-name",
1428− "Assign a view transition name",
1431− // Has pseudo-selector
1434− "Style when element has matching children :has()",
1437− // Critical CSS hint
1438− ("critical", "Mark as above-fold critical CSS", false),
1439− // New pseudo-state prefixes
1440− ("visited:", "Style visited links", false),
1441− ("empty:", "Style when element has no children", false),
1444− "Style when element is the URL fragment target",
1447− ("valid:", "Style when form element is valid", false),
1448− ("invalid:", "Style when form element is invalid", false),
1449− // New CSS properties
1451− "text-underline-offset",
1452− "Offset of text underline from its default position",
1457− "Ideal width of columns in multi-column layout",
1462− "Rule between columns (width style color)",
1467− .map(|(name, detail, takes_value)| {
1468− // HTML attributes are written `key=value`, styles `key value`.
1469− let is_html = !htmlang::vocab::is_style_attribute(name)
1470− && (htmlang::vocab::HTML_ATTRIBUTES.contains(name)
1471− || name.starts_with("aria-")
1472− || name.starts_with("data-"));
1473− let insert = match (*takes_value, is_html) {
1474− (true, true) => format!("{}=", name),
1475− (true, false) => format!("{} ", name),
1476− (false, _) => name.to_string(),
1478− let mut completion = item(name, CompletionItemKind::PROPERTY, detail, &insert, range);
1479− // Boost element-specific attributes to the top of the list when we
1480− // know which element owns the brackets. The default sort prefix is
1481− // "5_" so unboosted entries land below the prioritized ones, while
1482− // staying above snippets (which use "zz_").
1483− let boosted = element
1484− .map(|e| element_specific_attrs(e).contains(name))
1486− completion.sort_text = Some(if boosted {
1487− format!("0_{}", name)
1488 } else {
640 } else {
643+ let detail = doc.map_or("htmlang attribute", |d| d.summary);
644+ push(name.to_string(), insert, detail, "2", name);
646+ for name in vocab::CSS_PROPERTIES {
647+ let detail = docs::attribute(name).map_or("CSS property", |d| d.summary);
1489− format!("5_{}", name)
648+ push(name.to_string(), format!("{} ", name), detail, "5", name);
1491− completion
650+ for name in vocab::BOOLEAN_HTML_ATTRS {
651+ if !vocab::is_style_attribute(name) {
652+ push(name.to_string(), name.to_string(), "HTML attribute (boolean)", "3", name);
655+ for name in vocab::HTML_ATTRIBUTES {
656+ if !vocab::BOOLEAN_HTML_ATTRS.contains(name) {
657+ push(format!("{}=", name), format!("{}=", name), "HTML attribute", "3", name);
1493− .collect()
660+ let prefixes = vocab::PSEUDO_PREFIXES
663+ .chain(vocab::RESPONSIVE_PREFIXES.iter().copied())
664+ .chain(vocab::MEDIA_PREFIXES.iter().copied())
665+ .chain(vocab::CONTAINER_QUERY_PREFIXES.iter().copied());
666+ for prefix in prefixes {
667+ let detail = docs::prefix_selector(prefix).unwrap_or_default();
668+ push(prefix.to_string(), prefix.to_string(), &detail, "6", prefix);
673+/// Styles that can follow a state/media prefix: `hover:color`, `md:padding`.
1496fn state_attr_completions(prefix: &str, range: Range) -> Vec<CompletionItem> {
674fn state_attr_completions(prefix: &str, range: Range) -> Vec<CompletionItem> {
1497− [
675+ let htmlang = vocab::HTMLANG_ATTRIBUTES
1498− ("background", "Background color/value", true),
676+ .iter()
1499− ("color", "Text color", true),
677+ .map(|name| (*name, docs::attribute(name).is_none_or(|d| d.takes_value())));
1500− ("border", "Border (width [color])", true),
678+ let css = vocab::CSS_PROPERTIES.iter().map(|name| (*name, true));
1501− ("border-top", "Top border (width [color])", true),
679+ htmlang
1502− ("border-bottom", "Bottom border (width [color])", true),
680+ .chain(css)
1503− ("border-left", "Left border (width [color])", true),
1504− ("border-right", "Right border (width [color])", true),
1505− ("rounded", "Border radius", true),
1506− ("bold", "Bold text", false),
1507− ("italic", "Italic text", false),
1508− ("underline", "Underlined text", false),
1509− ("size", "Font size", true),
1510− ("opacity", "Opacity (0-1)", true),
1511− ("cursor", "CSS cursor type", true),
1512− ("shadow", "Box shadow (CSS value)", true),
1513− ("text-shadow", "Text shadow", true),
1514− ("transform", "CSS transform", true),
1515− ("filter", "CSS filter", true),
1516− ("display", "Display mode", true),
1517− ("visibility", "Visibility", true),
1518− ("pointer-events", "Pointer events", true),
1519− ("user-select", "User selection", true),
1520− // Layout attrs for pseudo-states
1521− ("width", "Width", true),
1522− ("height", "Height", true),
1523− ("padding", "Inner padding", true),
1524− ("padding-x", "Horizontal padding", true),
1525− ("padding-y", "Vertical padding", true),
1526− ("margin", "Outer margin", true),
1527− ("margin-x", "Horizontal margin", true),
1528− ("margin-y", "Vertical margin", true),
1529− ("outline", "Outline", true),
1530− ("text-decoration", "Text decoration", true),
1531− ("text-decoration-color", "Text decoration color", true),
1534− .map(|(name, detail, takes_value)| {
681+ .map(|(name, takes_value)| {
1535− let full = format!("{}:{}", prefix, name);
682+ let full = format!("{}{}", prefix, name);
1536− let insert = if *takes_value {
683+ let insert = if takes_value {
1537− format!("{} ", full)
684+ format!("{} ", full)
1538− } else {
685+ } else {
1539− full.clone()
686+ full.clone()
688+ let detail = docs::attribute(name).map_or("CSS property", |d| d.summary);
1541− item(&full, CompletionItemKind::PROPERTY, detail, &insert, range)
689+ item(&full, CompletionItemKind::PROPERTY, detail, &insert, range)
1543− .collect()
691+ .collect()
1546fn color_value_completions(before: &str, range: Range) -> Option<Vec<CompletionItem>> {
694fn color_value_completions(before: &str, range: Range) -> Option<Vec<CompletionItem>> {
@@ -1649,14 +797,10 @@ fn color_value_completions(before: &str, range: Range) -> Option<Vec<CompletionI
1649fn variable_completions(text: &str, range: Range) -> Vec<CompletionItem> {
797fn variable_completions(text: &str, range: Range) -> Vec<CompletionItem> {
1650 let mut items = Vec::new();
798 let mut items = Vec::new();
1652− for (name, value) in collect_theme_tokens(text) {
800+ for doc in docs::BUNDLES {
1653− items.push(item(
801+ let label = format!("${}", doc.name);
1654− &format!("${}", name),
802+ let detail = format!("{} (standard-library bundle)", doc.summary);
1655− CompletionItemKind::COLOR,
803+ items.push(item(&label, CompletionItemKind::CONSTANT, &detail, &label, range));
1656− &format!("@theme \u{2014} {}", value),
1657− &format!("${}", name),
1662 for line in text.lines() {
806 for line in text.lines() {
@@ -1797,15 +941,51 @@ mod tests {
1800− fn theme_tokens_extracted_from_block() {
944+ fn element_and_directive_completions_come_from_the_compiler() {
1801− let text = "@theme\n primary #3b82f6\n spacing-md 16\n@row\n";
945+ let range = Range::default();
1802− let tokens = collect_theme_tokens(text);
946+ let elements = element_completions(range);
1803− assert_eq!(
947+ for name in ElementKind::all_names() {
1804− tokens,
948+ let label = format!("@{}", name);
1805− vec![
949+ assert!(elements.iter().any(|i| i.label == label), "missing {}", label);
1806− ("primary".into(), "#3b82f6".into()),
950+ }
1807− ("spacing-md".into(), "16".into()),
951+ assert!(elements.iter().any(|i| i.label == "@badge"));
1808− ]
952+ let directives = directive_completions(range);
1809− );
953+ for name in htmlang::parser::known_directives() {
954+ assert!(directives.iter().any(|i| i.label == format!("@{}", name)));
959+ fn attribute_completions_use_the_right_form() {
960+ let items = attr_completions(Range::default(), Some("input"));
961+ let insert = |label: &str| {
962+ let item = items.iter().find(|i| i.label == label).unwrap();
963+ match &item.text_edit {
964+ Some(CompletionTextEdit::Edit(edit)) => edit.new_text.clone(),
965+ _ => panic!("no edit for {}", label),
968+ assert_eq!(insert("spacing"), "spacing ");
969+ assert_eq!(insert("bold"), "bold");
970+ assert_eq!(insert("opacity"), "opacity ");
971+ assert_eq!(insert("type="), "type=");
972+ assert_eq!(insert("required"), "required");
973+ assert_eq!(insert("hover:"), "hover:");
974+ let boosted = items.iter().find(|i| i.label == "type=").unwrap();
975+ assert!(boosted.sort_text.as_deref().unwrap().starts_with("0_"));
979+ fn prefixed_attribute_completions() {
980+ let items = completions("@el [hover:", Position::new(0, 11));
981+ assert!(items.iter().any(|i| i.label == "hover:background"));
982+ let items = completions("@el [md:", Position::new(0, 8));
983+ assert!(items.iter().any(|i| i.label == "md:padding"));
987+ fn value_completions_after_html_attribute() {
988+ let items = completions("@input [type=", Position::new(0, 13));
989+ assert!(items.iter().any(|i| i.label == "email"));