on master help sign in
Unify @fn, @define, and @mixin into single @let directive
commitbe4fcdc9a7be8725da91a99291ca843dbd6d867f authoriko <mail@iko.soy> dateSun 26 Apr 2026, 17:30 (5 months ago) parentdb9aa3d
20 files changed+404−505
crates/htmlang-core/src/parser.rs+71−127 examples/blog.hl+2−2 examples/demo.hl+5−5 examples/docs.hl+1−1 examples/landing.hl+1−1 src/bin/htmlang_lsp/analysis.rs+97−136 src/bin/htmlang_lsp/completion.rs+66−59 src/bin/htmlang_lsp/hover.rs+19−28 src/bin/htmlang_lsp/main.rs+36−31 src/bin/htmlang_lsp/navigation.rs+44−43 src/cli.rs+4−4 src/main.rs+29−39 src/playground_default.hl+5−5 tests/snapshots/fn_defaults.hl+1−1 tests/snapshots/functions.hl+2−2 tests/snapshots/mixin_spread.hl+2−2 tests/snapshots/named_slots.hl+1−1 tests/snapshots/stress_deeply_nested.hl+2−2 tests/snapshots/stress_large.hl+15−15 tests/snapshots/variables_defines.hl+1−1
crates/htmlang-core/src/parser.rs+71−127
@@ -89,26 +89,20 @@ struct ParseContext {
8989 file_cache: HashMap<PathBuf, String>,
9090 /// Track which @let variables are referenced (for unused warnings)
9191 used_variables: HashSet<String>,
92− /// Track which @fn functions are called (for unused warnings)
92+ /// Track which functions are called (for unused warnings)
9393 used_functions: HashSet<String>,
94− /// Track which @define bundles are referenced (for unused warnings)
94+ /// Track which attribute bundles are referenced (for unused warnings)
9595 used_defines: HashSet<String>,
9696 /// Line numbers of @let definitions (name -> line)
9797 let_lines: HashMap<String, usize>,
98− /// Line numbers of @fn definitions (name -> line)
98+ /// Line numbers of function definitions (name -> line)
9999 fn_lines: HashMap<String, usize>,
100− /// Line numbers of @define definitions (name -> line)
100+ /// Line numbers of attribute bundle definitions (name -> line)
101101 define_lines: HashMap<String, usize>,
102102 /// Deprecated functions: name -> deprecation message
103103 deprecated_fns: HashMap<String, String>,
104104 /// Theme tokens: (name, value) pairs from @theme
105105 theme_tokens: Vec<(String, String)>,
106− /// Mixin definitions: name -> list of attributes
107− mixins: HashMap<String, Vec<Attribute>>,
108− /// Line numbers of @mixin definitions (name -> line)
109− mixin_lines: HashMap<String, usize>,
110− /// Track which @mixin definitions are referenced (for unused warnings)
111− used_mixins: HashSet<String>,
112106 /// Canonical URL
113107 canonical: Option<String>,
114108 /// Base URL for relative links
@@ -175,9 +169,6 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
175169 base_url: None,
176170 font_faces: Vec::new(),
177171 json_ld_blocks: Vec::new(),
178− mixins: HashMap::new(),
179− mixin_lines: HashMap::new(),
180− used_mixins: HashSet::new(),
181172 scope_blocks: Vec::new(),
182173 starting_style_blocks: Vec::new(),
183174 manifest: None,
@@ -427,6 +418,54 @@ impl Parser {
427418
428419 if let Some(rest) = content.strip_prefix("@let ") {
429420 let rest = rest.trim();
421+
422+ // Check if next lines are indented (function/component definition)
423+ let has_body = self.pos < self.lines.len()
424+ && self.lines[self.pos].indent > current_indent;
425+
426+ if has_body {
427+ // Function definition: @let name $param1 $param2=default
428+ let parts: Vec<&str> = rest.split_whitespace().collect();
429+ if parts.is_empty() {
430+ return Err(ParseError {
431+ line: line_num,
432+ message: "@let with body requires a name".to_string(),
433+ });
434+ }
435+ let name = parts[0].to_string();
436+ let mut params = Vec::new();
437+ let mut defaults = HashMap::new();
438+ for part in &parts[1..] {
439+ let part = part.strip_prefix('$').unwrap_or(part);
440+ if let Some((param_name, default_val)) = part.split_once('=') {
441+ params.push(param_name.to_string());
442+ defaults.insert(param_name.to_string(), default_val.to_string());
443+ } else {
444+ params.push(part.to_string());
445+ }
446+ }
447+
448+ // Collect body lines (all lines indented deeper than @let)
449+ let mut body_lines = Vec::new();
450+ while self.pos < self.lines.len()
451+ && self.lines[self.pos].indent > current_indent
452+ {
453+ body_lines.push(self.lines[self.pos].clone());
454+ self.pos += 1;
455+ }
456+
457+ ctx.fn_lines.entry(name.clone()).or_insert(line_num);
458+ ctx.functions.insert(
459+ name,
460+ FnDef {
461+ params,
462+ defaults,
463+ body_lines,
464+ },
465+ );
466+ return Ok(None);
467+ }
468+
430469 if let Some((name, value)) = rest.split_once(' ') {
431470 let value = value.trim();
432471 // Support @let name = expr syntax (strip leading =)
@@ -438,6 +477,16 @@ impl Parser {
438477 value
439478 };
440479
480+ // Attribute bundle: @let name [attr1, attr2, ...]
481+ if value.starts_with('[') {
482+ let (attrs, _) = parse_attr_brackets(value, line_num, ctx)?;
483+ ctx.defines.insert(name.to_string(), attrs);
484+ ctx.define_lines
485+ .entry(name.to_string())
486+ .or_insert(line_num);
487+ return Ok(None);
488+ }
489+
441490 // Multi-line @let with triple quotes: @let name """..."""
442491 let value_str;
443492 let value = if let Some(after_open) = value.strip_prefix("\"\"\"") {
@@ -763,32 +812,6 @@ impl Parser {
763812 });
764813 }
765814
766− if let Some(rest) = content.strip_prefix("@define ") {
767− let rest = rest.trim();
768− if let Some(bracket_start) = rest.find('[') {
769− let name = rest[..bracket_start].trim();
770− let attrs_str = &rest[bracket_start..];
771− let (attrs, _) = parse_attr_brackets(attrs_str, line_num, ctx)?;
772− ctx.defines.insert(name.to_string(), attrs);
773− ctx.define_lines.entry(name.to_string()).or_insert(line_num);
774− }
775− return Ok(None);
776− }
777−
778− // --- @mixin directive (composable style groups) ---
779−
780− if let Some(rest) = content.strip_prefix("@mixin ") {
781− let rest = rest.trim();
782− if let Some(bracket_start) = rest.find('[') {
783− let name = rest[..bracket_start].trim();
784− let attrs_str = &rest[bracket_start..];
785− let (attrs, _) = parse_attr_brackets(attrs_str, line_num, ctx)?;
786− ctx.mixins.insert(name.to_string(), attrs);
787− ctx.mixin_lines.entry(name.to_string()).or_insert(line_num);
788− }
789− return Ok(None);
790− }
791−
792815 // --- @assert directive (compile-time assertions) ---
793816
794817 if let Some(rest) = content.strip_prefix("@assert ") {
@@ -1039,8 +1062,6 @@ impl Parser {
10391062 ctx.functions.keys().cloned().collect();
10401063 let define_keys_before: std::collections::HashSet<String> =
10411064 ctx.defines.keys().cloned().collect();
1042− let mixin_keys_before: std::collections::HashSet<String> =
1043− ctx.mixins.keys().cloned().collect();
10441065 let var_keys_before: std::collections::HashSet<String> =
10451066 ctx.variables.keys().cloned().collect();
10461067
@@ -1074,17 +1095,6 @@ impl Parser {
10741095 ctx.defines.insert(format!("{}.{}", prefix, name), attrs);
10751096 }
10761097 }
1077− let new_mixin_keys: Vec<String> = ctx
1078− .mixins
1079− .keys()
1080− .filter(|k| !mixin_keys_before.contains(*k))
1081− .cloned()
1082− .collect();
1083− for name in new_mixin_keys {
1084− if let Some(attrs) = ctx.mixins.remove(&name) {
1085− ctx.mixins.insert(format!("{}.{}", prefix, name), attrs);
1086− }
1087− }
10881098 let new_var_keys: Vec<String> = ctx
10891099 .variables
10901100 .keys()
@@ -2615,7 +2625,7 @@ impl Parser {
26152625 // Peek at the next line to get the function name
26162626 if self.pos < self.lines.len()
26172627 && let LineContent::Normal(ref s) = self.lines[self.pos].content
2618− && let Some(fn_rest) = s.trim().strip_prefix("@fn ")
2628+ && let Some(fn_rest) = s.trim().strip_prefix("@let ")
26192629 {
26202630 let parts: Vec<&str> = fn_rest.split_whitespace().collect();
26212631 if let Some(&fn_name) = parts.first() {
@@ -2884,48 +2894,6 @@ impl Parser {
28842894 return Ok(None);
28852895 }
28862896
2887− // --- Function definition ---
2888−
2889− if let Some(rest) = content.strip_prefix("@fn ") {
2890− let parts: Vec<&str> = rest.split_whitespace().collect();
2891− if parts.is_empty() {
2892− return Err(ParseError {
2893− line: line_num,
2894− message: "@fn requires a name".to_string(),
2895− });
2896− }
2897− let name = parts[0].to_string();
2898− let mut params = Vec::new();
2899− let mut defaults = HashMap::new();
2900− for part in &parts[1..] {
2901− let part = part.strip_prefix('$').unwrap_or(part);
2902− if let Some((param_name, default_val)) = part.split_once('=') {
2903− params.push(param_name.to_string());
2904− defaults.insert(param_name.to_string(), default_val.to_string());
2905− } else {
2906− params.push(part.to_string());
2907− }
2908− }
2909−
2910− // Collect body lines (all lines indented deeper than @fn)
2911− let mut body_lines = Vec::new();
2912− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
2913− body_lines.push(self.lines[self.pos].clone());
2914− self.pos += 1;
2915− }
2916−
2917− ctx.fn_lines.entry(name.clone()).or_insert(line_num);
2918− ctx.functions.insert(
2919− name,
2920− FnDef {
2921− params,
2922− defaults,
2923− body_lines,
2924− },
2925− );
2926− return Ok(None);
2927− }
2928−
29292897 // --- Function call ---
29302898
29312899 if content.starts_with('@') {
@@ -3362,8 +3330,6 @@ const KNOWN_ELEMENTS: &[&str] = &[
33623330const KNOWN_DIRECTIVES: &[&str] = &[
33633331 "page",
33643332 "let",
3365− "define",
3366− "fn",
33673333 "include",
33683334 "import",
33693335 "raw",
@@ -3392,7 +3358,6 @@ const KNOWN_DIRECTIVES: &[&str] = &[
33923358 "base",
33933359 "font-face",
33943360 "json-ld",
3395− "mixin",
33963361 "assert",
33973362 "for",
33983363 "component",
@@ -4452,13 +4417,8 @@ fn parse_attr_list(
44524417 continue;
44534418 }
44544419
4455− // ...$name spread — expand mixin or define attributes
4420+ // ...$name spread — expand define/attribute bundle
44564421 if let Some(name) = part.strip_prefix("...$") {
4457− if let Some(mixin_attrs) = ctx.mixins.get(name) {
4458− ctx.used_mixins.insert(name.to_string());
4459− attrs.extend(mixin_attrs.clone());
4460− continue;
4461− }
44624422 if let Some(define_attrs) = ctx.defines.get(name) {
44634423 ctx.used_defines.insert(name.to_string());
44644424 attrs.extend(define_attrs.clone());
@@ -4466,19 +4426,13 @@ fn parse_attr_list(
44664426 }
44674427 }
44684428
4469− // $define reference — expand
4429+ // $define reference — expand attribute bundle
44704430 if let Some(name) = part.strip_prefix('$') {
44714431 if let Some(define_attrs) = ctx.defines.get(name) {
44724432 ctx.used_defines.insert(name.to_string());
44734433 attrs.extend(define_attrs.clone());
44744434 continue;
44754435 }
4476− // Also try mixin expansion with $name syntax
4477− if let Some(mixin_attrs) = ctx.mixins.get(name) {
4478− ctx.used_mixins.insert(name.to_string());
4479− attrs.extend(mixin_attrs.clone());
4480− continue;
4481− }
44824436 }
44834437
44844438 // Substitute variables in value
@@ -5757,20 +5711,23 @@ fn check_unused(ctx: &mut ParseContext) {
57575711 }
57585712 }
57595713
5760− // Check unused @define bundles
5714+ // Check unused attribute bundles (@let name [...])
57615715 for (name, &line) in &ctx.define_lines {
57625716 if !ctx.used_defines.contains(name) {
57635717 ctx.diagnostics.push(Diagnostic {
57645718 line,
57655719 column: None,
5766− message: format!("unused define '${}' (defined but never referenced)", name),
5720+ message: format!(
5721+ "unused attribute bundle '${}' (defined but never referenced)",
5722+ name
5723+ ),
57675724 severity: Severity::Warning,
57685725 source_line: None,
57695726 });
57705727 }
57715728 }
57725729
5773− // Check unused @fn functions
5730+ // Check unused functions (@let name ... with body)
57745731 for (name, &line) in &ctx.fn_lines {
57755732 if !ctx.used_functions.contains(name) {
57765733 ctx.diagnostics.push(Diagnostic {
@@ -5782,19 +5739,6 @@ fn check_unused(ctx: &mut ParseContext) {
57825739 });
57835740 }
57845741 }
5785−
5786− // Check unused @mixin definitions
5787− for (name, &line) in &ctx.mixin_lines {
5788− if !ctx.used_mixins.contains(name) {
5789− ctx.diagnostics.push(Diagnostic {
5790− line,
5791− column: None,
5792− message: format!("unused mixin '{}' (defined but never referenced)", name),
5793− severity: Severity::Warning,
5794− source_line: None,
5795− });
5796− }
5797− }
57985742}
57995743
58005744// ---------------------------------------------------------------------------
examples/blog.hl+2−2
@@ -5,7 +5,7 @@
55@let ink #1f2937
66@let muted #6b7280
77
8−@define card [
8+@let card [
99 padding 24,
1010 background white,
1111 rounded 10,
@@ -14,7 +14,7 @@
1414 transition all 0.15s ease
1515]
1616
17−@fn post $title $date $excerpt
17+@let post $title $date $excerpt
1818 @article [$card, spacing 8]
1919 @text [bold, size 20, color $ink] $title
2020 @text [size 13, color $muted] $date
examples/demo.hl+5−5
@@ -18,7 +18,7 @@
1818@let gutter = $gap + $base
1919
2020-- Reusable attribute bundle
21−@define card [
21+@let card [
2222 padding $gap,
2323 background white,
2424 rounded $base,
@@ -27,7 +27,7 @@
2727]
2828
2929-- Composable style group (used with spread)
30−@mixin interactive [
30+@let interactive [
3131 cursor pointer,
3232 hover:shadow 0 4px 12px rgba(0,0,0,0.08),
3333 transition all 0.2s ease
@@ -44,7 +44,7 @@
4444"""
4545
4646-- Component with default parameter + @children slot
47−@fn card $title $icon=◆
47+@let card $title $icon=◆
4848 @article [$card, ...$interactive, animate fade-up 0.3s ease]
4949 @row [spacing 10, align-items center]
5050 @text [size 20, color $primary] $icon
@@ -52,7 +52,7 @@
5252 @children
5353
5454-- Component that wraps children in a titled section (function + slot)
55−@fn titled $title
55+@let titled $title
5656 @section [spacing 12, padding-y $base]
5757 @text [bold, size 22, color $ink] $title
5858 @children
@@ -124,7 +124,7 @@
124124 overflow-x auto
125125 ]
126126 @raw """
127−@fn button $label
127+@let button $label
128128 @el [padding 12, background blue]
129129 @text $label
130130"""
examples/docs.hl+1−1
@@ -6,7 +6,7 @@
66@let primary #3b82f6
77@let code-bg #f1f5f9
88
9−@fn nav-link $label $href
9+@let nav-link $label $href
1010 @link [
1111 padding 8, padding-x 12, rounded 6, color $ink,
1212 hover:background #f1f5f9, transition all 0.1s ease
examples/landing.hl+1−1
@@ -6,7 +6,7 @@
66@let muted #64748b
77@let bg-subtle #f8fafc
88
9−@fn feature $title $body
9+@let feature $title $body
1010 @column [padding 24, background white, rounded 12, border 1 #e2e8f0, spacing 8]
1111 @text [bold, size 18, color $ink] $title
1212 @paragraph [color $muted, line-height 1.6]
src/bin/htmlang_lsp/analysis.rs+97−136
@@ -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 != '-')
src/bin/htmlang_lsp/completion.rs+66−59
@@ -156,8 +156,8 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
156156 ("@opt", "Option (short for @option)"),
157157 ("@label", "Label element"),
158158 ("@raw", "Raw HTML escape hatch"),
159− ("@children", "Slot for caller's children (inside @fn)"),
160− ("@slot", "Named slot inside @fn (e.g., @slot header)"),
159+ ("@children", "Slot for caller's children (inside component)"),
160+ ("@slot", "Named slot inside component (e.g., @slot header)"),
161161 // Semantic elements
162162 ("@nav", "Navigation container (nav)"),
163163 ("@header", "Page/section header (header)"),
@@ -245,12 +245,10 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
245245fn directive_completions(range: Range) -> Vec<CompletionItem> {
246246 [
247247 ("@page", "Set HTML page title", "@page "),
248− ("@let", "Define a variable", "@let "),
249− ("@define", "Define an attribute bundle", "@define "),
250248 (
251− "@fn",
252− "Define a reusable function (supports $param=default)",
253− "@fn ",
249+ "@let",
250+ "Define a variable, attribute bundle, or component",
251+ "@let ",
254252 ),
255253 ("@keyframes", "Define a CSS animation", "@keyframes "),
256254 ("@if", "Conditional rendering", "@if "),
@@ -274,7 +272,7 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
274272 ("@meta", "Add a <meta> tag to <head>", "@meta "),
275273 ("@head", "Add raw content to <head>", "@head"),
276274 ("@style", "Add raw CSS to stylesheet", "@style"),
277− ("@slot", "Named slot in @fn for caller content", "@slot "),
275+ ("@slot", "Named slot in component for caller content", "@slot "),
278276 ("@match", "Pattern matching on a value", "@match "),
279277 ("@case", "Match case (inside @match)", "@case "),
280278 ("@default", "Default case (inside @match)", "@default"),
@@ -310,7 +308,7 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
310308 "Define design tokens (colors, spacing, fonts)",
311309 "@theme",
312310 ),
313− ("@deprecated", "Mark next @fn as deprecated", "@deprecated "),
311+ ("@deprecated", "Mark next @let component as deprecated", "@deprecated "),
314312 (
315313 "@extends",
316314 "Inherit a layout template and fill @slot blocks",
@@ -333,11 +331,6 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
333331 "Add JSON-LD structured data to head",
334332 "@json-ld",
335333 ),
336− (
337− "@mixin",
338− "Define a composable style group (use with ...$name)",
339− "@mixin ",
340− ),
341334 (
342335 "@assert",
343336 "Compile-time assertion for variable values",
@@ -367,7 +360,7 @@ fn snippet_completions(range: Range) -> Vec<CompletionItem> {
367360 (
368361 "card component",
369362 "Reusable card with title and content",
370− "@fn card \\$title\n @el [padding 20, background white, rounded 8]\n @text [bold] \\$title\n @children",
363+ "@let card \\$title\n @el [padding 20, background white, rounded 8]\n @text [bold] \\$title\n @children",
371364 ),
372365 (
373366 "responsive layout",
@@ -430,9 +423,9 @@ fn snippet_completions(range: Range) -> Vec<CompletionItem> {
430423 "@text [max-width ${1:200}, truncate] ${2:Long text that will be truncated...}",
431424 ),
432425 (
433− "@fn definition",
434− "Define a reusable function/component",
435− "@fn ${1:name} \\$${2:param}\n @el [${3:padding 16}]\n @children",
426+ "@let component",
427+ "Define a reusable component",
428+ "@let ${1:name} \\$${2:param}\n @el [${3:padding 16}]\n @children",
436429 ),
437430 (
438431 "@each loop",
@@ -529,7 +522,7 @@ pub(crate) fn path_completions(uri: &Url, position: Position) -> Vec<CompletionI
529522 items
530523}
531524
532−/// Suggest exported @fn and @define names from a file referenced in @use
525+/// Suggest exported names from a file referenced in @use
533526pub(crate) fn use_symbol_completions(
534527 uri: &Url,
535528 line: &str,
@@ -573,17 +566,33 @@ pub(crate) fn use_symbol_completions(
573566
574567 for target_line in target_content.lines() {
575568 let trimmed = target_line.trim();
576− if let Some(rest) = trimmed.strip_prefix("@fn ") {
569+ if let Some(rest) = trimmed.strip_prefix("@let ") {
577570 if let Some(name) = rest.split_whitespace().next() {
578− let params: Vec<&str> = rest.split_whitespace().skip(1).collect();
579− let detail = if params.is_empty() {
580− format!("@fn {} (from {})", name, filename)
571+ let name = name.trim_end_matches('[');
572+ let params: Vec<&str> = rest
573+ .split_whitespace()
574+ .skip(1)
575+ .filter(|p| p.starts_with('$'))
576+ .collect();
577+ let (kind, detail) = if !params.is_empty() {
578+ (
579+ CompletionItemKind::FUNCTION,
580+ format!("@let {} {} (from {})", name, params.join(" "), filename),
581+ )
582+ } else if rest[name.len()..].trim_start().starts_with('[') {
583+ (
584+ CompletionItemKind::VARIABLE,
585+ format!("@let {} [...] (from {})", name, filename),
586+ )
581587 } else {
582− format!("@fn {} {} (from {})", name, params.join(" "), filename)
588+ (
589+ CompletionItemKind::VARIABLE,
590+ format!("@let {} (from {})", name, filename),
591+ )
583592 };
584593 items.push(CompletionItem {
585594 label: name.to_string(),
586− kind: Some(CompletionItemKind::FUNCTION),
595+ kind: Some(kind),
587596 detail: Some(detail),
588597 text_edit: Some(CompletionTextEdit::Edit(TextEdit {
589598 range: edit_range,
@@ -592,20 +601,6 @@ pub(crate) fn use_symbol_completions(
592601 ..Default::default()
593602 });
594603 }
595− } else if let Some(rest) = trimmed.strip_prefix("@define ") {
596− if let Some(name) = rest.split_whitespace().next() {
597− let name = name.trim_end_matches('[');
598− items.push(CompletionItem {
599− label: name.to_string(),
600− kind: Some(CompletionItemKind::VARIABLE),
601− detail: Some(format!("@define {} (from {})", name, filename)),
602− text_edit: Some(CompletionTextEdit::Edit(TextEdit {
603− range: edit_range,
604− new_text: name.to_string(),
605− })),
606− ..Default::default()
607− });
608− }
609604 } else if let Some(rest) = trimmed.strip_prefix("@component ")
610605 && let Some(name) = rest.split_whitespace().next()
611606 {
@@ -1370,25 +1365,27 @@ fn variable_completions(text: &str, range: Range) -> Vec<CompletionItem> {
13701365 let trimmed = line.trim();
13711366 if let Some(rest) = trimmed.strip_prefix("@let ") {
13721367 if let Some((name, value)) = rest.trim().split_once(' ') {
1373− items.push(item(
1374− &format!("${}", name),
1375− CompletionItemKind::VARIABLE,
1376− &format!("= {}", value.trim()),
1377− &format!("${}", name),
1378− range,
1379− ));
1368+ let value = value.trim();
1369+ if value.starts_with('[') {
1370+ // Attribute bundle
1371+ items.push(item(
1372+ &format!("${}", name),
1373+ CompletionItemKind::CONSTANT,
1374+ "Attribute bundle",
1375+ &format!("${}", name),
1376+ range,
1377+ ));
1378+ } else if !value.starts_with('$') {
1379+ // Scalar variable (skip function params like $param)
1380+ items.push(item(
1381+ &format!("${}", name),
1382+ CompletionItemKind::VARIABLE,
1383+ &format!("= {}", value),
1384+ &format!("${}", name),
1385+ range,
1386+ ));
1387+ }
13801388 }
1381− } else if let Some(rest) = trimmed.strip_prefix("@define ")
1382− && let Some(bracket) = rest.find('[')
1383− {
1384− let name = rest[..bracket].trim();
1385− items.push(item(
1386− &format!("${}", name),
1387− CompletionItemKind::CONSTANT,
1388− "Attribute bundle",
1389− &format!("${}", name),
1390− range,
1391− ));
13921389 }
13931390 }
13941391
@@ -1397,12 +1394,22 @@ fn variable_completions(text: &str, range: Range) -> Vec<CompletionItem> {
13971394
13981395fn function_completions(text: &str, range: Range) -> Vec<CompletionItem> {
13991396 let mut items = Vec::new();
1397+ let all_lines: Vec<&str> = text.lines().collect();
14001398
1401− for line in text.lines() {
1399+ for (idx, line) in all_lines.iter().enumerate() {
14021400 let trimmed = line.trim();
1403− if let Some(rest) = trimmed.strip_prefix("@fn ") {
1401+ if let Some(rest) = trimmed.strip_prefix("@let ") {
14041402 let parts: Vec<&str> = rest.split_whitespace().collect();
14051403 if let Some(name) = parts.first() {
1404+ // Only suggest as function if it has params or an indented body
1405+ let has_params = parts.len() > 1 && parts[1..].iter().any(|p| p.starts_with('$'));
1406+ let has_body = all_lines
1407+ .get(idx + 1)
1408+ .map(|l| l.starts_with(" ") || l.starts_with('\t'))
1409+ .unwrap_or(false);
1410+ if !has_params && !has_body {
1411+ continue;
1412+ }
14061413 let params: Vec<&str> = parts[1..]
14071414 .iter()
14081415 .map(|p| p.strip_prefix('$').unwrap_or(p))
src/bin/htmlang_lsp/hover.rs+19−28
@@ -56,22 +56,19 @@ fn hover_variable(text: &str, name: &str) -> Option<String> {
5656
5757 for line in text.lines() {
5858 let trimmed = line.trim();
59− if let Some(rest) = trimmed.strip_prefix("@define ") {
59+ if let Some(rest) = trimmed.strip_prefix("@let ") {
6060 let rest = rest.trim();
61− if let Some(bracket) = rest.find('[')
62− && rest[..bracket].trim() == name
63− {
64− return Some(format!(
65− "**${}** \u{2014} Attribute bundle\n\n`{}`",
66− name, trimmed
67− ));
61+ // Attribute bundle: @let name [...]
62+ if let Some(bracket) = rest.find('[') {
63+ let def_name = rest[..bracket].trim();
64+ if def_name == name {
65+ return Some(format!(
66+ "**${}** \u{2014} Attribute bundle\n\n`{}`",
67+ name, trimmed
68+ ));
69+ }
6870 }
69− }
70− }
71−
72− for line in text.lines() {
73− let trimmed = line.trim();
74− if let Some(rest) = trimmed.strip_prefix("@fn ") {
71+ // Function parameter: @let fn-name $param (with body)
7572 let parts: Vec<&str> = rest.split_whitespace().collect();
7673 if let Some(fn_name) = parts.first() {
7774 for param in &parts[1..] {
@@ -94,12 +91,12 @@ fn hover_user_fn(text: &str, name: &str) -> Option<String> {
9491 let lines: Vec<&str> = text.lines().collect();
9592 for (i, line) in lines.iter().enumerate() {
9693 let trimmed = line.trim();
97− if let Some(rest) = trimmed.strip_prefix("@fn ") {
94+ if let Some(rest) = trimmed.strip_prefix("@let ") {
9895 let parts: Vec<&str> = rest.split_whitespace().collect();
9996 if parts.first() == Some(&name) {
10097 let params = &parts[1..];
10198
102− // Collect doc-comment lines above the @fn (lines starting with --)
99+ // Collect doc-comment lines above the definition (lines starting with --)
103100 let mut doc_lines: Vec<&str> = Vec::new();
104101 let mut j = i;
105102 while j > 0 {
@@ -247,19 +244,13 @@ fn hover_builtin(word: &str) -> Option<String> {
247244 "**@page** \u{2014} Page title\n\nSets the HTML `<title>` and wraps output in a full document.\n\nUsage: `@page My Page Title`"
248245 }
249246 "@let" => {
250− "**@let** \u{2014} Variable\n\nDefines a variable for `$name` substitution.\n\nUsage: `@let primary #3b82f6`"
251− }
252− "@define" => {
253− "**@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."
254− }
255− "@fn" => {
256− "**@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```"
247+ "**@let** \u{2014} Definition\n\nDefines a variable, attribute bundle, or component.\n\n- Variable: `@let primary #3b82f6`\n- Attribute bundle: `@let card-style [padding 20, rounded 8]`\n- Component:\n```\n@let card $title\n @el [padding 20]\n @text [bold] $title\n @children\n```"
257248 }
258249 "@keyframes" => {
259250 "**@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]`"
260251 }
261252 "@children" => {
262− "**@children** \u{2014} Children slot\n\nPlaceholder inside `@fn` body replaced with the caller's children."
253+ "**@children** \u{2014} Children slot\n\nPlaceholder inside a component body replaced with the caller's children."
263254 }
264255 "@input" => {
265256 "**@input** \u{2014} Form input\n\nRenders as self-closing `<input>`.\n\nUsage: `@input [type text, placeholder Name, name user]`"
@@ -289,7 +280,7 @@ fn hover_builtin(word: &str) -> Option<String> {
289280 "**@include** \u{2014} Include file\n\nIncludes another .hl file (DOM nodes + definitions).\n\nUsage: `@include header.hl`"
290281 }
291282 "@import" => {
292− "**@import** \u{2014} Import definitions\n\nImports `@let`, `@define`, `@fn` from another .hl file without emitting DOM nodes.\n\nUsage: `@import theme.hl`"
283+ "**@import** \u{2014} Import definitions\n\nImports `@let` definitions from another .hl file without emitting DOM nodes.\n\nUsage: `@import theme.hl`"
293284 }
294285 "@meta" => {
295286 "**@meta** \u{2014} Meta tag\n\nAdds a `<meta>` tag to `<head>`.\n\nUsage: `@meta description A portfolio site`"
@@ -301,7 +292,7 @@ fn hover_builtin(word: &str) -> Option<String> {
301292 "**@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```"
302293 }
303294 "@slot" => {
304− "**@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```"
295+ "**@slot** \u{2014} Named slot\n\nDefines a named insertion point inside a component. Callers fill it with `@slot name` + children.\n\n```\n@let layout\n @slot header\n @children\n @slot footer\n```"
305296 }
306297 // Semantic elements
307298 "@nav" => {
@@ -461,13 +452,13 @@ fn hover_builtin(word: &str) -> Option<String> {
461452 "**@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```"
462453 }
463454 "@deprecated" => {
464− "**@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```"
455+ "**@deprecated** `<message>`\n\nMarks the next `@let` component as deprecated. Callers get a compile-time warning.\n\n```\n@deprecated Use @new-card instead\n@let old-card $title\n ...\n```"
465456 }
466457 "@extends" => {
467458 "**@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```"
468459 }
469460 "@use" => {
470− "**@use** `<file.hl> name1, name2`\n\nSelective import: only imports named `@fn`/`@define` definitions.\n\n```\n@use components.hl card, button\n```"
461+ "**@use** `<file.hl> name1, name2`\n\nSelective import: only imports named `@let` definitions.\n\n```\n@use components.hl card, button\n```"
471462 }
472463 "@canonical" => {
473464 "**@canonical** `<url>`\n\nSets the canonical URL for the page. Adds `<link rel=\"canonical\">` to `<head>`.\n\nUsage: `@canonical https://example.com/page`"
src/bin/htmlang_lsp/main.rs+36−31
@@ -199,7 +199,7 @@ impl LanguageServer for Backend {
199199 return Ok(Some(CompletionResponse::Array(items)));
200200 }
201201 }
202− // @use "file.hl" fn1, fn2 -- after the filename, suggest exported @fn names
202+ // @use "file.hl" fn1, fn2 -- after the filename, suggest exported names
203203 if let Some(after_use) = trimmed.strip_prefix("@use ") {
204204 // If we already have a filename (quoted or unquoted), suggest symbols from that file
205205 let has_file = after_use.contains(".hl");
@@ -658,8 +658,7 @@ impl LanguageServer for Backend {
658658 drop(docs);
659659
660660 // Build a simple usage counter by scanning the whole document. For each
661− // definition (@fn name / @let name / @define name) we emit a lens that
662− // reports how many bare `@name` or `$name` call sites exist.
661+ // @let definition we emit a lens that reports how many call/ref sites exist.
663662 let lines: Vec<&str> = text.lines().collect();
664663
665664 #[derive(Clone)]
@@ -672,40 +671,46 @@ impl LanguageServer for Backend {
672671
673672 for (i, line) in lines.iter().enumerate() {
674673 let trimmed = line.trim_start();
675− if let Some(rest) = trimmed.strip_prefix("@fn ") {
676− if let Some(n) = rest.split_whitespace().next() {
677− defs.push(Def {
678− line: i as u32,
679− name: n.to_string(),
680− kind: "fn",
681− });
682− }
683− } else if let Some(rest) = trimmed.strip_prefix("@let ") {
684− if let Some((n, _)) = rest.trim().split_once(' ') {
685− defs.push(Def {
686− line: i as u32,
687− name: n.to_string(),
688− kind: "let",
689− });
690− }
691− } else if let Some(rest) = trimmed.strip_prefix("@define ")
692− && let Some(bracket) = rest.find('[')
693− {
694− let n = rest[..bracket].trim();
695− if !n.is_empty() {
696− defs.push(Def {
697− line: i as u32,
698− name: n.to_string(),
699− kind: "define",
700− });
674+ if let Some(rest) = trimmed.strip_prefix("@let ") {
675+ let rest = rest.trim();
676+ // Determine the kind based on the value
677+ if let Some(name) = rest.split_whitespace().next() {
678+ // Check for indented body (function)
679+ let has_body = lines
680+ .get(i + 1)
681+ .map(|l| l.starts_with(" ") || l.starts_with('\t'))
682+ .unwrap_or(false);
683+ let value_after_name = rest[name.len()..].trim_start();
684+ if has_body
685+ && (value_after_name.is_empty()
686+ || value_after_name.starts_with('$'))
687+ {
688+ defs.push(Def {
689+ line: i as u32,
690+ name: name.to_string(),
691+ kind: "fn",
692+ });
693+ } else if value_after_name.starts_with('[') {
694+ defs.push(Def {
695+ line: i as u32,
696+ name: name.to_string(),
697+ kind: "define",
698+ });
699+ } else {
700+ defs.push(Def {
701+ line: i as u32,
702+ name: name.to_string(),
703+ kind: "let",
704+ });
705+ }
701706 }
702707 }
703708 }
704709
705710 let mut lenses = Vec::with_capacity(defs.len());
706711 for def in &defs {
707− // Count references: for @fn, look for `@name` at start-of-token;
708− // for @let / @define, look for `$name`.
712+ // Count references: for functions, look for `@name` at start-of-token;
713+ // for variables / attribute bundles, look for `$name`.
709714 let mut count: usize = 0;
710715 let needle = match def.kind {
711716 "fn" => format!("@{}", def.name),
src/bin/htmlang_lsp/navigation.rs+44−43
@@ -58,58 +58,60 @@ pub(crate) fn definition_at(
5858 }))
5959}
6060
61−/// Find @let, @define, or @fn parameter definition for a $name reference.
61+/// Find @let definition for a $name reference (variable, attribute bundle, or function param).
6262pub(crate) fn find_definition(text: &str, name: &str) -> Option<(u32, u32, u32)> {
6363 for (i, line) in text.lines().enumerate() {
6464 let trimmed = line.trim();
6565 let offset = (line.len() - trimmed.len()) as u32;
6666
67− if let Some(rest) = trimmed.strip_prefix("@let ")
68− && let Some((n, _)) = rest.trim().split_once(' ')
69− && n == name
70− {
71− let col = offset + "@let ".len() as u32;
72− return Some((i as u32, col, n.len() as u32));
73− }
74−
75− if let Some(rest) = trimmed.strip_prefix("@define ") {
76− let rest = rest.trim();
77− if let Some(bracket) = rest.find('[') {
78− let n = rest[..bracket].trim();
67+ if let Some(rest) = trimmed.strip_prefix("@let ") {
68+ let rest_trimmed = rest.trim();
69+ // Match variable or attribute bundle: @let name value / @let name [...]
70+ if let Some((n, _)) = rest_trimmed.split_once(' ') {
7971 if n == name {
80− let col = offset + "@define ".len() as u32;
72+ let col = offset + "@let ".len() as u32;
8173 return Some((i as u32, col, n.len() as u32));
8274 }
83− }
84− }
85−
86− if let Some(rest) = trimmed.strip_prefix("@fn ") {
87− let parts: Vec<&str> = rest.split_whitespace().collect();
88− for param in &parts[1..] {
89− let p = param.strip_prefix('$').unwrap_or(param);
90− if p == name {
91− // Find the param position in the line
92− if let Some(pos) = line.find(param) {
93− return Some((i as u32, pos as u32, param.len() as u32));
75+ // Check function parameters
76+ let parts: Vec<&str> = rest_trimmed.split_whitespace().collect();
77+ for param in &parts[1..] {
78+ let p = param.strip_prefix('$').unwrap_or(param);
79+ let p = p.split('=').next().unwrap_or(p);
80+ if p == name {
81+ if let Some(pos) = line.find(param) {
82+ return Some((i as u32, pos as u32, param.len() as u32));
83+ }
9484 }
9585 }
86+ } else if rest_trimmed == name {
87+ // @let name (no value, function with no params)
88+ let col = offset + "@let ".len() as u32;
89+ return Some((i as u32, col, name.len() as u32));
9690 }
9791 }
9892 }
9993 None
10094}
10195
102−/// Find @fn definition for an @name function call.
96+/// Find @let function definition for an @name function call.
10397pub(crate) fn find_fn_definition(text: &str, name: &str) -> Option<(u32, u32, u32)> {
104− for (i, line) in text.lines().enumerate() {
98+ let lines: Vec<&str> = text.lines().collect();
99+ for (i, line) in lines.iter().enumerate() {
105100 let trimmed = line.trim();
106101 let offset = (line.len() - trimmed.len()) as u32;
107102
108− if let Some(rest) = trimmed.strip_prefix("@fn ") {
103+ if let Some(rest) = trimmed.strip_prefix("@let ") {
109104 let parts: Vec<&str> = rest.split_whitespace().collect();
110105 if parts.first() == Some(&name) {
111− let col = offset + "@fn ".len() as u32;
112− return Some((i as u32, col, name.len() as u32));
106+ // Verify it's a function (has body or params)
107+ let has_body = lines
108+ .get(i + 1)
109+ .map(|l| l.starts_with(" ") || l.starts_with('\t'))
110+ .unwrap_or(false);
111+ if has_body {
112+ let col = offset + "@let ".len() as u32;
113+ return Some((i as u32, col, name.len() as u32));
114+ }
113115 }
114116 }
115117 }
@@ -199,9 +201,10 @@ pub(crate) fn rename_at(
199201 });
200202 }
201203
202− // Rename @define definition
203− if let Some(rest) = trimmed.strip_prefix("@define ") {
204+ // Rename @let attribute bundle or parameter definitions
205+ if let Some(rest) = trimmed.strip_prefix("@let ") {
204206 let rest_trimmed = rest.trim();
207+ // Attribute bundle: @let name [...]
205208 if let Some(bracket) = rest_trimmed.find('[') {
206209 let n = rest_trimmed[..bracket].trim();
207210 if n == name
@@ -217,13 +220,11 @@ pub(crate) fn rename_at(
217220 continue; // Don't also match $ references on this line
218221 }
219222 }
220− }
221−
222− // Rename @fn parameter definitions
223− if let Some(rest) = trimmed.strip_prefix("@fn ") {
224− let parts: Vec<&str> = rest.split_whitespace().collect();
223+ // Function parameter definitions
224+ let parts: Vec<&str> = rest_trimmed.split_whitespace().collect();
225225 for param in &parts[1..] {
226226 let p = param.strip_prefix('$').unwrap_or(param);
227+ let p = p.split('=').next().unwrap_or(p);
227228 if p == name
228229 && let Some(pos) = line.find(param)
229230 {
@@ -263,13 +264,13 @@ pub(crate) fn rename_at(
263264 offset = abs_pos + search.len();
264265 }
265266 } else {
266− // Rename @fn definition
267− if let Some(rest) = trimmed.strip_prefix("@fn ") {
267+ // Rename @let function definition
268+ if let Some(rest) = trimmed.strip_prefix("@let ") {
268269 let parts: Vec<&str> = rest.split_whitespace().collect();
269270 if parts.first() == Some(&name)
270− && let Some(pos) = line.find(&format!("@fn {}", name))
271+ && let Some(pos) = line.find(&format!("@let {}", name))
271272 {
272− let start = pos + 4; // skip "@fn "
273+ let start = pos + 5; // skip "@let "
273274 edits.push(TextEdit {
274275 range: Range::new(
275276 Position::new(line_num, start as u32),
@@ -286,8 +287,8 @@ pub(crate) fn rename_at(
286287 let mut offset = 0;
287288 while let Some(pos) = line[offset..].find(&search) {
288289 let abs_pos = offset + pos;
289− // Don't match @fn definition (handled above)
290− if trimmed.starts_with("@fn ") {
290+ // Don't match @let definition (handled above)
291+ if trimmed.starts_with("@let ") {
291292 offset = abs_pos + search.len();
292293 continue;
293294 }
src/cli.rs+4−4
@@ -27,9 +27,9 @@ Commands:
2727 export <dir> [-o f] Compile and bundle into an archive
2828 repl Interactive REPL (type .hl, get HTML)
2929 feed <dir> [-b URL] Generate RSS feed from @page metadata
30− components <dir> List all @fn definitions across a project
30+ components <dir> List all component definitions across a project
3131 deps <dir> Show file dependency graph (@include/@import)
32− dead-code <dir> Find unused @fn, @define, @let across project
32+ dead-code <dir> Find unused @let definitions across project
3333 deploy <dir> [--provider github-pages|netlify|vercel|cloudflare]
3434 Build and deploy
3535 playground [out.html] Generate a self-contained HTML playground
@@ -192,7 +192,7 @@ _htmlang() {{
192192 'export:Bundle into archive'
193193 'repl:Interactive REPL'
194194 'feed:Generate RSS feed'
195− 'components:List @fn definitions'
195+ 'components:List component definitions'
196196 'deps:Show dependency graph'
197197 'dead-code:Find unused definitions'
198198 'deploy:Build and deploy'
@@ -266,7 +266,7 @@ fn print_fish_completions() {
266266 ("export", "Bundle into archive"),
267267 ("repl", "Interactive REPL"),
268268 ("feed", "Generate RSS feed"),
269− ("components", "List @fn definitions"),
269+ ("components", "List component definitions"),
270270 ("deps", "Show dependency graph"),
271271 ("dead-code", "Find unused definitions"),
272272 ("deploy", "Build and deploy"),
src/main.rs+29−39
@@ -2228,19 +2228,31 @@ fn main() {
22282228 let mut found = Vec::new();
22292229 for (line_num, line) in input.lines().enumerate() {
22302230 let trimmed = line.trim();
2231− if let Some(rest) = trimmed.strip_prefix("@fn ") {
2231+ if let Some(rest) = trimmed.strip_prefix("@let ") {
2232+ // Detect function definitions: @let name with indented body
22322233 let parts: Vec<&str> = rest.split_whitespace().collect();
22332234 if let Some(&name) = parts.first() {
2234− let params: Vec<&str> = parts[1..]
2235− .iter()
2236− .map(|p| p.strip_prefix('$').unwrap_or(p))
2237− .collect();
2238− let param_str = if params.is_empty() {
2239− String::new()
2240− } else {
2241− format!(" ({})", params.join(", "))
2242− };
2243− found.push((line_num + 1, name.to_string(), param_str));
2235+ // Check if next line is indented (indicating a function body)
2236+ let next_line = input.lines().nth(line_num + 1);
2237+ let is_fn = next_line
2238+ .map(|l| l.starts_with(" ") || l.starts_with('\t'))
2239+ .unwrap_or(false);
2240+ // Also include if it has $params (even without checking body)
2241+ let has_params = parts.len() > 1
2242+ && parts[1..].iter().any(|p| p.starts_with('$'));
2243+ if is_fn || has_params {
2244+ let params: Vec<&str> = parts[1..]
2245+ .iter()
2246+ .filter(|p| p.starts_with('$'))
2247+ .map(|p| p.strip_prefix('$').unwrap_or(p))
2248+ .collect();
2249+ let param_str = if params.is_empty() {
2250+ String::new()
2251+ } else {
2252+ format!(" ({})", params.join(", "))
2253+ };
2254+ found.push((line_num + 1, name.to_string(), param_str));
2255+ }
22442256 }
22452257 }
22462258 }
@@ -2253,7 +2265,7 @@ fn main() {
22532265 }
22542266 }
22552267 if total == 0 {
2256− eprintln!("no @fn definitions found");
2268+ eprintln!("no component definitions found");
22572269 } else {
22582270 eprintln!("\n{} component(s) found", total);
22592271 }
@@ -2343,9 +2355,7 @@ fn main() {
23432355 process::exit(1);
23442356 }
23452357 // Pass 1: collect all definitions and usages across all files
2346− let mut all_fn_defs: Vec<(String, String, usize)> = Vec::new(); // (name, file, line)
2347− let mut all_def_defs: Vec<(String, String, usize)> = Vec::new();
2348− let mut all_let_defs: Vec<(String, String, usize)> = Vec::new();
2358+ let mut all_let_defs: Vec<(String, String, usize)> = Vec::new(); // (name, file, line)
23492359 let mut all_refs: std::collections::HashSet<String> = std::collections::HashSet::new();
23502360 for file in &hl_files {
23512361 let input = match fs::read_to_string(file) {
@@ -2359,25 +2369,15 @@ fn main() {
23592369 .to_string();
23602370 for (line_num, line) in input.lines().enumerate() {
23612371 let trimmed = line.trim();
2362− if let Some(rest) = trimmed.strip_prefix("@fn ") {
2363− if let Some(name) = rest.split_whitespace().next() {
2364− all_fn_defs.push((name.to_string(), rel.clone(), line_num + 1));
2365− }
2366− } else if let Some(rest) = trimmed.strip_prefix("@define ") {
2367− if let Some(name) = rest.split_whitespace().next() {
2368− let name = name.trim_end_matches('[');
2369− all_def_defs.push((name.to_string(), rel.clone(), line_num + 1));
2370− }
2371− } else if let Some(rest) = trimmed.strip_prefix("@let ")
2372+ if let Some(rest) = trimmed.strip_prefix("@let ")
23722373 && let Some(name) = rest.split_whitespace().next()
23732374 {
2375+ let name = name.trim_end_matches('[');
23742376 all_let_defs.push((name.to_string(), rel.clone(), line_num + 1));
23752377 }
23762378 // Collect references: @name calls and $name usages
23772379 if trimmed.starts_with('@')
2378− && !trimmed.starts_with("@fn ")
23792380 && !trimmed.starts_with("@let ")
2380− && !trimmed.starts_with("@define ")
23812381 && !trimmed.starts_with("@include ")
23822382 && !trimmed.starts_with("@import ")
23832383 && !trimmed.starts_with("@extends ")
@@ -2415,16 +2415,6 @@ fn main() {
24152415 }
24162416 // Pass 2: report unused
24172417 let mut unused: Vec<(String, String, String, usize)> = Vec::new(); // (kind, name, file, line)
2418− for (name, file, line) in &all_fn_defs {
2419− if !all_refs.contains(name) {
2420− unused.push(("@fn".to_string(), name.clone(), file.clone(), *line));
2421− }
2422− }
2423− for (name, file, line) in &all_def_defs {
2424− if !all_refs.contains(name) {
2425− unused.push(("@define".to_string(), name.clone(), file.clone(), *line));
2426− }
2427− }
24282418 for (name, file, line) in &all_let_defs {
24292419 if !all_refs.contains(name) && !name.starts_with("--") {
24302420 unused.push(("@let".to_string(), name.clone(), file.clone(), *line));
@@ -3552,8 +3542,8 @@ compile();
35523542 template.push('\n');
35533543 template.push('\n');
35543544
3555− // Generate @fn definition
3556− template.push_str("@fn ");
3545+ // Generate @let component definition
3546+ template.push_str("@let ");
35573547 template.push_str(comp_name);
35583548 for p in &params {
35593549 template.push(' ');
src/playground_default.hl+5−5
@@ -18,7 +18,7 @@
1818@let gutter = $gap + $base
1919
2020-- Reusable attribute bundle
21−@define card [
21+@let card [
2222 padding $gap,
2323 background white,
2424 rounded $base,
@@ -27,7 +27,7 @@
2727]
2828
2929-- Composable style group (used with spread)
30−@mixin interactive [
30+@let interactive [
3131 cursor pointer,
3232 hover:shadow 0 4px 12px rgba(0,0,0,0.08),
3333 transition all 0.2s ease
@@ -44,7 +44,7 @@
4444"""
4545
4646-- Component with default parameter + @children slot
47−@fn card $title $icon=◆
47+@let card $title $icon=◆
4848 @article [$card, ...$interactive, animate fade-up 0.3s ease]
4949 @row [spacing 10, align-items center]
5050 @text [size 20, color $primary] $icon
@@ -52,7 +52,7 @@
5252 @children
5353
5454-- Component that wraps children in a titled section (function + slot)
55−@fn titled $title
55+@let titled $title
5656 @section [spacing 12, padding-y $base]
5757 @text [bold, size 22, color $ink] $title
5858 @children
@@ -124,7 +124,7 @@
124124 overflow-x auto
125125 ]
126126 @raw """
127−@fn button $label
127+@let button $label
128128 @el [padding 12, background blue]
129129 @text $label
130130"""
tests/snapshots/fn_defaults.hl+1−1
@@ -1,4 +1,4 @@
1−@fn badge $label $color=blue
1+@let badge $label $color=blue
22 @el [padding 4 8, background $color, rounded 4]
33 @text $label
44
tests/snapshots/functions.hl+2−2
@@ -1,10 +1,10 @@
11@page Functions
2−@fn card $title
2+@let card $title
33 @el [padding 20, rounded 8]
44 @text [bold] $title
55 @children
66
7−@fn wrapper
7+@let wrapper
88 @column [spacing 10]
99 @children
1010
tests/snapshots/mixin_spread.hl+2−2
@@ -1,7 +1,7 @@
11@page Mixin Test
22
3−@mixin card-style [padding 20, rounded 8, background #f3f4f6]
4−@mixin interactive [cursor pointer, hover:background #e5e7eb, transition all 0.15s ease]
3+@let card-style [padding 20, rounded 8, background #f3f4f6]
4+@let interactive [cursor pointer, hover:background #e5e7eb, transition all 0.15s ease]
55
66@el [...$card-style, ...$interactive]
77 Hello mixin world
tests/snapshots/named_slots.hl+1−1
@@ -1,4 +1,4 @@
1−@fn layout
1+@let layout
22 @el [padding 20]
33 @slot header
44 @text Default Header
tests/snapshots/stress_deeply_nested.hl+2−2
@@ -4,12 +4,12 @@
44@let --border #e2e8f0
55@let --radius 8
66
7−@fn deep-card $title
7+@let deep-card $title
88 @el [background white, dark:background #1e293b, rounded $--radius, padding 16, border 1 $--border]
99 @text [bold, size 16] $title
1010 @children
1111
12−@fn inner-panel $label
12+@let inner-panel $label
1313 @el [background #f8fafc, dark:background #0f172a, rounded 6, padding 12, margin-top 8]
1414 @text [size 13, color #64748b] $label
1515 @children
tests/snapshots/stress_large.hl+15−15
@@ -19,42 +19,42 @@
1919@keyframes slide-down
2020 0%{transform:translateY(-10px);opacity:0}100%{transform:translateY(0);opacity:1}
2121
22−@fn icon-button $label $color=var(--primary)
22+@let icon-button $label $color=var(--primary)
2323 @button [padding 8 16, background $color, hover:background $--dark, color white, rounded 6, border none, cursor pointer, transition all 0.2s, font-size 14]
2424 $label
2525
26−@fn badge $label $variant=blue
26+@let badge $label $variant=blue
2727 @el [display inline-block, padding 2 8, background $variant, color white, rounded 12, font-size 12]
2828 @text $label
2929
30−@fn card $title
30+@let card $title
3131 @el [background white, dark:background #1e293b, rounded $--radius, padding $--gap, border 1 $--border, dark:border 1 #334155, hover:shadow 0 4 12 rgba(0,0,0,0.1), transition all 0.2s, animation fade-in 0.3s ease]
3232 @text [bold, size 18, color $--dark, dark:color white] $title
3333 @el [margin-top 8]
3434 @children
3535
36−@fn stat-card $label $value $change
36+@let stat-card $label $value $change
3737 @el [background white, dark:background #1e293b, rounded $--radius, padding $--gap, border 1 $--border, dark:border 1 #334155]
3838 @text [size 14, color $--gray] $label
3939 @text [bold, size 28, color $--dark, dark:color white, margin-top 4] $value
4040 @text [size 12, color $--secondary, margin-top 2] $change
4141
42−@fn nav-link $url $label $active=false
42+@let nav-link $url $label $active=false
4343 @if $active == true
4444 @link [color $--primary, bold, padding 8 12, background #eff6ff, dark:background #1e3a5f, rounded 6] $url $label
4545 @else
4646 @link [color $--gray, hover:color $--primary, padding 8 12, hover:background #f1f5f9, dark:hover:background #334155, rounded 6, transition all 0.15s] $url $label
4747
48−@fn sidebar-section $title
48+@let sidebar-section $title
4949 @el [margin-bottom $--gap-lg]
5050 @text [bold, size 12, color $--gray, text-transform uppercase, letter-spacing 1, margin-bottom $--gap-sm] $title
5151 @children
5252
53−@fn avatar $initials $size=32
53+@let avatar $initials $size=32
5454 @el [width $size, height $size, rounded 50%, background $--primary, color white, display flex, align-items center, justify-content center, font-size 14, bold]
5555 @text $initials
5656
57−@fn table-row $col1 $col2 $col3 $col4
57+@let table-row $col1 $col2 $col3 $col4
5858 @row [spacing 0, padding 12 16, border-bottom 1 $--border, dark:border-bottom 1 #334155, hover:background #f8fafc, dark:hover:background #1e293b]
5959 @el [width 200]
6060 @text [size 14] $col1
@@ -65,7 +65,7 @@
6565 @el [width fill]
6666 @text [size 14, color $--gray] $col4
6767
68−@fn breadcrumb-item $url $label $is-last=false
68+@let breadcrumb-item $url $label $is-last=false
6969 @if $is-last == true
7070 @text [color $--dark, dark:color white, size 14] $label
7171 @else
@@ -73,7 +73,7 @@
7373 @link [color $--gray, hover:color $--primary, size 14] $url $label
7474 @text [color $--border, size 14] /
7575
76−@fn alert $message $type=info
76+@let alert $message $type=info
7777 @if $type == info
7878 @el [background #eff6ff, border 1 #bfdbfe, rounded $--radius, padding 12 16]
7979 @text [color #1e40af, size 14] $message
@@ -87,21 +87,21 @@
8787 @el [background #fef2f2, border 1 #fecaca, rounded $--radius, padding 12 16]
8888 @text [color #991b1b, size 14] $message
8989
90−@fn progress-bar $percent $color=var(--primary)
90+@let progress-bar $percent $color=var(--primary)
9191 @el [width fill, height 8, background #e2e8f0, dark:background #334155, rounded 4, overflow hidden]
9292 @el [width $percent%, height fill, background $color, rounded 4]
9393
94−@fn tag $label
94+@let tag $label
9595 @el [display inline-block, padding 4 10, background #f1f5f9, dark:background #334155, color $--gray, dark:color #94a3b8, rounded 4, font-size 12, margin 2]
9696 @text $label
9797
98−@fn notification-dot
98+@let notification-dot
9999 @el [width 8, height 8, rounded 50%, background $--danger]
100100
101−@fn divider
101+@let divider
102102 @el [width fill, height 1, background $--border, dark:background #334155, margin 8 0]
103103
104−@fn toggle $checked=false
104+@let toggle $checked=false
105105 @if $checked == true
106106 @el [width 40, height 22, rounded 11, background $--primary, padding 2, cursor pointer, transition all 0.2s]
107107 @el [width 18, height 18, rounded 50%, background white, margin-left 18, transition all 0.2s]
tests/snapshots/variables_defines.hl+1−1
@@ -1,7 +1,7 @@
11@page Variables
22@let primary #3b82f6
33@let gap 20
4−@define card-style [padding 20, rounded 8, background white]
4+@let card-style [padding 20, rounded 8, background white]
55
66@column [spacing $gap]
77 @el [$card-style]