@@ -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(¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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, ¶ms.range, ¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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 = ¤t_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−}