@@ -16,74 +16,78 @@ pub(crate) fn document_symbols(text: &str) -> Vec<SymbolInformation> {
1616 let trimmed = line.trim();
1717 let line_num = i as u32;
1818
19− // @fn definitions
20− if let Some(rest) = trimmed.strip_prefix("@fn ") {
21− let parts: Vec<&str> = rest.split_whitespace().collect();
22− if let Some(name) = parts.first() {
23− let params = parts[1..].join(" ");
24− let detail = if params.is_empty() {
25− None
26− } else {
27− Some(format!("({})", params))
28− };
29− symbols.push(SymbolInformation {
30− name: format!("@{}", name),
31− kind: SymbolKind::FUNCTION,
32− tags: None,
33− deprecated: None,
34− location: Location {
35− uri: Url::parse("file:///").unwrap(), // replaced by caller
36− range: Range::new(
37− Position::new(line_num, 0),
38− Position::new(line_num, line.len() as u32),
39− ),
40− },
41− container_name: detail,
42− });
19+ // @let definitions (variables, attribute bundles, and components)
20+ if let Some(rest) = trimmed.strip_prefix("@let ") {
21+ let rest_trimmed = rest.trim();
22+ let parts: Vec<&str> = rest_trimmed.split_whitespace().collect();
23+ if let Some(&name) = parts.first() {
24+ let value_part = rest_trimmed.get(name.len()..).unwrap_or("").trim_start();
25+ let has_body = text
26+ .lines()
27+ .nth(i + 1)
28+ .map(|l| l.starts_with(" ") || l.starts_with('\t'))
29+ .unwrap_or(false);
30+
31+ if has_body
32+ && (value_part.is_empty() || value_part.starts_with('$'))
33+ {
34+ // Component/function definition
35+ let params = parts[1..].join(" ");
36+ let detail = if params.is_empty() {
37+ None
38+ } else {
39+ Some(format!("({})", params))
40+ };
41+ symbols.push(SymbolInformation {
42+ name: format!("@{}", name),
43+ kind: SymbolKind::FUNCTION,
44+ tags: None,
45+ deprecated: None,
46+ location: Location {
47+ uri: Url::parse("file:///").unwrap(), // replaced by caller
48+ range: Range::new(
49+ Position::new(line_num, 0),
50+ Position::new(line_num, line.len() as u32),
51+ ),
52+ },
53+ container_name: detail,
54+ });
55+ } else if value_part.starts_with('[') {
56+ // Attribute bundle
57+ symbols.push(SymbolInformation {
58+ name: format!("${}", name),
59+ kind: SymbolKind::CONSTANT,
60+ tags: None,
61+ deprecated: None,
62+ location: Location {
63+ uri: Url::parse("file:///").unwrap(),
64+ range: Range::new(
65+ Position::new(line_num, 0),
66+ Position::new(line_num, line.len() as u32),
67+ ),
68+ },
69+ container_name: Some("attribute bundle".to_string()),
70+ });
71+ } else if !value_part.is_empty() {
72+ // Scalar variable
73+ symbols.push(SymbolInformation {
74+ name: format!("${}", name),
75+ kind: SymbolKind::VARIABLE,
76+ tags: None,
77+ deprecated: None,
78+ location: Location {
79+ uri: Url::parse("file:///").unwrap(),
80+ range: Range::new(
81+ Position::new(line_num, 0),
82+ Position::new(line_num, line.len() as u32),
83+ ),
84+ },
85+ container_name: Some(format!("= {}", value_part)),
86+ });
87+ }
4388 }
4489 }
4590
46− // @let definitions
47− if let Some(rest) = trimmed.strip_prefix("@let ")
48− && let Some((name, value)) = rest.trim().split_once(' ')
49− {
50− symbols.push(SymbolInformation {
51− name: format!("${}", name),
52− kind: SymbolKind::VARIABLE,
53− tags: None,
54− deprecated: None,
55− location: Location {
56− uri: Url::parse("file:///").unwrap(),
57− range: Range::new(
58− Position::new(line_num, 0),
59− Position::new(line_num, line.len() as u32),
60− ),
61− },
62− container_name: Some(format!("= {}", value.trim())),
63− });
64− }
65−
66− // @define definitions
67− if let Some(rest) = trimmed.strip_prefix("@define ")
68− && let Some(bracket) = rest.find('[')
69− {
70− let name = rest[..bracket].trim();
71− symbols.push(SymbolInformation {
72− name: format!("${}", name),
73− kind: SymbolKind::CONSTANT,
74− tags: None,
75− deprecated: None,
76− location: Location {
77− uri: Url::parse("file:///").unwrap(),
78− range: Range::new(
79− Position::new(line_num, 0),
80− Position::new(line_num, line.len() as u32),
81− ),
82− },
83− container_name: Some("attribute bundle".to_string()),
84− });
85− }
86−
8791 // @keyframes definitions
8892 if let Some(rest) = trimmed.strip_prefix("@keyframes ") {
8993 let name = rest.trim();
@@ -211,15 +215,15 @@ pub(crate) fn code_actions(
211215 }
212216 }
213217
214− // Quick-fix: remove unused @define
215− if msg.contains("unused define") {
218+ // Quick-fix: remove unused attribute bundle (@let name [...])
219+ if msg.contains("unused attribute bundle") {
216220 let line = diag.range.start.line as usize;
217221 let lines: Vec<&str> = text.lines().collect();
218222 if let Some(source_line) = lines.get(line) {
219223 let trimmed = source_line.trim_start();
220− if trimmed.starts_with("@define ") {
224+ if trimmed.starts_with("@let ") {
221225 let def_name = trimmed
222− .strip_prefix("@define ")
226+ .strip_prefix("@let ")
223227 .and_then(|r| {
224228 let r = r.trim();
225229 r.find('[')
@@ -237,7 +241,7 @@ pub(crate) fn code_actions(
237241 let mut changes = HashMap::new();
238242 changes.insert(uri.clone(), vec![edit]);
239243 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
240− title: format!("Remove unused define '${}'", def_name),
244+ title: format!("Remove unused attribute bundle '${}'", def_name),
241245 kind: Some(CodeActionKind::QUICKFIX),
242246 diagnostics: Some(vec![diag.clone()]),
243247 edit: Some(WorkspaceEdit {
@@ -250,15 +254,15 @@ pub(crate) fn code_actions(
250254 }
251255 }
252256
253− // Quick-fix: remove unused @fn function (remove definition and its body)
257+ // Quick-fix: remove unused function (@let name ... with body)
254258 if msg.contains("unused function") {
255259 let line = diag.range.start.line as usize;
256260 let lines: Vec<&str> = text.lines().collect();
257261 if let Some(source_line) = lines.get(line) {
258262 let trimmed = source_line.trim_start();
259− if trimmed.starts_with("@fn ") {
263+ if trimmed.starts_with("@let ") {
260264 let fn_name = trimmed
261− .strip_prefix("@fn ")
265+ .strip_prefix("@let ")
262266 .and_then(|r| r.split_whitespace().next())
263267 .unwrap_or("?");
264268 // Find the end of the function body (indented lines below)
@@ -301,45 +305,6 @@ pub(crate) fn code_actions(
301305 }
302306 }
303307
304− // Quick-fix: remove unused @mixin
305− if msg.contains("unused mixin") {
306− let line = diag.range.start.line as usize;
307− let lines: Vec<&str> = text.lines().collect();
308− if let Some(source_line) = lines.get(line) {
309− let trimmed = source_line.trim_start();
310− if trimmed.starts_with("@mixin ") {
311− let mixin_name = trimmed
312− .strip_prefix("@mixin ")
313− .and_then(|r| {
314− let r = r.trim();
315− r.find('[')
316− .map(|b| r[..b].trim())
317− .or_else(|| r.split_whitespace().next())
318− })
319− .unwrap_or("?");
320− let edit = TextEdit {
321− range: Range::new(
322− Position::new(diag.range.start.line, 0),
323− Position::new(diag.range.start.line + 1, 0),
324− ),
325− new_text: String::new(),
326− };
327− let mut changes = HashMap::new();
328− changes.insert(uri.clone(), vec![edit]);
329− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
330− title: format!("Remove unused mixin '{}'", mixin_name),
331− kind: Some(CodeActionKind::QUICKFIX),
332− diagnostics: Some(vec![diag.clone()]),
333− edit: Some(WorkspaceEdit {
334− changes: Some(changes),
335− ..Default::default()
336− }),
337− ..Default::default()
338− }));
339− }
340− }
341− }
342−
343308 // Quick-fix: add alt attribute to @image
344309 if msg.contains("@image should have") && msg.contains("alt") {
345310 let line = diag.range.start.line as usize;
@@ -459,7 +424,7 @@ pub(crate) fn code_actions(
459424 }
460425
461426 // Quick-fix: auto-import suggestion for unknown element @name
462− // Searches current directory and subdirectories for @fn definitions
427+ // Searches current directory and subdirectories for component definitions
463428 if msg.contains("unknown element @")
464429 && let Some(fn_name) = extract_between(msg, "unknown element @", ",")
465430 .or_else(|| extract_between(msg, "unknown element @", ""))
@@ -495,7 +460,7 @@ pub(crate) fn code_actions(
495460 if let Ok(content) = std::fs::read_to_string(&path) {
496461 let defines_fn = content.lines().any(|l| {
497462 let t = l.trim();
498− if let Some(rest) = t.strip_prefix("@fn ") {
463+ if let Some(rest) = t.strip_prefix("@let ") {
499464 rest.split_whitespace().next() == Some(fn_name)
500465 } else {
501466 false
@@ -577,7 +542,7 @@ pub(crate) fn code_actions(
577542 }
578543 }
579544
580− // Refactoring: extract selection to @fn
545+ // Refactoring: extract selection to @let component
581546 if selection.start.line != selection.end.line {
582547 let lines: Vec<&str> = text.lines().collect();
583548 let start_line = selection.start.line as usize;
@@ -594,7 +559,7 @@ pub(crate) fn code_actions(
594559 .min()
595560 .unwrap_or(0);
596561
597− // Build the function body with two-space indentation relative to @fn
562+ // Build the function body with two-space indentation relative to @let
598563 let fn_body: String = selected
599564 .iter()
600565 .map(|l| {
@@ -611,11 +576,11 @@ pub(crate) fn code_actions(
611576 })
612577 .collect();
613578
614− let fn_def = format!("@fn extracted\n{}", fn_body);
579+ let fn_def = format!("@let extracted\n{}", fn_body);
615580 let indent = " ".repeat(min_indent);
616581 let fn_call = format!("{}@extracted", indent);
617582
618− // Build edits: replace selected lines with @extracted call, and insert @fn definition at top
583+ // Build edits: replace selected lines with @extracted call, and insert @let definition at top
619584 let replace_edit = TextEdit {
620585 range: Range::new(
621586 Position::new(selection.start.line, 0),
@@ -630,7 +595,7 @@ pub(crate) fn code_actions(
630595 let mut changes = HashMap::new();
631596 changes.insert(uri.clone(), vec![insert_edit, replace_edit]);
632597 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
633− title: "Extract to @fn".into(),
598+ title: "Extract to @let component".into(),
634599 kind: Some(CodeActionKind::REFACTOR_EXTRACT),
635600 diagnostics: None,
636601 edit: Some(WorkspaceEdit {
@@ -643,8 +608,8 @@ pub(crate) fn code_actions(
643608 }
644609 }
645610
646− // Refactoring: extract attributes to @define
647− // Works on a single line with [attrs] — extracts attrs into a @define
611+ // Refactoring: extract attributes to @let attribute bundle
612+ // Works on a single line with [attrs] — extracts attrs into a @let
648613 {
649614 let lines: Vec<&str> = text.lines().collect();
650615 let line_idx = selection.start.line as usize;
@@ -658,7 +623,7 @@ pub(crate) fn code_actions(
658623 let attr_count = attrs_str.split(',').count();
659624 if attr_count >= 2 {
660625 let define_name = "extracted-style";
661− let define_line = format!("@define {} [{}]\n", define_name, attrs_str.trim());
626+ let define_line = format!("@let {} [{}]\n", define_name, attrs_str.trim());
662627 let indent = " ".repeat(line.len() - line.trim_start().len());
663628
664629 // Replace [attrs] with [$extracted-style]
@@ -684,7 +649,7 @@ pub(crate) fn code_actions(
684649 let mut changes = HashMap::new();
685650 changes.insert(uri.clone(), vec![insert_edit, replace_edit]);
686651 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
687− title: "Extract to @define".into(),
652+ title: "Extract to @let attribute bundle".into(),
688653 kind: Some(CodeActionKind::REFACTOR_EXTRACT),
689654 diagnostics: None,
690655 edit: Some(WorkspaceEdit {
@@ -921,8 +886,8 @@ pub(crate) fn folding_ranges(text: &str) -> Vec<FoldingRange> {
921886 let mut i = 0;
922887 while i < lines.len() {
923888 let trimmed = lines[i].trim();
924− // Fold blocks that start with @fn, @if, @else, @each, @match, @style, @head, @keyframes, @define
925− if trimmed.starts_with("@fn ")
889+ // Fold blocks that start with @let (with body), @if, @else, @each, @match, @style, @head, @keyframes
890+ if trimmed.starts_with("@let ")
926891 || trimmed.starts_with("@if ")
927892 || trimmed == "@else"
928893 || trimmed.starts_with("@else if ")
@@ -1066,25 +1031,21 @@ pub(crate) fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
10661031 }
10671032 let word = &line[start..i];
10681033 let token_type = match word {
1069− "@page" | "@let" | "@define" | "@fn" | "@if" | "@else" | "@each"
1034+ "@page" | "@let" | "@if" | "@else" | "@each"
10701035 | "@include" | "@import" | "@meta" | "@head" | "@style" | "@keyframes"
10711036 | "@match" | "@case" | "@default" | "@slot" | "@children" | "@warn"
10721037 | "@debug" | "@lang" | "@favicon" | "@fragment" | "@unless" | "@og"
10731038 | "@breakpoint" | "@canonical" | "@base" | "@font-face" | "@json-ld"
1074− | "@mixin" | "@assert" | "@theme" | "@deprecated" | "@extends" | "@use"
1039+ | "@assert" | "@theme" | "@deprecated" | "@extends" | "@use"
10751040 | "@data" | "@env" | "@fetch" | "@svg" | "@css-property" => 0, // keyword
10761041 _ => {
10771042 // Check if it's a user function call (starts with @ but not a builtin element)
10781043 if is_builtin_element(word) { 0 } else { 2 } // function
10791044 }
10801045 };
1081− // Mark unused @fn definitions with deprecated modifier (dimmed)
1082− let modifier = if (trimmed.starts_with("@fn ")
1083− || trimmed.starts_with("@define ")
1084− || trimmed.starts_with("@mixin "))
1085− && word != "@fn"
1086− && word != "@define"
1087− && word != "@mixin"
1046+ // Mark unused definitions with deprecated modifier (dimmed)
1047+ let modifier = if trimmed.starts_with("@let ")
1048+ && word != "@let"
10881049 {
10891050 let name_part = &word[1..]; // strip @
10901051 if unused_vars.contains(&format!("@{}", name_part)) {
@@ -1333,10 +1294,10 @@ pub(crate) fn get_signature_help(text: &str, position: Position) -> Option<Signa
13331294 }
13341295 let inside_args = in_brackets(before);
13351296
1336− // Find the @fn definition
1297+ // Find the @let component definition
13371298 for (line_idx, line_text) in text.lines().enumerate() {
13381299 let t = line_text.trim_start();
1339− if let Some(rest) = t.strip_prefix("@fn ") {
1300+ if let Some(rest) = t.strip_prefix("@let ") {
13401301 let rest = rest.trim();
13411302 let def_name_end = rest
13421303 .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')