on master help sign in
Add 9 improvements: named slots, @style blocks, @each ranges, unused warnings, element validation, LSP symbols/actions, container queries, and variable scoping
commitcd9c463fd4c17d7d78f0f51ead67dc77df899e4d authoriko <mail@iko.soy> dateWed 15 Apr 2026, 11:32 (6 months ago) parent4e4a559
9 files changed+796−15
src/ast.rs+3 src/bin/htmlang-lsp.rs+243 src/codegen.rs+35−1 src/parser.rs+281−14 tests/snapshots.rs+206 tests/snapshots/container_queries.hl+5 tests/snapshots/each_range.hl+2 tests/snapshots/named_slots.hl+13 tests/snapshots/style_block.hl+8
src/ast.rs+3
@@ -10,6 +10,7 @@ pub struct Document {
1010 pub defines: HashMap<String, Vec<Attribute>>,
1111 pub keyframes: Vec<(String, String)>,
1212 pub css_vars: Vec<(String, String)>,
13+ pub custom_css: Vec<String>,
1314 pub nodes: Vec<Node>,
1415}
1516
@@ -52,6 +53,8 @@ pub enum ElementKind {
5253 Textarea,
5354 Option,
5455 Label,
56+ // Slots
57+ Slot(String),
5558}
5659
5760#[derive(Debug, Clone)]
src/bin/htmlang-lsp.rs+243
@@ -66,6 +66,8 @@ impl LanguageServer for Backend {
6666 prepare_provider: Some(true),
6767 work_done_progress_options: Default::default(),
6868 })),
69+ document_symbol_provider: Some(OneOf::Left(true)),
70+ code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
6971 ..Default::default()
7072 },
7173 ..Default::default()
@@ -178,6 +180,46 @@ impl LanguageServer for Backend {
178180
179181 Ok(rename_at(&text, pos, &new_name, &uri))
180182 }
183+
184+ async fn document_symbol(
185+ &self,
186+ params: DocumentSymbolParams,
187+ ) -> Result<Option<DocumentSymbolResponse>> {
188+ let uri = &params.text_document.uri;
189+ let docs = self.documents.read().await;
190+ let text = match docs.get(uri) {
191+ Some(t) => t.clone(),
192+ None => return Ok(None),
193+ };
194+ drop(docs);
195+
196+ let symbols = document_symbols(&text);
197+ Ok(if symbols.is_empty() {
198+ None
199+ } else {
200+ Some(DocumentSymbolResponse::Flat(symbols))
201+ })
202+ }
203+
204+ async fn code_action(
205+ &self,
206+ params: CodeActionParams,
207+ ) -> Result<Option<CodeActionResponse>> {
208+ let uri = params.text_document.uri.clone();
209+ let docs = self.documents.read().await;
210+ let text = match docs.get(&uri) {
211+ Some(t) => t.clone(),
212+ None => return Ok(None),
213+ };
214+ drop(docs);
215+
216+ let actions = code_actions(&text, &params.range, &params.context.diagnostics, &uri);
217+ Ok(if actions.is_empty() {
218+ None
219+ } else {
220+ Some(actions)
221+ })
222+ }
181223}
182224
183225// ---------------------------------------------------------------------------
@@ -311,6 +353,7 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
311353 ("@label", "Label element"),
312354 ("@raw", "Raw HTML escape hatch"),
313355 ("@children", "Slot for caller's children (inside @fn)"),
356+ ("@slot", "Named slot inside @fn (e.g., @slot header)"),
314357 ]
315358 .iter()
316359 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
@@ -332,6 +375,8 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
332375 ("@import", "Import definitions only (no DOM nodes)", "@import "),
333376 ("@meta", "Add a <meta> tag to <head>", "@meta "),
334377 ("@head", "Add raw content to <head>", "@head"),
378+ ("@style", "Add raw CSS to stylesheet", "@style"),
379+ ("@slot", "Named slot in @fn for caller content", "@slot "),
335380 ]
336381 .iter()
337382 .map(|(name, detail, insert)| {
@@ -411,6 +456,10 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
411456 ("grid-rows", "Grid rows (number or template)", true),
412457 ("col-span", "Span columns in grid", true),
413458 ("row-span", "Span rows in grid", true),
459+ // Container queries
460+ ("container", "Enable container queries (inline-size)", false),
461+ ("container-name", "Container name for @container queries", true),
462+ ("container-type", "Container type (inline-size/size/normal)", true),
414463 // Identity
415464 ("id", "HTML id attribute", true),
416465 ("class", "HTML class attribute", true),
@@ -710,6 +759,8 @@ fn hover_builtin(word: &str) -> Option<String> {
710759 "@import" => "**@import** \u{2014} Import definitions\n\nImports `@let`, `@define`, `@fn` from another .hl file without emitting DOM nodes.\n\nUsage: `@import theme.hl`",
711760 "@meta" => "**@meta** \u{2014} Meta tag\n\nAdds a `<meta>` tag to `<head>`.\n\nUsage: `@meta description A portfolio site`",
712761 "@head" => "**@head** \u{2014} Head content\n\nAdds raw content to `<head>` (fonts, icons, etc.).\n\n```\n@head\n <link rel=\"icon\" href=\"favicon.ico\">\n```",
762+ "@style" => "**@style** \u{2014} Custom CSS\n\nAdds raw CSS to the stylesheet.\n\n```\n@style\n .custom { border: 1px solid red; }\n @container sidebar (min-width: 400px) { ... }\n```",
763+ "@slot" => "**@slot** \u{2014} Named slot\n\nDefines a named insertion point inside `@fn`. Callers fill it with `@slot name` + children.\n\n```\n@fn layout\n @slot header\n @children\n @slot footer\n```",
713764 // Attributes
714765 "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
715766 "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding. Supports CSS units. Accepts 1\u{2013}4 values.",
@@ -771,6 +822,9 @@ fn hover_builtin(word: &str) -> Option<String> {
771822 "id" => "**id** `<value>` \u{2014} HTML id attribute.",
772823 "class" => "**class** `<value>` \u{2014} HTML class attribute.",
773824 "animation" => "**animation** `<value>` \u{2014} CSS animation shorthand (e.g., `fade-in 0.3s ease`).\n\nDefine animations with `@keyframes`.",
825+ "container" => "**container** \u{2014} Enable container queries (`container-type: inline-size`).",
826+ "container-name" => "**container-name** `<value>` \u{2014} Name this container for `@container` queries.",
827+ "container-type" => "**container-type** `<value>` \u{2014} Container type (`inline-size`, `size`, `normal`).",
774828 // Form attributes
775829 "type" => "**type** `<value>` \u{2014} Input type (`text`, `email`, `password`, `submit`, etc.).",
776830 "placeholder" => "**placeholder** `<value>` \u{2014} Placeholder text for inputs.",
@@ -1092,6 +1146,195 @@ fn rename_at(text: &str, position: Position, new_name: &str, uri: &Url) -> Optio
10921146 })
10931147}
10941148
1149+// ---------------------------------------------------------------------------
1150+// Document symbols (outline view)
1151+// ---------------------------------------------------------------------------
1152+
1153+#[allow(deprecated)] // SymbolInformation::deprecated is deprecated but needed for the struct
1154+fn document_symbols(text: &str) -> Vec<SymbolInformation> {
1155+ let mut symbols = Vec::new();
1156+
1157+ for (i, line) in text.lines().enumerate() {
1158+ let trimmed = line.trim();
1159+ let line_num = i as u32;
1160+
1161+ // @fn definitions
1162+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
1163+ let parts: Vec<&str> = rest.split_whitespace().collect();
1164+ if let Some(name) = parts.first() {
1165+ let params = parts[1..].join(" ");
1166+ let detail = if params.is_empty() { None } else { Some(format!("({})", params)) };
1167+ symbols.push(SymbolInformation {
1168+ name: format!("@{}", name),
1169+ kind: SymbolKind::FUNCTION,
1170+ tags: None,
1171+ deprecated: None,
1172+ location: Location {
1173+ uri: Url::parse("file:///").unwrap(), // replaced by caller
1174+ range: Range::new(Position::new(line_num, 0), Position::new(line_num, line.len() as u32)),
1175+ },
1176+ container_name: detail,
1177+ });
1178+ }
1179+ }
1180+
1181+ // @let definitions
1182+ if let Some(rest) = trimmed.strip_prefix("@let ") {
1183+ if let Some((name, value)) = rest.trim().split_once(' ') {
1184+ symbols.push(SymbolInformation {
1185+ name: format!("${}", name),
1186+ kind: SymbolKind::VARIABLE,
1187+ tags: None,
1188+ deprecated: None,
1189+ location: Location {
1190+ uri: Url::parse("file:///").unwrap(),
1191+ range: Range::new(Position::new(line_num, 0), Position::new(line_num, line.len() as u32)),
1192+ },
1193+ container_name: Some(format!("= {}", value.trim())),
1194+ });
1195+ }
1196+ }
1197+
1198+ // @define definitions
1199+ if let Some(rest) = trimmed.strip_prefix("@define ") {
1200+ if let Some(bracket) = rest.find('[') {
1201+ let name = rest[..bracket].trim();
1202+ symbols.push(SymbolInformation {
1203+ name: format!("${}", name),
1204+ kind: SymbolKind::CONSTANT,
1205+ tags: None,
1206+ deprecated: None,
1207+ location: Location {
1208+ uri: Url::parse("file:///").unwrap(),
1209+ range: Range::new(Position::new(line_num, 0), Position::new(line_num, line.len() as u32)),
1210+ },
1211+ container_name: Some("attribute bundle".to_string()),
1212+ });
1213+ }
1214+ }
1215+
1216+ // @keyframes definitions
1217+ if let Some(rest) = trimmed.strip_prefix("@keyframes ") {
1218+ let name = rest.trim();
1219+ if !name.is_empty() {
1220+ symbols.push(SymbolInformation {
1221+ name: format!("@keyframes {}", name),
1222+ kind: SymbolKind::EVENT,
1223+ tags: None,
1224+ deprecated: None,
1225+ location: Location {
1226+ uri: Url::parse("file:///").unwrap(),
1227+ range: Range::new(Position::new(line_num, 0), Position::new(line_num, line.len() as u32)),
1228+ },
1229+ container_name: Some("animation".to_string()),
1230+ });
1231+ }
1232+ }
1233+ }
1234+
1235+ symbols
1236+}
1237+
1238+// ---------------------------------------------------------------------------
1239+// Code actions (quick-fixes for typo suggestions)
1240+// ---------------------------------------------------------------------------
1241+
1242+fn code_actions(
1243+ text: &str,
1244+ _range: &Range,
1245+ diagnostics: &[Diagnostic],
1246+ uri: &Url,
1247+) -> Vec<CodeActionOrCommand> {
1248+ let mut actions = Vec::new();
1249+
1250+ for diag in diagnostics {
1251+ let msg = &diag.message;
1252+
1253+ // Extract "did you mean 'X'?" or "did you mean @X?" suggestions
1254+ if let Some(suggestion) = extract_suggestion(msg) {
1255+ let line = diag.range.start.line as usize;
1256+ let lines: Vec<&str> = text.lines().collect();
1257+ if let Some(source_line) = lines.get(line) {
1258+ // Determine what to replace
1259+ let (old_text, new_text) = if msg.contains("unknown element") {
1260+ // Replace @wrong with @suggestion
1261+ let old = extract_between(msg, "unknown element @", ",")
1262+ .or_else(|| extract_between(msg, "unknown element @", ""));
1263+ if let Some(old) = old {
1264+ (format!("@{}", old), format!("@{}", suggestion))
1265+ } else {
1266+ continue;
1267+ }
1268+ } else if msg.contains("unknown attribute") {
1269+ // Replace wrong with suggestion in attribute list
1270+ let old = extract_between(msg, "unknown attribute '", "'");
1271+ if let Some(old) = old {
1272+ (old.to_string(), suggestion.to_string())
1273+ } else {
1274+ continue;
1275+ }
1276+ } else {
1277+ continue;
1278+ };
1279+
1280+ if let Some(col) = source_line.find(&old_text) {
1281+ let edit = TextEdit {
1282+ range: Range::new(
1283+ Position::new(diag.range.start.line, col as u32),
1284+ Position::new(diag.range.start.line, (col + old_text.len()) as u32),
1285+ ),
1286+ new_text: new_text.clone(),
1287+ };
1288+
1289+ let mut changes = HashMap::new();
1290+ changes.insert(uri.clone(), vec![edit]);
1291+
1292+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
1293+ title: format!("Replace with '{}'", new_text),
1294+ kind: Some(CodeActionKind::QUICKFIX),
1295+ diagnostics: Some(vec![diag.clone()]),
1296+ edit: Some(WorkspaceEdit {
1297+ changes: Some(changes),
1298+ ..Default::default()
1299+ }),
1300+ ..Default::default()
1301+ }));
1302+ }
1303+ }
1304+ }
1305+ }
1306+
1307+ actions
1308+}
1309+
1310+fn extract_suggestion(msg: &str) -> Option<&str> {
1311+ // "did you mean @X?" or "did you mean 'X'?"
1312+ if let Some(idx) = msg.find("did you mean @") {
1313+ let start = idx + "did you mean @".len();
1314+ let rest = &msg[start..];
1315+ let end = rest.find('?').unwrap_or(rest.len());
1316+ return Some(&rest[..end]);
1317+ }
1318+ if let Some(idx) = msg.find("did you mean '") {
1319+ let start = idx + "did you mean '".len();
1320+ let rest = &msg[start..];
1321+ let end = rest.find('\'')?;
1322+ return Some(&rest[..end]);
1323+ }
1324+ None
1325+}
1326+
1327+fn extract_between<'a>(msg: &'a str, prefix: &str, suffix: &str) -> Option<&'a str> {
1328+ let start = msg.find(prefix)? + prefix.len();
1329+ let rest = &msg[start..];
1330+ if suffix.is_empty() {
1331+ Some(rest.trim())
1332+ } else {
1333+ let end = rest.find(suffix)?;
1334+ Some(&rest[..end])
1335+ }
1336+}
1337+
10951338// ---------------------------------------------------------------------------
10961339// Entry point
10971340// ---------------------------------------------------------------------------
src/codegen.rs+35−1
@@ -203,6 +203,22 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
203203 }
204204 }
205205
206+ // @style blocks (custom CSS)
207+ for block in &doc.custom_css {
208+ if dev {
209+ element_css.push_str(block);
210+ element_css.push('\n');
211+ } else {
212+ // Minify: collapse whitespace
213+ let minified: String = block
214+ .lines()
215+ .map(|l| l.trim())
216+ .collect::<Vec<_>>()
217+ .join("");
218+ element_css.push_str(&minified);
219+ }
220+ }
221+
206222 // Build meta tags string
207223 let meta_html = if doc.meta_tags.is_empty() {
208224 String::new()
@@ -379,6 +395,9 @@ fn generate_element(
379395 if elem.kind == ElementKind::Children {
380396 return;
381397 }
398+ if matches!(elem.kind, ElementKind::Slot(_)) {
399+ return;
400+ }
382401
383402 let tag = match elem.kind {
384403 ElementKind::Row | ElementKind::Column | ElementKind::El => "div",
@@ -390,7 +409,7 @@ fn generate_element(
390409 ElementKind::Textarea => "textarea",
391410 ElementKind::Option => "option",
392411 ElementKind::Label => "label",
393− ElementKind::Image | ElementKind::Input | ElementKind::Children => unreachable!(),
412+ ElementKind::Image | ElementKind::Input | ElementKind::Children | ElementKind::Slot(_) => unreachable!(),
394413 };
395414
396415 let kind_label = match elem.kind {
@@ -979,6 +998,21 @@ fn attrs_to_css(
979998 }
980999 }
9811000
1001+ // Container queries
1002+ "container" => {
1003+ push_css(&mut css, "container-type", "inline-size");
1004+ }
1005+ "container-name" => {
1006+ if let Some(v) = val {
1007+ push_css(&mut css, "container-name", v);
1008+ }
1009+ }
1010+ "container-type" => {
1011+ if let Some(v) = val {
1012+ push_css(&mut css, "container-type", v);
1013+ }
1014+ }
1015+
9821016 // Identity and HTML passthrough — not CSS
9831017 "id" | "class" => {}
9841018 "type" | "placeholder" | "name" | "value" | "disabled" | "required"
src/parser.rs+281−14
@@ -1,4 +1,4 @@
1−use std::collections::HashMap;
1+use std::collections::{HashMap, HashSet};
22use std::fmt;
33use std::path::{Path, PathBuf};
44
@@ -73,12 +73,25 @@ struct ParseContext {
7373 functions: HashMap<String, FnDef>,
7474 keyframes: Vec<(String, String)>,
7575 css_vars: Vec<(String, String)>,
76+ custom_css: Vec<String>,
7677 diagnostics: Vec<Diagnostic>,
7778 base_path: Option<PathBuf>,
7879 included_files: Vec<PathBuf>,
7980 include_stack: Vec<PathBuf>,
8081 fn_call_stack: Vec<String>,
8182 file_cache: HashMap<PathBuf, String>,
83+ /// Track which @let variables are referenced (for unused warnings)
84+ used_variables: HashSet<String>,
85+ /// Track which @fn functions are called (for unused warnings)
86+ used_functions: HashSet<String>,
87+ /// Track which @define bundles are referenced (for unused warnings)
88+ used_defines: HashSet<String>,
89+ /// Line numbers of @let definitions (name -> line)
90+ let_lines: HashMap<String, usize>,
91+ /// Line numbers of @fn definitions (name -> line)
92+ fn_lines: HashMap<String, usize>,
93+ /// Line numbers of @define definitions (name -> line)
94+ define_lines: HashMap<String, usize>,
8295}
8396
8497struct Parser {
@@ -102,15 +115,23 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
102115 functions: HashMap::new(),
103116 keyframes: Vec::new(),
104117 css_vars: Vec::new(),
118+ custom_css: Vec::new(),
105119 diagnostics: Vec::new(),
106120 base_path: base_path.map(|p| p.to_path_buf()),
107121 included_files: Vec::new(),
108122 include_stack: Vec::new(),
109123 fn_call_stack: Vec::new(),
110124 file_cache: HashMap::new(),
125+ used_variables: HashSet::new(),
126+ used_functions: HashSet::new(),
127+ used_defines: HashSet::new(),
128+ let_lines: HashMap::new(),
129+ fn_lines: HashMap::new(),
130+ define_lines: HashMap::new(),
111131 };
112132 let nodes = parser.parse_children(0, &mut ctx);
113133 validate_tree(&nodes, None, &mut ctx.diagnostics);
134+ check_unused(&mut ctx);
114135 ParseResult {
115136 document: Document {
116137 page_title: ctx.page_title,
@@ -120,6 +141,7 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
120141 defines: ctx.defines,
121142 keyframes: ctx.keyframes,
122143 css_vars: ctx.css_vars,
144+ custom_css: ctx.custom_css,
123145 nodes,
124146 },
125147 diagnostics: ctx.diagnostics,
@@ -293,12 +315,14 @@ impl Parser {
293315 if let Some(rest) = content.strip_prefix("@let ") {
294316 let rest = rest.trim();
295317 if let Some((name, value)) = rest.split_once(' ') {
318+ track_var_refs(value.trim(), &mut ctx.used_variables);
296319 let value = substitute_vars(value.trim(), &ctx.variables);
297320 if name.starts_with("--") {
298321 // CSS custom property
299322 ctx.css_vars.push((name.to_string(), value.clone()));
300323 }
301324 ctx.variables.insert(name.to_string(), value);
325+ ctx.let_lines.entry(name.to_string()).or_insert(line_num);
302326 }
303327 return Ok(None);
304328 }
@@ -335,6 +359,29 @@ impl Parser {
335359 return Ok(None);
336360 }
337361
362+ // --- @style block (raw CSS) ---
363+ if content.trim() == "@style" {
364+ let mut style_content = String::new();
365+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
366+ match &self.lines[self.pos].content {
367+ LineContent::Normal(s) => {
368+ style_content.push_str(s.trim());
369+ style_content.push('\n');
370+ }
371+ LineContent::Raw(s) => {
372+ style_content.push_str(s);
373+ style_content.push('\n');
374+ }
375+ }
376+ self.pos += 1;
377+ }
378+ let trimmed = style_content.trim().to_string();
379+ if !trimmed.is_empty() {
380+ ctx.custom_css.push(trimmed);
381+ }
382+ return Ok(None);
383+ }
384+
338385 if let Some(rest) = content.strip_prefix("@define ") {
339386 let rest = rest.trim();
340387 if let Some(bracket_start) = rest.find('[') {
@@ -342,6 +389,7 @@ impl Parser {
342389 let attrs_str = &rest[bracket_start..];
343390 let (attrs, _) = parse_attr_brackets(attrs_str, line_num, ctx)?;
344391 ctx.defines.insert(name.to_string(), attrs);
392+ ctx.define_lines.entry(name.to_string()).or_insert(line_num);
345393 }
346394 return Ok(None);
347395 }
@@ -536,11 +584,14 @@ impl Parser {
536584 })
537585 .collect();
538586
587+ // Scope variables: @let inside @if doesn't leak out
588+ let saved_vars = ctx.variables.clone();
539589 let mut body_parser = Parser {
540590 lines: adjusted,
541591 pos: 0,
542592 };
543593 let nodes = body_parser.parse_children(0, ctx);
594+ ctx.variables = saved_vars;
544595 return Ok(Some(nodes));
545596 }
546597
@@ -574,11 +625,21 @@ impl Parser {
574625 };
575626
576627 let list_str = substitute_vars(&list_str, &ctx.variables);
577− let items: Vec<String> = list_str
578− .split(',')
579− .map(|s| s.trim().to_string())
580− .filter(|s| !s.is_empty())
581− .collect();
628+ track_var_refs(&list_str, &mut ctx.used_variables);
629+ // Support range syntax: @each $i in 1..5
630+ let items: Vec<String> = if let Some((start_s, end_s)) = list_str.split_once("..") {
631+ if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.trim().parse::<i64>()) {
632+ if start <= end {
633+ (start..=end).map(|n| n.to_string()).collect()
634+ } else {
635+ (end..=start).rev().map(|n| n.to_string()).collect()
636+ }
637+ } else {
638+ list_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
639+ }
640+ } else {
641+ list_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
642+ };
582643
583644 // Collect body lines
584645 let mut body_lines = Vec::new();
@@ -674,6 +735,7 @@ impl Parser {
674735 self.pos += 1;
675736 }
676737
738+ ctx.fn_lines.entry(name.clone()).or_insert(line_num);
677739 ctx.functions.insert(name, FnDef { params, defaults, body_lines });
678740 return Ok(None);
679741 }
@@ -683,6 +745,7 @@ impl Parser {
683745 if content.starts_with('@') {
684746 let name = extract_element_name(&content);
685747 if ctx.functions.contains_key(name) {
748+ ctx.used_functions.insert(name.to_string());
686749 let nodes =
687750 self.expand_fn_call(name, &content, current_indent, line_num, ctx)?;
688751 return Ok(Some(nodes));
@@ -737,8 +800,21 @@ impl Parser {
737800 // Clone function definition (releases borrow on ctx)
738801 let fn_def = ctx.functions.get(name).unwrap().clone();
739802
740− // Parse caller's children
741− let caller_children = self.parse_children(current_indent + 1, ctx);
803+ // Parse caller's children, separating named slots from default children
804+ let all_caller_children = self.parse_children(current_indent + 1, ctx);
805+ let mut slot_contents: HashMap<String, Vec<Node>> = HashMap::new();
806+ let mut caller_children = Vec::new();
807+ for child in all_caller_children {
808+ if let Node::Element(ref elem) = child {
809+ if let ElementKind::Slot(ref slot_name) = elem.kind {
810+ if !slot_name.is_empty() {
811+ slot_contents.entry(slot_name.clone()).or_default().extend(elem.children.clone());
812+ continue;
813+ }
814+ }
815+ }
816+ caller_children.push(child);
817+ }
742818
743819 // Save variable state, inject function parameters
744820 let saved_vars = ctx.variables.clone();
@@ -781,8 +857,8 @@ impl Parser {
781857 ctx.variables = saved_vars;
782858 ctx.fn_call_stack.pop();
783859
784− // Replace @children with caller's children
785− Ok(replace_children_nodes(body_nodes, &caller_children))
860+ // Replace @children with caller's children and @slot with slot content
861+ Ok(replace_children_and_slots(body_nodes, &caller_children, &slot_contents))
786862 }
787863
788864 fn parse_element_line(
@@ -819,15 +895,30 @@ impl Parser {
819895// @children replacement
820896// ---------------------------------------------------------------------------
821897
822−fn replace_children_nodes(nodes: Vec<Node>, caller_children: &[Node]) -> Vec<Node> {
898+fn replace_children_and_slots(
899+ nodes: Vec<Node>,
900+ caller_children: &[Node],
901+ slot_contents: &HashMap<String, Vec<Node>>,
902+) -> Vec<Node> {
823903 let mut result = Vec::new();
824904 for node in nodes {
825905 match node {
826906 Node::Element(elem) if elem.kind == ElementKind::Children => {
827907 result.extend(caller_children.iter().cloned());
828908 }
909+ Node::Element(elem) if matches!(&elem.kind, ElementKind::Slot(name) if !name.is_empty()) => {
910+ if let ElementKind::Slot(ref name) = elem.kind {
911+ if let Some(content) = slot_contents.get(name) {
912+ result.extend(content.iter().cloned());
913+ }
914+ // If no content provided for this slot, use the slot's own children as default
915+ else if !elem.children.is_empty() {
916+ result.extend(elem.children);
917+ }
918+ }
919+ }
829920 Node::Element(mut elem) => {
830− elem.children = replace_children_nodes(elem.children, caller_children);
921+ elem.children = replace_children_and_slots(elem.children, caller_children, slot_contents);
831922 result.push(Node::Element(elem));
832923 }
833924 other => result.push(other),
@@ -905,6 +996,13 @@ fn parse_single_element(
905996 Some(substitute_vars(&rest, &ctx.variables))
906997 };
907998
999+ // For @slot, the argument is the slot name
1000+ let kind = if let ElementKind::Slot(_) = kind {
1001+ ElementKind::Slot(argument.clone().unwrap_or_default())
1002+ } else {
1003+ kind
1004+ };
1005+
9081006 Ok(Element {
9091007 kind,
9101008 attrs,
@@ -916,12 +1014,12 @@ fn parse_single_element(
9161014
9171015const KNOWN_ELEMENTS: &[&str] = &[
9181016 "row", "column", "col", "el", "text", "paragraph", "p", "image", "img", "link", "children",
919− "input", "button", "select", "textarea", "option", "label",
1017+ "input", "button", "select", "textarea", "option", "label", "slot",
9201018];
9211019
9221020const KNOWN_DIRECTIVES: &[&str] = &[
9231021 "page", "let", "define", "fn", "include", "import", "raw", "keyframes",
924− "if", "else", "each", "meta", "head",
1022+ "if", "else", "each", "meta", "head", "style",
9251023];
9261024
9271025fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
@@ -940,6 +1038,7 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
9401038 "textarea" => Ok(ElementKind::Textarea),
9411039 "option" | "opt" => Ok(ElementKind::Option),
9421040 "label" => Ok(ElementKind::Label),
1041+ "slot" => Ok(ElementKind::Slot(String::new())), // slot name filled in by parse_single_element
9431042 _ => {
9441043 let all_known: Vec<&str> = KNOWN_ELEMENTS
9451044 .iter()
@@ -1015,6 +1114,8 @@ const KNOWN_ATTRS: &[&str] = &[
10151114 "transform", "backdrop-filter",
10161115 // Grid
10171116 "grid", "grid-cols", "grid-rows", "col-span", "row-span",
1117+ // Container queries
1118+ "container", "container-name", "container-type",
10181119 // Identity
10191120 "id", "class",
10201121 // Animation
@@ -1195,12 +1296,14 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
11951296 if part.starts_with('$') {
11961297 let name = &part[1..];
11971298 if let Some(define_attrs) = ctx.defines.get(name) {
1299+ ctx.used_defines.insert(name.to_string());
11981300 attrs.extend(define_attrs.clone());
11991301 continue;
12001302 }
12011303 }
12021304
12031305 // Substitute variables in value
1306+ track_var_refs(part, &mut ctx.used_variables);
12041307 let part = substitute_vars(part, &ctx.variables);
12051308
12061309 let attr = if let Some((key, value)) = part.split_once(' ') {
@@ -1429,6 +1532,37 @@ fn strip_all_prefixes(key: &str) -> &str {
14291532 .unwrap_or(key)
14301533}
14311534
1535+/// Attributes that only make sense on container elements (@row, @column, @el).
1536+const CONTAINER_ONLY_ATTRS: &[&str] = &[
1537+ "spacing", "gap", "gap-x", "gap-y", "wrap",
1538+ "grid", "grid-cols", "grid-rows",
1539+ "container", "container-name", "container-type",
1540+];
1541+
1542+fn element_kind_name(kind: &ElementKind) -> &'static str {
1543+ match kind {
1544+ ElementKind::Row => "@row",
1545+ ElementKind::Column => "@column",
1546+ ElementKind::El => "@el",
1547+ ElementKind::Text => "@text",
1548+ ElementKind::Paragraph => "@paragraph",
1549+ ElementKind::Image => "@image",
1550+ ElementKind::Link => "@link",
1551+ ElementKind::Children => "@children",
1552+ ElementKind::Input => "@input",
1553+ ElementKind::Button => "@button",
1554+ ElementKind::Select => "@select",
1555+ ElementKind::Textarea => "@textarea",
1556+ ElementKind::Option => "@option",
1557+ ElementKind::Label => "@label",
1558+ ElementKind::Slot(_) => "@slot",
1559+ }
1560+}
1561+
1562+fn is_container(kind: &ElementKind) -> bool {
1563+ matches!(kind, ElementKind::Row | ElementKind::Column | ElementKind::El)
1564+}
1565+
14321566fn validate_tree(
14331567 nodes: &[Node],
14341568 parent_kind: Option<&ElementKind>,
@@ -1461,6 +1595,62 @@ fn validate_tree(
14611595 });
14621596 }
14631597 }
1598+
1599+ // Container-only attributes on non-container elements
1600+ if CONTAINER_ONLY_ATTRS.contains(&base) && !is_container(&elem.kind) {
1601+ diagnostics.push(Diagnostic {
1602+ line: elem.line_num,
1603+ message: format!(
1604+ "'{}' has no effect on {} (only works on @row, @column, @el)",
1605+ base, element_kind_name(&elem.kind)
1606+ ),
1607+ severity: Severity::Warning,
1608+ source_line: None,
1609+ });
1610+ }
1611+
1612+ // Form-specific: placeholder only on @input/@textarea
1613+ if base == "placeholder"
1614+ && !matches!(elem.kind, ElementKind::Input | ElementKind::Textarea)
1615+ {
1616+ diagnostics.push(Diagnostic {
1617+ line: elem.line_num,
1618+ message: format!(
1619+ "'placeholder' has no effect on {} (only works on @input, @textarea)",
1620+ element_kind_name(&elem.kind)
1621+ ),
1622+ severity: Severity::Warning,
1623+ source_line: None,
1624+ });
1625+ }
1626+
1627+ // 'for' only on @label
1628+ if base == "for" && !matches!(elem.kind, ElementKind::Label) {
1629+ diagnostics.push(Diagnostic {
1630+ line: elem.line_num,
1631+ message: format!(
1632+ "'for' has no effect on {} (only works on @label)",
1633+ element_kind_name(&elem.kind)
1634+ ),
1635+ severity: Severity::Warning,
1636+ source_line: None,
1637+ });
1638+ }
1639+
1640+ // 'rows'/'cols' only on @textarea
1641+ if (base == "rows" || base == "cols")
1642+ && !matches!(elem.kind, ElementKind::Textarea)
1643+ {
1644+ diagnostics.push(Diagnostic {
1645+ line: elem.line_num,
1646+ message: format!(
1647+ "'{}' has no effect on {} (only works on @textarea)",
1648+ base, element_kind_name(&elem.kind)
1649+ ),
1650+ severity: Severity::Warning,
1651+ source_line: None,
1652+ });
1653+ }
14641654 }
14651655 validate_tree(&elem.children, Some(&elem.kind), diagnostics);
14661656 }
@@ -1508,3 +1698,80 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
15081698
15091699 result
15101700}
1701+
1702+// ---------------------------------------------------------------------------
1703+// Variable usage tracking
1704+// ---------------------------------------------------------------------------
1705+
1706+/// Scan a string for $name references and record them in the used set.
1707+fn track_var_refs(input: &str, used: &mut HashSet<String>) {
1708+ if !input.contains('$') {
1709+ return;
1710+ }
1711+ let chars: Vec<char> = input.chars().collect();
1712+ let mut i = 0;
1713+ while i < chars.len() {
1714+ if chars[i] == '$'
1715+ && i + 1 < chars.len()
1716+ && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
1717+ {
1718+ let start = i + 1;
1719+ let mut end = start;
1720+ while end < chars.len()
1721+ && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
1722+ {
1723+ end += 1;
1724+ }
1725+ let name: String = chars[start..end].iter().collect();
1726+ used.insert(name);
1727+ i = end;
1728+ } else {
1729+ i += 1;
1730+ }
1731+ }
1732+}
1733+
1734+// ---------------------------------------------------------------------------
1735+// Unused definition warnings
1736+// ---------------------------------------------------------------------------
1737+
1738+fn check_unused(ctx: &mut ParseContext) {
1739+ // Check unused @let variables
1740+ for (name, &line) in &ctx.let_lines {
1741+ if name.starts_with("--") {
1742+ continue; // CSS vars are always used
1743+ }
1744+ if !ctx.used_variables.contains(name) {
1745+ ctx.diagnostics.push(Diagnostic {
1746+ line,
1747+ message: format!("unused variable '${}' (defined but never referenced)", name),
1748+ severity: Severity::Warning,
1749+ source_line: None,
1750+ });
1751+ }
1752+ }
1753+
1754+ // Check unused @define bundles
1755+ for (name, &line) in &ctx.define_lines {
1756+ if !ctx.used_defines.contains(name) {
1757+ ctx.diagnostics.push(Diagnostic {
1758+ line,
1759+ message: format!("unused define '${}' (defined but never referenced)", name),
1760+ severity: Severity::Warning,
1761+ source_line: None,
1762+ });
1763+ }
1764+ }
1765+
1766+ // Check unused @fn functions
1767+ for (name, &line) in &ctx.fn_lines {
1768+ if !ctx.used_functions.contains(name) {
1769+ ctx.diagnostics.push(Diagnostic {
1770+ line,
1771+ message: format!("unused function '@{}' (defined but never called)", name),
1772+ severity: Severity::Warning,
1773+ source_line: None,
1774+ });
1775+ }
1776+ }
1777+}
tests/snapshots.rs+206
@@ -172,6 +172,26 @@ fn snapshot_each_index() {
172172 snapshot_test("each_index");
173173}
174174
175+#[test]
176+fn snapshot_style_block() {
177+ snapshot_test("style_block");
178+}
179+
180+#[test]
181+fn snapshot_named_slots() {
182+ snapshot_test("named_slots");
183+}
184+
185+#[test]
186+fn snapshot_each_range() {
187+ snapshot_test("each_range");
188+}
189+
190+#[test]
191+fn snapshot_container_queries() {
192+ snapshot_test("container_queries");
193+}
194+
175195// ---------------------------------------------------------------------------
176196// Error case tests
177197// ---------------------------------------------------------------------------
@@ -382,3 +402,189 @@ fn aria_data_attrs_accepted() {
382402 diags
383403 );
384404}
405+
406+// ---------------------------------------------------------------------------
407+// New feature tests
408+// ---------------------------------------------------------------------------
409+
410+// --- Unused variable/function/define warnings ---
411+
412+#[test]
413+fn warning_unused_variable() {
414+ let diags = parse_diagnostics("@let color red\n@el [padding 10]");
415+ assert!(
416+ diags.iter().any(|d| d.message.contains("unused variable") && d.message.contains("color")),
417+ "expected unused variable warning, got: {:?}",
418+ diags
419+ );
420+}
421+
422+#[test]
423+fn no_warning_used_variable() {
424+ let diags = parse_diagnostics("@let color red\n@el [background $color]");
425+ assert!(
426+ !diags.iter().any(|d| d.message.contains("unused variable")),
427+ "should not warn about used variable, got: {:?}",
428+ diags
429+ );
430+}
431+
432+#[test]
433+fn warning_unused_function() {
434+ let diags = parse_diagnostics("@fn card\n @el [padding 10]\n@el");
435+ assert!(
436+ diags.iter().any(|d| d.message.contains("unused function") && d.message.contains("card")),
437+ "expected unused function warning, got: {:?}",
438+ diags
439+ );
440+}
441+
442+#[test]
443+fn no_warning_used_function() {
444+ let diags = parse_diagnostics("@fn card\n @el [padding 10]\n@card");
445+ assert!(
446+ !diags.iter().any(|d| d.message.contains("unused function")),
447+ "should not warn about used function, got: {:?}",
448+ diags
449+ );
450+}
451+
452+#[test]
453+fn warning_unused_define() {
454+ let diags = parse_diagnostics("@define card-style [padding 10]\n@el");
455+ assert!(
456+ diags.iter().any(|d| d.message.contains("unused define")),
457+ "expected unused define warning, got: {:?}",
458+ diags
459+ );
460+}
461+
462+#[test]
463+fn no_warning_used_define() {
464+ let diags = parse_diagnostics("@define card-style [padding 10]\n@el [$card-style]");
465+ assert!(
466+ !diags.iter().any(|d| d.message.contains("unused define")),
467+ "should not warn about used define, got: {:?}",
468+ diags
469+ );
470+}
471+
472+// --- Element-specific attribute validation ---
473+
474+#[test]
475+fn warning_spacing_on_text() {
476+ let diags = parse_diagnostics("@text [spacing 10] hello");
477+ assert!(
478+ diags.iter().any(|d| d.message.contains("spacing") && d.message.contains("no effect")),
479+ "expected spacing on @text warning, got: {:?}",
480+ diags
481+ );
482+}
483+
484+#[test]
485+fn no_warning_spacing_on_row() {
486+ let diags = parse_diagnostics("@row [spacing 10]");
487+ assert!(
488+ !diags.iter().any(|d| d.message.contains("spacing") && d.message.contains("no effect")),
489+ "should not warn about spacing on @row, got: {:?}",
490+ diags
491+ );
492+}
493+
494+#[test]
495+fn warning_placeholder_on_row() {
496+ let diags = parse_diagnostics("@row [placeholder test]");
497+ assert!(
498+ diags.iter().any(|d| d.message.contains("placeholder") && d.message.contains("no effect")),
499+ "expected placeholder on @row warning, got: {:?}",
500+ diags
501+ );
502+}
503+
504+#[test]
505+fn warning_for_on_non_label() {
506+ let diags = parse_diagnostics("@el [for email]");
507+ assert!(
508+ diags.iter().any(|d| d.message.contains("'for'") && d.message.contains("@label")),
509+ "expected 'for' on non-label warning, got: {:?}",
510+ diags
511+ );
512+}
513+
514+// --- @each ranges ---
515+
516+#[test]
517+fn each_range_basic() {
518+ let output = compile("@each $i in 1..3\n @text $i");
519+ assert!(output.contains("1"));
520+ assert!(output.contains("2"));
521+ assert!(output.contains("3"));
522+}
523+
524+#[test]
525+fn each_range_with_index() {
526+ let output = compile("@each $n, $i in 1..3\n @text $i");
527+ assert!(output.contains("0"));
528+ assert!(output.contains("1"));
529+ assert!(output.contains("2"));
530+}
531+
532+// --- Named slots ---
533+
534+#[test]
535+fn named_slot_basic() {
536+ let output = compile("@fn card\n @el\n @slot header\n @children\n@card\n @slot header\n @text Title\n @text Body");
537+ assert!(output.contains("Title"));
538+ assert!(output.contains("Body"));
539+}
540+
541+#[test]
542+fn named_slot_default_content() {
543+ let output = compile("@fn card\n @el\n @slot header\n @text Default\n @children\n@card\n @text Body");
544+ assert!(output.contains("Default"));
545+ assert!(output.contains("Body"));
546+}
547+
548+// --- @style block ---
549+
550+#[test]
551+fn style_block_output() {
552+ let output = compile("@page Test\n@style\n .custom { color: red; }\n@el [class custom]\n @text styled");
553+ assert!(output.contains(".custom{color:red;}") || output.contains(".custom { color: red; }"));
554+ assert!(output.contains("styled"));
555+}
556+
557+// --- Container queries ---
558+
559+#[test]
560+fn container_attr() {
561+ let output = compile("@page T\n@el [container]");
562+ assert!(output.contains("container-type:inline-size"));
563+}
564+
565+#[test]
566+fn container_name_attr() {
567+ let output = compile("@page T\n@el [container-name sidebar]");
568+ assert!(output.contains("container-name:sidebar"));
569+}
570+
571+// --- Variable scoping in @if ---
572+
573+#[test]
574+fn if_block_scopes_variables() {
575+ let output = compile("@let x before\n@if true\n @let x inside\n@text $x");
576+ // $x should be "before" outside the @if block
577+ assert!(output.contains("before"));
578+}
579+
580+// --- CSS custom vars not warned as unused ---
581+
582+#[test]
583+fn css_var_not_warned_unused() {
584+ let diags = parse_diagnostics("@let --primary blue");
585+ assert!(
586+ !diags.iter().any(|d| d.message.contains("unused")),
587+ "CSS vars should not be warned as unused, got: {:?}",
588+ diags
589+ );
590+}
tests/snapshots/container_queries.hlnew file+5
@@ -0,0 +1,5 @@
1+@page Container Test
2+
3+@el [container, container-name sidebar]
4+ @el [width 200]
5+ @text Sidebar content
tests/snapshots/each_range.hlnew file+2
@@ -0,0 +1,2 @@
1+@each $i in 1..5
2+ @text $i
tests/snapshots/named_slots.hlnew file+13
@@ -0,0 +1,13 @@
1+@fn layout
2+ @el [padding 20]
3+ @slot header
4+ @text Default Header
5+ @children
6+ @slot footer
7+
8+@layout
9+ @slot header
10+ @text [bold] Custom Header
11+ @slot footer
12+ @text Footer content
13+ @text Main body
tests/snapshots/style_block.hlnew file+8
@@ -0,0 +1,8 @@
1+@page Style Test
2+
3+@style
4+ .highlight { background: yellow; border: 2px solid orange; }
5+ .highlight:hover { background: gold; }
6+
7+@el [class highlight]
8+ @text Custom styled