on master help sign in
Unify @fn, @define, and @mixin into single @let directive
be4fcdciko
crates/htmlang-core/src/parser.rs+71−127
@@ -89,26 +89,20 @@ struct ParseContext {
89 file_cache: HashMap<PathBuf, String>,
89 file_cache: HashMap<PathBuf, String>,
90 /// Track which @let variables are referenced (for unused warnings)
90 /// Track which @let variables are referenced (for unused warnings)
91 used_variables: HashSet<String>,
91 used_variables: HashSet<String>,
92− /// Track which @fn functions are called (for unused warnings)
92+ /// Track which functions are called (for unused warnings)
93 used_functions: HashSet<String>,
93 used_functions: HashSet<String>,
94− /// Track which @define bundles are referenced (for unused warnings)
94+ /// Track which attribute bundles are referenced (for unused warnings)
95 used_defines: HashSet<String>,
95 used_defines: HashSet<String>,
96 /// Line numbers of @let definitions (name -> line)
96 /// Line numbers of @let definitions (name -> line)
97 let_lines: HashMap<String, usize>,
97 let_lines: HashMap<String, usize>,
98− /// Line numbers of @fn definitions (name -> line)
98+ /// Line numbers of function definitions (name -> line)
99 fn_lines: HashMap<String, usize>,
99 fn_lines: HashMap<String, usize>,
100− /// Line numbers of @define definitions (name -> line)
100+ /// Line numbers of attribute bundle definitions (name -> line)
101 define_lines: HashMap<String, usize>,
101 define_lines: HashMap<String, usize>,
102 /// Deprecated functions: name -> deprecation message
102 /// Deprecated functions: name -> deprecation message
103 deprecated_fns: HashMap<String, String>,
103 deprecated_fns: HashMap<String, String>,
104 /// Theme tokens: (name, value) pairs from @theme
104 /// Theme tokens: (name, value) pairs from @theme
105 theme_tokens: Vec<(String, String)>,
105 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>,
112 /// Canonical URL
106 /// Canonical URL
113 canonical: Option<String>,
107 canonical: Option<String>,
114 /// Base URL for relative links
108 /// Base URL for relative links
@@ -175,9 +169,6 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
175 base_url: None,
169 base_url: None,
176 font_faces: Vec::new(),
170 font_faces: Vec::new(),
177 json_ld_blocks: Vec::new(),
171 json_ld_blocks: Vec::new(),
178− mixins: HashMap::new(),
179− mixin_lines: HashMap::new(),
180− used_mixins: HashSet::new(),
181 scope_blocks: Vec::new(),
172 scope_blocks: Vec::new(),
182 starting_style_blocks: Vec::new(),
173 starting_style_blocks: Vec::new(),
183 manifest: None,
174 manifest: None,
@@ -427,6 +418,54 @@ impl Parser {
427
418
428 if let Some(rest) = content.strip_prefix("@let ") {
419 if let Some(rest) = content.strip_prefix("@let ") {
429 let rest = rest.trim();
420 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+
430 if let Some((name, value)) = rest.split_once(' ') {
469 if let Some((name, value)) = rest.split_once(' ') {
431 let value = value.trim();
470 let value = value.trim();
432 // Support @let name = expr syntax (strip leading =)
471 // Support @let name = expr syntax (strip leading =)
@@ -438,6 +477,16 @@ impl Parser {
438 value
477 value
439 };
478 };
440
479
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+
441 // Multi-line @let with triple quotes: @let name """..."""
490 // Multi-line @let with triple quotes: @let name """..."""
442 let value_str;
491 let value_str;
443 let value = if let Some(after_open) = value.strip_prefix("\"\"\"") {
492 let value = if let Some(after_open) = value.strip_prefix("\"\"\"") {
@@ -763,32 +812,6 @@ impl Parser {
763 });
812 });
764 }
813 }
765
814
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−
792 // --- @assert directive (compile-time assertions) ---
815 // --- @assert directive (compile-time assertions) ---
793
816
794 if let Some(rest) = content.strip_prefix("@assert ") {
817 if let Some(rest) = content.strip_prefix("@assert ") {
@@ -1039,8 +1062,6 @@ impl Parser {
1039 ctx.functions.keys().cloned().collect();
1062 ctx.functions.keys().cloned().collect();
1040 let define_keys_before: std::collections::HashSet<String> =
1063 let define_keys_before: std::collections::HashSet<String> =
1041 ctx.defines.keys().cloned().collect();
1064 ctx.defines.keys().cloned().collect();
1042− let mixin_keys_before: std::collections::HashSet<String> =
1043− ctx.mixins.keys().cloned().collect();
1044 let var_keys_before: std::collections::HashSet<String> =
1065 let var_keys_before: std::collections::HashSet<String> =
1045 ctx.variables.keys().cloned().collect();
1066 ctx.variables.keys().cloned().collect();
1046
1067
@@ -1074,17 +1095,6 @@ impl Parser {
1074 ctx.defines.insert(format!("{}.{}", prefix, name), attrs);
1095 ctx.defines.insert(format!("{}.{}", prefix, name), attrs);
1075 }
1096 }
1076 }
1097 }
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− }
1088 let new_var_keys: Vec<String> = ctx
1098 let new_var_keys: Vec<String> = ctx
1089 .variables
1099 .variables
1090 .keys()
1100 .keys()
@@ -2615,7 +2625,7 @@ impl Parser {
2615 // Peek at the next line to get the function name
2625 // Peek at the next line to get the function name
2616 if self.pos < self.lines.len()
2626 if self.pos < self.lines.len()
2617 && let LineContent::Normal(ref s) = self.lines[self.pos].content
2627 && 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 ")
2619 {
2629 {
2620 let parts: Vec<&str> = fn_rest.split_whitespace().collect();
2630 let parts: Vec<&str> = fn_rest.split_whitespace().collect();
2621 if let Some(&fn_name) = parts.first() {
2631 if let Some(&fn_name) = parts.first() {
@@ -2884,48 +2894,6 @@ impl Parser {
2884 return Ok(None);
2894 return Ok(None);
2885 }
2895 }
2886
2896
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−
2929 // --- Function call ---
2897 // --- Function call ---
2930
2898
2931 if content.starts_with('@') {
2899 if content.starts_with('@') {
@@ -3362,8 +3330,6 @@ const KNOWN_ELEMENTS: &[&str] = &[
3362const KNOWN_DIRECTIVES: &[&str] = &[
3330const KNOWN_DIRECTIVES: &[&str] = &[
3363 "page",
3331 "page",
3364 "let",
3332 "let",
3365− "define",
3366− "fn",
3367 "include",
3333 "include",
3368 "import",
3334 "import",
3369 "raw",
3335 "raw",
@@ -3392,7 +3358,6 @@ const KNOWN_DIRECTIVES: &[&str] = &[
3392 "base",
3358 "base",
3393 "font-face",
3359 "font-face",
3394 "json-ld",
3360 "json-ld",
3395− "mixin",
3396 "assert",
3361 "assert",
3397 "for",
3362 "for",
3398 "component",
3363 "component",
@@ -4452,13 +4417,8 @@ fn parse_attr_list(
4452 continue;
4417 continue;
4453 }
4418 }
4454
4419
4455− // ...$name spread — expand mixin or define attributes
4420+ // ...$name spread — expand define/attribute bundle
4456 if let Some(name) = part.strip_prefix("...$") {
4421 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− }
4462 if let Some(define_attrs) = ctx.defines.get(name) {
4422 if let Some(define_attrs) = ctx.defines.get(name) {
4463 ctx.used_defines.insert(name.to_string());
4423 ctx.used_defines.insert(name.to_string());
4464 attrs.extend(define_attrs.clone());
4424 attrs.extend(define_attrs.clone());
@@ -4466,19 +4426,13 @@ fn parse_attr_list(
4466 }
4426 }
4467 }
4427 }
4468
4428
4469− // $define reference — expand
4429+ // $define reference — expand attribute bundle
4470 if let Some(name) = part.strip_prefix('$') {
4430 if let Some(name) = part.strip_prefix('$') {
4471 if let Some(define_attrs) = ctx.defines.get(name) {
4431 if let Some(define_attrs) = ctx.defines.get(name) {
4472 ctx.used_defines.insert(name.to_string());
4432 ctx.used_defines.insert(name.to_string());
4473 attrs.extend(define_attrs.clone());
4433 attrs.extend(define_attrs.clone());
4474 continue;
4434 continue;
4475 }
4435 }
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− }
4482 }
4436 }
4483
4437
4484 // Substitute variables in value
4438 // Substitute variables in value
@@ -5757,20 +5711,23 @@ fn check_unused(ctx: &mut ParseContext) {
5757 }
5711 }
5758 }
5712 }
5759
5713
5760− // Check unused @define bundles
5714+ // Check unused attribute bundles (@let name [...])
5761 for (name, &line) in &ctx.define_lines {
5715 for (name, &line) in &ctx.define_lines {
5762 if !ctx.used_defines.contains(name) {
5716 if !ctx.used_defines.contains(name) {
5763 ctx.diagnostics.push(Diagnostic {
5717 ctx.diagnostics.push(Diagnostic {
5764 line,
5718 line,
5765 column: None,
5719 column: None,
5720+ message: format!(
5766− message: format!("unused define '${}' (defined but never referenced)", name),
5721+ "unused attribute bundle '${}' (defined but never referenced)",
5722+ name
5723+ ),
5767 severity: Severity::Warning,
5724 severity: Severity::Warning,
5768 source_line: None,
5725 source_line: None,
5769 });
5726 });
5770 }
5727 }
5771 }
5728 }
5772
5729
5773− // Check unused @fn functions
5730+ // Check unused functions (@let name ... with body)
5774 for (name, &line) in &ctx.fn_lines {
5731 for (name, &line) in &ctx.fn_lines {
5775 if !ctx.used_functions.contains(name) {
5732 if !ctx.used_functions.contains(name) {
5776 ctx.diagnostics.push(Diagnostic {
5733 ctx.diagnostics.push(Diagnostic {
@@ -5782,19 +5739,6 @@ fn check_unused(ctx: &mut ParseContext) {
5782 });
5739 });
5783 }
5740 }
5784 }
5741 }
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− }
5798}
5742}
5799
5743
5800// ---------------------------------------------------------------------------
5744// ---------------------------------------------------------------------------
examples/blog.hl+2−2
@@ -5,7 +5,7 @@
5@let ink #1f2937
5@let ink #1f2937
6@let muted #6b7280
6@let muted #6b7280
7
7
8−@define card [
8+@let card [
9 padding 24,
9 padding 24,
10 background white,
10 background white,
11 rounded 10,
11 rounded 10,
@@ -14,7 +14,7 @@
14 transition all 0.15s ease
14 transition all 0.15s ease
15]
15]
16
16
17−@fn post $title $date $excerpt
17+@let post $title $date $excerpt
18 @article [$card, spacing 8]
18 @article [$card, spacing 8]
19 @text [bold, size 20, color $ink] $title
19 @text [bold, size 20, color $ink] $title
20 @text [size 13, color $muted] $date
20 @text [size 13, color $muted] $date
examples/demo.hl+5−5
@@ -18,7 +18,7 @@
18@let gutter = $gap + $base
18@let gutter = $gap + $base
19
19
20-- Reusable attribute bundle
20-- Reusable attribute bundle
21−@define card [
21+@let card [
22 padding $gap,
22 padding $gap,
23 background white,
23 background white,
24 rounded $base,
24 rounded $base,
@@ -27,7 +27,7 @@
27]
27]
28
28
29-- Composable style group (used with spread)
29-- Composable style group (used with spread)
30−@mixin interactive [
30+@let interactive [
31 cursor pointer,
31 cursor pointer,
32 hover:shadow 0 4px 12px rgba(0,0,0,0.08),
32 hover:shadow 0 4px 12px rgba(0,0,0,0.08),
33 transition all 0.2s ease
33 transition all 0.2s ease
@@ -44,7 +44,7 @@
44"""
44"""
45
45
46-- Component with default parameter + @children slot
46-- Component with default parameter + @children slot
47−@fn card $title $icon=◆
47+@let card $title $icon=◆
48 @article [$card, ...$interactive, animate fade-up 0.3s ease]
48 @article [$card, ...$interactive, animate fade-up 0.3s ease]
49 @row [spacing 10, align-items center]
49 @row [spacing 10, align-items center]
50 @text [size 20, color $primary] $icon
50 @text [size 20, color $primary] $icon
@@ -52,7 +52,7 @@
52 @children
52 @children
53
53
54-- Component that wraps children in a titled section (function + slot)
54-- Component that wraps children in a titled section (function + slot)
55−@fn titled $title
55+@let titled $title
56 @section [spacing 12, padding-y $base]
56 @section [spacing 12, padding-y $base]
57 @text [bold, size 22, color $ink] $title
57 @text [bold, size 22, color $ink] $title
58 @children
58 @children
@@ -124,7 +124,7 @@
124 overflow-x auto
124 overflow-x auto
125 ]
125 ]
126 @raw """
126 @raw """
127−@fn button $label
127+@let button $label
128 @el [padding 12, background blue]
128 @el [padding 12, background blue]
129 @text $label
129 @text $label
130"""
130"""
examples/docs.hl+1−1
@@ -6,7 +6,7 @@
6@let primary #3b82f6
6@let primary #3b82f6
7@let code-bg #f1f5f9
7@let code-bg #f1f5f9
8
8
9−@fn nav-link $label $href
9+@let nav-link $label $href
10 @link [
10 @link [
11 padding 8, padding-x 12, rounded 6, color $ink,
11 padding 8, padding-x 12, rounded 6, color $ink,
12 hover:background #f1f5f9, transition all 0.1s ease
12 hover:background #f1f5f9, transition all 0.1s ease
examples/landing.hl+1−1
@@ -6,7 +6,7 @@
6@let muted #64748b
6@let muted #64748b
7@let bg-subtle #f8fafc
7@let bg-subtle #f8fafc
8
8
9−@fn feature $title $body
9+@let feature $title $body
10 @column [padding 24, background white, rounded 12, border 1 #e2e8f0, spacing 8]
10 @column [padding 24, background white, rounded 12, border 1 #e2e8f0, spacing 8]
11 @text [bold, size 18, color $ink] $title
11 @text [bold, size 18, color $ink] $title
12 @paragraph [color $muted, line-height 1.6]
12 @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> {
16 let trimmed = line.trim();
16 let trimmed = line.trim();
17 let line_num = i as u32;
17 let line_num = i as u32;
18
18
19− // @fn definitions
19+ // @let definitions (variables, attribute bundles, and components)
20− if let Some(rest) = trimmed.strip_prefix("@fn ") {
20+ if let Some(rest) = trimmed.strip_prefix("@let ") {
21+ let rest_trimmed = rest.trim();
21− let parts: Vec<&str> = rest.split_whitespace().collect();
22+ let parts: Vec<&str> = rest_trimmed.split_whitespace().collect();
22− if let Some(name) = parts.first() {
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
23− let params = parts[1..].join(" ");
35+ let params = parts[1..].join(" ");
24− let detail = if params.is_empty() {
36+ let detail = if params.is_empty() {
25− None
37+ None
26− } else {
38+ } else {
27− Some(format!("({})", params))
39+ Some(format!("({})", params))
28− };
40+ };
29− symbols.push(SymbolInformation {
41+ symbols.push(SymbolInformation {
30− name: format!("@{}", name),
42+ name: format!("@{}", name),
31− kind: SymbolKind::FUNCTION,
43+ kind: SymbolKind::FUNCTION,
32− tags: None,
44+ tags: None,
33− deprecated: None,
45+ deprecated: None,
34− location: Location {
46+ location: Location {
35− uri: Url::parse("file:///").unwrap(), // replaced by caller
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(),
36− range: Range::new(
64+ range: Range::new(
37− Position::new(line_num, 0),
65+ Position::new(line_num, 0),
38− Position::new(line_num, line.len() as u32),
66+ Position::new(line_num, line.len() as u32),
39− ),
67+ ),
68+ },
69+ container_name: Some("attribute bundle".to_string()),
40− },
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+ },
41− container_name: detail,
85+ container_name: Some(format!("= {}", value_part)),
42− });
86+ });
87+ }
43 }
88 }
44 }
89 }
45
90
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−
87 // @keyframes definitions
91 // @keyframes definitions
88 if let Some(rest) = trimmed.strip_prefix("@keyframes ") {
92 if let Some(rest) = trimmed.strip_prefix("@keyframes ") {
89 let name = rest.trim();
93 let name = rest.trim();
@@ -211,15 +215,15 @@ pub(crate) fn code_actions(
211 }
215 }
212 }
216 }
213
217
214− // Quick-fix: remove unused @define
218+ // Quick-fix: remove unused attribute bundle (@let name [...])
215− if msg.contains("unused define") {
219+ if msg.contains("unused attribute bundle") {
216 let line = diag.range.start.line as usize;
220 let line = diag.range.start.line as usize;
217 let lines: Vec<&str> = text.lines().collect();
221 let lines: Vec<&str> = text.lines().collect();
218 if let Some(source_line) = lines.get(line) {
222 if let Some(source_line) = lines.get(line) {
219 let trimmed = source_line.trim_start();
223 let trimmed = source_line.trim_start();
220− if trimmed.starts_with("@define ") {
224+ if trimmed.starts_with("@let ") {
221 let def_name = trimmed
225 let def_name = trimmed
222− .strip_prefix("@define ")
226+ .strip_prefix("@let ")
223 .and_then(|r| {
227 .and_then(|r| {
224 let r = r.trim();
228 let r = r.trim();
225 r.find('[')
229 r.find('[')
@@ -237,7 +241,7 @@ pub(crate) fn code_actions(
237 let mut changes = HashMap::new();
241 let mut changes = HashMap::new();
238 changes.insert(uri.clone(), vec![edit]);
242 changes.insert(uri.clone(), vec![edit]);
239 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
243 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
240− title: format!("Remove unused define '${}'", def_name),
244+ title: format!("Remove unused attribute bundle '${}'", def_name),
241 kind: Some(CodeActionKind::QUICKFIX),
245 kind: Some(CodeActionKind::QUICKFIX),
242 diagnostics: Some(vec![diag.clone()]),
246 diagnostics: Some(vec![diag.clone()]),
243 edit: Some(WorkspaceEdit {
247 edit: Some(WorkspaceEdit {
@@ -250,15 +254,15 @@ pub(crate) fn code_actions(
250 }
254 }
251 }
255 }
252
256
253− // Quick-fix: remove unused @fn function (remove definition and its body)
257+ // Quick-fix: remove unused function (@let name ... with body)
254 if msg.contains("unused function") {
258 if msg.contains("unused function") {
255 let line = diag.range.start.line as usize;
259 let line = diag.range.start.line as usize;
256 let lines: Vec<&str> = text.lines().collect();
260 let lines: Vec<&str> = text.lines().collect();
257 if let Some(source_line) = lines.get(line) {
261 if let Some(source_line) = lines.get(line) {
258 let trimmed = source_line.trim_start();
262 let trimmed = source_line.trim_start();
259− if trimmed.starts_with("@fn ") {
263+ if trimmed.starts_with("@let ") {
260 let fn_name = trimmed
264 let fn_name = trimmed
261− .strip_prefix("@fn ")
265+ .strip_prefix("@let ")
262 .and_then(|r| r.split_whitespace().next())
266 .and_then(|r| r.split_whitespace().next())
263 .unwrap_or("?");
267 .unwrap_or("?");
264 // Find the end of the function body (indented lines below)
268 // Find the end of the function body (indented lines below)
@@ -301,45 +305,6 @@ pub(crate) fn code_actions(
301 }
305 }
302 }
306 }
303
307
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−
343 // Quick-fix: add alt attribute to @image
308 // Quick-fix: add alt attribute to @image
344 if msg.contains("@image should have") && msg.contains("alt") {
309 if msg.contains("@image should have") && msg.contains("alt") {
345 let line = diag.range.start.line as usize;
310 let line = diag.range.start.line as usize;
@@ -459,7 +424,7 @@ pub(crate) fn code_actions(
459 }
424 }
460
425
461 // Quick-fix: auto-import suggestion for unknown element @name
426 // 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
463 if msg.contains("unknown element @")
428 if msg.contains("unknown element @")
464 && let Some(fn_name) = extract_between(msg, "unknown element @", ",")
429 && let Some(fn_name) = extract_between(msg, "unknown element @", ",")
465 .or_else(|| extract_between(msg, "unknown element @", ""))
430 .or_else(|| extract_between(msg, "unknown element @", ""))
@@ -495,7 +460,7 @@ pub(crate) fn code_actions(
495 if let Ok(content) = std::fs::read_to_string(&path) {
460 if let Ok(content) = std::fs::read_to_string(&path) {
496 let defines_fn = content.lines().any(|l| {
461 let defines_fn = content.lines().any(|l| {
497 let t = l.trim();
462 let t = l.trim();
498− if let Some(rest) = t.strip_prefix("@fn ") {
463+ if let Some(rest) = t.strip_prefix("@let ") {
499 rest.split_whitespace().next() == Some(fn_name)
464 rest.split_whitespace().next() == Some(fn_name)
500 } else {
465 } else {
501 false
466 false
@@ -577,7 +542,7 @@ pub(crate) fn code_actions(
577 }
542 }
578 }
543 }
579
544
580− // Refactoring: extract selection to @fn
545+ // Refactoring: extract selection to @let component
581 if selection.start.line != selection.end.line {
546 if selection.start.line != selection.end.line {
582 let lines: Vec<&str> = text.lines().collect();
547 let lines: Vec<&str> = text.lines().collect();
583 let start_line = selection.start.line as usize;
548 let start_line = selection.start.line as usize;
@@ -594,7 +559,7 @@ pub(crate) fn code_actions(
594 .min()
559 .min()
595 .unwrap_or(0);
560 .unwrap_or(0);
596
561
597− // Build the function body with two-space indentation relative to @fn
562+ // Build the function body with two-space indentation relative to @let
598 let fn_body: String = selected
563 let fn_body: String = selected
599 .iter()
564 .iter()
600 .map(|l| {
565 .map(|l| {
@@ -611,11 +576,11 @@ pub(crate) fn code_actions(
611 })
576 })
612 .collect();
577 .collect();
613
578
614− let fn_def = format!("@fn extracted\n{}", fn_body);
579+ let fn_def = format!("@let extracted\n{}", fn_body);
615 let indent = " ".repeat(min_indent);
580 let indent = " ".repeat(min_indent);
616 let fn_call = format!("{}@extracted", indent);
581 let fn_call = format!("{}@extracted", indent);
617
582
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
619 let replace_edit = TextEdit {
584 let replace_edit = TextEdit {
620 range: Range::new(
585 range: Range::new(
621 Position::new(selection.start.line, 0),
586 Position::new(selection.start.line, 0),
@@ -630,7 +595,7 @@ pub(crate) fn code_actions(
630 let mut changes = HashMap::new();
595 let mut changes = HashMap::new();
631 changes.insert(uri.clone(), vec![insert_edit, replace_edit]);
596 changes.insert(uri.clone(), vec![insert_edit, replace_edit]);
632 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
597 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
633− title: "Extract to @fn".into(),
598+ title: "Extract to @let component".into(),
634 kind: Some(CodeActionKind::REFACTOR_EXTRACT),
599 kind: Some(CodeActionKind::REFACTOR_EXTRACT),
635 diagnostics: None,
600 diagnostics: None,
636 edit: Some(WorkspaceEdit {
601 edit: Some(WorkspaceEdit {
@@ -643,8 +608,8 @@ pub(crate) fn code_actions(
643 }
608 }
644 }
609 }
645
610
646− // Refactoring: extract attributes to @define
611+ // Refactoring: extract attributes to @let attribute bundle
647− // Works on a single line with [attrs] — extracts attrs into a @define
612+ // Works on a single line with [attrs] — extracts attrs into a @let
648 {
613 {
649 let lines: Vec<&str> = text.lines().collect();
614 let lines: Vec<&str> = text.lines().collect();
650 let line_idx = selection.start.line as usize;
615 let line_idx = selection.start.line as usize;
@@ -658,7 +623,7 @@ pub(crate) fn code_actions(
658 let attr_count = attrs_str.split(',').count();
623 let attr_count = attrs_str.split(',').count();
659 if attr_count >= 2 {
624 if attr_count >= 2 {
660 let define_name = "extracted-style";
625 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());
662 let indent = " ".repeat(line.len() - line.trim_start().len());
627 let indent = " ".repeat(line.len() - line.trim_start().len());
663
628
664 // Replace [attrs] with [$extracted-style]
629 // Replace [attrs] with [$extracted-style]
@@ -684,7 +649,7 @@ pub(crate) fn code_actions(
684 let mut changes = HashMap::new();
649 let mut changes = HashMap::new();
685 changes.insert(uri.clone(), vec![insert_edit, replace_edit]);
650 changes.insert(uri.clone(), vec![insert_edit, replace_edit]);
686 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
651 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
687− title: "Extract to @define".into(),
652+ title: "Extract to @let attribute bundle".into(),
688 kind: Some(CodeActionKind::REFACTOR_EXTRACT),
653 kind: Some(CodeActionKind::REFACTOR_EXTRACT),
689 diagnostics: None,
654 diagnostics: None,
690 edit: Some(WorkspaceEdit {
655 edit: Some(WorkspaceEdit {
@@ -921,8 +886,8 @@ pub(crate) fn folding_ranges(text: &str) -> Vec<FoldingRange> {
921 let mut i = 0;
886 let mut i = 0;
922 while i < lines.len() {
887 while i < lines.len() {
923 let trimmed = lines[i].trim();
888 let trimmed = lines[i].trim();
924− // Fold blocks that start with @fn, @if, @else, @each, @match, @style, @head, @keyframes, @define
889+ // Fold blocks that start with @let (with body), @if, @else, @each, @match, @style, @head, @keyframes
925− if trimmed.starts_with("@fn ")
890+ if trimmed.starts_with("@let ")
926 || trimmed.starts_with("@if ")
891 || trimmed.starts_with("@if ")
927 || trimmed == "@else"
892 || trimmed == "@else"
928 || trimmed.starts_with("@else if ")
893 || trimmed.starts_with("@else if ")
@@ -1066,25 +1031,21 @@ pub(crate) fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
1066 }
1031 }
1067 let word = &line[start..i];
1032 let word = &line[start..i];
1068 let token_type = match word {
1033 let token_type = match word {
1069− "@page" | "@let" | "@define" | "@fn" | "@if" | "@else" | "@each"
1034+ "@page" | "@let" | "@if" | "@else" | "@each"
1070 | "@include" | "@import" | "@meta" | "@head" | "@style" | "@keyframes"
1035 | "@include" | "@import" | "@meta" | "@head" | "@style" | "@keyframes"
1071 | "@match" | "@case" | "@default" | "@slot" | "@children" | "@warn"
1036 | "@match" | "@case" | "@default" | "@slot" | "@children" | "@warn"
1072 | "@debug" | "@lang" | "@favicon" | "@fragment" | "@unless" | "@og"
1037 | "@debug" | "@lang" | "@favicon" | "@fragment" | "@unless" | "@og"
1073 | "@breakpoint" | "@canonical" | "@base" | "@font-face" | "@json-ld"
1038 | "@breakpoint" | "@canonical" | "@base" | "@font-face" | "@json-ld"
1074− | "@mixin" | "@assert" | "@theme" | "@deprecated" | "@extends" | "@use"
1039+ | "@assert" | "@theme" | "@deprecated" | "@extends" | "@use"
1075 | "@data" | "@env" | "@fetch" | "@svg" | "@css-property" => 0, // keyword
1040 | "@data" | "@env" | "@fetch" | "@svg" | "@css-property" => 0, // keyword
1076 _ => {
1041 _ => {
1077 // Check if it's a user function call (starts with @ but not a builtin element)
1042 // Check if it's a user function call (starts with @ but not a builtin element)
1078 if is_builtin_element(word) { 0 } else { 2 } // function
1043 if is_builtin_element(word) { 0 } else { 2 } // function
1079 }
1044 }
1080 };
1045 };
1081− // Mark unused @fn definitions with deprecated modifier (dimmed)
1046+ // Mark unused definitions with deprecated modifier (dimmed)
1082− let modifier = if (trimmed.starts_with("@fn ")
1083− || trimmed.starts_with("@define ")
1047+ let modifier = if trimmed.starts_with("@let ")
1084− || trimmed.starts_with("@mixin "))
1085− && word != "@fn"
1086− && word != "@define"
1048+ && word != "@let"
1087− && word != "@mixin"
1088 {
1049 {
1089 let name_part = &word[1..]; // strip @
1050 let name_part = &word[1..]; // strip @
1090 if unused_vars.contains(&format!("@{}", name_part)) {
1051 if unused_vars.contains(&format!("@{}", name_part)) {
@@ -1333,10 +1294,10 @@ pub(crate) fn get_signature_help(text: &str, position: Position) -> Option<Signa
1333 }
1294 }
1334 let inside_args = in_brackets(before);
1295 let inside_args = in_brackets(before);
1335
1296
1336− // Find the @fn definition
1297+ // Find the @let component definition
1337 for (line_idx, line_text) in text.lines().enumerate() {
1298 for (line_idx, line_text) in text.lines().enumerate() {
1338 let t = line_text.trim_start();
1299 let t = line_text.trim_start();
1339− if let Some(rest) = t.strip_prefix("@fn ") {
1300+ if let Some(rest) = t.strip_prefix("@let ") {
1340 let rest = rest.trim();
1301 let rest = rest.trim();
1341 let def_name_end = rest
1302 let def_name_end = rest
1342 .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
1303 .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> {
156 ("@opt", "Option (short for @option)"),
156 ("@opt", "Option (short for @option)"),
157 ("@label", "Label element"),
157 ("@label", "Label element"),
158 ("@raw", "Raw HTML escape hatch"),
158 ("@raw", "Raw HTML escape hatch"),
159− ("@children", "Slot for caller's children (inside @fn)"),
159+ ("@children", "Slot for caller's children (inside component)"),
160− ("@slot", "Named slot inside @fn (e.g., @slot header)"),
160+ ("@slot", "Named slot inside component (e.g., @slot header)"),
161 // Semantic elements
161 // Semantic elements
162 ("@nav", "Navigation container (nav)"),
162 ("@nav", "Navigation container (nav)"),
163 ("@header", "Page/section header (header)"),
163 ("@header", "Page/section header (header)"),
@@ -245,12 +245,10 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
245fn directive_completions(range: Range) -> Vec<CompletionItem> {
245fn directive_completions(range: Range) -> Vec<CompletionItem> {
246 [
246 [
247 ("@page", "Set HTML page title", "@page "),
247 ("@page", "Set HTML page title", "@page "),
248− ("@let", "Define a variable", "@let "),
249− ("@define", "Define an attribute bundle", "@define "),
250 (
248 (
251− "@fn",
249+ "@let",
252− "Define a reusable function (supports $param=default)",
250+ "Define a variable, attribute bundle, or component",
253− "@fn ",
251+ "@let ",
254 ),
252 ),
255 ("@keyframes", "Define a CSS animation", "@keyframes "),
253 ("@keyframes", "Define a CSS animation", "@keyframes "),
256 ("@if", "Conditional rendering", "@if "),
254 ("@if", "Conditional rendering", "@if "),
@@ -274,7 +272,7 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
274 ("@meta", "Add a <meta> tag to <head>", "@meta "),
272 ("@meta", "Add a <meta> tag to <head>", "@meta "),
275 ("@head", "Add raw content to <head>", "@head"),
273 ("@head", "Add raw content to <head>", "@head"),
276 ("@style", "Add raw CSS to stylesheet", "@style"),
274 ("@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 "),
278 ("@match", "Pattern matching on a value", "@match "),
276 ("@match", "Pattern matching on a value", "@match "),
279 ("@case", "Match case (inside @match)", "@case "),
277 ("@case", "Match case (inside @match)", "@case "),
280 ("@default", "Default case (inside @match)", "@default"),
278 ("@default", "Default case (inside @match)", "@default"),
@@ -310,7 +308,7 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
310 "Define design tokens (colors, spacing, fonts)",
308 "Define design tokens (colors, spacing, fonts)",
311 "@theme",
309 "@theme",
312 ),
310 ),
313− ("@deprecated", "Mark next @fn as deprecated", "@deprecated "),
311+ ("@deprecated", "Mark next @let component as deprecated", "@deprecated "),
314 (
312 (
315 "@extends",
313 "@extends",
316 "Inherit a layout template and fill @slot blocks",
314 "Inherit a layout template and fill @slot blocks",
@@ -333,11 +331,6 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
333 "Add JSON-LD structured data to head",
331 "Add JSON-LD structured data to head",
334 "@json-ld",
332 "@json-ld",
335 ),
333 ),
336− (
337− "@mixin",
338− "Define a composable style group (use with ...$name)",
339− "@mixin ",
340− ),
341 (
334 (
342 "@assert",
335 "@assert",
343 "Compile-time assertion for variable values",
336 "Compile-time assertion for variable values",
@@ -367,7 +360,7 @@ fn snippet_completions(range: Range) -> Vec<CompletionItem> {
367 (
360 (
368 "card component",
361 "card component",
369 "Reusable card with title and content",
362 "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",
371 ),
364 ),
372 (
365 (
373 "responsive layout",
366 "responsive layout",
@@ -430,9 +423,9 @@ fn snippet_completions(range: Range) -> Vec<CompletionItem> {
430 "@text [max-width ${1:200}, truncate] ${2:Long text that will be truncated...}",
423 "@text [max-width ${1:200}, truncate] ${2:Long text that will be truncated...}",
431 ),
424 ),
432 (
425 (
433− "@fn definition",
426+ "@let component",
434− "Define a reusable function/component",
427+ "Define a reusable component",
435− "@fn ${1:name} \\$${2:param}\n @el [${3:padding 16}]\n @children",
428+ "@let ${1:name} \\$${2:param}\n @el [${3:padding 16}]\n @children",
436 ),
429 ),
437 (
430 (
438 "@each loop",
431 "@each loop",
@@ -529,7 +522,7 @@ pub(crate) fn path_completions(uri: &Url, position: Position) -> Vec<CompletionI
529 items
522 items
530}
523}
531
524
532−/// Suggest exported @fn and @define names from a file referenced in @use
525+/// Suggest exported names from a file referenced in @use
533pub(crate) fn use_symbol_completions(
526pub(crate) fn use_symbol_completions(
534 uri: &Url,
527 uri: &Url,
535 line: &str,
528 line: &str,
@@ -573,17 +566,33 @@ pub(crate) fn use_symbol_completions(
573
566
574 for target_line in target_content.lines() {
567 for target_line in target_content.lines() {
575 let trimmed = target_line.trim();
568 let trimmed = target_line.trim();
576− if let Some(rest) = trimmed.strip_prefix("@fn ") {
569+ if let Some(rest) = trimmed.strip_prefix("@let ") {
577 if let Some(name) = rest.split_whitespace().next() {
570 if let Some(name) = rest.split_whitespace().next() {
571+ let name = name.trim_end_matches('[');
572+ let params: Vec<&str> = rest
578− let params: Vec<&str> = rest.split_whitespace().skip(1).collect();
573+ .split_whitespace()
574+ .skip(1)
575+ .filter(|p| p.starts_with('$'))
576+ .collect();
579− let detail = if params.is_empty() {
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,
580− format!("@fn {} (from {})", name, filename)
585+ format!("@let {} [...] (from {})", name, filename),
586+ )
581 } else {
587 } else {
588+ (
589+ CompletionItemKind::VARIABLE,
582− format!("@fn {} {} (from {})", name, params.join(" "), filename)
590+ format!("@let {} (from {})", name, filename),
591+ )
583 };
592 };
584 items.push(CompletionItem {
593 items.push(CompletionItem {
585 label: name.to_string(),
594 label: name.to_string(),
586− kind: Some(CompletionItemKind::FUNCTION),
595+ kind: Some(kind),
587 detail: Some(detail),
596 detail: Some(detail),
588 text_edit: Some(CompletionTextEdit::Edit(TextEdit {
597 text_edit: Some(CompletionTextEdit::Edit(TextEdit {
589 range: edit_range,
598 range: edit_range,
@@ -592,20 +601,6 @@ pub(crate) fn use_symbol_completions(
592 ..Default::default()
601 ..Default::default()
593 });
602 });
594 }
603 }
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− }
609 } else if let Some(rest) = trimmed.strip_prefix("@component ")
604 } else if let Some(rest) = trimmed.strip_prefix("@component ")
610 && let Some(name) = rest.split_whitespace().next()
605 && let Some(name) = rest.split_whitespace().next()
611 {
606 {
@@ -1370,25 +1365,27 @@ fn variable_completions(text: &str, range: Range) -> Vec<CompletionItem> {
1370 let trimmed = line.trim();
1365 let trimmed = line.trim();
1371 if let Some(rest) = trimmed.strip_prefix("@let ") {
1366 if let Some(rest) = trimmed.strip_prefix("@let ") {
1372 if let Some((name, value)) = rest.trim().split_once(' ') {
1367 if let Some((name, value)) = rest.trim().split_once(' ') {
1368+ let value = value.trim();
1369+ if value.starts_with('[') {
1370+ // Attribute bundle
1373− items.push(item(
1371+ items.push(item(
1374− &format!("${}", name),
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),
1375− CompletionItemKind::VARIABLE,
1382+ CompletionItemKind::VARIABLE,
1376− &format!("= {}", value.trim()),
1383+ &format!("= {}", value),
1377− &format!("${}", name),
1384+ &format!("${}", name),
1378− range,
1385+ range,
1379− ));
1386+ ));
1387+ }
1380 }
1388 }
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− ));
1392 }
1389 }
1393 }
1390 }
1394
1391
@@ -1397,12 +1394,22 @@ fn variable_completions(text: &str, range: Range) -> Vec<CompletionItem> {
1397
1394
1398fn function_completions(text: &str, range: Range) -> Vec<CompletionItem> {
1395fn function_completions(text: &str, range: Range) -> Vec<CompletionItem> {
1399 let mut items = Vec::new();
1396 let mut items = Vec::new();
1397+ let all_lines: Vec<&str> = text.lines().collect();
1400
1398
1401− for line in text.lines() {
1399+ for (idx, line) in all_lines.iter().enumerate() {
1402 let trimmed = line.trim();
1400 let trimmed = line.trim();
1403− if let Some(rest) = trimmed.strip_prefix("@fn ") {
1401+ if let Some(rest) = trimmed.strip_prefix("@let ") {
1404 let parts: Vec<&str> = rest.split_whitespace().collect();
1402 let parts: Vec<&str> = rest.split_whitespace().collect();
1405 if let Some(name) = parts.first() {
1403 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+ }
1406 let params: Vec<&str> = parts[1..]
1413 let params: Vec<&str> = parts[1..]
1407 .iter()
1414 .iter()
1408 .map(|p| p.strip_prefix('$').unwrap_or(p))
1415 .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> {
56
56
57 for line in text.lines() {
57 for line in text.lines() {
58 let trimmed = line.trim();
58 let trimmed = line.trim();
59− if let Some(rest) = trimmed.strip_prefix("@define ") {
59+ if let Some(rest) = trimmed.strip_prefix("@let ") {
60 let rest = rest.trim();
60 let rest = rest.trim();
61+ // Attribute bundle: @let name [...]
61− if let Some(bracket) = rest.find('[')
62+ if let Some(bracket) = rest.find('[') {
62− && rest[..bracket].trim() == name
63+ let def_name = rest[..bracket].trim();
63− {
64+ if def_name == name {
64− return Some(format!(
65+ return Some(format!(
65− "**${}** \u{2014} Attribute bundle\n\n`{}`",
66+ "**${}** \u{2014} Attribute bundle\n\n`{}`",
66− name, trimmed
67+ name, trimmed
67− ));
68+ ));
69+ }
68 }
70 }
69− }
71+ // Function parameter: @let fn-name $param (with body)
70− }
71−
72− for line in text.lines() {
73− let trimmed = line.trim();
74− if let Some(rest) = trimmed.strip_prefix("@fn ") {
75 let parts: Vec<&str> = rest.split_whitespace().collect();
72 let parts: Vec<&str> = rest.split_whitespace().collect();
76 if let Some(fn_name) = parts.first() {
73 if let Some(fn_name) = parts.first() {
77 for param in &parts[1..] {
74 for param in &parts[1..] {
@@ -94,12 +91,12 @@ fn hover_user_fn(text: &str, name: &str) -> Option<String> {
94 let lines: Vec<&str> = text.lines().collect();
91 let lines: Vec<&str> = text.lines().collect();
95 for (i, line) in lines.iter().enumerate() {
92 for (i, line) in lines.iter().enumerate() {
96 let trimmed = line.trim();
93 let trimmed = line.trim();
97− if let Some(rest) = trimmed.strip_prefix("@fn ") {
94+ if let Some(rest) = trimmed.strip_prefix("@let ") {
98 let parts: Vec<&str> = rest.split_whitespace().collect();
95 let parts: Vec<&str> = rest.split_whitespace().collect();
99 if parts.first() == Some(&name) {
96 if parts.first() == Some(&name) {
100 let params = &parts[1..];
97 let params = &parts[1..];
101
98
102− // Collect doc-comment lines above the @fn (lines starting with --)
99+ // Collect doc-comment lines above the definition (lines starting with --)
103 let mut doc_lines: Vec<&str> = Vec::new();
100 let mut doc_lines: Vec<&str> = Vec::new();
104 let mut j = i;
101 let mut j = i;
105 while j > 0 {
102 while j > 0 {
@@ -247,19 +244,13 @@ fn hover_builtin(word: &str) -> Option<String> {
247 "**@page** \u{2014} Page title\n\nSets the HTML `<title>` and wraps output in a full document.\n\nUsage: `@page My Page Title`"
244 "**@page** \u{2014} Page title\n\nSets the HTML `<title>` and wraps output in a full document.\n\nUsage: `@page My Page Title`"
248 }
245 }
249 "@let" => {
246 "@let" => {
250− "**@let** \u{2014} Variable\n\nDefines a variable for `$name` substitution.\n\nUsage: `@let primary #3b82f6`"
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```"
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```"
257 }
248 }
258 "@keyframes" => {
249 "@keyframes" => {
259 "**@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]`"
250 "**@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]`"
260 }
251 }
261 "@children" => {
252 "@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."
263 }
254 }
264 "@input" => {
255 "@input" => {
265 "**@input** \u{2014} Form input\n\nRenders as self-closing `<input>`.\n\nUsage: `@input [type text, placeholder Name, name user]`"
256 "**@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> {
289 "**@include** \u{2014} Include file\n\nIncludes another .hl file (DOM nodes + definitions).\n\nUsage: `@include header.hl`"
280 "**@include** \u{2014} Include file\n\nIncludes another .hl file (DOM nodes + definitions).\n\nUsage: `@include header.hl`"
290 }
281 }
291 "@import" => {
282 "@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`"
293 }
284 }
294 "@meta" => {
285 "@meta" => {
295 "**@meta** \u{2014} Meta tag\n\nAdds a `<meta>` tag to `<head>`.\n\nUsage: `@meta description A portfolio site`"
286 "**@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> {
301 "**@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```"
292 "**@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```"
302 }
293 }
303 "@slot" => {
294 "@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```"
305 }
296 }
306 // Semantic elements
297 // Semantic elements
307 "@nav" => {
298 "@nav" => {
@@ -461,13 +452,13 @@ fn hover_builtin(word: &str) -> Option<String> {
461 "**@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```"
452 "**@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```"
462 }
453 }
463 "@deprecated" => {
454 "@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```"
465 }
456 }
466 "@extends" => {
457 "@extends" => {
467 "**@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```"
458 "**@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```"
468 }
459 }
469 "@use" => {
460 "@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```"
471 }
462 }
472 "@canonical" => {
463 "@canonical" => {
473 "**@canonical** `<url>`\n\nSets the canonical URL for the page. Adds `<link rel=\"canonical\">` to `<head>`.\n\nUsage: `@canonical https://example.com/page`"
464 "**@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 {
199 return Ok(Some(CompletionResponse::Array(items)));
199 return Ok(Some(CompletionResponse::Array(items)));
200 }
200 }
201 }
201 }
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
203 if let Some(after_use) = trimmed.strip_prefix("@use ") {
203 if let Some(after_use) = trimmed.strip_prefix("@use ") {
204 // If we already have a filename (quoted or unquoted), suggest symbols from that file
204 // If we already have a filename (quoted or unquoted), suggest symbols from that file
205 let has_file = after_use.contains(".hl");
205 let has_file = after_use.contains(".hl");
@@ -658,8 +658,7 @@ impl LanguageServer for Backend {
658 drop(docs);
658 drop(docs);
659
659
660 // Build a simple usage counter by scanning the whole document. For each
660 // 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.
663 let lines: Vec<&str> = text.lines().collect();
662 let lines: Vec<&str> = text.lines().collect();
664
663
665 #[derive(Clone)]
664 #[derive(Clone)]
@@ -672,40 +671,46 @@ impl LanguageServer for Backend {
672
671
673 for (i, line) in lines.iter().enumerate() {
672 for (i, line) in lines.iter().enumerate() {
674 let trimmed = line.trim_start();
673 let trimmed = line.trim_start();
675− if let Some(rest) = trimmed.strip_prefix("@fn ") {
674+ if let Some(rest) = trimmed.strip_prefix("@let ") {
675+ let rest = rest.trim();
676+ // Determine the kind based on the value
676− if let Some(n) = rest.split_whitespace().next() {
677+ if let Some(name) = rest.split_whitespace().next() {
677− defs.push(Def {
678+ // Check for indented body (function)
678− line: i as u32,
679+ let has_body = lines
679− name: n.to_string(),
680+ .get(i + 1)
680− kind: "fn",
681+ .map(|l| l.starts_with(" ") || l.starts_with('\t'))
681− });
682+ .unwrap_or(false);
682− }
683+ let value_after_name = rest[name.len()..].trim_start();
683− } else if let Some(rest) = trimmed.strip_prefix("@let ") {
684+ if has_body
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() {
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('[') {
696− defs.push(Def {
694+ defs.push(Def {
697− line: i as u32,
695+ line: i as u32,
698− name: n.to_string(),
696+ name: name.to_string(),
699− kind: "define",
697+ kind: "define",
698+ });
699+ } else {
700+ defs.push(Def {
701+ line: i as u32,
702+ name: name.to_string(),
703+ kind: "let",
700− });
704+ });
705+ }
701 }
706 }
702 }
707 }
703 }
708 }
704
709
705 let mut lenses = Vec::with_capacity(defs.len());
710 let mut lenses = Vec::with_capacity(defs.len());
706 for def in &defs {
711 for def in &defs {
707− // Count references: for @fn, look for `@name` at start-of-token;
712+ // Count references: for functions, look for `@name` at start-of-token;
708− // for @let / @define, look for `$name`.
713+ // for variables / attribute bundles, look for `$name`.
709 let mut count: usize = 0;
714 let mut count: usize = 0;
710 let needle = match def.kind {
715 let needle = match def.kind {
711 "fn" => format!("@{}", def.name),
716 "fn" => format!("@{}", def.name),
src/bin/htmlang_lsp/navigation.rs+44−43
@@ -58,58 +58,60 @@ pub(crate) fn definition_at(
58 }))
58 }))
59}
59}
60
60
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).
62pub(crate) fn find_definition(text: &str, name: &str) -> Option<(u32, u32, u32)> {
62pub(crate) fn find_definition(text: &str, name: &str) -> Option<(u32, u32, u32)> {
63 for (i, line) in text.lines().enumerate() {
63 for (i, line) in text.lines().enumerate() {
64 let trimmed = line.trim();
64 let trimmed = line.trim();
65 let offset = (line.len() - trimmed.len()) as u32;
65 let offset = (line.len() - trimmed.len()) as u32;
66
66
67− if let Some(rest) = trimmed.strip_prefix("@let ")
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 [...]
68− && let Some((n, _)) = rest.trim().split_once(' ')
70+ if let Some((n, _)) = rest_trimmed.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();
79 if n == name {
71 if n == name {
80− let col = offset + "@define ".len() as u32;
72+ let col = offset + "@let ".len() as u32;
81 return Some((i as u32, col, n.len() as u32));
73 return Some((i as u32, col, n.len() as u32));
82 }
74 }
83− }
75+ // Check function parameters
84− }
85−
86− if let Some(rest) = trimmed.strip_prefix("@fn ") {
87− let parts: Vec<&str> = rest.split_whitespace().collect();
76+ let parts: Vec<&str> = rest_trimmed.split_whitespace().collect();
88− for param in &parts[1..] {
77+ for param in &parts[1..] {
89− let p = param.strip_prefix('$').unwrap_or(param);
78+ let p = param.strip_prefix('$').unwrap_or(param);
79+ let p = p.split('=').next().unwrap_or(p);
90− if p == name {
80+ if p == name {
91− // Find the param position in the line
92− if let Some(pos) = line.find(param) {
81+ if let Some(pos) = line.find(param) {
93− return Some((i as u32, pos as u32, param.len() as u32));
82+ return Some((i as u32, pos as u32, param.len() as u32));
83+ }
94 }
84 }
95 }
85 }
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));
96 }
90 }
97 }
91 }
98 }
92 }
99 None
93 None
100}
94}
101
95
102−/// Find @fn definition for an @name function call.
96+/// Find @let function definition for an @name function call.
103pub(crate) fn find_fn_definition(text: &str, name: &str) -> Option<(u32, u32, u32)> {
97pub(crate) fn find_fn_definition(text: &str, name: &str) -> Option<(u32, u32, u32)> {
98+ let lines: Vec<&str> = text.lines().collect();
104− for (i, line) in text.lines().enumerate() {
99+ for (i, line) in lines.iter().enumerate() {
105 let trimmed = line.trim();
100 let trimmed = line.trim();
106 let offset = (line.len() - trimmed.len()) as u32;
101 let offset = (line.len() - trimmed.len()) as u32;
107
102
108− if let Some(rest) = trimmed.strip_prefix("@fn ") {
103+ if let Some(rest) = trimmed.strip_prefix("@let ") {
109 let parts: Vec<&str> = rest.split_whitespace().collect();
104 let parts: Vec<&str> = rest.split_whitespace().collect();
110 if parts.first() == Some(&name) {
105 if parts.first() == Some(&name) {
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 {
111− let col = offset + "@fn ".len() as u32;
112+ let col = offset + "@let ".len() as u32;
112− return Some((i as u32, col, name.len() as u32));
113+ return Some((i as u32, col, name.len() as u32));
114+ }
113 }
115 }
114 }
116 }
115 }
117 }
@@ -199,9 +201,10 @@ pub(crate) fn rename_at(
199 });
201 });
200 }
202 }
201
203
202− // Rename @define definition
204+ // Rename @let attribute bundle or parameter definitions
203− if let Some(rest) = trimmed.strip_prefix("@define ") {
205+ if let Some(rest) = trimmed.strip_prefix("@let ") {
204 let rest_trimmed = rest.trim();
206 let rest_trimmed = rest.trim();
207+ // Attribute bundle: @let name [...]
205 if let Some(bracket) = rest_trimmed.find('[') {
208 if let Some(bracket) = rest_trimmed.find('[') {
206 let n = rest_trimmed[..bracket].trim();
209 let n = rest_trimmed[..bracket].trim();
207 if n == name
210 if n == name
@@ -217,13 +220,11 @@ pub(crate) fn rename_at(
217 continue; // Don't also match $ references on this line
220 continue; // Don't also match $ references on this line
218 }
221 }
219 }
222 }
220− }
221−
222− // Rename @fn parameter definitions
223+ // Function parameter definitions
223− if let Some(rest) = trimmed.strip_prefix("@fn ") {
224− let parts: Vec<&str> = rest.split_whitespace().collect();
224+ let parts: Vec<&str> = rest_trimmed.split_whitespace().collect();
225 for param in &parts[1..] {
225 for param in &parts[1..] {
226 let p = param.strip_prefix('$').unwrap_or(param);
226 let p = param.strip_prefix('$').unwrap_or(param);
227+ let p = p.split('=').next().unwrap_or(p);
227 if p == name
228 if p == name
228 && let Some(pos) = line.find(param)
229 && let Some(pos) = line.find(param)
229 {
230 {
@@ -263,13 +264,13 @@ pub(crate) fn rename_at(
263 offset = abs_pos + search.len();
264 offset = abs_pos + search.len();
264 }
265 }
265 } else {
266 } else {
266− // Rename @fn definition
267+ // Rename @let function definition
267− if let Some(rest) = trimmed.strip_prefix("@fn ") {
268+ if let Some(rest) = trimmed.strip_prefix("@let ") {
268 let parts: Vec<&str> = rest.split_whitespace().collect();
269 let parts: Vec<&str> = rest.split_whitespace().collect();
269 if parts.first() == Some(&name)
270 if parts.first() == Some(&name)
270− && let Some(pos) = line.find(&format!("@fn {}", name))
271+ && let Some(pos) = line.find(&format!("@let {}", name))
271 {
272 {
272− let start = pos + 4; // skip "@fn "
273+ let start = pos + 5; // skip "@let "
273 edits.push(TextEdit {
274 edits.push(TextEdit {
274 range: Range::new(
275 range: Range::new(
275 Position::new(line_num, start as u32),
276 Position::new(line_num, start as u32),
@@ -286,8 +287,8 @@ pub(crate) fn rename_at(
286 let mut offset = 0;
287 let mut offset = 0;
287 while let Some(pos) = line[offset..].find(&search) {
288 while let Some(pos) = line[offset..].find(&search) {
288 let abs_pos = offset + pos;
289 let abs_pos = offset + pos;
289− // Don't match @fn definition (handled above)
290+ // Don't match @let definition (handled above)
290− if trimmed.starts_with("@fn ") {
291+ if trimmed.starts_with("@let ") {
291 offset = abs_pos + search.len();
292 offset = abs_pos + search.len();
292 continue;
293 continue;
293 }
294 }
src/cli.rs+4−4
@@ -27,9 +27,9 @@ Commands:
27 export <dir> [-o f] Compile and bundle into an archive
27 export <dir> [-o f] Compile and bundle into an archive
28 repl Interactive REPL (type .hl, get HTML)
28 repl Interactive REPL (type .hl, get HTML)
29 feed <dir> [-b URL] Generate RSS feed from @page metadata
29 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
31 deps <dir> Show file dependency graph (@include/@import)
31 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
33 deploy <dir> [--provider github-pages|netlify|vercel|cloudflare]
33 deploy <dir> [--provider github-pages|netlify|vercel|cloudflare]
34 Build and deploy
34 Build and deploy
35 playground [out.html] Generate a self-contained HTML playground
35 playground [out.html] Generate a self-contained HTML playground
@@ -192,7 +192,7 @@ _htmlang() {{
192 'export:Bundle into archive'
192 'export:Bundle into archive'
193 'repl:Interactive REPL'
193 'repl:Interactive REPL'
194 'feed:Generate RSS feed'
194 'feed:Generate RSS feed'
195− 'components:List @fn definitions'
195+ 'components:List component definitions'
196 'deps:Show dependency graph'
196 'deps:Show dependency graph'
197 'dead-code:Find unused definitions'
197 'dead-code:Find unused definitions'
198 'deploy:Build and deploy'
198 'deploy:Build and deploy'
@@ -266,7 +266,7 @@ fn print_fish_completions() {
266 ("export", "Bundle into archive"),
266 ("export", "Bundle into archive"),
267 ("repl", "Interactive REPL"),
267 ("repl", "Interactive REPL"),
268 ("feed", "Generate RSS feed"),
268 ("feed", "Generate RSS feed"),
269− ("components", "List @fn definitions"),
269+ ("components", "List component definitions"),
270 ("deps", "Show dependency graph"),
270 ("deps", "Show dependency graph"),
271 ("dead-code", "Find unused definitions"),
271 ("dead-code", "Find unused definitions"),
272 ("deploy", "Build and deploy"),
272 ("deploy", "Build and deploy"),
src/main.rs+29−39
@@ -2228,19 +2228,31 @@ fn main() {
2228 let mut found = Vec::new();
2228 let mut found = Vec::new();
2229 for (line_num, line) in input.lines().enumerate() {
2229 for (line_num, line) in input.lines().enumerate() {
2230 let trimmed = line.trim();
2230 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
2232 let parts: Vec<&str> = rest.split_whitespace().collect();
2233 let parts: Vec<&str> = rest.split_whitespace().collect();
2233 if let Some(&name) = parts.first() {
2234 if let Some(&name) = parts.first() {
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 {
2234− let params: Vec<&str> = parts[1..]
2244+ let params: Vec<&str> = parts[1..]
2235− .iter()
2245+ .iter()
2246+ .filter(|p| p.starts_with('$'))
2236− .map(|p| p.strip_prefix('$').unwrap_or(p))
2247+ .map(|p| p.strip_prefix('$').unwrap_or(p))
2237− .collect();
2248+ .collect();
2238− let param_str = if params.is_empty() {
2249+ let param_str = if params.is_empty() {
2239− String::new()
2250+ String::new()
2240− } else {
2251+ } else {
2241− format!(" ({})", params.join(", "))
2252+ format!(" ({})", params.join(", "))
2242− };
2253+ };
2243− found.push((line_num + 1, name.to_string(), param_str));
2254+ found.push((line_num + 1, name.to_string(), param_str));
2255+ }
2244 }
2256 }
2245 }
2257 }
2246 }
2258 }
@@ -2253,7 +2265,7 @@ fn main() {
2253 }
2265 }
2254 }
2266 }
2255 if total == 0 {
2267 if total == 0 {
2256− eprintln!("no @fn definitions found");
2268+ eprintln!("no component definitions found");
2257 } else {
2269 } else {
2258 eprintln!("\n{} component(s) found", total);
2270 eprintln!("\n{} component(s) found", total);
2259 }
2271 }
@@ -2343,9 +2355,7 @@ fn main() {
2343 process::exit(1);
2355 process::exit(1);
2344 }
2356 }
2345 // Pass 1: collect all definitions and usages across all files
2357 // 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)
2358+ let mut all_let_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();
2349 let mut all_refs: std::collections::HashSet<String> = std::collections::HashSet::new();
2359 let mut all_refs: std::collections::HashSet<String> = std::collections::HashSet::new();
2350 for file in &hl_files {
2360 for file in &hl_files {
2351 let input = match fs::read_to_string(file) {
2361 let input = match fs::read_to_string(file) {
@@ -2359,25 +2369,15 @@ fn main() {
2359 .to_string();
2369 .to_string();
2360 for (line_num, line) in input.lines().enumerate() {
2370 for (line_num, line) in input.lines().enumerate() {
2361 let trimmed = line.trim();
2371 let trimmed = line.trim();
2362− if let Some(rest) = trimmed.strip_prefix("@fn ") {
2372+ if let Some(rest) = trimmed.strip_prefix("@let ")
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 && let Some(name) = rest.split_whitespace().next()
2373 && let Some(name) = rest.split_whitespace().next()
2373 {
2374 {
2375+ let name = name.trim_end_matches('[');
2374 all_let_defs.push((name.to_string(), rel.clone(), line_num + 1));
2376 all_let_defs.push((name.to_string(), rel.clone(), line_num + 1));
2375 }
2377 }
2376 // Collect references: @name calls and $name usages
2378 // Collect references: @name calls and $name usages
2377 if trimmed.starts_with('@')
2379 if trimmed.starts_with('@')
2378− && !trimmed.starts_with("@fn ")
2379 && !trimmed.starts_with("@let ")
2380 && !trimmed.starts_with("@let ")
2380− && !trimmed.starts_with("@define ")
2381 && !trimmed.starts_with("@include ")
2381 && !trimmed.starts_with("@include ")
2382 && !trimmed.starts_with("@import ")
2382 && !trimmed.starts_with("@import ")
2383 && !trimmed.starts_with("@extends ")
2383 && !trimmed.starts_with("@extends ")
@@ -2415,16 +2415,6 @@ fn main() {
2415 }
2415 }
2416 // Pass 2: report unused
2416 // Pass 2: report unused
2417 let mut unused: Vec<(String, String, String, usize)> = Vec::new(); // (kind, name, file, line)
2417 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− }
2428 for (name, file, line) in &all_let_defs {
2418 for (name, file, line) in &all_let_defs {
2429 if !all_refs.contains(name) && !name.starts_with("--") {
2419 if !all_refs.contains(name) && !name.starts_with("--") {
2430 unused.push(("@let".to_string(), name.clone(), file.clone(), *line));
2420 unused.push(("@let".to_string(), name.clone(), file.clone(), *line));
@@ -3552,8 +3542,8 @@ compile();
3552 template.push('\n');
3542 template.push('\n');
3553 template.push('\n');
3543 template.push('\n');
3554
3544
3555− // Generate @fn definition
3545+ // Generate @let component definition
3556− template.push_str("@fn ");
3546+ template.push_str("@let ");
3557 template.push_str(comp_name);
3547 template.push_str(comp_name);
3558 for p in &params {
3548 for p in &params {
3559 template.push(' ');
3549 template.push(' ');
src/playground_default.hl+5−5
@@ -18,7 +18,7 @@
18@let gutter = $gap + $base
18@let gutter = $gap + $base
19
19
20-- Reusable attribute bundle
20-- Reusable attribute bundle
21−@define card [
21+@let card [
22 padding $gap,
22 padding $gap,
23 background white,
23 background white,
24 rounded $base,
24 rounded $base,
@@ -27,7 +27,7 @@
27]
27]
28
28
29-- Composable style group (used with spread)
29-- Composable style group (used with spread)
30−@mixin interactive [
30+@let interactive [
31 cursor pointer,
31 cursor pointer,
32 hover:shadow 0 4px 12px rgba(0,0,0,0.08),
32 hover:shadow 0 4px 12px rgba(0,0,0,0.08),
33 transition all 0.2s ease
33 transition all 0.2s ease
@@ -44,7 +44,7 @@
44"""
44"""
45
45
46-- Component with default parameter + @children slot
46-- Component with default parameter + @children slot
47−@fn card $title $icon=◆
47+@let card $title $icon=◆
48 @article [$card, ...$interactive, animate fade-up 0.3s ease]
48 @article [$card, ...$interactive, animate fade-up 0.3s ease]
49 @row [spacing 10, align-items center]
49 @row [spacing 10, align-items center]
50 @text [size 20, color $primary] $icon
50 @text [size 20, color $primary] $icon
@@ -52,7 +52,7 @@
52 @children
52 @children
53
53
54-- Component that wraps children in a titled section (function + slot)
54-- Component that wraps children in a titled section (function + slot)
55−@fn titled $title
55+@let titled $title
56 @section [spacing 12, padding-y $base]
56 @section [spacing 12, padding-y $base]
57 @text [bold, size 22, color $ink] $title
57 @text [bold, size 22, color $ink] $title
58 @children
58 @children
@@ -124,7 +124,7 @@
124 overflow-x auto
124 overflow-x auto
125 ]
125 ]
126 @raw """
126 @raw """
127−@fn button $label
127+@let button $label
128 @el [padding 12, background blue]
128 @el [padding 12, background blue]
129 @text $label
129 @text $label
130"""
130"""
tests/snapshots/fn_defaults.hl+1−1
@@ -1,4 +1,4 @@
1−@fn badge $label $color=blue
1+@let badge $label $color=blue
2 @el [padding 4 8, background $color, rounded 4]
2 @el [padding 4 8, background $color, rounded 4]
3 @text $label
3 @text $label
4
4
tests/snapshots/functions.hl+2−2
@@ -1,10 +1,10 @@
1@page Functions
1@page Functions
2−@fn card $title
2+@let card $title
3 @el [padding 20, rounded 8]
3 @el [padding 20, rounded 8]
4 @text [bold] $title
4 @text [bold] $title
5 @children
5 @children
6
6
7−@fn wrapper
7+@let wrapper
8 @column [spacing 10]
8 @column [spacing 10]
9 @children
9 @children
10
10
tests/snapshots/mixin_spread.hl+2−2
@@ -1,7 +1,7 @@
1@page Mixin Test
1@page Mixin Test
2
2
3−@mixin card-style [padding 20, rounded 8, background #f3f4f6]
3+@let card-style [padding 20, rounded 8, background #f3f4f6]
4−@mixin interactive [cursor pointer, hover:background #e5e7eb, transition all 0.15s ease]
4+@let interactive [cursor pointer, hover:background #e5e7eb, transition all 0.15s ease]
5
5
6@el [...$card-style, ...$interactive]
6@el [...$card-style, ...$interactive]
7 Hello mixin world
7 Hello mixin world
tests/snapshots/named_slots.hl+1−1
@@ -1,4 +1,4 @@
1−@fn layout
1+@let layout
2 @el [padding 20]
2 @el [padding 20]
3 @slot header
3 @slot header
4 @text Default Header
4 @text Default Header
tests/snapshots/stress_deeply_nested.hl+2−2
@@ -4,12 +4,12 @@
4@let --border #e2e8f0
4@let --border #e2e8f0
5@let --radius 8
5@let --radius 8
6
6
7−@fn deep-card $title
7+@let deep-card $title
8 @el [background white, dark:background #1e293b, rounded $--radius, padding 16, border 1 $--border]
8 @el [background white, dark:background #1e293b, rounded $--radius, padding 16, border 1 $--border]
9 @text [bold, size 16] $title
9 @text [bold, size 16] $title
10 @children
10 @children
11
11
12−@fn inner-panel $label
12+@let inner-panel $label
13 @el [background #f8fafc, dark:background #0f172a, rounded 6, padding 12, margin-top 8]
13 @el [background #f8fafc, dark:background #0f172a, rounded 6, padding 12, margin-top 8]
14 @text [size 13, color #64748b] $label
14 @text [size 13, color #64748b] $label
15 @children
15 @children
tests/snapshots/stress_large.hl+15−15
@@ -19,42 +19,42 @@
19@keyframes slide-down
19@keyframes slide-down
20 0%{transform:translateY(-10px);opacity:0}100%{transform:translateY(0);opacity:1}
20 0%{transform:translateY(-10px);opacity:0}100%{transform:translateY(0);opacity:1}
21
21
22−@fn icon-button $label $color=var(--primary)
22+@let icon-button $label $color=var(--primary)
23 @button [padding 8 16, background $color, hover:background $--dark, color white, rounded 6, border none, cursor pointer, transition all 0.2s, font-size 14]
23 @button [padding 8 16, background $color, hover:background $--dark, color white, rounded 6, border none, cursor pointer, transition all 0.2s, font-size 14]
24 $label
24 $label
25
25
26−@fn badge $label $variant=blue
26+@let badge $label $variant=blue
27 @el [display inline-block, padding 2 8, background $variant, color white, rounded 12, font-size 12]
27 @el [display inline-block, padding 2 8, background $variant, color white, rounded 12, font-size 12]
28 @text $label
28 @text $label
29
29
30−@fn card $title
30+@let card $title
31 @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]
31 @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]
32 @text [bold, size 18, color $--dark, dark:color white] $title
32 @text [bold, size 18, color $--dark, dark:color white] $title
33 @el [margin-top 8]
33 @el [margin-top 8]
34 @children
34 @children
35
35
36−@fn stat-card $label $value $change
36+@let stat-card $label $value $change
37 @el [background white, dark:background #1e293b, rounded $--radius, padding $--gap, border 1 $--border, dark:border 1 #334155]
37 @el [background white, dark:background #1e293b, rounded $--radius, padding $--gap, border 1 $--border, dark:border 1 #334155]
38 @text [size 14, color $--gray] $label
38 @text [size 14, color $--gray] $label
39 @text [bold, size 28, color $--dark, dark:color white, margin-top 4] $value
39 @text [bold, size 28, color $--dark, dark:color white, margin-top 4] $value
40 @text [size 12, color $--secondary, margin-top 2] $change
40 @text [size 12, color $--secondary, margin-top 2] $change
41
41
42−@fn nav-link $url $label $active=false
42+@let nav-link $url $label $active=false
43 @if $active == true
43 @if $active == true
44 @link [color $--primary, bold, padding 8 12, background #eff6ff, dark:background #1e3a5f, rounded 6] $url $label
44 @link [color $--primary, bold, padding 8 12, background #eff6ff, dark:background #1e3a5f, rounded 6] $url $label
45 @else
45 @else
46 @link [color $--gray, hover:color $--primary, padding 8 12, hover:background #f1f5f9, dark:hover:background #334155, rounded 6, transition all 0.15s] $url $label
46 @link [color $--gray, hover:color $--primary, padding 8 12, hover:background #f1f5f9, dark:hover:background #334155, rounded 6, transition all 0.15s] $url $label
47
47
48−@fn sidebar-section $title
48+@let sidebar-section $title
49 @el [margin-bottom $--gap-lg]
49 @el [margin-bottom $--gap-lg]
50 @text [bold, size 12, color $--gray, text-transform uppercase, letter-spacing 1, margin-bottom $--gap-sm] $title
50 @text [bold, size 12, color $--gray, text-transform uppercase, letter-spacing 1, margin-bottom $--gap-sm] $title
51 @children
51 @children
52
52
53−@fn avatar $initials $size=32
53+@let avatar $initials $size=32
54 @el [width $size, height $size, rounded 50%, background $--primary, color white, display flex, align-items center, justify-content center, font-size 14, bold]
54 @el [width $size, height $size, rounded 50%, background $--primary, color white, display flex, align-items center, justify-content center, font-size 14, bold]
55 @text $initials
55 @text $initials
56
56
57−@fn table-row $col1 $col2 $col3 $col4
57+@let table-row $col1 $col2 $col3 $col4
58 @row [spacing 0, padding 12 16, border-bottom 1 $--border, dark:border-bottom 1 #334155, hover:background #f8fafc, dark:hover:background #1e293b]
58 @row [spacing 0, padding 12 16, border-bottom 1 $--border, dark:border-bottom 1 #334155, hover:background #f8fafc, dark:hover:background #1e293b]
59 @el [width 200]
59 @el [width 200]
60 @text [size 14] $col1
60 @text [size 14] $col1
@@ -65,7 +65,7 @@
65 @el [width fill]
65 @el [width fill]
66 @text [size 14, color $--gray] $col4
66 @text [size 14, color $--gray] $col4
67
67
68−@fn breadcrumb-item $url $label $is-last=false
68+@let breadcrumb-item $url $label $is-last=false
69 @if $is-last == true
69 @if $is-last == true
70 @text [color $--dark, dark:color white, size 14] $label
70 @text [color $--dark, dark:color white, size 14] $label
71 @else
71 @else
@@ -73,7 +73,7 @@
73 @link [color $--gray, hover:color $--primary, size 14] $url $label
73 @link [color $--gray, hover:color $--primary, size 14] $url $label
74 @text [color $--border, size 14] /
74 @text [color $--border, size 14] /
75
75
76−@fn alert $message $type=info
76+@let alert $message $type=info
77 @if $type == info
77 @if $type == info
78 @el [background #eff6ff, border 1 #bfdbfe, rounded $--radius, padding 12 16]
78 @el [background #eff6ff, border 1 #bfdbfe, rounded $--radius, padding 12 16]
79 @text [color #1e40af, size 14] $message
79 @text [color #1e40af, size 14] $message
@@ -87,21 +87,21 @@
87 @el [background #fef2f2, border 1 #fecaca, rounded $--radius, padding 12 16]
87 @el [background #fef2f2, border 1 #fecaca, rounded $--radius, padding 12 16]
88 @text [color #991b1b, size 14] $message
88 @text [color #991b1b, size 14] $message
89
89
90−@fn progress-bar $percent $color=var(--primary)
90+@let progress-bar $percent $color=var(--primary)
91 @el [width fill, height 8, background #e2e8f0, dark:background #334155, rounded 4, overflow hidden]
91 @el [width fill, height 8, background #e2e8f0, dark:background #334155, rounded 4, overflow hidden]
92 @el [width $percent%, height fill, background $color, rounded 4]
92 @el [width $percent%, height fill, background $color, rounded 4]
93
93
94−@fn tag $label
94+@let tag $label
95 @el [display inline-block, padding 4 10, background #f1f5f9, dark:background #334155, color $--gray, dark:color #94a3b8, rounded 4, font-size 12, margin 2]
95 @el [display inline-block, padding 4 10, background #f1f5f9, dark:background #334155, color $--gray, dark:color #94a3b8, rounded 4, font-size 12, margin 2]
96 @text $label
96 @text $label
97
97
98−@fn notification-dot
98+@let notification-dot
99 @el [width 8, height 8, rounded 50%, background $--danger]
99 @el [width 8, height 8, rounded 50%, background $--danger]
100
100
101−@fn divider
101+@let divider
102 @el [width fill, height 1, background $--border, dark:background #334155, margin 8 0]
102 @el [width fill, height 1, background $--border, dark:background #334155, margin 8 0]
103
103
104−@fn toggle $checked=false
104+@let toggle $checked=false
105 @if $checked == true
105 @if $checked == true
106 @el [width 40, height 22, rounded 11, background $--primary, padding 2, cursor pointer, transition all 0.2s]
106 @el [width 40, height 22, rounded 11, background $--primary, padding 2, cursor pointer, transition all 0.2s]
107 @el [width 18, height 18, rounded 50%, background white, margin-left 18, transition all 0.2s]
107 @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 @@
1@page Variables
1@page Variables
2@let primary #3b82f6
2@let primary #3b82f6
3@let gap 20
3@let gap 20
4−@define card-style [padding 20, rounded 8, background white]
4+@let card-style [padding 20, rounded 8, background white]
5
5
6@column [spacing $gap]
6@column [spacing $gap]
7 @el [$card-style]
7 @el [$card-style]