@@ -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+}