@@ -1,5 +1,9 @@
1+use htmlang::ast::ElementKind;
2+use htmlang::vocab;
13use tower_lsp::lsp_types::*;
24
5+use crate::docs;
6+
37pub(crate) fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
48 let lines: Vec<&str> = text.lines().collect();
59 let line = match lines.get(position.line as usize) {
@@ -22,37 +26,11 @@ pub(crate) fn completions(text: &str, position: Position) -> Vec<CompletionItem>
2226 return variable_completions(text, edit_range);
2327 }
2428
25− // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
26− if let Some(colon) = current_word.find(':') {
27− let prefix = ¤t_word[..colon];
28− if matches!(
29− prefix,
30− "hover"
31− | "active"
32− | "focus"
33− | "focus-visible"
34− | "focus-within"
35− | "disabled"
36− | "checked"
37− | "placeholder"
38− | "first"
39− | "last"
40− | "odd"
41− | "even"
42− | "before"
43− | "after"
44− | "dark"
45− | "print"
46− | "sm"
47− | "md"
48− | "lg"
49− | "xl"
50− | "2xl"
51− | "motion-safe"
52− | "motion-reduce"
53− | "landscape"
54− | "portrait"
55− ) {
29+ // After a state/media prefix (`hover:`, `md:`, `nth:2n:`), offer
30+ // the styles it can apply to.
31+ if let Some(colon) = current_word.rfind(':') {
32+ let prefix = ¤t_word[..=colon];
33+ if vocab::is_prefixed(prefix) {
5634 return state_attr_completions(prefix, edit_range);
5735 }
5836 }
@@ -122,44 +100,6 @@ pub(crate) fn in_brackets(text: &str) -> bool {
122100 depth > 0
123101}
124102
125−/// Extract design tokens from any `@theme` blocks in the source.
126−///
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();
133− let mut i = 0;
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();
139− let mut j = i + 1;
140− while j < lines.len() {
141− let l = lines[j];
142− if l.trim().is_empty() {
143− j += 1;
144− continue;
145− }
146− let indent = l.len() - l.trim_start().len();
147− if indent <= header_indent {
148− break;
149− }
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()));
153− }
154− j += 1;
155− }
156− i = j;
157− } else {
158− i += 1;
159− }
160− }
161− out
162−}
163103
164104fn item(
165105 label: &str,
@@ -180,205 +120,42 @@ fn item(
180120 }
181121}
182122
123+/// Elements (from the compiler) and standard-library components.
183124fn element_completions(range: Range) -> Vec<CompletionItem> {
184− [
185− ("@row", "Horizontal layout (flexbox row)"),
186− ("@column", "Vertical layout (flexbox column)"),
187− ("@el", "Generic container"),
188− ("@text", "Styled inline text (span)"),
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)"),
209− // List elements
210− ("@list", "List container (ul/ol, use [ordered] for ol)"),
211− ("@item", "List item (li)"),
212− // Table elements
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"),
219− // Media elements
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)"),
238− // Definition lists
239− ("@dl", "Description list (dl)"),
240− ("@dt", "Description term (dt)"),
241− ("@dd", "Description details (dd)"),
242− // Form grouping
243− ("@fieldset", "Fieldset group (fieldset)"),
244− ("@legend", "Legend for @fieldset (legend)"),
245− // Picture/responsive images
246− ("@picture", "Responsive image container (picture)"),
247− (
248− "@source",
249− "Media source for @picture/@video/@audio (source)",
250− ),
251− // Heading elements
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)"),
258− // Inline semantics
259− ("@time", "Date/time element (time)"),
260− ("@mark", "Highlighted/marked text (mark)"),
261− ("@kbd", "Keyboard input (kbd)"),
262− ("@abbr", "Abbreviation (abbr)"),
263− // Datalist
264− ("@datalist", "Predefined options for @input (datalist)"),
265− // New elements
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)"),
270− (
271− "@noscript",
272− "Fallback content when scripts disabled (noscript)",
273− ),
274− ("@address", "Contact information (address)"),
275− ("@search", "Search section (search)"),
276− ("@breadcrumb", "Breadcrumb navigation (nav with aria)"),
277− // Overlay positioning
278− (
279− "@in-front",
280− "Overlay layer rendered on top of the parent (absolute, inset 0)",
281− ),
282− (
283− "@behind",
284− "Background layer rendered behind the parent's content (absolute, inset 0, z-index -1)",
285− ),
286− ]
287− .iter()
288− .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
289− .collect()
125+ let elements = ElementKind::all_names().map(|name| {
126+ let detail = docs::element_summary(name).unwrap_or_default();
127+ (name, detail)
128+ });
129+ let components = docs::COMPONENTS
130+ .iter()
131+ .map(|doc| (doc.name, format!("{} (standard library)", doc.summary)));
132+ elements
133+ .chain(components)
134+ .map(|(name, detail)| {
135+ let label = format!("@{}", name);
136+ item(&label, CompletionItemKind::KEYWORD, &detail, &label, range)
137+ })
138+ .collect()
290139}
291140
141+/// Directives, from the parser's directive list.
292142fn directive_completions(range: Range) -> Vec<CompletionItem> {
293− [
294− ("@page", "Set HTML page title", "@page "),
295− (
296− "@let",
297− "Define a variable, attribute bundle, or component",
298− "@let ",
299− ),
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"),
304− (
305− "@each",
306− "Loop over values (@each $var, $i in list)",
307− "@each ",
308− ),
309− (
310− "@include",
311− "Include another .hl file (DOM + definitions)",
312− "@include ",
313− ),
314− (
315− "@import",
316− "Import definitions only (no DOM nodes)",
317− "@import ",
318− ),
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"),
322− (
323− "@slot",
324− "Named slot in component for caller content",
325− "@slot ",
326− ),
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 "),
331− (
332− "@breakpoint",
333− "Define custom responsive breakpoint",
334− "@breakpoint ",
335− ),
336− (
337− "@theme",
338− "Define design tokens (colors, spacing, fonts)",
339− "@theme",
340− ),
341− (
342− "@deprecated",
343− "Mark next @let component as deprecated",
344− "@deprecated ",
345− ),
346− (
347− "@extends",
348− "Inherit a layout template and fill @slot blocks",
349− "@extends ",
350− ),
351− ("@font-face", "Define a custom font face", "@font-face"),
352− (
353− "@json-ld",
354− "Add JSON-LD structured data to head",
355− "@json-ld",
356− ),
357− (
358− "@assert",
359− "Compile-time assertion for variable values",
360− "@assert ",
361− ),
362− (
363− "@data",
364− "Load JSON data file into template variables",
365− "@data ",
366− ),
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 "),
370− ]
371− .iter()
372− .map(|(name, detail, insert)| item(name, CompletionItemKind::SNIPPET, detail, insert, range))
373− .collect()
143+ htmlang::parser::known_directives()
144+ .iter()
145+ .map(|name| {
146+ let label = format!("@{}", name);
147+ let detail = docs::directive(name).map_or("Directive", |d| d.summary);
148+ item(&label, CompletionItemKind::SNIPPET, detail, &format!("{} ", label), range)
149+ })
150+ .collect()
374151}
375152
376153fn snippet_completions(range: Range) -> Vec<CompletionItem> {
377154 let snippets: &[(&str, &str, &str)] = &[
378155 (
379− "card component",
380− "Reusable card with title and content",
381− "@let card \\$title\n @el [padding 20, background white, rounded 8]\n @text [bold] \\$title\n @children",
156+ "function",
157+ "Define a reusable function",
158+ "@let ${1:name} \\$${2:param}\n @el [${3:padding 16}]\n @children",
382159 ),
383160 (
384161 "responsive layout",
@@ -390,85 +167,30 @@ fn snippet_completions(range: Range) -> Vec<CompletionItem> {
390167 "Navigation bar with horizontal items",
391168 "@nav [padding 16, background #1a1a2e]\n @row [spacing 20, align-items center]",
392169 ),
393− (
394− "hero section",
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}",
397− ),
398170 (
399171 "each with else",
400172 "Loop with empty-state fallback",
401173 "@each \\$${1:item} in ${2:list}\n @text \\$${1:item}\n@else\n @text [color #888] No items found.",
402174 ),
403175 (
404− "button with hover",
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}",
407− ),
408− (
409− "form with inputs",
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",
412− ),
413− (
414− "grid layout",
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}",
417− ),
418− (
419− "footer section",
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}",
422− ),
423− (
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}",
427− ),
428− (
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}",
432− ),
433− (
434− "dark mode toggle",
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}",
437− ),
438− (
439− "truncated text",
440− "Text with ellipsis overflow",
441− "@text [max-width ${1:200}, truncate] ${2:Long text that will be truncated...}",
442− ),
443− (
444− "@let component",
445− "Define a reusable component",
446− "@let ${1:name} \\$${2:param}\n @el [${3:padding 16}]\n @children",
447− ),
448− (
449− "@each loop",
450− "Iterate over a list of items",
451− "@each \\$${1:item} in ${2:items}\n @text \\$${1:item}",
452− ),
453− (
454− "@if conditional",
176+ "if / else",
455177 "Conditional rendering block",
456178 "@if ${1:condition}\n ${2:content}\n@else\n ${3:fallback}",
457179 ),
458180 (
459− "@match pattern",
460− "Pattern matching block",
461− "@match \\$${1:value}\n @case ${2:option1}\n ${3:content}\n @default\n ${4:fallback}",
181+ "form with inputs",
182+ "Form with a labeled input and a submit button",
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",
462184 ),
463185 (
464− "@data JSON load",
465− "Load variables from a JSON file",
466− "@data ${1:data.json}",
186+ "grid layout",
187+ "Grid with equal columns",
188+ "@grid [grid-cols ${1:3}, spacing ${2:20}]\n @el [padding 20]\n ${3:Item}",
467189 ),
468190 (
469− "@component scoped",
470− "Define a scoped component with styles",
471− "@component ${1:name} \\$${2:param}\n @style\n .inner { ${3:padding: 16px;} }\n @el [class inner]\n @children",
191+ "dark mode",
192+ "Element with light and dark styles",
193+ "@el [background ${1:white}, dark:background ${2:#1a1a2e}, color ${3:#333}, dark:color ${4:#eee}]\n ${5:Content}",
472194 ),
473195 ];
474196
@@ -617,7 +339,7 @@ fn element_specific_attrs(element: &str) -> &'static [&'static str] {
617339 "list",
618340 "accept",
619341 ],
620− "button" | "btn" => &[
342+ "button" => &[
621343 "type",
622344 "disabled",
623345 "name",
@@ -641,7 +363,7 @@ fn element_specific_attrs(element: &str) -> &'static [&'static str] {
641363 "autofocus",
642364 "spellcheck",
643365 ],
644− "option" | "opt" => &["value", "selected", "disabled", "label"],
366+ "option" => &["value", "selected", "disabled", "label"],
645367 "form" => &[
646368 "action",
647369 "method",
@@ -651,7 +373,7 @@ fn element_specific_attrs(element: &str) -> &'static [&'static str] {
651373 "enctype",
652374 "name",
653375 ],
654− "image" | "img" => &[
376+ "image" => &[
655377 "src",
656378 "alt",
657379 "width",
@@ -707,19 +429,14 @@ fn element_specific_attrs(element: &str) -> &'static [&'static str] {
707429fn attr_value_completions(before: &str, range: Range) -> Option<Vec<CompletionItem>> {
708430 let bracket_content = before.rsplit('[').next()?;
709431 let segment = bracket_content.rsplit(',').next()?.trim_start();
710− let attr_end = segment
711− .find(|c: char| c.is_whitespace())
712− .unwrap_or(segment.len());
713− let attr = &segment[..attr_end];
714− let after_attr = &segment[attr_end..];
715− if !after_attr.starts_with(' ') {
716− return None;
717− }
718− // Only fire when no value has been started yet.
719− let typed = after_attr.trim_start();
720− if !typed.is_empty() {
721− return None;
722− }
432+ // A style (`cursor `) or an HTML attribute (`type=`) with no value typed yet.
433+ let attr = segment
434+ .strip_suffix('=')
435+ .filter(|a| !a.contains(char::is_whitespace))
436+ .or_else(|| {
437+ let (attr, rest) = segment.split_once(' ')?;
438+ rest.trim().is_empty().then_some(attr)
439+ })?;
723440
724441 // Strip state prefix to find the base attribute.
725442 let base_attr = if let Some(pos) = attr.rfind(':') {
@@ -903,644 +620,75 @@ fn attr_value_completions(before: &str, range: Range) -> Option<Vec<CompletionIt
903620 )
904621}
905622
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.
906627fn attr_completions(range: Range, element: Option<&str>) -> Vec<CompletionItem> {
907− [
908− // Layout
909− ("spacing", "Gap between children (supports CSS units)", true),
910− ("gap", "Gap between children (alias for spacing)", true),
911− (
912− "padding",
913− "Inner padding (1/2/3/4 values, supports CSS units)",
914− true,
915− ),
916− ("padding-x", "Horizontal padding", true),
917− ("padding-y", "Vertical padding", true),
918− // Sizing
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),
925− // Alignment
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),
932− // Style
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),
949− // Typography
950− (
951− "text-align",
952− "Text alignment (left/center/right/justify)",
953− true,
954− ),
955− ("line-height", "Line height (unitless or px)", true),
956− ("letter-spacing", "Letter spacing", true),
957− (
958− "text-transform",
959− "Text transform (uppercase/lowercase/capitalize)",
960− true,
961− ),
962− (
963− "white-space",
964− "White-space behavior (nowrap/pre/normal)",
965− true,
966− ),
967− // Overflow & positioning
968− ("overflow", "Overflow behavior (hidden/scroll/auto)", true),
969− (
970− "position",
971− "Position type (relative/absolute/fixed/sticky)",
972− true,
973− ),
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
980− (
981− "display",
982− "Display mode (none/block/inline/flex/grid)",
983− true,
984− ),
985− ("visibility", "Visibility (visible/hidden)", true),
986− // Transform & filters
987− ("transform", "CSS transform (e.g., rotate(45deg))", true),
988− (
989− "backdrop-filter",
990− "Backdrop filter (e.g., blur(10px))",
991− true,
992− ),
993− // Effects
994− ("shadow", "Box shadow (CSS value)", true),
995− // Flow
996− ("wrap", "Enable flex-wrap", false),
997− ("gap-x", "Horizontal gap between children", true),
998− ("gap-y", "Vertical gap between children", true),
999− // Grid
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),
1007− (
1008− "container-name",
1009− "Container name for @container queries",
1010− true,
1011− ),
1012− (
1013− "container-type",
1014− "Container type (inline-size/size/normal)",
1015− true,
1016− ),
1017− // Identity
1018− ("id", "HTML id attribute", true),
1019− ("class", "HTML class attribute", true),
1020− // Animation
1021− ("animation", "CSS animation (e.g., name 0.3s ease)", true),
1022− // Form
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),
1037− // Accessibility
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),
1048− (
1049− "padding-inline",
1050− "Inline (horizontal) padding for i18n",
1051− true,
1052− ),
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),
1056− (
1057− "scroll-snap-type",
1058− "Scroll snap behavior (x/y mandatory/proximity)",
1059− true,
1060− ),
1061− (
1062− "scroll-snap-align",
1063− "Snap alignment (start/center/end)",
1064− true,
1065− ),
1066− // Media attributes
1067− (
1068− "controls",
1069− "Show media controls (for @video, @audio)",
1070− false,
1071− ),
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),
1079− // List
1080− ("ordered", "Use ordered list (ol instead of ul)", false),
1081− // Media src
1082− ("src", "Source URL for media elements", true),
1083− // Margin
1084− ("margin", "Outer margin (1/2/3/4 values)", true),
1085− ("margin-x", "Horizontal margin", true),
1086− ("margin-y", "Vertical margin", true),
1087− // Filter & object
1088− (
1089− "filter",
1090− "CSS filter (blur, brightness, grayscale, etc.)",
1091− true,
1092− ),
1093− (
1094− "object-fit",
1095− "Object fit for images (cover/contain/fill)",
1096− true,
1097− ),
1098− ("object-position", "Object position within container", true),
1099− // Text extras
1100− ("text-shadow", "Text shadow (CSS value)", true),
1101− ("text-overflow", "Text overflow (ellipsis/clip)", true),
1102− // Interaction
1103− ("pointer-events", "Pointer events (none/auto)", true),
1104− ("user-select", "User selection (none/text/all)", true),
1105− // Flexbox/grid alignment
1106− (
1107− "justify-content",
1108− "Main axis alignment (center/space-between/etc.)",
1109− true,
1110− ),
1111− (
1112− "align-items",
1113− "Cross axis alignment (center/baseline/etc.)",
1114− true,
1115− ),
1116− // Flex item
1117− ("order", "Flex/grid item order", true),
1118− // Background extras
1119− (
1120− "background-size",
1121− "Background size (cover/contain/auto)",
1122− true,
1123− ),
1124− (
1125− "background-position",
1126− "Background position (center/top/etc.)",
1127− true,
1128− ),
1129− (
1130− "background-repeat",
1131− "Background repeat (no-repeat/repeat/etc.)",
1132− true,
1133− ),
1134− // Text wrapping
1135− (
1136− "word-break",
1137− "Word break behavior (break-all/keep-all)",
1138− true,
1139− ),
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),
1149− (
1150− "scope",
1151− "Table header scope (col/row/colgroup/rowgroup)",
1152− true,
1153− ),
1154− ("inline", "Inline SVG images into output", false),
1155− // Hidden
1156− ("hidden", "Hide element (display:none)", false),
1157− // Overflow directional
1158− (
1159− "overflow-x",
1160− "Horizontal overflow (hidden/scroll/auto)",
1161− true,
1162− ),
1163− ("overflow-y", "Vertical overflow (hidden/scroll/auto)", true),
1164− // Inset
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),
1169− (
1170− "color-scheme",
1171− "Color scheme preference (light/dark/light dark)",
1172− true,
1173− ),
1174− (
1175− "appearance",
1176− "Form element appearance (none to reset)",
1177− true,
1178− ),
1179− // Popover API
1180− (
1181− "popover",
1182− "Make element a popover (HTML Popover API)",
1183− false,
1184− ),
1185− ("popovertarget", "ID of popover to toggle", true),
1186− (
1187− "popovertargetaction",
1188− "Popover action (toggle/show/hide)",
1189− true,
1190− ),
1191− // Input hints
1192− (
1193− "inputmode",
1194− "Virtual keyboard type (numeric/email/search/tel/url)",
1195− true,
1196− ),
1197− (
1198− "enterkeyhint",
1199− "Enter key label (done/go/next/search/send)",
1200− true,
1201− ),
1202− (
1203− "fetchpriority",
1204− "Resource fetch priority (high/low/auto)",
1205− true,
1206− ),
1207− (
1208− "translate",
1209− "Whether element should be translated (yes/no)",
1210− true,
1211− ),
1212− ("spellcheck", "Spell check mode (true/false)", true),
1213− // List styling
1214− (
1215− "list-style",
1216− "List style type (disc/circle/square/none)",
1217− true,
1218− ),
1219− // Table styling
1220− (
1221− "border-collapse",
1222− "Border collapse mode (collapse/separate)",
1223− true,
1224− ),
1225− ("border-spacing", "Spacing between table cell borders", true),
1226− // Text decoration
1227− (
1228− "text-decoration",
1229− "Text decoration (underline/overline/line-through)",
1230− true,
1231− ),
1232− ("text-decoration-color", "Text decoration color", true),
1233− (
1234− "text-decoration-thickness",
1235− "Text decoration thickness",
1236− true,
1237− ),
1238− (
1239− "text-decoration-style",
1240− "Text decoration style (solid/dashed/dotted/wavy)",
1241− true,
1242− ),
1243− // Grid/flex placement
1244− (
1245− "place-items",
1246− "Shorthand for align-items + justify-items",
1247− true,
1248− ),
1249− (
1250− "place-self",
1251− "Shorthand for align-self + justify-self",
1252− true,
1253− ),
1254− // Scroll behavior
1255− ("scroll-behavior", "Scroll behavior (smooth/auto)", true),
1256− // Resize
1257− (
1258− "resize",
1259− "Resize behavior (none/both/horizontal/vertical)",
1260− true,
1261− ),
1262− // State prefixes
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),
1272− // Child selectors
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),
1284− // Motion prefixes
1285− ("motion-safe:", "Style when motion is allowed", false),
1286− (
1287− "motion-reduce:",
1288− "Style when reduced motion preferred",
1289− false,
1290− ),
1291− // Orientation prefixes
1292− ("landscape:", "Style in landscape orientation", false),
1293− ("portrait:", "Style in portrait orientation", false),
1294− // Media prefixes
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),
1299− (
1300− "mix-blend-mode",
1301− "Blend mode (multiply, screen, overlay, etc.)",
1302− true,
1303− ),
1304− ("background-blend-mode", "Background blend mode", true),
1305− // Writing mode
1306− (
1307− "writing-mode",
1308− "Writing mode (horizontal-tb, vertical-rl, etc.)",
1309− true,
1310− ),
1311− // Multi-column layout
1312− (
1313− "column-count",
1314− "Number of columns in multi-column layout",
1315− true,
1316− ),
1317− ("column-gap", "Gap between columns", true),
1318− // Text
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
1328− (
1329− "place-content",
1330− "Shorthand for align-content + justify-content",
1331− true,
1332− ),
1333− // Background image
1334− (
1335− "background-image",
1336− "Background image (url or gradient)",
1337− true,
1338− ),
1339− // New CSS properties
1340− (
1341− "font-weight",
1342− "Font weight (100-900, bold, lighter, bolder)",
1343− true,
1344− ),
1345− ("font-style", "Font style (normal/italic/oblique)", true),
1346− ("text-wrap", "Text wrapping (balance/pretty/nowrap)", true),
1347− (
1348− "will-change",
1349− "Performance hint for animations (transform, opacity)",
1350− true,
1351− ),
1352− (
1353− "touch-action",
1354− "Touch behavior (none/pan-x/pan-y/manipulation)",
1355− true,
1356− ),
1357− (
1358− "vertical-align",
1359− "Vertical alignment (middle/top/bottom/baseline)",
1360− true,
1361− ),
1362− (
1363− "contain",
1364− "CSS containment (layout/paint/content/strict)",
1365− true,
1366− ),
1367− (
1368− "content-visibility",
1369− "Content visibility (auto/visible/hidden)",
1370− true,
1371− ),
1372− (
1373− "scroll-margin",
1374− "Scroll margin (for scroll-snap and anchor offsets)",
1375− true,
1376− ),
1377− ("scroll-margin-top", "Scroll margin top", true),
1378− (
1379− "scroll-padding",
1380− "Scroll padding (for scroll-snap containers)",
1381− true,
1382− ),
1383− ("scroll-padding-top", "Scroll padding top", true),
1384− // Pseudo-element content
1385− (
1386− "content",
1387− "Content for ::before/::after (use with before:/after: prefix)",
1388− true,
1389− ),
1390− // Iframe/form/link attributes
1391− ("sandbox", "Iframe sandbox restrictions", true),
1392− ("allow", "Iframe permissions policy", true),
1393− ("allowfullscreen", "Allow iframe fullscreen", false),
1394− (
1395− "target",
1396− "Link/form target (_blank/_self/_parent/_top)",
1397− true,
1398− ),
1399− // CSS shorthands
1400− (
1401− "truncate",
1402− "Truncate text with ellipsis (single line)",
1403− false,
1404− ),
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),
1408− // Direction
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),
1414− // Pseudo-elements
1415− ("before:", "Style ::before pseudo-element", false),
1416− ("after:", "Style ::after pseudo-element", false),
1417− ("selection:", "Style text selection", false),
1418− // Grid areas
1419− (
1420− "grid-template-areas",
1421− "Define named grid areas (quoted string)",
1422− true,
1423− ),
1424− ("grid-area", "Place element in a named grid area", true),
1425− // View transitions
1426− (
1427− "view-transition-name",
1428− "Assign a view transition name",
1429− true,
1430− ),
1431− // Has pseudo-selector
1432− (
1433− "has(",
1434− "Style when element has matching children :has()",
1435− false,
1436− ),
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),
1442− (
1443− "target:",
1444− "Style when element is the URL fragment target",
1445− false,
1446− ),
1447− ("valid:", "Style when form element is valid", false),
1448− ("invalid:", "Style when form element is invalid", false),
1449− // New CSS properties
1450− (
1451− "text-underline-offset",
1452− "Offset of text underline from its default position",
1453− true,
1454− ),
1455− (
1456− "column-width",
1457− "Ideal width of columns in multi-column layout",
1458− true,
1459− ),
1460− (
1461− "column-rule",
1462− "Rule between columns (width style color)",
1463− true,
1464− ),
1465− ]
1466− .iter()
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(),
1477− };
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))
1485− .unwrap_or(false);
1486− completion.sort_text = Some(if boosted {
1487− format!("0_{}", name)
628+ let boosted = element.map_or(&[][..], element_specific_attrs);
629+ let mut items = Vec::new();
630+ let mut push = |label: String, insert: String, detail: &str, rank: &str, name: &str| {
631+ let mut completion = item(&label, CompletionItemKind::PROPERTY, detail, &insert, range);
632+ let rank = if boosted.contains(&name) { "0" } else { rank };
633+ completion.sort_text = Some(format!("{}_{}", rank, label));
634+ items.push(completion);
635+ };
636+ for name in vocab::HTMLANG_ATTRIBUTES {
637+ let doc = docs::attribute(name);
638+ let insert = if doc.is_none_or(|d| d.takes_value()) {
639+ format!("{} ", name)
1488640 } else {
1489− format!("5_{}", name)
1490− });
1491− completion
1492− })
1493− .collect()
641+ name.to_string()
642+ };
643+ let detail = doc.map_or("htmlang attribute", |d| d.summary);
644+ push(name.to_string(), insert, detail, "2", name);
645+ }
646+ for name in vocab::CSS_PROPERTIES {
647+ let detail = docs::attribute(name).map_or("CSS property", |d| d.summary);
648+ push(name.to_string(), format!("{} ", name), detail, "5", name);
649+ }
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);
653+ }
654+ }
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);
658+ }
659+ }
660+ let prefixes = vocab::PSEUDO_PREFIXES
661+ .iter()
662+ .map(|(p, _)| *p)
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);
669+ }
670+ items
1494671}
1495672
673+/// Styles that can follow a state/media prefix: `hover:color`, `md:padding`.
1496674fn state_attr_completions(prefix: &str, range: Range) -> Vec<CompletionItem> {
1497− [
1498− ("background", "Background color/value", true),
1499− ("color", "Text color", true),
1500− ("border", "Border (width [color])", true),
1501− ("border-top", "Top border (width [color])", true),
1502− ("border-bottom", "Bottom border (width [color])", true),
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),
1532− ]
1533− .iter()
1534− .map(|(name, detail, takes_value)| {
1535− let full = format!("{}:{}", prefix, name);
1536− let insert = if *takes_value {
1537− format!("{} ", full)
1538− } else {
1539− full.clone()
1540− };
1541− item(&full, CompletionItemKind::PROPERTY, detail, &insert, range)
1542− })
1543− .collect()
675+ let htmlang = vocab::HTMLANG_ATTRIBUTES
676+ .iter()
677+ .map(|name| (*name, docs::attribute(name).is_none_or(|d| d.takes_value())));
678+ let css = vocab::CSS_PROPERTIES.iter().map(|name| (*name, true));
679+ htmlang
680+ .chain(css)
681+ .map(|(name, takes_value)| {
682+ let full = format!("{}{}", prefix, name);
683+ let insert = if takes_value {
684+ format!("{} ", full)
685+ } else {
686+ full.clone()
687+ };
688+ let detail = docs::attribute(name).map_or("CSS property", |d| d.summary);
689+ item(&full, CompletionItemKind::PROPERTY, detail, &insert, range)
690+ })
691+ .collect()
1544692}
1545693
1546694fn 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
1649797fn variable_completions(text: &str, range: Range) -> Vec<CompletionItem> {
1650798 let mut items = Vec::new();
1651799
1652− for (name, value) in collect_theme_tokens(text) {
1653− items.push(item(
1654− &format!("${}", name),
1655− CompletionItemKind::COLOR,
1656− &format!("@theme \u{2014} {}", value),
1657− &format!("${}", name),
1658− range,
1659− ));
800+ for doc in docs::BUNDLES {
801+ let label = format!("${}", doc.name);
802+ let detail = format!("{} (standard-library bundle)", doc.summary);
803+ items.push(item(&label, CompletionItemKind::CONSTANT, &detail, &label, range));
1660804 }
1661805
1662806 for line in text.lines() {
@@ -1797,15 +941,51 @@ mod tests {
1797941 }
1798942
1799943 #[test]
1800− fn theme_tokens_extracted_from_block() {
1801− let text = "@theme\n primary #3b82f6\n spacing-md 16\n@row\n";
1802− let tokens = collect_theme_tokens(text);
1803− assert_eq!(
1804− tokens,
1805− vec![
1806− ("primary".into(), "#3b82f6".into()),
1807− ("spacing-md".into(), "16".into()),
1808− ]
1809− );
944+ fn element_and_directive_completions_come_from_the_compiler() {
945+ let range = Range::default();
946+ let elements = element_completions(range);
947+ for name in ElementKind::all_names() {
948+ let label = format!("@{}", name);
949+ assert!(elements.iter().any(|i| i.label == label), "missing {}", label);
950+ }
951+ assert!(elements.iter().any(|i| i.label == "@badge"));
952+ let directives = directive_completions(range);
953+ for name in htmlang::parser::known_directives() {
954+ assert!(directives.iter().any(|i| i.label == format!("@{}", name)));
955+ }
956+ }
957+
958+ #[test]
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),
966+ }
967+ };
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_"));
976+ }
977+
978+ #[test]
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"));
984+ }
985+
986+ #[test]
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"));
1810990 }
1811991}