on master help sign in
Add 10 improvements: CompileConfig struct, JSON helpers, LSP modularization, HTTP/404/CORS, shared CSS extraction, sitemap lastmod/priority, CSS logical properties, build perf reporting, @import glob patterns, @slot defaults
commit4497b38f09a9dfddec85438801720f1a06682dfc authoriko <mail@iko.soy> dateWed 15 Apr 2026, 23:51 (6 months ago) parentf8e4784
11 files changed+4201−3835
Cargo.toml+4 src/bin/htmlang-lsp.rs−3740 src/bin/htmlang_lsp/analysis.rs+1193 src/bin/htmlang_lsp/completion.rs+1052 src/bin/htmlang_lsp/hover.rs+552 src/bin/htmlang_lsp/main.rs+507 src/bin/htmlang_lsp/navigation.rs+455 src/codegen.rs+57 src/main.rs+240−80 src/parser.rs+101 src/serve.rs+40−15
Cargo.toml+4
@@ -3,6 +3,10 @@ name = "htmlang"
33version = "0.1.0"
44edition = "2024"
55
6+[[bin]]
7+name = "htmlang-lsp"
8+path = "src/bin/htmlang_lsp/main.rs"
9+
610[dependencies]
711notify = "7"
812tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "sync"] }
src/bin/htmlang-lsp.rsdeleted−3740
@@ -1,3740 +0,0 @@
1−use std::collections::HashMap;
2−use std::sync::Arc;
3−
4−use tokio::sync::RwLock;
5−use tower_lsp::jsonrpc::Result;
6−use tower_lsp::lsp_types::*;
7−use tower_lsp::{Client, LanguageServer, LspService, Server};
8−
9−// ---------------------------------------------------------------------------
10−// Backend
11−// ---------------------------------------------------------------------------
12−
13−struct Backend {
14− client: Client,
15− documents: Arc<RwLock<HashMap<Url, String>>>,
16−}
17−
18−impl Backend {
19− async fn on_change(&self, uri: Url, text: String) {
20− let result = htmlang::parser::parse(&text);
21− let diags: Vec<Diagnostic> = result
22− .diagnostics
23− .iter()
24− .map(|d| {
25− let severity = match d.severity {
26− htmlang::parser::Severity::Error => DiagnosticSeverity::ERROR,
27− htmlang::parser::Severity::Warning => DiagnosticSeverity::WARNING,
28− };
29− let line = d.line.saturating_sub(1) as u32;
30− Diagnostic {
31− range: Range::new(Position::new(line, 0), Position::new(line, 1000)),
32− severity: Some(severity),
33− source: Some("htmlang".into()),
34− message: d.message.clone(),
35− ..Default::default()
36− }
37− })
38− .collect();
39− self.client
40− .publish_diagnostics(uri.clone(), diags, None)
41− .await;
42− self.documents.write().await.insert(uri, text);
43− }
44−}
45−
46−#[tower_lsp::async_trait]
47−impl LanguageServer for Backend {
48− async fn initialize(&self, _: InitializeParams) -> Result<InitializeResult> {
49− Ok(InitializeResult {
50− capabilities: ServerCapabilities {
51− text_document_sync: Some(TextDocumentSyncCapability::Kind(
52− TextDocumentSyncKind::FULL,
53− )),
54− completion_provider: Some(CompletionOptions {
55− trigger_characters: Some(vec![
56− "@".into(),
57− "$".into(),
58− "[".into(),
59− ",".into(),
60− ]),
61− ..Default::default()
62− }),
63− hover_provider: Some(HoverProviderCapability::Simple(true)),
64− definition_provider: Some(OneOf::Left(true)),
65− rename_provider: Some(OneOf::Right(RenameOptions {
66− prepare_provider: Some(true),
67− work_done_progress_options: Default::default(),
68− })),
69− document_symbol_provider: Some(OneOf::Left(true)),
70− code_action_provider: Some(CodeActionProviderCapability::Options(
71− CodeActionOptions {
72− code_action_kinds: Some(vec![
73− CodeActionKind::QUICKFIX,
74− CodeActionKind::REFACTOR_EXTRACT,
75− ]),
76− ..Default::default()
77− },
78− )),
79− color_provider: Some(ColorProviderCapability::Simple(true)),
80− folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
81− semantic_tokens_provider: Some(
82− SemanticTokensServerCapabilities::SemanticTokensOptions(SemanticTokensOptions {
83− legend: SemanticTokensLegend {
84− token_types: vec![
85− SemanticTokenType::KEYWORD,
86− SemanticTokenType::VARIABLE,
87− SemanticTokenType::FUNCTION,
88− SemanticTokenType::STRING,
89− SemanticTokenType::COMMENT,
90− SemanticTokenType::PROPERTY,
91− ],
92− token_modifiers: vec![
93− SemanticTokenModifier::new("deprecated"), // bit 0 = 1 → dimmed/strikethrough
94− ],
95− },
96− full: Some(SemanticTokensFullOptions::Bool(true)),
97− range: None,
98− ..Default::default()
99− }),
100− ),
101− inlay_hint_provider: Some(OneOf::Left(true)),
102− workspace_symbol_provider: Some(OneOf::Left(true)),
103− linked_editing_range_provider: Some(
104− LinkedEditingRangeServerCapabilities::Simple(true),
105− ),
106− document_formatting_provider: Some(OneOf::Left(true)),
107− references_provider: Some(OneOf::Left(true)),
108− signature_help_provider: Some(SignatureHelpOptions {
109− trigger_characters: Some(vec!["[".into(), ",".into()]),
110− retrigger_characters: Some(vec![",".into()]),
111− work_done_progress_options: Default::default(),
112− }),
113− ..Default::default()
114− },
115− ..Default::default()
116− })
117− }
118−
119− async fn initialized(&self, _: InitializedParams) {}
120−
121− async fn shutdown(&self) -> Result<()> {
122− Ok(())
123− }
124−
125− async fn did_open(&self, params: DidOpenTextDocumentParams) {
126− self.on_change(params.text_document.uri, params.text_document.text)
127− .await;
128− }
129−
130− async fn did_change(&self, params: DidChangeTextDocumentParams) {
131− if let Some(change) = params.content_changes.into_iter().last() {
132− self.on_change(params.text_document.uri, change.text).await;
133− }
134− }
135−
136− async fn did_close(&self, params: DidCloseTextDocumentParams) {
137− self.documents
138− .write()
139− .await
140− .remove(&params.text_document.uri);
141− self.client
142− .publish_diagnostics(params.text_document.uri, vec![], None)
143− .await;
144− }
145−
146− async fn completion(
147− &self,
148− params: CompletionParams,
149− ) -> Result<Option<CompletionResponse>> {
150− let uri = &params.text_document_position.text_document.uri;
151− let pos = params.text_document_position.position;
152− let docs = self.documents.read().await;
153− let text = match docs.get(uri) {
154− Some(t) => t.clone(),
155− None => return Ok(None),
156− };
157− drop(docs);
158−
159− // Check if we're on an @include/@import/@use/@extends line for path/symbol completions
160− let lines: Vec<&str> = text.lines().collect();
161− if let Some(line) = lines.get(pos.line as usize) {
162− let trimmed = line.trim_start();
163− if trimmed.starts_with("@include ") || trimmed.starts_with("@import ")
164− || trimmed.starts_with("@extends ") {
165− let items = path_completions(uri, pos);
166− if !items.is_empty() {
167− return Ok(Some(CompletionResponse::Array(items)));
168− }
169− }
170− // @use "file.hl" fn1, fn2 — after the filename, suggest exported @fn names
171− if trimmed.starts_with("@use ") {
172− let after_use = &trimmed[5..];
173− // If we already have a filename (quoted or unquoted), suggest symbols from that file
174− let has_file = after_use.contains(".hl");
175− if has_file {
176− let items = use_symbol_completions(uri, trimmed, pos);
177− if !items.is_empty() {
178− return Ok(Some(CompletionResponse::Array(items)));
179− }
180− } else {
181− let items = path_completions(uri, pos);
182− if !items.is_empty() {
183− return Ok(Some(CompletionResponse::Array(items)));
184− }
185− }
186− }
187− }
188−
189− let items = completions(&text, pos);
190− Ok(if items.is_empty() {
191− None
192− } else {
193− Some(CompletionResponse::Array(items))
194− })
195− }
196−
197− async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
198− let uri = &params.text_document_position_params.text_document.uri;
199− let pos = params.text_document_position_params.position;
200− let docs = self.documents.read().await;
201− let text = match docs.get(uri) {
202− Some(t) => t.clone(),
203− None => return Ok(None),
204− };
205− drop(docs);
206−
207− Ok(hover_at(&text, pos))
208− }
209−
210− async fn goto_definition(
211− &self,
212− params: GotoDefinitionParams,
213− ) -> Result<Option<GotoDefinitionResponse>> {
214− let uri = params.text_document_position_params.text_document.uri.clone();
215− let pos = params.text_document_position_params.position;
216− let docs = self.documents.read().await;
217− let text = match docs.get(&uri) {
218− Some(t) => t.clone(),
219− None => return Ok(None),
220− };
221− drop(docs);
222−
223− Ok(definition_at(&text, pos, &uri))
224− }
225−
226− async fn prepare_rename(
227− &self,
228− params: TextDocumentPositionParams,
229− ) -> Result<Option<PrepareRenameResponse>> {
230− let uri = &params.text_document.uri;
231− let pos = params.position;
232− let docs = self.documents.read().await;
233− let text = match docs.get(uri) {
234− Some(t) => t.clone(),
235− None => return Ok(None),
236− };
237− drop(docs);
238−
239− Ok(prepare_rename_at(&text, pos))
240− }
241−
242− async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
243− let uri = params.text_document_position.text_document.uri.clone();
244− let pos = params.text_document_position.position;
245− let new_name = params.new_name;
246− let docs = self.documents.read().await;
247− let text = match docs.get(&uri) {
248− Some(t) => t.clone(),
249− None => return Ok(None),
250− };
251− drop(docs);
252−
253− Ok(rename_at(&text, pos, &new_name, &uri))
254− }
255−
256− async fn document_symbol(
257− &self,
258− params: DocumentSymbolParams,
259− ) -> Result<Option<DocumentSymbolResponse>> {
260− let uri = &params.text_document.uri;
261− let docs = self.documents.read().await;
262− let text = match docs.get(uri) {
263− Some(t) => t.clone(),
264− None => return Ok(None),
265− };
266− drop(docs);
267−
268− let symbols = document_symbols(&text);
269− Ok(if symbols.is_empty() {
270− None
271− } else {
272− Some(DocumentSymbolResponse::Flat(symbols))
273− })
274− }
275−
276− async fn code_action(
277− &self,
278− params: CodeActionParams,
279− ) -> Result<Option<CodeActionResponse>> {
280− let uri = params.text_document.uri.clone();
281− let docs = self.documents.read().await;
282− let text = match docs.get(&uri) {
283− Some(t) => t.clone(),
284− None => return Ok(None),
285− };
286− drop(docs);
287−
288− let actions = code_actions(&text, &params.range, &params.context.diagnostics, &uri);
289− Ok(if actions.is_empty() {
290− None
291− } else {
292− Some(actions)
293− })
294− }
295−
296− async fn document_color(
297− &self,
298− params: DocumentColorParams,
299− ) -> Result<Vec<ColorInformation>> {
300− let uri = &params.text_document.uri;
301− let docs = self.documents.read().await;
302− let text = match docs.get(uri) {
303− Some(t) => t.clone(),
304− None => return Ok(vec![]),
305− };
306− drop(docs);
307− Ok(find_colors(&text))
308− }
309−
310− async fn color_presentation(
311− &self,
312− params: ColorPresentationParams,
313− ) -> Result<Vec<ColorPresentation>> {
314− let c = params.color;
315− let r = (c.red * 255.0) as u8;
316− let g = (c.green * 255.0) as u8;
317− let b = (c.blue * 255.0) as u8;
318− let hex = if c.alpha < 1.0 {
319− let a = (c.alpha * 255.0) as u8;
320− format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a)
321− } else {
322− format!("#{:02x}{:02x}{:02x}", r, g, b)
323− };
324− Ok(vec![ColorPresentation {
325− label: hex.clone(),
326− text_edit: Some(TextEdit {
327− range: params.range,
328− new_text: hex,
329− }),
330− additional_text_edits: None,
331− }])
332− }
333−
334− async fn folding_range(
335− &self,
336− params: FoldingRangeParams,
337− ) -> Result<Option<Vec<FoldingRange>>> {
338− let uri = &params.text_document.uri;
339− let docs = self.documents.read().await;
340− let text = match docs.get(uri) {
341− Some(t) => t.clone(),
342− None => return Ok(None),
343− };
344− drop(docs);
345− let ranges = folding_ranges(&text);
346− Ok(if ranges.is_empty() { None } else { Some(ranges) })
347− }
348−
349− async fn semantic_tokens_full(
350− &self,
351− params: SemanticTokensParams,
352− ) -> Result<Option<SemanticTokensResult>> {
353− let uri = &params.text_document.uri;
354− let docs = self.documents.read().await;
355− let text = match docs.get(uri) {
356− Some(t) => t.clone(),
357− None => return Ok(None),
358− };
359− drop(docs);
360− let tokens = semantic_tokens(&text);
361− Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
362− result_id: None,
363− data: tokens,
364− })))
365− }
366−
367− async fn inlay_hint(
368− &self,
369− params: InlayHintParams,
370− ) -> Result<Option<Vec<InlayHint>>> {
371− let uri = &params.text_document.uri;
372− let docs = self.documents.read().await;
373− let text = match docs.get(uri) {
374− Some(t) => t.clone(),
375− None => return Ok(None),
376− };
377− drop(docs);
378− let hints = inlay_hints(&text);
379− Ok(if hints.is_empty() { None } else { Some(hints) })
380− }
381−
382− #[allow(deprecated)]
383− async fn symbol(
384− &self,
385− params: WorkspaceSymbolParams,
386− ) -> Result<Option<Vec<SymbolInformation>>> {
387− let query = params.query.to_lowercase();
388− let docs = self.documents.read().await;
389− let mut all_symbols = Vec::new();
390− for (uri, text) in docs.iter() {
391− let symbols = document_symbols(text);
392− for mut sym in symbols {
393− sym.location.uri = uri.clone();
394− if query.is_empty()
395− || sym.name.to_lowercase().contains(&query)
396− {
397− all_symbols.push(sym);
398− }
399− }
400− }
401− drop(docs);
402− Ok(if all_symbols.is_empty() {
403− None
404− } else {
405− Some(all_symbols)
406− })
407− }
408−
409− async fn linked_editing_range(
410− &self,
411− params: LinkedEditingRangeParams,
412− ) -> Result<Option<LinkedEditingRanges>> {
413− let uri = &params.text_document_position_params.text_document.uri;
414− let pos = params.text_document_position_params.position;
415− let docs = self.documents.read().await;
416− let text = match docs.get(uri) {
417− Some(t) => t.clone(),
418− None => return Ok(None),
419− };
420− drop(docs);
421− Ok(linked_editing_ranges(&text, pos))
422− }
423−
424− async fn formatting(
425− &self,
426− params: DocumentFormattingParams,
427− ) -> Result<Option<Vec<TextEdit>>> {
428− let uri = &params.text_document.uri;
429− let docs = self.documents.read().await;
430− let text = match docs.get(uri) {
431− Some(t) => t.clone(),
432− None => return Ok(None),
433− };
434− drop(docs);
435− let formatted = htmlang::fmt::format(&text);
436− if formatted == text {
437− return Ok(None);
438− }
439− let last_line = text.lines().count().saturating_sub(1) as u32;
440− let last_col = text.lines().last().map_or(0, |l| l.len()) as u32;
441− Ok(Some(vec![TextEdit {
442− range: Range::new(Position::new(0, 0), Position::new(last_line, last_col)),
443− new_text: formatted,
444− }]))
445− }
446−
447− async fn references(
448− &self,
449− params: ReferenceParams,
450− ) -> Result<Option<Vec<Location>>> {
451− let uri = params.text_document_position.text_document.uri.clone();
452− let pos = params.text_document_position.position;
453− let docs = self.documents.read().await;
454− let text = match docs.get(&uri) {
455− Some(t) => t.clone(),
456− None => return Ok(None),
457− };
458− drop(docs);
459− let refs = find_references(&text, pos, &uri);
460− Ok(if refs.is_empty() { None } else { Some(refs) })
461− }
462−
463− async fn signature_help(
464− &self,
465− params: SignatureHelpParams,
466− ) -> Result<Option<SignatureHelp>> {
467− let uri = &params.text_document_position_params.text_document.uri;
468− let pos = params.text_document_position_params.position;
469− let docs = self.documents.read().await;
470− let text = match docs.get(uri) {
471− Some(t) => t.clone(),
472− None => return Ok(None),
473− };
474− drop(docs);
475− Ok(get_signature_help(&text, pos))
476− }
477−}
478−
479−// ---------------------------------------------------------------------------
480−// Completions
481−// ---------------------------------------------------------------------------
482−
483−fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
484− let lines: Vec<&str> = text.lines().collect();
485− let line = match lines.get(position.line as usize) {
486− Some(l) => *l,
487− None => return vec![],
488− };
489−
490− let col = (position.character as usize).min(line.len());
491− let before = &line[..col];
492−
493− let word_start = find_word_start(before);
494− let edit_range = Range::new(
495− Position::new(position.line, word_start as u32),
496− position,
497− );
498−
499− // Inside attribute brackets?
500− if in_brackets(before) {
501− let current_word = &before[word_start..];
502−
503− // $ variable/define reference
504− if current_word.starts_with('$') {
505− return variable_completions(text, edit_range);
506− }
507−
508− // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
509− if let Some(colon) = current_word.find(':') {
510− let prefix = &current_word[..colon];
511− if matches!(prefix, "hover" | "active" | "focus" | "focus-visible" | "focus-within" | "disabled" | "checked" | "placeholder" | "first" | "last" | "odd" | "even" | "before" | "after" | "dark" | "print" | "sm" | "md" | "lg" | "xl" | "2xl" | "motion-safe" | "motion-reduce" | "landscape" | "portrait") {
512− return state_attr_completions(prefix, edit_range);
513− }
514− }
515−
516− // Color value completions after color-related attributes
517− if let Some(colors) = color_value_completions(before, edit_range) {
518− return colors;
519− }
520−
521− return attr_completions(edit_range);
522− }
523−
524− // $ variable reference outside brackets
525− let current_word = &before[word_start..];
526− if current_word.starts_with('$') {
527− return variable_completions(text, edit_range);
528− }
529−
530− // @ element/directive or start of line
531− let trimmed = before.trim_start();
532− if trimmed.is_empty() || trimmed.starts_with('@') {
533− let mut items = element_completions(edit_range);
534− items.extend(directive_completions(edit_range));
535− items.extend(function_completions(text, edit_range));
536− items.extend(snippet_completions(edit_range));
537− return items;
538− }
539−
540− vec![]
541−}
542−
543−fn find_word_start(text: &str) -> usize {
544− let bytes = text.as_bytes();
545− let mut i = bytes.len();
546− while i > 0 {
547− let c = bytes[i - 1];
548− if c.is_ascii_alphanumeric()
549− || c == b'@'
550− || c == b'$'
551− || c == b'-'
552− || c == b'_'
553− || c == b':'
554− {
555− i -= 1;
556− } else {
557− break;
558− }
559− }
560− i
561−}
562−
563−fn in_brackets(text: &str) -> bool {
564− let mut depth: i32 = 0;
565− for ch in text.chars() {
566− if ch == '[' {
567− depth += 1;
568− } else if ch == ']' {
569− depth -= 1;
570− }
571− }
572− depth > 0
573−}
574−
575−fn item(
576− label: &str,
577− kind: CompletionItemKind,
578− detail: &str,
579− insert: &str,
580− range: Range,
581−) -> CompletionItem {
582− CompletionItem {
583− label: label.to_string(),
584− kind: Some(kind),
585− detail: Some(detail.to_string()),
586− text_edit: Some(CompletionTextEdit::Edit(TextEdit {
587− range,
588− new_text: insert.to_string(),
589− })),
590− ..Default::default()
591− }
592−}
593−
594−fn element_completions(range: Range) -> Vec<CompletionItem> {
595− [
596− ("@row", "Horizontal layout (flexbox row)"),
597− ("@column", "Vertical layout (flexbox column)"),
598− ("@col", "Vertical layout (short for @column)"),
599− ("@el", "Generic container"),
600− ("@text", "Styled inline text (span)"),
601− ("@paragraph", "Flowing text block (p)"),
602− ("@p", "Flowing text block (short for @paragraph)"),
603− ("@image", "Image element"),
604− ("@img", "Image element (short for @image)"),
605− ("@link", "Anchor/link element"),
606− ("@input", "Form input element (self-closing)"),
607− ("@button", "Button element"),
608− ("@btn", "Button element (short for @button)"),
609− ("@select", "Select dropdown element"),
610− ("@textarea", "Multi-line text input"),
611− ("@option", "Option inside @select"),
612− ("@opt", "Option (short for @option)"),
613− ("@label", "Label element"),
614− ("@raw", "Raw HTML escape hatch"),
615− ("@children", "Slot for caller's children (inside @fn)"),
616− ("@slot", "Named slot inside @fn (e.g., @slot header)"),
617− // Semantic elements
618− ("@nav", "Navigation container (nav)"),
619− ("@header", "Page/section header (header)"),
620− ("@footer", "Page/section footer (footer)"),
621− ("@main", "Main content area (main)"),
622− ("@section", "Thematic section (section)"),
623− ("@article", "Self-contained content (article)"),
624− ("@aside", "Sidebar/tangential content (aside)"),
625− // List elements
626− ("@list", "List container (ul/ol, use [ordered] for ol)"),
627− ("@item", "List item (li)"),
628− ("@li", "List item (short for @item)"),
629− // Table elements
630− ("@table", "Table element"),
631− ("@thead", "Table head group"),
632− ("@tbody", "Table body group"),
633− ("@tr", "Table row"),
634− ("@td", "Table cell"),
635− ("@th", "Table header cell"),
636− // Media elements
637− ("@video", "Video element"),
638− ("@audio", "Audio element"),
639− // Additional elements
640− ("@form", "Form container (form)"),
641− ("@details", "Disclosure widget (details)"),
642− ("@summary", "Summary for @details"),
643− ("@blockquote", "Block quotation"),
644− ("@cite", "Citation/source reference"),
645− ("@code", "Inline code (monospace)"),
646− ("@pre", "Preformatted text block"),
647− ("@hr", "Horizontal rule/divider"),
648− ("@divider", "Horizontal rule (alias for @hr)"),
649− ("@figure", "Figure with optional caption"),
650− ("@figcaption", "Caption for @figure"),
651− ("@progress", "Progress bar (value, max attributes)"),
652− ("@meter", "Meter/gauge element (value, min, max)"),
653− ("@fragment", "Group children without a wrapper element"),
654− // Dialog & interactive
655− ("@dialog", "Dialog/modal element (dialog)"),
656− // Definition lists
657− ("@dl", "Description list (dl)"),
658− ("@dt", "Description term (dt)"),
659− ("@dd", "Description details (dd)"),
660− // Form grouping
661− ("@fieldset", "Fieldset group (fieldset)"),
662− ("@legend", "Legend for @fieldset (legend)"),
663− // Picture/responsive images
664− ("@picture", "Responsive image container (picture)"),
665− ("@source", "Media source for @picture/@video/@audio (source)"),
666− // Inline semantics
667− ("@time", "Date/time element (time)"),
668− ("@mark", "Highlighted/marked text (mark)"),
669− ("@kbd", "Keyboard input (kbd)"),
670− ("@abbr", "Abbreviation (abbr)"),
671− // Datalist
672− ("@datalist", "Predefined options for @input (datalist)"),
673− // New elements
674− ("@iframe", "Embedded external page (iframe)"),
675− ("@output", "Form calculation result (output)"),
676− ("@canvas", "Drawing surface for scripts (canvas)"),
677− ("@script", "Inline script element (script)"),
678− ("@noscript", "Fallback content when scripts disabled (noscript)"),
679− ("@address", "Contact information (address)"),
680− ("@search", "Search section (search)"),
681− ("@breadcrumb", "Breadcrumb navigation (nav with aria)"),
682− ]
683− .iter()
684− .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
685− .collect()
686−}
687−
688−fn directive_completions(range: Range) -> Vec<CompletionItem> {
689− [
690− ("@page", "Set HTML page title", "@page "),
691− ("@let", "Define a variable", "@let "),
692− ("@define", "Define an attribute bundle", "@define "),
693− ("@fn", "Define a reusable function (supports $param=default)", "@fn "),
694− ("@keyframes", "Define a CSS animation", "@keyframes "),
695− ("@if", "Conditional rendering", "@if "),
696− ("@else if", "Else-if branch", "@else if "),
697− ("@else", "Else branch", "@else"),
698− ("@each", "Loop over values (@each $var, $i in list)", "@each "),
699− ("@include", "Include another .hl file (DOM + definitions)", "@include "),
700− ("@import", "Import definitions only (no DOM nodes)", "@import "),
701− ("@meta", "Add a <meta> tag to <head>", "@meta "),
702− ("@head", "Add raw content to <head>", "@head"),
703− ("@style", "Add raw CSS to stylesheet", "@style"),
704− ("@slot", "Named slot in @fn for caller content", "@slot "),
705− ("@match", "Pattern matching on a value", "@match "),
706− ("@case", "Match case (inside @match)", "@case "),
707− ("@default", "Default case (inside @match)", "@default"),
708− ("@warn", "Emit a compile-time warning", "@warn "),
709− ("@debug", "Print debug message during compilation", "@debug "),
710− ("@lang", "Set document language (html lang attribute)", "@lang "),
711− ("@favicon", "Set favicon (inlined as base64 data URI)", "@favicon "),
712− ("@unless", "Inverse conditional (renders when false)", "@unless "),
713− ("@og", "Add Open Graph meta tag", "@og "),
714− ("@breakpoint", "Define custom responsive breakpoint", "@breakpoint "),
715− ("@theme", "Define design tokens (colors, spacing, fonts)", "@theme"),
716− ("@deprecated", "Mark next @fn as deprecated", "@deprecated "),
717− ("@extends", "Inherit a layout template and fill @slot blocks", "@extends "),
718− ("@use", "Selective import of definitions from a file", "@use "),
719− ("@canonical", "Set canonical URL for the page", "@canonical "),
720− ("@base", "Set base URL for relative links", "@base "),
721− ("@font-face", "Define a custom font face", "@font-face"),
722− ("@json-ld", "Add JSON-LD structured data to head", "@json-ld"),
723− ("@mixin", "Define a composable style group (use with ...$name)", "@mixin "),
724− ("@assert", "Compile-time assertion for variable values", "@assert "),
725− ("@data", "Load JSON data file into template variables", "@data "),
726− ("@env", "Access compile-time environment variable", "@env "),
727− ("@fetch", "Fetch data from URL at compile time", "@fetch "),
728− ("@svg", "Inline SVG file with optional attributes", "@svg "),
729− ("@css-property", "Define typed CSS custom property (@property)", "@css-property "),
730− ]
731− .iter()
732− .map(|(name, detail, insert)| {
733− item(name, CompletionItemKind::SNIPPET, detail, insert, range)
734− })
735− .collect()
736−}
737−
738−fn snippet_completions(range: Range) -> Vec<CompletionItem> {
739− let snippets: &[(&str, &str, &str)] = &[
740− (
741− "card component",
742− "Reusable card with title and content",
743− "@fn card \\$title\n @el [padding 20, background white, rounded 8]\n @text [bold] \\$title\n @children",
744− ),
745− (
746− "responsive layout",
747− "Centered responsive column layout",
748− "@column [max-width 800, center-x, padding 40, spacing 20]",
749− ),
750− (
751− "nav bar",
752− "Navigation bar with horizontal items",
753− "@nav [padding 16, background #1a1a2e]\n @row [spacing 20, align-items center]",
754− ),
755− (
756− "hero section",
757− "Hero section with title and subtitle",
758− "@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}",
759− ),
760− (
761− "each with else",
762− "Loop with empty-state fallback",
763− "@each \\$${1:item} in ${2:list}\n @text \\$${1:item}\n@else\n @text [color #888] No items found.",
764− ),
765− (
766− "button with hover",
767− "Interactive button with hover effect",
768− "@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}",
769− ),
770− (
771− "form with inputs",
772− "Form with labeled inputs and submit button",
773− "@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",
774− ),
775− (
776− "grid layout",
777− "Responsive grid with columns",
778− "@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}",
779− ),
780− (
781− "footer section",
782− "Footer with columns and copyright",
783− "@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}",
784− ),
785− (
786− "avatar with image",
787− "Circular avatar with fallback",
788− "@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}",
789− ),
790− (
791− "carousel horizontal",
792− "Scroll-snap horizontal carousel",
793− "@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}",
794− ),
795− (
796− "dark mode toggle",
797− "Element with light/dark mode styles",
798− "@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}",
799− ),
800− (
801− "truncated text",
802− "Text with ellipsis overflow",
803− "@text [max-width ${1:200}, truncate] ${2:Long text that will be truncated...}",
804− ),
805− (
806− "@fn definition",
807− "Define a reusable function/component",
808− "@fn ${1:name} \\$${2:param}\n @el [${3:padding 16}]\n @children",
809− ),
810− (
811− "@each loop",
812− "Iterate over a list of items",
813− "@each \\$${1:item} in ${2:items}\n @text \\$${1:item}",
814− ),
815− (
816− "@if conditional",
817− "Conditional rendering block",
818− "@if ${1:condition}\n ${2:content}\n@else\n ${3:fallback}",
819− ),
820− (
821− "@match pattern",
822− "Pattern matching block",
823− "@match \\$${1:value}\n @case ${2:option1}\n ${3:content}\n @default\n ${4:fallback}",
824− ),
825− (
826− "@for range loop",
827− "Loop over a numeric range",
828− "@for \\$${1:i} in ${2:0}..${3:10}\n @text \\$${1:i}",
829− ),
830− (
831− "@data JSON load",
832− "Load variables from a JSON file",
833− "@data ${1:data.json}",
834− ),
835− (
836− "@component scoped",
837− "Define a scoped component with styles",
838− "@component ${1:name} \\$${2:param}\n @style\n .inner { ${3:padding: 16px;} }\n @el [class inner]\n @children",
839− ),
840− ];
841−
842− snippets
843− .iter()
844− .map(|(label, detail, insert)| CompletionItem {
845− label: label.to_string(),
846− kind: Some(CompletionItemKind::SNIPPET),
847− detail: Some(detail.to_string()),
848− insert_text_format: Some(InsertTextFormat::SNIPPET),
849− text_edit: Some(CompletionTextEdit::Edit(TextEdit {
850− range,
851− new_text: insert.to_string(),
852− })),
853− sort_text: Some(format!("zz_{}", label)),
854− ..Default::default()
855− })
856− .collect()
857−}
858−
859−fn path_completions(uri: &Url, position: Position) -> Vec<CompletionItem> {
860− let file_path = match uri.to_file_path() {
861− Ok(p) => p,
862− Err(_) => return vec![],
863− };
864− let dir = match file_path.parent() {
865− Some(d) => d,
866− None => return vec![],
867− };
868−
869− let col = position.character as u32;
870− let edit_range = Range::new(Position::new(position.line, col), Position::new(position.line, col));
871−
872− let entries = match std::fs::read_dir(dir) {
873− Ok(e) => e,
874− Err(_) => return vec![],
875− };
876−
877− let mut items = Vec::new();
878− for entry in entries.flatten() {
879− let path = entry.path();
880− if path.extension().and_then(|e| e.to_str()) == Some("hl") {
881− if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
882− // Skip the current file itself
883− if Some(name) == file_path.file_name().and_then(|n| n.to_str()) {
884− continue;
885− }
886− items.push(CompletionItem {
887− label: name.to_string(),
888− kind: Some(CompletionItemKind::FILE),
889− detail: Some("htmlang file".to_string()),
890− text_edit: Some(CompletionTextEdit::Edit(TextEdit {
891− range: edit_range,
892− new_text: name.to_string(),
893− })),
894− ..Default::default()
895− });
896− }
897− }
898− }
899− items
900−}
901−
902−/// Suggest exported @fn and @define names from a file referenced in @use
903−fn use_symbol_completions(uri: &Url, line: &str, position: Position) -> Vec<CompletionItem> {
904− let file_path = match uri.to_file_path() {
905− Ok(p) => p,
906− Err(_) => return vec![],
907− };
908− let dir = match file_path.parent() {
909− Some(d) => d,
910− None => return vec![],
911− };
912−
913− // Extract the filename from @use "file.hl" or @use file.hl
914− let after_use = &line.trim_start()[5..]; // skip "@use "
915− let filename = if after_use.starts_with('"') {
916− let end = after_use[1..].find('"').map(|i| i + 1).unwrap_or(after_use.len());
917− &after_use[1..end]
918− } else {
919− after_use.split_whitespace().next().unwrap_or("")
920− };
921−
922− if filename.is_empty() {
923− return vec![];
924− }
925−
926− let target_path = dir.join(filename);
927− let target_content = match std::fs::read_to_string(&target_path) {
928− Ok(s) => s,
929− Err(_) => return vec![],
930− };
931−
932− let col = position.character as u32;
933− let edit_range = Range::new(Position::new(position.line, col), Position::new(position.line, col));
934−
935− let mut items = Vec::new();
936−
937− for target_line in target_content.lines() {
938− let trimmed = target_line.trim();
939− if let Some(rest) = trimmed.strip_prefix("@fn ") {
940− if let Some(name) = rest.split_whitespace().next() {
941− let params: Vec<&str> = rest.split_whitespace().skip(1).collect();
942− let detail = if params.is_empty() {
943− format!("@fn {} (from {})", name, filename)
944− } else {
945− format!("@fn {} {} (from {})", name, params.join(" "), filename)
946− };
947− items.push(CompletionItem {
948− label: name.to_string(),
949− kind: Some(CompletionItemKind::FUNCTION),
950− detail: Some(detail),
951− text_edit: Some(CompletionTextEdit::Edit(TextEdit {
952− range: edit_range,
953− new_text: name.to_string(),
954− })),
955− ..Default::default()
956− });
957− }
958− } else if let Some(rest) = trimmed.strip_prefix("@define ") {
959− if let Some(name) = rest.split_whitespace().next() {
960− let name = name.trim_end_matches('[');
961− items.push(CompletionItem {
962− label: name.to_string(),
963− kind: Some(CompletionItemKind::VARIABLE),
964− detail: Some(format!("@define {} (from {})", name, filename)),
965− text_edit: Some(CompletionTextEdit::Edit(TextEdit {
966− range: edit_range,
967− new_text: name.to_string(),
968− })),
969− ..Default::default()
970− });
971− }
972− } else if let Some(rest) = trimmed.strip_prefix("@component ") {
973− if let Some(name) = rest.split_whitespace().next() {
974− items.push(CompletionItem {
975− label: name.to_string(),
976− kind: Some(CompletionItemKind::CLASS),
977− detail: Some(format!("@component {} (from {})", name, filename)),
978− text_edit: Some(CompletionTextEdit::Edit(TextEdit {
979− range: edit_range,
980− new_text: name.to_string(),
981− })),
982− ..Default::default()
983− });
984− }
985− }
986− }
987−
988− items
989−}
990−
991−fn attr_completions(range: Range) -> Vec<CompletionItem> {
992− [
993− // Layout
994− ("spacing", "Gap between children (supports CSS units)", true),
995− ("gap", "Gap between children (alias for spacing)", true),
996− ("padding", "Inner padding (1/2/3/4 values, supports CSS units)", true),
997− ("padding-x", "Horizontal padding", true),
998− ("padding-y", "Vertical padding", true),
999− // Sizing
1000− ("width", "Width (px/% | fill | shrink)", true),
1001− ("height", "Height (px/% | fill | shrink)", true),
1002− ("min-width", "Minimum width", true),
1003− ("max-width", "Maximum width", true),
1004− ("min-height", "Minimum height", true),
1005− ("max-height", "Maximum height", true),
1006− // Alignment
1007− ("center-x", "Center horizontally", false),
1008− ("center-y", "Center vertically", false),
1009− ("align-left", "Align left", false),
1010− ("align-right", "Align right", false),
1011− ("align-top", "Align top", false),
1012− ("align-bottom", "Align bottom", false),
1013− // Style
1014− ("background", "Background color/value", true),
1015− ("color", "Text color", true),
1016− ("border", "Border (width [color])", true),
1017− ("border-top", "Top border (width [color])", true),
1018− ("border-bottom", "Bottom border (width [color])", true),
1019− ("border-left", "Left border (width [color])", true),
1020− ("border-right", "Right border (width [color])", true),
1021− ("rounded", "Border radius", true),
1022− ("bold", "Bold text", false),
1023− ("italic", "Italic text", false),
1024− ("underline", "Underlined text", false),
1025− ("size", "Font size", true),
1026− ("font", "Font family", true),
1027− ("transition", "CSS transition", true),
1028− ("cursor", "CSS cursor type", true),
1029− ("opacity", "Opacity (0-1)", true),
1030− // Typography
1031− ("text-align", "Text alignment (left/center/right/justify)", true),
1032− ("line-height", "Line height (unitless or px)", true),
1033− ("letter-spacing", "Letter spacing", true),
1034− ("text-transform", "Text transform (uppercase/lowercase/capitalize)", true),
1035− ("white-space", "White-space behavior (nowrap/pre/normal)", true),
1036− // Overflow & positioning
1037− ("overflow", "Overflow behavior (hidden/scroll/auto)", true),
1038− ("position", "Position type (relative/absolute/fixed/sticky)", true),
1039− ("top", "Top offset (for positioned elements)", true),
1040− ("right", "Right offset (for positioned elements)", true),
1041− ("bottom", "Bottom offset (for positioned elements)", true),
1042− ("left", "Left offset (for positioned elements)", true),
1043− ("z-index", "Stack order (integer)", true),
1044− // Display & visibility
1045− ("display", "Display mode (none/block/inline/flex/grid)", true),
1046− ("visibility", "Visibility (visible/hidden)", true),
1047− // Transform & filters
1048− ("transform", "CSS transform (e.g., rotate(45deg))", true),
1049− ("backdrop-filter", "Backdrop filter (e.g., blur(10px))", true),
1050− // Effects
1051− ("shadow", "Box shadow (CSS value)", true),
1052− // Flow
1053− ("wrap", "Enable flex-wrap", false),
1054− ("gap-x", "Horizontal gap between children", true),
1055− ("gap-y", "Vertical gap between children", true),
1056− // Grid
1057− ("grid", "Enable CSS grid layout", false),
1058− ("grid-cols", "Grid columns (number or template)", true),
1059− ("grid-rows", "Grid rows (number or template)", true),
1060− ("col-span", "Span columns in grid", true),
1061− ("row-span", "Span rows in grid", true),
1062− // Container queries
1063− ("container", "Enable container queries (inline-size)", false),
1064− ("container-name", "Container name for @container queries", true),
1065− ("container-type", "Container type (inline-size/size/normal)", true),
1066− // Identity
1067− ("id", "HTML id attribute", true),
1068− ("class", "HTML class attribute", true),
1069− // Animation
1070− ("animation", "CSS animation (e.g., name 0.3s ease)", true),
1071− // Form
1072− ("type", "Input type (text/email/password/submit/...)", true),
1073− ("placeholder", "Placeholder text", true),
1074− ("name", "Form field name", true),
1075− ("value", "Form field value", true),
1076− ("disabled", "Disable the element", false),
1077− ("required", "Mark field as required", false),
1078− ("checked", "Checkbox/radio checked state", false),
1079− ("for", "Label target (id of associated input)", true),
1080− ("action", "Form action URL", true),
1081− ("method", "Form method (get/post)", true),
1082− ("rows", "Textarea rows", true),
1083− ("cols", "Textarea columns", true),
1084− ("maxlength", "Maximum input length", true),
1085− ("multiple", "Allow multiple selections", false),
1086− // Accessibility
1087− ("alt", "Alternative text (for images)", true),
1088− ("role", "ARIA role", true),
1089− ("tabindex", "Tab order", true),
1090− ("title", "Tooltip text", true),
1091− ("aria-label", "Accessible label", true),
1092− ("aria-hidden", "Hide from assistive tech (true/false)", true),
1093− ("data-", "Custom data attribute", true),
1094− // CSS: aspect-ratio, outline, logical properties, scroll-snap
1095− ("aspect-ratio", "CSS aspect ratio (e.g., 16/9, 1)", true),
1096− ("outline", "Outline (width [color])", true),
1097− ("padding-inline", "Inline (horizontal) padding for i18n", true),
1098− ("padding-block", "Block (vertical) padding for i18n", true),
1099− ("margin-inline", "Inline (horizontal) margin for i18n", true),
1100− ("margin-block", "Block (vertical) margin for i18n", true),
1101− ("scroll-snap-type", "Scroll snap behavior (x/y mandatory/proximity)", true),
1102− ("scroll-snap-align", "Snap alignment (start/center/end)", true),
1103− // Media attributes
1104− ("controls", "Show media controls (for @video, @audio)", false),
1105− ("autoplay", "Auto-play media", false),
1106− ("loop", "Loop media playback", false),
1107− ("muted", "Mute media", false),
1108− ("poster", "Video poster image URL", true),
1109− ("preload", "Media preload hint (auto/metadata/none)", true),
1110− ("loading", "Loading behavior (lazy/eager)", true),
1111− ("decoding", "Image decoding (async/sync/auto)", true),
1112− // List
1113− ("ordered", "Use ordered list (ol instead of ul)", false),
1114− // Media src
1115− ("src", "Source URL for media elements", true),
1116− // Margin
1117− ("margin", "Outer margin (1/2/3/4 values)", true),
1118− ("margin-x", "Horizontal margin", true),
1119− ("margin-y", "Vertical margin", true),
1120− // Filter & object
1121− ("filter", "CSS filter (blur, brightness, grayscale, etc.)", true),
1122− ("object-fit", "Object fit for images (cover/contain/fill)", true),
1123− ("object-position", "Object position within container", true),
1124− // Text extras
1125− ("text-shadow", "Text shadow (CSS value)", true),
1126− ("text-overflow", "Text overflow (ellipsis/clip)", true),
1127− // Interaction
1128− ("pointer-events", "Pointer events (none/auto)", true),
1129− ("user-select", "User selection (none/text/all)", true),
1130− // Flexbox/grid alignment
1131− ("justify-content", "Main axis alignment (center/space-between/etc.)", true),
1132− ("align-items", "Cross axis alignment (center/baseline/etc.)", true),
1133− // Flex item
1134− ("order", "Flex/grid item order", true),
1135− // Background extras
1136− ("background-size", "Background size (cover/contain/auto)", true),
1137− ("background-position", "Background position (center/top/etc.)", true),
1138− ("background-repeat", "Background repeat (no-repeat/repeat/etc.)", true),
1139− // Text wrapping
1140− ("word-break", "Word break behavior (break-all/keep-all)", true),
1141− ("overflow-wrap", "Overflow wrap (break-word/anywhere)", true),
1142− // New element attrs
1143− ("open", "Details initially open", false),
1144− ("novalidate", "Disable form validation", false),
1145− ("low", "Meter low threshold", true),
1146− ("high", "Meter high threshold", true),
1147− ("optimum", "Meter optimum value", true),
1148− ("colspan", "Table cell column span", true),
1149− ("rowspan", "Table cell row span", true),
1150− ("scope", "Table header scope (col/row/colgroup/rowgroup)", true),
1151− ("inline", "Inline SVG images into output", false),
1152− // Hidden
1153− ("hidden", "Hide element (display:none)", false),
1154− // Overflow directional
1155− ("overflow-x", "Horizontal overflow (hidden/scroll/auto)", true),
1156− ("overflow-y", "Vertical overflow (hidden/scroll/auto)", true),
1157− // Inset
1158− ("inset", "Shorthand for top/right/bottom/left", true),
1159− // Modern form theming
1160− ("accent-color", "Accent color for form controls", true),
1161− ("caret-color", "Text cursor color", true),
1162− ("color-scheme", "Color scheme preference (light/dark/light dark)", true),
1163− ("appearance", "Form element appearance (none to reset)", true),
1164− // Popover API
1165− ("popover", "Make element a popover (HTML Popover API)", false),
1166− ("popovertarget", "ID of popover to toggle", true),
1167− ("popovertargetaction", "Popover action (toggle/show/hide)", true),
1168− // Input hints
1169− ("inputmode", "Virtual keyboard type (numeric/email/search/tel/url)", true),
1170− ("enterkeyhint", "Enter key label (done/go/next/search/send)", true),
1171− ("fetchpriority", "Resource fetch priority (high/low/auto)", true),
1172− ("translate", "Whether element should be translated (yes/no)", true),
1173− ("spellcheck", "Spell check mode (true/false)", true),
1174− // List styling
1175− ("list-style", "List style type (disc/circle/square/none)", true),
1176− // Table styling
1177− ("border-collapse", "Border collapse mode (collapse/separate)", true),
1178− ("border-spacing", "Spacing between table cell borders", true),
1179− // Text decoration
1180− ("text-decoration", "Text decoration (underline/overline/line-through)", true),
1181− ("text-decoration-color", "Text decoration color", true),
1182− ("text-decoration-thickness", "Text decoration thickness", true),
1183− ("text-decoration-style", "Text decoration style (solid/dashed/dotted/wavy)", true),
1184− // Grid/flex placement
1185− ("place-items", "Shorthand for align-items + justify-items", true),
1186− ("place-self", "Shorthand for align-self + justify-self", true),
1187− // Scroll behavior
1188− ("scroll-behavior", "Scroll behavior (smooth/auto)", true),
1189− // Resize
1190− ("resize", "Resize behavior (none/both/horizontal/vertical)", true),
1191− // State prefixes
1192− ("hover:", "Style on hover", false),
1193− ("active:", "Style on active/click", false),
1194− ("focus:", "Style on focus", false),
1195− // New pseudo-state prefixes
1196− ("focus-visible:", "Style on keyboard focus", false),
1197− ("focus-within:", "Style when child has focus", false),
1198− ("disabled:", "Style when disabled", false),
1199− ("checked:", "Style when checked", false),
1200− ("placeholder:", "Style placeholder text", false),
1201− // Child selectors
1202− ("first:", "Style first child", false),
1203− ("last:", "Style last child", false),
1204− ("odd:", "Style odd children (1st, 3rd, ...)", false),
1205− ("even:", "Style even children (2nd, 4th, ...)", false),
1206− // Responsive prefixes
1207− ("sm:", "Style at 640px+ (small)", false),
1208− ("md:", "Style at 768px+ (medium)", false),
1209− ("lg:", "Style at 1024px+ (large)", false),
1210− ("xl:", "Style at 1280px+ (extra large)", false),
1211− // Additional responsive prefixes
1212− ("2xl:", "Style at 1536px+ (2x extra large)", false),
1213− // Motion prefixes
1214− ("motion-safe:", "Style when motion is allowed", false),
1215− ("motion-reduce:", "Style when reduced motion preferred", false),
1216− // Orientation prefixes
1217− ("landscape:", "Style in landscape orientation", false),
1218− ("portrait:", "Style in portrait orientation", false),
1219− // Media prefixes
1220− ("dark:", "Style in dark color scheme", false),
1221− ("print:", "Style for print media", false),
1222− // Clipping & blending
1223− ("clip-path", "Clip path (circle, polygon, etc.)", true),
1224− ("mix-blend-mode", "Blend mode (multiply, screen, overlay, etc.)", true),
1225− ("background-blend-mode", "Background blend mode", true),
1226− // Writing mode
1227− ("writing-mode", "Writing mode (horizontal-tb, vertical-rl, etc.)", true),
1228− // Multi-column layout
1229− ("column-count", "Number of columns in multi-column layout", true),
1230− ("column-gap", "Gap between columns", true),
1231− // Text
1232− ("text-indent", "First-line text indentation", true),
1233− ("hyphens", "Hyphenation behavior (none/manual/auto)", true),
1234− // Flex item sizing
1235− ("flex-grow", "Flex grow factor", true),
1236− ("flex-shrink", "Flex shrink factor", true),
1237− ("flex-basis", "Flex basis (initial main size)", true),
1238− // Stacking context
1239− ("isolation", "Create stacking context (isolate/auto)", true),
1240− // Grid/flex placement
1241− ("place-content", "Shorthand for align-content + justify-content", true),
1242− // Background image
1243− ("background-image", "Background image (url or gradient)", true),
1244− // New CSS properties
1245− ("font-weight", "Font weight (100-900, bold, lighter, bolder)", true),
1246− ("font-style", "Font style (normal/italic/oblique)", true),
1247− ("text-wrap", "Text wrapping (balance/pretty/nowrap)", true),
1248− ("will-change", "Performance hint for animations (transform, opacity)", true),
1249− ("touch-action", "Touch behavior (none/pan-x/pan-y/manipulation)", true),
1250− ("vertical-align", "Vertical alignment (middle/top/bottom/baseline)", true),
1251− ("contain", "CSS containment (layout/paint/content/strict)", true),
1252− ("content-visibility", "Content visibility (auto/visible/hidden)", true),
1253− ("scroll-margin", "Scroll margin (for scroll-snap and anchor offsets)", true),
1254− ("scroll-margin-top", "Scroll margin top", true),
1255− ("scroll-padding", "Scroll padding (for scroll-snap containers)", true),
1256− ("scroll-padding-top", "Scroll padding top", true),
1257− // Pseudo-element content
1258− ("content", "Content for ::before/::after (use with before:/after: prefix)", true),
1259− // Iframe/form/link attributes
1260− ("sandbox", "Iframe sandbox restrictions", true),
1261− ("allow", "Iframe permissions policy", true),
1262− ("allowfullscreen", "Allow iframe fullscreen", false),
1263− ("target", "Link/form target (_blank/_self/_parent/_top)", true),
1264− // CSS shorthands
1265− ("truncate", "Truncate text with ellipsis (single line)", false),
1266− ("line-clamp", "Clamp text to N lines with ellipsis", true),
1267− ("blur", "Apply blur filter (px)", true),
1268− ("backdrop-blur", "Apply backdrop blur filter (px)", true),
1269− ("no-scrollbar", "Hide scrollbar while keeping overflow", false),
1270− ("skeleton", "Add shimmer loading skeleton animation", false),
1271− ("gradient", "Linear gradient (color1 color2 [angle])", true),
1272− // Direction
1273− ("direction", "Text direction (ltr/rtl)", true),
1274− // Container query prefixes
1275− ("cq-sm:", "Container query at 640px+", false),
1276− ("cq-md:", "Container query at 768px+", false),
1277− ("cq-lg:", "Container query at 1024px+", false),
1278− // Pseudo-elements
1279− ("before:", "Style ::before pseudo-element", false),
1280− ("after:", "Style ::after pseudo-element", false),
1281− ("selection:", "Style text selection", false),
1282− // Grid areas
1283− ("grid-template-areas", "Define named grid areas (quoted string)", true),
1284− ("grid-area", "Place element in a named grid area", true),
1285− // View transitions
1286− ("view-transition-name", "Assign a view transition name", true),
1287− // Animate shorthand
1288− ("animate", "Animation shorthand (name duration [timing])", true),
1289− // Has pseudo-selector
1290− ("has(", "Style when element has matching children :has()", false),
1291− // Critical CSS hint
1292− ("critical", "Mark as above-fold critical CSS", false),
1293− // New pseudo-state prefixes
1294− ("visited:", "Style visited links", false),
1295− ("empty:", "Style when element has no children", false),
1296− ("target:", "Style when element is the URL fragment target", false),
1297− ("valid:", "Style when form element is valid", false),
1298− ("invalid:", "Style when form element is invalid", false),
1299− // New CSS properties
1300− ("text-underline-offset", "Offset of text underline from its default position", true),
1301− ("column-width", "Ideal width of columns in multi-column layout", true),
1302− ("column-rule", "Rule between columns (width style color)", true),
1303− ]
1304− .iter()
1305− .map(|(name, detail, takes_value)| {
1306− let insert = if *takes_value {
1307− format!("{} ", name)
1308− } else {
1309− name.to_string()
1310− };
1311− item(name, CompletionItemKind::PROPERTY, detail, &insert, range)
1312− })
1313− .collect()
1314−}
1315−
1316−fn state_attr_completions(prefix: &str, range: Range) -> Vec<CompletionItem> {
1317− [
1318− ("background", "Background color/value", true),
1319− ("color", "Text color", true),
1320− ("border", "Border (width [color])", true),
1321− ("border-top", "Top border (width [color])", true),
1322− ("border-bottom", "Bottom border (width [color])", true),
1323− ("border-left", "Left border (width [color])", true),
1324− ("border-right", "Right border (width [color])", true),
1325− ("rounded", "Border radius", true),
1326− ("bold", "Bold text", false),
1327− ("italic", "Italic text", false),
1328− ("underline", "Underlined text", false),
1329− ("size", "Font size", true),
1330− ("opacity", "Opacity (0-1)", true),
1331− ("cursor", "CSS cursor type", true),
1332− ("shadow", "Box shadow (CSS value)", true),
1333− ("text-shadow", "Text shadow", true),
1334− ("transform", "CSS transform", true),
1335− ("filter", "CSS filter", true),
1336− ("display", "Display mode", true),
1337− ("visibility", "Visibility", true),
1338− ("pointer-events", "Pointer events", true),
1339− ("user-select", "User selection", true),
1340− // Layout attrs for pseudo-states
1341− ("width", "Width", true),
1342− ("height", "Height", true),
1343− ("padding", "Inner padding", true),
1344− ("padding-x", "Horizontal padding", true),
1345− ("padding-y", "Vertical padding", true),
1346− ("margin", "Outer margin", true),
1347− ("margin-x", "Horizontal margin", true),
1348− ("margin-y", "Vertical margin", true),
1349− ("outline", "Outline", true),
1350− ("text-decoration", "Text decoration", true),
1351− ("text-decoration-color", "Text decoration color", true),
1352− ]
1353− .iter()
1354− .map(|(name, detail, takes_value)| {
1355− let full = format!("{}:{}", prefix, name);
1356− let insert = if *takes_value {
1357− format!("{} ", full)
1358− } else {
1359− full.clone()
1360− };
1361− item(&full, CompletionItemKind::PROPERTY, detail, &insert, range)
1362− })
1363− .collect()
1364−}
1365−
1366−fn color_value_completions(before: &str, range: Range) -> Option<Vec<CompletionItem>> {
1367− // Find the preceding attribute name before the cursor value position.
1368− // Inside brackets, attributes are comma-separated. Look for the last attribute token
1369− // before the current value position. Pattern: "attr value" or "attr " at end.
1370− let bracket_content = before.rsplit('[').next()?;
1371− // Split by commas to get the current segment
1372− let segment = bracket_content.rsplit(',').next()?.trim();
1373−
1374− // Check if the first word in this segment is a color-related attribute
1375− let attr = segment.split_whitespace().next()?;
1376−
1377− // Strip state prefix (e.g., "hover:background" -> "background")
1378− let base_attr = if let Some(pos) = attr.rfind(':') {
1379− &attr[pos + 1..]
1380− } else {
1381− attr
1382− };
1383−
1384− if !matches!(
1385− base_attr,
1386− "background" | "color" | "border" | "border-top" | "border-bottom"
1387− | "border-left" | "border-right" | "accent-color" | "caret-color"
1388− | "text-decoration-color" | "outline"
1389− ) {
1390− return None;
1391− }
1392−
1393− // Only show colors if we're in the value position (at least one space after the attr name)
1394− let after_attr = &segment[attr.len()..];
1395− if !after_attr.starts_with(' ') {
1396− return None;
1397− }
1398−
1399− let colors: &[(&str, &str, &str)] = &[
1400− ("white", "#ffffff", "White"),
1401− ("black", "#000000", "Black"),
1402− ("red", "#ef4444", "Red"),
1403− ("orange", "#f97316", "Orange"),
1404− ("yellow", "#eab308", "Yellow"),
1405− ("green", "#22c55e", "Green"),
1406− ("blue", "#3b82f6", "Blue"),
1407− ("indigo", "#6366f1", "Indigo"),
1408− ("purple", "#a855f7", "Purple"),
1409− ("pink", "#ec4899", "Pink"),
1410− ("gray", "#6b7280", "Gray"),
1411− ("slate", "#64748b", "Slate"),
1412− ("zinc", "#71717a", "Zinc"),
1413− ("neutral", "#737373", "Neutral"),
1414− ("stone", "#78716c", "Stone"),
1415− ("amber", "#f59e0b", "Amber"),
1416− ("lime", "#84cc16", "Lime"),
1417− ("emerald", "#10b981", "Emerald"),
1418− ("teal", "#14b8a6", "Teal"),
1419− ("cyan", "#06b6d4", "Cyan"),
1420− ("sky", "#0ea5e9", "Sky"),
1421− ("violet", "#8b5cf6", "Violet"),
1422− ("fuchsia", "#d946ef", "Fuchsia"),
1423− ("rose", "#f43f5e", "Rose"),
1424− ("transparent", "transparent", "Transparent"),
1425− ("currentColor", "currentColor", "Inherit from parent text color"),
1426− ];
1427−
1428− let items: Vec<CompletionItem> = colors.iter().map(|(label, value, detail)| {
1429− let doc = if value.starts_with('#') {
1430− format!("{} (`{}`)", detail, value)
1431− } else {
1432− detail.to_string()
1433− };
1434− CompletionItem {
1435− label: label.to_string(),
1436− kind: Some(CompletionItemKind::COLOR),
1437− detail: Some(doc),
1438− text_edit: Some(CompletionTextEdit::Edit(TextEdit {
1439− range,
1440− new_text: value.to_string(),
1441− })),
1442− documentation: if value.starts_with('#') {
1443− Some(Documentation::String(value.to_string()))
1444− } else {
1445− None
1446− },
1447− ..Default::default()
1448− }
1449− }).collect();
1450−
1451− Some(items)
1452−}
1453−
1454−fn variable_completions(text: &str, range: Range) -> Vec<CompletionItem> {
1455− let mut items = Vec::new();
1456−
1457− for line in text.lines() {
1458− let trimmed = line.trim();
1459− if let Some(rest) = trimmed.strip_prefix("@let ") {
1460− if let Some((name, value)) = rest.trim().split_once(' ') {
1461− items.push(item(
1462− &format!("${}", name),
1463− CompletionItemKind::VARIABLE,
1464− &format!("= {}", value.trim()),
1465− &format!("${}", name),
1466− range,
1467− ));
1468− }
1469− } else if let Some(rest) = trimmed.strip_prefix("@define ") {
1470− if let Some(bracket) = rest.find('[') {
1471− let name = rest[..bracket].trim();
1472− items.push(item(
1473− &format!("${}", name),
1474− CompletionItemKind::CONSTANT,
1475− "Attribute bundle",
1476− &format!("${}", name),
1477− range,
1478− ));
1479− }
1480− }
1481− }
1482−
1483− items
1484−}
1485−
1486−fn function_completions(text: &str, range: Range) -> Vec<CompletionItem> {
1487− let mut items = Vec::new();
1488−
1489− for line in text.lines() {
1490− let trimmed = line.trim();
1491− if let Some(rest) = trimmed.strip_prefix("@fn ") {
1492− let parts: Vec<&str> = rest.split_whitespace().collect();
1493− if let Some(name) = parts.first() {
1494− let params: Vec<&str> = parts[1..].iter()
1495− .map(|p| p.strip_prefix('$').unwrap_or(p))
1496− .collect();
1497− let detail = if params.is_empty() {
1498− "Function".to_string()
1499− } else {
1500− format!("Function({})", params.join(", "))
1501− };
1502− // Generate snippet with tab stops for parameters
1503− let insert_text = if params.is_empty() {
1504− format!("@{}", name)
1505− } else {
1506− let param_snippets: Vec<String> = params.iter().enumerate().map(|(i, p)| {
1507− let p_name = p.split('=').next().unwrap_or(p);
1508− let default = p.split('=').nth(1).unwrap_or(p_name);
1509− format!("{} ${{{}:{}}}", p_name, i + 1, default)
1510− }).collect();
1511− format!("@{} [{}]", name, param_snippets.join(", "))
1512− };
1513− let mut ci = CompletionItem {
1514− label: format!("@{}", name),
1515− kind: Some(CompletionItemKind::FUNCTION),
1516− detail: Some(detail),
1517− text_edit: Some(CompletionTextEdit::Edit(TextEdit {
1518− range,
1519− new_text: insert_text,
1520− })),
1521− ..Default::default()
1522− };
1523− if !params.is_empty() {
1524− ci.insert_text_format = Some(tower_lsp::lsp_types::InsertTextFormat::SNIPPET);
1525− }
1526− items.push(ci);
1527− }
1528− }
1529− }
1530−
1531− items
1532−}
1533−
1534−// ---------------------------------------------------------------------------
1535−// Hover
1536−// ---------------------------------------------------------------------------
1537−
1538−fn hover_at(text: &str, position: Position) -> Option<Hover> {
1539− let lines: Vec<&str> = text.lines().collect();
1540− let line = lines.get(position.line as usize)?;
1541− let col = (position.character as usize).min(line.len());
1542− let word = word_at(line, col)?;
1543−
1544− let doc = if word.starts_with('$') {
1545− hover_variable(text, &word[1..])
1546− } else if word.starts_with('@') {
1547− hover_user_fn(text, &word[1..]).or_else(|| hover_builtin(&word))
1548− } else {
1549− hover_builtin(&word)
1550− }?;
1551−
1552− Some(Hover {
1553− contents: HoverContents::Markup(MarkupContent {
1554− kind: MarkupKind::Markdown,
1555− value: doc,
1556− }),
1557− range: None,
1558− })
1559−}
1560−
1561−fn word_at(line: &str, col: usize) -> Option<String> {
1562− let bytes = line.as_bytes();
1563− let mut start = col;
1564− while start > 0 && is_word_byte(bytes[start - 1]) {
1565− start -= 1;
1566− }
1567− let mut end = col;
1568− while end < bytes.len() && is_word_byte(bytes[end]) {
1569− end += 1;
1570− }
1571− if start == end {
1572− return None;
1573− }
1574− Some(line[start..end].to_string())
1575−}
1576−
1577−fn is_word_byte(c: u8) -> bool {
1578− c.is_ascii_alphanumeric() || c == b'@' || c == b'$' || c == b'-' || c == b'_' || c == b':'
1579−}
1580−
1581−fn hover_variable(text: &str, name: &str) -> Option<String> {
1582− for line in text.lines() {
1583− let trimmed = line.trim();
1584− if let Some(rest) = trimmed.strip_prefix("@let ") {
1585− if let Some((n, v)) = rest.trim().split_once(' ') {
1586− if n == name {
1587− return Some(format!("**${}** = `{}`", name, v.trim()));
1588− }
1589− }
1590− }
1591− }
1592−
1593− for line in text.lines() {
1594− let trimmed = line.trim();
1595− if let Some(rest) = trimmed.strip_prefix("@define ") {
1596− let rest = rest.trim();
1597− if let Some(bracket) = rest.find('[') {
1598− if rest[..bracket].trim() == name {
1599− return Some(format!(
1600− "**${}** \u{2014} Attribute bundle\n\n`{}`",
1601− name, trimmed
1602− ));
1603− }
1604− }
1605− }
1606− }
1607−
1608− for line in text.lines() {
1609− let trimmed = line.trim();
1610− if let Some(rest) = trimmed.strip_prefix("@fn ") {
1611− let parts: Vec<&str> = rest.split_whitespace().collect();
1612− if let Some(fn_name) = parts.first() {
1613− for param in &parts[1..] {
1614− let p = param.strip_prefix('$').unwrap_or(param);
1615− if p == name {
1616− return Some(format!(
1617− "**${}** \u{2014} Parameter of `@{}`",
1618− name, fn_name
1619− ));
1620− }
1621− }
1622− }
1623− }
1624− }
1625−
1626− None
1627−}
1628−
1629−fn hover_user_fn(text: &str, name: &str) -> Option<String> {
1630− let lines: Vec<&str> = text.lines().collect();
1631− for (i, line) in lines.iter().enumerate() {
1632− let trimmed = line.trim();
1633− if let Some(rest) = trimmed.strip_prefix("@fn ") {
1634− let parts: Vec<&str> = rest.split_whitespace().collect();
1635− if parts.first() == Some(&name) {
1636− let params = &parts[1..];
1637−
1638− // Collect doc-comment lines above the @fn (lines starting with --)
1639− let mut doc_lines: Vec<&str> = Vec::new();
1640− let mut j = i;
1641− while j > 0 {
1642− j -= 1;
1643− let prev = lines[j].trim();
1644− if let Some(comment) = prev.strip_prefix("-- ") {
1645− doc_lines.push(comment);
1646− } else if prev.starts_with("--") {
1647− doc_lines.push(&prev[2..]);
1648− } else {
1649− break;
1650− }
1651− }
1652− doc_lines.reverse();
1653−
1654− let doc_str = if doc_lines.is_empty() {
1655− String::new()
1656− } else {
1657− format!("\n\n{}", doc_lines.join("\n"))
1658− };
1659−
1660− // Format params showing defaults
1661− let params_str = if params.is_empty() {
1662− String::new()
1663− } else {
1664− let formatted: Vec<String> = params.iter().map(|p| {
1665− if p.contains('=') {
1666− let (name, default) = p.split_once('=').unwrap();
1667− format!("{} (default: {})", name, default)
1668− } else {
1669− p.to_string()
1670− }
1671− }).collect();
1672− format!("\n\nParameters: {}", formatted.join(", "))
1673− };
1674−
1675− return Some(format!(
1676− "**@{}** \u{2014} User function{}{}",
1677− name, params_str, doc_str
1678− ));
1679− }
1680− }
1681− }
1682− None
1683−}
1684−
1685−fn hover_builtin(word: &str) -> Option<String> {
1686− // Strip state prefix for attribute lookup
1687− let (state, base) = if let Some(rest) = word.strip_prefix("hover:") {
1688− (Some("hover"), rest)
1689− } else if let Some(rest) = word.strip_prefix("active:") {
1690− (Some("active"), rest)
1691− } else if let Some(rest) = word.strip_prefix("focus:") {
1692− (Some("focus"), rest)
1693− } else if let Some(rest) = word.strip_prefix("focus-visible:") {
1694− (Some("focus-visible"), rest)
1695− } else if let Some(rest) = word.strip_prefix("focus-within:") {
1696− (Some("focus-within"), rest)
1697− } else if let Some(rest) = word.strip_prefix("disabled:") {
1698− (Some("disabled"), rest)
1699− } else if let Some(rest) = word.strip_prefix("checked:") {
1700− (Some("checked"), rest)
1701− } else if let Some(rest) = word.strip_prefix("placeholder:") {
1702− (Some("placeholder"), rest)
1703− } else if let Some(rest) = word.strip_prefix("first:") {
1704− (Some("first"), rest)
1705− } else if let Some(rest) = word.strip_prefix("last:") {
1706− (Some("last"), rest)
1707− } else if let Some(rest) = word.strip_prefix("odd:") {
1708− (Some("odd"), rest)
1709− } else if let Some(rest) = word.strip_prefix("even:") {
1710− (Some("even"), rest)
1711− } else if let Some(rest) = word.strip_prefix("before:") {
1712− (Some("before"), rest)
1713− } else if let Some(rest) = word.strip_prefix("after:") {
1714− (Some("after"), rest)
1715− } else if let Some(rest) = word.strip_prefix("sm:") {
1716− (Some("sm"), rest)
1717− } else if let Some(rest) = word.strip_prefix("md:") {
1718− (Some("md"), rest)
1719− } else if let Some(rest) = word.strip_prefix("lg:") {
1720− (Some("lg"), rest)
1721− } else if let Some(rest) = word.strip_prefix("xl:") {
1722− (Some("xl"), rest)
1723− } else if let Some(rest) = word.strip_prefix("2xl:") {
1724− (Some("2xl"), rest)
1725− } else if let Some(rest) = word.strip_prefix("motion-safe:") {
1726− (Some("motion-safe"), rest)
1727− } else if let Some(rest) = word.strip_prefix("motion-reduce:") {
1728− (Some("motion-reduce"), rest)
1729− } else if let Some(rest) = word.strip_prefix("landscape:") {
1730− (Some("landscape"), rest)
1731− } else if let Some(rest) = word.strip_prefix("portrait:") {
1732− (Some("portrait"), rest)
1733− } else if let Some(rest) = word.strip_prefix("visited:") {
1734− (Some("visited"), rest)
1735− } else if let Some(rest) = word.strip_prefix("empty:") {
1736− (Some("empty"), rest)
1737− } else if let Some(rest) = word.strip_prefix("target:") {
1738− (Some("target"), rest)
1739− } else if let Some(rest) = word.strip_prefix("valid:") {
1740− (Some("valid"), rest)
1741− } else if let Some(rest) = word.strip_prefix("invalid:") {
1742− (Some("invalid"), rest)
1743− } else {
1744− (None, word)
1745− };
1746−
1747− let doc = match base {
1748− // Elements
1749− "@row" => "**@row** \u{2014} Horizontal layout\n\nRenders as `<div>` with `display: flex; flex-direction: row`.\n\nChildren are laid out left-to-right.",
1750− "@column" | "@col" => "**@column** \u{2014} Vertical layout\n\nRenders as `<div>` with `display: flex; flex-direction: column`.\n\nChildren are laid out top-to-bottom.",
1751− "@el" => "**@el** \u{2014} Generic container\n\nRenders as `<div>` with column flex layout.",
1752− "@text" => "**@text** \u{2014} Inline text\n\nRenders as `<span>`.\n\nUsage: `@text [bold, size 24] Hello world`",
1753− "@paragraph" | "@p" => "**@paragraph** \u{2014} Text block\n\nRenders as `<p>`.\n\nSupports inline elements: `{@text [bold] word}`",
1754− "@image" | "@img" => "**@image** \u{2014} Image\n\nRenders as `<img>`.\n\nUsage: `@image [width 200] https://example.com/photo.jpg`",
1755− "@link" => "**@link** \u{2014} Hyperlink\n\nRenders as `<a>`.\n\nUsage: `@link [color blue] https://example.com Link text`",
1756− "@raw" => "**@raw** \u{2014} Raw HTML\n\nPasses content through without processing.\n\nUsage: `@raw \"\"\"<div>custom html</div>\"\"\"`",
1757− "@page" => "**@page** \u{2014} Page title\n\nSets the HTML `<title>` and wraps output in a full document.\n\nUsage: `@page My Page Title`",
1758− "@let" => "**@let** \u{2014} Variable\n\nDefines a variable for `$name` substitution.\n\nUsage: `@let primary #3b82f6`",
1759− "@define" => "**@define** \u{2014} Attribute bundle\n\nDefines a reusable set of attributes.\n\nUsage: `@define card-style [padding 20, rounded 8]`\n\nApply with `$card-style` in attribute lists.",
1760− "@fn" => "**@fn** \u{2014} Function\n\nDefines a reusable component.\n\n```\n@fn card $title\n @el [padding 20]\n @text [bold] $title\n @children\n```",
1761− "@keyframes" => "**@keyframes** \u{2014} CSS Animation\n\nDefines keyframes for CSS animations.\n\n```\n@keyframes fade-in\n from{opacity:0}to{opacity:1}\n```\n\nUse with `animation` attribute: `[animation fade-in 0.3s ease]`",
1762− "@children" => "**@children** \u{2014} Children slot\n\nPlaceholder inside `@fn` body replaced with the caller's children.",
1763− "@input" => "**@input** \u{2014} Form input\n\nRenders as self-closing `<input>`.\n\nUsage: `@input [type text, placeholder Name, name user]`",
1764− "@button" | "@btn" => "**@button** \u{2014} Button\n\nRenders as `<button>`.\n\nUsage: `@button [type submit] Click me`",
1765− "@select" => "**@select** \u{2014} Select dropdown\n\nRenders as `<select>`. Use `@option` children.\n\nUsage: `@select [name color]`",
1766− "@textarea" => "**@textarea** \u{2014} Multi-line text input\n\nRenders as `<textarea>`.\n\nUsage: `@textarea [name bio, rows 4] Default text`",
1767− "@option" | "@opt" => "**@option** \u{2014} Select option\n\nRenders as `<option>`.\n\nUsage: `@option [value red] Red`",
1768− "@label" => "**@label** \u{2014} Form label\n\nRenders as `<label>`.\n\nUsage: `@label [for email] Email Address`",
1769− "@if" => "**@if** \u{2014} Conditional\n\nConditionally includes children at compile time.\n\n```\n@if $theme == dark\n @el [background #333]\n@else if $theme == light\n @el [background white]\n@else\n @el [background gray]\n```",
1770− "@each" => "**@each** \u{2014} Loop\n\nRepeat children for each item in a comma-separated list.\nOptional index variable.\n\n```\n@each $color, $i in red,green,blue\n @text $i: $color\n```",
1771− "@include" => "**@include** \u{2014} Include file\n\nIncludes another .hl file (DOM nodes + definitions).\n\nUsage: `@include header.hl`",
1772− "@import" => "**@import** \u{2014} Import definitions\n\nImports `@let`, `@define`, `@fn` from another .hl file without emitting DOM nodes.\n\nUsage: `@import theme.hl`",
1773− "@meta" => "**@meta** \u{2014} Meta tag\n\nAdds a `<meta>` tag to `<head>`.\n\nUsage: `@meta description A portfolio site`",
1774− "@head" => "**@head** \u{2014} Head content\n\nAdds raw content to `<head>` (fonts, icons, etc.).\n\n```\n@head\n <link rel=\"icon\" href=\"favicon.ico\">\n```",
1775− "@style" => "**@style** \u{2014} Custom CSS\n\nAdds raw CSS to the stylesheet.\n\n```\n@style\n .custom { border: 1px solid red; }\n @container sidebar (min-width: 400px) { ... }\n```",
1776− "@slot" => "**@slot** \u{2014} Named slot\n\nDefines a named insertion point inside `@fn`. Callers fill it with `@slot name` + children.\n\n```\n@fn layout\n @slot header\n @children\n @slot footer\n```",
1777− // Semantic elements
1778− "@nav" => "**@nav** \u{2014} Navigation\n\nRenders as `<nav>`. Semantic landmark for navigation links.",
1779− "@header" => "**@header** \u{2014} Header\n\nRenders as `<header>`. Page or section header.",
1780− "@footer" => "**@footer** \u{2014} Footer\n\nRenders as `<footer>`. Page or section footer.",
1781− "@main" => "**@main** \u{2014} Main content\n\nRenders as `<main>`. Primary content of the page.",
1782− "@section" => "**@section** \u{2014} Section\n\nRenders as `<section>`. Thematic grouping of content.",
1783− "@article" => "**@article** \u{2014} Article\n\nRenders as `<article>`. Self-contained, independently distributable content.",
1784− "@aside" => "**@aside** \u{2014} Aside\n\nRenders as `<aside>`. Content tangentially related to surrounding content.",
1785− // List elements
1786− "@list" => "**@list** \u{2014} List\n\nRenders as `<ul>` (or `<ol>` with `[ordered]`).\n\nUsage:\n```\n@list [ordered]\n @item First\n @item Second\n```",
1787− "@item" | "@li" => "**@item** \u{2014} List item\n\nRenders as `<li>`. Use inside `@list`.",
1788− // Table elements
1789− "@table" => "**@table** \u{2014} Table\n\nRenders as `<table>`.\n\n```\n@table\n @thead\n @tr\n @th Name\n @th Age\n @tbody\n @tr\n @td Alice\n @td 30\n```",
1790− "@thead" => "**@thead** \u{2014} Table head\n\nRenders as `<thead>`. Groups header rows.",
1791− "@tbody" => "**@tbody** \u{2014} Table body\n\nRenders as `<tbody>`. Groups body rows.",
1792− "@tr" => "**@tr** \u{2014} Table row\n\nRenders as `<tr>`.",
1793− "@td" => "**@td** \u{2014} Table cell\n\nRenders as `<td>`. Regular table data cell.",
1794− "@th" => "**@th** \u{2014} Table header cell\n\nRenders as `<th>`. Header cell (typically bold/centered).",
1795− // Media elements
1796− "@video" => "**@video** \u{2014} Video\n\nRenders as `<video>`.\n\nUsage: `@video [controls] demo.mp4`",
1797− "@audio" => "**@audio** \u{2014} Audio\n\nRenders as `<audio>`.\n\nUsage: `@audio [controls] song.mp3`",
1798− // Additional elements
1799− "@form" => "**@form** \u{2014} Form\n\nRenders as `<form>`. Container for form elements.\n\nUsage: `@form [method post] /submit`",
1800− "@details" => "**@details** \u{2014} Disclosure\n\nRenders as `<details>`. Use `[open]` for initially expanded.\n\nContains `@summary` for the toggle label.",
1801− "@summary" => "**@summary** \u{2014} Summary\n\nRenders as `<summary>`. Toggle label inside `@details`.\n\nUsage: `@summary Click to expand`",
1802− "@blockquote" => "**@blockquote** \u{2014} Block quotation\n\nRenders as `<blockquote>`. Semantic quotation container.",
1803− "@cite" => "**@cite** \u{2014} Citation\n\nRenders as `<cite>`. Source or reference for a quotation.\n\nUsage: `@cite The Great Gatsby`",
1804− "@code" => "**@code** \u{2014} Code\n\nRenders as `<code>` with monospace font.\n\nUsage: `@code console.log(\"hello\")`",
1805− "@pre" => "**@pre** \u{2014} Preformatted\n\nRenders as `<pre>` with preserved whitespace and monospace font.",
1806− "@hr" | "@divider" => "**@hr** \u{2014} Horizontal Rule\n\nRenders as self-closing `<hr>`. Visual divider.\n\nUsage: `@hr [border-top 1 #ccc]`",
1807− "@figure" => "**@figure** \u{2014} Figure\n\nRenders as `<figure>`. Container for media with optional `@figcaption`.\n\n```\n@figure\n @image photo.jpg\n @figcaption A nice photo\n```",
1808− "@figcaption" => "**@figcaption** \u{2014} Figure caption\n\nRenders as `<figcaption>`. Caption text inside `@figure`.",
1809− "@progress" => "**@progress** \u{2014} Progress bar\n\nRenders as `<progress>`.\n\nUsage: `@progress [value 70, max 100]`",
1810− "@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]`",
1811− "@fragment" => "**@fragment** \u{2014} Fragment\n\nGroups children without emitting a wrapper element. Renders children directly in the parent.",
1812− // New elements
1813− "@dialog" => "**@dialog** \u{2014} Dialog\n\nRenders as `<dialog>`. Modal or non-modal dialog box.\n\nUsage: `@dialog [open] Dialog content`",
1814− "@dl" => "**@dl** \u{2014} Description list\n\nRenders as `<dl>`. Contains `@dt` and `@dd` pairs.",
1815− "@dt" => "**@dt** \u{2014} Description term\n\nRenders as `<dt>`. Term in a `@dl` description list.",
1816− "@dd" => "**@dd** \u{2014} Description details\n\nRenders as `<dd>`. Details for a `@dt` term.",
1817− "@fieldset" => "**@fieldset** \u{2014} Fieldset\n\nRenders as `<fieldset>`. Groups related form elements.\n\nUse `@legend` for a caption.",
1818− "@legend" => "**@legend** \u{2014} Legend\n\nRenders as `<legend>`. Caption for a `@fieldset`.",
1819− "@picture" => "**@picture** \u{2014} Picture\n\nRenders as `<picture>`. Container for responsive image sources.\n\nUse `@source` children for different media queries.",
1820− "@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]`",
1821− "@time" => "**@time** \u{2014} Time\n\nRenders as `<time>`. Machine-readable date/time.\n\nUsage: `@time [datetime 2024-01-15] January 15`",
1822− "@mark" => "**@mark** \u{2014} Mark\n\nRenders as `<mark>`. Highlighted or marked text.",
1823− "@kbd" => "**@kbd** \u{2014} Keyboard input\n\nRenders as `<kbd>`. Represents keyboard input.\n\nUsage: `@kbd Ctrl+C`",
1824− "@abbr" => "**@abbr** \u{2014} Abbreviation\n\nRenders as `<abbr>`. Abbreviation with optional title.\n\nUsage: `@abbr [title Hypertext Markup Language] HTML`",
1825− "@datalist" => "**@datalist** \u{2014} Datalist\n\nRenders as `<datalist>`. Provides predefined options for `@input`.\n\nUsage: `@datalist [id colors]`",
1826− // Directives
1827− "@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```",
1828− "@case" => "**@case** \u{2014} Match case\n\nA case inside `@match`. Matches when the value equals the case value.",
1829− "@default" => "**@default** \u{2014} Default case\n\nFallback case inside `@match` when no other case matches.",
1830− "@warn" => "**@warn** \u{2014} Compile warning\n\nEmit a custom warning during compilation.\n\nUsage: `@warn This value is deprecated`",
1831− "@debug" => "**@debug** \u{2014} Debug message\n\nPrint a debug message to stderr during compilation.\n\nUsage: `@debug Theme is $theme`",
1832− "@lang" => "**@lang** \u{2014} Document language\n\nSets the `lang` attribute on the `<html>` element.\n\nUsage: `@lang en`",
1833− "@favicon" => "**@favicon** \u{2014} Favicon\n\nInlines a favicon as a base64 data URI in the `<head>`.\n\nUsage: `@favicon favicon.png`",
1834− "@unless" => "**@unless** \u{2014} Inverse conditional\n\nRenders children when the condition is false (opposite of `@if`).\n\nUsage: `@unless $debug`",
1835− "@og" => "**@og** \u{2014} Open Graph meta tag\n\nAdds an Open Graph `<meta>` tag to `<head>`.\n\nUsage: `@og title My Page Title`",
1836− "@breakpoint" => "**@breakpoint** \u{2014} Custom breakpoint\n\nDefines a custom responsive breakpoint.\n\nUsage: `@breakpoint tablet 600`",
1837− "@theme" => "**@theme** \u{2014} Design tokens\n\nDefines centralized design tokens (colors, spacing, fonts).\nEach token becomes both a `$variable` and a `--css-custom-property`.\n\n```\n@theme\n primary #3b82f6\n spacing-md 16\n font-body system-ui, sans-serif\n```",
1838− "@deprecated" => "**@deprecated** `<message>`\n\nMarks the next `@fn` as deprecated. Callers get a compile-time warning.\n\n```\n@deprecated Use @new-card instead\n@fn old-card $title\n ...\n```",
1839− "@extends" => "**@extends** `<file.hl>`\n\nInherit a layout template. Fill named `@slot` blocks.\n\n```\n@extends layout.hl\n@slot content\n My page content\n@slot sidebar\n Sidebar content\n```",
1840− "@use" => "**@use** `<file.hl> name1, name2`\n\nSelective import: only imports named `@fn`/`@define` definitions.\n\n```\n@use components.hl card, button\n```",
1841− "@canonical" => "**@canonical** `<url>`\n\nSets the canonical URL for the page. Adds `<link rel=\"canonical\">` to `<head>`.\n\nUsage: `@canonical https://example.com/page`",
1842− "@base" => "**@base** `<url>`\n\nSets the base URL for all relative URLs in the document. Adds `<base>` to `<head>`.\n\nUsage: `@base https://example.com/`",
1843− "@font-face" => "**@font-face** \u{2014} Custom font\n\nDefines a custom font face. Generates a CSS `@font-face` rule.\n\n```\n@font-face\n family Inter\n src url(/fonts/Inter.woff2)\n weight 400 700\n```",
1844− "@json-ld" => "**@json-ld** \u{2014} Structured data\n\nAdds JSON-LD structured data to `<head>` as `<script type=\"application/ld+json\">`.\n\n```\n@json-ld\n type Organization\n name My Company\n url https://example.com\n```",
1845− // Attributes
1846− "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
1847− "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding. Supports CSS units. Accepts 1\u{2013}4 values.",
1848− "padding-x" => "**padding-x** `<value>`\n\nHorizontal padding (left + right). Supports CSS units.",
1849− "padding-y" => "**padding-y** `<value>`\n\nVertical padding (top + bottom). Supports CSS units.",
1850− "width" => "**width** `<value>` | `fill` | `shrink`\n\n- Number/unit: fixed width (e.g., `300`, `50%`, `80ch`)\n- `fill`: expand to fill parent\n- `shrink`: prevent flex shrinking",
1851− "height" => "**height** `<value>` | `fill` | `shrink`\n\n- Number/unit: fixed height (e.g., `300`, `100vh`)\n- `fill`: expand to fill parent\n- `shrink`: prevent flex shrinking",
1852− "min-width" => "**min-width** `<value>` \u{2014} Minimum width. Supports CSS units.",
1853− "max-width" => "**max-width** `<value>` \u{2014} Maximum width. Supports CSS units.",
1854− "min-height" => "**min-height** `<value>` \u{2014} Minimum height. Supports CSS units.",
1855− "max-height" => "**max-height** `<value>` \u{2014} Maximum height. Supports CSS units.",
1856− "center-x" => "**center-x**\n\nCenter horizontally.\n\nIn column parent: `align-self: center`\nOtherwise: auto margins.",
1857− "center-y" => "**center-y**\n\nCenter vertically.\n\nIn row parent: `align-self: center`\nOtherwise: auto margins.",
1858− "align-left" => "**align-left** \u{2014} Align to the left edge.",
1859− "align-right" => "**align-right** \u{2014} Align to the right edge.",
1860− "align-top" => "**align-top** \u{2014} Align to the top edge.",
1861− "align-bottom" => "**align-bottom** \u{2014} Align to the bottom edge.",
1862− "background" => "**background** `<color>` \u{2014} Background color or CSS background value.",
1863− "color" => "**color** `<color>` \u{2014} Text color.",
1864− "border" => "**border** `<width> [color]`\n\nBorder. Width in pixels, color defaults to `currentColor`.",
1865− "border-top" => "**border-top** `<width> [color]` \u{2014} Top border.",
1866− "border-bottom" => "**border-bottom** `<width> [color]` \u{2014} Bottom border.",
1867− "border-left" => "**border-left** `<width> [color]` \u{2014} Left border.",
1868− "border-right" => "**border-right** `<width> [color]` \u{2014} Right border.",
1869− "rounded" => "**rounded** `<value>` \u{2014} Border radius. Supports CSS units.",
1870− "bold" => "**bold** \u{2014} Bold text (`font-weight: bold`).",
1871− "italic" => "**italic** \u{2014} Italic text (`font-style: italic`).",
1872− "underline" => "**underline** \u{2014} Underlined text.",
1873− "size" => "**size** `<value>` \u{2014} Font size in pixels.",
1874− "font" => "**font** `<family>` \u{2014} Font family.",
1875− "transition" => "**transition** `<value>` \u{2014} CSS transition (e.g., `all 0.15s ease`).",
1876− "cursor" => "**cursor** `<value>` \u{2014} CSS cursor (e.g., `pointer`).",
1877− "opacity" => "**opacity** `<value>` \u{2014} Opacity from 0 to 1.",
1878− "text-align" => "**text-align** `<value>` \u{2014} Text alignment (`left`, `center`, `right`, `justify`).",
1879− "line-height" => "**line-height** `<value>` \u{2014} Line height. Unitless (e.g., `1.5`) or pixels.",
1880− "overflow" => "**overflow** `<value>` \u{2014} Overflow behavior (`hidden`, `scroll`, `auto`, `visible`).",
1881− "position" => "**position** `<value>` \u{2014} Position type (`relative`, `absolute`, `fixed`, `sticky`).",
1882− "top" => "**top** `<value>` \u{2014} Top offset for positioned elements.",
1883− "right" => "**right** `<value>` \u{2014} Right offset for positioned elements.",
1884− "bottom" => "**bottom** `<value>` \u{2014} Bottom offset for positioned elements.",
1885− "left" => "**left** `<value>` \u{2014} Left offset for positioned elements.",
1886− "z-index" => "**z-index** `<value>` \u{2014} Stack order (integer).",
1887− "display" => "**display** `<value>` \u{2014} Display mode (`none`, `block`, `inline`, `flex`, `grid`).",
1888− "visibility" => "**visibility** `<value>` \u{2014} Visibility (`visible`, `hidden`).",
1889− "transform" => "**transform** `<value>` \u{2014} CSS transform (e.g., `rotate(45deg)`, `scale(1.5)`).",
1890− "backdrop-filter" => "**backdrop-filter** `<value>` \u{2014} Backdrop filter (e.g., `blur(10px)`).",
1891− "letter-spacing" => "**letter-spacing** `<value>` \u{2014} Letter spacing. Supports CSS units.",
1892− "text-transform" => "**text-transform** `<value>` \u{2014} Text transform (`uppercase`, `lowercase`, `capitalize`).",
1893− "white-space" => "**white-space** `<value>` \u{2014} White-space behavior (`nowrap`, `pre`, `normal`).",
1894− "grid" => "**grid** \u{2014} Enable CSS grid layout on this element.",
1895− "grid-cols" => "**grid-cols** `<value>` \u{2014} Grid template columns. Number for equal columns, or CSS value.",
1896− "grid-rows" => "**grid-rows** `<value>` \u{2014} Grid template rows. Number for equal rows, or CSS value.",
1897− "col-span" => "**col-span** `<value>` \u{2014} Span N columns in a grid.",
1898− "row-span" => "**row-span** `<value>` \u{2014} Span N rows in a grid.",
1899− "shadow" => "**shadow** `<value>` \u{2014} Box shadow. Raw CSS value (e.g., `0 2px 4px rgba(0,0,0,0.1)`).",
1900− "gap-x" => "**gap-x** `<value>` \u{2014} Horizontal gap between children in pixels. Maps to `column-gap`.",
1901− "gap-y" => "**gap-y** `<value>` \u{2014} Vertical gap between children in pixels. Maps to `row-gap`.",
1902− "wrap" => "**wrap** \u{2014} Enable flex-wrap for children.",
1903− "id" => "**id** `<value>` \u{2014} HTML id attribute.",
1904− "class" => "**class** `<value>` \u{2014} HTML class attribute.",
1905− "animation" => "**animation** `<value>` \u{2014} CSS animation shorthand (e.g., `fade-in 0.3s ease`).\n\nDefine animations with `@keyframes`.",
1906− "container" => "**container** \u{2014} Enable container queries (`container-type: inline-size`).",
1907− "container-name" => "**container-name** `<value>` \u{2014} Name this container for `@container` queries.",
1908− "container-type" => "**container-type** `<value>` \u{2014} Container type (`inline-size`, `size`, `normal`).",
1909− // Form attributes
1910− "type" => "**type** `<value>` \u{2014} Input type (`text`, `email`, `password`, `submit`, etc.).",
1911− "placeholder" => "**placeholder** `<value>` \u{2014} Placeholder text for inputs.",
1912− "name" => "**name** `<value>` \u{2014} Form field name.",
1913− "value" => "**value** `<value>` \u{2014} Form field value.",
1914− "disabled" => "**disabled** \u{2014} Disable the element.",
1915− "required" => "**required** \u{2014} Mark field as required.",
1916− "checked" => "**checked** \u{2014} Checkbox/radio checked state.",
1917− "for" => "**for** `<id>` \u{2014} Label target (id of the associated input).",
1918− "rows" => "**rows** `<value>` \u{2014} Number of visible rows for textarea.",
1919− "cols" => "**cols** `<value>` \u{2014} Number of visible columns for textarea.",
1920− "maxlength" => "**maxlength** `<value>` \u{2014} Maximum input length.",
1921− // Accessibility
1922− "alt" => "**alt** `<value>` \u{2014} Alternative text for images.",
1923− "role" => "**role** `<value>` \u{2014} ARIA role (e.g., `navigation`, `banner`, `main`).",
1924− "tabindex" => "**tabindex** `<value>` \u{2014} Tab order. `0` = natural order, `-1` = skip.",
1925− "title" => "**title** `<value>` \u{2014} Tooltip text.",
1926− // New CSS attributes
1927− "aspect-ratio" => "**aspect-ratio** `<value>` \u{2014} CSS aspect ratio (e.g., `16/9`, `1`, `4/3`).",
1928− "outline" => "**outline** `<width> [color]` \u{2014} Outline (like border but doesn't affect layout).",
1929− "padding-inline" => "**padding-inline** `<value>` \u{2014} Horizontal padding (logical property, i18n-aware).",
1930− "padding-block" => "**padding-block** `<value>` \u{2014} Vertical padding (logical property, i18n-aware).",
1931− "margin-inline" => "**margin-inline** `<value>` \u{2014} Horizontal margin (logical property, i18n-aware).",
1932− "margin-block" => "**margin-block** `<value>` \u{2014} Vertical margin (logical property, i18n-aware).",
1933− "scroll-snap-type" => "**scroll-snap-type** `<value>` \u{2014} Scroll snap type (`x mandatory`, `y proximity`).",
1934− "scroll-snap-align" => "**scroll-snap-align** `<value>` \u{2014} Scroll snap alignment (`start`, `center`, `end`).",
1935− // Media/image attributes
1936− "loading" => "**loading** `<value>` \u{2014} Loading behavior for images (`lazy`, `eager`).",
1937− "decoding" => "**decoding** `<value>` \u{2014} Image decoding mode (`async`, `sync`, `auto`).",
1938− "controls" => "**controls** \u{2014} Show media controls (for @video, @audio).",
1939− "autoplay" => "**autoplay** \u{2014} Auto-play media.",
1940− "loop" => "**loop** \u{2014} Loop media playback.",
1941− "muted" => "**muted** \u{2014} Mute media.",
1942− "poster" => "**poster** `<url>` \u{2014} Poster image for video.",
1943− "preload" => "**preload** `<value>` \u{2014} Media preload hint (`auto`, `metadata`, `none`).",
1944− "ordered" => "**ordered** \u{2014} Use ordered list (`<ol>` instead of `<ul>`).",
1945− "src" => "**src** `<url>` \u{2014} Source URL for media elements.",
1946− // New CSS attributes
1947− "margin" => "**margin** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nOuter margin. Supports CSS units. Accepts 1\u{2013}4 values.",
1948− "margin-x" => "**margin-x** `<value>` \u{2014} Horizontal margin (left + right).",
1949− "margin-y" => "**margin-y** `<value>` \u{2014} Vertical margin (top + bottom).",
1950− "filter" => "**filter** `<value>` \u{2014} CSS filter (e.g., `blur(5px)`, `brightness(1.2)`, `grayscale(1)`).",
1951− "object-fit" => "**object-fit** `<value>` \u{2014} How content fits its container (`cover`, `contain`, `fill`, `none`, `scale-down`).",
1952− "object-position" => "**object-position** `<value>` \u{2014} Position of content within container (e.g., `center`, `top left`).",
1953− "text-shadow" => "**text-shadow** `<value>` \u{2014} Text shadow. Raw CSS value (e.g., `1px 1px 2px rgba(0,0,0,0.3)`).",
1954− "text-overflow" => "**text-overflow** `<value>` \u{2014} Text overflow behavior (`ellipsis`, `clip`). Combine with `white-space nowrap` and `overflow hidden`.",
1955− "pointer-events" => "**pointer-events** `<value>` \u{2014} Pointer event behavior (`none`, `auto`).",
1956− "user-select" => "**user-select** `<value>` \u{2014} Text selection behavior (`none`, `text`, `all`, `auto`).",
1957− "justify-content" => "**justify-content** `<value>` \u{2014} Main axis alignment (`center`, `space-between`, `space-around`, `flex-start`, `flex-end`).",
1958− "align-items" => "**align-items** `<value>` \u{2014} Cross axis alignment (`center`, `flex-start`, `flex-end`, `stretch`, `baseline`).",
1959− "order" => "**order** `<value>` \u{2014} Flex/grid item order (integer).",
1960− "background-size" => "**background-size** `<value>` \u{2014} Background size (`cover`, `contain`, `auto`, or dimensions).",
1961− "background-position" => "**background-position** `<value>` \u{2014} Background position (`center`, `top`, `bottom left`, etc.).",
1962− "background-repeat" => "**background-repeat** `<value>` \u{2014} Background repeat (`no-repeat`, `repeat`, `repeat-x`, `repeat-y`).",
1963− "word-break" => "**word-break** `<value>` \u{2014} Word breaking behavior (`break-all`, `keep-all`, `normal`).",
1964− "overflow-wrap" => "**overflow-wrap** `<value>` \u{2014} Overflow wrapping (`break-word`, `anywhere`, `normal`).",
1965− // New element attributes
1966− "open" => "**open** \u{2014} Initially expand `@details` element.",
1967− "novalidate" => "**novalidate** \u{2014} Disable form validation.",
1968− "low" => "**low** `<value>` \u{2014} Low threshold for `@meter`.",
1969− "high" => "**high** `<value>` \u{2014} High threshold for `@meter`.",
1970− "optimum" => "**optimum** `<value>` \u{2014} Optimum value for `@meter`.",
1971− "colspan" => "**colspan** `<value>` \u{2014} Number of columns a cell spans.",
1972− "rowspan" => "**rowspan** `<value>` \u{2014} Number of rows a cell spans.",
1973− "scope" => "**scope** `<value>` \u{2014} Header scope (`col`, `row`, `colgroup`, `rowgroup`).",
1974− "inline" => "**inline** \u{2014} Inline SVG image content into the HTML output.",
1975− "color-scheme" => "**color-scheme** `<value>` \u{2014} Color scheme preference (`light`, `dark`, `light dark`).",
1976− "appearance" => "**appearance** `<value>` \u{2014} Form element appearance (`none` to remove native styling).",
1977− "popover" => "**popover** \u{2014} Make element a popover (HTML Popover API). Shown/hidden declaratively.",
1978− "popovertarget" => "**popovertarget** `<id>` \u{2014} ID of the popover element to toggle.",
1979− "popovertargetaction" => "**popovertargetaction** `<value>` \u{2014} Popover action (`toggle`, `show`, `hide`).",
1980− "inputmode" => "**inputmode** `<value>` \u{2014} Virtual keyboard type (`numeric`, `email`, `search`, `tel`, `url`).",
1981− "enterkeyhint" => "**enterkeyhint** `<value>` \u{2014} Enter key label (`done`, `go`, `next`, `search`, `send`).",
1982− "fetchpriority" => "**fetchpriority** `<value>` \u{2014} Resource fetch priority (`high`, `low`, `auto`).",
1983− "translate" => "**translate** `<value>` \u{2014} Whether element should be translated (`yes`, `no`).",
1984− "spellcheck" => "**spellcheck** `<value>` \u{2014} Spell check mode (`true`, `false`).",
1985− "hidden" => "**hidden** \u{2014} Hide element (`display: none`).",
1986− "overflow-x" => "**overflow-x** `<value>` \u{2014} Horizontal overflow (`hidden`, `scroll`, `auto`, `visible`).",
1987− "overflow-y" => "**overflow-y** `<value>` \u{2014} Vertical overflow (`hidden`, `scroll`, `auto`, `visible`).",
1988− "inset" => "**inset** `<value>` \u{2014} Shorthand for `top`, `right`, `bottom`, `left`. Maps to CSS `inset`.",
1989− "accent-color" => "**accent-color** `<color>` \u{2014} Accent color for form controls (checkboxes, radios, range).",
1990− "caret-color" => "**caret-color** `<color>` \u{2014} Color of the text input cursor.",
1991− "list-style" => "**list-style** `<value>` \u{2014} List style type (`disc`, `circle`, `square`, `decimal`, `none`).",
1992− "border-collapse" => "**border-collapse** `<value>` \u{2014} Table border model (`collapse`, `separate`).",
1993− "border-spacing" => "**border-spacing** `<value>` \u{2014} Spacing between table cell borders (when `border-collapse: separate`).",
1994− "text-decoration" => "**text-decoration** `<value>` \u{2014} Text decoration (`underline`, `overline`, `line-through`, `none`).",
1995− "text-decoration-color" => "**text-decoration-color** `<color>` \u{2014} Color of text decoration.",
1996− "text-decoration-thickness" => "**text-decoration-thickness** `<value>` \u{2014} Thickness of text decoration.",
1997− "text-decoration-style" => "**text-decoration-style** `<value>` \u{2014} Style of text decoration (`solid`, `dashed`, `dotted`, `wavy`, `double`).",
1998− "place-items" => "**place-items** `<value>` \u{2014} Shorthand for `align-items` and `justify-items`.",
1999− "place-self" => "**place-self** `<value>` \u{2014} Shorthand for `align-self` and `justify-self`.",
2000− "scroll-behavior" => "**scroll-behavior** `<value>` \u{2014} Scroll behavior (`smooth`, `auto`).",
2001− "resize" => "**resize** `<value>` \u{2014} Resize behavior (`none`, `both`, `horizontal`, `vertical`).",
2002− // New CSS attributes
2003− "clip-path" => "**clip-path** `<value>` \u{2014} Clip path (`circle()`, `polygon()`, `inset()`, `url()`).",
2004− "mix-blend-mode" => "**mix-blend-mode** `<value>` \u{2014} Blend mode (`multiply`, `screen`, `overlay`, `darken`, `lighten`).",
2005− "background-blend-mode" => "**background-blend-mode** `<value>` \u{2014} Background blend mode for layered backgrounds.",
2006− "writing-mode" => "**writing-mode** `<value>` \u{2014} Writing direction (`horizontal-tb`, `vertical-rl`, `vertical-lr`).",
2007− "column-count" => "**column-count** `<value>` \u{2014} Number of columns in multi-column layout.",
2008− "column-gap" => "**column-gap** `<value>` \u{2014} Gap between columns. Supports CSS units.",
2009− "text-indent" => "**text-indent** `<value>` \u{2014} Indentation of the first line of text.",
2010− "hyphens" => "**hyphens** `<value>` \u{2014} Hyphenation behavior (`none`, `manual`, `auto`).",
2011− "flex-grow" => "**flex-grow** `<value>` \u{2014} Flex grow factor (number). Controls how much an item grows.",
2012− "flex-shrink" => "**flex-shrink** `<value>` \u{2014} Flex shrink factor (number). Controls how much an item shrinks.",
2013− "flex-basis" => "**flex-basis** `<value>` \u{2014} Initial main size of a flex item (e.g., `200px`, `auto`, `0`).",
2014− "isolation" => "**isolation** `<value>` \u{2014} Creates a new stacking context (`isolate`, `auto`).",
2015− "place-content" => "**place-content** `<value>` \u{2014} Shorthand for `align-content` and `justify-content`.",
2016− "background-image" => "**background-image** `<value>` \u{2014} Background image (`url()` or gradient function).",
2017− // New CSS properties (batch 2)
2018− "font-weight" => "**font-weight** `<value>` \u{2014} Font weight (`100`\u{2013}`900`, `bold`, `lighter`, `bolder`). More precise than `bold`.",
2019− "font-style" => "**font-style** `<value>` \u{2014} Font style (`normal`, `italic`, `oblique`). More precise than `italic`.",
2020− "text-wrap" => "**text-wrap** `<value>` \u{2014} Text wrapping behavior (`balance`, `pretty`, `nowrap`, `wrap`). `balance` is great for headings.",
2021− "will-change" => "**will-change** `<value>` \u{2014} Performance hint for upcoming changes (`transform`, `opacity`, `scroll-position`).",
2022− "touch-action" => "**touch-action** `<value>` \u{2014} Touch behavior for mobile (`none`, `pan-x`, `pan-y`, `manipulation`, `auto`).",
2023− "vertical-align" => "**vertical-align** `<value>` \u{2014} Vertical alignment for inline elements (`middle`, `top`, `bottom`, `baseline`, `text-top`).",
2024− "contain" => "**contain** `<value>` \u{2014} CSS containment for performance (`layout`, `paint`, `content`, `strict`, `size`).",
2025− "content-visibility" => "**content-visibility** `<value>` \u{2014} Content rendering optimization (`auto`, `visible`, `hidden`). `auto` enables lazy rendering.",
2026− "scroll-margin" => "**scroll-margin** `<value>` \u{2014} Scroll margin (for scroll-snap and anchor link offsets). Supports CSS units.",
2027− "scroll-padding" => "**scroll-padding** `<value>` \u{2014} Scroll padding for scroll-snap containers. Supports CSS units.",
2028− "content" => "**content** `<value>` \u{2014} CSS content property. Use with `before:` or `after:` prefix.\n\nExample: `before:content \u{2192}, before:color red`",
2029− // New elements
2030− "@iframe" => "**@iframe** \u{2014} Embedded page\n\nRenders as `<iframe>`.\n\nUsage: `@iframe [width fill, height 400] https://example.com`\n\nAttributes: `sandbox`, `allow`, `allowfullscreen`",
2031− "@output" => "**@output** \u{2014} Form output\n\nRenders as `<output>`. Displays calculation results in forms.\n\nUsage: `@output [for a b] Result`",
2032− "@canvas" => "**@canvas** \u{2014} Drawing surface\n\nRenders as `<canvas>`. Use with `@raw` JavaScript for drawing.\n\nUsage: `@canvas [width 400, height 300, id myCanvas]`",
2033− "@script" => "**@script** \u{2014} Script\n\nRenders as `<script>`. Inline JavaScript.\n\nUsage: `@script console.log(\"hello\")`",
2034− "@noscript" => "**@noscript** \u{2014} NoScript fallback\n\nRenders as `<noscript>`. Shown when JavaScript is disabled.\n\nUsage: `@noscript Please enable JavaScript.`",
2035− "@address" => "**@address** \u{2014} Address\n\nRenders as `<address>`. Contact information for the nearest `@article` or `@body`.\n\nUsage: `@address hello@example.com`",
2036− "@search" => "**@search** \u{2014} Search\n\nRenders as `<search>`. Semantic container for search functionality.\n\nUsage:\n```\n@search\n @input [type search, placeholder Search...]\n @button Search\n```",
2037− "@breadcrumb" => "**@breadcrumb** \u{2014} Breadcrumb\n\nRenders as `<nav aria-label=\"Breadcrumb\">`. Semantic breadcrumb navigation.\n\nUsage:\n```\n@breadcrumb\n @link / Home\n @link /docs Docs\n @text Current Page\n```",
2038− // Convenience elements
2039− "@grid" => "**@grid** \u{2014} CSS Grid container\n\nRenders as `<div>` with `display: grid`.\n\nUsage: `@grid [grid-cols 3, gap 20]`",
2040− "@stack" => "**@stack** \u{2014} Stack container\n\nRenders as `<div>` with `position: relative`. Children can be absolutely positioned on top of each other.",
2041− "@spacer" => "**@spacer** \u{2014} Flexible spacer\n\nRenders as `<div>` with `flex: 1`. Pushes siblings apart in flex containers.",
2042− "@badge" => "**@badge** \u{2014} Badge\n\nRenders as `<span>` with pill shape, centered text.\n\nUsage: `@badge [background #3b82f6, color white] NEW`",
2043− "@tooltip" => "**@tooltip** \u{2014} Tooltip\n\nRenders as `<span>` with `title` attribute for native tooltips.\n\nUsage: `@tooltip [cursor help] Hover me`",
2044− "@avatar" => "**@avatar** \u{2014} Avatar\n\nRenders as `<div>` with circular shape, centered content, `overflow: hidden`.\n\nUsage:\n```\n@avatar [width 48, height 48, background #e5e7eb]\n @image [object-fit cover] photo.jpg\n```",
2045− "@carousel" => "**@carousel** \u{2014} Horizontal carousel\n\nRenders as `<div>` with horizontal scroll-snap.\nChildren auto-receive `scroll-snap-align: start` and `flex-shrink: 0`.\n\nUsage:\n```\n@carousel [gap 16]\n @el [width 300] Slide 1\n @el [width 300] Slide 2\n```",
2046− "@chip" => "**@chip** \u{2014} Chip / pill\n\nRenders as `<span>` with rounded borders, inline-flex layout.\n\nUsage: `@chip [background #e5e7eb] Category`",
2047− "@tag" => "**@tag** \u{2014} Tag label\n\nRenders as `<span>` with subtle rounded rectangle, bold small text.\n\nUsage: `@tag [background #dbeafe, color #1e40af] v2.0`",
2048− // CSS shorthands
2049− "truncate" => "**truncate** \u{2014} Truncate with ellipsis\n\nShorthand for: `overflow: hidden; text-overflow: ellipsis; white-space: nowrap`\n\nUsage: `@text [max-width 200, truncate] Long text here...`",
2050− "line-clamp" => "**line-clamp** `<N>` \u{2014} Multi-line truncation\n\nClamps text to N lines with ellipsis.\n\nUsage: `@paragraph [line-clamp 3] Long paragraph...`",
2051− "blur" => "**blur** `<value>` \u{2014} Apply blur filter\n\nShorthand for `filter: blur(Npx)`.\n\nUsage: `[blur 4]` \u{2192} `filter: blur(4px)`",
2052− "backdrop-blur" => "**backdrop-blur** `<value>` \u{2014} Apply backdrop blur\n\nShorthand for `backdrop-filter: blur(Npx)`.\n\nUsage: `[backdrop-blur 10]` \u{2192} `backdrop-filter: blur(10px)`",
2053− "no-scrollbar" => "**no-scrollbar** \u{2014} Hide scrollbar\n\nHides scrollbar while keeping overflow scrollable.\n\nSets `scrollbar-width: none` and `::-webkit-scrollbar { display: none }`.",
2054− "skeleton" => "**skeleton** \u{2014} Loading skeleton\n\nAdds a shimmer animation for loading placeholders.\n\nUsage: `@el [width fill, height 20, rounded 4, skeleton]`",
2055− "gradient" => "**gradient** `<from> <to> [angle]` \u{2014} Linear gradient\n\nShorthand for `background: linear-gradient(...)`.\n\nUsage:\n- `[gradient #fff #000]` \u{2192} top-to-bottom\n- `[gradient #fff #000 45deg]` \u{2192} 45\u{00b0} angle",
2056− "direction" => "**direction** `<value>` \u{2014} Text direction (`ltr`, `rtl`).",
2057− // Grid areas
2058− "grid-template-areas" => "**grid-template-areas** `<value>` \u{2014} Define named grid areas.\n\nUsage: `[grid, grid-template-areas \"header header\" \"sidebar main\"]`\n\nChildren use `grid-area` to place themselves.",
2059− "grid-area" => "**grid-area** `<name>` \u{2014} Place element in a named grid area.\n\nUsage: `@el [grid-area header] ...`\n\nRequires parent with `grid-template-areas`.",
2060− // View transitions
2061− "view-transition-name" => "**view-transition-name** `<name>` \u{2014} Assign a View Transition API name.\n\nEnables smooth transitions between pages.\n\nUsage: `@el [view-transition-name hero]`",
2062− // Animate shorthand
2063− "animate" => "**animate** `<name> <duration> [timing]` \u{2014} Animation shorthand.\n\nAlias for the CSS `animation` property.\n\nUsage: `@el [animate fade-in 0.3s ease]`\n\nRequires a matching `@keyframes fade-in` definition.",
2064− // Has pseudo-selector
2065− "has(" => "**has(selector):** \u{2014} Parent selector pseudo-class.\n\nStyle an element based on its children.\n\nUsage: `@el [has(.active):background blue, has(img):padding 0]`\n\nGenerates CSS `:has()` selector.",
2066− // Critical
2067− "critical" => "**critical** \u{2014} Mark element as above-fold.\n\nHint for build tools to prioritize this element's CSS.",
2068− // New CSS properties
2069− "text-underline-offset" => "**text-underline-offset** `<value>` \u{2014} Offset of text underline from its default position. Supports CSS units.\n\nUsage: `[underline, text-underline-offset 4]`",
2070− "column-width" => "**column-width** `<value>` \u{2014} Ideal column width in multi-column layout. Browser determines column count.\n\nUsage: `[column-width 200]`",
2071− "column-rule" => "**column-rule** `<value>` \u{2014} Rule between columns (shorthand for width, style, color).\n\nUsage: `[column-rule 1px solid #ccc]`",
2072− // Variable filters
2073− "\\$|uppercase" => "**|uppercase** \u{2014} Convert variable to UPPERCASE.\n\nUsage: `$name|uppercase`",
2074− "\\$|lowercase" => "**|lowercase** \u{2014} Convert variable to lowercase.\n\nUsage: `$name|lowercase`",
2075− "\\$|capitalize" => "**|capitalize** \u{2014} Capitalize first letter.\n\nUsage: `$name|capitalize`",
2076− "\\$|trim" => "**|trim** \u{2014} Strip leading/trailing whitespace.\n\nUsage: `$name|trim`",
2077− "\\$|length" => "**|length** \u{2014} Get string length.\n\nUsage: `$name|length`",
2078− "\\$|reverse" => "**|reverse** \u{2014} Reverse string.\n\nUsage: `$name|reverse`",
2079− _ => return None,
2080− };
2081−
2082− Some(if let Some(state) = state {
2083− format!("*({} state)* {}", state, doc)
2084− } else {
2085− doc.to_string()
2086− })
2087−}
2088−
2089−// ---------------------------------------------------------------------------
2090−// Go to definition
2091−// ---------------------------------------------------------------------------
2092−
2093−fn definition_at(text: &str, position: Position, uri: &Url) -> Option<GotoDefinitionResponse> {
2094− let lines: Vec<&str> = text.lines().collect();
2095− let line = lines.get(position.line as usize)?;
2096−
2097− // Check for @include/@import file path navigation
2098− let trimmed = line.trim();
2099− if let Some(filename) = trimmed
2100− .strip_prefix("@include ")
2101− .or_else(|| trimmed.strip_prefix("@import "))
2102− {
2103− let filename = filename.trim();
2104− if !filename.is_empty() {
2105− let file_path = uri.to_file_path().ok()?;
2106− let dir = file_path.parent()?;
2107− let target = dir.join(filename);
2108− if target.exists() {
2109− let target_uri = Url::from_file_path(&target).ok()?;
2110− return Some(GotoDefinitionResponse::Scalar(Location {
2111− uri: target_uri,
2112− range: Range::new(Position::new(0, 0), Position::new(0, 0)),
2113− }));
2114− }
2115− }
2116− }
2117−
2118− let col = (position.character as usize).min(line.len());
2119− let word = word_at(line, col)?;
2120−
2121− // Find definition location
2122− let (def_line, def_col, def_len) = if word.starts_with('$') {
2123− let name = &word[1..];
2124− find_definition(text, name)?
2125− } else if word.starts_with('@') {
2126− let name = &word[1..];
2127− find_fn_definition(text, name)?
2128− } else {
2129− return None;
2130− };
2131−
2132− Some(GotoDefinitionResponse::Scalar(Location {
2133− uri: uri.clone(),
2134− range: Range::new(
2135− Position::new(def_line, def_col),
2136− Position::new(def_line, def_col + def_len),
2137− ),
2138− }))
2139−}
2140−
2141−/// Find @let, @define, or @fn parameter definition for a $name reference.
2142−fn find_definition(text: &str, name: &str) -> Option<(u32, u32, u32)> {
2143− for (i, line) in text.lines().enumerate() {
2144− let trimmed = line.trim();
2145− let offset = (line.len() - trimmed.len()) as u32;
2146−
2147− if let Some(rest) = trimmed.strip_prefix("@let ") {
2148− if let Some((n, _)) = rest.trim().split_once(' ') {
2149− if n == name {
2150− let col = offset + "@let ".len() as u32;
2151− return Some((i as u32, col, n.len() as u32));
2152− }
2153− }
2154− }
2155−
2156− if let Some(rest) = trimmed.strip_prefix("@define ") {
2157− let rest = rest.trim();
2158− if let Some(bracket) = rest.find('[') {
2159− let n = rest[..bracket].trim();
2160− if n == name {
2161− let col = offset + "@define ".len() as u32;
2162− return Some((i as u32, col, n.len() as u32));
2163− }
2164− }
2165− }
2166−
2167− if let Some(rest) = trimmed.strip_prefix("@fn ") {
2168− let parts: Vec<&str> = rest.split_whitespace().collect();
2169− for param in &parts[1..] {
2170− let p = param.strip_prefix('$').unwrap_or(param);
2171− if p == name {
2172− // Find the param position in the line
2173− if let Some(pos) = line.find(param) {
2174− return Some((i as u32, pos as u32, param.len() as u32));
2175− }
2176− }
2177− }
2178− }
2179− }
2180− None
2181−}
2182−
2183−/// Find @fn definition for an @name function call.
2184−fn find_fn_definition(text: &str, name: &str) -> Option<(u32, u32, u32)> {
2185− for (i, line) in text.lines().enumerate() {
2186− let trimmed = line.trim();
2187− let offset = (line.len() - trimmed.len()) as u32;
2188−
2189− if let Some(rest) = trimmed.strip_prefix("@fn ") {
2190− let parts: Vec<&str> = rest.split_whitespace().collect();
2191− if parts.first() == Some(&name) {
2192− let col = offset + "@fn ".len() as u32;
2193− return Some((i as u32, col, name.len() as u32));
2194− }
2195− }
2196− }
2197− None
2198−}
2199−
2200−// ---------------------------------------------------------------------------
2201−// Rename
2202−// ---------------------------------------------------------------------------
2203−
2204−fn prepare_rename_at(text: &str, position: Position) -> Option<PrepareRenameResponse> {
2205− let lines: Vec<&str> = text.lines().collect();
2206− let line = lines.get(position.line as usize)?;
2207− let col = (position.character as usize).min(line.len());
2208− let word = word_at(line, col)?;
2209−
2210− // Only allow renaming $variables and @function calls/definitions
2211− if !word.starts_with('$') && !word.starts_with('@') {
2212− return None;
2213− }
2214−
2215− let name = &word[1..];
2216−
2217− // Check that the symbol actually has a definition
2218− if word.starts_with('$') {
2219− find_definition(text, name)?;
2220− } else {
2221− find_fn_definition(text, name)?;
2222− }
2223−
2224− // Find the range of the word in the line
2225− let bytes = line.as_bytes();
2226− let mut start = col;
2227− while start > 0 && is_word_byte(bytes[start - 1]) {
2228− start -= 1;
2229− }
2230− let mut end = col;
2231− while end < bytes.len() && is_word_byte(bytes[end]) {
2232− end += 1;
2233− }
2234−
2235− Some(PrepareRenameResponse::Range(Range::new(
2236− Position::new(position.line, start as u32),
2237− Position::new(position.line, end as u32),
2238− )))
2239−}
2240−
2241−fn rename_at(text: &str, position: Position, new_name: &str, uri: &Url) -> Option<WorkspaceEdit> {
2242− let lines: Vec<&str> = text.lines().collect();
2243− let line = lines.get(position.line as usize)?;
2244− let col = (position.character as usize).min(line.len());
2245− let word = word_at(line, col)?;
2246−
2247− let is_var = word.starts_with('$');
2248− let name = &word[1..]; // strip $ or @
2249−
2250− // Strip $ or @ from new_name if user included it
2251− let new_base = new_name
2252− .strip_prefix('$')
2253− .or_else(|| new_name.strip_prefix('@'))
2254− .unwrap_or(new_name);
2255−
2256− let mut edits = Vec::new();
2257−
2258− for (i, line) in text.lines().enumerate() {
2259− let line_num = i as u32;
2260− let trimmed = line.trim();
2261−
2262− if is_var {
2263− // Rename @let definition
2264− if let Some(rest) = trimmed.strip_prefix("@let ") {
2265− if let Some((n, _)) = rest.trim().split_once(' ') {
2266− if n == name {
2267− if let Some(pos) = line.find(n) {
2268− edits.push(TextEdit {
2269− range: Range::new(
2270− Position::new(line_num, pos as u32),
2271− Position::new(line_num, (pos + n.len()) as u32),
2272− ),
2273− new_text: new_base.to_string(),
2274− });
2275− }
2276− }
2277− }
2278− }
2279−
2280− // Rename @define definition
2281− if let Some(rest) = trimmed.strip_prefix("@define ") {
2282− let rest_trimmed = rest.trim();
2283− if let Some(bracket) = rest_trimmed.find('[') {
2284− let n = rest_trimmed[..bracket].trim();
2285− if n == name {
2286− if let Some(pos) = line.find(n) {
2287− edits.push(TextEdit {
2288− range: Range::new(
2289− Position::new(line_num, pos as u32),
2290− Position::new(line_num, (pos + n.len()) as u32),
2291− ),
2292− new_text: new_base.to_string(),
2293− });
2294− continue; // Don't also match $ references on this line
2295− }
2296− }
2297− }
2298− }
2299−
2300− // Rename @fn parameter definitions
2301− if let Some(rest) = trimmed.strip_prefix("@fn ") {
2302− let parts: Vec<&str> = rest.split_whitespace().collect();
2303− for param in &parts[1..] {
2304− let p = param.strip_prefix('$').unwrap_or(param);
2305− if p == name {
2306− if let Some(pos) = line.find(param) {
2307− let prefix = if param.starts_with('$') { "$" } else { "" };
2308− edits.push(TextEdit {
2309− range: Range::new(
2310− Position::new(line_num, pos as u32),
2311− Position::new(line_num, (pos + param.len()) as u32),
2312− ),
2313− new_text: format!("{}{}", prefix, new_base),
2314− });
2315− }
2316− }
2317− }
2318− }
2319−
2320− // Rename all $name references
2321− let search = format!("${}", name);
2322− let replace = format!("${}", new_base);
2323− let mut offset = 0;
2324− while let Some(pos) = line[offset..].find(&search) {
2325− let abs_pos = offset + pos;
2326− // Check it's not part of a longer identifier
2327− let after = abs_pos + search.len();
2328− let is_end = after >= line.len()
2329− || !line.as_bytes()[after].is_ascii_alphanumeric()
2330− && line.as_bytes()[after] != b'-'
2331− && line.as_bytes()[after] != b'_';
2332− if is_end {
2333− edits.push(TextEdit {
2334− range: Range::new(
2335− Position::new(line_num, abs_pos as u32),
2336− Position::new(line_num, (abs_pos + search.len()) as u32),
2337− ),
2338− new_text: replace.clone(),
2339− });
2340− }
2341− offset = abs_pos + search.len();
2342− }
2343− } else {
2344− // Rename @fn definition
2345− if let Some(rest) = trimmed.strip_prefix("@fn ") {
2346− let parts: Vec<&str> = rest.split_whitespace().collect();
2347− if parts.first() == Some(&name) {
2348− if let Some(pos) = line.find(&format!("@fn {}", name)) {
2349− let start = pos + 4; // skip "@fn "
2350− edits.push(TextEdit {
2351− range: Range::new(
2352− Position::new(line_num, start as u32),
2353− Position::new(line_num, (start + name.len()) as u32),
2354− ),
2355− new_text: new_base.to_string(),
2356− });
2357− }
2358− }
2359− }
2360−
2361− // Rename @name function calls
2362− let search = format!("@{}", name);
2363− let replace = format!("@{}", new_base);
2364− let mut offset = 0;
2365− while let Some(pos) = line[offset..].find(&search) {
2366− let abs_pos = offset + pos;
2367− // Don't match @fn definition (handled above)
2368− if trimmed.starts_with("@fn ") {
2369− offset = abs_pos + search.len();
2370− continue;
2371− }
2372− // Check it's not part of a longer identifier
2373− let after = abs_pos + search.len();
2374− let is_end = after >= line.len()
2375− || !line.as_bytes()[after].is_ascii_alphanumeric()
2376− && line.as_bytes()[after] != b'-'
2377− && line.as_bytes()[after] != b'_';
2378− if is_end {
2379− edits.push(TextEdit {
2380− range: Range::new(
2381− Position::new(line_num, abs_pos as u32),
2382− Position::new(line_num, (abs_pos + search.len()) as u32),
2383− ),
2384− new_text: replace.clone(),
2385− });
2386− }
2387− offset = abs_pos + search.len();
2388− }
2389− }
2390− }
2391−
2392− if edits.is_empty() {
2393− return None;
2394− }
2395−
2396− let mut changes = HashMap::new();
2397− changes.insert(uri.clone(), edits);
2398−
2399− Some(WorkspaceEdit {
2400− changes: Some(changes),
2401− ..Default::default()
2402− })
2403−}
2404−
2405−// ---------------------------------------------------------------------------
2406−// Document symbols (outline view)
2407−// ---------------------------------------------------------------------------
2408−
2409−#[allow(deprecated)] // SymbolInformation::deprecated is deprecated but needed for the struct
2410−fn document_symbols(text: &str) -> Vec<SymbolInformation> {
2411− let mut symbols = Vec::new();
2412−
2413− for (i, line) in text.lines().enumerate() {
2414− let trimmed = line.trim();
2415− let line_num = i as u32;
2416−
2417− // @fn definitions
2418− if let Some(rest) = trimmed.strip_prefix("@fn ") {
2419− let parts: Vec<&str> = rest.split_whitespace().collect();
2420− if let Some(name) = parts.first() {
2421− let params = parts[1..].join(" ");
2422− let detail = if params.is_empty() { None } else { Some(format!("({})", params)) };
2423− symbols.push(SymbolInformation {
2424− name: format!("@{}", name),
2425− kind: SymbolKind::FUNCTION,
2426− tags: None,
2427− deprecated: None,
2428− location: Location {
2429− uri: Url::parse("file:///").unwrap(), // replaced by caller
2430− range: Range::new(Position::new(line_num, 0), Position::new(line_num, line.len() as u32)),
2431− },
2432− container_name: detail,
2433− });
2434− }
2435− }
2436−
2437− // @let definitions
2438− if let Some(rest) = trimmed.strip_prefix("@let ") {
2439− if let Some((name, value)) = rest.trim().split_once(' ') {
2440− symbols.push(SymbolInformation {
2441− name: format!("${}", name),
2442− kind: SymbolKind::VARIABLE,
2443− tags: None,
2444− deprecated: None,
2445− location: Location {
2446− uri: Url::parse("file:///").unwrap(),
2447− range: Range::new(Position::new(line_num, 0), Position::new(line_num, line.len() as u32)),
2448− },
2449− container_name: Some(format!("= {}", value.trim())),
2450− });
2451− }
2452− }
2453−
2454− // @define definitions
2455− if let Some(rest) = trimmed.strip_prefix("@define ") {
2456− if let Some(bracket) = rest.find('[') {
2457− let name = rest[..bracket].trim();
2458− symbols.push(SymbolInformation {
2459− name: format!("${}", name),
2460− kind: SymbolKind::CONSTANT,
2461− tags: None,
2462− deprecated: None,
2463− location: Location {
2464− uri: Url::parse("file:///").unwrap(),
2465− range: Range::new(Position::new(line_num, 0), Position::new(line_num, line.len() as u32)),
2466− },
2467− container_name: Some("attribute bundle".to_string()),
2468− });
2469− }
2470− }
2471−
2472− // @keyframes definitions
2473− if let Some(rest) = trimmed.strip_prefix("@keyframes ") {
2474− let name = rest.trim();
2475− if !name.is_empty() {
2476− symbols.push(SymbolInformation {
2477− name: format!("@keyframes {}", name),
2478− kind: SymbolKind::EVENT,
2479− tags: None,
2480− deprecated: None,
2481− location: Location {
2482− uri: Url::parse("file:///").unwrap(),
2483− range: Range::new(Position::new(line_num, 0), Position::new(line_num, line.len() as u32)),
2484− },
2485− container_name: Some("animation".to_string()),
2486− });
2487− }
2488− }
2489− }
2490−
2491− symbols
2492−}
2493−
2494−// ---------------------------------------------------------------------------
2495−// Code actions (quick-fixes for typo suggestions)
2496−// ---------------------------------------------------------------------------
2497−
2498−fn code_actions(
2499− text: &str,
2500− selection: &Range,
2501− diagnostics: &[Diagnostic],
2502− uri: &Url,
2503−) -> Vec<CodeActionOrCommand> {
2504− let mut actions = Vec::new();
2505−
2506− for diag in diagnostics {
2507− let msg = &diag.message;
2508−
2509− // Extract "did you mean 'X'?" or "did you mean @X?" suggestions
2510− if let Some(suggestion) = extract_suggestion(msg) {
2511− let line = diag.range.start.line as usize;
2512− let lines: Vec<&str> = text.lines().collect();
2513− if let Some(source_line) = lines.get(line) {
2514− // Determine what to replace
2515− let (old_text, new_text) = if msg.contains("unknown element") {
2516− // Replace @wrong with @suggestion
2517− let old = extract_between(msg, "unknown element @", ",")
2518− .or_else(|| extract_between(msg, "unknown element @", ""));
2519− if let Some(old) = old {
2520− (format!("@{}", old), format!("@{}", suggestion))
2521− } else {
2522− continue;
2523− }
2524− } else if msg.contains("unknown attribute") {
2525− // Replace wrong with suggestion in attribute list
2526− let old = extract_between(msg, "unknown attribute '", "'");
2527− if let Some(old) = old {
2528− (old.to_string(), suggestion.to_string())
2529− } else {
2530− continue;
2531− }
2532− } else {
2533− continue;
2534− };
2535−
2536− if let Some(col) = source_line.find(&old_text) {
2537− let edit = TextEdit {
2538− range: Range::new(
2539− Position::new(diag.range.start.line, col as u32),
2540− Position::new(diag.range.start.line, (col + old_text.len()) as u32),
2541− ),
2542− new_text: new_text.clone(),
2543− };
2544−
2545− let mut changes = HashMap::new();
2546− changes.insert(uri.clone(), vec![edit]);
2547−
2548− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2549− title: format!("Replace with '{}'", new_text),
2550− kind: Some(CodeActionKind::QUICKFIX),
2551− diagnostics: Some(vec![diag.clone()]),
2552− edit: Some(WorkspaceEdit {
2553− changes: Some(changes),
2554− ..Default::default()
2555− }),
2556− ..Default::default()
2557− }));
2558− }
2559− }
2560− }
2561−
2562− // Quick-fix: remove unused @let variable
2563− if msg.contains("unused variable") {
2564− let line = diag.range.start.line as usize;
2565− let lines: Vec<&str> = text.lines().collect();
2566− if let Some(source_line) = lines.get(line) {
2567− let trimmed = source_line.trim_start();
2568− if trimmed.starts_with("@let ") {
2569− let var_name = trimmed.strip_prefix("@let ")
2570− .and_then(|r| r.trim().split_whitespace().next())
2571− .unwrap_or("?");
2572− let edit = TextEdit {
2573− range: Range::new(
2574− Position::new(diag.range.start.line, 0),
2575− Position::new(diag.range.start.line + 1, 0),
2576− ),
2577− new_text: String::new(),
2578− };
2579− let mut changes = HashMap::new();
2580− changes.insert(uri.clone(), vec![edit]);
2581− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2582− title: format!("Remove unused variable '${}'", var_name),
2583− kind: Some(CodeActionKind::QUICKFIX),
2584− diagnostics: Some(vec![diag.clone()]),
2585− edit: Some(WorkspaceEdit {
2586− changes: Some(changes),
2587− ..Default::default()
2588− }),
2589− ..Default::default()
2590− }));
2591− }
2592− }
2593− }
2594−
2595− // Quick-fix: remove unused @define
2596− if msg.contains("unused define") {
2597− let line = diag.range.start.line as usize;
2598− let lines: Vec<&str> = text.lines().collect();
2599− if let Some(source_line) = lines.get(line) {
2600− let trimmed = source_line.trim_start();
2601− if trimmed.starts_with("@define ") {
2602− let def_name = trimmed.strip_prefix("@define ")
2603− .and_then(|r| {
2604− let r = r.trim();
2605− r.find('[').map(|b| r[..b].trim()).or_else(|| r.split_whitespace().next())
2606− })
2607− .unwrap_or("?");
2608− let edit = TextEdit {
2609− range: Range::new(
2610− Position::new(diag.range.start.line, 0),
2611− Position::new(diag.range.start.line + 1, 0),
2612− ),
2613− new_text: String::new(),
2614− };
2615− let mut changes = HashMap::new();
2616− changes.insert(uri.clone(), vec![edit]);
2617− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2618− title: format!("Remove unused define '${}'", def_name),
2619− kind: Some(CodeActionKind::QUICKFIX),
2620− diagnostics: Some(vec![diag.clone()]),
2621− edit: Some(WorkspaceEdit {
2622− changes: Some(changes),
2623− ..Default::default()
2624− }),
2625− ..Default::default()
2626− }));
2627− }
2628− }
2629− }
2630−
2631− // Quick-fix: remove unused @fn function (remove definition and its body)
2632− if msg.contains("unused function") {
2633− let line = diag.range.start.line as usize;
2634− let lines: Vec<&str> = text.lines().collect();
2635− if let Some(source_line) = lines.get(line) {
2636− let trimmed = source_line.trim_start();
2637− if trimmed.starts_with("@fn ") {
2638− let fn_name = trimmed.strip_prefix("@fn ")
2639− .and_then(|r| r.split_whitespace().next())
2640− .unwrap_or("?");
2641− // Find the end of the function body (indented lines below)
2642− let start_indent = source_line.len() - trimmed.len();
2643− let mut end_line = line;
2644− let mut j = line + 1;
2645− while j < lines.len() {
2646− let l = lines[j];
2647− if l.trim().is_empty() {
2648− j += 1;
2649− continue;
2650− }
2651− let indent = l.len() - l.trim_start().len();
2652− if indent <= start_indent {
2653− break;
2654− }
2655− end_line = j;
2656− j += 1;
2657− }
2658− let edit = TextEdit {
2659− range: Range::new(
2660− Position::new(diag.range.start.line, 0),
2661− Position::new(end_line as u32 + 1, 0),
2662− ),
2663− new_text: String::new(),
2664− };
2665− let mut changes = HashMap::new();
2666− changes.insert(uri.clone(), vec![edit]);
2667− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2668− title: format!("Remove unused function '@{}'", fn_name),
2669− kind: Some(CodeActionKind::QUICKFIX),
2670− diagnostics: Some(vec![diag.clone()]),
2671− edit: Some(WorkspaceEdit {
2672− changes: Some(changes),
2673− ..Default::default()
2674− }),
2675− ..Default::default()
2676− }));
2677− }
2678− }
2679− }
2680−
2681− // Quick-fix: remove unused @mixin
2682− if msg.contains("unused mixin") {
2683− let line = diag.range.start.line as usize;
2684− let lines: Vec<&str> = text.lines().collect();
2685− if let Some(source_line) = lines.get(line) {
2686− let trimmed = source_line.trim_start();
2687− if trimmed.starts_with("@mixin ") {
2688− let mixin_name = trimmed.strip_prefix("@mixin ")
2689− .and_then(|r| {
2690− let r = r.trim();
2691− r.find('[').map(|b| r[..b].trim()).or_else(|| r.split_whitespace().next())
2692− })
2693− .unwrap_or("?");
2694− let edit = TextEdit {
2695− range: Range::new(
2696− Position::new(diag.range.start.line, 0),
2697− Position::new(diag.range.start.line + 1, 0),
2698− ),
2699− new_text: String::new(),
2700− };
2701− let mut changes = HashMap::new();
2702− changes.insert(uri.clone(), vec![edit]);
2703− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2704− title: format!("Remove unused mixin '{}'", mixin_name),
2705− kind: Some(CodeActionKind::QUICKFIX),
2706− diagnostics: Some(vec![diag.clone()]),
2707− edit: Some(WorkspaceEdit {
2708− changes: Some(changes),
2709− ..Default::default()
2710− }),
2711− ..Default::default()
2712− }));
2713− }
2714− }
2715− }
2716−
2717− // Quick-fix: add alt attribute to @image
2718− if msg.contains("@image should have") && msg.contains("alt") {
2719− let line = diag.range.start.line as usize;
2720− let lines: Vec<&str> = text.lines().collect();
2721− if let Some(source_line) = lines.get(line) {
2722− if let Some(bracket_pos) = source_line.find('[') {
2723− let insert_pos = bracket_pos + 1;
2724− let edit = TextEdit {
2725− range: Range::new(
2726− Position::new(diag.range.start.line, insert_pos as u32),
2727− Position::new(diag.range.start.line, insert_pos as u32),
2728− ),
2729− new_text: "alt , ".into(),
2730− };
2731− let mut changes = HashMap::new();
2732− changes.insert(uri.clone(), vec![edit]);
2733− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2734− title: "Add alt attribute".into(),
2735− kind: Some(CodeActionKind::QUICKFIX),
2736− diagnostics: Some(vec![diag.clone()]),
2737− edit: Some(WorkspaceEdit {
2738− changes: Some(changes),
2739− ..Default::default()
2740− }),
2741− ..Default::default()
2742− }));
2743− } else if source_line.contains("@image") {
2744− // No brackets yet, add them
2745− if let Some(img_pos) = source_line.find("@image") {
2746− let after_image = img_pos + "@image".len();
2747− let edit = TextEdit {
2748− range: Range::new(
2749− Position::new(diag.range.start.line, after_image as u32),
2750− Position::new(diag.range.start.line, after_image as u32),
2751− ),
2752− new_text: " [alt ]".into(),
2753− };
2754− let mut changes = HashMap::new();
2755− changes.insert(uri.clone(), vec![edit]);
2756− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2757− title: "Add alt attribute".into(),
2758− kind: Some(CodeActionKind::QUICKFIX),
2759− diagnostics: Some(vec![diag.clone()]),
2760− edit: Some(WorkspaceEdit {
2761− changes: Some(changes),
2762− ..Default::default()
2763− }),
2764− ..Default::default()
2765− }));
2766− }
2767− }
2768− }
2769− }
2770−
2771− // Quick-fix: add missing type to @input
2772− if msg.contains("@input missing 'type'") {
2773− let line = diag.range.start.line as usize;
2774− let lines: Vec<&str> = text.lines().collect();
2775− if let Some(source_line) = lines.get(line) {
2776− if let Some(bracket_pos) = source_line.find('[') {
2777− let insert_pos = bracket_pos + 1;
2778− let edit = TextEdit {
2779− range: Range::new(
2780− Position::new(diag.range.start.line, insert_pos as u32),
2781− Position::new(diag.range.start.line, insert_pos as u32),
2782− ),
2783− new_text: "type text, ".into(),
2784− };
2785− let mut changes = HashMap::new();
2786− changes.insert(uri.clone(), vec![edit]);
2787− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2788− title: "Add type=\"text\" attribute".into(),
2789− kind: Some(CodeActionKind::QUICKFIX),
2790− diagnostics: Some(vec![diag.clone()]),
2791− edit: Some(WorkspaceEdit {
2792− changes: Some(changes),
2793− ..Default::default()
2794− }),
2795− ..Default::default()
2796− }));
2797− } else if source_line.contains("@input") {
2798− if let Some(pos) = source_line.find("@input") {
2799− let after = pos + "@input".len();
2800− let edit = TextEdit {
2801− range: Range::new(
2802− Position::new(diag.range.start.line, after as u32),
2803− Position::new(diag.range.start.line, after as u32),
2804− ),
2805− new_text: " [type text]".into(),
2806− };
2807− let mut changes = HashMap::new();
2808− changes.insert(uri.clone(), vec![edit]);
2809− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2810− title: "Add type=\"text\" attribute".into(),
2811− kind: Some(CodeActionKind::QUICKFIX),
2812− diagnostics: Some(vec![diag.clone()]),
2813− edit: Some(WorkspaceEdit {
2814− changes: Some(changes),
2815− ..Default::default()
2816− }),
2817− ..Default::default()
2818− }));
2819− }
2820− }
2821− }
2822− }
2823−
2824− // Quick-fix: low contrast ratio — suggest swapping to a high-contrast pair
2825− if msg.contains("low contrast ratio") {
2826− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2827− title: "Acknowledged: low contrast ratio".into(),
2828− kind: Some(CodeActionKind::QUICKFIX),
2829− diagnostics: Some(vec![diag.clone()]),
2830− is_preferred: Some(false),
2831− ..Default::default()
2832− }));
2833− }
2834−
2835− // Quick-fix: auto-import suggestion for unknown element @name
2836− // Searches current directory and subdirectories for @fn definitions
2837− if msg.contains("unknown element @") {
2838− if let Some(fn_name) = extract_between(msg, "unknown element @", ",")
2839− .or_else(|| extract_between(msg, "unknown element @", ""))
2840− {
2841− let fn_name = fn_name.trim();
2842− if !fn_name.is_empty() {
2843− if let Ok(file_path) = uri.to_file_path() {
2844− if let Some(dir) = file_path.parent() {
2845− // Search current dir and subdirs for .hl files defining this function
2846− let mut search_dirs = vec![dir.to_path_buf()];
2847− // Also search parent dir's subdirs (for project-wide imports)
2848− if let Some(parent) = dir.parent() {
2849− if let Ok(entries) = std::fs::read_dir(parent) {
2850− for entry in entries.flatten() {
2851− let p = entry.path();
2852− if p.is_dir() && p != dir {
2853− search_dirs.push(p);
2854− }
2855− }
2856− }
2857− }
2858− for search_dir in &search_dirs {
2859− if let Ok(entries) = std::fs::read_dir(search_dir) {
2860− for entry in entries.flatten() {
2861− let path = entry.path();
2862− if path.extension().and_then(|e| e.to_str()) != Some("hl") {
2863− continue;
2864− }
2865− if path == file_path {
2866− continue;
2867− }
2868− if let Ok(content) = std::fs::read_to_string(&path) {
2869− let defines_fn = content.lines().any(|l| {
2870− let t = l.trim();
2871− if let Some(rest) = t.strip_prefix("@fn ") {
2872− rest.split_whitespace().next() == Some(fn_name)
2873− } else {
2874− false
2875− }
2876− });
2877− if defines_fn {
2878− // Compute relative path from current file's dir
2879− let rel = path.strip_prefix(dir)
2880− .map(|p| p.display().to_string())
2881− .unwrap_or_else(|_| {
2882− path.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string()
2883− });
2884− let already_imported = text.lines().any(|l| {
2885− let t = l.trim();
2886− t == format!("@import {}", rel)
2887− || t == format!("@include {}", rel)
2888− });
2889− if !already_imported {
2890− // Offer @import (all definitions)
2891− let import_line = format!("@import {}\n", rel);
2892− let edit = TextEdit {
2893− range: Range::new(
2894− Position::new(0, 0),
2895− Position::new(0, 0),
2896− ),
2897− new_text: import_line,
2898− };
2899− let mut changes = HashMap::new();
2900− changes.insert(uri.clone(), vec![edit]);
2901− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2902− title: format!("Add '@import {}' for @{}", rel, fn_name),
2903− kind: Some(CodeActionKind::QUICKFIX),
2904− diagnostics: Some(vec![diag.clone()]),
2905− edit: Some(WorkspaceEdit {
2906− changes: Some(changes),
2907− ..Default::default()
2908− }),
2909− ..Default::default()
2910− }));
2911−
2912− // Also offer @use (selective import)
2913− let use_line = format!("@use \"{}\" {}\n", rel, fn_name);
2914− let use_edit = TextEdit {
2915− range: Range::new(
2916− Position::new(0, 0),
2917− Position::new(0, 0),
2918− ),
2919− new_text: use_line,
2920− };
2921− let mut use_changes = HashMap::new();
2922− use_changes.insert(uri.clone(), vec![use_edit]);
2923− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2924− title: format!("Add '@use \"{}\" {}' (selective)", rel, fn_name),
2925− kind: Some(CodeActionKind::QUICKFIX),
2926− diagnostics: Some(vec![diag.clone()]),
2927− edit: Some(WorkspaceEdit {
2928− changes: Some(use_changes),
2929− ..Default::default()
2930− }),
2931− ..Default::default()
2932− }));
2933− }
2934− }
2935− }
2936− }
2937− }
2938− }
2939− }
2940− }
2941− }
2942− }
2943− }
2944− }
2945−
2946− // Refactoring: extract selection to @fn
2947− if selection.start.line != selection.end.line {
2948− let lines: Vec<&str> = text.lines().collect();
2949− let start_line = selection.start.line as usize;
2950− let end_line = (selection.end.line as usize).min(lines.len().saturating_sub(1));
2951− if start_line < lines.len() && end_line < lines.len() {
2952− // Collect selected lines
2953− let selected: Vec<&str> = lines[start_line..=end_line].to_vec();
2954− if !selected.is_empty() {
2955− // Determine the minimum indentation of selected lines (ignoring blank lines)
2956− let min_indent = selected.iter()
2957− .filter(|l| !l.trim().is_empty())
2958− .map(|l| l.len() - l.trim_start().len())
2959− .min()
2960− .unwrap_or(0);
2961−
2962− // Build the function body with two-space indentation relative to @fn
2963− let fn_body: String = selected.iter()
2964− .map(|l| {
2965− if l.trim().is_empty() {
2966− String::from("\n")
2967− } else {
2968− let stripped = if l.len() > min_indent { &l[min_indent..] } else { l.trim_start() };
2969− format!(" {}\n", stripped)
2970− }
2971− })
2972− .collect();
2973−
2974− let fn_def = format!("@fn extracted\n{}", fn_body);
2975− let indent = " ".repeat(min_indent);
2976− let fn_call = format!("{}@extracted", indent);
2977−
2978− // Build edits: replace selected lines with @extracted call, and insert @fn definition at top
2979− let replace_edit = TextEdit {
2980− range: Range::new(
2981− Position::new(selection.start.line, 0),
2982− Position::new(selection.end.line + 1, 0),
2983− ),
2984− new_text: format!("{}\n", fn_call),
2985− };
2986− let insert_edit = TextEdit {
2987− range: Range::new(Position::new(0, 0), Position::new(0, 0)),
2988− new_text: format!("{}\n", fn_def),
2989− };
2990− let mut changes = HashMap::new();
2991− changes.insert(uri.clone(), vec![insert_edit, replace_edit]);
2992− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2993− title: "Extract to @fn".into(),
2994− kind: Some(CodeActionKind::REFACTOR_EXTRACT),
2995− diagnostics: None,
2996− edit: Some(WorkspaceEdit {
2997− changes: Some(changes),
2998− ..Default::default()
2999− }),
3000− ..Default::default()
3001− }));
3002− }
3003− }
3004− }
3005−
3006− // Refactoring: extract attributes to @define
3007− // Works on a single line with [attrs] — extracts attrs into a @define
3008− {
3009− let lines: Vec<&str> = text.lines().collect();
3010− let line_idx = selection.start.line as usize;
3011− if line_idx < lines.len() {
3012− let line = lines[line_idx];
3013− if let Some(bracket_start) = line.find('[') {
3014− if let Some(bracket_end) = line[bracket_start..].find(']') {
3015− let attrs_str = &line[bracket_start + 1..bracket_start + bracket_end];
3016− // Only offer if there are at least 2 attributes
3017− let attr_count = attrs_str.split(',').count();
3018− if attr_count >= 2 {
3019− let define_name = "extracted-style";
3020− let define_line = format!("@define {} [{}]\n", define_name, attrs_str.trim());
3021− let indent = " ".repeat(line.len() - line.trim_start().len());
3022−
3023− // Replace [attrs] with [$extracted-style]
3024− let new_line = format!(
3025− "{}{}[${}]{}",
3026− indent,
3027− &line.trim_start()[..line.trim_start().find('[').unwrap_or(0)],
3028− define_name,
3029− &line[bracket_start + bracket_end + 1..]
3030− );
3031−
3032− let replace_edit = TextEdit {
3033− range: Range::new(
3034− Position::new(line_idx as u32, 0),
3035− Position::new(line_idx as u32, line.len() as u32),
3036− ),
3037− new_text: new_line,
3038− };
3039− let insert_edit = TextEdit {
3040− range: Range::new(Position::new(0, 0), Position::new(0, 0)),
3041− new_text: define_line,
3042− };
3043− let mut changes = HashMap::new();
3044− changes.insert(uri.clone(), vec![insert_edit, replace_edit]);
3045− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
3046− title: "Extract to @define".into(),
3047− kind: Some(CodeActionKind::REFACTOR_EXTRACT),
3048− diagnostics: None,
3049− edit: Some(WorkspaceEdit {
3050− changes: Some(changes),
3051− ..Default::default()
3052− }),
3053− ..Default::default()
3054− }));
3055− }
3056− }
3057− }
3058− }
3059− }
3060−
3061− actions
3062−}
3063−
3064−fn extract_suggestion(msg: &str) -> Option<&str> {
3065− // "did you mean @X?" or "did you mean 'X'?"
3066− if let Some(idx) = msg.find("did you mean @") {
3067− let start = idx + "did you mean @".len();
3068− let rest = &msg[start..];
3069− let end = rest.find('?').unwrap_or(rest.len());
3070− return Some(&rest[..end]);
3071− }
3072− if let Some(idx) = msg.find("did you mean '") {
3073− let start = idx + "did you mean '".len();
3074− let rest = &msg[start..];
3075− let end = rest.find('\'')?;
3076− return Some(&rest[..end]);
3077− }
3078− None
3079−}
3080−
3081−fn extract_between<'a>(msg: &'a str, prefix: &str, suffix: &str) -> Option<&'a str> {
3082− let start = msg.find(prefix)? + prefix.len();
3083− let rest = &msg[start..];
3084− if suffix.is_empty() {
3085− Some(rest.trim())
3086− } else {
3087− let end = rest.find(suffix)?;
3088− Some(&rest[..end])
3089− }
3090−}
3091−
3092−// ---------------------------------------------------------------------------
3093−// Color provider
3094−// ---------------------------------------------------------------------------
3095−
3096−fn find_colors(text: &str) -> Vec<ColorInformation> {
3097− let mut colors = Vec::new();
3098− for (line_idx, line) in text.lines().enumerate() {
3099− let mut start = 0;
3100− // Detect hex colors (#fff, #ffffff, #ffffffff)
3101− while let Some(pos) = line[start..].find('#') {
3102− let abs_pos = start + pos;
3103− let hex_start = abs_pos + 1;
3104− let hex_end = line[hex_start..]
3105− .find(|c: char| !c.is_ascii_hexdigit())
3106− .map(|p| hex_start + p)
3107− .unwrap_or(line.len());
3108− let hex = &line[hex_start..hex_end];
3109− let len = hex.len();
3110− if len == 3 || len == 6 || len == 8 {
3111− if let Some((r, g, b, a)) = parse_hex_color(hex) {
3112− colors.push(ColorInformation {
3113− range: Range::new(
3114− Position::new(line_idx as u32, abs_pos as u32),
3115− Position::new(line_idx as u32, hex_end as u32),
3116− ),
3117− color: Color {
3118− red: r as f32 / 255.0,
3119− green: g as f32 / 255.0,
3120− blue: b as f32 / 255.0,
3121− alpha: a as f32 / 255.0,
3122− },
3123− });
3124− }
3125− }
3126− start = hex_end;
3127− }
3128− // Detect named CSS colors
3129− for &(name, r, g, b) in NAMED_CSS_COLORS {
3130− let lower_line = line.to_lowercase();
3131− let mut search_start = 0;
3132− while let Some(pos) = lower_line[search_start..].find(name) {
3133− let abs = search_start + pos;
3134− let end = abs + name.len();
3135− // Ensure it's a word boundary (not inside another word)
3136− let before_ok = abs == 0 || !line.as_bytes()[abs - 1].is_ascii_alphanumeric();
3137− let after_ok = end >= line.len() || !line.as_bytes()[end].is_ascii_alphanumeric();
3138− if before_ok && after_ok {
3139− colors.push(ColorInformation {
3140− range: Range::new(
3141− Position::new(line_idx as u32, abs as u32),
3142− Position::new(line_idx as u32, end as u32),
3143− ),
3144− color: Color {
3145− red: r as f32 / 255.0,
3146− green: g as f32 / 255.0,
3147− blue: b as f32 / 255.0,
3148− alpha: 1.0,
3149− },
3150− });
3151− }
3152− search_start = end;
3153− }
3154− }
3155− }
3156− colors
3157−}
3158−
3159−/// Named CSS colors: (name, r, g, b)
3160−const NAMED_CSS_COLORS: &[(&str, u8, u8, u8)] = &[
3161− ("red", 255, 0, 0), ("green", 0, 128, 0), ("blue", 0, 0, 255),
3162− ("white", 255, 255, 255), ("black", 0, 0, 0),
3163− ("orange", 255, 165, 0), ("yellow", 255, 255, 0), ("purple", 128, 0, 128),
3164− ("pink", 255, 192, 203), ("gray", 128, 128, 128), ("grey", 128, 128, 128),
3165− ("navy", 0, 0, 128), ("teal", 0, 128, 128), ("maroon", 128, 0, 0),
3166− ("aqua", 0, 255, 255), ("cyan", 0, 255, 255), ("fuchsia", 255, 0, 255),
3167− ("magenta", 255, 0, 255), ("lime", 0, 255, 0), ("olive", 128, 128, 0),
3168− ("silver", 192, 192, 192), ("coral", 255, 127, 80), ("salmon", 250, 128, 114),
3169− ("tomato", 255, 99, 71), ("gold", 255, 215, 0), ("khaki", 240, 230, 140),
3170− ("violet", 238, 130, 238), ("indigo", 75, 0, 130), ("crimson", 220, 20, 60),
3171− ("turquoise", 64, 224, 208), ("plum", 221, 160, 221), ("orchid", 218, 112, 214),
3172− ("sienna", 160, 82, 45), ("tan", 210, 180, 140), ("peru", 205, 133, 63),
3173− ("chocolate", 210, 105, 30), ("firebrick", 178, 34, 34),
3174− ("darkred", 139, 0, 0), ("darkgreen", 0, 100, 0), ("darkblue", 0, 0, 139),
3175− ("darkgray", 169, 169, 169), ("darkgrey", 169, 169, 169),
3176− ("lightgray", 211, 211, 211), ("lightgrey", 211, 211, 211),
3177− ("lightblue", 173, 216, 230), ("lightgreen", 144, 238, 144),
3178− ("lightyellow", 255, 255, 224), ("lightcoral", 240, 128, 128),
3179− ("lightpink", 255, 182, 193), ("lightsalmon", 255, 160, 122),
3180− ("steelblue", 70, 130, 180), ("royalblue", 65, 105, 225),
3181− ("dodgerblue", 30, 144, 255), ("deepskyblue", 0, 191, 255),
3182− ("cornflowerblue", 100, 149, 237), ("midnightblue", 25, 25, 112),
3183− ("slateblue", 106, 90, 205), ("mediumblue", 0, 0, 205),
3184− ("springgreen", 0, 255, 127), ("limegreen", 50, 205, 50),
3185− ("forestgreen", 34, 139, 34), ("seagreen", 46, 139, 87),
3186− ("darkslategray", 47, 79, 79), ("darkslategrey", 47, 79, 79),
3187− ("cadetblue", 95, 158, 160), ("mediumaquamarine", 102, 205, 170),
3188− ("darkorange", 255, 140, 0), ("orangered", 255, 69, 0),
3189− ("deeppink", 255, 20, 147), ("hotpink", 255, 105, 180),
3190− ("mediumvioletred", 199, 21, 133), ("palevioletred", 219, 112, 147),
3191− ("sandybrown", 244, 164, 96), ("goldenrod", 218, 165, 32),
3192− ("darkgoldenrod", 184, 134, 11), ("saddlebrown", 139, 69, 19),
3193− ("wheat", 245, 222, 179), ("beige", 245, 245, 220),
3194− ("linen", 250, 240, 230), ("ivory", 255, 255, 240),
3195− ("snow", 255, 250, 250), ("honeydew", 240, 255, 240),
3196− ("azure", 240, 255, 255), ("lavender", 230, 230, 250),
3197− ("mistyrose", 255, 228, 225), ("seashell", 255, 245, 238),
3198−];
3199−
3200−fn parse_hex_color(hex: &str) -> Option<(u8, u8, u8, u8)> {
3201− match hex.len() {
3202− 3 => {
3203− let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17;
3204− let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17;
3205− let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17;
3206− Some((r, g, b, 255))
3207− }
3208− 6 => {
3209− let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
3210− let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
3211− let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
3212− Some((r, g, b, 255))
3213− }
3214− 8 => {
3215− let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
3216− let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
3217− let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
3218− let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
3219− Some((r, g, b, a))
3220− }
3221− _ => None,
3222− }
3223−}
3224−
3225−// ---------------------------------------------------------------------------
3226−// Folding ranges
3227−// ---------------------------------------------------------------------------
3228−
3229−fn folding_ranges(text: &str) -> Vec<FoldingRange> {
3230− let mut ranges = Vec::new();
3231− let lines: Vec<&str> = text.lines().collect();
3232− let mut i = 0;
3233− while i < lines.len() {
3234− let trimmed = lines[i].trim();
3235− // Fold blocks that start with @fn, @if, @else, @each, @match, @style, @head, @keyframes, @define
3236− if trimmed.starts_with("@fn ")
3237− || trimmed.starts_with("@if ")
3238− || trimmed == "@else"
3239− || trimmed.starts_with("@else if ")
3240− || trimmed.starts_with("@each ")
3241− || trimmed.starts_with("@match ")
3242− || trimmed == "@style"
3243− || trimmed == "@head"
3244− || trimmed.starts_with("@keyframes ")
3245− {
3246− let start_indent = lines[i].len() - lines[i].trim_start().len();
3247− let start_line = i;
3248− let mut end_line = i;
3249− let mut j = i + 1;
3250− while j < lines.len() {
3251− let l = lines[j];
3252− if l.trim().is_empty() {
3253− j += 1;
3254− continue;
3255− }
3256− let indent = l.len() - l.trim_start().len();
3257− if indent <= start_indent {
3258− break;
3259− }
3260− end_line = j;
3261− j += 1;
3262− }
3263− if end_line > start_line {
3264− ranges.push(FoldingRange {
3265− start_line: start_line as u32,
3266− start_character: None,
3267− end_line: end_line as u32,
3268− end_character: None,
3269− kind: Some(FoldingRangeKind::Region),
3270− collapsed_text: None,
3271− });
3272− }
3273− }
3274− // Fold comment blocks (lines starting with --)
3275− if trimmed.starts_with("--") {
3276− let start_line = i;
3277− let mut end_line = i;
3278− let mut j = i + 1;
3279− while j < lines.len() && lines[j].trim().starts_with("--") {
3280− end_line = j;
3281− j += 1;
3282− }
3283− if end_line > start_line {
3284− ranges.push(FoldingRange {
3285− start_line: start_line as u32,
3286− start_character: None,
3287− end_line: end_line as u32,
3288− end_character: None,
3289− kind: Some(FoldingRangeKind::Comment),
3290− collapsed_text: None,
3291− });
3292− }
3293− }
3294− i += 1;
3295− }
3296− ranges
3297−}
3298−
3299−// ---------------------------------------------------------------------------
3300−// Semantic tokens
3301−// ---------------------------------------------------------------------------
3302−
3303−fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
3304− let mut tokens = Vec::new();
3305− let mut prev_line: u32 = 0;
3306− let mut prev_start: u32 = 0;
3307−
3308− // Build set of unused variables by parsing diagnostics
3309− let result = htmlang::parser::parse(text);
3310− let mut unused_vars: std::collections::HashSet<String> = std::collections::HashSet::new();
3311− for d in &result.diagnostics {
3312− if d.message.contains("unused variable '$") {
3313− if let Some(start) = d.message.find("'$") {
3314− let rest = &d.message[start + 2..];
3315− if let Some(end) = rest.find('\'') {
3316− unused_vars.insert(rest[..end].to_string());
3317− }
3318− }
3319− }
3320− if d.message.contains("unused function '@") {
3321− if let Some(start) = d.message.find("'@") {
3322− let rest = &d.message[start + 2..];
3323− if let Some(end) = rest.find('\'') {
3324− unused_vars.insert(format!("@{}", &rest[..end]));
3325− }
3326− }
3327− }
3328− if d.message.contains("unused define '$") {
3329− if let Some(start) = d.message.find("'$") {
3330− let rest = &d.message[start + 2..];
3331− if let Some(end) = rest.find('\'') {
3332− unused_vars.insert(rest[..end].to_string());
3333− }
3334− }
3335− }
3336− if d.message.contains("unused mixin '") {
3337− if let Some(start) = d.message.find("unused mixin '") {
3338− let rest = &d.message[start + 14..];
3339− if let Some(end) = rest.find('\'') {
3340− unused_vars.insert(rest[..end].to_string());
3341− }
3342− }
3343− }
3344− }
3345−
3346− for (line_idx, line) in text.lines().enumerate() {
3347− let trimmed = line.trim();
3348− let line_num = line_idx as u32;
3349−
3350− // Detect comments
3351− if trimmed.starts_with("--") {
3352− let col = (line.len() - trimmed.len()) as u32;
3353− push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, col, trimmed.len() as u32, 4, 0);
3354− continue;
3355− }
3356−
3357− // Scan for @keywords
3358− let bytes = line.as_bytes();
3359− let mut i = 0;
3360− while i < bytes.len() {
3361− if bytes[i] == b'@' {
3362− let start = i;
3363− i += 1;
3364− while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_') {
3365− i += 1;
3366− }
3367− let word = &line[start..i];
3368− let token_type = match word {
3369− "@page" | "@let" | "@define" | "@fn" | "@if" | "@else" | "@each"
3370− | "@include" | "@import" | "@meta" | "@head" | "@style" | "@keyframes"
3371− | "@match" | "@case" | "@default" | "@slot" | "@children"
3372− | "@warn" | "@debug" | "@lang" | "@favicon" | "@fragment"
3373− | "@unless" | "@og" | "@breakpoint"
3374− | "@canonical" | "@base" | "@font-face" | "@json-ld"
3375− | "@mixin" | "@assert" | "@theme" | "@deprecated" | "@extends"
3376− | "@use" | "@data" | "@env" | "@fetch" | "@svg" | "@css-property" => 0, // keyword
3377− _ => {
3378− // Check if it's a user function call (starts with @ but not a builtin element)
3379− if is_builtin_element(word) { 0 } else { 2 } // function
3380− }
3381− };
3382− // Mark unused @fn definitions with deprecated modifier (dimmed)
3383− let modifier = if (trimmed.starts_with("@fn ") || trimmed.starts_with("@define ") || trimmed.starts_with("@mixin ")) && word != "@fn" && word != "@define" && word != "@mixin" {
3384− let name_part = &word[1..]; // strip @
3385− if unused_vars.contains(&format!("@{}", name_part)) { 1 } else { 0 }
3386− } else { 0 };
3387− push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, start as u32, (i - start) as u32, token_type, modifier);
3388− } else if bytes[i] == b'$' {
3389− let start = i;
3390− i += 1;
3391− while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_') {
3392− i += 1;
3393− }
3394− if i > start + 1 {
3395− // Check if this is an unused variable definition on a @let line
3396− let var_name = &line[start + 1..i];
3397− let modifier = if trimmed.starts_with("@let ") && unused_vars.contains(var_name) { 1 } else { 0 };
3398− push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, start as u32, (i - start) as u32, 1, modifier); // variable
3399− }
3400− } else {
3401− i += 1;
3402− }
3403− }
3404− }
3405− tokens
3406−}
3407−
3408−fn is_builtin_element(word: &str) -> bool {
3409− matches!(word,
3410− "@row" | "@column" | "@col" | "@el" | "@text" | "@paragraph" | "@p"
3411− | "@image" | "@img" | "@link" | "@input" | "@button" | "@btn"
3412− | "@select" | "@textarea" | "@option" | "@opt" | "@label" | "@raw"
3413− | "@nav" | "@header" | "@footer" | "@main" | "@section" | "@article" | "@aside"
3414− | "@list" | "@item" | "@li" | "@table" | "@thead" | "@tbody" | "@tr" | "@td" | "@th"
3415− | "@video" | "@audio" | "@form" | "@details" | "@summary"
3416− | "@blockquote" | "@cite" | "@code" | "@pre" | "@hr" | "@divider"
3417− | "@figure" | "@figcaption" | "@progress" | "@meter" | "@fragment"
3418− | "@dialog" | "@dl" | "@dt" | "@dd" | "@fieldset" | "@legend"
3419− | "@picture" | "@source" | "@time" | "@mark" | "@kbd" | "@abbr" | "@datalist"
3420− | "@script" | "@noscript" | "@address" | "@search" | "@breadcrumb"
3421− )
3422−}
3423−
3424−fn push_token(
3425− tokens: &mut Vec<SemanticToken>,
3426− prev_line: &mut u32,
3427− prev_start: &mut u32,
3428− line: u32,
3429− start: u32,
3430− length: u32,
3431− token_type: u32,
3432− token_modifiers_bitset: u32,
3433−) {
3434− let delta_line = line - *prev_line;
3435− let delta_start = if delta_line == 0 {
3436− start - *prev_start
3437− } else {
3438− start
3439− };
3440− tokens.push(SemanticToken {
3441− delta_line,
3442− delta_start,
3443− length,
3444− token_type,
3445− token_modifiers_bitset,
3446− });
3447− *prev_line = line;
3448− *prev_start = start;
3449−}
3450−
3451−// ---------------------------------------------------------------------------
3452−// Inlay hints
3453−// ---------------------------------------------------------------------------
3454−
3455−fn inlay_hints(text: &str) -> Vec<InlayHint> {
3456− // Build variable map: name -> value
3457− let mut vars: HashMap<&str, &str> = HashMap::new();
3458− for line in text.lines() {
3459− let trimmed = line.trim();
3460− if let Some(rest) = trimmed.strip_prefix("@let ") {
3461− if let Some((name, value)) = rest.trim().split_once(' ') {
3462− vars.insert(name, value.trim());
3463− }
3464− }
3465− }
3466−
3467− let mut hints = Vec::new();
3468− for (line_idx, line) in text.lines().enumerate() {
3469− let trimmed = line.trim();
3470− // Skip @let definition lines — the value is already visible there
3471− if trimmed.starts_with("@let ") {
3472− continue;
3473− }
3474− let bytes = line.as_bytes();
3475− let mut i = 0;
3476− while i < bytes.len() {
3477− if bytes[i] == b'$' {
3478− let start = i;
3479− i += 1;
3480− while i < bytes.len()
3481− && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
3482− {
3483− i += 1;
3484− }
3485− if i > start + 1 {
3486− let var_name = &line[start + 1..i];
3487− if let Some(value) = vars.get(var_name) {
3488− hints.push(InlayHint {
3489− position: Position::new(line_idx as u32, i as u32),
3490− label: InlayHintLabel::String(format!(" \u{2192} {}", value)),
3491− kind: None,
3492− text_edits: None,
3493− tooltip: None,
3494− padding_left: Some(false),
3495− padding_right: Some(true),
3496− data: None,
3497− });
3498− }
3499− }
3500− } else {
3501− i += 1;
3502− }
3503− }
3504− }
3505− hints
3506−}
3507−
3508−// ---------------------------------------------------------------------------
3509−// Linked editing ranges
3510−// ---------------------------------------------------------------------------
3511−
3512−fn linked_editing_ranges(text: &str, position: Position) -> Option<LinkedEditingRanges> {
3513− let lines: Vec<&str> = text.lines().collect();
3514− let line = lines.get(position.line as usize)?;
3515− let col = (position.character as usize).min(line.len());
3516−
3517− // Find the $variable at the cursor
3518− let bytes = line.as_bytes();
3519− let mut start = col;
3520− while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'$' || bytes[start - 1] == b'-' || bytes[start - 1] == b'_') {
3521− start -= 1;
3522− }
3523− let mut end = col;
3524− while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'$' || bytes[end] == b'-' || bytes[end] == b'_') {
3525− end += 1;
3526− }
3527− if start == end {
3528− return None;
3529− }
3530−
3531− let word = &line[start..end];
3532− if !word.starts_with('$') {
3533− return None;
3534− }
3535−
3536− // Find all occurrences of this $variable in the document
3537− let mut ranges = Vec::new();
3538− for (line_idx, line) in text.lines().enumerate() {
3539− let line_bytes = line.as_bytes();
3540− let mut offset = 0;
3541− while let Some(pos) = line[offset..].find(word) {
3542− let abs_pos = offset + pos;
3543− // Check it's a whole word match
3544− let before_ok = abs_pos == 0 || {
3545− let c = line_bytes[abs_pos - 1];
3546− !c.is_ascii_alphanumeric() && c != b'-' && c != b'_'
3547− };
3548− let after_end = abs_pos + word.len();
3549− let after_ok = after_end >= line.len() || {
3550− let c = line_bytes[after_end];
3551− !c.is_ascii_alphanumeric() && c != b'-' && c != b'_'
3552− };
3553− if before_ok && after_ok {
3554− ranges.push(Range::new(
3555− Position::new(line_idx as u32, abs_pos as u32),
3556− Position::new(line_idx as u32, after_end as u32),
3557− ));
3558− }
3559− offset = abs_pos + word.len();
3560− }
3561− }
3562−
3563− if ranges.len() < 2 {
3564− return None;
3565− }
3566−
3567− Some(LinkedEditingRanges {
3568− ranges,
3569− word_pattern: None,
3570− })
3571−}
3572−
3573−// ---------------------------------------------------------------------------
3574−// Find references
3575−// ---------------------------------------------------------------------------
3576−
3577−fn find_references(text: &str, position: Position, uri: &Url) -> Vec<Location> {
3578− let lines: Vec<&str> = text.lines().collect();
3579− let line = match lines.get(position.line as usize) {
3580− Some(l) => *l,
3581− None => return vec![],
3582− };
3583− let col = (position.character as usize).min(line.len());
3584− let bytes = line.as_bytes();
3585−
3586− // Find the word at cursor
3587− let mut start = col;
3588− while start > 0 && is_word_byte(bytes[start - 1]) {
3589− start -= 1;
3590− }
3591− let mut end = col;
3592− while end < bytes.len() && is_word_byte(bytes[end]) {
3593− end += 1;
3594− }
3595− if start == end {
3596− return vec![];
3597− }
3598− let word = &line[start..end];
3599−
3600− // Determine the search pattern
3601− let search = if word.starts_with('$') || word.starts_with('@') {
3602− word.to_string()
3603− } else if start > 0 && bytes[start - 1] == b'$' {
3604− format!("${}", word)
3605− } else if start > 0 && bytes[start - 1] == b'@' {
3606− format!("@{}", word)
3607− } else {
3608− return vec![];
3609− };
3610−
3611− let mut locations = Vec::new();
3612− for (line_idx, line_text) in text.lines().enumerate() {
3613− let mut offset = 0;
3614− while let Some(pos) = line_text[offset..].find(&search) {
3615− let abs_pos = offset + pos;
3616− let after = abs_pos + search.len();
3617− // Ensure word boundary
3618− let before_ok = abs_pos == 0 || {
3619− let c = line_text.as_bytes()[abs_pos - 1];
3620− !c.is_ascii_alphanumeric() && c != b'_' && c != b'-'
3621− };
3622− let after_ok = after >= line_text.len() || {
3623− let c = line_text.as_bytes()[after];
3624− !c.is_ascii_alphanumeric() && c != b'_' && c != b'-'
3625− };
3626− if before_ok && after_ok {
3627− locations.push(Location {
3628− uri: uri.clone(),
3629− range: Range::new(
3630− Position::new(line_idx as u32, abs_pos as u32),
3631− Position::new(line_idx as u32, after as u32),
3632− ),
3633− });
3634− }
3635− offset = after;
3636− }
3637− }
3638−
3639− locations
3640−}
3641−
3642−// ---------------------------------------------------------------------------
3643−// Signature help
3644−// ---------------------------------------------------------------------------
3645−
3646−fn get_signature_help(text: &str, position: Position) -> Option<SignatureHelp> {
3647− let lines: Vec<&str> = text.lines().collect();
3648− let line = lines.get(position.line as usize)?;
3649− let col = (position.character as usize).min(line.len());
3650− let before = &line[..col];
3651−
3652− // Check if we're inside a function call: @funcname [...
3653− let trimmed = before.trim_start();
3654− if !trimmed.starts_with('@') {
3655− return None;
3656− }
3657−
3658− // Extract the function name
3659− let after_at = &trimmed[1..];
3660− let name_end = after_at.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
3661− .unwrap_or(after_at.len());
3662− let fn_name = &after_at[..name_end];
3663−
3664− // Must be inside brackets
3665− if !in_brackets(before) {
3666− return None;
3667− }
3668−
3669− // Find the @fn definition
3670− for (line_idx, line_text) in text.lines().enumerate() {
3671− let t = line_text.trim_start();
3672− if let Some(rest) = t.strip_prefix("@fn ") {
3673− let rest = rest.trim();
3674− let def_name_end = rest.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
3675− .unwrap_or(rest.len());
3676− let def_name = &rest[..def_name_end];
3677− if def_name != fn_name {
3678− continue;
3679− }
3680− let params_str = &rest[def_name_end..].trim();
3681− let params: Vec<&str> = params_str
3682− .split_whitespace()
3683− .filter(|p| p.starts_with('$'))
3684− .collect();
3685−
3686− if params.is_empty() {
3687− return None;
3688− }
3689−
3690− let param_labels: Vec<ParameterInformation> = params.iter().map(|p| {
3691− let name = p.trim_start_matches('$');
3692− let (label, doc) = if let Some((n, default)) = name.split_once('=') {
3693− (n.to_string(), Some(format!("Default: {}", default)))
3694− } else {
3695− (name.to_string(), None)
3696− };
3697− ParameterInformation {
3698− label: ParameterLabel::Simple(label),
3699− documentation: doc.map(|d| Documentation::String(d)),
3700− }
3701− }).collect();
3702−
3703− let sig_label = format!("@{} {}", fn_name, params.join(" "));
3704−
3705− // Determine active parameter by counting commas before cursor inside brackets
3706− let bracket_start = before.rfind('[').unwrap_or(0);
3707− let inside = &before[bracket_start..];
3708− let active_param = inside.matches(',').count() as u32;
3709−
3710− return Some(SignatureHelp {
3711− signatures: vec![SignatureInformation {
3712− label: sig_label,
3713− documentation: Some(Documentation::String(
3714− format!("Defined at line {}", line_idx + 1),
3715− )),
3716− parameters: Some(param_labels),
3717− active_parameter: Some(active_param),
3718− }],
3719− active_signature: Some(0),
3720− active_parameter: Some(active_param),
3721− });
3722− }
3723− }
3724− None
3725−}
3726−
3727−// ---------------------------------------------------------------------------
3728−// Entry point
3729−// ---------------------------------------------------------------------------
3730−
3731−#[tokio::main]
3732−async fn main() {
3733− let stdin = tokio::io::stdin();
3734− let stdout = tokio::io::stdout();
3735− let (service, socket) = LspService::new(|client| Backend {
3736− client,
3737− documents: Arc::new(RwLock::new(HashMap::new())),
3738− });
3739− Server::new(stdin, stdout, socket).serve(service).await;
3740−}
src/bin/htmlang_lsp/analysis.rsnew file+1193
@@ -0,0 +1,1193 @@
1+use std::collections::HashMap;
2+
3+use tower_lsp::lsp_types::*;
4+
5+use crate::completion::in_brackets;
6+
7+// ---------------------------------------------------------------------------
8+// Document symbols (outline view)
9+// ---------------------------------------------------------------------------
10+
11+#[allow(deprecated)] // SymbolInformation::deprecated is deprecated but needed for the struct
12+pub(crate) fn document_symbols(text: &str) -> Vec<SymbolInformation> {
13+ let mut symbols = Vec::new();
14+
15+ for (i, line) in text.lines().enumerate() {
16+ let trimmed = line.trim();
17+ let line_num = i as u32;
18+
19+ // @fn definitions
20+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
21+ let parts: Vec<&str> = rest.split_whitespace().collect();
22+ if let Some(name) = parts.first() {
23+ let params = parts[1..].join(" ");
24+ let detail = if params.is_empty() { None } else { Some(format!("({})", params)) };
25+ symbols.push(SymbolInformation {
26+ name: format!("@{}", name),
27+ kind: SymbolKind::FUNCTION,
28+ tags: None,
29+ deprecated: None,
30+ location: Location {
31+ uri: Url::parse("file:///").unwrap(), // replaced by caller
32+ range: Range::new(Position::new(line_num, 0), Position::new(line_num, line.len() as u32)),
33+ },
34+ container_name: detail,
35+ });
36+ }
37+ }
38+
39+ // @let definitions
40+ if let Some(rest) = trimmed.strip_prefix("@let ") {
41+ if let Some((name, value)) = rest.trim().split_once(' ') {
42+ symbols.push(SymbolInformation {
43+ name: format!("${}", name),
44+ kind: SymbolKind::VARIABLE,
45+ tags: None,
46+ deprecated: None,
47+ location: Location {
48+ uri: Url::parse("file:///").unwrap(),
49+ range: Range::new(Position::new(line_num, 0), Position::new(line_num, line.len() as u32)),
50+ },
51+ container_name: Some(format!("= {}", value.trim())),
52+ });
53+ }
54+ }
55+
56+ // @define definitions
57+ if let Some(rest) = trimmed.strip_prefix("@define ") {
58+ if let Some(bracket) = rest.find('[') {
59+ let name = rest[..bracket].trim();
60+ symbols.push(SymbolInformation {
61+ name: format!("${}", name),
62+ kind: SymbolKind::CONSTANT,
63+ tags: None,
64+ deprecated: None,
65+ location: Location {
66+ uri: Url::parse("file:///").unwrap(),
67+ range: Range::new(Position::new(line_num, 0), Position::new(line_num, line.len() as u32)),
68+ },
69+ container_name: Some("attribute bundle".to_string()),
70+ });
71+ }
72+ }
73+
74+ // @keyframes definitions
75+ if let Some(rest) = trimmed.strip_prefix("@keyframes ") {
76+ let name = rest.trim();
77+ if !name.is_empty() {
78+ symbols.push(SymbolInformation {
79+ name: format!("@keyframes {}", name),
80+ kind: SymbolKind::EVENT,
81+ tags: None,
82+ deprecated: None,
83+ location: Location {
84+ uri: Url::parse("file:///").unwrap(),
85+ range: Range::new(Position::new(line_num, 0), Position::new(line_num, line.len() as u32)),
86+ },
87+ container_name: Some("animation".to_string()),
88+ });
89+ }
90+ }
91+ }
92+
93+ symbols
94+}
95+
96+// ---------------------------------------------------------------------------
97+// Code actions (quick-fixes for typo suggestions)
98+// ---------------------------------------------------------------------------
99+
100+pub(crate) fn code_actions(
101+ text: &str,
102+ selection: &Range,
103+ diagnostics: &[Diagnostic],
104+ uri: &Url,
105+) -> Vec<CodeActionOrCommand> {
106+ let mut actions = Vec::new();
107+
108+ for diag in diagnostics {
109+ let msg = &diag.message;
110+
111+ // Extract "did you mean 'X'?" or "did you mean @X?" suggestions
112+ if let Some(suggestion) = extract_suggestion(msg) {
113+ let line = diag.range.start.line as usize;
114+ let lines: Vec<&str> = text.lines().collect();
115+ if let Some(source_line) = lines.get(line) {
116+ // Determine what to replace
117+ let (old_text, new_text) = if msg.contains("unknown element") {
118+ // Replace @wrong with @suggestion
119+ let old = extract_between(msg, "unknown element @", ",")
120+ .or_else(|| extract_between(msg, "unknown element @", ""));
121+ if let Some(old) = old {
122+ (format!("@{}", old), format!("@{}", suggestion))
123+ } else {
124+ continue;
125+ }
126+ } else if msg.contains("unknown attribute") {
127+ // Replace wrong with suggestion in attribute list
128+ let old = extract_between(msg, "unknown attribute '", "'");
129+ if let Some(old) = old {
130+ (old.to_string(), suggestion.to_string())
131+ } else {
132+ continue;
133+ }
134+ } else {
135+ continue;
136+ };
137+
138+ if let Some(col) = source_line.find(&old_text) {
139+ let edit = TextEdit {
140+ range: Range::new(
141+ Position::new(diag.range.start.line, col as u32),
142+ Position::new(diag.range.start.line, (col + old_text.len()) as u32),
143+ ),
144+ new_text: new_text.clone(),
145+ };
146+
147+ let mut changes = HashMap::new();
148+ changes.insert(uri.clone(), vec![edit]);
149+
150+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
151+ title: format!("Replace with '{}'", new_text),
152+ kind: Some(CodeActionKind::QUICKFIX),
153+ diagnostics: Some(vec![diag.clone()]),
154+ edit: Some(WorkspaceEdit {
155+ changes: Some(changes),
156+ ..Default::default()
157+ }),
158+ ..Default::default()
159+ }));
160+ }
161+ }
162+ }
163+
164+ // Quick-fix: remove unused @let variable
165+ if msg.contains("unused variable") {
166+ let line = diag.range.start.line as usize;
167+ let lines: Vec<&str> = text.lines().collect();
168+ if let Some(source_line) = lines.get(line) {
169+ let trimmed = source_line.trim_start();
170+ if trimmed.starts_with("@let ") {
171+ let var_name = trimmed.strip_prefix("@let ")
172+ .and_then(|r| r.trim().split_whitespace().next())
173+ .unwrap_or("?");
174+ let edit = TextEdit {
175+ range: Range::new(
176+ Position::new(diag.range.start.line, 0),
177+ Position::new(diag.range.start.line + 1, 0),
178+ ),
179+ new_text: String::new(),
180+ };
181+ let mut changes = HashMap::new();
182+ changes.insert(uri.clone(), vec![edit]);
183+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
184+ title: format!("Remove unused variable '${}'", var_name),
185+ kind: Some(CodeActionKind::QUICKFIX),
186+ diagnostics: Some(vec![diag.clone()]),
187+ edit: Some(WorkspaceEdit {
188+ changes: Some(changes),
189+ ..Default::default()
190+ }),
191+ ..Default::default()
192+ }));
193+ }
194+ }
195+ }
196+
197+ // Quick-fix: remove unused @define
198+ if msg.contains("unused define") {
199+ let line = diag.range.start.line as usize;
200+ let lines: Vec<&str> = text.lines().collect();
201+ if let Some(source_line) = lines.get(line) {
202+ let trimmed = source_line.trim_start();
203+ if trimmed.starts_with("@define ") {
204+ let def_name = trimmed.strip_prefix("@define ")
205+ .and_then(|r| {
206+ let r = r.trim();
207+ r.find('[').map(|b| r[..b].trim()).or_else(|| r.split_whitespace().next())
208+ })
209+ .unwrap_or("?");
210+ let edit = TextEdit {
211+ range: Range::new(
212+ Position::new(diag.range.start.line, 0),
213+ Position::new(diag.range.start.line + 1, 0),
214+ ),
215+ new_text: String::new(),
216+ };
217+ let mut changes = HashMap::new();
218+ changes.insert(uri.clone(), vec![edit]);
219+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
220+ title: format!("Remove unused define '${}'", def_name),
221+ kind: Some(CodeActionKind::QUICKFIX),
222+ diagnostics: Some(vec![diag.clone()]),
223+ edit: Some(WorkspaceEdit {
224+ changes: Some(changes),
225+ ..Default::default()
226+ }),
227+ ..Default::default()
228+ }));
229+ }
230+ }
231+ }
232+
233+ // Quick-fix: remove unused @fn function (remove definition and its body)
234+ if msg.contains("unused function") {
235+ let line = diag.range.start.line as usize;
236+ let lines: Vec<&str> = text.lines().collect();
237+ if let Some(source_line) = lines.get(line) {
238+ let trimmed = source_line.trim_start();
239+ if trimmed.starts_with("@fn ") {
240+ let fn_name = trimmed.strip_prefix("@fn ")
241+ .and_then(|r| r.split_whitespace().next())
242+ .unwrap_or("?");
243+ // Find the end of the function body (indented lines below)
244+ let start_indent = source_line.len() - trimmed.len();
245+ let mut end_line = line;
246+ let mut j = line + 1;
247+ while j < lines.len() {
248+ let l = lines[j];
249+ if l.trim().is_empty() {
250+ j += 1;
251+ continue;
252+ }
253+ let indent = l.len() - l.trim_start().len();
254+ if indent <= start_indent {
255+ break;
256+ }
257+ end_line = j;
258+ j += 1;
259+ }
260+ let edit = TextEdit {
261+ range: Range::new(
262+ Position::new(diag.range.start.line, 0),
263+ Position::new(end_line as u32 + 1, 0),
264+ ),
265+ new_text: String::new(),
266+ };
267+ let mut changes = HashMap::new();
268+ changes.insert(uri.clone(), vec![edit]);
269+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
270+ title: format!("Remove unused function '@{}'", fn_name),
271+ kind: Some(CodeActionKind::QUICKFIX),
272+ diagnostics: Some(vec![diag.clone()]),
273+ edit: Some(WorkspaceEdit {
274+ changes: Some(changes),
275+ ..Default::default()
276+ }),
277+ ..Default::default()
278+ }));
279+ }
280+ }
281+ }
282+
283+ // Quick-fix: remove unused @mixin
284+ if msg.contains("unused mixin") {
285+ let line = diag.range.start.line as usize;
286+ let lines: Vec<&str> = text.lines().collect();
287+ if let Some(source_line) = lines.get(line) {
288+ let trimmed = source_line.trim_start();
289+ if trimmed.starts_with("@mixin ") {
290+ let mixin_name = trimmed.strip_prefix("@mixin ")
291+ .and_then(|r| {
292+ let r = r.trim();
293+ r.find('[').map(|b| r[..b].trim()).or_else(|| r.split_whitespace().next())
294+ })
295+ .unwrap_or("?");
296+ let edit = TextEdit {
297+ range: Range::new(
298+ Position::new(diag.range.start.line, 0),
299+ Position::new(diag.range.start.line + 1, 0),
300+ ),
301+ new_text: String::new(),
302+ };
303+ let mut changes = HashMap::new();
304+ changes.insert(uri.clone(), vec![edit]);
305+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
306+ title: format!("Remove unused mixin '{}'", mixin_name),
307+ kind: Some(CodeActionKind::QUICKFIX),
308+ diagnostics: Some(vec![diag.clone()]),
309+ edit: Some(WorkspaceEdit {
310+ changes: Some(changes),
311+ ..Default::default()
312+ }),
313+ ..Default::default()
314+ }));
315+ }
316+ }
317+ }
318+
319+ // Quick-fix: add alt attribute to @image
320+ if msg.contains("@image should have") && msg.contains("alt") {
321+ let line = diag.range.start.line as usize;
322+ let lines: Vec<&str> = text.lines().collect();
323+ if let Some(source_line) = lines.get(line) {
324+ if let Some(bracket_pos) = source_line.find('[') {
325+ let insert_pos = bracket_pos + 1;
326+ let edit = TextEdit {
327+ range: Range::new(
328+ Position::new(diag.range.start.line, insert_pos as u32),
329+ Position::new(diag.range.start.line, insert_pos as u32),
330+ ),
331+ new_text: "alt , ".into(),
332+ };
333+ let mut changes = HashMap::new();
334+ changes.insert(uri.clone(), vec![edit]);
335+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
336+ title: "Add alt attribute".into(),
337+ kind: Some(CodeActionKind::QUICKFIX),
338+ diagnostics: Some(vec![diag.clone()]),
339+ edit: Some(WorkspaceEdit {
340+ changes: Some(changes),
341+ ..Default::default()
342+ }),
343+ ..Default::default()
344+ }));
345+ } else if source_line.contains("@image") {
346+ // No brackets yet, add them
347+ if let Some(img_pos) = source_line.find("@image") {
348+ let after_image = img_pos + "@image".len();
349+ let edit = TextEdit {
350+ range: Range::new(
351+ Position::new(diag.range.start.line, after_image as u32),
352+ Position::new(diag.range.start.line, after_image as u32),
353+ ),
354+ new_text: " [alt ]".into(),
355+ };
356+ let mut changes = HashMap::new();
357+ changes.insert(uri.clone(), vec![edit]);
358+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
359+ title: "Add alt attribute".into(),
360+ kind: Some(CodeActionKind::QUICKFIX),
361+ diagnostics: Some(vec![diag.clone()]),
362+ edit: Some(WorkspaceEdit {
363+ changes: Some(changes),
364+ ..Default::default()
365+ }),
366+ ..Default::default()
367+ }));
368+ }
369+ }
370+ }
371+ }
372+
373+ // Quick-fix: add missing type to @input
374+ if msg.contains("@input missing 'type'") {
375+ let line = diag.range.start.line as usize;
376+ let lines: Vec<&str> = text.lines().collect();
377+ if let Some(source_line) = lines.get(line) {
378+ if let Some(bracket_pos) = source_line.find('[') {
379+ let insert_pos = bracket_pos + 1;
380+ let edit = TextEdit {
381+ range: Range::new(
382+ Position::new(diag.range.start.line, insert_pos as u32),
383+ Position::new(diag.range.start.line, insert_pos as u32),
384+ ),
385+ new_text: "type text, ".into(),
386+ };
387+ let mut changes = HashMap::new();
388+ changes.insert(uri.clone(), vec![edit]);
389+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
390+ title: "Add type=\"text\" attribute".into(),
391+ kind: Some(CodeActionKind::QUICKFIX),
392+ diagnostics: Some(vec![diag.clone()]),
393+ edit: Some(WorkspaceEdit {
394+ changes: Some(changes),
395+ ..Default::default()
396+ }),
397+ ..Default::default()
398+ }));
399+ } else if source_line.contains("@input") {
400+ if let Some(pos) = source_line.find("@input") {
401+ let after = pos + "@input".len();
402+ let edit = TextEdit {
403+ range: Range::new(
404+ Position::new(diag.range.start.line, after as u32),
405+ Position::new(diag.range.start.line, after as u32),
406+ ),
407+ new_text: " [type text]".into(),
408+ };
409+ let mut changes = HashMap::new();
410+ changes.insert(uri.clone(), vec![edit]);
411+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
412+ title: "Add type=\"text\" attribute".into(),
413+ kind: Some(CodeActionKind::QUICKFIX),
414+ diagnostics: Some(vec![diag.clone()]),
415+ edit: Some(WorkspaceEdit {
416+ changes: Some(changes),
417+ ..Default::default()
418+ }),
419+ ..Default::default()
420+ }));
421+ }
422+ }
423+ }
424+ }
425+
426+ // Quick-fix: low contrast ratio — suggest swapping to a high-contrast pair
427+ if msg.contains("low contrast ratio") {
428+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
429+ title: "Acknowledged: low contrast ratio".into(),
430+ kind: Some(CodeActionKind::QUICKFIX),
431+ diagnostics: Some(vec![diag.clone()]),
432+ is_preferred: Some(false),
433+ ..Default::default()
434+ }));
435+ }
436+
437+ // Quick-fix: auto-import suggestion for unknown element @name
438+ // Searches current directory and subdirectories for @fn definitions
439+ if msg.contains("unknown element @") {
440+ if let Some(fn_name) = extract_between(msg, "unknown element @", ",")
441+ .or_else(|| extract_between(msg, "unknown element @", ""))
442+ {
443+ let fn_name = fn_name.trim();
444+ if !fn_name.is_empty() {
445+ if let Ok(file_path) = uri.to_file_path() {
446+ if let Some(dir) = file_path.parent() {
447+ // Search current dir and subdirs for .hl files defining this function
448+ let mut search_dirs = vec![dir.to_path_buf()];
449+ // Also search parent dir's subdirs (for project-wide imports)
450+ if let Some(parent) = dir.parent() {
451+ if let Ok(entries) = std::fs::read_dir(parent) {
452+ for entry in entries.flatten() {
453+ let p = entry.path();
454+ if p.is_dir() && p != dir {
455+ search_dirs.push(p);
456+ }
457+ }
458+ }
459+ }
460+ for search_dir in &search_dirs {
461+ if let Ok(entries) = std::fs::read_dir(search_dir) {
462+ for entry in entries.flatten() {
463+ let path = entry.path();
464+ if path.extension().and_then(|e| e.to_str()) != Some("hl") {
465+ continue;
466+ }
467+ if path == file_path {
468+ continue;
469+ }
470+ if let Ok(content) = std::fs::read_to_string(&path) {
471+ let defines_fn = content.lines().any(|l| {
472+ let t = l.trim();
473+ if let Some(rest) = t.strip_prefix("@fn ") {
474+ rest.split_whitespace().next() == Some(fn_name)
475+ } else {
476+ false
477+ }
478+ });
479+ if defines_fn {
480+ // Compute relative path from current file's dir
481+ let rel = path.strip_prefix(dir)
482+ .map(|p| p.display().to_string())
483+ .unwrap_or_else(|_| {
484+ path.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string()
485+ });
486+ let already_imported = text.lines().any(|l| {
487+ let t = l.trim();
488+ t == format!("@import {}", rel)
489+ || t == format!("@include {}", rel)
490+ });
491+ if !already_imported {
492+ // Offer @import (all definitions)
493+ let import_line = format!("@import {}\n", rel);
494+ let edit = TextEdit {
495+ range: Range::new(
496+ Position::new(0, 0),
497+ Position::new(0, 0),
498+ ),
499+ new_text: import_line,
500+ };
501+ let mut changes = HashMap::new();
502+ changes.insert(uri.clone(), vec![edit]);
503+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
504+ title: format!("Add '@import {}' for @{}", rel, fn_name),
505+ kind: Some(CodeActionKind::QUICKFIX),
506+ diagnostics: Some(vec![diag.clone()]),
507+ edit: Some(WorkspaceEdit {
508+ changes: Some(changes),
509+ ..Default::default()
510+ }),
511+ ..Default::default()
512+ }));
513+
514+ // Also offer @use (selective import)
515+ let use_line = format!("@use \"{}\" {}\n", rel, fn_name);
516+ let use_edit = TextEdit {
517+ range: Range::new(
518+ Position::new(0, 0),
519+ Position::new(0, 0),
520+ ),
521+ new_text: use_line,
522+ };
523+ let mut use_changes = HashMap::new();
524+ use_changes.insert(uri.clone(), vec![use_edit]);
525+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
526+ title: format!("Add '@use \"{}\" {}' (selective)", rel, fn_name),
527+ kind: Some(CodeActionKind::QUICKFIX),
528+ diagnostics: Some(vec![diag.clone()]),
529+ edit: Some(WorkspaceEdit {
530+ changes: Some(use_changes),
531+ ..Default::default()
532+ }),
533+ ..Default::default()
534+ }));
535+ }
536+ }
537+ }
538+ }
539+ }
540+ }
541+ }
542+ }
543+ }
544+ }
545+ }
546+ }
547+
548+ // Refactoring: extract selection to @fn
549+ if selection.start.line != selection.end.line {
550+ let lines: Vec<&str> = text.lines().collect();
551+ let start_line = selection.start.line as usize;
552+ let end_line = (selection.end.line as usize).min(lines.len().saturating_sub(1));
553+ if start_line < lines.len() && end_line < lines.len() {
554+ // Collect selected lines
555+ let selected: Vec<&str> = lines[start_line..=end_line].to_vec();
556+ if !selected.is_empty() {
557+ // Determine the minimum indentation of selected lines (ignoring blank lines)
558+ let min_indent = selected.iter()
559+ .filter(|l| !l.trim().is_empty())
560+ .map(|l| l.len() - l.trim_start().len())
561+ .min()
562+ .unwrap_or(0);
563+
564+ // Build the function body with two-space indentation relative to @fn
565+ let fn_body: String = selected.iter()
566+ .map(|l| {
567+ if l.trim().is_empty() {
568+ String::from("\n")
569+ } else {
570+ let stripped = if l.len() > min_indent { &l[min_indent..] } else { l.trim_start() };
571+ format!(" {}\n", stripped)
572+ }
573+ })
574+ .collect();
575+
576+ let fn_def = format!("@fn extracted\n{}", fn_body);
577+ let indent = " ".repeat(min_indent);
578+ let fn_call = format!("{}@extracted", indent);
579+
580+ // Build edits: replace selected lines with @extracted call, and insert @fn definition at top
581+ let replace_edit = TextEdit {
582+ range: Range::new(
583+ Position::new(selection.start.line, 0),
584+ Position::new(selection.end.line + 1, 0),
585+ ),
586+ new_text: format!("{}\n", fn_call),
587+ };
588+ let insert_edit = TextEdit {
589+ range: Range::new(Position::new(0, 0), Position::new(0, 0)),
590+ new_text: format!("{}\n", fn_def),
591+ };
592+ let mut changes = HashMap::new();
593+ changes.insert(uri.clone(), vec![insert_edit, replace_edit]);
594+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
595+ title: "Extract to @fn".into(),
596+ kind: Some(CodeActionKind::REFACTOR_EXTRACT),
597+ diagnostics: None,
598+ edit: Some(WorkspaceEdit {
599+ changes: Some(changes),
600+ ..Default::default()
601+ }),
602+ ..Default::default()
603+ }));
604+ }
605+ }
606+ }
607+
608+ // Refactoring: extract attributes to @define
609+ // Works on a single line with [attrs] — extracts attrs into a @define
610+ {
611+ let lines: Vec<&str> = text.lines().collect();
612+ let line_idx = selection.start.line as usize;
613+ if line_idx < lines.len() {
614+ let line = lines[line_idx];
615+ if let Some(bracket_start) = line.find('[') {
616+ if let Some(bracket_end) = line[bracket_start..].find(']') {
617+ let attrs_str = &line[bracket_start + 1..bracket_start + bracket_end];
618+ // Only offer if there are at least 2 attributes
619+ let attr_count = attrs_str.split(',').count();
620+ if attr_count >= 2 {
621+ let define_name = "extracted-style";
622+ let define_line = format!("@define {} [{}]\n", define_name, attrs_str.trim());
623+ let indent = " ".repeat(line.len() - line.trim_start().len());
624+
625+ // Replace [attrs] with [$extracted-style]
626+ let new_line = format!(
627+ "{}{}[${}]{}",
628+ indent,
629+ &line.trim_start()[..line.trim_start().find('[').unwrap_or(0)],
630+ define_name,
631+ &line[bracket_start + bracket_end + 1..]
632+ );
633+
634+ let replace_edit = TextEdit {
635+ range: Range::new(
636+ Position::new(line_idx as u32, 0),
637+ Position::new(line_idx as u32, line.len() as u32),
638+ ),
639+ new_text: new_line,
640+ };
641+ let insert_edit = TextEdit {
642+ range: Range::new(Position::new(0, 0), Position::new(0, 0)),
643+ new_text: define_line,
644+ };
645+ let mut changes = HashMap::new();
646+ changes.insert(uri.clone(), vec![insert_edit, replace_edit]);
647+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
648+ title: "Extract to @define".into(),
649+ kind: Some(CodeActionKind::REFACTOR_EXTRACT),
650+ diagnostics: None,
651+ edit: Some(WorkspaceEdit {
652+ changes: Some(changes),
653+ ..Default::default()
654+ }),
655+ ..Default::default()
656+ }));
657+ }
658+ }
659+ }
660+ }
661+ }
662+
663+ actions
664+}
665+
666+fn extract_suggestion(msg: &str) -> Option<&str> {
667+ // "did you mean @X?" or "did you mean 'X'?"
668+ if let Some(idx) = msg.find("did you mean @") {
669+ let start = idx + "did you mean @".len();
670+ let rest = &msg[start..];
671+ let end = rest.find('?').unwrap_or(rest.len());
672+ return Some(&rest[..end]);
673+ }
674+ if let Some(idx) = msg.find("did you mean '") {
675+ let start = idx + "did you mean '".len();
676+ let rest = &msg[start..];
677+ let end = rest.find('\'')?;
678+ return Some(&rest[..end]);
679+ }
680+ None
681+}
682+
683+fn extract_between<'a>(msg: &'a str, prefix: &str, suffix: &str) -> Option<&'a str> {
684+ let start = msg.find(prefix)? + prefix.len();
685+ let rest = &msg[start..];
686+ if suffix.is_empty() {
687+ Some(rest.trim())
688+ } else {
689+ let end = rest.find(suffix)?;
690+ Some(&rest[..end])
691+ }
692+}
693+
694+// ---------------------------------------------------------------------------
695+// Color provider
696+// ---------------------------------------------------------------------------
697+
698+pub(crate) fn find_colors(text: &str) -> Vec<ColorInformation> {
699+ let mut colors = Vec::new();
700+ for (line_idx, line) in text.lines().enumerate() {
701+ let mut start = 0;
702+ // Detect hex colors (#fff, #ffffff, #ffffffff)
703+ while let Some(pos) = line[start..].find('#') {
704+ let abs_pos = start + pos;
705+ let hex_start = abs_pos + 1;
706+ let hex_end = line[hex_start..]
707+ .find(|c: char| !c.is_ascii_hexdigit())
708+ .map(|p| hex_start + p)
709+ .unwrap_or(line.len());
710+ let hex = &line[hex_start..hex_end];
711+ let len = hex.len();
712+ if len == 3 || len == 6 || len == 8 {
713+ if let Some((r, g, b, a)) = parse_hex_color(hex) {
714+ colors.push(ColorInformation {
715+ range: Range::new(
716+ Position::new(line_idx as u32, abs_pos as u32),
717+ Position::new(line_idx as u32, hex_end as u32),
718+ ),
719+ color: Color {
720+ red: r as f32 / 255.0,
721+ green: g as f32 / 255.0,
722+ blue: b as f32 / 255.0,
723+ alpha: a as f32 / 255.0,
724+ },
725+ });
726+ }
727+ }
728+ start = hex_end;
729+ }
730+ // Detect named CSS colors
731+ for &(name, r, g, b) in NAMED_CSS_COLORS {
732+ let lower_line = line.to_lowercase();
733+ let mut search_start = 0;
734+ while let Some(pos) = lower_line[search_start..].find(name) {
735+ let abs = search_start + pos;
736+ let end = abs + name.len();
737+ // Ensure it's a word boundary (not inside another word)
738+ let before_ok = abs == 0 || !line.as_bytes()[abs - 1].is_ascii_alphanumeric();
739+ let after_ok = end >= line.len() || !line.as_bytes()[end].is_ascii_alphanumeric();
740+ if before_ok && after_ok {
741+ colors.push(ColorInformation {
742+ range: Range::new(
743+ Position::new(line_idx as u32, abs as u32),
744+ Position::new(line_idx as u32, end as u32),
745+ ),
746+ color: Color {
747+ red: r as f32 / 255.0,
748+ green: g as f32 / 255.0,
749+ blue: b as f32 / 255.0,
750+ alpha: 1.0,
751+ },
752+ });
753+ }
754+ search_start = end;
755+ }
756+ }
757+ }
758+ colors
759+}
760+
761+/// Named CSS colors: (name, r, g, b)
762+const NAMED_CSS_COLORS: &[(&str, u8, u8, u8)] = &[
763+ ("red", 255, 0, 0), ("green", 0, 128, 0), ("blue", 0, 0, 255),
764+ ("white", 255, 255, 255), ("black", 0, 0, 0),
765+ ("orange", 255, 165, 0), ("yellow", 255, 255, 0), ("purple", 128, 0, 128),
766+ ("pink", 255, 192, 203), ("gray", 128, 128, 128), ("grey", 128, 128, 128),
767+ ("navy", 0, 0, 128), ("teal", 0, 128, 128), ("maroon", 128, 0, 0),
768+ ("aqua", 0, 255, 255), ("cyan", 0, 255, 255), ("fuchsia", 255, 0, 255),
769+ ("magenta", 255, 0, 255), ("lime", 0, 255, 0), ("olive", 128, 128, 0),
770+ ("silver", 192, 192, 192), ("coral", 255, 127, 80), ("salmon", 250, 128, 114),
771+ ("tomato", 255, 99, 71), ("gold", 255, 215, 0), ("khaki", 240, 230, 140),
772+ ("violet", 238, 130, 238), ("indigo", 75, 0, 130), ("crimson", 220, 20, 60),
773+ ("turquoise", 64, 224, 208), ("plum", 221, 160, 221), ("orchid", 218, 112, 214),
774+ ("sienna", 160, 82, 45), ("tan", 210, 180, 140), ("peru", 205, 133, 63),
775+ ("chocolate", 210, 105, 30), ("firebrick", 178, 34, 34),
776+ ("darkred", 139, 0, 0), ("darkgreen", 0, 100, 0), ("darkblue", 0, 0, 139),
777+ ("darkgray", 169, 169, 169), ("darkgrey", 169, 169, 169),
778+ ("lightgray", 211, 211, 211), ("lightgrey", 211, 211, 211),
779+ ("lightblue", 173, 216, 230), ("lightgreen", 144, 238, 144),
780+ ("lightyellow", 255, 255, 224), ("lightcoral", 240, 128, 128),
781+ ("lightpink", 255, 182, 193), ("lightsalmon", 255, 160, 122),
782+ ("steelblue", 70, 130, 180), ("royalblue", 65, 105, 225),
783+ ("dodgerblue", 30, 144, 255), ("deepskyblue", 0, 191, 255),
784+ ("cornflowerblue", 100, 149, 237), ("midnightblue", 25, 25, 112),
785+ ("slateblue", 106, 90, 205), ("mediumblue", 0, 0, 205),
786+ ("springgreen", 0, 255, 127), ("limegreen", 50, 205, 50),
787+ ("forestgreen", 34, 139, 34), ("seagreen", 46, 139, 87),
788+ ("darkslategray", 47, 79, 79), ("darkslategrey", 47, 79, 79),
789+ ("cadetblue", 95, 158, 160), ("mediumaquamarine", 102, 205, 170),
790+ ("darkorange", 255, 140, 0), ("orangered", 255, 69, 0),
791+ ("deeppink", 255, 20, 147), ("hotpink", 255, 105, 180),
792+ ("mediumvioletred", 199, 21, 133), ("palevioletred", 219, 112, 147),
793+ ("sandybrown", 244, 164, 96), ("goldenrod", 218, 165, 32),
794+ ("darkgoldenrod", 184, 134, 11), ("saddlebrown", 139, 69, 19),
795+ ("wheat", 245, 222, 179), ("beige", 245, 245, 220),
796+ ("linen", 250, 240, 230), ("ivory", 255, 255, 240),
797+ ("snow", 255, 250, 250), ("honeydew", 240, 255, 240),
798+ ("azure", 240, 255, 255), ("lavender", 230, 230, 250),
799+ ("mistyrose", 255, 228, 225), ("seashell", 255, 245, 238),
800+];
801+
802+fn parse_hex_color(hex: &str) -> Option<(u8, u8, u8, u8)> {
803+ match hex.len() {
804+ 3 => {
805+ let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17;
806+ let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17;
807+ let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17;
808+ Some((r, g, b, 255))
809+ }
810+ 6 => {
811+ let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
812+ let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
813+ let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
814+ Some((r, g, b, 255))
815+ }
816+ 8 => {
817+ let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
818+ let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
819+ let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
820+ let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
821+ Some((r, g, b, a))
822+ }
823+ _ => None,
824+ }
825+}
826+
827+// ---------------------------------------------------------------------------
828+// Folding ranges
829+// ---------------------------------------------------------------------------
830+
831+pub(crate) fn folding_ranges(text: &str) -> Vec<FoldingRange> {
832+ let mut ranges = Vec::new();
833+ let lines: Vec<&str> = text.lines().collect();
834+ let mut i = 0;
835+ while i < lines.len() {
836+ let trimmed = lines[i].trim();
837+ // Fold blocks that start with @fn, @if, @else, @each, @match, @style, @head, @keyframes, @define
838+ if trimmed.starts_with("@fn ")
839+ || trimmed.starts_with("@if ")
840+ || trimmed == "@else"
841+ || trimmed.starts_with("@else if ")
842+ || trimmed.starts_with("@each ")
843+ || trimmed.starts_with("@match ")
844+ || trimmed == "@style"
845+ || trimmed == "@head"
846+ || trimmed.starts_with("@keyframes ")
847+ {
848+ let start_indent = lines[i].len() - lines[i].trim_start().len();
849+ let start_line = i;
850+ let mut end_line = i;
851+ let mut j = i + 1;
852+ while j < lines.len() {
853+ let l = lines[j];
854+ if l.trim().is_empty() {
855+ j += 1;
856+ continue;
857+ }
858+ let indent = l.len() - l.trim_start().len();
859+ if indent <= start_indent {
860+ break;
861+ }
862+ end_line = j;
863+ j += 1;
864+ }
865+ if end_line > start_line {
866+ ranges.push(FoldingRange {
867+ start_line: start_line as u32,
868+ start_character: None,
869+ end_line: end_line as u32,
870+ end_character: None,
871+ kind: Some(FoldingRangeKind::Region),
872+ collapsed_text: None,
873+ });
874+ }
875+ }
876+ // Fold comment blocks (lines starting with --)
877+ if trimmed.starts_with("--") {
878+ let start_line = i;
879+ let mut end_line = i;
880+ let mut j = i + 1;
881+ while j < lines.len() && lines[j].trim().starts_with("--") {
882+ end_line = j;
883+ j += 1;
884+ }
885+ if end_line > start_line {
886+ ranges.push(FoldingRange {
887+ start_line: start_line as u32,
888+ start_character: None,
889+ end_line: end_line as u32,
890+ end_character: None,
891+ kind: Some(FoldingRangeKind::Comment),
892+ collapsed_text: None,
893+ });
894+ }
895+ }
896+ i += 1;
897+ }
898+ ranges
899+}
900+
901+// ---------------------------------------------------------------------------
902+// Semantic tokens
903+// ---------------------------------------------------------------------------
904+
905+pub(crate) fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
906+ let mut tokens = Vec::new();
907+ let mut prev_line: u32 = 0;
908+ let mut prev_start: u32 = 0;
909+
910+ // Build set of unused variables by parsing diagnostics
911+ let result = htmlang::parser::parse(text);
912+ let mut unused_vars: std::collections::HashSet<String> = std::collections::HashSet::new();
913+ for d in &result.diagnostics {
914+ if d.message.contains("unused variable '$") {
915+ if let Some(start) = d.message.find("'$") {
916+ let rest = &d.message[start + 2..];
917+ if let Some(end) = rest.find('\'') {
918+ unused_vars.insert(rest[..end].to_string());
919+ }
920+ }
921+ }
922+ if d.message.contains("unused function '@") {
923+ if let Some(start) = d.message.find("'@") {
924+ let rest = &d.message[start + 2..];
925+ if let Some(end) = rest.find('\'') {
926+ unused_vars.insert(format!("@{}", &rest[..end]));
927+ }
928+ }
929+ }
930+ if d.message.contains("unused define '$") {
931+ if let Some(start) = d.message.find("'$") {
932+ let rest = &d.message[start + 2..];
933+ if let Some(end) = rest.find('\'') {
934+ unused_vars.insert(rest[..end].to_string());
935+ }
936+ }
937+ }
938+ if d.message.contains("unused mixin '") {
939+ if let Some(start) = d.message.find("unused mixin '") {
940+ let rest = &d.message[start + 14..];
941+ if let Some(end) = rest.find('\'') {
942+ unused_vars.insert(rest[..end].to_string());
943+ }
944+ }
945+ }
946+ }
947+
948+ for (line_idx, line) in text.lines().enumerate() {
949+ let trimmed = line.trim();
950+ let line_num = line_idx as u32;
951+
952+ // Detect comments
953+ if trimmed.starts_with("--") {
954+ let col = (line.len() - trimmed.len()) as u32;
955+ push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, col, trimmed.len() as u32, 4, 0);
956+ continue;
957+ }
958+
959+ // Scan for @keywords
960+ let bytes = line.as_bytes();
961+ let mut i = 0;
962+ while i < bytes.len() {
963+ if bytes[i] == b'@' {
964+ let start = i;
965+ i += 1;
966+ while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_') {
967+ i += 1;
968+ }
969+ let word = &line[start..i];
970+ let token_type = match word {
971+ "@page" | "@let" | "@define" | "@fn" | "@if" | "@else" | "@each"
972+ | "@include" | "@import" | "@meta" | "@head" | "@style" | "@keyframes"
973+ | "@match" | "@case" | "@default" | "@slot" | "@children"
974+ | "@warn" | "@debug" | "@lang" | "@favicon" | "@fragment"
975+ | "@unless" | "@og" | "@breakpoint"
976+ | "@canonical" | "@base" | "@font-face" | "@json-ld"
977+ | "@mixin" | "@assert" | "@theme" | "@deprecated" | "@extends"
978+ | "@use" | "@data" | "@env" | "@fetch" | "@svg" | "@css-property" => 0, // keyword
979+ _ => {
980+ // Check if it's a user function call (starts with @ but not a builtin element)
981+ if is_builtin_element(word) { 0 } else { 2 } // function
982+ }
983+ };
984+ // Mark unused @fn definitions with deprecated modifier (dimmed)
985+ let modifier = if (trimmed.starts_with("@fn ") || trimmed.starts_with("@define ") || trimmed.starts_with("@mixin ")) && word != "@fn" && word != "@define" && word != "@mixin" {
986+ let name_part = &word[1..]; // strip @
987+ if unused_vars.contains(&format!("@{}", name_part)) { 1 } else { 0 }
988+ } else { 0 };
989+ push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, start as u32, (i - start) as u32, token_type, modifier);
990+ } else if bytes[i] == b'$' {
991+ let start = i;
992+ i += 1;
993+ while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_') {
994+ i += 1;
995+ }
996+ if i > start + 1 {
997+ // Check if this is an unused variable definition on a @let line
998+ let var_name = &line[start + 1..i];
999+ let modifier = if trimmed.starts_with("@let ") && unused_vars.contains(var_name) { 1 } else { 0 };
1000+ push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, start as u32, (i - start) as u32, 1, modifier); // variable
1001+ }
1002+ } else {
1003+ i += 1;
1004+ }
1005+ }
1006+ }
1007+ tokens
1008+}
1009+
1010+fn is_builtin_element(word: &str) -> bool {
1011+ matches!(word,
1012+ "@row" | "@column" | "@col" | "@el" | "@text" | "@paragraph" | "@p"
1013+ | "@image" | "@img" | "@link" | "@input" | "@button" | "@btn"
1014+ | "@select" | "@textarea" | "@option" | "@opt" | "@label" | "@raw"
1015+ | "@nav" | "@header" | "@footer" | "@main" | "@section" | "@article" | "@aside"
1016+ | "@list" | "@item" | "@li" | "@table" | "@thead" | "@tbody" | "@tr" | "@td" | "@th"
1017+ | "@video" | "@audio" | "@form" | "@details" | "@summary"
1018+ | "@blockquote" | "@cite" | "@code" | "@pre" | "@hr" | "@divider"
1019+ | "@figure" | "@figcaption" | "@progress" | "@meter" | "@fragment"
1020+ | "@dialog" | "@dl" | "@dt" | "@dd" | "@fieldset" | "@legend"
1021+ | "@picture" | "@source" | "@time" | "@mark" | "@kbd" | "@abbr" | "@datalist"
1022+ | "@script" | "@noscript" | "@address" | "@search" | "@breadcrumb"
1023+ )
1024+}
1025+
1026+fn push_token(
1027+ tokens: &mut Vec<SemanticToken>,
1028+ prev_line: &mut u32,
1029+ prev_start: &mut u32,
1030+ line: u32,
1031+ start: u32,
1032+ length: u32,
1033+ token_type: u32,
1034+ token_modifiers_bitset: u32,
1035+) {
1036+ let delta_line = line - *prev_line;
1037+ let delta_start = if delta_line == 0 {
1038+ start - *prev_start
1039+ } else {
1040+ start
1041+ };
1042+ tokens.push(SemanticToken {
1043+ delta_line,
1044+ delta_start,
1045+ length,
1046+ token_type,
1047+ token_modifiers_bitset,
1048+ });
1049+ *prev_line = line;
1050+ *prev_start = start;
1051+}
1052+
1053+// ---------------------------------------------------------------------------
1054+// Inlay hints
1055+// ---------------------------------------------------------------------------
1056+
1057+pub(crate) fn inlay_hints(text: &str) -> Vec<InlayHint> {
1058+ // Build variable map: name -> value
1059+ let mut vars: HashMap<&str, &str> = HashMap::new();
1060+ for line in text.lines() {
1061+ let trimmed = line.trim();
1062+ if let Some(rest) = trimmed.strip_prefix("@let ") {
1063+ if let Some((name, value)) = rest.trim().split_once(' ') {
1064+ vars.insert(name, value.trim());
1065+ }
1066+ }
1067+ }
1068+
1069+ let mut hints = Vec::new();
1070+ for (line_idx, line) in text.lines().enumerate() {
1071+ let trimmed = line.trim();
1072+ // Skip @let definition lines — the value is already visible there
1073+ if trimmed.starts_with("@let ") {
1074+ continue;
1075+ }
1076+ let bytes = line.as_bytes();
1077+ let mut i = 0;
1078+ while i < bytes.len() {
1079+ if bytes[i] == b'$' {
1080+ let start = i;
1081+ i += 1;
1082+ while i < bytes.len()
1083+ && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
1084+ {
1085+ i += 1;
1086+ }
1087+ if i > start + 1 {
1088+ let var_name = &line[start + 1..i];
1089+ if let Some(value) = vars.get(var_name) {
1090+ hints.push(InlayHint {
1091+ position: Position::new(line_idx as u32, i as u32),
1092+ label: InlayHintLabel::String(format!(" \u{2192} {}", value)),
1093+ kind: None,
1094+ text_edits: None,
1095+ tooltip: None,
1096+ padding_left: Some(false),
1097+ padding_right: Some(true),
1098+ data: None,
1099+ });
1100+ }
1101+ }
1102+ } else {
1103+ i += 1;
1104+ }
1105+ }
1106+ }
1107+ hints
1108+}
1109+
1110+// ---------------------------------------------------------------------------
1111+// Signature help
1112+// ---------------------------------------------------------------------------
1113+
1114+pub(crate) fn get_signature_help(text: &str, position: Position) -> Option<SignatureHelp> {
1115+ let lines: Vec<&str> = text.lines().collect();
1116+ let line = lines.get(position.line as usize)?;
1117+ let col = (position.character as usize).min(line.len());
1118+ let before = &line[..col];
1119+
1120+ // Check if we're inside a function call: @funcname [...
1121+ let trimmed = before.trim_start();
1122+ if !trimmed.starts_with('@') {
1123+ return None;
1124+ }
1125+
1126+ // Extract the function name
1127+ let after_at = &trimmed[1..];
1128+ let name_end = after_at.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
1129+ .unwrap_or(after_at.len());
1130+ let fn_name = &after_at[..name_end];
1131+
1132+ // Must be inside brackets
1133+ if !in_brackets(before) {
1134+ return None;
1135+ }
1136+
1137+ // Find the @fn definition
1138+ for (line_idx, line_text) in text.lines().enumerate() {
1139+ let t = line_text.trim_start();
1140+ if let Some(rest) = t.strip_prefix("@fn ") {
1141+ let rest = rest.trim();
1142+ let def_name_end = rest.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
1143+ .unwrap_or(rest.len());
1144+ let def_name = &rest[..def_name_end];
1145+ if def_name != fn_name {
1146+ continue;
1147+ }
1148+ let params_str = &rest[def_name_end..].trim();
1149+ let params: Vec<&str> = params_str
1150+ .split_whitespace()
1151+ .filter(|p| p.starts_with('$'))
1152+ .collect();
1153+
1154+ if params.is_empty() {
1155+ return None;
1156+ }
1157+
1158+ let param_labels: Vec<ParameterInformation> = params.iter().map(|p| {
1159+ let name = p.trim_start_matches('$');
1160+ let (label, doc) = if let Some((n, default)) = name.split_once('=') {
1161+ (n.to_string(), Some(format!("Default: {}", default)))
1162+ } else {
1163+ (name.to_string(), None)
1164+ };
1165+ ParameterInformation {
1166+ label: ParameterLabel::Simple(label),
1167+ documentation: doc.map(|d| Documentation::String(d)),
1168+ }
1169+ }).collect();
1170+
1171+ let sig_label = format!("@{} {}", fn_name, params.join(" "));
1172+
1173+ // Determine active parameter by counting commas before cursor inside brackets
1174+ let bracket_start = before.rfind('[').unwrap_or(0);
1175+ let inside = &before[bracket_start..];
1176+ let active_param = inside.matches(',').count() as u32;
1177+
1178+ return Some(SignatureHelp {
1179+ signatures: vec![SignatureInformation {
1180+ label: sig_label,
1181+ documentation: Some(Documentation::String(
1182+ format!("Defined at line {}", line_idx + 1),
1183+ )),
1184+ parameters: Some(param_labels),
1185+ active_parameter: Some(active_param),
1186+ }],
1187+ active_signature: Some(0),
1188+ active_parameter: Some(active_param),
1189+ });
1190+ }
1191+ }
1192+ None
1193+}
src/bin/htmlang_lsp/completion.rsnew file+1052
@@ -0,0 +1,1052 @@
1+use tower_lsp::lsp_types::*;
2+
3+pub(crate) fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
4+ let lines: Vec<&str> = text.lines().collect();
5+ let line = match lines.get(position.line as usize) {
6+ Some(l) => *l,
7+ None => return vec![],
8+ };
9+
10+ let col = (position.character as usize).min(line.len());
11+ let before = &line[..col];
12+
13+ let word_start = find_word_start(before);
14+ let edit_range = Range::new(
15+ Position::new(position.line, word_start as u32),
16+ position,
17+ );
18+
19+ // Inside attribute brackets?
20+ if in_brackets(before) {
21+ let current_word = &before[word_start..];
22+
23+ // $ variable/define reference
24+ if current_word.starts_with('$') {
25+ return variable_completions(text, edit_range);
26+ }
27+
28+ // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
29+ if let Some(colon) = current_word.find(':') {
30+ let prefix = &current_word[..colon];
31+ if matches!(prefix, "hover" | "active" | "focus" | "focus-visible" | "focus-within" | "disabled" | "checked" | "placeholder" | "first" | "last" | "odd" | "even" | "before" | "after" | "dark" | "print" | "sm" | "md" | "lg" | "xl" | "2xl" | "motion-safe" | "motion-reduce" | "landscape" | "portrait") {
32+ return state_attr_completions(prefix, edit_range);
33+ }
34+ }
35+
36+ // Color value completions after color-related attributes
37+ if let Some(colors) = color_value_completions(before, edit_range) {
38+ return colors;
39+ }
40+
41+ return attr_completions(edit_range);
42+ }
43+
44+ // $ variable reference outside brackets
45+ let current_word = &before[word_start..];
46+ if current_word.starts_with('$') {
47+ return variable_completions(text, edit_range);
48+ }
49+
50+ // @ element/directive or start of line
51+ let trimmed = before.trim_start();
52+ if trimmed.is_empty() || trimmed.starts_with('@') {
53+ let mut items = element_completions(edit_range);
54+ items.extend(directive_completions(edit_range));
55+ items.extend(function_completions(text, edit_range));
56+ items.extend(snippet_completions(edit_range));
57+ return items;
58+ }
59+
60+ vec![]
61+}
62+
63+pub(crate) fn find_word_start(text: &str) -> usize {
64+ let bytes = text.as_bytes();
65+ let mut i = bytes.len();
66+ while i > 0 {
67+ let c = bytes[i - 1];
68+ if c.is_ascii_alphanumeric()
69+ || c == b'@'
70+ || c == b'$'
71+ || c == b'-'
72+ || c == b'_'
73+ || c == b':'
74+ {
75+ i -= 1;
76+ } else {
77+ break;
78+ }
79+ }
80+ i
81+}
82+
83+pub(crate) fn in_brackets(text: &str) -> bool {
84+ let mut depth: i32 = 0;
85+ for ch in text.chars() {
86+ if ch == '[' {
87+ depth += 1;
88+ } else if ch == ']' {
89+ depth -= 1;
90+ }
91+ }
92+ depth > 0
93+}
94+
95+fn item(
96+ label: &str,
97+ kind: CompletionItemKind,
98+ detail: &str,
99+ insert: &str,
100+ range: Range,
101+) -> CompletionItem {
102+ CompletionItem {
103+ label: label.to_string(),
104+ kind: Some(kind),
105+ detail: Some(detail.to_string()),
106+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
107+ range,
108+ new_text: insert.to_string(),
109+ })),
110+ ..Default::default()
111+ }
112+}
113+
114+fn element_completions(range: Range) -> Vec<CompletionItem> {
115+ [
116+ ("@row", "Horizontal layout (flexbox row)"),
117+ ("@column", "Vertical layout (flexbox column)"),
118+ ("@col", "Vertical layout (short for @column)"),
119+ ("@el", "Generic container"),
120+ ("@text", "Styled inline text (span)"),
121+ ("@paragraph", "Flowing text block (p)"),
122+ ("@p", "Flowing text block (short for @paragraph)"),
123+ ("@image", "Image element"),
124+ ("@img", "Image element (short for @image)"),
125+ ("@link", "Anchor/link element"),
126+ ("@input", "Form input element (self-closing)"),
127+ ("@button", "Button element"),
128+ ("@btn", "Button element (short for @button)"),
129+ ("@select", "Select dropdown element"),
130+ ("@textarea", "Multi-line text input"),
131+ ("@option", "Option inside @select"),
132+ ("@opt", "Option (short for @option)"),
133+ ("@label", "Label element"),
134+ ("@raw", "Raw HTML escape hatch"),
135+ ("@children", "Slot for caller's children (inside @fn)"),
136+ ("@slot", "Named slot inside @fn (e.g., @slot header)"),
137+ // Semantic elements
138+ ("@nav", "Navigation container (nav)"),
139+ ("@header", "Page/section header (header)"),
140+ ("@footer", "Page/section footer (footer)"),
141+ ("@main", "Main content area (main)"),
142+ ("@section", "Thematic section (section)"),
143+ ("@article", "Self-contained content (article)"),
144+ ("@aside", "Sidebar/tangential content (aside)"),
145+ // List elements
146+ ("@list", "List container (ul/ol, use [ordered] for ol)"),
147+ ("@item", "List item (li)"),
148+ ("@li", "List item (short for @item)"),
149+ // Table elements
150+ ("@table", "Table element"),
151+ ("@thead", "Table head group"),
152+ ("@tbody", "Table body group"),
153+ ("@tr", "Table row"),
154+ ("@td", "Table cell"),
155+ ("@th", "Table header cell"),
156+ // Media elements
157+ ("@video", "Video element"),
158+ ("@audio", "Audio element"),
159+ // Additional elements
160+ ("@form", "Form container (form)"),
161+ ("@details", "Disclosure widget (details)"),
162+ ("@summary", "Summary for @details"),
163+ ("@blockquote", "Block quotation"),
164+ ("@cite", "Citation/source reference"),
165+ ("@code", "Inline code (monospace)"),
166+ ("@pre", "Preformatted text block"),
167+ ("@hr", "Horizontal rule/divider"),
168+ ("@divider", "Horizontal rule (alias for @hr)"),
169+ ("@figure", "Figure with optional caption"),
170+ ("@figcaption", "Caption for @figure"),
171+ ("@progress", "Progress bar (value, max attributes)"),
172+ ("@meter", "Meter/gauge element (value, min, max)"),
173+ ("@fragment", "Group children without a wrapper element"),
174+ // Dialog & interactive
175+ ("@dialog", "Dialog/modal element (dialog)"),
176+ // Definition lists
177+ ("@dl", "Description list (dl)"),
178+ ("@dt", "Description term (dt)"),
179+ ("@dd", "Description details (dd)"),
180+ // Form grouping
181+ ("@fieldset", "Fieldset group (fieldset)"),
182+ ("@legend", "Legend for @fieldset (legend)"),
183+ // Picture/responsive images
184+ ("@picture", "Responsive image container (picture)"),
185+ ("@source", "Media source for @picture/@video/@audio (source)"),
186+ // Inline semantics
187+ ("@time", "Date/time element (time)"),
188+ ("@mark", "Highlighted/marked text (mark)"),
189+ ("@kbd", "Keyboard input (kbd)"),
190+ ("@abbr", "Abbreviation (abbr)"),
191+ // Datalist
192+ ("@datalist", "Predefined options for @input (datalist)"),
193+ // New elements
194+ ("@iframe", "Embedded external page (iframe)"),
195+ ("@output", "Form calculation result (output)"),
196+ ("@canvas", "Drawing surface for scripts (canvas)"),
197+ ("@script", "Inline script element (script)"),
198+ ("@noscript", "Fallback content when scripts disabled (noscript)"),
199+ ("@address", "Contact information (address)"),
200+ ("@search", "Search section (search)"),
201+ ("@breadcrumb", "Breadcrumb navigation (nav with aria)"),
202+ ]
203+ .iter()
204+ .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
205+ .collect()
206+}
207+
208+fn directive_completions(range: Range) -> Vec<CompletionItem> {
209+ [
210+ ("@page", "Set HTML page title", "@page "),
211+ ("@let", "Define a variable", "@let "),
212+ ("@define", "Define an attribute bundle", "@define "),
213+ ("@fn", "Define a reusable function (supports $param=default)", "@fn "),
214+ ("@keyframes", "Define a CSS animation", "@keyframes "),
215+ ("@if", "Conditional rendering", "@if "),
216+ ("@else if", "Else-if branch", "@else if "),
217+ ("@else", "Else branch", "@else"),
218+ ("@each", "Loop over values (@each $var, $i in list)", "@each "),
219+ ("@include", "Include another .hl file (DOM + definitions)", "@include "),
220+ ("@import", "Import definitions only (no DOM nodes)", "@import "),
221+ ("@meta", "Add a <meta> tag to <head>", "@meta "),
222+ ("@head", "Add raw content to <head>", "@head"),
223+ ("@style", "Add raw CSS to stylesheet", "@style"),
224+ ("@slot", "Named slot in @fn for caller content", "@slot "),
225+ ("@match", "Pattern matching on a value", "@match "),
226+ ("@case", "Match case (inside @match)", "@case "),
227+ ("@default", "Default case (inside @match)", "@default"),
228+ ("@warn", "Emit a compile-time warning", "@warn "),
229+ ("@debug", "Print debug message during compilation", "@debug "),
230+ ("@lang", "Set document language (html lang attribute)", "@lang "),
231+ ("@favicon", "Set favicon (inlined as base64 data URI)", "@favicon "),
232+ ("@unless", "Inverse conditional (renders when false)", "@unless "),
233+ ("@og", "Add Open Graph meta tag", "@og "),
234+ ("@breakpoint", "Define custom responsive breakpoint", "@breakpoint "),
235+ ("@theme", "Define design tokens (colors, spacing, fonts)", "@theme"),
236+ ("@deprecated", "Mark next @fn as deprecated", "@deprecated "),
237+ ("@extends", "Inherit a layout template and fill @slot blocks", "@extends "),
238+ ("@use", "Selective import of definitions from a file", "@use "),
239+ ("@canonical", "Set canonical URL for the page", "@canonical "),
240+ ("@base", "Set base URL for relative links", "@base "),
241+ ("@font-face", "Define a custom font face", "@font-face"),
242+ ("@json-ld", "Add JSON-LD structured data to head", "@json-ld"),
243+ ("@mixin", "Define a composable style group (use with ...$name)", "@mixin "),
244+ ("@assert", "Compile-time assertion for variable values", "@assert "),
245+ ("@data", "Load JSON data file into template variables", "@data "),
246+ ("@env", "Access compile-time environment variable", "@env "),
247+ ("@fetch", "Fetch data from URL at compile time", "@fetch "),
248+ ("@svg", "Inline SVG file with optional attributes", "@svg "),
249+ ("@css-property", "Define typed CSS custom property (@property)", "@css-property "),
250+ ]
251+ .iter()
252+ .map(|(name, detail, insert)| {
253+ item(name, CompletionItemKind::SNIPPET, detail, insert, range)
254+ })
255+ .collect()
256+}
257+
258+fn snippet_completions(range: Range) -> Vec<CompletionItem> {
259+ let snippets: &[(&str, &str, &str)] = &[
260+ (
261+ "card component",
262+ "Reusable card with title and content",
263+ "@fn card \\$title\n @el [padding 20, background white, rounded 8]\n @text [bold] \\$title\n @children",
264+ ),
265+ (
266+ "responsive layout",
267+ "Centered responsive column layout",
268+ "@column [max-width 800, center-x, padding 40, spacing 20]",
269+ ),
270+ (
271+ "nav bar",
272+ "Navigation bar with horizontal items",
273+ "@nav [padding 16, background #1a1a2e]\n @row [spacing 20, align-items center]",
274+ ),
275+ (
276+ "hero section",
277+ "Hero section with title and subtitle",
278+ "@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}",
279+ ),
280+ (
281+ "each with else",
282+ "Loop with empty-state fallback",
283+ "@each \\$${1:item} in ${2:list}\n @text \\$${1:item}\n@else\n @text [color #888] No items found.",
284+ ),
285+ (
286+ "button with hover",
287+ "Interactive button with hover effect",
288+ "@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}",
289+ ),
290+ (
291+ "form with inputs",
292+ "Form with labeled inputs and submit button",
293+ "@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",
294+ ),
295+ (
296+ "grid layout",
297+ "Responsive grid with columns",
298+ "@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}",
299+ ),
300+ (
301+ "footer section",
302+ "Footer with columns and copyright",
303+ "@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}",
304+ ),
305+ (
306+ "avatar with image",
307+ "Circular avatar with fallback",
308+ "@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}",
309+ ),
310+ (
311+ "carousel horizontal",
312+ "Scroll-snap horizontal carousel",
313+ "@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}",
314+ ),
315+ (
316+ "dark mode toggle",
317+ "Element with light/dark mode styles",
318+ "@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}",
319+ ),
320+ (
321+ "truncated text",
322+ "Text with ellipsis overflow",
323+ "@text [max-width ${1:200}, truncate] ${2:Long text that will be truncated...}",
324+ ),
325+ (
326+ "@fn definition",
327+ "Define a reusable function/component",
328+ "@fn ${1:name} \\$${2:param}\n @el [${3:padding 16}]\n @children",
329+ ),
330+ (
331+ "@each loop",
332+ "Iterate over a list of items",
333+ "@each \\$${1:item} in ${2:items}\n @text \\$${1:item}",
334+ ),
335+ (
336+ "@if conditional",
337+ "Conditional rendering block",
338+ "@if ${1:condition}\n ${2:content}\n@else\n ${3:fallback}",
339+ ),
340+ (
341+ "@match pattern",
342+ "Pattern matching block",
343+ "@match \\$${1:value}\n @case ${2:option1}\n ${3:content}\n @default\n ${4:fallback}",
344+ ),
345+ (
346+ "@for range loop",
347+ "Loop over a numeric range",
348+ "@for \\$${1:i} in ${2:0}..${3:10}\n @text \\$${1:i}",
349+ ),
350+ (
351+ "@data JSON load",
352+ "Load variables from a JSON file",
353+ "@data ${1:data.json}",
354+ ),
355+ (
356+ "@component scoped",
357+ "Define a scoped component with styles",
358+ "@component ${1:name} \\$${2:param}\n @style\n .inner { ${3:padding: 16px;} }\n @el [class inner]\n @children",
359+ ),
360+ ];
361+
362+ snippets
363+ .iter()
364+ .map(|(label, detail, insert)| CompletionItem {
365+ label: label.to_string(),
366+ kind: Some(CompletionItemKind::SNIPPET),
367+ detail: Some(detail.to_string()),
368+ insert_text_format: Some(InsertTextFormat::SNIPPET),
369+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
370+ range,
371+ new_text: insert.to_string(),
372+ })),
373+ sort_text: Some(format!("zz_{}", label)),
374+ ..Default::default()
375+ })
376+ .collect()
377+}
378+
379+pub(crate) fn path_completions(uri: &Url, position: Position) -> Vec<CompletionItem> {
380+ let file_path = match uri.to_file_path() {
381+ Ok(p) => p,
382+ Err(_) => return vec![],
383+ };
384+ let dir = match file_path.parent() {
385+ Some(d) => d,
386+ None => return vec![],
387+ };
388+
389+ let col = position.character as u32;
390+ let edit_range = Range::new(Position::new(position.line, col), Position::new(position.line, col));
391+
392+ let entries = match std::fs::read_dir(dir) {
393+ Ok(e) => e,
394+ Err(_) => return vec![],
395+ };
396+
397+ let mut items = Vec::new();
398+ for entry in entries.flatten() {
399+ let path = entry.path();
400+ if path.extension().and_then(|e| e.to_str()) == Some("hl") {
401+ if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
402+ // Skip the current file itself
403+ if Some(name) == file_path.file_name().and_then(|n| n.to_str()) {
404+ continue;
405+ }
406+ items.push(CompletionItem {
407+ label: name.to_string(),
408+ kind: Some(CompletionItemKind::FILE),
409+ detail: Some("htmlang file".to_string()),
410+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
411+ range: edit_range,
412+ new_text: name.to_string(),
413+ })),
414+ ..Default::default()
415+ });
416+ }
417+ }
418+ }
419+ items
420+}
421+
422+/// Suggest exported @fn and @define names from a file referenced in @use
423+pub(crate) fn use_symbol_completions(uri: &Url, line: &str, position: Position) -> Vec<CompletionItem> {
424+ let file_path = match uri.to_file_path() {
425+ Ok(p) => p,
426+ Err(_) => return vec![],
427+ };
428+ let dir = match file_path.parent() {
429+ Some(d) => d,
430+ None => return vec![],
431+ };
432+
433+ // Extract the filename from @use "file.hl" or @use file.hl
434+ let after_use = &line.trim_start()[5..]; // skip "@use "
435+ let filename = if after_use.starts_with('"') {
436+ let end = after_use[1..].find('"').map(|i| i + 1).unwrap_or(after_use.len());
437+ &after_use[1..end]
438+ } else {
439+ after_use.split_whitespace().next().unwrap_or("")
440+ };
441+
442+ if filename.is_empty() {
443+ return vec![];
444+ }
445+
446+ let target_path = dir.join(filename);
447+ let target_content = match std::fs::read_to_string(&target_path) {
448+ Ok(s) => s,
449+ Err(_) => return vec![],
450+ };
451+
452+ let col = position.character as u32;
453+ let edit_range = Range::new(Position::new(position.line, col), Position::new(position.line, col));
454+
455+ let mut items = Vec::new();
456+
457+ for target_line in target_content.lines() {
458+ let trimmed = target_line.trim();
459+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
460+ if let Some(name) = rest.split_whitespace().next() {
461+ let params: Vec<&str> = rest.split_whitespace().skip(1).collect();
462+ let detail = if params.is_empty() {
463+ format!("@fn {} (from {})", name, filename)
464+ } else {
465+ format!("@fn {} {} (from {})", name, params.join(" "), filename)
466+ };
467+ items.push(CompletionItem {
468+ label: name.to_string(),
469+ kind: Some(CompletionItemKind::FUNCTION),
470+ detail: Some(detail),
471+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
472+ range: edit_range,
473+ new_text: name.to_string(),
474+ })),
475+ ..Default::default()
476+ });
477+ }
478+ } else if let Some(rest) = trimmed.strip_prefix("@define ") {
479+ if let Some(name) = rest.split_whitespace().next() {
480+ let name = name.trim_end_matches('[');
481+ items.push(CompletionItem {
482+ label: name.to_string(),
483+ kind: Some(CompletionItemKind::VARIABLE),
484+ detail: Some(format!("@define {} (from {})", name, filename)),
485+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
486+ range: edit_range,
487+ new_text: name.to_string(),
488+ })),
489+ ..Default::default()
490+ });
491+ }
492+ } else if let Some(rest) = trimmed.strip_prefix("@component ") {
493+ if let Some(name) = rest.split_whitespace().next() {
494+ items.push(CompletionItem {
495+ label: name.to_string(),
496+ kind: Some(CompletionItemKind::CLASS),
497+ detail: Some(format!("@component {} (from {})", name, filename)),
498+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
499+ range: edit_range,
500+ new_text: name.to_string(),
501+ })),
502+ ..Default::default()
503+ });
504+ }
505+ }
506+ }
507+
508+ items
509+}
510+
511+fn attr_completions(range: Range) -> Vec<CompletionItem> {
512+ [
513+ // Layout
514+ ("spacing", "Gap between children (supports CSS units)", true),
515+ ("gap", "Gap between children (alias for spacing)", true),
516+ ("padding", "Inner padding (1/2/3/4 values, supports CSS units)", true),
517+ ("padding-x", "Horizontal padding", true),
518+ ("padding-y", "Vertical padding", true),
519+ // Sizing
520+ ("width", "Width (px/% | fill | shrink)", true),
521+ ("height", "Height (px/% | fill | shrink)", true),
522+ ("min-width", "Minimum width", true),
523+ ("max-width", "Maximum width", true),
524+ ("min-height", "Minimum height", true),
525+ ("max-height", "Maximum height", true),
526+ // Alignment
527+ ("center-x", "Center horizontally", false),
528+ ("center-y", "Center vertically", false),
529+ ("align-left", "Align left", false),
530+ ("align-right", "Align right", false),
531+ ("align-top", "Align top", false),
532+ ("align-bottom", "Align bottom", false),
533+ // Style
534+ ("background", "Background color/value", true),
535+ ("color", "Text color", true),
536+ ("border", "Border (width [color])", true),
537+ ("border-top", "Top border (width [color])", true),
538+ ("border-bottom", "Bottom border (width [color])", true),
539+ ("border-left", "Left border (width [color])", true),
540+ ("border-right", "Right border (width [color])", true),
541+ ("rounded", "Border radius", true),
542+ ("bold", "Bold text", false),
543+ ("italic", "Italic text", false),
544+ ("underline", "Underlined text", false),
545+ ("size", "Font size", true),
546+ ("font", "Font family", true),
547+ ("transition", "CSS transition", true),
548+ ("cursor", "CSS cursor type", true),
549+ ("opacity", "Opacity (0-1)", true),
550+ // Typography
551+ ("text-align", "Text alignment (left/center/right/justify)", true),
552+ ("line-height", "Line height (unitless or px)", true),
553+ ("letter-spacing", "Letter spacing", true),
554+ ("text-transform", "Text transform (uppercase/lowercase/capitalize)", true),
555+ ("white-space", "White-space behavior (nowrap/pre/normal)", true),
556+ // Overflow & positioning
557+ ("overflow", "Overflow behavior (hidden/scroll/auto)", true),
558+ ("position", "Position type (relative/absolute/fixed/sticky)", true),
559+ ("top", "Top offset (for positioned elements)", true),
560+ ("right", "Right offset (for positioned elements)", true),
561+ ("bottom", "Bottom offset (for positioned elements)", true),
562+ ("left", "Left offset (for positioned elements)", true),
563+ ("z-index", "Stack order (integer)", true),
564+ // Display & visibility
565+ ("display", "Display mode (none/block/inline/flex/grid)", true),
566+ ("visibility", "Visibility (visible/hidden)", true),
567+ // Transform & filters
568+ ("transform", "CSS transform (e.g., rotate(45deg))", true),
569+ ("backdrop-filter", "Backdrop filter (e.g., blur(10px))", true),
570+ // Effects
571+ ("shadow", "Box shadow (CSS value)", true),
572+ // Flow
573+ ("wrap", "Enable flex-wrap", false),
574+ ("gap-x", "Horizontal gap between children", true),
575+ ("gap-y", "Vertical gap between children", true),
576+ // Grid
577+ ("grid", "Enable CSS grid layout", false),
578+ ("grid-cols", "Grid columns (number or template)", true),
579+ ("grid-rows", "Grid rows (number or template)", true),
580+ ("col-span", "Span columns in grid", true),
581+ ("row-span", "Span rows in grid", true),
582+ // Container queries
583+ ("container", "Enable container queries (inline-size)", false),
584+ ("container-name", "Container name for @container queries", true),
585+ ("container-type", "Container type (inline-size/size/normal)", true),
586+ // Identity
587+ ("id", "HTML id attribute", true),
588+ ("class", "HTML class attribute", true),
589+ // Animation
590+ ("animation", "CSS animation (e.g., name 0.3s ease)", true),
591+ // Form
592+ ("type", "Input type (text/email/password/submit/...)", true),
593+ ("placeholder", "Placeholder text", true),
594+ ("name", "Form field name", true),
595+ ("value", "Form field value", true),
596+ ("disabled", "Disable the element", false),
597+ ("required", "Mark field as required", false),
598+ ("checked", "Checkbox/radio checked state", false),
599+ ("for", "Label target (id of associated input)", true),
600+ ("action", "Form action URL", true),
601+ ("method", "Form method (get/post)", true),
602+ ("rows", "Textarea rows", true),
603+ ("cols", "Textarea columns", true),
604+ ("maxlength", "Maximum input length", true),
605+ ("multiple", "Allow multiple selections", false),
606+ // Accessibility
607+ ("alt", "Alternative text (for images)", true),
608+ ("role", "ARIA role", true),
609+ ("tabindex", "Tab order", true),
610+ ("title", "Tooltip text", true),
611+ ("aria-label", "Accessible label", true),
612+ ("aria-hidden", "Hide from assistive tech (true/false)", true),
613+ ("data-", "Custom data attribute", true),
614+ // CSS: aspect-ratio, outline, logical properties, scroll-snap
615+ ("aspect-ratio", "CSS aspect ratio (e.g., 16/9, 1)", true),
616+ ("outline", "Outline (width [color])", true),
617+ ("padding-inline", "Inline (horizontal) padding for i18n", true),
618+ ("padding-block", "Block (vertical) padding for i18n", true),
619+ ("margin-inline", "Inline (horizontal) margin for i18n", true),
620+ ("margin-block", "Block (vertical) margin for i18n", true),
621+ ("scroll-snap-type", "Scroll snap behavior (x/y mandatory/proximity)", true),
622+ ("scroll-snap-align", "Snap alignment (start/center/end)", true),
623+ // Media attributes
624+ ("controls", "Show media controls (for @video, @audio)", false),
625+ ("autoplay", "Auto-play media", false),
626+ ("loop", "Loop media playback", false),
627+ ("muted", "Mute media", false),
628+ ("poster", "Video poster image URL", true),
629+ ("preload", "Media preload hint (auto/metadata/none)", true),
630+ ("loading", "Loading behavior (lazy/eager)", true),
631+ ("decoding", "Image decoding (async/sync/auto)", true),
632+ // List
633+ ("ordered", "Use ordered list (ol instead of ul)", false),
634+ // Media src
635+ ("src", "Source URL for media elements", true),
636+ // Margin
637+ ("margin", "Outer margin (1/2/3/4 values)", true),
638+ ("margin-x", "Horizontal margin", true),
639+ ("margin-y", "Vertical margin", true),
640+ // Filter & object
641+ ("filter", "CSS filter (blur, brightness, grayscale, etc.)", true),
642+ ("object-fit", "Object fit for images (cover/contain/fill)", true),
643+ ("object-position", "Object position within container", true),
644+ // Text extras
645+ ("text-shadow", "Text shadow (CSS value)", true),
646+ ("text-overflow", "Text overflow (ellipsis/clip)", true),
647+ // Interaction
648+ ("pointer-events", "Pointer events (none/auto)", true),
649+ ("user-select", "User selection (none/text/all)", true),
650+ // Flexbox/grid alignment
651+ ("justify-content", "Main axis alignment (center/space-between/etc.)", true),
652+ ("align-items", "Cross axis alignment (center/baseline/etc.)", true),
653+ // Flex item
654+ ("order", "Flex/grid item order", true),
655+ // Background extras
656+ ("background-size", "Background size (cover/contain/auto)", true),
657+ ("background-position", "Background position (center/top/etc.)", true),
658+ ("background-repeat", "Background repeat (no-repeat/repeat/etc.)", true),
659+ // Text wrapping
660+ ("word-break", "Word break behavior (break-all/keep-all)", true),
661+ ("overflow-wrap", "Overflow wrap (break-word/anywhere)", true),
662+ // New element attrs
663+ ("open", "Details initially open", false),
664+ ("novalidate", "Disable form validation", false),
665+ ("low", "Meter low threshold", true),
666+ ("high", "Meter high threshold", true),
667+ ("optimum", "Meter optimum value", true),
668+ ("colspan", "Table cell column span", true),
669+ ("rowspan", "Table cell row span", true),
670+ ("scope", "Table header scope (col/row/colgroup/rowgroup)", true),
671+ ("inline", "Inline SVG images into output", false),
672+ // Hidden
673+ ("hidden", "Hide element (display:none)", false),
674+ // Overflow directional
675+ ("overflow-x", "Horizontal overflow (hidden/scroll/auto)", true),
676+ ("overflow-y", "Vertical overflow (hidden/scroll/auto)", true),
677+ // Inset
678+ ("inset", "Shorthand for top/right/bottom/left", true),
679+ // Modern form theming
680+ ("accent-color", "Accent color for form controls", true),
681+ ("caret-color", "Text cursor color", true),
682+ ("color-scheme", "Color scheme preference (light/dark/light dark)", true),
683+ ("appearance", "Form element appearance (none to reset)", true),
684+ // Popover API
685+ ("popover", "Make element a popover (HTML Popover API)", false),
686+ ("popovertarget", "ID of popover to toggle", true),
687+ ("popovertargetaction", "Popover action (toggle/show/hide)", true),
688+ // Input hints
689+ ("inputmode", "Virtual keyboard type (numeric/email/search/tel/url)", true),
690+ ("enterkeyhint", "Enter key label (done/go/next/search/send)", true),
691+ ("fetchpriority", "Resource fetch priority (high/low/auto)", true),
692+ ("translate", "Whether element should be translated (yes/no)", true),
693+ ("spellcheck", "Spell check mode (true/false)", true),
694+ // List styling
695+ ("list-style", "List style type (disc/circle/square/none)", true),
696+ // Table styling
697+ ("border-collapse", "Border collapse mode (collapse/separate)", true),
698+ ("border-spacing", "Spacing between table cell borders", true),
699+ // Text decoration
700+ ("text-decoration", "Text decoration (underline/overline/line-through)", true),
701+ ("text-decoration-color", "Text decoration color", true),
702+ ("text-decoration-thickness", "Text decoration thickness", true),
703+ ("text-decoration-style", "Text decoration style (solid/dashed/dotted/wavy)", true),
704+ // Grid/flex placement
705+ ("place-items", "Shorthand for align-items + justify-items", true),
706+ ("place-self", "Shorthand for align-self + justify-self", true),
707+ // Scroll behavior
708+ ("scroll-behavior", "Scroll behavior (smooth/auto)", true),
709+ // Resize
710+ ("resize", "Resize behavior (none/both/horizontal/vertical)", true),
711+ // State prefixes
712+ ("hover:", "Style on hover", false),
713+ ("active:", "Style on active/click", false),
714+ ("focus:", "Style on focus", false),
715+ // New pseudo-state prefixes
716+ ("focus-visible:", "Style on keyboard focus", false),
717+ ("focus-within:", "Style when child has focus", false),
718+ ("disabled:", "Style when disabled", false),
719+ ("checked:", "Style when checked", false),
720+ ("placeholder:", "Style placeholder text", false),
721+ // Child selectors
722+ ("first:", "Style first child", false),
723+ ("last:", "Style last child", false),
724+ ("odd:", "Style odd children (1st, 3rd, ...)", false),
725+ ("even:", "Style even children (2nd, 4th, ...)", false),
726+ // Responsive prefixes
727+ ("sm:", "Style at 640px+ (small)", false),
728+ ("md:", "Style at 768px+ (medium)", false),
729+ ("lg:", "Style at 1024px+ (large)", false),
730+ ("xl:", "Style at 1280px+ (extra large)", false),
731+ // Additional responsive prefixes
732+ ("2xl:", "Style at 1536px+ (2x extra large)", false),
733+ // Motion prefixes
734+ ("motion-safe:", "Style when motion is allowed", false),
735+ ("motion-reduce:", "Style when reduced motion preferred", false),
736+ // Orientation prefixes
737+ ("landscape:", "Style in landscape orientation", false),
738+ ("portrait:", "Style in portrait orientation", false),
739+ // Media prefixes
740+ ("dark:", "Style in dark color scheme", false),
741+ ("print:", "Style for print media", false),
742+ // Clipping & blending
743+ ("clip-path", "Clip path (circle, polygon, etc.)", true),
744+ ("mix-blend-mode", "Blend mode (multiply, screen, overlay, etc.)", true),
745+ ("background-blend-mode", "Background blend mode", true),
746+ // Writing mode
747+ ("writing-mode", "Writing mode (horizontal-tb, vertical-rl, etc.)", true),
748+ // Multi-column layout
749+ ("column-count", "Number of columns in multi-column layout", true),
750+ ("column-gap", "Gap between columns", true),
751+ // Text
752+ ("text-indent", "First-line text indentation", true),
753+ ("hyphens", "Hyphenation behavior (none/manual/auto)", true),
754+ // Flex item sizing
755+ ("flex-grow", "Flex grow factor", true),
756+ ("flex-shrink", "Flex shrink factor", true),
757+ ("flex-basis", "Flex basis (initial main size)", true),
758+ // Stacking context
759+ ("isolation", "Create stacking context (isolate/auto)", true),
760+ // Grid/flex placement
761+ ("place-content", "Shorthand for align-content + justify-content", true),
762+ // Background image
763+ ("background-image", "Background image (url or gradient)", true),
764+ // New CSS properties
765+ ("font-weight", "Font weight (100-900, bold, lighter, bolder)", true),
766+ ("font-style", "Font style (normal/italic/oblique)", true),
767+ ("text-wrap", "Text wrapping (balance/pretty/nowrap)", true),
768+ ("will-change", "Performance hint for animations (transform, opacity)", true),
769+ ("touch-action", "Touch behavior (none/pan-x/pan-y/manipulation)", true),
770+ ("vertical-align", "Vertical alignment (middle/top/bottom/baseline)", true),
771+ ("contain", "CSS containment (layout/paint/content/strict)", true),
772+ ("content-visibility", "Content visibility (auto/visible/hidden)", true),
773+ ("scroll-margin", "Scroll margin (for scroll-snap and anchor offsets)", true),
774+ ("scroll-margin-top", "Scroll margin top", true),
775+ ("scroll-padding", "Scroll padding (for scroll-snap containers)", true),
776+ ("scroll-padding-top", "Scroll padding top", true),
777+ // Pseudo-element content
778+ ("content", "Content for ::before/::after (use with before:/after: prefix)", true),
779+ // Iframe/form/link attributes
780+ ("sandbox", "Iframe sandbox restrictions", true),
781+ ("allow", "Iframe permissions policy", true),
782+ ("allowfullscreen", "Allow iframe fullscreen", false),
783+ ("target", "Link/form target (_blank/_self/_parent/_top)", true),
784+ // CSS shorthands
785+ ("truncate", "Truncate text with ellipsis (single line)", false),
786+ ("line-clamp", "Clamp text to N lines with ellipsis", true),
787+ ("blur", "Apply blur filter (px)", true),
788+ ("backdrop-blur", "Apply backdrop blur filter (px)", true),
789+ ("no-scrollbar", "Hide scrollbar while keeping overflow", false),
790+ ("skeleton", "Add shimmer loading skeleton animation", false),
791+ ("gradient", "Linear gradient (color1 color2 [angle])", true),
792+ // Direction
793+ ("direction", "Text direction (ltr/rtl)", true),
794+ // Container query prefixes
795+ ("cq-sm:", "Container query at 640px+", false),
796+ ("cq-md:", "Container query at 768px+", false),
797+ ("cq-lg:", "Container query at 1024px+", false),
798+ // Pseudo-elements
799+ ("before:", "Style ::before pseudo-element", false),
800+ ("after:", "Style ::after pseudo-element", false),
801+ ("selection:", "Style text selection", false),
802+ // Grid areas
803+ ("grid-template-areas", "Define named grid areas (quoted string)", true),
804+ ("grid-area", "Place element in a named grid area", true),
805+ // View transitions
806+ ("view-transition-name", "Assign a view transition name", true),
807+ // Animate shorthand
808+ ("animate", "Animation shorthand (name duration [timing])", true),
809+ // Has pseudo-selector
810+ ("has(", "Style when element has matching children :has()", false),
811+ // Critical CSS hint
812+ ("critical", "Mark as above-fold critical CSS", false),
813+ // New pseudo-state prefixes
814+ ("visited:", "Style visited links", false),
815+ ("empty:", "Style when element has no children", false),
816+ ("target:", "Style when element is the URL fragment target", false),
817+ ("valid:", "Style when form element is valid", false),
818+ ("invalid:", "Style when form element is invalid", false),
819+ // New CSS properties
820+ ("text-underline-offset", "Offset of text underline from its default position", true),
821+ ("column-width", "Ideal width of columns in multi-column layout", true),
822+ ("column-rule", "Rule between columns (width style color)", true),
823+ ]
824+ .iter()
825+ .map(|(name, detail, takes_value)| {
826+ let insert = if *takes_value {
827+ format!("{} ", name)
828+ } else {
829+ name.to_string()
830+ };
831+ item(name, CompletionItemKind::PROPERTY, detail, &insert, range)
832+ })
833+ .collect()
834+}
835+
836+fn state_attr_completions(prefix: &str, range: Range) -> Vec<CompletionItem> {
837+ [
838+ ("background", "Background color/value", true),
839+ ("color", "Text color", true),
840+ ("border", "Border (width [color])", true),
841+ ("border-top", "Top border (width [color])", true),
842+ ("border-bottom", "Bottom border (width [color])", true),
843+ ("border-left", "Left border (width [color])", true),
844+ ("border-right", "Right border (width [color])", true),
845+ ("rounded", "Border radius", true),
846+ ("bold", "Bold text", false),
847+ ("italic", "Italic text", false),
848+ ("underline", "Underlined text", false),
849+ ("size", "Font size", true),
850+ ("opacity", "Opacity (0-1)", true),
851+ ("cursor", "CSS cursor type", true),
852+ ("shadow", "Box shadow (CSS value)", true),
853+ ("text-shadow", "Text shadow", true),
854+ ("transform", "CSS transform", true),
855+ ("filter", "CSS filter", true),
856+ ("display", "Display mode", true),
857+ ("visibility", "Visibility", true),
858+ ("pointer-events", "Pointer events", true),
859+ ("user-select", "User selection", true),
860+ // Layout attrs for pseudo-states
861+ ("width", "Width", true),
862+ ("height", "Height", true),
863+ ("padding", "Inner padding", true),
864+ ("padding-x", "Horizontal padding", true),
865+ ("padding-y", "Vertical padding", true),
866+ ("margin", "Outer margin", true),
867+ ("margin-x", "Horizontal margin", true),
868+ ("margin-y", "Vertical margin", true),
869+ ("outline", "Outline", true),
870+ ("text-decoration", "Text decoration", true),
871+ ("text-decoration-color", "Text decoration color", true),
872+ ]
873+ .iter()
874+ .map(|(name, detail, takes_value)| {
875+ let full = format!("{}:{}", prefix, name);
876+ let insert = if *takes_value {
877+ format!("{} ", full)
878+ } else {
879+ full.clone()
880+ };
881+ item(&full, CompletionItemKind::PROPERTY, detail, &insert, range)
882+ })
883+ .collect()
884+}
885+
886+fn color_value_completions(before: &str, range: Range) -> Option<Vec<CompletionItem>> {
887+ // Find the preceding attribute name before the cursor value position.
888+ // Inside brackets, attributes are comma-separated. Look for the last attribute token
889+ // before the current value position. Pattern: "attr value" or "attr " at end.
890+ let bracket_content = before.rsplit('[').next()?;
891+ // Split by commas to get the current segment
892+ let segment = bracket_content.rsplit(',').next()?.trim();
893+
894+ // Check if the first word in this segment is a color-related attribute
895+ let attr = segment.split_whitespace().next()?;
896+
897+ // Strip state prefix (e.g., "hover:background" -> "background")
898+ let base_attr = if let Some(pos) = attr.rfind(':') {
899+ &attr[pos + 1..]
900+ } else {
901+ attr
902+ };
903+
904+ if !matches!(
905+ base_attr,
906+ "background" | "color" | "border" | "border-top" | "border-bottom"
907+ | "border-left" | "border-right" | "accent-color" | "caret-color"
908+ | "text-decoration-color" | "outline"
909+ ) {
910+ return None;
911+ }
912+
913+ // Only show colors if we're in the value position (at least one space after the attr name)
914+ let after_attr = &segment[attr.len()..];
915+ if !after_attr.starts_with(' ') {
916+ return None;
917+ }
918+
919+ let colors: &[(&str, &str, &str)] = &[
920+ ("white", "#ffffff", "White"),
921+ ("black", "#000000", "Black"),
922+ ("red", "#ef4444", "Red"),
923+ ("orange", "#f97316", "Orange"),
924+ ("yellow", "#eab308", "Yellow"),
925+ ("green", "#22c55e", "Green"),
926+ ("blue", "#3b82f6", "Blue"),
927+ ("indigo", "#6366f1", "Indigo"),
928+ ("purple", "#a855f7", "Purple"),
929+ ("pink", "#ec4899", "Pink"),
930+ ("gray", "#6b7280", "Gray"),
931+ ("slate", "#64748b", "Slate"),
932+ ("zinc", "#71717a", "Zinc"),
933+ ("neutral", "#737373", "Neutral"),
934+ ("stone", "#78716c", "Stone"),
935+ ("amber", "#f59e0b", "Amber"),
936+ ("lime", "#84cc16", "Lime"),
937+ ("emerald", "#10b981", "Emerald"),
938+ ("teal", "#14b8a6", "Teal"),
939+ ("cyan", "#06b6d4", "Cyan"),
940+ ("sky", "#0ea5e9", "Sky"),
941+ ("violet", "#8b5cf6", "Violet"),
942+ ("fuchsia", "#d946ef", "Fuchsia"),
943+ ("rose", "#f43f5e", "Rose"),
944+ ("transparent", "transparent", "Transparent"),
945+ ("currentColor", "currentColor", "Inherit from parent text color"),
946+ ];
947+
948+ let items: Vec<CompletionItem> = colors.iter().map(|(label, value, detail)| {
949+ let doc = if value.starts_with('#') {
950+ format!("{} (`{}`)", detail, value)
951+ } else {
952+ detail.to_string()
953+ };
954+ CompletionItem {
955+ label: label.to_string(),
956+ kind: Some(CompletionItemKind::COLOR),
957+ detail: Some(doc),
958+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
959+ range,
960+ new_text: value.to_string(),
961+ })),
962+ documentation: if value.starts_with('#') {
963+ Some(Documentation::String(value.to_string()))
964+ } else {
965+ None
966+ },
967+ ..Default::default()
968+ }
969+ }).collect();
970+
971+ Some(items)
972+}
973+
974+fn variable_completions(text: &str, range: Range) -> Vec<CompletionItem> {
975+ let mut items = Vec::new();
976+
977+ for line in text.lines() {
978+ let trimmed = line.trim();
979+ if let Some(rest) = trimmed.strip_prefix("@let ") {
980+ if let Some((name, value)) = rest.trim().split_once(' ') {
981+ items.push(item(
982+ &format!("${}", name),
983+ CompletionItemKind::VARIABLE,
984+ &format!("= {}", value.trim()),
985+ &format!("${}", name),
986+ range,
987+ ));
988+ }
989+ } else if let Some(rest) = trimmed.strip_prefix("@define ") {
990+ if let Some(bracket) = rest.find('[') {
991+ let name = rest[..bracket].trim();
992+ items.push(item(
993+ &format!("${}", name),
994+ CompletionItemKind::CONSTANT,
995+ "Attribute bundle",
996+ &format!("${}", name),
997+ range,
998+ ));
999+ }
1000+ }
1001+ }
1002+
1003+ items
1004+}
1005+
1006+fn function_completions(text: &str, range: Range) -> Vec<CompletionItem> {
1007+ let mut items = Vec::new();
1008+
1009+ for line in text.lines() {
1010+ let trimmed = line.trim();
1011+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
1012+ let parts: Vec<&str> = rest.split_whitespace().collect();
1013+ if let Some(name) = parts.first() {
1014+ let params: Vec<&str> = parts[1..].iter()
1015+ .map(|p| p.strip_prefix('$').unwrap_or(p))
1016+ .collect();
1017+ let detail = if params.is_empty() {
1018+ "Function".to_string()
1019+ } else {
1020+ format!("Function({})", params.join(", "))
1021+ };
1022+ // Generate snippet with tab stops for parameters
1023+ let insert_text = if params.is_empty() {
1024+ format!("@{}", name)
1025+ } else {
1026+ let param_snippets: Vec<String> = params.iter().enumerate().map(|(i, p)| {
1027+ let p_name = p.split('=').next().unwrap_or(p);
1028+ let default = p.split('=').nth(1).unwrap_or(p_name);
1029+ format!("{} ${{{}:{}}}", p_name, i + 1, default)
1030+ }).collect();
1031+ format!("@{} [{}]", name, param_snippets.join(", "))
1032+ };
1033+ let mut ci = CompletionItem {
1034+ label: format!("@{}", name),
1035+ kind: Some(CompletionItemKind::FUNCTION),
1036+ detail: Some(detail),
1037+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
1038+ range,
1039+ new_text: insert_text,
1040+ })),
1041+ ..Default::default()
1042+ };
1043+ if !params.is_empty() {
1044+ ci.insert_text_format = Some(tower_lsp::lsp_types::InsertTextFormat::SNIPPET);
1045+ }
1046+ items.push(ci);
1047+ }
1048+ }
1049+ }
1050+
1051+ items
1052+}
src/bin/htmlang_lsp/hover.rsnew file+552
@@ -0,0 +1,552 @@
1+use tower_lsp::lsp_types::*;
2+
3+pub(crate) fn hover_at(text: &str, position: Position) -> Option<Hover> {
4+ let lines: Vec<&str> = text.lines().collect();
5+ let line = lines.get(position.line as usize)?;
6+ let col = (position.character as usize).min(line.len());
7+ let word = word_at(line, col)?;
8+
9+ let doc = if word.starts_with('$') {
10+ hover_variable(text, &word[1..])
11+ } else if word.starts_with('@') {
12+ hover_user_fn(text, &word[1..]).or_else(|| hover_builtin(&word))
13+ } else {
14+ hover_builtin(&word)
15+ }?;
16+
17+ Some(Hover {
18+ contents: HoverContents::Markup(MarkupContent {
19+ kind: MarkupKind::Markdown,
20+ value: doc,
21+ }),
22+ range: None,
23+ })
24+}
25+
26+pub(crate) fn word_at(line: &str, col: usize) -> Option<String> {
27+ let bytes = line.as_bytes();
28+ let mut start = col;
29+ while start > 0 && is_word_byte(bytes[start - 1]) {
30+ start -= 1;
31+ }
32+ let mut end = col;
33+ while end < bytes.len() && is_word_byte(bytes[end]) {
34+ end += 1;
35+ }
36+ if start == end {
37+ return None;
38+ }
39+ Some(line[start..end].to_string())
40+}
41+
42+pub(crate) fn is_word_byte(c: u8) -> bool {
43+ c.is_ascii_alphanumeric() || c == b'@' || c == b'$' || c == b'-' || c == b'_' || c == b':'
44+}
45+
46+fn hover_variable(text: &str, name: &str) -> Option<String> {
47+ for line in text.lines() {
48+ let trimmed = line.trim();
49+ if let Some(rest) = trimmed.strip_prefix("@let ") {
50+ if let Some((n, v)) = rest.trim().split_once(' ') {
51+ if n == name {
52+ return Some(format!("**${}** = `{}`", name, v.trim()));
53+ }
54+ }
55+ }
56+ }
57+
58+ for line in text.lines() {
59+ let trimmed = line.trim();
60+ if let Some(rest) = trimmed.strip_prefix("@define ") {
61+ let rest = rest.trim();
62+ if let Some(bracket) = rest.find('[') {
63+ if rest[..bracket].trim() == name {
64+ return Some(format!(
65+ "**${}** \u{2014} Attribute bundle\n\n`{}`",
66+ name, trimmed
67+ ));
68+ }
69+ }
70+ }
71+ }
72+
73+ for line in text.lines() {
74+ let trimmed = line.trim();
75+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
76+ let parts: Vec<&str> = rest.split_whitespace().collect();
77+ if let Some(fn_name) = parts.first() {
78+ for param in &parts[1..] {
79+ let p = param.strip_prefix('$').unwrap_or(param);
80+ if p == name {
81+ return Some(format!(
82+ "**${}** \u{2014} Parameter of `@{}`",
83+ name, fn_name
84+ ));
85+ }
86+ }
87+ }
88+ }
89+ }
90+
91+ None
92+}
93+
94+fn hover_user_fn(text: &str, name: &str) -> Option<String> {
95+ let lines: Vec<&str> = text.lines().collect();
96+ for (i, line) in lines.iter().enumerate() {
97+ let trimmed = line.trim();
98+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
99+ let parts: Vec<&str> = rest.split_whitespace().collect();
100+ if parts.first() == Some(&name) {
101+ let params = &parts[1..];
102+
103+ // Collect doc-comment lines above the @fn (lines starting with --)
104+ let mut doc_lines: Vec<&str> = Vec::new();
105+ let mut j = i;
106+ while j > 0 {
107+ j -= 1;
108+ let prev = lines[j].trim();
109+ if let Some(comment) = prev.strip_prefix("-- ") {
110+ doc_lines.push(comment);
111+ } else if prev.starts_with("--") {
112+ doc_lines.push(&prev[2..]);
113+ } else {
114+ break;
115+ }
116+ }
117+ doc_lines.reverse();
118+
119+ let doc_str = if doc_lines.is_empty() {
120+ String::new()
121+ } else {
122+ format!("\n\n{}", doc_lines.join("\n"))
123+ };
124+
125+ // Format params showing defaults
126+ let params_str = if params.is_empty() {
127+ String::new()
128+ } else {
129+ let formatted: Vec<String> = params.iter().map(|p| {
130+ if p.contains('=') {
131+ let (name, default) = p.split_once('=').unwrap();
132+ format!("{} (default: {})", name, default)
133+ } else {
134+ p.to_string()
135+ }
136+ }).collect();
137+ format!("\n\nParameters: {}", formatted.join(", "))
138+ };
139+
140+ return Some(format!(
141+ "**@{}** \u{2014} User function{}{}",
142+ name, params_str, doc_str
143+ ));
144+ }
145+ }
146+ }
147+ None
148+}
149+
150+fn hover_builtin(word: &str) -> Option<String> {
151+ // Strip state prefix for attribute lookup
152+ let (state, base) = if let Some(rest) = word.strip_prefix("hover:") {
153+ (Some("hover"), rest)
154+ } else if let Some(rest) = word.strip_prefix("active:") {
155+ (Some("active"), rest)
156+ } else if let Some(rest) = word.strip_prefix("focus:") {
157+ (Some("focus"), rest)
158+ } else if let Some(rest) = word.strip_prefix("focus-visible:") {
159+ (Some("focus-visible"), rest)
160+ } else if let Some(rest) = word.strip_prefix("focus-within:") {
161+ (Some("focus-within"), rest)
162+ } else if let Some(rest) = word.strip_prefix("disabled:") {
163+ (Some("disabled"), rest)
164+ } else if let Some(rest) = word.strip_prefix("checked:") {
165+ (Some("checked"), rest)
166+ } else if let Some(rest) = word.strip_prefix("placeholder:") {
167+ (Some("placeholder"), rest)
168+ } else if let Some(rest) = word.strip_prefix("first:") {
169+ (Some("first"), rest)
170+ } else if let Some(rest) = word.strip_prefix("last:") {
171+ (Some("last"), rest)
172+ } else if let Some(rest) = word.strip_prefix("odd:") {
173+ (Some("odd"), rest)
174+ } else if let Some(rest) = word.strip_prefix("even:") {
175+ (Some("even"), rest)
176+ } else if let Some(rest) = word.strip_prefix("before:") {
177+ (Some("before"), rest)
178+ } else if let Some(rest) = word.strip_prefix("after:") {
179+ (Some("after"), rest)
180+ } else if let Some(rest) = word.strip_prefix("sm:") {
181+ (Some("sm"), rest)
182+ } else if let Some(rest) = word.strip_prefix("md:") {
183+ (Some("md"), rest)
184+ } else if let Some(rest) = word.strip_prefix("lg:") {
185+ (Some("lg"), rest)
186+ } else if let Some(rest) = word.strip_prefix("xl:") {
187+ (Some("xl"), rest)
188+ } else if let Some(rest) = word.strip_prefix("2xl:") {
189+ (Some("2xl"), rest)
190+ } else if let Some(rest) = word.strip_prefix("motion-safe:") {
191+ (Some("motion-safe"), rest)
192+ } else if let Some(rest) = word.strip_prefix("motion-reduce:") {
193+ (Some("motion-reduce"), rest)
194+ } else if let Some(rest) = word.strip_prefix("landscape:") {
195+ (Some("landscape"), rest)
196+ } else if let Some(rest) = word.strip_prefix("portrait:") {
197+ (Some("portrait"), rest)
198+ } else if let Some(rest) = word.strip_prefix("visited:") {
199+ (Some("visited"), rest)
200+ } else if let Some(rest) = word.strip_prefix("empty:") {
201+ (Some("empty"), rest)
202+ } else if let Some(rest) = word.strip_prefix("target:") {
203+ (Some("target"), rest)
204+ } else if let Some(rest) = word.strip_prefix("valid:") {
205+ (Some("valid"), rest)
206+ } else if let Some(rest) = word.strip_prefix("invalid:") {
207+ (Some("invalid"), rest)
208+ } else {
209+ (None, word)
210+ };
211+
212+ let doc = match base {
213+ // Elements
214+ "@row" => "**@row** \u{2014} Horizontal layout\n\nRenders as `<div>` with `display: flex; flex-direction: row`.\n\nChildren are laid out left-to-right.",
215+ "@column" | "@col" => "**@column** \u{2014} Vertical layout\n\nRenders as `<div>` with `display: flex; flex-direction: column`.\n\nChildren are laid out top-to-bottom.",
216+ "@el" => "**@el** \u{2014} Generic container\n\nRenders as `<div>` with column flex layout.",
217+ "@text" => "**@text** \u{2014} Inline text\n\nRenders as `<span>`.\n\nUsage: `@text [bold, size 24] Hello world`",
218+ "@paragraph" | "@p" => "**@paragraph** \u{2014} Text block\n\nRenders as `<p>`.\n\nSupports inline elements: `{@text [bold] word}`",
219+ "@image" | "@img" => "**@image** \u{2014} Image\n\nRenders as `<img>`.\n\nUsage: `@image [width 200] https://example.com/photo.jpg`",
220+ "@link" => "**@link** \u{2014} Hyperlink\n\nRenders as `<a>`.\n\nUsage: `@link [color blue] https://example.com Link text`",
221+ "@raw" => "**@raw** \u{2014} Raw HTML\n\nPasses content through without processing.\n\nUsage: `@raw \"\"\"<div>custom html</div>\"\"\"`",
222+ "@page" => "**@page** \u{2014} Page title\n\nSets the HTML `<title>` and wraps output in a full document.\n\nUsage: `@page My Page Title`",
223+ "@let" => "**@let** \u{2014} Variable\n\nDefines a variable for `$name` substitution.\n\nUsage: `@let primary #3b82f6`",
224+ "@define" => "**@define** \u{2014} Attribute bundle\n\nDefines a reusable set of attributes.\n\nUsage: `@define card-style [padding 20, rounded 8]`\n\nApply with `$card-style` in attribute lists.",
225+ "@fn" => "**@fn** \u{2014} Function\n\nDefines a reusable component.\n\n```\n@fn card $title\n @el [padding 20]\n @text [bold] $title\n @children\n```",
226+ "@keyframes" => "**@keyframes** \u{2014} CSS Animation\n\nDefines keyframes for CSS animations.\n\n```\n@keyframes fade-in\n from{opacity:0}to{opacity:1}\n```\n\nUse with `animation` attribute: `[animation fade-in 0.3s ease]`",
227+ "@children" => "**@children** \u{2014} Children slot\n\nPlaceholder inside `@fn` body replaced with the caller's children.",
228+ "@input" => "**@input** \u{2014} Form input\n\nRenders as self-closing `<input>`.\n\nUsage: `@input [type text, placeholder Name, name user]`",
229+ "@button" | "@btn" => "**@button** \u{2014} Button\n\nRenders as `<button>`.\n\nUsage: `@button [type submit] Click me`",
230+ "@select" => "**@select** \u{2014} Select dropdown\n\nRenders as `<select>`. Use `@option` children.\n\nUsage: `@select [name color]`",
231+ "@textarea" => "**@textarea** \u{2014} Multi-line text input\n\nRenders as `<textarea>`.\n\nUsage: `@textarea [name bio, rows 4] Default text`",
232+ "@option" | "@opt" => "**@option** \u{2014} Select option\n\nRenders as `<option>`.\n\nUsage: `@option [value red] Red`",
233+ "@label" => "**@label** \u{2014} Form label\n\nRenders as `<label>`.\n\nUsage: `@label [for email] Email Address`",
234+ "@if" => "**@if** \u{2014} Conditional\n\nConditionally includes children at compile time.\n\n```\n@if $theme == dark\n @el [background #333]\n@else if $theme == light\n @el [background white]\n@else\n @el [background gray]\n```",
235+ "@each" => "**@each** \u{2014} Loop\n\nRepeat children for each item in a comma-separated list.\nOptional index variable.\n\n```\n@each $color, $i in red,green,blue\n @text $i: $color\n```",
236+ "@include" => "**@include** \u{2014} Include file\n\nIncludes another .hl file (DOM nodes + definitions).\n\nUsage: `@include header.hl`",
237+ "@import" => "**@import** \u{2014} Import definitions\n\nImports `@let`, `@define`, `@fn` from another .hl file without emitting DOM nodes.\n\nUsage: `@import theme.hl`",
238+ "@meta" => "**@meta** \u{2014} Meta tag\n\nAdds a `<meta>` tag to `<head>`.\n\nUsage: `@meta description A portfolio site`",
239+ "@head" => "**@head** \u{2014} Head content\n\nAdds raw content to `<head>` (fonts, icons, etc.).\n\n```\n@head\n <link rel=\"icon\" href=\"favicon.ico\">\n```",
240+ "@style" => "**@style** \u{2014} Custom CSS\n\nAdds raw CSS to the stylesheet.\n\n```\n@style\n .custom { border: 1px solid red; }\n @container sidebar (min-width: 400px) { ... }\n```",
241+ "@slot" => "**@slot** \u{2014} Named slot\n\nDefines a named insertion point inside `@fn`. Callers fill it with `@slot name` + children.\n\n```\n@fn layout\n @slot header\n @children\n @slot footer\n```",
242+ // Semantic elements
243+ "@nav" => "**@nav** \u{2014} Navigation\n\nRenders as `<nav>`. Semantic landmark for navigation links.",
244+ "@header" => "**@header** \u{2014} Header\n\nRenders as `<header>`. Page or section header.",
245+ "@footer" => "**@footer** \u{2014} Footer\n\nRenders as `<footer>`. Page or section footer.",
246+ "@main" => "**@main** \u{2014} Main content\n\nRenders as `<main>`. Primary content of the page.",
247+ "@section" => "**@section** \u{2014} Section\n\nRenders as `<section>`. Thematic grouping of content.",
248+ "@article" => "**@article** \u{2014} Article\n\nRenders as `<article>`. Self-contained, independently distributable content.",
249+ "@aside" => "**@aside** \u{2014} Aside\n\nRenders as `<aside>`. Content tangentially related to surrounding content.",
250+ // List elements
251+ "@list" => "**@list** \u{2014} List\n\nRenders as `<ul>` (or `<ol>` with `[ordered]`).\n\nUsage:\n```\n@list [ordered]\n @item First\n @item Second\n```",
252+ "@item" | "@li" => "**@item** \u{2014} List item\n\nRenders as `<li>`. Use inside `@list`.",
253+ // Table elements
254+ "@table" => "**@table** \u{2014} Table\n\nRenders as `<table>`.\n\n```\n@table\n @thead\n @tr\n @th Name\n @th Age\n @tbody\n @tr\n @td Alice\n @td 30\n```",
255+ "@thead" => "**@thead** \u{2014} Table head\n\nRenders as `<thead>`. Groups header rows.",
256+ "@tbody" => "**@tbody** \u{2014} Table body\n\nRenders as `<tbody>`. Groups body rows.",
257+ "@tr" => "**@tr** \u{2014} Table row\n\nRenders as `<tr>`.",
258+ "@td" => "**@td** \u{2014} Table cell\n\nRenders as `<td>`. Regular table data cell.",
259+ "@th" => "**@th** \u{2014} Table header cell\n\nRenders as `<th>`. Header cell (typically bold/centered).",
260+ // Media elements
261+ "@video" => "**@video** \u{2014} Video\n\nRenders as `<video>`.\n\nUsage: `@video [controls] demo.mp4`",
262+ "@audio" => "**@audio** \u{2014} Audio\n\nRenders as `<audio>`.\n\nUsage: `@audio [controls] song.mp3`",
263+ // Additional elements
264+ "@form" => "**@form** \u{2014} Form\n\nRenders as `<form>`. Container for form elements.\n\nUsage: `@form [method post] /submit`",
265+ "@details" => "**@details** \u{2014} Disclosure\n\nRenders as `<details>`. Use `[open]` for initially expanded.\n\nContains `@summary` for the toggle label.",
266+ "@summary" => "**@summary** \u{2014} Summary\n\nRenders as `<summary>`. Toggle label inside `@details`.\n\nUsage: `@summary Click to expand`",
267+ "@blockquote" => "**@blockquote** \u{2014} Block quotation\n\nRenders as `<blockquote>`. Semantic quotation container.",
268+ "@cite" => "**@cite** \u{2014} Citation\n\nRenders as `<cite>`. Source or reference for a quotation.\n\nUsage: `@cite The Great Gatsby`",
269+ "@code" => "**@code** \u{2014} Code\n\nRenders as `<code>` with monospace font.\n\nUsage: `@code console.log(\"hello\")`",
270+ "@pre" => "**@pre** \u{2014} Preformatted\n\nRenders as `<pre>` with preserved whitespace and monospace font.",
271+ "@hr" | "@divider" => "**@hr** \u{2014} Horizontal Rule\n\nRenders as self-closing `<hr>`. Visual divider.\n\nUsage: `@hr [border-top 1 #ccc]`",
272+ "@figure" => "**@figure** \u{2014} Figure\n\nRenders as `<figure>`. Container for media with optional `@figcaption`.\n\n```\n@figure\n @image photo.jpg\n @figcaption A nice photo\n```",
273+ "@figcaption" => "**@figcaption** \u{2014} Figure caption\n\nRenders as `<figcaption>`. Caption text inside `@figure`.",
274+ "@progress" => "**@progress** \u{2014} Progress bar\n\nRenders as `<progress>`.\n\nUsage: `@progress [value 70, max 100]`",
275+ "@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]`",
276+ "@fragment" => "**@fragment** \u{2014} Fragment\n\nGroups children without emitting a wrapper element. Renders children directly in the parent.",
277+ // New elements
278+ "@dialog" => "**@dialog** \u{2014} Dialog\n\nRenders as `<dialog>`. Modal or non-modal dialog box.\n\nUsage: `@dialog [open] Dialog content`",
279+ "@dl" => "**@dl** \u{2014} Description list\n\nRenders as `<dl>`. Contains `@dt` and `@dd` pairs.",
280+ "@dt" => "**@dt** \u{2014} Description term\n\nRenders as `<dt>`. Term in a `@dl` description list.",
281+ "@dd" => "**@dd** \u{2014} Description details\n\nRenders as `<dd>`. Details for a `@dt` term.",
282+ "@fieldset" => "**@fieldset** \u{2014} Fieldset\n\nRenders as `<fieldset>`. Groups related form elements.\n\nUse `@legend` for a caption.",
283+ "@legend" => "**@legend** \u{2014} Legend\n\nRenders as `<legend>`. Caption for a `@fieldset`.",
284+ "@picture" => "**@picture** \u{2014} Picture\n\nRenders as `<picture>`. Container for responsive image sources.\n\nUse `@source` children for different media queries.",
285+ "@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]`",
286+ "@time" => "**@time** \u{2014} Time\n\nRenders as `<time>`. Machine-readable date/time.\n\nUsage: `@time [datetime 2024-01-15] January 15`",
287+ "@mark" => "**@mark** \u{2014} Mark\n\nRenders as `<mark>`. Highlighted or marked text.",
288+ "@kbd" => "**@kbd** \u{2014} Keyboard input\n\nRenders as `<kbd>`. Represents keyboard input.\n\nUsage: `@kbd Ctrl+C`",
289+ "@abbr" => "**@abbr** \u{2014} Abbreviation\n\nRenders as `<abbr>`. Abbreviation with optional title.\n\nUsage: `@abbr [title Hypertext Markup Language] HTML`",
290+ "@datalist" => "**@datalist** \u{2014} Datalist\n\nRenders as `<datalist>`. Provides predefined options for `@input`.\n\nUsage: `@datalist [id colors]`",
291+ // Directives
292+ "@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```",
293+ "@case" => "**@case** \u{2014} Match case\n\nA case inside `@match`. Matches when the value equals the case value.",
294+ "@default" => "**@default** \u{2014} Default case\n\nFallback case inside `@match` when no other case matches.",
295+ "@warn" => "**@warn** \u{2014} Compile warning\n\nEmit a custom warning during compilation.\n\nUsage: `@warn This value is deprecated`",
296+ "@debug" => "**@debug** \u{2014} Debug message\n\nPrint a debug message to stderr during compilation.\n\nUsage: `@debug Theme is $theme`",
297+ "@lang" => "**@lang** \u{2014} Document language\n\nSets the `lang` attribute on the `<html>` element.\n\nUsage: `@lang en`",
298+ "@favicon" => "**@favicon** \u{2014} Favicon\n\nInlines a favicon as a base64 data URI in the `<head>`.\n\nUsage: `@favicon favicon.png`",
299+ "@unless" => "**@unless** \u{2014} Inverse conditional\n\nRenders children when the condition is false (opposite of `@if`).\n\nUsage: `@unless $debug`",
300+ "@og" => "**@og** \u{2014} Open Graph meta tag\n\nAdds an Open Graph `<meta>` tag to `<head>`.\n\nUsage: `@og title My Page Title`",
301+ "@breakpoint" => "**@breakpoint** \u{2014} Custom breakpoint\n\nDefines a custom responsive breakpoint.\n\nUsage: `@breakpoint tablet 600`",
302+ "@theme" => "**@theme** \u{2014} Design tokens\n\nDefines centralized design tokens (colors, spacing, fonts).\nEach token becomes both a `$variable` and a `--css-custom-property`.\n\n```\n@theme\n primary #3b82f6\n spacing-md 16\n font-body system-ui, sans-serif\n```",
303+ "@deprecated" => "**@deprecated** `<message>`\n\nMarks the next `@fn` as deprecated. Callers get a compile-time warning.\n\n```\n@deprecated Use @new-card instead\n@fn old-card $title\n ...\n```",
304+ "@extends" => "**@extends** `<file.hl>`\n\nInherit a layout template. Fill named `@slot` blocks.\n\n```\n@extends layout.hl\n@slot content\n My page content\n@slot sidebar\n Sidebar content\n```",
305+ "@use" => "**@use** `<file.hl> name1, name2`\n\nSelective import: only imports named `@fn`/`@define` definitions.\n\n```\n@use components.hl card, button\n```",
306+ "@canonical" => "**@canonical** `<url>`\n\nSets the canonical URL for the page. Adds `<link rel=\"canonical\">` to `<head>`.\n\nUsage: `@canonical https://example.com/page`",
307+ "@base" => "**@base** `<url>`\n\nSets the base URL for all relative URLs in the document. Adds `<base>` to `<head>`.\n\nUsage: `@base https://example.com/`",
308+ "@font-face" => "**@font-face** \u{2014} Custom font\n\nDefines a custom font face. Generates a CSS `@font-face` rule.\n\n```\n@font-face\n family Inter\n src url(/fonts/Inter.woff2)\n weight 400 700\n```",
309+ "@json-ld" => "**@json-ld** \u{2014} Structured data\n\nAdds JSON-LD structured data to `<head>` as `<script type=\"application/ld+json\">`.\n\n```\n@json-ld\n type Organization\n name My Company\n url https://example.com\n```",
310+ // Attributes
311+ "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
312+ "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding. Supports CSS units. Accepts 1\u{2013}4 values.",
313+ "padding-x" => "**padding-x** `<value>`\n\nHorizontal padding (left + right). Supports CSS units.",
314+ "padding-y" => "**padding-y** `<value>`\n\nVertical padding (top + bottom). Supports CSS units.",
315+ "width" => "**width** `<value>` | `fill` | `shrink`\n\n- Number/unit: fixed width (e.g., `300`, `50%`, `80ch`)\n- `fill`: expand to fill parent\n- `shrink`: prevent flex shrinking",
316+ "height" => "**height** `<value>` | `fill` | `shrink`\n\n- Number/unit: fixed height (e.g., `300`, `100vh`)\n- `fill`: expand to fill parent\n- `shrink`: prevent flex shrinking",
317+ "min-width" => "**min-width** `<value>` \u{2014} Minimum width. Supports CSS units.",
318+ "max-width" => "**max-width** `<value>` \u{2014} Maximum width. Supports CSS units.",
319+ "min-height" => "**min-height** `<value>` \u{2014} Minimum height. Supports CSS units.",
320+ "max-height" => "**max-height** `<value>` \u{2014} Maximum height. Supports CSS units.",
321+ "center-x" => "**center-x**\n\nCenter horizontally.\n\nIn column parent: `align-self: center`\nOtherwise: auto margins.",
322+ "center-y" => "**center-y**\n\nCenter vertically.\n\nIn row parent: `align-self: center`\nOtherwise: auto margins.",
323+ "align-left" => "**align-left** \u{2014} Align to the left edge.",
324+ "align-right" => "**align-right** \u{2014} Align to the right edge.",
325+ "align-top" => "**align-top** \u{2014} Align to the top edge.",
326+ "align-bottom" => "**align-bottom** \u{2014} Align to the bottom edge.",
327+ "background" => "**background** `<color>` \u{2014} Background color or CSS background value.",
328+ "color" => "**color** `<color>` \u{2014} Text color.",
329+ "border" => "**border** `<width> [color]`\n\nBorder. Width in pixels, color defaults to `currentColor`.",
330+ "border-top" => "**border-top** `<width> [color]` \u{2014} Top border.",
331+ "border-bottom" => "**border-bottom** `<width> [color]` \u{2014} Bottom border.",
332+ "border-left" => "**border-left** `<width> [color]` \u{2014} Left border.",
333+ "border-right" => "**border-right** `<width> [color]` \u{2014} Right border.",
334+ "rounded" => "**rounded** `<value>` \u{2014} Border radius. Supports CSS units.",
335+ "bold" => "**bold** \u{2014} Bold text (`font-weight: bold`).",
336+ "italic" => "**italic** \u{2014} Italic text (`font-style: italic`).",
337+ "underline" => "**underline** \u{2014} Underlined text.",
338+ "size" => "**size** `<value>` \u{2014} Font size in pixels.",
339+ "font" => "**font** `<family>` \u{2014} Font family.",
340+ "transition" => "**transition** `<value>` \u{2014} CSS transition (e.g., `all 0.15s ease`).",
341+ "cursor" => "**cursor** `<value>` \u{2014} CSS cursor (e.g., `pointer`).",
342+ "opacity" => "**opacity** `<value>` \u{2014} Opacity from 0 to 1.",
343+ "text-align" => "**text-align** `<value>` \u{2014} Text alignment (`left`, `center`, `right`, `justify`).",
344+ "line-height" => "**line-height** `<value>` \u{2014} Line height. Unitless (e.g., `1.5`) or pixels.",
345+ "overflow" => "**overflow** `<value>` \u{2014} Overflow behavior (`hidden`, `scroll`, `auto`, `visible`).",
346+ "position" => "**position** `<value>` \u{2014} Position type (`relative`, `absolute`, `fixed`, `sticky`).",
347+ "top" => "**top** `<value>` \u{2014} Top offset for positioned elements.",
348+ "right" => "**right** `<value>` \u{2014} Right offset for positioned elements.",
349+ "bottom" => "**bottom** `<value>` \u{2014} Bottom offset for positioned elements.",
350+ "left" => "**left** `<value>` \u{2014} Left offset for positioned elements.",
351+ "z-index" => "**z-index** `<value>` \u{2014} Stack order (integer).",
352+ "display" => "**display** `<value>` \u{2014} Display mode (`none`, `block`, `inline`, `flex`, `grid`).",
353+ "visibility" => "**visibility** `<value>` \u{2014} Visibility (`visible`, `hidden`).",
354+ "transform" => "**transform** `<value>` \u{2014} CSS transform (e.g., `rotate(45deg)`, `scale(1.5)`).",
355+ "backdrop-filter" => "**backdrop-filter** `<value>` \u{2014} Backdrop filter (e.g., `blur(10px)`).",
356+ "letter-spacing" => "**letter-spacing** `<value>` \u{2014} Letter spacing. Supports CSS units.",
357+ "text-transform" => "**text-transform** `<value>` \u{2014} Text transform (`uppercase`, `lowercase`, `capitalize`).",
358+ "white-space" => "**white-space** `<value>` \u{2014} White-space behavior (`nowrap`, `pre`, `normal`).",
359+ "grid" => "**grid** \u{2014} Enable CSS grid layout on this element.",
360+ "grid-cols" => "**grid-cols** `<value>` \u{2014} Grid template columns. Number for equal columns, or CSS value.",
361+ "grid-rows" => "**grid-rows** `<value>` \u{2014} Grid template rows. Number for equal rows, or CSS value.",
362+ "col-span" => "**col-span** `<value>` \u{2014} Span N columns in a grid.",
363+ "row-span" => "**row-span** `<value>` \u{2014} Span N rows in a grid.",
364+ "shadow" => "**shadow** `<value>` \u{2014} Box shadow. Raw CSS value (e.g., `0 2px 4px rgba(0,0,0,0.1)`).",
365+ "gap-x" => "**gap-x** `<value>` \u{2014} Horizontal gap between children in pixels. Maps to `column-gap`.",
366+ "gap-y" => "**gap-y** `<value>` \u{2014} Vertical gap between children in pixels. Maps to `row-gap`.",
367+ "wrap" => "**wrap** \u{2014} Enable flex-wrap for children.",
368+ "id" => "**id** `<value>` \u{2014} HTML id attribute.",
369+ "class" => "**class** `<value>` \u{2014} HTML class attribute.",
370+ "animation" => "**animation** `<value>` \u{2014} CSS animation shorthand (e.g., `fade-in 0.3s ease`).\n\nDefine animations with `@keyframes`.",
371+ "container" => "**container** \u{2014} Enable container queries (`container-type: inline-size`).",
372+ "container-name" => "**container-name** `<value>` \u{2014} Name this container for `@container` queries.",
373+ "container-type" => "**container-type** `<value>` \u{2014} Container type (`inline-size`, `size`, `normal`).",
374+ // Form attributes
375+ "type" => "**type** `<value>` \u{2014} Input type (`text`, `email`, `password`, `submit`, etc.).",
376+ "placeholder" => "**placeholder** `<value>` \u{2014} Placeholder text for inputs.",
377+ "name" => "**name** `<value>` \u{2014} Form field name.",
378+ "value" => "**value** `<value>` \u{2014} Form field value.",
379+ "disabled" => "**disabled** \u{2014} Disable the element.",
380+ "required" => "**required** \u{2014} Mark field as required.",
381+ "checked" => "**checked** \u{2014} Checkbox/radio checked state.",
382+ "for" => "**for** `<id>` \u{2014} Label target (id of the associated input).",
383+ "rows" => "**rows** `<value>` \u{2014} Number of visible rows for textarea.",
384+ "cols" => "**cols** `<value>` \u{2014} Number of visible columns for textarea.",
385+ "maxlength" => "**maxlength** `<value>` \u{2014} Maximum input length.",
386+ // Accessibility
387+ "alt" => "**alt** `<value>` \u{2014} Alternative text for images.",
388+ "role" => "**role** `<value>` \u{2014} ARIA role (e.g., `navigation`, `banner`, `main`).",
389+ "tabindex" => "**tabindex** `<value>` \u{2014} Tab order. `0` = natural order, `-1` = skip.",
390+ "title" => "**title** `<value>` \u{2014} Tooltip text.",
391+ // New CSS attributes
392+ "aspect-ratio" => "**aspect-ratio** `<value>` \u{2014} CSS aspect ratio (e.g., `16/9`, `1`, `4/3`).",
393+ "outline" => "**outline** `<width> [color]` \u{2014} Outline (like border but doesn't affect layout).",
394+ "padding-inline" => "**padding-inline** `<value>` \u{2014} Horizontal padding (logical property, i18n-aware).",
395+ "padding-block" => "**padding-block** `<value>` \u{2014} Vertical padding (logical property, i18n-aware).",
396+ "margin-inline" => "**margin-inline** `<value>` \u{2014} Horizontal margin (logical property, i18n-aware).",
397+ "margin-block" => "**margin-block** `<value>` \u{2014} Vertical margin (logical property, i18n-aware).",
398+ "scroll-snap-type" => "**scroll-snap-type** `<value>` \u{2014} Scroll snap type (`x mandatory`, `y proximity`).",
399+ "scroll-snap-align" => "**scroll-snap-align** `<value>` \u{2014} Scroll snap alignment (`start`, `center`, `end`).",
400+ // Media/image attributes
401+ "loading" => "**loading** `<value>` \u{2014} Loading behavior for images (`lazy`, `eager`).",
402+ "decoding" => "**decoding** `<value>` \u{2014} Image decoding mode (`async`, `sync`, `auto`).",
403+ "controls" => "**controls** \u{2014} Show media controls (for @video, @audio).",
404+ "autoplay" => "**autoplay** \u{2014} Auto-play media.",
405+ "loop" => "**loop** \u{2014} Loop media playback.",
406+ "muted" => "**muted** \u{2014} Mute media.",
407+ "poster" => "**poster** `<url>` \u{2014} Poster image for video.",
408+ "preload" => "**preload** `<value>` \u{2014} Media preload hint (`auto`, `metadata`, `none`).",
409+ "ordered" => "**ordered** \u{2014} Use ordered list (`<ol>` instead of `<ul>`).",
410+ "src" => "**src** `<url>` \u{2014} Source URL for media elements.",
411+ // New CSS attributes
412+ "margin" => "**margin** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nOuter margin. Supports CSS units. Accepts 1\u{2013}4 values.",
413+ "margin-x" => "**margin-x** `<value>` \u{2014} Horizontal margin (left + right).",
414+ "margin-y" => "**margin-y** `<value>` \u{2014} Vertical margin (top + bottom).",
415+ "filter" => "**filter** `<value>` \u{2014} CSS filter (e.g., `blur(5px)`, `brightness(1.2)`, `grayscale(1)`).",
416+ "object-fit" => "**object-fit** `<value>` \u{2014} How content fits its container (`cover`, `contain`, `fill`, `none`, `scale-down`).",
417+ "object-position" => "**object-position** `<value>` \u{2014} Position of content within container (e.g., `center`, `top left`).",
418+ "text-shadow" => "**text-shadow** `<value>` \u{2014} Text shadow. Raw CSS value (e.g., `1px 1px 2px rgba(0,0,0,0.3)`).",
419+ "text-overflow" => "**text-overflow** `<value>` \u{2014} Text overflow behavior (`ellipsis`, `clip`). Combine with `white-space nowrap` and `overflow hidden`.",
420+ "pointer-events" => "**pointer-events** `<value>` \u{2014} Pointer event behavior (`none`, `auto`).",
421+ "user-select" => "**user-select** `<value>` \u{2014} Text selection behavior (`none`, `text`, `all`, `auto`).",
422+ "justify-content" => "**justify-content** `<value>` \u{2014} Main axis alignment (`center`, `space-between`, `space-around`, `flex-start`, `flex-end`).",
423+ "align-items" => "**align-items** `<value>` \u{2014} Cross axis alignment (`center`, `flex-start`, `flex-end`, `stretch`, `baseline`).",
424+ "order" => "**order** `<value>` \u{2014} Flex/grid item order (integer).",
425+ "background-size" => "**background-size** `<value>` \u{2014} Background size (`cover`, `contain`, `auto`, or dimensions).",
426+ "background-position" => "**background-position** `<value>` \u{2014} Background position (`center`, `top`, `bottom left`, etc.).",
427+ "background-repeat" => "**background-repeat** `<value>` \u{2014} Background repeat (`no-repeat`, `repeat`, `repeat-x`, `repeat-y`).",
428+ "word-break" => "**word-break** `<value>` \u{2014} Word breaking behavior (`break-all`, `keep-all`, `normal`).",
429+ "overflow-wrap" => "**overflow-wrap** `<value>` \u{2014} Overflow wrapping (`break-word`, `anywhere`, `normal`).",
430+ // New element attributes
431+ "open" => "**open** \u{2014} Initially expand `@details` element.",
432+ "novalidate" => "**novalidate** \u{2014} Disable form validation.",
433+ "low" => "**low** `<value>` \u{2014} Low threshold for `@meter`.",
434+ "high" => "**high** `<value>` \u{2014} High threshold for `@meter`.",
435+ "optimum" => "**optimum** `<value>` \u{2014} Optimum value for `@meter`.",
436+ "colspan" => "**colspan** `<value>` \u{2014} Number of columns a cell spans.",
437+ "rowspan" => "**rowspan** `<value>` \u{2014} Number of rows a cell spans.",
438+ "scope" => "**scope** `<value>` \u{2014} Header scope (`col`, `row`, `colgroup`, `rowgroup`).",
439+ "inline" => "**inline** \u{2014} Inline SVG image content into the HTML output.",
440+ "color-scheme" => "**color-scheme** `<value>` \u{2014} Color scheme preference (`light`, `dark`, `light dark`).",
441+ "appearance" => "**appearance** `<value>` \u{2014} Form element appearance (`none` to remove native styling).",
442+ "popover" => "**popover** \u{2014} Make element a popover (HTML Popover API). Shown/hidden declaratively.",
443+ "popovertarget" => "**popovertarget** `<id>` \u{2014} ID of the popover element to toggle.",
444+ "popovertargetaction" => "**popovertargetaction** `<value>` \u{2014} Popover action (`toggle`, `show`, `hide`).",
445+ "inputmode" => "**inputmode** `<value>` \u{2014} Virtual keyboard type (`numeric`, `email`, `search`, `tel`, `url`).",
446+ "enterkeyhint" => "**enterkeyhint** `<value>` \u{2014} Enter key label (`done`, `go`, `next`, `search`, `send`).",
447+ "fetchpriority" => "**fetchpriority** `<value>` \u{2014} Resource fetch priority (`high`, `low`, `auto`).",
448+ "translate" => "**translate** `<value>` \u{2014} Whether element should be translated (`yes`, `no`).",
449+ "spellcheck" => "**spellcheck** `<value>` \u{2014} Spell check mode (`true`, `false`).",
450+ "hidden" => "**hidden** \u{2014} Hide element (`display: none`).",
451+ "overflow-x" => "**overflow-x** `<value>` \u{2014} Horizontal overflow (`hidden`, `scroll`, `auto`, `visible`).",
452+ "overflow-y" => "**overflow-y** `<value>` \u{2014} Vertical overflow (`hidden`, `scroll`, `auto`, `visible`).",
453+ "inset" => "**inset** `<value>` \u{2014} Shorthand for `top`, `right`, `bottom`, `left`. Maps to CSS `inset`.",
454+ "accent-color" => "**accent-color** `<color>` \u{2014} Accent color for form controls (checkboxes, radios, range).",
455+ "caret-color" => "**caret-color** `<color>` \u{2014} Color of the text input cursor.",
456+ "list-style" => "**list-style** `<value>` \u{2014} List style type (`disc`, `circle`, `square`, `decimal`, `none`).",
457+ "border-collapse" => "**border-collapse** `<value>` \u{2014} Table border model (`collapse`, `separate`).",
458+ "border-spacing" => "**border-spacing** `<value>` \u{2014} Spacing between table cell borders (when `border-collapse: separate`).",
459+ "text-decoration" => "**text-decoration** `<value>` \u{2014} Text decoration (`underline`, `overline`, `line-through`, `none`).",
460+ "text-decoration-color" => "**text-decoration-color** `<color>` \u{2014} Color of text decoration.",
461+ "text-decoration-thickness" => "**text-decoration-thickness** `<value>` \u{2014} Thickness of text decoration.",
462+ "text-decoration-style" => "**text-decoration-style** `<value>` \u{2014} Style of text decoration (`solid`, `dashed`, `dotted`, `wavy`, `double`).",
463+ "place-items" => "**place-items** `<value>` \u{2014} Shorthand for `align-items` and `justify-items`.",
464+ "place-self" => "**place-self** `<value>` \u{2014} Shorthand for `align-self` and `justify-self`.",
465+ "scroll-behavior" => "**scroll-behavior** `<value>` \u{2014} Scroll behavior (`smooth`, `auto`).",
466+ "resize" => "**resize** `<value>` \u{2014} Resize behavior (`none`, `both`, `horizontal`, `vertical`).",
467+ // New CSS attributes
468+ "clip-path" => "**clip-path** `<value>` \u{2014} Clip path (`circle()`, `polygon()`, `inset()`, `url()`).",
469+ "mix-blend-mode" => "**mix-blend-mode** `<value>` \u{2014} Blend mode (`multiply`, `screen`, `overlay`, `darken`, `lighten`).",
470+ "background-blend-mode" => "**background-blend-mode** `<value>` \u{2014} Background blend mode for layered backgrounds.",
471+ "writing-mode" => "**writing-mode** `<value>` \u{2014} Writing direction (`horizontal-tb`, `vertical-rl`, `vertical-lr`).",
472+ "column-count" => "**column-count** `<value>` \u{2014} Number of columns in multi-column layout.",
473+ "column-gap" => "**column-gap** `<value>` \u{2014} Gap between columns. Supports CSS units.",
474+ "text-indent" => "**text-indent** `<value>` \u{2014} Indentation of the first line of text.",
475+ "hyphens" => "**hyphens** `<value>` \u{2014} Hyphenation behavior (`none`, `manual`, `auto`).",
476+ "flex-grow" => "**flex-grow** `<value>` \u{2014} Flex grow factor (number). Controls how much an item grows.",
477+ "flex-shrink" => "**flex-shrink** `<value>` \u{2014} Flex shrink factor (number). Controls how much an item shrinks.",
478+ "flex-basis" => "**flex-basis** `<value>` \u{2014} Initial main size of a flex item (e.g., `200px`, `auto`, `0`).",
479+ "isolation" => "**isolation** `<value>` \u{2014} Creates a new stacking context (`isolate`, `auto`).",
480+ "place-content" => "**place-content** `<value>` \u{2014} Shorthand for `align-content` and `justify-content`.",
481+ "background-image" => "**background-image** `<value>` \u{2014} Background image (`url()` or gradient function).",
482+ // New CSS properties (batch 2)
483+ "font-weight" => "**font-weight** `<value>` \u{2014} Font weight (`100`\u{2013}`900`, `bold`, `lighter`, `bolder`). More precise than `bold`.",
484+ "font-style" => "**font-style** `<value>` \u{2014} Font style (`normal`, `italic`, `oblique`). More precise than `italic`.",
485+ "text-wrap" => "**text-wrap** `<value>` \u{2014} Text wrapping behavior (`balance`, `pretty`, `nowrap`, `wrap`). `balance` is great for headings.",
486+ "will-change" => "**will-change** `<value>` \u{2014} Performance hint for upcoming changes (`transform`, `opacity`, `scroll-position`).",
487+ "touch-action" => "**touch-action** `<value>` \u{2014} Touch behavior for mobile (`none`, `pan-x`, `pan-y`, `manipulation`, `auto`).",
488+ "vertical-align" => "**vertical-align** `<value>` \u{2014} Vertical alignment for inline elements (`middle`, `top`, `bottom`, `baseline`, `text-top`).",
489+ "contain" => "**contain** `<value>` \u{2014} CSS containment for performance (`layout`, `paint`, `content`, `strict`, `size`).",
490+ "content-visibility" => "**content-visibility** `<value>` \u{2014} Content rendering optimization (`auto`, `visible`, `hidden`). `auto` enables lazy rendering.",
491+ "scroll-margin" => "**scroll-margin** `<value>` \u{2014} Scroll margin (for scroll-snap and anchor link offsets). Supports CSS units.",
492+ "scroll-padding" => "**scroll-padding** `<value>` \u{2014} Scroll padding for scroll-snap containers. Supports CSS units.",
493+ "content" => "**content** `<value>` \u{2014} CSS content property. Use with `before:` or `after:` prefix.\n\nExample: `before:content \u{2192}, before:color red`",
494+ // New elements
495+ "@iframe" => "**@iframe** \u{2014} Embedded page\n\nRenders as `<iframe>`.\n\nUsage: `@iframe [width fill, height 400] https://example.com`\n\nAttributes: `sandbox`, `allow`, `allowfullscreen`",
496+ "@output" => "**@output** \u{2014} Form output\n\nRenders as `<output>`. Displays calculation results in forms.\n\nUsage: `@output [for a b] Result`",
497+ "@canvas" => "**@canvas** \u{2014} Drawing surface\n\nRenders as `<canvas>`. Use with `@raw` JavaScript for drawing.\n\nUsage: `@canvas [width 400, height 300, id myCanvas]`",
498+ "@script" => "**@script** \u{2014} Script\n\nRenders as `<script>`. Inline JavaScript.\n\nUsage: `@script console.log(\"hello\")`",
499+ "@noscript" => "**@noscript** \u{2014} NoScript fallback\n\nRenders as `<noscript>`. Shown when JavaScript is disabled.\n\nUsage: `@noscript Please enable JavaScript.`",
500+ "@address" => "**@address** \u{2014} Address\n\nRenders as `<address>`. Contact information for the nearest `@article` or `@body`.\n\nUsage: `@address hello@example.com`",
501+ "@search" => "**@search** \u{2014} Search\n\nRenders as `<search>`. Semantic container for search functionality.\n\nUsage:\n```\n@search\n @input [type search, placeholder Search...]\n @button Search\n```",
502+ "@breadcrumb" => "**@breadcrumb** \u{2014} Breadcrumb\n\nRenders as `<nav aria-label=\"Breadcrumb\">`. Semantic breadcrumb navigation.\n\nUsage:\n```\n@breadcrumb\n @link / Home\n @link /docs Docs\n @text Current Page\n```",
503+ // Convenience elements
504+ "@grid" => "**@grid** \u{2014} CSS Grid container\n\nRenders as `<div>` with `display: grid`.\n\nUsage: `@grid [grid-cols 3, gap 20]`",
505+ "@stack" => "**@stack** \u{2014} Stack container\n\nRenders as `<div>` with `position: relative`. Children can be absolutely positioned on top of each other.",
506+ "@spacer" => "**@spacer** \u{2014} Flexible spacer\n\nRenders as `<div>` with `flex: 1`. Pushes siblings apart in flex containers.",
507+ "@badge" => "**@badge** \u{2014} Badge\n\nRenders as `<span>` with pill shape, centered text.\n\nUsage: `@badge [background #3b82f6, color white] NEW`",
508+ "@tooltip" => "**@tooltip** \u{2014} Tooltip\n\nRenders as `<span>` with `title` attribute for native tooltips.\n\nUsage: `@tooltip [cursor help] Hover me`",
509+ "@avatar" => "**@avatar** \u{2014} Avatar\n\nRenders as `<div>` with circular shape, centered content, `overflow: hidden`.\n\nUsage:\n```\n@avatar [width 48, height 48, background #e5e7eb]\n @image [object-fit cover] photo.jpg\n```",
510+ "@carousel" => "**@carousel** \u{2014} Horizontal carousel\n\nRenders as `<div>` with horizontal scroll-snap.\nChildren auto-receive `scroll-snap-align: start` and `flex-shrink: 0`.\n\nUsage:\n```\n@carousel [gap 16]\n @el [width 300] Slide 1\n @el [width 300] Slide 2\n```",
511+ "@chip" => "**@chip** \u{2014} Chip / pill\n\nRenders as `<span>` with rounded borders, inline-flex layout.\n\nUsage: `@chip [background #e5e7eb] Category`",
512+ "@tag" => "**@tag** \u{2014} Tag label\n\nRenders as `<span>` with subtle rounded rectangle, bold small text.\n\nUsage: `@tag [background #dbeafe, color #1e40af] v2.0`",
513+ // CSS shorthands
514+ "truncate" => "**truncate** \u{2014} Truncate with ellipsis\n\nShorthand for: `overflow: hidden; text-overflow: ellipsis; white-space: nowrap`\n\nUsage: `@text [max-width 200, truncate] Long text here...`",
515+ "line-clamp" => "**line-clamp** `<N>` \u{2014} Multi-line truncation\n\nClamps text to N lines with ellipsis.\n\nUsage: `@paragraph [line-clamp 3] Long paragraph...`",
516+ "blur" => "**blur** `<value>` \u{2014} Apply blur filter\n\nShorthand for `filter: blur(Npx)`.\n\nUsage: `[blur 4]` \u{2192} `filter: blur(4px)`",
517+ "backdrop-blur" => "**backdrop-blur** `<value>` \u{2014} Apply backdrop blur\n\nShorthand for `backdrop-filter: blur(Npx)`.\n\nUsage: `[backdrop-blur 10]` \u{2192} `backdrop-filter: blur(10px)`",
518+ "no-scrollbar" => "**no-scrollbar** \u{2014} Hide scrollbar\n\nHides scrollbar while keeping overflow scrollable.\n\nSets `scrollbar-width: none` and `::-webkit-scrollbar { display: none }`.",
519+ "skeleton" => "**skeleton** \u{2014} Loading skeleton\n\nAdds a shimmer animation for loading placeholders.\n\nUsage: `@el [width fill, height 20, rounded 4, skeleton]`",
520+ "gradient" => "**gradient** `<from> <to> [angle]` \u{2014} Linear gradient\n\nShorthand for `background: linear-gradient(...)`.\n\nUsage:\n- `[gradient #fff #000]` \u{2192} top-to-bottom\n- `[gradient #fff #000 45deg]` \u{2192} 45\u{00b0} angle",
521+ "direction" => "**direction** `<value>` \u{2014} Text direction (`ltr`, `rtl`).",
522+ // Grid areas
523+ "grid-template-areas" => "**grid-template-areas** `<value>` \u{2014} Define named grid areas.\n\nUsage: `[grid, grid-template-areas \"header header\" \"sidebar main\"]`\n\nChildren use `grid-area` to place themselves.",
524+ "grid-area" => "**grid-area** `<name>` \u{2014} Place element in a named grid area.\n\nUsage: `@el [grid-area header] ...`\n\nRequires parent with `grid-template-areas`.",
525+ // View transitions
526+ "view-transition-name" => "**view-transition-name** `<name>` \u{2014} Assign a View Transition API name.\n\nEnables smooth transitions between pages.\n\nUsage: `@el [view-transition-name hero]`",
527+ // Animate shorthand
528+ "animate" => "**animate** `<name> <duration> [timing]` \u{2014} Animation shorthand.\n\nAlias for the CSS `animation` property.\n\nUsage: `@el [animate fade-in 0.3s ease]`\n\nRequires a matching `@keyframes fade-in` definition.",
529+ // Has pseudo-selector
530+ "has(" => "**has(selector):** \u{2014} Parent selector pseudo-class.\n\nStyle an element based on its children.\n\nUsage: `@el [has(.active):background blue, has(img):padding 0]`\n\nGenerates CSS `:has()` selector.",
531+ // Critical
532+ "critical" => "**critical** \u{2014} Mark element as above-fold.\n\nHint for build tools to prioritize this element's CSS.",
533+ // New CSS properties
534+ "text-underline-offset" => "**text-underline-offset** `<value>` \u{2014} Offset of text underline from its default position. Supports CSS units.\n\nUsage: `[underline, text-underline-offset 4]`",
535+ "column-width" => "**column-width** `<value>` \u{2014} Ideal column width in multi-column layout. Browser determines column count.\n\nUsage: `[column-width 200]`",
536+ "column-rule" => "**column-rule** `<value>` \u{2014} Rule between columns (shorthand for width, style, color).\n\nUsage: `[column-rule 1px solid #ccc]`",
537+ // Variable filters
538+ "\\$|uppercase" => "**|uppercase** \u{2014} Convert variable to UPPERCASE.\n\nUsage: `$name|uppercase`",
539+ "\\$|lowercase" => "**|lowercase** \u{2014} Convert variable to lowercase.\n\nUsage: `$name|lowercase`",
540+ "\\$|capitalize" => "**|capitalize** \u{2014} Capitalize first letter.\n\nUsage: `$name|capitalize`",
541+ "\\$|trim" => "**|trim** \u{2014} Strip leading/trailing whitespace.\n\nUsage: `$name|trim`",
542+ "\\$|length" => "**|length** \u{2014} Get string length.\n\nUsage: `$name|length`",
543+ "\\$|reverse" => "**|reverse** \u{2014} Reverse string.\n\nUsage: `$name|reverse`",
544+ _ => return None,
545+ };
546+
547+ Some(if let Some(state) = state {
548+ format!("*({} state)* {}", state, doc)
549+ } else {
550+ doc.to_string()
551+ })
552+}
src/bin/htmlang_lsp/main.rsnew file+507
@@ -0,0 +1,507 @@
1+use std::collections::HashMap;
2+use std::sync::Arc;
3+
4+use tokio::sync::RwLock;
5+use tower_lsp::jsonrpc::Result;
6+use tower_lsp::lsp_types::*;
7+use tower_lsp::{Client, LanguageServer, LspService, Server};
8+
9+mod analysis;
10+mod completion;
11+mod hover;
12+mod navigation;
13+
14+use analysis::{
15+ code_actions, document_symbols, find_colors, folding_ranges, get_signature_help, inlay_hints,
16+ semantic_tokens,
17+};
18+use completion::{completions, path_completions, use_symbol_completions};
19+use hover::hover_at;
20+use navigation::{
21+ definition_at, find_references, linked_editing_ranges, prepare_rename_at, rename_at,
22+};
23+
24+// ---------------------------------------------------------------------------
25+// Backend
26+// ---------------------------------------------------------------------------
27+
28+struct Backend {
29+ client: Client,
30+ documents: Arc<RwLock<HashMap<Url, String>>>,
31+}
32+
33+impl Backend {
34+ async fn on_change(&self, uri: Url, text: String) {
35+ let result = htmlang::parser::parse(&text);
36+ let diags: Vec<Diagnostic> = result
37+ .diagnostics
38+ .iter()
39+ .map(|d| {
40+ let severity = match d.severity {
41+ htmlang::parser::Severity::Error => DiagnosticSeverity::ERROR,
42+ htmlang::parser::Severity::Warning => DiagnosticSeverity::WARNING,
43+ };
44+ let line = d.line.saturating_sub(1) as u32;
45+ Diagnostic {
46+ range: Range::new(Position::new(line, 0), Position::new(line, 1000)),
47+ severity: Some(severity),
48+ source: Some("htmlang".into()),
49+ message: d.message.clone(),
50+ ..Default::default()
51+ }
52+ })
53+ .collect();
54+ self.client
55+ .publish_diagnostics(uri.clone(), diags, None)
56+ .await;
57+ self.documents.write().await.insert(uri, text);
58+ }
59+}
60+
61+#[tower_lsp::async_trait]
62+impl LanguageServer for Backend {
63+ async fn initialize(&self, _: InitializeParams) -> Result<InitializeResult> {
64+ Ok(InitializeResult {
65+ capabilities: ServerCapabilities {
66+ text_document_sync: Some(TextDocumentSyncCapability::Kind(
67+ TextDocumentSyncKind::FULL,
68+ )),
69+ completion_provider: Some(CompletionOptions {
70+ trigger_characters: Some(vec![
71+ "@".into(),
72+ "$".into(),
73+ "[".into(),
74+ ",".into(),
75+ ]),
76+ ..Default::default()
77+ }),
78+ hover_provider: Some(HoverProviderCapability::Simple(true)),
79+ definition_provider: Some(OneOf::Left(true)),
80+ rename_provider: Some(OneOf::Right(RenameOptions {
81+ prepare_provider: Some(true),
82+ work_done_progress_options: Default::default(),
83+ })),
84+ document_symbol_provider: Some(OneOf::Left(true)),
85+ code_action_provider: Some(CodeActionProviderCapability::Options(
86+ CodeActionOptions {
87+ code_action_kinds: Some(vec![
88+ CodeActionKind::QUICKFIX,
89+ CodeActionKind::REFACTOR_EXTRACT,
90+ ]),
91+ ..Default::default()
92+ },
93+ )),
94+ color_provider: Some(ColorProviderCapability::Simple(true)),
95+ folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
96+ semantic_tokens_provider: Some(
97+ SemanticTokensServerCapabilities::SemanticTokensOptions(SemanticTokensOptions {
98+ legend: SemanticTokensLegend {
99+ token_types: vec![
100+ SemanticTokenType::KEYWORD,
101+ SemanticTokenType::VARIABLE,
102+ SemanticTokenType::FUNCTION,
103+ SemanticTokenType::STRING,
104+ SemanticTokenType::COMMENT,
105+ SemanticTokenType::PROPERTY,
106+ ],
107+ token_modifiers: vec![
108+ SemanticTokenModifier::new("deprecated"), // bit 0 = 1 -> dimmed/strikethrough
109+ ],
110+ },
111+ full: Some(SemanticTokensFullOptions::Bool(true)),
112+ range: None,
113+ ..Default::default()
114+ }),
115+ ),
116+ inlay_hint_provider: Some(OneOf::Left(true)),
117+ workspace_symbol_provider: Some(OneOf::Left(true)),
118+ linked_editing_range_provider: Some(
119+ LinkedEditingRangeServerCapabilities::Simple(true),
120+ ),
121+ document_formatting_provider: Some(OneOf::Left(true)),
122+ references_provider: Some(OneOf::Left(true)),
123+ signature_help_provider: Some(SignatureHelpOptions {
124+ trigger_characters: Some(vec!["[".into(), ",".into()]),
125+ retrigger_characters: Some(vec![",".into()]),
126+ work_done_progress_options: Default::default(),
127+ }),
128+ ..Default::default()
129+ },
130+ ..Default::default()
131+ })
132+ }
133+
134+ async fn initialized(&self, _: InitializedParams) {}
135+
136+ async fn shutdown(&self) -> Result<()> {
137+ Ok(())
138+ }
139+
140+ async fn did_open(&self, params: DidOpenTextDocumentParams) {
141+ self.on_change(params.text_document.uri, params.text_document.text)
142+ .await;
143+ }
144+
145+ async fn did_change(&self, params: DidChangeTextDocumentParams) {
146+ if let Some(change) = params.content_changes.into_iter().last() {
147+ self.on_change(params.text_document.uri, change.text).await;
148+ }
149+ }
150+
151+ async fn did_close(&self, params: DidCloseTextDocumentParams) {
152+ self.documents
153+ .write()
154+ .await
155+ .remove(&params.text_document.uri);
156+ self.client
157+ .publish_diagnostics(params.text_document.uri, vec![], None)
158+ .await;
159+ }
160+
161+ async fn completion(
162+ &self,
163+ params: CompletionParams,
164+ ) -> Result<Option<CompletionResponse>> {
165+ let uri = &params.text_document_position.text_document.uri;
166+ let pos = params.text_document_position.position;
167+ let docs = self.documents.read().await;
168+ let text = match docs.get(uri) {
169+ Some(t) => t.clone(),
170+ None => return Ok(None),
171+ };
172+ drop(docs);
173+
174+ // Check if we're on an @include/@import/@use/@extends line for path/symbol completions
175+ let lines: Vec<&str> = text.lines().collect();
176+ if let Some(line) = lines.get(pos.line as usize) {
177+ let trimmed = line.trim_start();
178+ if trimmed.starts_with("@include ") || trimmed.starts_with("@import ")
179+ || trimmed.starts_with("@extends ") {
180+ let items = path_completions(uri, pos);
181+ if !items.is_empty() {
182+ return Ok(Some(CompletionResponse::Array(items)));
183+ }
184+ }
185+ // @use "file.hl" fn1, fn2 -- after the filename, suggest exported @fn names
186+ if trimmed.starts_with("@use ") {
187+ let after_use = &trimmed[5..];
188+ // If we already have a filename (quoted or unquoted), suggest symbols from that file
189+ let has_file = after_use.contains(".hl");
190+ if has_file {
191+ let items = use_symbol_completions(uri, trimmed, pos);
192+ if !items.is_empty() {
193+ return Ok(Some(CompletionResponse::Array(items)));
194+ }
195+ } else {
196+ let items = path_completions(uri, pos);
197+ if !items.is_empty() {
198+ return Ok(Some(CompletionResponse::Array(items)));
199+ }
200+ }
201+ }
202+ }
203+
204+ let items = completions(&text, pos);
205+ Ok(if items.is_empty() {
206+ None
207+ } else {
208+ Some(CompletionResponse::Array(items))
209+ })
210+ }
211+
212+ async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
213+ let uri = &params.text_document_position_params.text_document.uri;
214+ let pos = params.text_document_position_params.position;
215+ let docs = self.documents.read().await;
216+ let text = match docs.get(uri) {
217+ Some(t) => t.clone(),
218+ None => return Ok(None),
219+ };
220+ drop(docs);
221+
222+ Ok(hover_at(&text, pos))
223+ }
224+
225+ async fn goto_definition(
226+ &self,
227+ params: GotoDefinitionParams,
228+ ) -> Result<Option<GotoDefinitionResponse>> {
229+ let uri = params.text_document_position_params.text_document.uri.clone();
230+ let pos = params.text_document_position_params.position;
231+ let docs = self.documents.read().await;
232+ let text = match docs.get(&uri) {
233+ Some(t) => t.clone(),
234+ None => return Ok(None),
235+ };
236+ drop(docs);
237+
238+ Ok(definition_at(&text, pos, &uri))
239+ }
240+
241+ async fn prepare_rename(
242+ &self,
243+ params: TextDocumentPositionParams,
244+ ) -> Result<Option<PrepareRenameResponse>> {
245+ let uri = &params.text_document.uri;
246+ let pos = params.position;
247+ let docs = self.documents.read().await;
248+ let text = match docs.get(uri) {
249+ Some(t) => t.clone(),
250+ None => return Ok(None),
251+ };
252+ drop(docs);
253+
254+ Ok(prepare_rename_at(&text, pos))
255+ }
256+
257+ async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
258+ let uri = params.text_document_position.text_document.uri.clone();
259+ let pos = params.text_document_position.position;
260+ let new_name = params.new_name;
261+ let docs = self.documents.read().await;
262+ let text = match docs.get(&uri) {
263+ Some(t) => t.clone(),
264+ None => return Ok(None),
265+ };
266+ drop(docs);
267+
268+ Ok(rename_at(&text, pos, &new_name, &uri))
269+ }
270+
271+ async fn document_symbol(
272+ &self,
273+ params: DocumentSymbolParams,
274+ ) -> Result<Option<DocumentSymbolResponse>> {
275+ let uri = &params.text_document.uri;
276+ let docs = self.documents.read().await;
277+ let text = match docs.get(uri) {
278+ Some(t) => t.clone(),
279+ None => return Ok(None),
280+ };
281+ drop(docs);
282+
283+ let symbols = document_symbols(&text);
284+ Ok(if symbols.is_empty() {
285+ None
286+ } else {
287+ Some(DocumentSymbolResponse::Flat(symbols))
288+ })
289+ }
290+
291+ async fn code_action(
292+ &self,
293+ params: CodeActionParams,
294+ ) -> Result<Option<CodeActionResponse>> {
295+ let uri = params.text_document.uri.clone();
296+ let docs = self.documents.read().await;
297+ let text = match docs.get(&uri) {
298+ Some(t) => t.clone(),
299+ None => return Ok(None),
300+ };
301+ drop(docs);
302+
303+ let actions = code_actions(&text, &params.range, &params.context.diagnostics, &uri);
304+ Ok(if actions.is_empty() {
305+ None
306+ } else {
307+ Some(actions)
308+ })
309+ }
310+
311+ async fn document_color(
312+ &self,
313+ params: DocumentColorParams,
314+ ) -> Result<Vec<ColorInformation>> {
315+ let uri = &params.text_document.uri;
316+ let docs = self.documents.read().await;
317+ let text = match docs.get(uri) {
318+ Some(t) => t.clone(),
319+ None => return Ok(vec![]),
320+ };
321+ drop(docs);
322+ Ok(find_colors(&text))
323+ }
324+
325+ async fn color_presentation(
326+ &self,
327+ params: ColorPresentationParams,
328+ ) -> Result<Vec<ColorPresentation>> {
329+ let c = params.color;
330+ let r = (c.red * 255.0) as u8;
331+ let g = (c.green * 255.0) as u8;
332+ let b = (c.blue * 255.0) as u8;
333+ let hex = if c.alpha < 1.0 {
334+ let a = (c.alpha * 255.0) as u8;
335+ format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a)
336+ } else {
337+ format!("#{:02x}{:02x}{:02x}", r, g, b)
338+ };
339+ Ok(vec![ColorPresentation {
340+ label: hex.clone(),
341+ text_edit: Some(TextEdit {
342+ range: params.range,
343+ new_text: hex,
344+ }),
345+ additional_text_edits: None,
346+ }])
347+ }
348+
349+ async fn folding_range(
350+ &self,
351+ params: FoldingRangeParams,
352+ ) -> Result<Option<Vec<FoldingRange>>> {
353+ let uri = &params.text_document.uri;
354+ let docs = self.documents.read().await;
355+ let text = match docs.get(uri) {
356+ Some(t) => t.clone(),
357+ None => return Ok(None),
358+ };
359+ drop(docs);
360+ let ranges = folding_ranges(&text);
361+ Ok(if ranges.is_empty() { None } else { Some(ranges) })
362+ }
363+
364+ async fn semantic_tokens_full(
365+ &self,
366+ params: SemanticTokensParams,
367+ ) -> Result<Option<SemanticTokensResult>> {
368+ let uri = &params.text_document.uri;
369+ let docs = self.documents.read().await;
370+ let text = match docs.get(uri) {
371+ Some(t) => t.clone(),
372+ None => return Ok(None),
373+ };
374+ drop(docs);
375+ let tokens = semantic_tokens(&text);
376+ Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
377+ result_id: None,
378+ data: tokens,
379+ })))
380+ }
381+
382+ async fn inlay_hint(
383+ &self,
384+ params: InlayHintParams,
385+ ) -> Result<Option<Vec<InlayHint>>> {
386+ let uri = &params.text_document.uri;
387+ let docs = self.documents.read().await;
388+ let text = match docs.get(uri) {
389+ Some(t) => t.clone(),
390+ None => return Ok(None),
391+ };
392+ drop(docs);
393+ let hints = inlay_hints(&text);
394+ Ok(if hints.is_empty() { None } else { Some(hints) })
395+ }
396+
397+ #[allow(deprecated)]
398+ async fn symbol(
399+ &self,
400+ params: WorkspaceSymbolParams,
401+ ) -> Result<Option<Vec<SymbolInformation>>> {
402+ let query = params.query.to_lowercase();
403+ let docs = self.documents.read().await;
404+ let mut all_symbols = Vec::new();
405+ for (uri, text) in docs.iter() {
406+ let symbols = document_symbols(text);
407+ for mut sym in symbols {
408+ sym.location.uri = uri.clone();
409+ if query.is_empty()
410+ || sym.name.to_lowercase().contains(&query)
411+ {
412+ all_symbols.push(sym);
413+ }
414+ }
415+ }
416+ drop(docs);
417+ Ok(if all_symbols.is_empty() {
418+ None
419+ } else {
420+ Some(all_symbols)
421+ })
422+ }
423+
424+ async fn linked_editing_range(
425+ &self,
426+ params: LinkedEditingRangeParams,
427+ ) -> Result<Option<LinkedEditingRanges>> {
428+ let uri = &params.text_document_position_params.text_document.uri;
429+ let pos = params.text_document_position_params.position;
430+ let docs = self.documents.read().await;
431+ let text = match docs.get(uri) {
432+ Some(t) => t.clone(),
433+ None => return Ok(None),
434+ };
435+ drop(docs);
436+ Ok(linked_editing_ranges(&text, pos))
437+ }
438+
439+ async fn formatting(
440+ &self,
441+ params: DocumentFormattingParams,
442+ ) -> Result<Option<Vec<TextEdit>>> {
443+ let uri = &params.text_document.uri;
444+ let docs = self.documents.read().await;
445+ let text = match docs.get(uri) {
446+ Some(t) => t.clone(),
447+ None => return Ok(None),
448+ };
449+ drop(docs);
450+ let formatted = htmlang::fmt::format(&text);
451+ if formatted == text {
452+ return Ok(None);
453+ }
454+ let last_line = text.lines().count().saturating_sub(1) as u32;
455+ let last_col = text.lines().last().map_or(0, |l| l.len()) as u32;
456+ Ok(Some(vec![TextEdit {
457+ range: Range::new(Position::new(0, 0), Position::new(last_line, last_col)),
458+ new_text: formatted,
459+ }]))
460+ }
461+
462+ async fn references(
463+ &self,
464+ params: ReferenceParams,
465+ ) -> Result<Option<Vec<Location>>> {
466+ let uri = params.text_document_position.text_document.uri.clone();
467+ let pos = params.text_document_position.position;
468+ let docs = self.documents.read().await;
469+ let text = match docs.get(&uri) {
470+ Some(t) => t.clone(),
471+ None => return Ok(None),
472+ };
473+ drop(docs);
474+ let refs = find_references(&text, pos, &uri);
475+ Ok(if refs.is_empty() { None } else { Some(refs) })
476+ }
477+
478+ async fn signature_help(
479+ &self,
480+ params: SignatureHelpParams,
481+ ) -> Result<Option<SignatureHelp>> {
482+ let uri = &params.text_document_position_params.text_document.uri;
483+ let pos = params.text_document_position_params.position;
484+ let docs = self.documents.read().await;
485+ let text = match docs.get(uri) {
486+ Some(t) => t.clone(),
487+ None => return Ok(None),
488+ };
489+ drop(docs);
490+ Ok(get_signature_help(&text, pos))
491+ }
492+}
493+
494+// ---------------------------------------------------------------------------
495+// Entry point
496+// ---------------------------------------------------------------------------
497+
498+#[tokio::main]
499+async fn main() {
500+ let stdin = tokio::io::stdin();
501+ let stdout = tokio::io::stdout();
502+ let (service, socket) = LspService::new(|client| Backend {
503+ client,
504+ documents: Arc::new(RwLock::new(HashMap::new())),
505+ });
506+ Server::new(stdin, stdout, socket).serve(service).await;
507+}
src/bin/htmlang_lsp/navigation.rsnew file+455
@@ -0,0 +1,455 @@
1+use std::collections::HashMap;
2+
3+use tower_lsp::lsp_types::*;
4+
5+use crate::hover::{is_word_byte, word_at};
6+
7+// ---------------------------------------------------------------------------
8+// Go to definition
9+// ---------------------------------------------------------------------------
10+
11+pub(crate) fn definition_at(text: &str, position: Position, uri: &Url) -> Option<GotoDefinitionResponse> {
12+ let lines: Vec<&str> = text.lines().collect();
13+ let line = lines.get(position.line as usize)?;
14+
15+ // Check for @include/@import file path navigation
16+ let trimmed = line.trim();
17+ if let Some(filename) = trimmed
18+ .strip_prefix("@include ")
19+ .or_else(|| trimmed.strip_prefix("@import "))
20+ {
21+ let filename = filename.trim();
22+ if !filename.is_empty() {
23+ let file_path = uri.to_file_path().ok()?;
24+ let dir = file_path.parent()?;
25+ let target = dir.join(filename);
26+ if target.exists() {
27+ let target_uri = Url::from_file_path(&target).ok()?;
28+ return Some(GotoDefinitionResponse::Scalar(Location {
29+ uri: target_uri,
30+ range: Range::new(Position::new(0, 0), Position::new(0, 0)),
31+ }));
32+ }
33+ }
34+ }
35+
36+ let col = (position.character as usize).min(line.len());
37+ let word = word_at(line, col)?;
38+
39+ // Find definition location
40+ let (def_line, def_col, def_len) = if word.starts_with('$') {
41+ let name = &word[1..];
42+ find_definition(text, name)?
43+ } else if word.starts_with('@') {
44+ let name = &word[1..];
45+ find_fn_definition(text, name)?
46+ } else {
47+ return None;
48+ };
49+
50+ Some(GotoDefinitionResponse::Scalar(Location {
51+ uri: uri.clone(),
52+ range: Range::new(
53+ Position::new(def_line, def_col),
54+ Position::new(def_line, def_col + def_len),
55+ ),
56+ }))
57+}
58+
59+/// Find @let, @define, or @fn parameter definition for a $name reference.
60+pub(crate) fn find_definition(text: &str, name: &str) -> Option<(u32, u32, u32)> {
61+ for (i, line) in text.lines().enumerate() {
62+ let trimmed = line.trim();
63+ let offset = (line.len() - trimmed.len()) as u32;
64+
65+ if let Some(rest) = trimmed.strip_prefix("@let ") {
66+ if let Some((n, _)) = rest.trim().split_once(' ') {
67+ if n == name {
68+ let col = offset + "@let ".len() as u32;
69+ return Some((i as u32, col, n.len() as u32));
70+ }
71+ }
72+ }
73+
74+ if let Some(rest) = trimmed.strip_prefix("@define ") {
75+ let rest = rest.trim();
76+ if let Some(bracket) = rest.find('[') {
77+ let n = rest[..bracket].trim();
78+ if n == name {
79+ let col = offset + "@define ".len() as u32;
80+ return Some((i as u32, col, n.len() as u32));
81+ }
82+ }
83+ }
84+
85+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
86+ let parts: Vec<&str> = rest.split_whitespace().collect();
87+ for param in &parts[1..] {
88+ let p = param.strip_prefix('$').unwrap_or(param);
89+ if p == name {
90+ // Find the param position in the line
91+ if let Some(pos) = line.find(param) {
92+ return Some((i as u32, pos as u32, param.len() as u32));
93+ }
94+ }
95+ }
96+ }
97+ }
98+ None
99+}
100+
101+/// Find @fn definition for an @name function call.
102+pub(crate) fn find_fn_definition(text: &str, name: &str) -> Option<(u32, u32, u32)> {
103+ for (i, line) in text.lines().enumerate() {
104+ let trimmed = line.trim();
105+ let offset = (line.len() - trimmed.len()) as u32;
106+
107+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
108+ let parts: Vec<&str> = rest.split_whitespace().collect();
109+ if parts.first() == Some(&name) {
110+ let col = offset + "@fn ".len() as u32;
111+ return Some((i as u32, col, name.len() as u32));
112+ }
113+ }
114+ }
115+ None
116+}
117+
118+// ---------------------------------------------------------------------------
119+// Rename
120+// ---------------------------------------------------------------------------
121+
122+pub(crate) fn prepare_rename_at(text: &str, position: Position) -> Option<PrepareRenameResponse> {
123+ let lines: Vec<&str> = text.lines().collect();
124+ let line = lines.get(position.line as usize)?;
125+ let col = (position.character as usize).min(line.len());
126+ let word = word_at(line, col)?;
127+
128+ // Only allow renaming $variables and @function calls/definitions
129+ if !word.starts_with('$') && !word.starts_with('@') {
130+ return None;
131+ }
132+
133+ let name = &word[1..];
134+
135+ // Check that the symbol actually has a definition
136+ if word.starts_with('$') {
137+ find_definition(text, name)?;
138+ } else {
139+ find_fn_definition(text, name)?;
140+ }
141+
142+ // Find the range of the word in the line
143+ let bytes = line.as_bytes();
144+ let mut start = col;
145+ while start > 0 && is_word_byte(bytes[start - 1]) {
146+ start -= 1;
147+ }
148+ let mut end = col;
149+ while end < bytes.len() && is_word_byte(bytes[end]) {
150+ end += 1;
151+ }
152+
153+ Some(PrepareRenameResponse::Range(Range::new(
154+ Position::new(position.line, start as u32),
155+ Position::new(position.line, end as u32),
156+ )))
157+}
158+
159+pub(crate) fn rename_at(text: &str, position: Position, new_name: &str, uri: &Url) -> Option<WorkspaceEdit> {
160+ let lines: Vec<&str> = text.lines().collect();
161+ let line = lines.get(position.line as usize)?;
162+ let col = (position.character as usize).min(line.len());
163+ let word = word_at(line, col)?;
164+
165+ let is_var = word.starts_with('$');
166+ let name = &word[1..]; // strip $ or @
167+
168+ // Strip $ or @ from new_name if user included it
169+ let new_base = new_name
170+ .strip_prefix('$')
171+ .or_else(|| new_name.strip_prefix('@'))
172+ .unwrap_or(new_name);
173+
174+ let mut edits = Vec::new();
175+
176+ for (i, line) in text.lines().enumerate() {
177+ let line_num = i as u32;
178+ let trimmed = line.trim();
179+
180+ if is_var {
181+ // Rename @let definition
182+ if let Some(rest) = trimmed.strip_prefix("@let ") {
183+ if let Some((n, _)) = rest.trim().split_once(' ') {
184+ if n == name {
185+ if let Some(pos) = line.find(n) {
186+ edits.push(TextEdit {
187+ range: Range::new(
188+ Position::new(line_num, pos as u32),
189+ Position::new(line_num, (pos + n.len()) as u32),
190+ ),
191+ new_text: new_base.to_string(),
192+ });
193+ }
194+ }
195+ }
196+ }
197+
198+ // Rename @define definition
199+ if let Some(rest) = trimmed.strip_prefix("@define ") {
200+ let rest_trimmed = rest.trim();
201+ if let Some(bracket) = rest_trimmed.find('[') {
202+ let n = rest_trimmed[..bracket].trim();
203+ if n == name {
204+ if let Some(pos) = line.find(n) {
205+ edits.push(TextEdit {
206+ range: Range::new(
207+ Position::new(line_num, pos as u32),
208+ Position::new(line_num, (pos + n.len()) as u32),
209+ ),
210+ new_text: new_base.to_string(),
211+ });
212+ continue; // Don't also match $ references on this line
213+ }
214+ }
215+ }
216+ }
217+
218+ // Rename @fn parameter definitions
219+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
220+ let parts: Vec<&str> = rest.split_whitespace().collect();
221+ for param in &parts[1..] {
222+ let p = param.strip_prefix('$').unwrap_or(param);
223+ if p == name {
224+ if let Some(pos) = line.find(param) {
225+ let prefix = if param.starts_with('$') { "$" } else { "" };
226+ edits.push(TextEdit {
227+ range: Range::new(
228+ Position::new(line_num, pos as u32),
229+ Position::new(line_num, (pos + param.len()) as u32),
230+ ),
231+ new_text: format!("{}{}", prefix, new_base),
232+ });
233+ }
234+ }
235+ }
236+ }
237+
238+ // Rename all $name references
239+ let search = format!("${}", name);
240+ let replace = format!("${}", new_base);
241+ let mut offset = 0;
242+ while let Some(pos) = line[offset..].find(&search) {
243+ let abs_pos = offset + pos;
244+ // Check it's not part of a longer identifier
245+ let after = abs_pos + search.len();
246+ let is_end = after >= line.len()
247+ || !line.as_bytes()[after].is_ascii_alphanumeric()
248+ && line.as_bytes()[after] != b'-'
249+ && line.as_bytes()[after] != b'_';
250+ if is_end {
251+ edits.push(TextEdit {
252+ range: Range::new(
253+ Position::new(line_num, abs_pos as u32),
254+ Position::new(line_num, (abs_pos + search.len()) as u32),
255+ ),
256+ new_text: replace.clone(),
257+ });
258+ }
259+ offset = abs_pos + search.len();
260+ }
261+ } else {
262+ // Rename @fn definition
263+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
264+ let parts: Vec<&str> = rest.split_whitespace().collect();
265+ if parts.first() == Some(&name) {
266+ if let Some(pos) = line.find(&format!("@fn {}", name)) {
267+ let start = pos + 4; // skip "@fn "
268+ edits.push(TextEdit {
269+ range: Range::new(
270+ Position::new(line_num, start as u32),
271+ Position::new(line_num, (start + name.len()) as u32),
272+ ),
273+ new_text: new_base.to_string(),
274+ });
275+ }
276+ }
277+ }
278+
279+ // Rename @name function calls
280+ let search = format!("@{}", name);
281+ let replace = format!("@{}", new_base);
282+ let mut offset = 0;
283+ while let Some(pos) = line[offset..].find(&search) {
284+ let abs_pos = offset + pos;
285+ // Don't match @fn definition (handled above)
286+ if trimmed.starts_with("@fn ") {
287+ offset = abs_pos + search.len();
288+ continue;
289+ }
290+ // Check it's not part of a longer identifier
291+ let after = abs_pos + search.len();
292+ let is_end = after >= line.len()
293+ || !line.as_bytes()[after].is_ascii_alphanumeric()
294+ && line.as_bytes()[after] != b'-'
295+ && line.as_bytes()[after] != b'_';
296+ if is_end {
297+ edits.push(TextEdit {
298+ range: Range::new(
299+ Position::new(line_num, abs_pos as u32),
300+ Position::new(line_num, (abs_pos + search.len()) as u32),
301+ ),
302+ new_text: replace.clone(),
303+ });
304+ }
305+ offset = abs_pos + search.len();
306+ }
307+ }
308+ }
309+
310+ if edits.is_empty() {
311+ return None;
312+ }
313+
314+ let mut changes = HashMap::new();
315+ changes.insert(uri.clone(), edits);
316+
317+ Some(WorkspaceEdit {
318+ changes: Some(changes),
319+ ..Default::default()
320+ })
321+}
322+
323+// ---------------------------------------------------------------------------
324+// Linked editing ranges
325+// ---------------------------------------------------------------------------
326+
327+pub(crate) fn linked_editing_ranges(text: &str, position: Position) -> Option<LinkedEditingRanges> {
328+ let lines: Vec<&str> = text.lines().collect();
329+ let line = lines.get(position.line as usize)?;
330+ let col = (position.character as usize).min(line.len());
331+
332+ // Find the $variable at the cursor
333+ let bytes = line.as_bytes();
334+ let mut start = col;
335+ while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'$' || bytes[start - 1] == b'-' || bytes[start - 1] == b'_') {
336+ start -= 1;
337+ }
338+ let mut end = col;
339+ while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'$' || bytes[end] == b'-' || bytes[end] == b'_') {
340+ end += 1;
341+ }
342+ if start == end {
343+ return None;
344+ }
345+
346+ let word = &line[start..end];
347+ if !word.starts_with('$') {
348+ return None;
349+ }
350+
351+ // Find all occurrences of this $variable in the document
352+ let mut ranges = Vec::new();
353+ for (line_idx, line) in text.lines().enumerate() {
354+ let line_bytes = line.as_bytes();
355+ let mut offset = 0;
356+ while let Some(pos) = line[offset..].find(word) {
357+ let abs_pos = offset + pos;
358+ // Check it's a whole word match
359+ let before_ok = abs_pos == 0 || {
360+ let c = line_bytes[abs_pos - 1];
361+ !c.is_ascii_alphanumeric() && c != b'-' && c != b'_'
362+ };
363+ let after_end = abs_pos + word.len();
364+ let after_ok = after_end >= line.len() || {
365+ let c = line_bytes[after_end];
366+ !c.is_ascii_alphanumeric() && c != b'-' && c != b'_'
367+ };
368+ if before_ok && after_ok {
369+ ranges.push(Range::new(
370+ Position::new(line_idx as u32, abs_pos as u32),
371+ Position::new(line_idx as u32, after_end as u32),
372+ ));
373+ }
374+ offset = abs_pos + word.len();
375+ }
376+ }
377+
378+ if ranges.len() < 2 {
379+ return None;
380+ }
381+
382+ Some(LinkedEditingRanges {
383+ ranges,
384+ word_pattern: None,
385+ })
386+}
387+
388+// ---------------------------------------------------------------------------
389+// Find references
390+// ---------------------------------------------------------------------------
391+
392+pub(crate) fn find_references(text: &str, position: Position, uri: &Url) -> Vec<Location> {
393+ let lines: Vec<&str> = text.lines().collect();
394+ let line = match lines.get(position.line as usize) {
395+ Some(l) => *l,
396+ None => return vec![],
397+ };
398+ let col = (position.character as usize).min(line.len());
399+ let bytes = line.as_bytes();
400+
401+ // Find the word at cursor
402+ let mut start = col;
403+ while start > 0 && is_word_byte(bytes[start - 1]) {
404+ start -= 1;
405+ }
406+ let mut end = col;
407+ while end < bytes.len() && is_word_byte(bytes[end]) {
408+ end += 1;
409+ }
410+ if start == end {
411+ return vec![];
412+ }
413+ let word = &line[start..end];
414+
415+ // Determine the search pattern
416+ let search = if word.starts_with('$') || word.starts_with('@') {
417+ word.to_string()
418+ } else if start > 0 && bytes[start - 1] == b'$' {
419+ format!("${}", word)
420+ } else if start > 0 && bytes[start - 1] == b'@' {
421+ format!("@{}", word)
422+ } else {
423+ return vec![];
424+ };
425+
426+ let mut locations = Vec::new();
427+ for (line_idx, line_text) in text.lines().enumerate() {
428+ let mut offset = 0;
429+ while let Some(pos) = line_text[offset..].find(&search) {
430+ let abs_pos = offset + pos;
431+ let after = abs_pos + search.len();
432+ // Ensure word boundary
433+ let before_ok = abs_pos == 0 || {
434+ let c = line_text.as_bytes()[abs_pos - 1];
435+ !c.is_ascii_alphanumeric() && c != b'_' && c != b'-'
436+ };
437+ let after_ok = after >= line_text.len() || {
438+ let c = line_text.as_bytes()[after];
439+ !c.is_ascii_alphanumeric() && c != b'_' && c != b'-'
440+ };
441+ if before_ok && after_ok {
442+ locations.push(Location {
443+ uri: uri.clone(),
444+ range: Range::new(
445+ Position::new(line_idx as u32, abs_pos as u32),
446+ Position::new(line_idx as u32, after as u32),
447+ ),
448+ });
449+ }
450+ offset = after;
451+ }
452+ }
453+
454+ locations
455+}
src/codegen.rs+57
@@ -2399,6 +2399,63 @@ fn attrs_to_css(
23992399 }
24002400 }
24012401
2402+ // Logical property start/end variants
2403+ "padding-inline-start" | "padding-inline-end" |
2404+ "padding-block-start" | "padding-block-end" |
2405+ "margin-inline-start" | "margin-inline-end" |
2406+ "margin-block-start" | "margin-block-end" => {
2407+ if let Some(v) = val {
2408+ push_css(&mut css, effective_key, &css_px(v));
2409+ }
2410+ }
2411+
2412+ // Logical inset
2413+ "inset-inline" | "inset-block" => {
2414+ if let Some(v) = val {
2415+ push_css(&mut css, effective_key, &css_px_multi(v));
2416+ }
2417+ }
2418+ "inset-inline-start" | "inset-inline-end" |
2419+ "inset-block-start" | "inset-block-end" => {
2420+ if let Some(v) = val {
2421+ push_css(&mut css, effective_key, &css_px(v));
2422+ }
2423+ }
2424+
2425+ // Logical border
2426+ "border-inline" | "border-block" |
2427+ "border-inline-start" | "border-inline-end" |
2428+ "border-block-start" | "border-block-end" => {
2429+ if let Some(v) = val {
2430+ push_css(&mut css, effective_key, v);
2431+ }
2432+ }
2433+
2434+ // Logical border-radius
2435+ "border-start-start-radius" | "border-start-end-radius" |
2436+ "border-end-start-radius" | "border-end-end-radius" => {
2437+ if let Some(v) = val {
2438+ push_css(&mut css, effective_key, &css_px(v));
2439+ }
2440+ }
2441+
2442+ // Logical scroll margins & padding
2443+ "scroll-margin-inline" | "scroll-margin-block" |
2444+ "scroll-padding-inline" | "scroll-padding-block" => {
2445+ if let Some(v) = val {
2446+ push_css(&mut css, effective_key, &css_px_multi(v));
2447+ }
2448+ }
2449+
2450+ // Logical sizing
2451+ "inline-size" | "block-size" |
2452+ "min-inline-size" | "max-inline-size" |
2453+ "min-block-size" | "max-block-size" => {
2454+ if let Some(v) = val {
2455+ push_css(&mut css, effective_key, &css_px(v));
2456+ }
2457+ }
2458+
24022459 // Scroll snap
24032460 "scroll-snap-type" => {
24042461 if let Some(v) = val {
src/main.rs+240−80
@@ -13,16 +13,26 @@ struct DiagnosticJson {
1313 message: String,
1414}
1515
16−fn compile(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>, format_json: bool, json_collector: Option<&Mutex<Vec<DiagnosticJson>>>) -> (bool, Vec<PathBuf>) {
17− compile_inner(input_path, dev, error_overlay, check_only, output_path, format_json, json_collector, false, false, false, false)
16+#[derive(Default)]
17+struct CompileConfig<'a> {
18+ dev: bool,
19+ error_overlay: bool,
20+ check_only: bool,
21+ output_path: Option<&'a str>,
22+ format_json: bool,
23+ json_collector: Option<&'a Mutex<Vec<DiagnosticJson>>>,
24+ minify: bool,
25+ compat: bool,
26+ strict: bool,
27+ partial: bool,
1828}
1929
20−fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>, format_json: bool, json_collector: Option<&Mutex<Vec<DiagnosticJson>>>, minify: bool, compat: bool, strict: bool, partial: bool) -> (bool, Vec<PathBuf>) {
30+fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
2131 let input = match fs::read_to_string(input_path) {
2232 Ok(s) => s,
2333 Err(e) => {
24− if format_json {
25− if let Some(collector) = json_collector {
34+ if cfg.format_json {
35+ if let Some(collector) = cfg.json_collector {
2636 collector.lock().unwrap().push(DiagnosticJson {
2737 file: input_path.to_string(),
2838 line: 0,
@@ -40,8 +50,8 @@ fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: b
4050 let base = Path::new(input_path).parent();
4151 let result = htmlang::parser::parse_with_base(&input, base);
4252
43− if format_json {
44− if let Some(collector) = json_collector {
53+ if cfg.format_json {
54+ if let Some(collector) = cfg.json_collector {
4555 let mut collected = collector.lock().unwrap();
4656 for d in &result.diagnostics {
4757 let severity = match d.severity {
@@ -73,28 +83,28 @@ fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: b
7383 .diagnostics
7484 .iter()
7585 .any(|d| d.severity == htmlang::parser::Severity::Error
76− || (strict && d.severity == htmlang::parser::Severity::Warning));
86+ || (cfg.strict && d.severity == htmlang::parser::Severity::Warning));
7787
78− let out_path = match output_path {
88+ let out_path = match cfg.output_path {
7989 Some(p) => PathBuf::from(p),
8090 None => Path::new(input_path).with_extension("html"),
8191 };
8292
83− if !check_only {
93+ if !cfg.check_only {
8494 if has_errors {
85− if error_overlay {
95+ if cfg.error_overlay {
8696 let error_html = generate_error_overlay(&result.diagnostics, input_path);
8797 let _ = fs::write(&out_path, &error_html);
8898 }
8999 } else {
90− let html = if partial {
91− if dev {
100+ let html = if cfg.partial {
101+ if cfg.dev {
92102 htmlang::codegen::generate_partial_dev(&result.document)
93103 } else {
94104 htmlang::codegen::generate_partial(&result.document)
95105 }
96106 } else {
97− match (minify, dev, compat) {
107+ match (cfg.minify, cfg.dev, cfg.compat) {
98108 (true, _, true) => htmlang::codegen::generate_minified_compat(&result.document),
99109 (true, _, false) => htmlang::codegen::generate_minified(&result.document),
100110 (false, true, true) => htmlang::codegen::generate_dev_compat(&result.document),
@@ -108,7 +118,7 @@ fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: b
108118 Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
109119 }
110120 // Generate source map alongside HTML
111− if dev {
121+ if cfg.dev {
112122 let map_path = out_path.with_extension("html.map");
113123 let source_map = htmlang::codegen::generate_source_map(
114124 &result.document,
@@ -122,28 +132,35 @@ fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: b
122132 (has_errors, result.included_files)
123133}
124134
125−fn print_json_diagnostics(diagnostics: &[DiagnosticJson]) {
126− let mut json = String::from("{\"diagnostics\":[");
127− for (i, d) in diagnostics.iter().enumerate() {
128− if i > 0 {
129− json.push(',');
130− }
131− let escaped_file = d.file.replace('\\', "\\\\").replace('"', "\\\"");
132− let escaped_msg = d.message.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
133− json.push_str(&format!(
134− "{{\"file\":\"{}\",\"line\":{},\"severity\":\"{}\",\"message\":\"{}\"}}",
135− escaped_file, d.line, d.severity, escaped_msg
136− ));
137− }
138− json.push_str("]}");
139− println!("{}", json);
140−}
141−
142135fn json_escape_string(s: &str) -> String {
143136 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
144137 format!("\"{}\"", escaped)
145138}
146139
140+fn json_array(items: impl Iterator<Item = String>) -> String {
141+ let inner: Vec<String> = items.collect();
142+ format!("[{}]", inner.join(","))
143+}
144+
145+fn json_object(fields: &[(&str, String)]) -> String {
146+ let inner: Vec<String> = fields.iter()
147+ .map(|(k, v)| format!("{}:{}", json_escape_string(k), v))
148+ .collect();
149+ format!("{{{}}}", inner.join(","))
150+}
151+
152+fn print_json_diagnostics(diagnostics: &[DiagnosticJson]) {
153+ let arr = json_array(diagnostics.iter().map(|d| {
154+ json_object(&[
155+ ("file", json_escape_string(&d.file)),
156+ ("line", d.line.to_string()),
157+ ("severity", json_escape_string(&d.severity)),
158+ ("message", json_escape_string(&d.message)),
159+ ])
160+ }));
161+ println!("{}", json_object(&[("diagnostics", arr)]));
162+}
163+
147164fn kebab_to_title(s: &str) -> String {
148165 s.split('-')
149166 .map(|word| {
@@ -589,12 +606,39 @@ fn generate_sitemap(dir: &str, base_url: &str) {
589606 } else {
590607 format!("{}/{}", base_url.trim_end_matches('/'), url_path)
591608 };
592− xml.push_str(&format!(" <url><loc>{}</loc></url>\n", url));
609+
610+ // Get file modification time for <lastmod>
611+ let lastmod = file.metadata().ok()
612+ .and_then(|m| m.modified().ok())
613+ .map(|t| {
614+ let secs = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
615+ let days = secs / 86400;
616+ // Simple date calculation from epoch days
617+ let (y, m, d) = epoch_days_to_date(days);
618+ format!("{:04}-{:02}-{:02}", y, m, d)
619+ });
620+
621+ // Heuristic priority: index pages get higher priority
622+ let priority = if url_path == "index.html" {
623+ "1.0"
624+ } else if rel.components().count() <= 2 {
625+ "0.8"
626+ } else {
627+ "0.5"
628+ };
629+
630+ xml.push_str(" <url>\n");
631+ xml.push_str(&format!(" <loc>{}</loc>\n", url));
632+ if let Some(ref date) = lastmod {
633+ xml.push_str(&format!(" <lastmod>{}</lastmod>\n", date));
634+ }
635+ xml.push_str(&format!(" <priority>{}</priority>\n", priority));
636+ xml.push_str(" </url>\n");
593637 }
594638 xml.push_str("</urlset>\n");
595639 let out_path = dir.join("sitemap.xml");
596640 match fs::write(&out_path, &xml) {
597− Ok(()) => eprintln!("wrote {}", out_path.display()),
641+ Ok(()) => eprintln!("wrote {} ({} URLs)", out_path.display(), hl_files.len()),
598642 Err(e) => {
599643 eprintln!("error: {}: {}", out_path.display(), e);
600644 process::exit(1);
@@ -602,6 +646,21 @@ fn generate_sitemap(dir: &str, base_url: &str) {
602646 }
603647}
604648
649+fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
650+ // Algorithm from http://howardhinnant.github.io/date_algorithms.html
651+ let z = days + 719468;
652+ let era = z / 146097;
653+ let doe = z - era * 146097;
654+ let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
655+ let y = yoe + era * 400;
656+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
657+ let mp = (5 * doy + 2) / 153;
658+ let d = doy - (153 * mp + 2) / 5 + 1;
659+ let m = if mp < 10 { mp + 3 } else { mp - 9 };
660+ let y = if m <= 2 { y + 1 } else { y };
661+ (y, m, d)
662+}
663+
605664fn lint_file(path: &str) -> Vec<String> {
606665 let input = match fs::read_to_string(path) {
607666 Ok(s) => s,
@@ -810,10 +869,58 @@ fn extract_shared_css(html_files: &[PathBuf], out_dir: &Path) {
810869 if shared_rules.is_empty() {
811870 return;
812871 }
872+ let shared_set: std::collections::HashSet<&String> = shared_rules.iter().copied().collect();
813873 let shared_css_path = out_dir.join("shared.css");
814874 let shared_css: String = shared_rules.iter().map(|r| r.as_str()).collect::<Vec<_>>().join("\n");
815− if fs::write(&shared_css_path, &shared_css).is_ok() {
816− eprintln!("extracted {} shared CSS rules to {}", shared_rules.len(), shared_css_path.display());
875+ if fs::write(&shared_css_path, &shared_css).is_err() {
876+ return;
877+ }
878+ eprintln!("extracted {} shared CSS rules to {}", shared_rules.len(), shared_css_path.display());
879+
880+ // Remove shared rules from individual files and inject <link> tag
881+ for file in html_files {
882+ if let Ok(html) = fs::read_to_string(file) {
883+ if let Some(style_start) = html.find("<style>") {
884+ if let Some(style_end_rel) = html[style_start..].find("</style>") {
885+ let css = &html[style_start + 7..style_start + style_end_rel];
886+ // Rebuild CSS without shared rules
887+ let mut filtered = String::new();
888+ let mut i = 0;
889+ let bytes = css.as_bytes();
890+ while i < bytes.len() {
891+ if bytes[i] == b'.' || bytes[i] == b'@' {
892+ let start_pos = i;
893+ let mut depth = 0;
894+ let mut found_open = false;
895+ while i < bytes.len() {
896+ if bytes[i] == b'{' { depth += 1; found_open = true; }
897+ else if bytes[i] == b'}' {
898+ depth -= 1;
899+ if found_open && depth == 0 { i += 1; break; }
900+ }
901+ i += 1;
902+ }
903+ let rule = &css[start_pos..i];
904+ if !shared_set.contains(&rule.to_string()) {
905+ filtered.push_str(rule);
906+ }
907+ } else {
908+ filtered.push(css.as_bytes()[i] as char);
909+ i += 1;
910+ }
911+ }
912+ let link_tag = "<link rel=\"stylesheet\" href=\"shared.css\">";
913+ let new_html = format!(
914+ "{}{}<style>{}</style>{}",
915+ &html[..style_start],
916+ link_tag,
917+ filtered,
918+ &html[style_start + style_end_rel + 8..],
919+ );
920+ let _ = fs::write(file, new_html);
921+ }
922+ }
923+ }
817924 }
818925}
819926
@@ -1011,6 +1118,7 @@ fn main() {
10111118 }).collect();
10121119
10131120 // Compile files in parallel (with incremental skip for unchanged files)
1121+ let build_start = std::time::Instant::now();
10141122 let any_errors = std::sync::atomic::AtomicBool::new(false);
10151123 let skipped = std::sync::atomic::AtomicUsize::new(0);
10161124 std::thread::scope(|s| {
@@ -1033,21 +1141,32 @@ fn main() {
10331141 }
10341142 }
10351143 let path_str = file.to_string_lossy().to_string();
1036− let (has_errors, _) = if build_minify || build_compat {
1037− compile_inner(&path_str, false, false, false, effective_out.as_deref(), false, None, build_minify, build_compat, false, false)
1038− } else {
1039− compile(&path_str, false, false, false, effective_out.as_deref(), false, None)
1040− };
1144+ let (has_errors, _) = compile(&path_str, &CompileConfig {
1145+ output_path: effective_out.as_deref(),
1146+ minify: build_minify, compat: build_compat,
1147+ ..Default::default()
1148+ });
10411149 if has_errors {
10421150 any_errors.store(true, std::sync::atomic::Ordering::Relaxed);
10431151 }
10441152 });
10451153 }
10461154 });
1155+ let build_elapsed = build_start.elapsed();
10471156 let skipped_count = skipped.load(std::sync::atomic::Ordering::Relaxed);
1048− if skipped_count > 0 {
1049− eprintln!("skipped {} unchanged files", skipped_count);
1050− }
1157+ let compiled_count = hl_files.len() - skipped_count;
1158+
1159+ // Report build performance
1160+ let total_output_size: usize = effective_outs.iter()
1161+ .filter_map(|p| p.as_ref())
1162+ .filter_map(|p| fs::metadata(p).ok())
1163+ .map(|m| m.len() as usize)
1164+ .sum();
1165+ eprintln!(
1166+ "built {} files in {:.2}s ({}){}", compiled_count,
1167+ build_elapsed.as_secs_f64(), format_bytes(total_output_size),
1168+ if skipped_count > 0 { format!(", {} skipped", skipped_count) } else { String::new() },
1169+ );
10511170 if any_errors.load(std::sync::atomic::Ordering::Relaxed) { process::exit(1); }
10521171
10531172 // Copy non-.hl static assets to output directory
@@ -1115,16 +1234,13 @@ fn main() {
11151234 all_warnings.extend(lint_file(lint_target));
11161235 }
11171236 if lint_json {
1118− let mut json = String::from("{\"diagnostics\":[");
1119− for (i, w) in all_warnings.iter().enumerate() {
1120− if i > 0 { json.push(','); }
1121− json.push_str(&format!(
1122− "{{\"severity\":\"warning\",\"message\":{}}}",
1123− json_escape_string(w)
1124− ));
1125− }
1126− json.push_str("]}");
1127− println!("{}", json);
1237+ let arr = json_array(all_warnings.iter().map(|w| {
1238+ json_object(&[
1239+ ("severity", json_escape_string("warning")),
1240+ ("message", json_escape_string(w)),
1241+ ])
1242+ }));
1243+ println!("{}", json_object(&[("diagnostics", arr)]));
11281244 } else if all_warnings.is_empty() {
11291245 eprintln!("no issues found");
11301246 } else {
@@ -1188,11 +1304,19 @@ fn main() {
11881304 }
11891305 for file in &hl_files {
11901306 let path_str = file.to_string_lossy().to_string();
1191− let (has_errors, _) = compile(&path_str, false, false, true, None, check_format_json, json_collector.as_ref());
1307+ let (has_errors, _) = compile(&path_str, &CompileConfig {
1308+ check_only: true, format_json: check_format_json,
1309+ json_collector: json_collector.as_ref(),
1310+ ..Default::default()
1311+ });
11921312 if has_errors { any_errors = true; }
11931313 }
11941314 } else {
1195− let (has_errors, _) = compile(target, false, false, true, None, check_format_json, json_collector.as_ref());
1315+ let (has_errors, _) = compile(target, &CompileConfig {
1316+ check_only: true, format_json: check_format_json,
1317+ json_collector: json_collector.as_ref(),
1318+ ..Default::default()
1319+ });
11961320 if has_errors { any_errors = true; }
11971321 }
11981322 if check_format_json {
@@ -1301,7 +1425,10 @@ fn main() {
13011425 let tmp_dir = env::temp_dir().join("htmlang-preview");
13021426 let _ = fs::create_dir_all(&tmp_dir);
13031427 let out_path = tmp_dir.join("preview.html");
1304− let (has_errors, _) = compile(file, true, false, false, Some(&out_path.to_string_lossy()), false, None);
1428+ let (has_errors, _) = compile(file, &CompileConfig {
1429+ dev: true, output_path: Some(&out_path.to_string_lossy()),
1430+ ..Default::default()
1431+ });
13051432 if has_errors {
13061433 process::exit(1);
13071434 }
@@ -1403,7 +1530,10 @@ fn main() {
14031530 let _ = fs::create_dir_all(parent);
14041531 }
14051532 let path_str = file.to_string_lossy().to_string();
1406− let (has_errors, _) = compile(&path_str, false, false, false, Some(&out_path.to_string_lossy()), false, None);
1533+ let (has_errors, _) = compile(&path_str, &CompileConfig {
1534+ output_path: Some(&out_path.to_string_lossy()),
1535+ ..Default::default()
1536+ });
14071537 if has_errors { any_errors = true; }
14081538 }
14091539 // Copy non-.hl assets
@@ -1487,7 +1617,11 @@ fn main() {
14871617 if let Some(parent) = out_p.parent() { let _ = fs::create_dir_all(parent); }
14881618 out_p.to_string_lossy().to_string()
14891619 });
1490− compile(&path_str, true, true, false, effective_out.as_deref(), false, None);
1620+ compile(&path_str, &CompileConfig {
1621+ dev: true, error_overlay: true,
1622+ output_path: effective_out.as_deref(),
1623+ ..Default::default()
1624+ });
14911625 }
14921626 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
14931627 let serve_dir = config.output.as_ref().map(|o| PathBuf::from(o)).unwrap_or_else(|| target_path.to_path_buf());
@@ -1500,7 +1634,10 @@ fn main() {
15001634 if serve_open { open_in_browser(effective_port); }
15011635 watch_loop(target_path, &hl_files, &[], true, true, Some(tx));
15021636 } else {
1503− let (_, included) = compile(&target, true, true, false, None, false, None);
1637+ let (_, included) = compile(&target, &CompileConfig {
1638+ dev: true, error_overlay: true,
1639+ ..Default::default()
1640+ });
15041641 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
15051642 let out_path = Path::new(&target).with_extension("html");
15061643 let server_tx = tx.clone();
@@ -1551,12 +1688,18 @@ fn main() {
15511688 if let Some(parent) = out_p.parent() { let _ = fs::create_dir_all(parent); }
15521689 out_p.to_string_lossy().to_string()
15531690 });
1554− let (_, included) = compile(&path_str, false, false, false, effective_out.as_deref(), false, None);
1691+ let (_, included) = compile(&path_str, &CompileConfig {
1692+ output_path: effective_out.as_deref(),
1693+ ..Default::default()
1694+ });
15551695 all_included.extend(included);
15561696 }
15571697 watch_loop(target_path, &hl_files, &all_included, false, false, None);
15581698 } else {
1559− let (_, included) = compile(&target, false, false, false, effective_output.as_deref(), false, None);
1699+ let (_, included) = compile(&target, &CompileConfig {
1700+ output_path: effective_output.as_deref(),
1701+ ..Default::default()
1702+ });
15601703 let files = vec![PathBuf::from(&target)];
15611704 watch_loop(Path::new(&target).parent().unwrap_or(Path::new(".")), &files, &included, false, false, None);
15621705 }
@@ -1881,19 +2024,15 @@ fn main() {
18812024 }
18822025 }
18832026 if dc_json {
1884− let mut json = String::from("{\"unused\":[");
1885− for (i, (kind, name, file, line)) in unused.iter().enumerate() {
1886− if i > 0 { json.push(','); }
1887− json.push_str(&format!(
1888− "{{\"kind\":{},\"name\":{},\"file\":{},\"line\":{}}}",
1889− json_escape_string(kind),
1890− json_escape_string(name),
1891− json_escape_string(file),
1892− line
1893− ));
1894− }
1895− json.push_str("]}");
1896− println!("{}", json);
2027+ let arr = json_array(unused.iter().map(|(kind, name, file, line)| {
2028+ json_object(&[
2029+ ("kind", json_escape_string(kind)),
2030+ ("name", json_escape_string(name)),
2031+ ("file", json_escape_string(file)),
2032+ ("line", line.to_string()),
2033+ ])
2034+ }));
2035+ println!("{}", json_object(&[("unused", arr)]));
18972036 } else if unused.is_empty() {
18982037 eprintln!("no unused definitions found");
18992038 } else {
@@ -1959,7 +2098,10 @@ fn main() {
19592098 }
19602099 let out_str = out_path.to_string_lossy().to_string();
19612100 let path_str = file.to_string_lossy().to_string();
1962− let (has_errors, _) = compile(&path_str, false, false, false, Some(&out_str), false, None);
2101+ let (has_errors, _) = compile(&path_str, &CompileConfig {
2102+ output_path: Some(&out_str),
2103+ ..Default::default()
2104+ });
19632105 if has_errors {
19642106 any_errors.store(true, std::sync::atomic::Ordering::Relaxed);
19652107 }
@@ -3034,7 +3176,13 @@ compile();
30343176 }
30353177 out_p.to_string_lossy().to_string()
30363178 });
3037− let (has_errors, included) = compile_inner(&path_str, dev, serve, check, effective_out.as_deref(), format_json, json_collector.as_ref(), false, compat, strict, partial);
3179+ let (has_errors, included) = compile(&path_str, &CompileConfig {
3180+ dev, error_overlay: serve, check_only: check,
3181+ output_path: effective_out.as_deref(), format_json,
3182+ json_collector: json_collector.as_ref(),
3183+ compat, strict, partial,
3184+ ..Default::default()
3185+ });
30383186 if has_errors {
30393187 any_errors = true;
30403188 }
@@ -3077,7 +3225,13 @@ compile();
30773225
30783226 // --- Single file mode ---
30793227 let json_collector_single = if format_json { Some(Mutex::new(Vec::new())) } else { None };
3080− let (has_errors, included_files) = compile_inner(&input_path, dev, serve, check, output_path.as_deref(), format_json, json_collector_single.as_ref(), false, compat, strict, partial);
3228+ let (has_errors, included_files) = compile(&input_path, &CompileConfig {
3229+ dev, error_overlay: serve, check_only: check,
3230+ output_path: output_path.as_deref(), format_json,
3231+ json_collector: json_collector_single.as_ref(),
3232+ compat, strict, partial,
3233+ ..Default::default()
3234+ });
30813235 if format_json {
30823236 if let Some(ref collector) = json_collector_single {
30833237 print_json_diagnostics(&collector.lock().unwrap());
@@ -3490,7 +3644,10 @@ fn watch_loop(
34903644 let mut recompiled = 0usize;
34913645 for file in source_files {
34923646 let path_str = file.to_string_lossy().to_string();
3493− let (_, new_includes) = compile(&path_str, dev, serve, false, None, false, None);
3647+ let (_, new_includes) = compile(&path_str, &CompileConfig {
3648+ dev, error_overlay: serve,
3649+ ..Default::default()
3650+ });
34943651 recompiled += 1;
34953652 for inc in &new_includes {
34963653 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
@@ -3505,7 +3662,10 @@ fn watch_loop(
35053662 for file in &collect_hl_files(watch_dir) {
35063663 if !source_files.contains(file) {
35073664 let path_str = file.to_string_lossy().to_string();
3508− let (_, new_includes) = compile(&path_str, dev, serve, false, None, false, None);
3665+ let (_, new_includes) = compile(&path_str, &CompileConfig {
3666+ dev, error_overlay: serve,
3667+ ..Default::default()
3668+ });
35093669 recompiled += 1;
35103670 for inc in &new_includes {
35113671 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
src/parser.rs+101
@@ -795,6 +795,70 @@ impl Parser {
795795 filename
796796 };
797797 let filename = substitute_vars(&filename, &ctx.variables);
798+
799+ // Glob support: if filename contains *, expand to multiple imports
800+ if filename.contains('*') {
801+ let base_dir = match &ctx.base_path {
802+ Some(base) => base.clone(),
803+ None => PathBuf::from("."),
804+ };
805+ let pattern_path = Path::new(&filename);
806+ let (glob_dir, glob_pattern) = match pattern_path.parent() {
807+ Some(dir) if !dir.as_os_str().is_empty() => (base_dir.join(dir), pattern_path.file_name().unwrap_or_default().to_string_lossy().to_string()),
808+ _ => (base_dir.clone(), filename.clone()),
809+ };
810+ let matched_files = match std::fs::read_dir(&glob_dir) {
811+ Ok(entries) => {
812+ let mut files: Vec<PathBuf> = entries
813+ .flatten()
814+ .filter(|e| {
815+ let name = e.file_name().to_string_lossy().to_string();
816+ glob_match(&glob_pattern, &name)
817+ })
818+ .map(|e| e.path())
819+ .collect();
820+ files.sort();
821+ files
822+ }
823+ Err(e) => {
824+ ctx.diagnostics.push(Diagnostic {
825+ line: line_num,
826+ message: format!("cannot read directory for glob '{}': {}", filename, e),
827+ severity: Severity::Error,
828+ source_line: Some(content.clone()),
829+ });
830+ return Ok(None);
831+ }
832+ };
833+ if matched_files.is_empty() {
834+ ctx.diagnostics.push(Diagnostic {
835+ line: line_num,
836+ message: format!("no files matched glob pattern '{}'", filename),
837+ severity: Severity::Warning,
838+ source_line: Some(content.clone()),
839+ });
840+ return Ok(None);
841+ }
842+ for file_path in matched_files {
843+ let rel_name = file_path.strip_prefix(&base_dir).unwrap_or(&file_path).to_string_lossy().to_string();
844+ // Synthesize an @import line for each matched file
845+ let import_line = match &alias {
846+ Some(pfx) => {
847+ let stem = file_path.file_stem().unwrap_or_default().to_string_lossy().to_string();
848+ format!("@import \"{}\" as {}.{}", rel_name, pfx, stem)
849+ }
850+ None => format!("@import \"{}\"", rel_name),
851+ };
852+ let synth_lines = preprocess(&import_line);
853+ let mut synth_parser = Parser {
854+ lines: synth_lines,
855+ pos: 0,
856+ };
857+ let _ = synth_parser.parse_children(0, ctx);
858+ }
859+ return Ok(None);
860+ }
861+
798862 let resolved = match &ctx.base_path {
799863 Some(base) => base.join(&filename),
800864 None => PathBuf::from(&filename),
@@ -2995,6 +3059,13 @@ const KNOWN_ATTRS: &[&str] = &[
29953059 // CSS: aspect-ratio, outline, logical properties, scroll-snap
29963060 "aspect-ratio", "outline",
29973061 "padding-inline", "padding-block", "margin-inline", "margin-block",
3062+ "padding-inline-start", "padding-inline-end", "padding-block-start", "padding-block-end",
3063+ "margin-inline-start", "margin-inline-end", "margin-block-start", "margin-block-end",
3064+ "inset-inline", "inset-block", "inset-inline-start", "inset-inline-end", "inset-block-start", "inset-block-end",
3065+ "border-inline", "border-block", "border-inline-start", "border-inline-end", "border-block-start", "border-block-end",
3066+ "border-start-start-radius", "border-start-end-radius", "border-end-start-radius", "border-end-end-radius",
3067+ "scroll-margin-inline", "scroll-margin-block", "scroll-padding-inline", "scroll-padding-block",
3068+ "inline-size", "block-size", "min-inline-size", "max-inline-size", "min-block-size", "max-block-size",
29983069 "scroll-snap-type", "scroll-snap-align",
29993070 // Media attributes
30003071 "controls", "autoplay", "loop", "muted", "poster", "preload",
@@ -5026,6 +5097,36 @@ fn find_closing_single(chars: &[char], start: usize, marker: char) -> Option<usi
50265097 None
50275098}
50285099
5100+/// Simple glob matching supporting `*` as wildcard for any characters and `?` for a single character.
5101+fn glob_match(pattern: &str, text: &str) -> bool {
5102+ let mut pi = 0;
5103+ let mut ti = 0;
5104+ let pb = pattern.as_bytes();
5105+ let tb = text.as_bytes();
5106+ let mut star_pi = usize::MAX;
5107+ let mut star_ti = 0;
5108+ while ti < tb.len() {
5109+ if pi < pb.len() && (pb[pi] == b'?' || pb[pi] == tb[ti]) {
5110+ pi += 1;
5111+ ti += 1;
5112+ } else if pi < pb.len() && pb[pi] == b'*' {
5113+ star_pi = pi;
5114+ star_ti = ti;
5115+ pi += 1;
5116+ } else if star_pi != usize::MAX {
5117+ pi = star_pi + 1;
5118+ star_ti += 1;
5119+ ti = star_ti;
5120+ } else {
5121+ return false;
5122+ }
5123+ }
5124+ while pi < pb.len() && pb[pi] == b'*' {
5125+ pi += 1;
5126+ }
5127+ pi == pb.len()
5128+}
5129+
50295130fn html_escape_md(s: &str) -> String {
50305131 s.replace('&', "&amp;")
50315132 .replace('<', "&lt;")
src/serve.rs+40−15
@@ -137,20 +137,14 @@ async fn handle_dir_request(
137137 };
138138 let content_type = content_type_for(&fp);
139139 let header = format!(
140− "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n",
140+ "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\nAccess-Control-Allow-Origin: *\r\n\r\n",
141141 content_type, body.len(),
142142 );
143143 stream.write_all(header.as_bytes()).await?;
144144 stream.write_all(&body).await?;
145145 }
146146 None => {
147− let body = b"<h1>404 Not Found</h1>";
148− let header = format!(
149− "HTTP/1.1 404 Not Found\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n",
150− body.len()
151− );
152− stream.write_all(header.as_bytes()).await?;
153− stream.write_all(body).await?;
147+ send_404(&mut stream, clean_path).await?;
154148 }
155149 }
156150
@@ -289,16 +283,14 @@ async fn handle_file(
289283 }
290284 }
291285 Err(_) => {
292− stream
293− .write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
294− .await?;
286+ send_404(&mut stream, request_path).await?;
295287 return Ok(());
296288 }
297289 };
298290
299291 let content_type = content_type_for(&file_path);
300292 let header = format!(
301− "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n",
293+ "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\nAccess-Control-Allow-Origin: *\r\n\r\n",
302294 body.len(),
303295 );
304296 stream.write_all(header.as_bytes()).await?;
@@ -307,6 +299,29 @@ async fn handle_file(
307299 Ok(())
308300}
309301
302+async fn send_404(stream: &mut TcpStream, path: &str) -> std::io::Result<()> {
303+ let body = format!(
304+ "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>404</title>\
305+ <style>*{{margin:0;box-sizing:border-box}}body{{font-family:system-ui,-apple-system,sans-serif;\
306+ display:flex;align-items:center;justify-content:center;min-height:100vh;background:#fafafa;color:#333}}\
307+ .box{{text-align:center;padding:3rem}}\
308+ h1{{font-size:4rem;font-weight:200;color:#999;margin-bottom:0.5rem}}\
309+ p{{color:#666;margin-bottom:1.5rem}}\
310+ code{{background:#eee;padding:0.2em 0.5em;border-radius:4px;font-size:0.9rem}}\
311+ a{{color:#2563eb;text-decoration:none}}a:hover{{text-decoration:underline}}</style></head>\
312+ <body><div class=\"box\"><h1>404</h1><p>Not found: <code>{}</code></p>\
313+ <a href=\"/\">Back to index</a></div></body></html>",
314+ path.replace('<', "&lt;").replace('>', "&gt;")
315+ );
316+ let header = format!(
317+ "HTTP/1.1 404 Not Found\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n",
318+ body.len()
319+ );
320+ stream.write_all(header.as_bytes()).await?;
321+ stream.write_all(body.as_bytes()).await?;
322+ Ok(())
323+}
324+
310325fn inject_reload_script(content: &[u8]) -> Vec<u8> {
311326 let html = String::from_utf8_lossy(content);
312327 match html.rfind("</body>") {
@@ -330,8 +345,8 @@ fn content_type_for(path: &Path) -> &'static str {
330345 match path.extension().and_then(|e| e.to_str()) {
331346 Some("html") => "text/html; charset=utf-8",
332347 Some("css") => "text/css; charset=utf-8",
333− Some("js") => "application/javascript",
334− Some("json") => "application/json",
348+ Some("js" | "mjs") => "application/javascript; charset=utf-8",
349+ Some("json") => "application/json; charset=utf-8",
335350 Some("png") => "image/png",
336351 Some("jpg" | "jpeg") => "image/jpeg",
337352 Some("gif") => "image/gif",
@@ -339,12 +354,22 @@ fn content_type_for(path: &Path) -> &'static str {
339354 Some("ico") => "image/x-icon",
340355 Some("woff") => "font/woff",
341356 Some("woff2") => "font/woff2",
357+ Some("ttf") => "font/ttf",
358+ Some("otf") => "font/otf",
359+ Some("eot") => "application/vnd.ms-fontobject",
342360 Some("webp") => "image/webp",
343361 Some("avif") => "image/avif",
344362 Some("mp4") => "video/mp4",
345363 Some("webm") => "video/webm",
364+ Some("ogg") => "audio/ogg",
365+ Some("mp3") => "audio/mpeg",
366+ Some("wav") => "audio/wav",
346367 Some("xml") => "application/xml",
347− Some("txt") => "text/plain; charset=utf-8",
368+ Some("txt" | "md") => "text/plain; charset=utf-8",
369+ Some("pdf") => "application/pdf",
370+ Some("zip") => "application/zip",
371+ Some("wasm") => "application/wasm",
372+ Some("map") => "application/json",
348373 _ => "application/octet-stream",
349374 }
350375}