@@ -89,7 +89,9 @@ impl LanguageServer for Backend {
89 SemanticTokenType::COMMENT,
89 SemanticTokenType::COMMENT,
90 SemanticTokenType::PROPERTY,
90 SemanticTokenType::PROPERTY,
92− token_modifiers: vec![],
92+ token_modifiers: vec![
93+ SemanticTokenModifier::new("deprecated"), // bit 0 = 1 → dimmed/strikethrough
94 full: Some(SemanticTokensFullOptions::Bool(true)),
96 full: Some(SemanticTokensFullOptions::Bool(true)),
95 range: None,
97 range: None,
@@ -700,6 +702,8 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
700 ("@base", "Set base URL for relative links", "@base "),
702 ("@base", "Set base URL for relative links", "@base "),
701 ("@font-face", "Define a custom font face", "@font-face"),
703 ("@font-face", "Define a custom font face", "@font-face"),
702 ("@json-ld", "Add JSON-LD structured data to head", "@json-ld"),
704 ("@json-ld", "Add JSON-LD structured data to head", "@json-ld"),
705+ ("@mixin", "Define a composable style group (use with ...$name)", "@mixin "),
706+ ("@assert", "Compile-time assertion for variable values", "@assert "),
705 .map(|(name, detail, insert)| {
709 .map(|(name, detail, insert)| {
@@ -1328,19 +1332,39 @@ fn function_completions(text: &str, range: Range) -> Vec<CompletionItem> {
1328 if let Some(rest) = trimmed.strip_prefix("@fn ") {
1332 if let Some(rest) = trimmed.strip_prefix("@fn ") {
1329 let parts: Vec<&str> = rest.split_whitespace().collect();
1333 let parts: Vec<&str> = rest.split_whitespace().collect();
1330 if let Some(name) = parts.first() {
1334 if let Some(name) = parts.first() {
1331− let params = parts[1..].join(" ");
1335+ let params: Vec<&str> = parts[1..].iter()
1336+ .map(|p| p.strip_prefix('$').unwrap_or(p))
1332 let detail = if params.is_empty() {
1338 let detail = if params.is_empty() {
1333 "Function".to_string()
1339 "Function".to_string()
1334 } else {
1340 } else {
1335− format!("Function({})", params)
1341+ format!("Function({})", params.join(", "))
1337− items.push(item(
1343+ // Generate snippet with tab stops for parameters
1344+ let insert_text = if params.is_empty() {
1345+ format!("@{}", name)
1347+ let param_snippets: Vec<String> = params.iter().enumerate().map(|(i, p)| {
1348+ let p_name = p.split('=').next().unwrap_or(p);
1349+ let default = p.split('=').nth(1).unwrap_or(p_name);
1350+ format!("{} ${{{}:{}}}", p_name, i + 1, default)
1352+ format!("@{} [{}]", name, param_snippets.join(", "))
1354+ let mut ci = CompletionItem {
1338− &format!("@{}", name),
1355+ label: format!("@{}", name),
1339− CompletionItemKind::FUNCTION,
1356+ kind: Some(CompletionItemKind::FUNCTION),
1340− &detail,
1357+ detail: Some(detail),
1341− &format!("@{}", name),
1358+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
1343− ));
1360+ new_text: insert_text,
1362+ ..Default::default()
1364+ if !params.is_empty() {
1365+ ci.insert_text_format = Some(tower_lsp::lsp_types::InsertTextFormat::SNIPPET);
@@ -2485,6 +2509,42 @@ fn code_actions(
2512+ // Quick-fix: remove unused @mixin
2513+ if msg.contains("unused mixin") {
2514+ let line = diag.range.start.line as usize;
2515+ let lines: Vec<&str> = text.lines().collect();
2516+ if let Some(source_line) = lines.get(line) {
2517+ let trimmed = source_line.trim_start();
2518+ if trimmed.starts_with("@mixin ") {
2519+ let mixin_name = trimmed.strip_prefix("@mixin ")
2522+ r.find('[').map(|b| r[..b].trim()).or_else(|| r.split_whitespace().next())
2525+ let edit = TextEdit {
2527+ Position::new(diag.range.start.line, 0),
2528+ Position::new(diag.range.start.line + 1, 0),
2530+ new_text: String::new(),
2532+ let mut changes = HashMap::new();
2533+ changes.insert(uri.clone(), vec![edit]);
2534+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2535+ title: format!("Remove unused mixin '{}'", mixin_name),
2536+ kind: Some(CodeActionKind::QUICKFIX),
2537+ diagnostics: Some(vec![diag.clone()]),
2538+ edit: Some(WorkspaceEdit {
2539+ changes: Some(changes),
2540+ ..Default::default()
2542+ ..Default::default()
2488 // Quick-fix: add alt attribute to @image
2548 // Quick-fix: add alt attribute to @image
2489 if msg.contains("@image should have") && msg.contains("alt") {
2549 if msg.contains("@image should have") && msg.contains("alt") {
2490 let line = diag.range.start.line as usize;
2550 let line = diag.range.start.line as usize;
@@ -2929,6 +2989,44 @@ fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
2929 let mut prev_line: u32 = 0;
2989 let mut prev_line: u32 = 0;
2930 let mut prev_start: u32 = 0;
2990 let mut prev_start: u32 = 0;
2992+ // Build set of unused variables by parsing diagnostics
2993+ let result = htmlang::parser::parse(text);
2994+ let mut unused_vars: std::collections::HashSet<String> = std::collections::HashSet::new();
2995+ for d in &result.diagnostics {
2996+ if d.message.contains("unused variable '$") {
2997+ if let Some(start) = d.message.find("'$") {
2998+ let rest = &d.message[start + 2..];
2999+ if let Some(end) = rest.find('\'') {
3000+ unused_vars.insert(rest[..end].to_string());
3004+ if d.message.contains("unused function '@") {
3005+ if let Some(start) = d.message.find("'@") {
3006+ let rest = &d.message[start + 2..];
3007+ if let Some(end) = rest.find('\'') {
3008+ unused_vars.insert(format!("@{}", &rest[..end]));
3012+ if d.message.contains("unused define '$") {
3013+ if let Some(start) = d.message.find("'$") {
3014+ let rest = &d.message[start + 2..];
3015+ if let Some(end) = rest.find('\'') {
3016+ unused_vars.insert(rest[..end].to_string());
3020+ if d.message.contains("unused mixin '") {
3021+ if let Some(start) = d.message.find("unused mixin '") {
3022+ let rest = &d.message[start + 14..];
3023+ if let Some(end) = rest.find('\'') {
3024+ unused_vars.insert(rest[..end].to_string());
2932 for (line_idx, line) in text.lines().enumerate() {
3030 for (line_idx, line) in text.lines().enumerate() {
2933 let trimmed = line.trim();
3031 let trimmed = line.trim();
2934 let line_num = line_idx as u32;
3032 let line_num = line_idx as u32;
@@ -2936,7 +3034,7 @@ fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
2936 // Detect comments
3034 // Detect comments
2937 if trimmed.starts_with("--") {
3035 if trimmed.starts_with("--") {
2938 let col = (line.len() - trimmed.len()) as u32;
3036 let col = (line.len() - trimmed.len()) as u32;
2939− push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, col, trimmed.len() as u32, 4);
3037+ push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, col, trimmed.len() as u32, 4, 0);
2940 continue;
3038 continue;
@@ -2957,13 +3055,20 @@ fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
2957 | "@match" | "@case" | "@default" | "@slot" | "@children"
3055 | "@match" | "@case" | "@default" | "@slot" | "@children"
2958 | "@warn" | "@debug" | "@lang" | "@favicon" | "@fragment"
3056 | "@warn" | "@debug" | "@lang" | "@favicon" | "@fragment"
2959 | "@unless" | "@og" | "@breakpoint"
3057 | "@unless" | "@og" | "@breakpoint"
2960− | "@canonical" | "@base" | "@font-face" | "@json-ld" => 0, // keyword
3058+ | "@canonical" | "@base" | "@font-face" | "@json-ld"
3059+ | "@mixin" | "@assert" | "@theme" | "@deprecated" | "@extends"
3060+ | "@use" => 0, // keyword
2962 // Check if it's a user function call (starts with @ but not a builtin element)
3062 // Check if it's a user function call (starts with @ but not a builtin element)
2963 if is_builtin_element(word) { 0 } else { 2 } // function
3063 if is_builtin_element(word) { 0 } else { 2 } // function
3066+ // Mark unused @fn definitions with deprecated modifier (dimmed)
3067+ let modifier = if (trimmed.starts_with("@fn ") || trimmed.starts_with("@define ") || trimmed.starts_with("@mixin ")) && word != "@fn" && word != "@define" && word != "@mixin" {
3068+ let name_part = &word[1..]; // strip @
3069+ if unused_vars.contains(&format!("@{}", name_part)) { 1 } else { 0 }
2966− push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, start as u32, (i - start) as u32, token_type);
3071+ push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, start as u32, (i - start) as u32, token_type, modifier);
2967 } else if bytes[i] == b'$' {
3072 } else if bytes[i] == b'$' {
2968 let start = i;
3073 let start = i;
@@ -2971,7 +3076,10 @@ fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
2973 if i > start + 1 {
3078 if i > start + 1 {
3079+ // Check if this is an unused variable definition on a @let line
3080+ let var_name = &line[start + 1..i];
3081+ let modifier = if trimmed.starts_with("@let ") && unused_vars.contains(var_name) { 1 } else { 0 };
2974− push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, start as u32, (i - start) as u32, 1); // variable
3082+ push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, start as u32, (i - start) as u32, 1, modifier); // variable
2976 } else {
3084 } else {
@@ -3005,6 +3113,7 @@ fn push_token(
3005 start: u32,
3113 start: u32,
3006 length: u32,
3114 length: u32,
3007 token_type: u32,
3115 token_type: u32,
3116+ token_modifiers_bitset: u32,
3009 let delta_line = line - *prev_line;
3118 let delta_line = line - *prev_line;
3010 let delta_start = if delta_line == 0 {
3119 let delta_start = if delta_line == 0 {
@@ -3017,7 +3126,7 @@ fn push_token(
3017 delta_start,
3126 delta_start,
3019 token_type,
3128 token_type,
3020− token_modifiers_bitset: 0,
3129+ token_modifiers_bitset,
3022 *prev_line = line;
3131 *prev_line = line;
3023 *prev_start = start;
3132 *prev_start = start;