@@ -1,4 +1,4 @@
1−use std::collections::HashMap;
1+use std::collections::{HashMap, HashSet};
2use std::fmt;
2use std::fmt;
3use std::path::{Path, PathBuf};
3use std::path::{Path, PathBuf};
@@ -73,12 +73,25 @@ struct ParseContext {
73 functions: HashMap<String, FnDef>,
73 functions: HashMap<String, FnDef>,
74 keyframes: Vec<(String, String)>,
74 keyframes: Vec<(String, String)>,
75 css_vars: Vec<(String, String)>,
75 css_vars: Vec<(String, String)>,
76+ custom_css: Vec<String>,
76 diagnostics: Vec<Diagnostic>,
77 diagnostics: Vec<Diagnostic>,
77 base_path: Option<PathBuf>,
78 base_path: Option<PathBuf>,
78 included_files: Vec<PathBuf>,
79 included_files: Vec<PathBuf>,
79 include_stack: Vec<PathBuf>,
80 include_stack: Vec<PathBuf>,
80 fn_call_stack: Vec<String>,
81 fn_call_stack: Vec<String>,
81 file_cache: HashMap<PathBuf, String>,
82 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>,
84struct Parser {
97struct Parser {
@@ -102,15 +115,23 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
102 functions: HashMap::new(),
115 functions: HashMap::new(),
103 keyframes: Vec::new(),
116 keyframes: Vec::new(),
104 css_vars: Vec::new(),
117 css_vars: Vec::new(),
118+ custom_css: Vec::new(),
105 diagnostics: Vec::new(),
119 diagnostics: Vec::new(),
106 base_path: base_path.map(|p| p.to_path_buf()),
120 base_path: base_path.map(|p| p.to_path_buf()),
107 included_files: Vec::new(),
121 included_files: Vec::new(),
108 include_stack: Vec::new(),
122 include_stack: Vec::new(),
109 fn_call_stack: Vec::new(),
123 fn_call_stack: Vec::new(),
110 file_cache: HashMap::new(),
124 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(),
112 let nodes = parser.parse_children(0, &mut ctx);
132 let nodes = parser.parse_children(0, &mut ctx);
113 validate_tree(&nodes, None, &mut ctx.diagnostics);
133 validate_tree(&nodes, None, &mut ctx.diagnostics);
134+ check_unused(&mut ctx);
114 ParseResult {
135 ParseResult {
115 document: Document {
136 document: Document {
116 page_title: ctx.page_title,
137 page_title: ctx.page_title,
@@ -120,6 +141,7 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
120 defines: ctx.defines,
141 defines: ctx.defines,
121 keyframes: ctx.keyframes,
142 keyframes: ctx.keyframes,
122 css_vars: ctx.css_vars,
143 css_vars: ctx.css_vars,
144+ custom_css: ctx.custom_css,
125 diagnostics: ctx.diagnostics,
147 diagnostics: ctx.diagnostics,
@@ -293,12 +315,14 @@ impl Parser {
293 if let Some(rest) = content.strip_prefix("@let ") {
315 if let Some(rest) = content.strip_prefix("@let ") {
294 let rest = rest.trim();
316 let rest = rest.trim();
295 if let Some((name, value)) = rest.split_once(' ') {
317 if let Some((name, value)) = rest.split_once(' ') {
318+ track_var_refs(value.trim(), &mut ctx.used_variables);
296 let value = substitute_vars(value.trim(), &ctx.variables);
319 let value = substitute_vars(value.trim(), &ctx.variables);
297 if name.starts_with("--") {
320 if name.starts_with("--") {
298 // CSS custom property
321 // CSS custom property
299 ctx.css_vars.push((name.to_string(), value.clone()));
322 ctx.css_vars.push((name.to_string(), value.clone()));
301 ctx.variables.insert(name.to_string(), value);
324 ctx.variables.insert(name.to_string(), value);
325+ ctx.let_lines.entry(name.to_string()).or_insert(line_num);
303 return Ok(None);
327 return Ok(None);
@@ -335,6 +359,29 @@ impl Parser {
335 return Ok(None);
359 return Ok(None);
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');
371+ LineContent::Raw(s) => {
372+ style_content.push_str(s);
373+ style_content.push('\n');
378+ let trimmed = style_content.trim().to_string();
379+ if !trimmed.is_empty() {
380+ ctx.custom_css.push(trimmed);
338 if let Some(rest) = content.strip_prefix("@define ") {
385 if let Some(rest) = content.strip_prefix("@define ") {
339 let rest = rest.trim();
386 let rest = rest.trim();
340 if let Some(bracket_start) = rest.find('[') {
387 if let Some(bracket_start) = rest.find('[') {
@@ -342,6 +389,7 @@ impl Parser {
342 let attrs_str = &rest[bracket_start..];
389 let attrs_str = &rest[bracket_start..];
343 let (attrs, _) = parse_attr_brackets(attrs_str, line_num, ctx)?;
390 let (attrs, _) = parse_attr_brackets(attrs_str, line_num, ctx)?;
344 ctx.defines.insert(name.to_string(), attrs);
391 ctx.defines.insert(name.to_string(), attrs);
392+ ctx.define_lines.entry(name.to_string()).or_insert(line_num);
346 return Ok(None);
394 return Ok(None);
@@ -536,11 +584,14 @@ impl Parser {
537 .collect();
585 .collect();
587+ // Scope variables: @let inside @if doesn't leak out
588+ let saved_vars = ctx.variables.clone();
539 let mut body_parser = Parser {
589 let mut body_parser = Parser {
540 lines: adjusted,
590 lines: adjusted,
543 let nodes = body_parser.parse_children(0, ctx);
593 let nodes = body_parser.parse_children(0, ctx);
594+ ctx.variables = saved_vars;
544 return Ok(Some(nodes));
595 return Ok(Some(nodes));
@@ -574,11 +625,21 @@ impl Parser {
576 let list_str = substitute_vars(&list_str, &ctx.variables);
627 let list_str = substitute_vars(&list_str, &ctx.variables);
628+ track_var_refs(&list_str, &mut ctx.used_variables);
629+ // Support range syntax: @each $i in 1..5
577− let items: Vec<String> = list_str
630+ let items: Vec<String> = if let Some((start_s, end_s)) = list_str.split_once("..") {
578− .split(',')
631+ if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.trim().parse::<i64>()) {
633+ (start..=end).map(|n| n.to_string()).collect()
635+ (end..=start).rev().map(|n| n.to_string()).collect()
579− .map(|s| s.trim().to_string())
638+ list_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
580− .filter(|s| !s.is_empty())
639+ }
581− .collect();
640+ } else {
641+ list_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
583 // Collect body lines
644 // Collect body lines
584 let mut body_lines = Vec::new();
645 let mut body_lines = Vec::new();
@@ -674,6 +735,7 @@ impl Parser {
674 self.pos += 1;
735 self.pos += 1;
738+ ctx.fn_lines.entry(name.clone()).or_insert(line_num);
677 ctx.functions.insert(name, FnDef { params, defaults, body_lines });
739 ctx.functions.insert(name, FnDef { params, defaults, body_lines });
678 return Ok(None);
740 return Ok(None);
@@ -683,6 +745,7 @@ impl Parser {
683 if content.starts_with('@') {
745 if content.starts_with('@') {
684 let name = extract_element_name(&content);
746 let name = extract_element_name(&content);
685 if ctx.functions.contains_key(name) {
747 if ctx.functions.contains_key(name) {
748+ ctx.used_functions.insert(name.to_string());
686 let nodes =
749 let nodes =
687 self.expand_fn_call(name, &content, current_indent, line_num, ctx)?;
750 self.expand_fn_call(name, &content, current_indent, line_num, ctx)?;
688 return Ok(Some(nodes));
751 return Ok(Some(nodes));
@@ -737,8 +800,21 @@ impl Parser {
737 // Clone function definition (releases borrow on ctx)
800 // Clone function definition (releases borrow on ctx)
738 let fn_def = ctx.functions.get(name).unwrap().clone();
801 let fn_def = ctx.functions.get(name).unwrap().clone();
740− // Parse caller's children
803+ // Parse caller's children, separating named slots from default children
741− let caller_children = self.parse_children(current_indent + 1, ctx);
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());
816+ caller_children.push(child);
743 // Save variable state, inject function parameters
819 // Save variable state, inject function parameters
744 let saved_vars = ctx.variables.clone();
820 let saved_vars = ctx.variables.clone();
@@ -781,8 +857,8 @@ impl Parser {
781 ctx.variables = saved_vars;
857 ctx.variables = saved_vars;
782 ctx.fn_call_stack.pop();
858 ctx.fn_call_stack.pop();
784− // Replace @children with caller's children
860+ // Replace @children with caller's children and @slot with slot content
785− Ok(replace_children_nodes(body_nodes, &caller_children))
861+ Ok(replace_children_and_slots(body_nodes, &caller_children, &slot_contents))
788 fn parse_element_line(
864 fn parse_element_line(
@@ -819,15 +895,30 @@ impl Parser {
819// @children replacement
895// @children replacement
820// ---------------------------------------------------------------------------
896// ---------------------------------------------------------------------------
898+fn replace_children_and_slots(
822−fn replace_children_nodes(nodes: Vec<Node>, caller_children: &[Node]) -> Vec<Node> {
900+ caller_children: &[Node],
901+ slot_contents: &HashMap<String, Vec<Node>>,
823 let mut result = Vec::new();
903 let mut result = Vec::new();
824 for node in nodes {
904 for node in nodes {
825 match node {
905 match node {
826 Node::Element(elem) if elem.kind == ElementKind::Children => {
906 Node::Element(elem) if elem.kind == ElementKind::Children => {
827 result.extend(caller_children.iter().cloned());
907 result.extend(caller_children.iter().cloned());
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());
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);
829 Node::Element(mut elem) => {
920 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);
831 result.push(Node::Element(elem));
922 result.push(Node::Element(elem));
833 other => result.push(other),
924 other => result.push(other),
@@ -905,6 +996,13 @@ fn parse_single_element(
905 Some(substitute_vars(&rest, &ctx.variables))
996 Some(substitute_vars(&rest, &ctx.variables))
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())
908 Ok(Element {
1006 Ok(Element {
@@ -916,12 +1014,12 @@ fn parse_single_element(
917const KNOWN_ELEMENTS: &[&str] = &[
1015const KNOWN_ELEMENTS: &[&str] = &[
918 "row", "column", "col", "el", "text", "paragraph", "p", "image", "img", "link", "children",
1016 "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",
922const KNOWN_DIRECTIVES: &[&str] = &[
1020const KNOWN_DIRECTIVES: &[&str] = &[
923 "page", "let", "define", "fn", "include", "import", "raw", "keyframes",
1021 "page", "let", "define", "fn", "include", "import", "raw", "keyframes",
924− "if", "else", "each", "meta", "head",
1022+ "if", "else", "each", "meta", "head", "style",
927fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
1025fn 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
940 "textarea" => Ok(ElementKind::Textarea),
1038 "textarea" => Ok(ElementKind::Textarea),
941 "option" | "opt" => Ok(ElementKind::Option),
1039 "option" | "opt" => Ok(ElementKind::Option),
942 "label" => Ok(ElementKind::Label),
1040 "label" => Ok(ElementKind::Label),
1041+ "slot" => Ok(ElementKind::Slot(String::new())), // slot name filled in by parse_single_element
944 let all_known: Vec<&str> = KNOWN_ELEMENTS
1043 let all_known: Vec<&str> = KNOWN_ELEMENTS
@@ -1015,6 +1114,8 @@ const KNOWN_ATTRS: &[&str] = &[
1015 "transform", "backdrop-filter",
1114 "transform", "backdrop-filter",
1017 "grid", "grid-cols", "grid-rows", "col-span", "row-span",
1116 "grid", "grid-cols", "grid-rows", "col-span", "row-span",
1117+ // Container queries
1118+ "container", "container-name", "container-type",
1018 // Identity
1119 // Identity
1019 "id", "class",
1120 "id", "class",
1020 // Animation
1121 // Animation
@@ -1195,12 +1296,14 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
1195 if part.starts_with('$') {
1296 if part.starts_with('$') {
1196 let name = &part[1..];
1297 let name = &part[1..];
1197 if let Some(define_attrs) = ctx.defines.get(name) {
1298 if let Some(define_attrs) = ctx.defines.get(name) {
1299+ ctx.used_defines.insert(name.to_string());
1198 attrs.extend(define_attrs.clone());
1300 attrs.extend(define_attrs.clone());
1199 continue;
1301 continue;
1203 // Substitute variables in value
1305 // Substitute variables in value
1306+ track_var_refs(part, &mut ctx.used_variables);
1204 let part = substitute_vars(part, &ctx.variables);
1307 let part = substitute_vars(part, &ctx.variables);
1206 let attr = if let Some((key, value)) = part.split_once(' ') {
1309 let attr = if let Some((key, value)) = part.split_once(' ') {
@@ -1429,6 +1532,37 @@ fn strip_all_prefixes(key: &str) -> &str {
1429 .unwrap_or(key)
1532 .unwrap_or(key)
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",
1542+fn element_kind_name(kind: &ElementKind) -> &'static str {
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",
1562+fn is_container(kind: &ElementKind) -> bool {
1563+ matches!(kind, ElementKind::Row | ElementKind::Column | ElementKind::El)
1432fn validate_tree(
1566fn validate_tree(
1433 nodes: &[Node],
1567 nodes: &[Node],
1434 parent_kind: Option<&ElementKind>,
1568 parent_kind: Option<&ElementKind>,
@@ -1461,6 +1595,62 @@ fn validate_tree(
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,
1604+ "'{}' has no effect on {} (only works on @row, @column, @el)",
1605+ base, element_kind_name(&elem.kind)
1607+ severity: Severity::Warning,
1612+ // Form-specific: placeholder only on @input/@textarea
1613+ if base == "placeholder"
1614+ && !matches!(elem.kind, ElementKind::Input | ElementKind::Textarea)
1616+ diagnostics.push(Diagnostic {
1617+ line: elem.line_num,
1619+ "'placeholder' has no effect on {} (only works on @input, @textarea)",
1620+ element_kind_name(&elem.kind)
1622+ severity: Severity::Warning,
1627+ // 'for' only on @label
1628+ if base == "for" && !matches!(elem.kind, ElementKind::Label) {
1629+ diagnostics.push(Diagnostic {
1630+ line: elem.line_num,
1632+ "'for' has no effect on {} (only works on @label)",
1633+ element_kind_name(&elem.kind)
1635+ severity: Severity::Warning,
1640+ // 'rows'/'cols' only on @textarea
1641+ if (base == "rows" || base == "cols")
1642+ && !matches!(elem.kind, ElementKind::Textarea)
1644+ diagnostics.push(Diagnostic {
1645+ line: elem.line_num,
1647+ "'{}' has no effect on {} (only works on @textarea)",
1648+ base, element_kind_name(&elem.kind)
1650+ severity: Severity::Warning,
1465 validate_tree(&elem.children, Some(&elem.kind), diagnostics);
1655 validate_tree(&elem.children, Some(&elem.kind), diagnostics);
@@ -1508,3 +1698,80 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
1702+// ---------------------------------------------------------------------------
1703+// Variable usage tracking
1704+// ---------------------------------------------------------------------------
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('$') {
1711+ let chars: Vec<char> = input.chars().collect();
1713+ while i < chars.len() {
1715+ && i + 1 < chars.len()
1716+ && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
1719+ let mut end = start;
1720+ while end < chars.len()
1721+ && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
1725+ let name: String = chars[start..end].iter().collect();
1734+// ---------------------------------------------------------------------------
1735+// Unused definition warnings
1736+// ---------------------------------------------------------------------------
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
1744+ if !ctx.used_variables.contains(name) {
1745+ ctx.diagnostics.push(Diagnostic {
1747+ message: format!("unused variable '${}' (defined but never referenced)", name),
1748+ severity: Severity::Warning,
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 {
1759+ message: format!("unused define '${}' (defined but never referenced)", name),
1760+ severity: Severity::Warning,
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 {
1771+ message: format!("unused function '@{}' (defined but never called)", name),
1772+ severity: Severity::Warning,