1use std::collections::{HashMap, HashSet};
3use std::path::{Path, PathBuf};
7// ---------------------------------------------------------------------------
9// ---------------------------------------------------------------------------
11#[derive(Debug, Clone, PartialEq)]
17#[derive(Debug, Clone)]
18pub struct Diagnostic {
21 pub severity: Severity,
22 pub source_line: Option<String>,
25pub struct ParseResult {
26 pub document: Document,
27 pub diagnostics: Vec<Diagnostic>,
28 pub included_files: Vec<PathBuf>,
37impl fmt::Display for ParseError {
38 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
39 write!(f, "line {}: {}", self.line, self.message)
43// ---------------------------------------------------------------------------
45// ---------------------------------------------------------------------------
63 defaults: HashMap<String, String>,
64 body_lines: Vec<Line>,
68 page_title: Option<String>,
70 favicon: Option<String>,
71 meta_tags: Vec<(String, String)>,
72 head_blocks: Vec<String>,
73 variables: HashMap<String, String>,
74 defines: HashMap<String, Vec<Attribute>>,
75 functions: HashMap<String, FnDef>,
76 keyframes: Vec<(String, String)>,
77 css_vars: Vec<(String, String)>,
78 custom_css: Vec<String>,
79 og_tags: Vec<(String, String)>,
80 custom_breakpoints: Vec<(String, String)>,
81 diagnostics: Vec<Diagnostic>,
82 base_path: Option<PathBuf>,
83 included_files: Vec<PathBuf>,
84 include_stack: Vec<PathBuf>,
85 fn_call_stack: Vec<String>,
86 file_cache: HashMap<PathBuf, String>,
87 /// Track which @let variables are referenced (for unused warnings)
88 used_variables: HashSet<String>,
89 /// Track which @fn functions are called (for unused warnings)
90 used_functions: HashSet<String>,
91 /// Track which @define bundles are referenced (for unused warnings)
92 used_defines: HashSet<String>,
93 /// Line numbers of @let definitions (name -> line)
94 let_lines: HashMap<String, usize>,
95 /// Line numbers of @fn definitions (name -> line)
96 fn_lines: HashMap<String, usize>,
97 /// Line numbers of @define definitions (name -> line)
98 define_lines: HashMap<String, usize>,
99 /// Deprecated functions: name -> deprecation message
100 deprecated_fns: HashMap<String, String>,
101 /// Theme tokens: (name, value) pairs from @theme
102 theme_tokens: Vec<(String, String)>,
104 canonical: Option<String>,
105 /// Base URL for relative links
106 base_url: Option<String>,
107 /// @font-face declarations: (font_name, url)
108 font_faces: Vec<(String, String)>,
110 json_ld_blocks: Vec<String>,
118pub fn parse(input: &str) -> ParseResult {
119 parse_with_base(input, None)
122pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
123 let lines = preprocess(input);
124 let mut parser = Parser { lines, pos: 0 };
125 let mut ctx = ParseContext {
129 meta_tags: Vec::new(),
130 head_blocks: Vec::new(),
131 variables: HashMap::new(),
132 defines: HashMap::new(),
133 functions: HashMap::new(),
134 keyframes: Vec::new(),
135 css_vars: Vec::new(),
136 custom_css: Vec::new(),
138 custom_breakpoints: Vec::new(),
139 diagnostics: Vec::new(),
140 base_path: base_path.map(|p| p.to_path_buf()),
141 included_files: Vec::new(),
142 include_stack: Vec::new(),
143 fn_call_stack: Vec::new(),
144 file_cache: HashMap::new(),
145 used_variables: HashSet::new(),
146 used_functions: HashSet::new(),
147 used_defines: HashSet::new(),
148 let_lines: HashMap::new(),
149 fn_lines: HashMap::new(),
150 define_lines: HashMap::new(),
151 deprecated_fns: HashMap::new(),
152 theme_tokens: Vec::new(),
155 font_faces: Vec::new(),
156 json_ld_blocks: Vec::new(),
158 let nodes = parser.parse_children(0, &mut ctx);
159 validate_tree(&nodes, None, &mut ctx.diagnostics);
160 check_unused(&mut ctx);
163 page_title: ctx.page_title,
165 favicon: ctx.favicon,
166 meta_tags: ctx.meta_tags,
167 head_blocks: ctx.head_blocks,
168 variables: ctx.variables,
169 defines: ctx.defines,
170 keyframes: ctx.keyframes,
171 css_vars: ctx.css_vars,
172 custom_css: ctx.custom_css,
173 og_tags: ctx.og_tags,
174 custom_breakpoints: ctx.custom_breakpoints,
175 theme_tokens: ctx.theme_tokens,
176 canonical: ctx.canonical,
177 base_url: ctx.base_url,
178 font_faces: ctx.font_faces,
179 json_ld_blocks: ctx.json_ld_blocks,
182 diagnostics: ctx.diagnostics,
183 included_files: ctx.included_files,
187// ---------------------------------------------------------------------------
188// Preprocessing: strip comments/blanks, collapse @raw blocks
189// ---------------------------------------------------------------------------
191fn preprocess(input: &str) -> Vec<Line> {
192 let raw_lines: Vec<&str> = input.lines().collect();
193 let mut lines = Vec::new();
196 while i < raw_lines.len() {
197 let line = raw_lines[i];
198 let trimmed = line.trim();
200 if trimmed.is_empty() || trimmed.starts_with("--") {
205 let indent = line.len() - line.trim_start().len();
207 // Handle @raw """..."""
208 if trimmed.starts_with("@raw") {
209 let after_raw = trimmed[4..].trim_start();
210 if after_raw.starts_with("\"\"\"") {
211 let after_open = &after_raw[3..];
213 // Single-line: @raw """content"""
214 if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
215 let content = &after_open[..after_open.len() - 3];
218 content: LineContent::Raw(content.to_string()),
225 // Multiline: collect until closing """
226 let mut raw_content = String::new();
227 if !after_open.is_empty() {
228 raw_content.push_str(after_open);
229 raw_content.push('\n');
232 while i < raw_lines.len() {
233 if raw_lines[i].trim() == "\"\"\"" {
237 raw_content.push_str(raw_lines[i]);
238 raw_content.push('\n');
244 content: LineContent::Raw(
245 raw_content.trim_end_matches('\n').to_string(),
253 // Join continuation lines for multi-line attribute brackets
254 let first_line_num = i + 1;
255 let mut full = trimmed.to_string();
256 let mut depth: i32 = full.chars().filter(|&c| c == '[').count() as i32
257 - full.chars().filter(|&c| c == ']').count() as i32;
258 while depth > 0 && i + 1 < raw_lines.len() {
260 let next = raw_lines[i].trim();
261 if next.is_empty() || next.starts_with("--") {
266 depth += next.chars().filter(|&c| c == '[').count() as i32;
267 depth -= next.chars().filter(|&c| c == ']').count() as i32;
272 content: LineContent::Normal(full),
273 line_num: first_line_num,
281// ---------------------------------------------------------------------------
283// ---------------------------------------------------------------------------
289 ctx: &mut ParseContext,
291 let mut nodes = Vec::new();
293 while self.pos < self.lines.len() {
294 let indent = self.lines[self.pos].indent;
295 if indent < min_indent {
299 match self.parse_line(ctx) {
300 Ok(Some(new_nodes)) => nodes.extend(new_nodes),
303 ctx.diagnostics.push(Diagnostic {
306 severity: Severity::Error,
309 // Skip forward past any deeper-indented children of the errored line
310 while self.pos < self.lines.len()
311 && self.lines[self.pos].indent > indent
324 ctx: &mut ParseContext,
325 ) -> Result<Option<Vec<Node>>, ParseError> {
326 let line_num = self.lines[self.pos].line_num;
327 let current_indent = self.lines[self.pos].indent;
329 // Handle raw content
330 if let LineContent::Raw(s) = &self.lines[self.pos].content {
331 let content = s.clone();
333 return Ok(Some(vec![Node::Raw(content)]));
336 // Normal content — clone to release borrow
337 let content = match &self.lines[self.pos].content {
338 LineContent::Normal(s) => s.clone(),
343 // --- Directives ---
345 if let Some(rest) = content.strip_prefix("@page ") {
346 ctx.page_title = Some(substitute_vars(rest, &ctx.variables));
350 if let Some(rest) = content.strip_prefix("@lang ") {
351 ctx.lang = Some(substitute_vars(rest.trim(), &ctx.variables));
355 if let Some(rest) = content.strip_prefix("@favicon ") {
356 ctx.favicon = Some(substitute_vars(rest.trim(), &ctx.variables));
360 if let Some(rest) = content.strip_prefix("@let ") {
361 let rest = rest.trim();
362 if let Some((name, value)) = rest.split_once(' ') {
363 let value = value.trim();
364 // Support @let name = expr syntax (strip leading =)
365 let value = if value.starts_with("= ") {
367 } else if value == "=" {
372 // Support quoted string interpolation: @let greeting "Hello $name"
373 let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
374 &value[1..value.len()-1]
378 track_var_refs(value, &mut ctx.used_variables);
379 let value = substitute_vars(value, &ctx.variables);
380 let value = evaluate_arithmetic(&value);
381 if name.starts_with("--") {
382 // CSS custom property
383 ctx.css_vars.push((name.to_string(), value.clone()));
385 ctx.variables.insert(name.to_string(), value);
386 ctx.let_lines.entry(name.to_string()).or_insert(line_num);
391 if let Some(rest) = content.strip_prefix("@meta ") {
392 let rest = rest.trim();
393 if let Some((name, value)) = rest.split_once(' ') {
394 let value = substitute_vars(value.trim(), &ctx.variables);
395 ctx.meta_tags.push((name.trim().to_string(), value));
400 if let Some(rest) = content.strip_prefix("@og ") {
401 let rest = rest.trim();
402 let parts: Vec<&str> = rest.splitn(2, ' ').collect();
403 if parts.len() == 2 {
404 let value = substitute_vars(parts[1].trim(), &ctx.variables);
405 let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
406 value[1..value.len()-1].to_string()
410 ctx.og_tags.push((parts[0].to_string(), value));
415 if let Some(rest) = content.strip_prefix("@breakpoint ") {
416 let rest = rest.trim();
417 if let Some((name, value)) = rest.split_once(' ') {
418 ctx.custom_breakpoints.push((name.trim().to_string(), substitute_vars(value.trim(), &ctx.variables)));
423 if content == "@head" || content.starts_with("@head ") {
424 // Collect indented body lines as raw head content
425 let mut head_content = String::new();
426 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
427 match &self.lines[self.pos].content {
428 LineContent::Normal(s) => {
429 head_content.push_str(s.trim());
430 head_content.push('\n');
432 LineContent::Raw(s) => {
433 head_content.push_str(s);
434 head_content.push('\n');
439 let trimmed = head_content.trim().to_string();
440 if !trimmed.is_empty() {
441 ctx.head_blocks.push(trimmed);
446 // --- @style block (raw CSS) ---
447 if content.trim() == "@style" {
448 let mut style_content = String::new();
449 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
450 match &self.lines[self.pos].content {
451 LineContent::Normal(s) => {
452 style_content.push_str(s.trim());
453 style_content.push('\n');
455 LineContent::Raw(s) => {
456 style_content.push_str(s);
457 style_content.push('\n');
462 let trimmed = style_content.trim().to_string();
463 if !trimmed.is_empty() {
464 ctx.custom_css.push(trimmed);
469 if let Some(rest) = content.strip_prefix("@define ") {
470 let rest = rest.trim();
471 if let Some(bracket_start) = rest.find('[') {
472 let name = rest[..bracket_start].trim();
473 let attrs_str = &rest[bracket_start..];
474 let (attrs, _) = parse_attr_brackets(attrs_str, line_num, ctx)?;
475 ctx.defines.insert(name.to_string(), attrs);
476 ctx.define_lines.entry(name.to_string()).or_insert(line_num);
481 if let Some(rest) = content.strip_prefix("@include ") {
482 let filename = substitute_vars(rest.trim(), &ctx.variables);
483 let resolved = match &ctx.base_path {
484 Some(base) => base.join(&filename),
485 None => PathBuf::from(&filename),
488 // Circular include check
489 if ctx.include_stack.contains(&resolved) {
490 ctx.diagnostics.push(Diagnostic {
492 message: format!("circular include '{}'", filename),
493 severity: Severity::Error,
494 source_line: Some(content.clone()),
499 // Use file cache to avoid redundant reads
500 let included_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
503 match std::fs::read_to_string(&resolved) {
505 ctx.file_cache.insert(resolved.clone(), text.clone());
509 ctx.diagnostics.push(Diagnostic {
511 message: format!("cannot include '{}': {}", filename, e),
512 severity: Severity::Error,
513 source_line: Some(content.clone()),
520 ctx.included_files.push(resolved.clone());
521 ctx.include_stack.push(resolved.clone());
522 let saved_base = ctx.base_path.clone();
523 ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
525 let included_lines = preprocess(&included_text);
526 let mut included_parser = Parser {
527 lines: included_lines,
530 let nodes = included_parser.parse_children(0, ctx);
532 ctx.base_path = saved_base;
533 ctx.include_stack.pop();
534 return Ok(Some(nodes));
537 // --- @import (definitions only, no DOM nodes) ---
539 if let Some(rest) = content.strip_prefix("@import ") {
540 let filename = substitute_vars(rest.trim(), &ctx.variables);
541 let resolved = match &ctx.base_path {
542 Some(base) => base.join(&filename),
543 None => PathBuf::from(&filename),
546 if ctx.include_stack.contains(&resolved) {
547 ctx.diagnostics.push(Diagnostic {
549 message: format!("circular import '{}'", filename),
550 severity: Severity::Error,
551 source_line: Some(content.clone()),
556 let imported_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
559 match std::fs::read_to_string(&resolved) {
561 ctx.file_cache.insert(resolved.clone(), text.clone());
565 ctx.diagnostics.push(Diagnostic {
567 message: format!("cannot import '{}': {}", filename, e),
568 severity: Severity::Error,
569 source_line: Some(content.clone()),
576 ctx.included_files.push(resolved.clone());
577 ctx.include_stack.push(resolved.clone());
578 let saved_base = ctx.base_path.clone();
579 ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
581 // Parse the file but discard DOM nodes — only keep definitions
582 let imported_lines = preprocess(&imported_text);
583 let mut imported_parser = Parser {
584 lines: imported_lines,
587 let _discarded_nodes = imported_parser.parse_children(0, ctx);
589 ctx.base_path = saved_base;
590 ctx.include_stack.pop();
591 return Ok(None); // No nodes emitted
594 // --- @use (selective import) ---
596 if let Some(rest) = content.strip_prefix("@use ") {
597 let rest = rest.trim();
598 // @use "./file.hl" fn1, fn2, define1
599 let (filename, names_str) = if rest.starts_with('"') {
600 if let Some(end_quote) = rest[1..].find('"') {
601 let filename = &rest[1..end_quote + 1];
602 let names = rest[end_quote + 2..].trim();
603 (filename.to_string(), names.to_string())
605 return Err(ParseError {
607 message: "@use requires: @use \"file.hl\" name1, name2".to_string(),
610 } else if let Some((filename, names)) = rest.split_once(' ') {
611 (filename.to_string(), names.to_string())
613 return Err(ParseError {
615 message: "@use requires: @use file.hl name1, name2".to_string(),
619 let wanted: HashSet<String> = names_str
621 .map(|s| s.trim().to_string())
622 .filter(|s| !s.is_empty())
625 let filename = substitute_vars(&filename, &ctx.variables);
626 let resolved = match &ctx.base_path {
627 Some(base) => base.join(&filename),
628 None => PathBuf::from(&filename),
631 if ctx.include_stack.contains(&resolved) {
632 ctx.diagnostics.push(Diagnostic {
634 message: format!("circular use '{}'", filename),
635 severity: Severity::Error,
636 source_line: Some(content.clone()),
641 let use_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
644 match std::fs::read_to_string(&resolved) {
646 ctx.file_cache.insert(resolved.clone(), text.clone());
650 ctx.diagnostics.push(Diagnostic {
652 message: format!("cannot use '{}': {}", filename, e),
653 severity: Severity::Error,
654 source_line: Some(content.clone()),
661 ctx.included_files.push(resolved.clone());
662 ctx.include_stack.push(resolved.clone());
663 let saved_base = ctx.base_path.clone();
664 ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
666 // Parse the file fully, then filter to only keep wanted definitions
667 let saved_fns = ctx.functions.clone();
668 let saved_defines = ctx.defines.clone();
670 let use_lines = preprocess(&use_text);
671 let mut use_parser = Parser { lines: use_lines, pos: 0 };
672 let _discarded = use_parser.parse_children(0, ctx);
674 // Keep only the wanted functions/defines, restore everything else
675 let mut new_fns = HashMap::new();
676 let mut new_defines = HashMap::new();
677 for name in &wanted {
678 if let Some(f) = ctx.functions.get(name) {
679 new_fns.insert(name.clone(), f.clone());
681 if let Some(d) = ctx.defines.get(name) {
682 new_defines.insert(name.clone(), d.clone());
685 ctx.functions = saved_fns;
686 ctx.defines = saved_defines;
687 ctx.functions.extend(new_fns);
688 ctx.defines.extend(new_defines);
690 ctx.base_path = saved_base;
691 ctx.include_stack.pop();
695 // --- @if / @else ---
697 if let Some(rest) = content.strip_prefix("@if ") {
698 let rest = rest.trim();
699 let condition = substitute_vars(rest, &ctx.variables);
700 let result = evaluate_condition(&condition);
702 // Collect then-body lines
703 let mut then_lines = Vec::new();
704 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
705 then_lines.push(self.lines[self.pos].clone());
709 // Build branches: [(condition_result, body_lines), ...]
710 let mut branches: Vec<(bool, Vec<Line>)> = vec![(result, then_lines)];
712 // Check for @else if / @else chains at same indent
714 if self.pos >= self.lines.len() || self.lines[self.pos].indent != current_indent {
717 if let LineContent::Normal(ref s) = self.lines[self.pos].content {
718 let trimmed = s.trim();
719 if let Some(else_if_cond) = trimmed.strip_prefix("@else if ") {
720 self.pos += 1; // consume @else if
721 let cond = substitute_vars(else_if_cond.trim(), &ctx.variables);
722 let cond_result = evaluate_condition(&cond);
723 let mut body = Vec::new();
724 while self.pos < self.lines.len()
725 && self.lines[self.pos].indent > current_indent
727 body.push(self.lines[self.pos].clone());
730 branches.push((cond_result, body));
731 } else if trimmed == "@else" {
732 self.pos += 1; // consume @else
733 let mut body = Vec::new();
734 while self.pos < self.lines.len()
735 && self.lines[self.pos].indent > current_indent
737 body.push(self.lines[self.pos].clone());
740 // @else is always true (fallback)
741 branches.push((true, body));
751 // Pick the first branch whose condition is true
752 let body_lines = branches
754 .find(|(cond, _)| *cond)
755 .map(|(_, lines)| lines)
756 .unwrap_or_default();
758 if body_lines.is_empty() {
762 let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
763 let adjusted: Vec<Line> = body_lines
766 indent: l.indent - min_indent,
767 content: l.content.clone(),
768 line_num: l.line_num,
772 // Scope variables: @let inside @if doesn't leak out
773 let saved_vars = ctx.variables.clone();
774 let mut body_parser = Parser {
778 let nodes = body_parser.parse_children(0, ctx);
779 ctx.variables = saved_vars;
780 return Ok(Some(nodes));
783 if let Some(rest) = content.strip_prefix("@unless ") {
784 let rest = rest.trim();
785 let condition = substitute_vars(rest, &ctx.variables);
786 let result = !evaluate_condition(&condition);
787 let mut body_lines = Vec::new();
788 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
789 body_lines.push(self.lines[self.pos].clone());
792 if !result || body_lines.is_empty() {
795 let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
796 let adjusted: Vec<Line> = body_lines.iter().map(|l| Line {
797 indent: l.indent - min_indent,
798 content: l.content.clone(),
799 line_num: l.line_num,
801 let saved_vars = ctx.variables.clone();
802 let mut body_parser = Parser { lines: adjusted, pos: 0 };
803 let nodes = body_parser.parse_children(0, ctx);
804 ctx.variables = saved_vars;
805 return Ok(Some(nodes));
808 if content.trim() == "@else" || content.trim().starts_with("@else if ") {
809 return Err(ParseError {
811 message: "@else without matching @if".to_string(),
815 // --- @each loop ---
817 if let Some(rest) = content.strip_prefix("@each ") {
818 let rest = rest.trim();
819 // Support: @each $var in list OR @each $var, $index in list
820 // OR @each $name, $url in Alice /alice, Bob /bob (destructuring)
821 let (var_names, list_str) = if let Some((before_in, after_in)) = rest.split_once(" in ") {
822 let before_in = before_in.trim();
823 let vars: Vec<String> = before_in.split(',')
824 .map(|v| v.trim().strip_prefix('$').unwrap_or(v.trim()).to_string())
826 (vars, after_in.trim().to_string())
828 return Err(ParseError {
830 message: "@each requires: @each $var in list".to_string(),
833 let var_name = var_names[0].clone();
834 let index_var = var_names.get(1).cloned();
836 let list_str = substitute_vars(&list_str, &ctx.variables);
837 track_var_refs(&list_str, &mut ctx.used_variables);
838 // Support range syntax: @each $i in 1..5 or @each $i in 0..100 step 10
839 let items: Vec<String> = if let Some((start_s, rest)) = list_str.split_once("..") {
840 let (end_s, step) = if let Some((e, s)) = rest.split_once(" step ") {
841 (e.trim(), s.trim().parse::<i64>().unwrap_or(1).max(1))
845 if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>()) {
847 let mut items = Vec::new();
849 while n <= end { items.push(n.to_string()); n += step; }
852 let mut items = Vec::new();
854 while n >= end { items.push(n.to_string()); n -= step; }
858 list_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
861 list_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
864 // Collect body lines
865 let mut body_lines = Vec::new();
866 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
867 body_lines.push(self.lines[self.pos].clone());
871 // Check for @else block (empty-state fallback)
872 let mut else_lines = Vec::new();
873 if self.pos < self.lines.len() && self.lines[self.pos].indent == current_indent {
874 if let LineContent::Normal(ref s) = self.lines[self.pos].content {
875 if s.trim() == "@else" {
876 self.pos += 1; // consume @else
877 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
878 else_lines.push(self.lines[self.pos].clone());
885 if body_lines.is_empty() {
889 // If list is empty, render @else body
890 if items.is_empty() {
891 if else_lines.is_empty() {
894 let min_indent = else_lines.iter().map(|l| l.indent).min().unwrap_or(0);
895 let adjusted: Vec<Line> = else_lines
898 indent: l.indent - min_indent,
899 content: l.content.clone(),
900 line_num: l.line_num,
903 let mut body_parser = Parser {
907 let nodes = body_parser.parse_children(0, ctx);
908 return Ok(Some(nodes));
911 let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
912 let adjusted: Vec<Line> = body_lines
915 indent: l.indent - min_indent,
916 content: l.content.clone(),
917 line_num: l.line_num,
921 let saved_vars = ctx.variables.clone();
922 let mut all_nodes = Vec::new();
924 let has_extra_vars = var_names.len() > 2
925 || (var_names.len() == 2 && items.first().map_or(false, |it| it.contains(' ')));
927 for (i, item) in items.iter().enumerate() {
929 // Destructuring: split item by spaces and assign to each variable
930 let parts: Vec<&str> = item.splitn(var_names.len(), ' ').collect();
931 for (vi, vn) in var_names.iter().enumerate() {
932 let val = parts.get(vi).unwrap_or(&"").to_string();
933 ctx.variables.insert(vn.clone(), val);
936 ctx.variables.insert(var_name.clone(), item.clone());
937 if let Some(ref idx_name) = index_var {
938 ctx.variables.insert(idx_name.clone(), i.to_string());
941 let mut body_parser = Parser {
942 lines: adjusted.clone(),
945 let nodes = body_parser.parse_children(0, ctx);
946 all_nodes.extend(nodes);
949 ctx.variables = saved_vars;
950 return Ok(Some(all_nodes));
955 if let Some(rest) = content.strip_prefix("@match ") {
956 let match_val = substitute_vars(rest.trim(), &ctx.variables);
957 track_var_refs(rest.trim(), &mut ctx.used_variables);
959 // Collect all child lines
960 let mut match_lines = Vec::new();
961 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
962 match_lines.push(self.lines[self.pos].clone());
966 if match_lines.is_empty() {
970 let case_indent = match_lines[0].indent;
972 // Group into cases: (Some(value), body) or (None, body) for @default
973 let mut cases: Vec<(Option<String>, Vec<Line>)> = Vec::new();
975 while mi < match_lines.len() {
976 if match_lines[mi].indent == case_indent {
977 if let LineContent::Normal(ref s) = match_lines[mi].content {
978 let trimmed = s.trim();
979 if let Some(case_val) = trimmed.strip_prefix("@case ") {
980 let case_val = substitute_vars(case_val.trim(), &ctx.variables);
982 let mut body = Vec::new();
983 while mi < match_lines.len() && match_lines[mi].indent > case_indent {
984 body.push(match_lines[mi].clone());
987 cases.push((Some(case_val), body));
988 } else if trimmed == "@default" {
990 let mut body = Vec::new();
991 while mi < match_lines.len() && match_lines[mi].indent > case_indent {
992 body.push(match_lines[mi].clone());
995 cases.push((None, body));
1007 // Find first matching case or @default
1008 let body_lines = cases
1010 .find(|(case_val, _)| match case_val {
1011 Some(v) => *v == match_val,
1014 .map(|(_, lines)| lines)
1015 .unwrap_or_default();
1017 if body_lines.is_empty() {
1021 let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1022 let adjusted: Vec<Line> = body_lines
1025 indent: l.indent - min_indent,
1026 content: l.content.clone(),
1027 line_num: l.line_num,
1031 let saved_vars = ctx.variables.clone();
1032 let mut body_parser = Parser {
1036 let nodes = body_parser.parse_children(0, ctx);
1037 ctx.variables = saved_vars;
1038 return Ok(Some(nodes));
1041 // --- @warn / @debug ---
1043 if let Some(rest) = content.strip_prefix("@warn ") {
1044 let msg = substitute_vars(rest.trim(), &ctx.variables);
1045 ctx.diagnostics.push(Diagnostic {
1048 severity: Severity::Warning,
1049 source_line: Some(content.clone()),
1054 if let Some(rest) = content.strip_prefix("@debug ") {
1055 let msg = substitute_vars(rest.trim(), &ctx.variables);
1056 eprintln!("debug: line {}: {}", line_num, msg);
1060 // --- @keyframes directive ---
1062 if let Some(rest) = content.strip_prefix("@keyframes ") {
1063 let name = rest.trim().to_string();
1064 if name.is_empty() {
1065 return Err(ParseError {
1067 message: "@keyframes requires a name".to_string(),
1070 // Collect body lines (indented deeper)
1071 let mut body = String::new();
1072 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1073 if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1074 let trimmed = s.trim();
1075 // Support htmlang-style: from [opacity 0] / to [opacity 1] / 50% [transform scale(1.5)]
1076 if let Some(kf_css) = parse_keyframe_line(trimmed) {
1077 body.push_str(&kf_css);
1079 body.push_str(trimmed);
1084 ctx.keyframes.push((name, body));
1088 // --- @theme directive ---
1090 if content.trim() == "@theme" {
1091 let mut tokens = Vec::new();
1092 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1093 if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1094 let trimmed = s.trim();
1095 if let Some((name, value)) = trimmed.split_once(' ') {
1096 let name = name.trim().to_string();
1097 let value = value.trim().to_string();
1098 tokens.push((name.clone(), value.clone()));
1099 // Set as regular variable
1100 ctx.variables.insert(name.clone(), value.clone());
1101 // Also set as CSS custom property
1102 let css_name = format!("--{}", name);
1103 ctx.css_vars.push((css_name, value));
1108 ctx.theme_tokens = tokens;
1112 // --- @canonical directive ---
1114 if let Some(rest) = content.strip_prefix("@canonical ") {
1115 ctx.canonical = Some(substitute_vars(rest.trim(), &ctx.variables));
1119 // --- @base directive ---
1121 if let Some(rest) = content.strip_prefix("@base ") {
1122 ctx.base_url = Some(substitute_vars(rest.trim(), &ctx.variables));
1126 // --- @font-face directive ---
1128 if let Some(rest) = content.strip_prefix("@font-face ") {
1129 let rest = rest.trim();
1130 if let Some((name, url)) = rest.split_once(' ') {
1131 ctx.font_faces.push((
1132 substitute_vars(name.trim(), &ctx.variables),
1133 substitute_vars(url.trim(), &ctx.variables),
1139 // --- @json-ld block ---
1141 if content.trim() == "@json-ld" {
1142 let mut block = String::new();
1143 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1144 match &self.lines[self.pos].content {
1145 LineContent::Normal(s) => {
1146 block.push_str(s.trim());
1149 LineContent::Raw(s) => {
1156 let trimmed = block.trim().to_string();
1157 if !trimmed.is_empty() {
1158 ctx.json_ld_blocks.push(trimmed);
1163 // --- @deprecated annotation ---
1165 if let Some(rest) = content.strip_prefix("@deprecated ") {
1166 let message = rest.trim().to_string();
1167 // Peek at the next line to get the function name
1168 if self.pos < self.lines.len() {
1169 if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1170 if let Some(fn_rest) = s.trim().strip_prefix("@fn ") {
1171 let parts: Vec<&str> = fn_rest.split_whitespace().collect();
1172 if let Some(&fn_name) = parts.first() {
1173 ctx.deprecated_fns.insert(fn_name.to_string(), message);
1181 // --- @extends (template inheritance) ---
1183 if let Some(rest) = content.strip_prefix("@extends ") {
1184 let filename = substitute_vars(rest.trim(), &ctx.variables);
1185 let resolved = match &ctx.base_path {
1186 Some(base) => base.join(&filename),
1187 None => PathBuf::from(&filename),
1190 if ctx.include_stack.contains(&resolved) {
1191 ctx.diagnostics.push(Diagnostic {
1193 message: format!("circular extends '{}'", filename),
1194 severity: Severity::Error,
1195 source_line: Some(content.clone()),
1200 let extends_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
1203 match std::fs::read_to_string(&resolved) {
1205 ctx.file_cache.insert(resolved.clone(), text.clone());
1209 ctx.diagnostics.push(Diagnostic {
1211 message: format!("cannot extend '{}': {}", filename, e),
1212 severity: Severity::Error,
1213 source_line: Some(content.clone()),
1220 // Collect slot blocks defined in the extending file
1221 let mut slot_contents: HashMap<String, Vec<Line>> = HashMap::new();
1222 while self.pos < self.lines.len() {
1223 let line_indent = self.lines[self.pos].indent;
1224 if line_indent < current_indent {
1227 if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1228 let trimmed = s.trim();
1229 if let Some(slot_name) = trimmed.strip_prefix("@slot ") {
1230 let slot_name = slot_name.trim().to_string();
1232 let mut slot_lines = Vec::new();
1233 while self.pos < self.lines.len() && self.lines[self.pos].indent > line_indent {
1234 slot_lines.push(self.lines[self.pos].clone());
1237 slot_contents.insert(slot_name, slot_lines);
1244 // Parse slot contents into nodes
1245 let mut slot_nodes: HashMap<String, Vec<Node>> = HashMap::new();
1246 for (name, lines) in &slot_contents {
1247 if lines.is_empty() {
1250 let min_indent = lines.iter().map(|l| l.indent).min().unwrap_or(0);
1251 let adjusted: Vec<Line> = lines.iter().map(|l| Line {
1252 indent: l.indent - min_indent,
1253 content: l.content.clone(),
1254 line_num: l.line_num,
1256 let mut slot_parser = Parser { lines: adjusted, pos: 0 };
1257 let nodes = slot_parser.parse_children(0, ctx);
1258 slot_nodes.insert(name.clone(), nodes);
1261 // Parse the base layout file
1262 ctx.included_files.push(resolved.clone());
1263 ctx.include_stack.push(resolved.clone());
1264 let saved_base = ctx.base_path.clone();
1265 ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
1267 let extends_lines = preprocess(&extends_text);
1268 let mut extends_parser = Parser { lines: extends_lines, pos: 0 };
1269 let layout_nodes = extends_parser.parse_children(0, ctx);
1271 ctx.base_path = saved_base;
1272 ctx.include_stack.pop();
1274 // Replace @slot placeholders in layout with provided content
1275 let result_nodes = replace_extends_slots(layout_nodes, &slot_nodes);
1276 return Ok(Some(result_nodes));
1279 // --- Function definition ---
1281 if let Some(rest) = content.strip_prefix("@fn ") {
1282 let parts: Vec<&str> = rest.split_whitespace().collect();
1283 if parts.is_empty() {
1284 return Err(ParseError {
1286 message: "@fn requires a name".to_string(),
1289 let name = parts[0].to_string();
1290 let mut params = Vec::new();
1291 let mut defaults = HashMap::new();
1292 for part in &parts[1..] {
1293 let part = part.strip_prefix('$').unwrap_or(part);
1294 if let Some((param_name, default_val)) = part.split_once('=') {
1295 params.push(param_name.to_string());
1296 defaults.insert(param_name.to_string(), default_val.to_string());
1298 params.push(part.to_string());
1302 // Collect body lines (all lines indented deeper than @fn)
1303 let mut body_lines = Vec::new();
1304 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1305 body_lines.push(self.lines[self.pos].clone());
1309 ctx.fn_lines.entry(name.clone()).or_insert(line_num);
1310 ctx.functions.insert(name, FnDef { params, defaults, body_lines });
1314 // --- Function call ---
1316 if content.starts_with('@') {
1317 let name = extract_element_name(&content);
1318 if ctx.functions.contains_key(name) {
1319 ctx.used_functions.insert(name.to_string());
1320 // Emit deprecation warning if function is marked @deprecated
1321 if let Some(msg) = ctx.deprecated_fns.get(name) {
1322 ctx.diagnostics.push(Diagnostic {
1324 message: format!("@{} is deprecated: {}", name, msg),
1325 severity: Severity::Warning,
1326 source_line: Some(content.clone()),
1330 self.expand_fn_call(name, &content, current_indent, line_num, ctx)?;
1331 return Ok(Some(nodes));
1337 if content.starts_with('@') || content.starts_with('[') {
1338 let node = self.parse_element_line(&content, current_indent, line_num, ctx)?;
1339 return Ok(Some(vec![node]));
1342 // --- Bare text ---
1344 let segments = parse_text_segments(&content, ctx);
1345 Ok(Some(vec![Node::Text(segments)]))
1352 current_indent: usize,
1354 ctx: &mut ParseContext,
1355 ) -> Result<Vec<Node>, ParseError> {
1356 // Recursive function cycle detection
1357 if ctx.fn_call_stack.contains(&name.to_string()) {
1358 return Err(ParseError {
1361 "recursive function call to @{} (call stack: {})",
1363 ctx.fn_call_stack.join(" -> ")
1367 ctx.fn_call_stack.push(name.to_string());
1369 // Parse [param value, ...] arguments
1370 let rest = &content[1 + name.len()..];
1371 let rest = rest.trim_start();
1373 let args = if rest.starts_with('[') {
1374 let (attrs, _) = parse_attr_brackets_no_validate(rest, line_num, ctx)?;
1380 // Clone function definition (releases borrow on ctx)
1381 let fn_def = ctx.functions.get(name).unwrap().clone();
1383 // Parse caller's children, separating named slots from default children
1384 let all_caller_children = self.parse_children(current_indent + 1, ctx);
1385 let mut slot_contents: HashMap<String, Vec<Node>> = HashMap::new();
1386 let mut caller_children = Vec::new();
1387 for child in all_caller_children {
1388 if let Node::Element(ref elem) = child {
1389 if let ElementKind::Slot(ref slot_name) = elem.kind {
1390 if !slot_name.is_empty() {
1391 slot_contents.entry(slot_name.clone()).or_default().extend(elem.children.clone());
1396 caller_children.push(child);
1399 // Save variable state, inject function parameters
1400 let saved_vars = ctx.variables.clone();
1401 for (i, param) in fn_def.params.iter().enumerate() {
1404 .find(|a| a.key == *param)
1405 .and_then(|a| a.value.clone())
1406 .or_else(|| args.get(i).and_then(|a| a.value.clone()))
1407 .or_else(|| fn_def.defaults.get(param).cloned())
1408 .unwrap_or_default();
1409 ctx.variables.insert(param.clone(), value);
1412 // Normalize body indentation so it parses from indent 0
1413 let min_indent = fn_def
1419 let adjusted: Vec<Line> = fn_def
1423 indent: l.indent - min_indent,
1424 content: l.content.clone(),
1425 line_num: l.line_num,
1429 // Parse body with params in scope
1430 let mut body_parser = Parser {
1434 let body_nodes = body_parser.parse_children(0, ctx);
1436 // Restore variables and call stack
1437 ctx.variables = saved_vars;
1438 ctx.fn_call_stack.pop();
1440 // Replace @children with caller's children and @slot with slot content
1441 Ok(replace_children_and_slots(body_nodes, &caller_children, &slot_contents))
1444 fn parse_element_line(
1447 current_indent: usize,
1449 ctx: &mut ParseContext,
1450 ) -> Result<Node, ParseError> {
1451 let segments = split_chain(content);
1453 // Parse each segment into an Element
1454 let mut elements: Vec<Element> = Vec::new();
1455 for seg in &segments {
1456 let elem = parse_single_element(seg.trim(), line_num, ctx)?;
1457 elements.push(elem);
1460 // Parse indented children (belong to the innermost element)
1461 let children = self.parse_children(current_indent + 1, ctx);
1463 // Build chain right-to-left: rightmost gets children, each wraps the next
1464 let mut current_children = children;
1465 for mut elem in elements.into_iter().rev() {
1466 elem.children.extend(current_children);
1467 current_children = vec![Node::Element(elem)];
1470 Ok(current_children.into_iter().next().unwrap())
1474// ---------------------------------------------------------------------------
1475// @children replacement
1476// ---------------------------------------------------------------------------
1478fn replace_children_and_slots(
1480 caller_children: &[Node],
1481 slot_contents: &HashMap<String, Vec<Node>>,
1483 let mut result = Vec::new();
1486 Node::Element(elem) if elem.kind == ElementKind::Children => {
1487 result.extend(caller_children.iter().cloned());
1489 Node::Element(elem) if matches!(&elem.kind, ElementKind::Slot(name) if !name.is_empty()) => {
1490 if let ElementKind::Slot(ref name) = elem.kind {
1491 if let Some(content) = slot_contents.get(name) {
1492 result.extend(content.iter().cloned());
1494 // If no content provided for this slot, use the slot's own children as default
1495 else if !elem.children.is_empty() {
1496 result.extend(elem.children);
1500 Node::Element(mut elem) => {
1501 elem.children = replace_children_and_slots(elem.children, caller_children, slot_contents);
1502 result.push(Node::Element(elem));
1504 other => result.push(other),
1510// ---------------------------------------------------------------------------
1512// ---------------------------------------------------------------------------
1514fn extract_element_name(content: &str) -> &str {
1515 let without_at = &content[1..];
1516 match without_at.find(|c: char| c == ' ' || c == '[') {
1517 Some(i) => &without_at[..i],
1522fn parse_single_element(
1525 ctx: &mut ParseContext,
1526) -> Result<Element, ParseError> {
1527 let (kind, rest) = if content.starts_with('[') {
1529 (ElementKind::El, content.to_string())
1530 } else if content.starts_with('@') {
1531 let without_at = &content[1..];
1532 match without_at.find(|c: char| c == ' ' || c == '[') {
1534 let kind_str = &without_at[..i];
1535 let rest = if without_at.as_bytes()[i] == b'[' {
1536 without_at[i..].to_string()
1538 without_at[i + 1..].to_string()
1540 (parse_element_kind(kind_str, line_num)?, rest)
1542 None => (parse_element_kind(without_at, line_num)?, String::new()),
1545 return Err(ParseError {
1547 message: format!("expected @element or [attrs], got: {}", content),
1551 // Parse optional [attrs]
1552 let (attrs, rest) = if rest.starts_with('[') {
1553 parse_attr_brackets(&rest, line_num, ctx)?
1558 let rest = rest.trim().to_string();
1560 // For @link, first token of rest is URL, remainder is inline text
1561 let mut children = Vec::new();
1562 let argument = if rest.is_empty() {
1564 } else if kind == ElementKind::Link {
1565 let rest_sub = substitute_vars(&rest, &ctx.variables);
1566 if let Some((url, text)) = rest_sub.split_once(' ') {
1567 let text = text.trim();
1568 if !text.is_empty() {
1569 children.push(Node::Text(parse_text_segments(text, ctx)));
1571 Some(url.to_string())
1576 Some(substitute_vars(&rest, &ctx.variables))
1579 // For @slot, the argument is the slot name
1580 let kind = if let ElementKind::Slot(_) = kind {
1581 ElementKind::Slot(argument.clone().unwrap_or_default())
1595const KNOWN_ELEMENTS: &[&str] = &[
1596 "row", "column", "col", "el", "text", "paragraph", "p", "image", "img", "link", "children",
1597 "input", "button", "select", "textarea", "option", "label", "slot",
1598 "nav", "header", "footer", "main", "section", "article", "aside",
1599 "list", "item", "li",
1600 "table", "thead", "tbody", "tr", "td", "th",
1602 "form", "details", "summary", "blockquote", "cite", "code", "pre", "hr", "divider",
1603 "figure", "figcaption", "progress", "meter",
1605 "btn", "ul", "dialog", "dl", "dt", "dd", "fieldset", "legend",
1606 "picture", "source", "time", "mark", "kbd", "abbr", "datalist",
1607 "iframe", "output", "canvas",
1608 "grid", "stack", "spacer", "badge", "tooltip",
1609 "avatar", "carousel", "chip", "tag",
1610 "script", "noscript", "address", "search", "breadcrumb",
1613const KNOWN_DIRECTIVES: &[&str] = &[
1614 "page", "let", "define", "fn", "include", "import", "raw", "keyframes",
1615 "if", "else", "each", "meta", "head", "style",
1616 "match", "case", "default", "warn", "debug",
1617 "unless", "og", "breakpoint", "lang", "favicon",
1618 "use", "theme", "deprecated", "extends",
1619 "canonical", "base", "font-face", "json-ld",
1622fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
1624 "row" => Ok(ElementKind::Row),
1625 "column" | "col" => Ok(ElementKind::Column),
1626 "el" => Ok(ElementKind::El),
1627 "text" => Ok(ElementKind::Text),
1628 "paragraph" | "p" => Ok(ElementKind::Paragraph),
1629 "image" | "img" => Ok(ElementKind::Image),
1630 "link" => Ok(ElementKind::Link),
1631 "children" => Ok(ElementKind::Children),
1632 "input" => Ok(ElementKind::Input),
1633 "button" | "btn" => Ok(ElementKind::Button),
1634 "select" => Ok(ElementKind::Select),
1635 "textarea" => Ok(ElementKind::Textarea),
1636 "option" | "opt" => Ok(ElementKind::Option),
1637 "label" => Ok(ElementKind::Label),
1638 "slot" => Ok(ElementKind::Slot(String::new())), // slot name filled in by parse_single_element
1639 "nav" => Ok(ElementKind::Nav),
1640 "header" => Ok(ElementKind::Header),
1641 "footer" => Ok(ElementKind::Footer),
1642 "main" => Ok(ElementKind::Main),
1643 "section" => Ok(ElementKind::Section),
1644 "article" => Ok(ElementKind::Article),
1645 "aside" => Ok(ElementKind::Aside),
1646 "list" => Ok(ElementKind::List),
1647 "item" | "li" => Ok(ElementKind::ListItem),
1648 "table" => Ok(ElementKind::Table),
1649 "thead" => Ok(ElementKind::TableHead),
1650 "tbody" => Ok(ElementKind::TableBody),
1651 "tr" => Ok(ElementKind::TableRow),
1652 "td" => Ok(ElementKind::TableCell),
1653 "th" => Ok(ElementKind::TableHeaderCell),
1654 "video" => Ok(ElementKind::Video),
1655 "audio" => Ok(ElementKind::Audio),
1656 "form" => Ok(ElementKind::Form),
1657 "details" => Ok(ElementKind::Details),
1658 "summary" => Ok(ElementKind::Summary),
1659 "blockquote" => Ok(ElementKind::Blockquote),
1660 "cite" => Ok(ElementKind::Cite),
1661 "code" => Ok(ElementKind::Code),
1662 "pre" => Ok(ElementKind::Pre),
1663 "hr" | "divider" => Ok(ElementKind::HorizontalRule),
1664 "figure" => Ok(ElementKind::Figure),
1665 "figcaption" => Ok(ElementKind::FigCaption),
1666 "progress" => Ok(ElementKind::Progress),
1667 "meter" => Ok(ElementKind::Meter),
1668 "fragment" => Ok(ElementKind::Fragment),
1669 "dialog" => Ok(ElementKind::Dialog),
1670 "dl" => Ok(ElementKind::DefinitionList),
1671 "dt" => Ok(ElementKind::DefinitionTerm),
1672 "dd" => Ok(ElementKind::DefinitionDescription),
1673 "fieldset" => Ok(ElementKind::Fieldset),
1674 "legend" => Ok(ElementKind::Legend),
1675 "picture" => Ok(ElementKind::Picture),
1676 "source" => Ok(ElementKind::Source),
1677 "time" => Ok(ElementKind::Time),
1678 "mark" => Ok(ElementKind::Mark),
1679 "kbd" => Ok(ElementKind::Kbd),
1680 "abbr" => Ok(ElementKind::Abbr),
1681 "datalist" => Ok(ElementKind::Datalist),
1682 "iframe" => Ok(ElementKind::Iframe),
1683 "output" => Ok(ElementKind::Output),
1684 "canvas" => Ok(ElementKind::Canvas),
1685 "ul" => Ok(ElementKind::List),
1686 "grid" => Ok(ElementKind::Grid),
1687 "stack" => Ok(ElementKind::Stack),
1688 "spacer" => Ok(ElementKind::Spacer),
1689 "badge" => Ok(ElementKind::Badge),
1690 "tooltip" => Ok(ElementKind::Tooltip),
1691 "avatar" => Ok(ElementKind::Avatar),
1692 "carousel" => Ok(ElementKind::Carousel),
1693 "chip" => Ok(ElementKind::Chip),
1694 "tag" => Ok(ElementKind::Tag),
1695 "script" => Ok(ElementKind::Script),
1696 "noscript" => Ok(ElementKind::Noscript),
1697 "address" => Ok(ElementKind::Address),
1698 "search" => Ok(ElementKind::Search),
1699 "breadcrumb" => Ok(ElementKind::Breadcrumb),
1701 let all_known: Vec<&str> = KNOWN_ELEMENTS
1703 .chain(KNOWN_DIRECTIVES.iter())
1706 let suggestion = suggest_closest(s, &all_known);
1707 let msg = match suggestion {
1708 Some(closest) => format!("unknown element @{}, did you mean @{}?", s, closest),
1709 None => format!("unknown element @{}", s),
1719fn levenshtein(a: &str, b: &str) -> usize {
1720 let a: Vec<char> = a.chars().collect();
1721 let b: Vec<char> = b.chars().collect();
1722 let mut dp = vec![vec![0usize; b.len() + 1]; a.len() + 1];
1723 for i in 0..=a.len() {
1726 for j in 0..=b.len() {
1729 for i in 1..=a.len() {
1730 for j in 1..=b.len() {
1731 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
1732 dp[i][j] = (dp[i - 1][j] + 1)
1733 .min(dp[i][j - 1] + 1)
1734 .min(dp[i - 1][j - 1] + cost);
1737 dp[a.len()][b.len()]
1740fn suggest_closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
1741 let mut best = None;
1742 let mut best_dist = usize::MAX;
1743 for &candidate in candidates {
1744 let dist = levenshtein(input, candidate);
1745 if dist < best_dist && dist <= 2 && dist < input.len() {
1747 best = Some(candidate);
1753// ---------------------------------------------------------------------------
1755// ---------------------------------------------------------------------------
1757const KNOWN_ATTRS: &[&str] = &[
1759 "spacing", "gap", "padding", "padding-x", "padding-y",
1760 "width", "height", "min-width", "max-width", "min-height", "max-height",
1761 "center-x", "center-y", "align-left", "align-right", "align-top", "align-bottom",
1763 "background", "color", "border", "border-top", "border-bottom", "border-left", "border-right",
1764 "rounded", "bold", "italic", "underline",
1765 "size", "font", "transition", "cursor", "opacity",
1766 "text-align", "line-height", "letter-spacing", "text-transform", "white-space",
1767 "overflow", "position", "top", "right", "bottom", "left", "z-index", "shadow",
1768 "wrap", "gap-x", "gap-y",
1769 // Display & visibility
1770 "display", "visibility",
1771 // Transform & filters
1772 "transform", "backdrop-filter",
1774 "grid", "grid-cols", "grid-rows", "col-span", "row-span",
1775 // Container queries
1776 "container", "container-name", "container-type",
1782 "type", "placeholder", "name", "value", "disabled", "required", "checked",
1783 "for", "action", "method", "autocomplete",
1784 "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
1786 "alt", "role", "tabindex", "title", "autofocus",
1787 // CSS: aspect-ratio, outline, logical properties, scroll-snap
1788 "aspect-ratio", "outline",
1789 "padding-inline", "padding-block", "margin-inline", "margin-block",
1790 "scroll-snap-type", "scroll-snap-align",
1792 "controls", "autoplay", "loop", "muted", "poster", "preload",
1793 "loading", "decoding",
1796 // Media src (explicit attribute form)
1799 "open", "novalidate", "low", "high", "optimum",
1800 "colspan", "rowspan", "scope",
1802 "margin", "margin-x", "margin-y",
1804 "filter", "object-fit", "object-position",
1806 "text-shadow", "text-overflow",
1808 "pointer-events", "user-select",
1809 // Flexbox/grid alignment
1810 "justify-content", "align-items",
1813 // Background extras
1814 "background-size", "background-position", "background-repeat",
1816 "word-break", "overflow-wrap",
1821 // Overflow directional
1822 "overflow-x", "overflow-y",
1825 // Modern form theming
1826 "accent-color", "caret-color",
1830 "border-collapse", "border-spacing",
1831 // Text decoration variants
1832 "text-decoration", "text-decoration-color", "text-decoration-thickness", "text-decoration-style",
1833 "text-underline-offset",
1834 "column-width", "column-rule",
1835 // Grid/flex placement
1836 "place-items", "place-self",
1841 // New CSS properties
1842 "clip-path", "mix-blend-mode", "background-blend-mode", "writing-mode",
1843 "column-count", "column-gap", "text-indent", "hyphens",
1844 "flex-grow", "flex-shrink", "flex-basis", "isolation",
1845 "place-content", "background-image", "datetime", "direction",
1846 // New CSS properties (batch 2)
1847 "font-weight", "font-style", "text-wrap", "will-change", "touch-action",
1848 "vertical-align", "contain", "content-visibility",
1849 "scroll-margin", "scroll-margin-top", "scroll-margin-bottom", "scroll-margin-left", "scroll-margin-right",
1850 "scroll-padding", "scroll-padding-top", "scroll-padding-bottom", "scroll-padding-left", "scroll-padding-right",
1851 // Iframe/output attrs
1852 "sandbox", "allow", "allowfullscreen", "referrerpolicy",
1853 "formaction", "formmethod", "formtarget", "target",
1854 // Script attributes
1855 "defer", "async", "crossorigin", "integrity", "nomodule",
1856 // Pseudo-element content
1859 "truncate", "line-clamp", "blur", "backdrop-blur", "no-scrollbar", "skeleton", "gradient",
1861 "grid-template-areas", "grid-area",
1863 "view-transition-name",
1864 // Animate shorthand
1866 // Critical CSS hint
1870/// Attributes that expect purely numeric values (px-based) or values with CSS units.
1871const NUMERIC_ATTRS: &[&str] = &[
1872 "spacing", "gap", "padding", "padding-x", "padding-y",
1873 "min-width", "max-width", "min-height", "max-height",
1874 "rounded", "size", "gap-x", "gap-y",
1875 "top", "right", "bottom", "left", "letter-spacing",
1878const CSS_UNIT_SUFFIXES: &[&str] = &[
1879 "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh",
1880 "ch", "ex", "cm", "mm", "in", "pt", "pc", "fr",
1883fn has_css_unit(value: &str) -> bool {
1884 CSS_UNIT_SUFFIXES.iter().any(|u| value.ends_with(u))
1885 || value.starts_with("var(")
1886 || value.starts_with("calc(")
1889/// Attributes that accept numeric OR keyword values.
1890const NUMERIC_OR_KEYWORD_ATTRS: &[&str] = &["width", "height"];
1891const SIZE_KEYWORDS: &[&str] = &["fill", "shrink"];
1893fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext) {
1894 let base_key = strip_all_prefixes(attr.key.as_str());
1896 if let Some(val) = &attr.value {
1897 if NUMERIC_ATTRS.contains(&base_key) {
1898 // All space-separated parts must be numeric or have a CSS unit
1899 for part in val.split_whitespace() {
1900 if part.parse::<f64>().is_err() && !has_css_unit(part) {
1901 ctx.diagnostics.push(Diagnostic {
1904 "'{}' expects a numeric value (with optional unit), got '{}'",
1907 severity: Severity::Warning,
1913 } else if NUMERIC_OR_KEYWORD_ATTRS.contains(&base_key) {
1914 let is_keyword = SIZE_KEYWORDS.contains(&val.as_str());
1915 let is_numeric = val.parse::<f64>().is_ok();
1916 let has_unit = has_css_unit(val);
1917 if !is_keyword && !is_numeric && !has_unit {
1918 ctx.diagnostics.push(Diagnostic {
1921 "'{}' expects a number or one of [{}], got '{}'",
1923 SIZE_KEYWORDS.join(", "),
1926 severity: Severity::Warning,
1930 } else if base_key == "opacity" {
1931 if let Ok(v) = val.parse::<f64>() {
1932 if !(0.0..=1.0).contains(&v) {
1933 ctx.diagnostics.push(Diagnostic {
1935 message: format!("'opacity' should be between 0 and 1, got '{}'", val),
1936 severity: Severity::Warning,
1941 ctx.diagnostics.push(Diagnostic {
1943 message: format!("'opacity' expects a numeric value, got '{}'", val),
1944 severity: Severity::Warning,
1948 } else if base_key == "z-index" {
1949 if val.parse::<i32>().is_err() {
1950 ctx.diagnostics.push(Diagnostic {
1952 message: format!("'z-index' expects an integer, got '{}'", val),
1953 severity: Severity::Warning,
1961fn parse_attr_brackets(
1964 ctx: &mut ParseContext,
1965) -> Result<(Vec<Attribute>, String), ParseError> {
1966 parse_attr_brackets_inner(input, line_num, ctx, true)
1969fn parse_attr_brackets_no_validate(
1972 ctx: &mut ParseContext,
1973) -> Result<(Vec<Attribute>, String), ParseError> {
1974 parse_attr_brackets_inner(input, line_num, ctx, false)
1977fn parse_attr_brackets_inner(
1980 ctx: &mut ParseContext,
1982) -> Result<(Vec<Attribute>, String), ParseError> {
1984 let mut end_pos = 0;
1986 for (i, c) in input.char_indices() {
2001 return Err(ParseError {
2003 message: "unclosed '[' in attribute list".to_string(),
2007 let attrs_inner = &input[1..end_pos];
2008 let remaining = input[end_pos + 1..].trim().to_string();
2009 let attrs = parse_attr_list(attrs_inner, line_num, ctx, validate);
2011 Ok((attrs, remaining))
2014fn is_valid_hex_color(s: &str) -> bool {
2015 if !s.starts_with('#') {
2016 return true; // Not a hex color, skip
2019 matches!(hex.len(), 3 | 4 | 6 | 8) && hex.chars().all(|c| c.is_ascii_hexdigit())
2022fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validate: bool) -> Vec<Attribute> {
2023 let mut attrs = Vec::new();
2024 let mut seen_keys: Vec<String> = Vec::new();
2026 for part in split_commas(input) {
2027 let part = part.trim();
2028 if part.is_empty() {
2032 // $define reference — expand
2033 if part.starts_with('$') {
2034 let name = &part[1..];
2035 if let Some(define_attrs) = ctx.defines.get(name) {
2036 ctx.used_defines.insert(name.to_string());
2037 attrs.extend(define_attrs.clone());
2042 // Substitute variables in value
2043 track_var_refs(part, &mut ctx.used_variables);
2044 let part = substitute_vars(part, &ctx.variables);
2046 let attr = if let Some((key, value)) = part.split_once(' ') {
2047 let value = evaluate_if_expr(value.trim());
2049 key: key.trim().to_string(),
2054 key: part.to_string(),
2059 // Warn on duplicate attributes
2061 let stripped = strip_all_prefixes(&attr.key).to_string();
2062 if seen_keys.contains(&stripped) {
2063 ctx.diagnostics.push(Diagnostic {
2065 message: format!("duplicate attribute '{}'", attr.key),
2066 severity: Severity::Warning,
2070 seen_keys.push(stripped);
2073 // Color validation for hex colors
2074 if matches!(strip_all_prefixes(&attr.key), "background" | "color") {
2075 if let Some(ref val) = attr.value {
2076 if val.starts_with('#') && !is_valid_hex_color(val) {
2077 ctx.diagnostics.push(Diagnostic {
2079 message: format!("invalid hex color '{}'", val),
2080 severity: Severity::Warning,
2088 // Warn on unknown attributes
2090 let base_key = strip_all_prefixes(attr.key.as_str());
2091 if !KNOWN_ATTRS.contains(&base_key)
2092 && !base_key.starts_with("aria-")
2093 && !base_key.starts_with("data-")
2095 let suggestion = suggest_closest(base_key, KNOWN_ATTRS);
2096 let msg = match suggestion {
2098 format!("unknown attribute '{}', did you mean '{}'?", attr.key, closest)
2100 None => format!("unknown attribute '{}'", attr.key),
2102 ctx.diagnostics.push(Diagnostic {
2105 severity: Severity::Warning,
2109 validate_attr_value(&attr, line_num, ctx);
2119fn split_commas(input: &str) -> Vec<&str> {
2120 let mut parts = Vec::new();
2124 for (i, c) in input.char_indices() {
2126 '[' | '(' => depth += 1,
2127 ']' | ')' => depth -= 1,
2128 ',' if depth == 0 => {
2129 parts.push(&input[start..i]);
2135 parts.push(&input[start..]);
2139// ---------------------------------------------------------------------------
2140// Chain splitting (the > operator)
2141// ---------------------------------------------------------------------------
2143fn split_chain(content: &str) -> Vec<String> {
2144 let mut segments = Vec::new();
2145 let mut current = String::new();
2146 let chars: Vec<char> = content.chars().collect();
2148 let mut bracket_depth = 0;
2150 while i < chars.len() {
2151 if chars[i] == '[' {
2154 if chars[i] == ']' {
2158 // Match " > @" outside brackets
2159 if bracket_depth == 0
2160 && i + 3 < chars.len()
2162 && chars[i + 1] == '>'
2163 && chars[i + 2] == ' '
2164 && chars[i + 3] == '@'
2166 segments.push(current.trim().to_string());
2167 current = String::new();
2168 i += 3; // skip " > ", keep the "@"
2172 current.push(chars[i]);
2176 if !current.trim().is_empty() {
2177 segments.push(current.trim().to_string());
2183// ---------------------------------------------------------------------------
2184// Text segment parsing (inline {...} elements)
2185// ---------------------------------------------------------------------------
2187fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment> {
2188 let mut segments = Vec::new();
2189 let mut current_text = String::new();
2190 let chars: Vec<char> = input.chars().collect();
2193 while i < chars.len() {
2195 && i + 1 < chars.len()
2196 && chars[i + 1] == '@'
2198 // Flush accumulated plain text
2199 if !current_text.is_empty() {
2200 segments.push(TextSegment::Plain(substitute_vars(
2204 current_text.clear();
2209 let start = i + 1; // after {
2212 if i >= chars.len() {
2215 if chars[i] == '{' {
2217 } else if chars[i] == '}' {
2226 let inner: String = chars[start..i].iter().collect();
2229 if let Ok(elem) = parse_single_element(&inner, 0, ctx) {
2230 segments.push(TextSegment::Inline(elem));
2232 current_text.push('{');
2233 current_text.push_str(&inner);
2234 current_text.push('}');
2237 current_text.push(chars[i]);
2242 if !current_text.is_empty() {
2243 segments.push(TextSegment::Plain(substitute_vars(
2252// ---------------------------------------------------------------------------
2253// Variable substitution
2254// ---------------------------------------------------------------------------
2256// ---------------------------------------------------------------------------
2257// Condition evaluation for @if
2258// ---------------------------------------------------------------------------
2260fn evaluate_condition(condition: &str) -> bool {
2261 if let Some((left, right)) = condition.split_once("!=") {
2262 left.trim() != right.trim()
2263 } else if let Some((left, right)) = condition.split_once("==") {
2264 left.trim() == right.trim()
2266 // Truthy check: non-empty, not "false", not "0"
2267 let trimmed = condition.trim();
2268 !trimmed.is_empty() && trimmed != "false" && trimmed != "0"
2272/// Evaluate `if(condition, true_val, false_val)` expressions in attribute values.
2273fn evaluate_if_expr(input: &str) -> String {
2274 let trimmed = input.trim();
2275 if !trimmed.starts_with("if(") || !trimmed.ends_with(')') {
2276 return input.to_string();
2278 let inner = &trimmed[3..trimmed.len() - 1];
2279 let parts = split_if_args(inner);
2280 if parts.len() != 3 {
2281 return input.to_string();
2283 let condition = parts[0].trim();
2284 let true_val = parts[1].trim();
2285 let false_val = parts[2].trim();
2287 if evaluate_condition(condition) {
2288 true_val.to_string()
2290 false_val.to_string()
2294fn split_if_args(input: &str) -> Vec<&str> {
2295 let mut parts = Vec::new();
2298 for (i, c) in input.char_indices() {
2302 ',' if depth == 0 => {
2303 parts.push(&input[start..i]);
2309 parts.push(&input[start..]);
2313fn evaluate_arithmetic(input: &str) -> String {
2314 let input = input.trim();
2315 for op in &[" * ", " / ", " + ", " - "] {
2316 if let Some((left, right)) = input.split_once(op) {
2317 let left = left.trim();
2318 let right = right.trim();
2319 let op_char = op.trim().chars().next().unwrap();
2320 if let (Ok(l), Ok(r)) = (left.parse::<f64>(), right.parse::<f64>()) {
2321 let result = match op_char {
2325 '/' => if r != 0.0 { l / r } else { return input.to_string() },
2326 _ => return input.to_string(),
2328 if result == result.floor() && result.abs() < i64::MAX as f64 {
2329 return format!("{}", result as i64);
2331 return format!("{}", result);
2338// ---------------------------------------------------------------------------
2339// Post-parse validation (context-dependent warnings)
2340// ---------------------------------------------------------------------------
2342fn strip_all_prefixes(key: &str) -> &str {
2343 // Strip pseudo-state prefixes
2345 .strip_prefix("hover:")
2346 .or_else(|| key.strip_prefix("active:"))
2347 .or_else(|| key.strip_prefix("focus:"))
2348 .or_else(|| key.strip_prefix("focus-visible:"))
2349 .or_else(|| key.strip_prefix("focus-within:"))
2350 .or_else(|| key.strip_prefix("disabled:"))
2351 .or_else(|| key.strip_prefix("checked:"))
2352 .or_else(|| key.strip_prefix("placeholder:"))
2353 .or_else(|| key.strip_prefix("first:"))
2354 .or_else(|| key.strip_prefix("last:"))
2355 .or_else(|| key.strip_prefix("odd:"))
2356 .or_else(|| key.strip_prefix("even:"))
2357 .or_else(|| key.strip_prefix("selection:"))
2358 .or_else(|| key.strip_prefix("before:"))
2359 .or_else(|| key.strip_prefix("after:"))
2360 .or_else(|| key.strip_prefix("visited:"))
2361 .or_else(|| key.strip_prefix("empty:"))
2362 .or_else(|| key.strip_prefix("target:"))
2363 .or_else(|| key.strip_prefix("valid:"))
2364 .or_else(|| key.strip_prefix("invalid:"))
2366 // Handle nth:EXPR: prefix (e.g., nth:3:background -> background)
2367 if key.starts_with("nth:") {
2368 let rest = &key[4..];
2369 rest.find(':').map(|pos| &rest[pos + 1..])
2375 // Handle has(...): prefix (e.g., has(.active):background -> background)
2376 if key.starts_with("has(") {
2377 if let Some(close) = key.find("):") {
2378 Some(&key[close + 2..])
2387 // Strip responsive prefixes
2388 let key = key.strip_prefix("sm:")
2389 .or_else(|| key.strip_prefix("md:"))
2390 .or_else(|| key.strip_prefix("lg:"))
2391 .or_else(|| key.strip_prefix("xl:"))
2392 .or_else(|| key.strip_prefix("2xl:"))
2394 // Strip media prefixes
2395 let key = key.strip_prefix("dark:")
2396 .or_else(|| key.strip_prefix("print:"))
2398 let key = key.strip_prefix("motion-safe:")
2399 .or_else(|| key.strip_prefix("motion-reduce:"))
2400 .or_else(|| key.strip_prefix("landscape:"))
2401 .or_else(|| key.strip_prefix("portrait:"))
2403 // Strip container query prefixes (cq-sm:, cq-md:, etc.)
2404 let key = key.strip_prefix("cq-sm:")
2405 .or_else(|| key.strip_prefix("cq-md:"))
2406 .or_else(|| key.strip_prefix("cq-lg:"))
2407 .or_else(|| key.strip_prefix("cq-xl:"))
2408 .or_else(|| key.strip_prefix("cq-2xl:"))
2413/// Attributes that only make sense on container elements (@row, @column, @el).
2414const CONTAINER_ONLY_ATTRS: &[&str] = &[
2415 "spacing", "gap", "gap-x", "gap-y", "wrap",
2416 "grid", "grid-cols", "grid-rows",
2417 "container", "container-name", "container-type",
2420fn element_kind_name(kind: &ElementKind) -> &'static str {
2422 ElementKind::Row => "@row",
2423 ElementKind::Column => "@column",
2424 ElementKind::El => "@el",
2425 ElementKind::Text => "@text",
2426 ElementKind::Paragraph => "@paragraph",
2427 ElementKind::Image => "@image",
2428 ElementKind::Link => "@link",
2429 ElementKind::Children => "@children",
2430 ElementKind::Input => "@input",
2431 ElementKind::Button => "@button",
2432 ElementKind::Select => "@select",
2433 ElementKind::Textarea => "@textarea",
2434 ElementKind::Option => "@option",
2435 ElementKind::Label => "@label",
2436 ElementKind::Slot(_) => "@slot",
2437 ElementKind::Nav => "@nav",
2438 ElementKind::Header => "@header",
2439 ElementKind::Footer => "@footer",
2440 ElementKind::Main => "@main",
2441 ElementKind::Section => "@section",
2442 ElementKind::Article => "@article",
2443 ElementKind::Aside => "@aside",
2444 ElementKind::List => "@list",
2445 ElementKind::ListItem => "@item",
2446 ElementKind::Table => "@table",
2447 ElementKind::TableHead => "@thead",
2448 ElementKind::TableBody => "@tbody",
2449 ElementKind::TableRow => "@tr",
2450 ElementKind::TableCell => "@td",
2451 ElementKind::TableHeaderCell => "@th",
2452 ElementKind::Video => "@video",
2453 ElementKind::Audio => "@audio",
2454 ElementKind::Form => "@form",
2455 ElementKind::Details => "@details",
2456 ElementKind::Summary => "@summary",
2457 ElementKind::Blockquote => "@blockquote",
2458 ElementKind::Cite => "@cite",
2459 ElementKind::Code => "@code",
2460 ElementKind::Pre => "@pre",
2461 ElementKind::HorizontalRule => "@hr",
2462 ElementKind::Figure => "@figure",
2463 ElementKind::FigCaption => "@figcaption",
2464 ElementKind::Progress => "@progress",
2465 ElementKind::Meter => "@meter",
2466 ElementKind::Fragment => "@fragment",
2467 ElementKind::Dialog => "@dialog",
2468 ElementKind::DefinitionList => "@dl",
2469 ElementKind::DefinitionTerm => "@dt",
2470 ElementKind::DefinitionDescription => "@dd",
2471 ElementKind::Fieldset => "@fieldset",
2472 ElementKind::Legend => "@legend",
2473 ElementKind::Picture => "@picture",
2474 ElementKind::Source => "@source",
2475 ElementKind::Time => "@time",
2476 ElementKind::Mark => "@mark",
2477 ElementKind::Kbd => "@kbd",
2478 ElementKind::Abbr => "@abbr",
2479 ElementKind::Datalist => "@datalist",
2480 ElementKind::Iframe => "@iframe",
2481 ElementKind::Output => "@output",
2482 ElementKind::Canvas => "@canvas",
2483 ElementKind::Grid => "@grid",
2484 ElementKind::Stack => "@stack",
2485 ElementKind::Spacer => "@spacer",
2486 ElementKind::Badge => "@badge",
2487 ElementKind::Tooltip => "@tooltip",
2488 ElementKind::Avatar => "@avatar",
2489 ElementKind::Carousel => "@carousel",
2490 ElementKind::Chip => "@chip",
2491 ElementKind::Tag => "@tag",
2492 ElementKind::Script => "@script",
2493 ElementKind::Noscript => "@noscript",
2494 ElementKind::Address => "@address",
2495 ElementKind::Search => "@search",
2496 ElementKind::Breadcrumb => "@breadcrumb",
2500fn is_container(kind: &ElementKind) -> bool {
2502 ElementKind::Row | ElementKind::Column | ElementKind::El
2503 | ElementKind::Nav | ElementKind::Header | ElementKind::Footer
2504 | ElementKind::Main | ElementKind::Section | ElementKind::Article
2505 | ElementKind::Aside | ElementKind::List | ElementKind::ListItem
2506 | ElementKind::Form | ElementKind::Details | ElementKind::Figure
2507 | ElementKind::Blockquote
2508 | ElementKind::Dialog | ElementKind::DefinitionList | ElementKind::DefinitionTerm
2509 | ElementKind::DefinitionDescription | ElementKind::Fieldset | ElementKind::Datalist
2510 | ElementKind::Iframe | ElementKind::Canvas
2511 | ElementKind::Grid | ElementKind::Stack | ElementKind::Carousel
2512 | ElementKind::Noscript | ElementKind::Address | ElementKind::Search | ElementKind::Breadcrumb
2518 parent_kind: Option<&ElementKind>,
2519 diagnostics: &mut Vec<Diagnostic>,
2522 if let Node::Element(elem) = node {
2523 for attr in &elem.attrs {
2524 let base = strip_all_prefixes(&attr.key);
2525 if base == "width" && attr.value.as_deref() == Some("fill") {
2526 if !matches!(parent_kind, Some(ElementKind::Row)) {
2527 diagnostics.push(Diagnostic {
2528 line: elem.line_num,
2529 message: "'width fill' works best inside @row; using 100% as fallback"
2531 severity: Severity::Warning,
2536 if base == "height" && attr.value.as_deref() == Some("fill") {
2537 if !matches!(parent_kind, Some(ElementKind::Column)) {
2538 diagnostics.push(Diagnostic {
2539 line: elem.line_num,
2541 "'height fill' works best inside @column; using 100% as fallback"
2543 severity: Severity::Warning,
2549 // Container-only attributes on non-container elements
2550 if CONTAINER_ONLY_ATTRS.contains(&base) && !is_container(&elem.kind) {
2551 diagnostics.push(Diagnostic {
2552 line: elem.line_num,
2554 "'{}' has no effect on {} (only works on @row, @column, @el)",
2555 base, element_kind_name(&elem.kind)
2557 severity: Severity::Warning,
2562 // Form-specific: placeholder only on @input/@textarea
2563 if base == "placeholder"
2564 && !matches!(elem.kind, ElementKind::Input | ElementKind::Textarea)
2566 diagnostics.push(Diagnostic {
2567 line: elem.line_num,
2569 "'placeholder' has no effect on {} (only works on @input, @textarea)",
2570 element_kind_name(&elem.kind)
2572 severity: Severity::Warning,
2577 // 'for' only on @label
2578 if base == "for" && !matches!(elem.kind, ElementKind::Label) {
2579 diagnostics.push(Diagnostic {
2580 line: elem.line_num,
2582 "'for' has no effect on {} (only works on @label)",
2583 element_kind_name(&elem.kind)
2585 severity: Severity::Warning,
2590 // 'rows'/'cols' only on @textarea
2591 if (base == "rows" || base == "cols")
2592 && !matches!(elem.kind, ElementKind::Textarea)
2594 diagnostics.push(Diagnostic {
2595 line: elem.line_num,
2597 "'{}' has no effect on {} (only works on @textarea)",
2598 base, element_kind_name(&elem.kind)
2600 severity: Severity::Warning,
2605 // 'ordered' only on @list
2606 if base == "ordered" && !matches!(elem.kind, ElementKind::List) {
2607 diagnostics.push(Diagnostic {
2608 line: elem.line_num,
2610 "'ordered' has no effect on {} (only works on @list)",
2611 element_kind_name(&elem.kind)
2613 severity: Severity::Warning,
2618 // Media-specific attributes only on @video/@audio
2619 if matches!(base, "controls" | "autoplay" | "loop" | "muted" | "poster" | "preload")
2620 && !matches!(elem.kind, ElementKind::Video | ElementKind::Audio)
2622 diagnostics.push(Diagnostic {
2623 line: elem.line_num,
2625 "'{}' has no effect on {} (only works on @video, @audio)",
2626 base, element_kind_name(&elem.kind)
2628 severity: Severity::Warning,
2633 // Missing alt text on @image
2634 if matches!(elem.kind, ElementKind::Image) {
2635 if !elem.attrs.iter().any(|a| a.key == "alt") {
2636 diagnostics.push(Diagnostic {
2637 line: elem.line_num,
2638 message: "@image missing 'alt' attribute (accessibility)".to_string(),
2639 severity: Severity::Warning,
2644 if matches!(elem.kind, ElementKind::Input) {
2645 if !elem.attrs.iter().any(|a| a.key == "type") {
2646 diagnostics.push(Diagnostic {
2647 line: elem.line_num,
2648 message: "@input missing 'type' attribute (defaults to 'text')".to_string(),
2649 severity: Severity::Warning,
2654 if matches!(elem.kind, ElementKind::Link) {
2655 // For @link, argument is the URL, not text content
2656 let has_text = !elem.children.is_empty();
2657 if !has_text && !elem.attrs.iter().any(|a| a.key == "aria-label" || a.key == "title") {
2658 diagnostics.push(Diagnostic {
2659 line: elem.line_num,
2660 message: "@link has no visible text or aria-label (accessibility)".to_string(),
2661 severity: Severity::Warning,
2667 // Contrast ratio check for hex color pairs
2669 let bg_color = elem.attrs.iter()
2670 .find(|a| strip_all_prefixes(&a.key) == "background")
2671 .and_then(|a| a.value.as_deref());
2672 let fg_color = elem.attrs.iter()
2673 .find(|a| strip_all_prefixes(&a.key) == "color")
2674 .and_then(|a| a.value.as_deref());
2675 if let (Some(bg), Some(fg)) = (bg_color, fg_color) {
2676 if let (Some(bg_rgb), Some(fg_rgb)) = (parse_hex_rgb(bg), parse_hex_rgb(fg)) {
2677 let ratio = contrast_ratio(bg_rgb, fg_rgb);
2679 diagnostics.push(Diagnostic {
2680 line: elem.line_num,
2682 "low contrast ratio {:.1}:1 between '{}' and '{}' (WCAG AA requires 4.5:1)",
2685 severity: Severity::Warning,
2693 validate_tree(&elem.children, Some(&elem.kind), diagnostics);
2698fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
2699 let s = s.strip_prefix('#')?;
2702 let r = u8::from_str_radix(&s[0..1], 16).ok()?;
2703 let g = u8::from_str_radix(&s[1..2], 16).ok()?;
2704 let b = u8::from_str_radix(&s[2..3], 16).ok()?;
2705 Some((r * 17, g * 17, b * 17))
2708 let r = u8::from_str_radix(&s[0..2], 16).ok()?;
2709 let g = u8::from_str_radix(&s[2..4], 16).ok()?;
2710 let b = u8::from_str_radix(&s[4..6], 16).ok()?;
2717fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
2718 fn linearize(c: u8) -> f64 {
2719 let s = c as f64 / 255.0;
2720 if s <= 0.03928 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
2722 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
2725fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
2726 let l1 = relative_luminance(c1.0, c1.1, c1.2);
2727 let l2 = relative_luminance(c2.0, c2.1, c2.2);
2728 let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
2729 (lighter + 0.05) / (darker + 0.05)
2732fn lighten_color(rgb: (u8, u8, u8), amount: f64) -> (u8, u8, u8) {
2733 let r = rgb.0 as f64 + (255.0 - rgb.0 as f64) * amount.clamp(0.0, 1.0);
2734 let g = rgb.1 as f64 + (255.0 - rgb.1 as f64) * amount.clamp(0.0, 1.0);
2735 let b = rgb.2 as f64 + (255.0 - rgb.2 as f64) * amount.clamp(0.0, 1.0);
2736 (r.round() as u8, g.round() as u8, b.round() as u8)
2739fn darken_color(rgb: (u8, u8, u8), amount: f64) -> (u8, u8, u8) {
2740 let factor = 1.0 - amount.clamp(0.0, 1.0);
2741 let r = (rgb.0 as f64 * factor).round() as u8;
2742 let g = (rgb.1 as f64 * factor).round() as u8;
2743 let b = (rgb.2 as f64 * factor).round() as u8;
2747fn mix_colors(c1: (u8, u8, u8), c2: (u8, u8, u8), weight: f64) -> (u8, u8, u8) {
2748 let w = weight.clamp(0.0, 1.0);
2749 let r = (c1.0 as f64 * (1.0 - w) + c2.0 as f64 * w).round() as u8;
2750 let g = (c1.1 as f64 * (1.0 - w) + c2.1 as f64 * w).round() as u8;
2751 let b = (c1.2 as f64 * (1.0 - w) + c2.2 as f64 * w).round() as u8;
2755// ---------------------------------------------------------------------------
2756// Variable substitution
2757// ---------------------------------------------------------------------------
2759fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
2760 if !input.contains('$') {
2761 return input.to_string();
2764 let mut result = String::new();
2765 let chars: Vec<char> = input.chars().collect();
2768 while i < chars.len() {
2770 && i + 1 < chars.len()
2771 && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
2774 let mut end = start;
2775 while end < chars.len()
2776 && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
2780 let name: String = chars[start..end].iter().collect();
2782 // Collect pipe filters: $name|filter1|filter2:arg
2783 let mut filters: Vec<String> = Vec::new();
2784 while end < chars.len() && chars[end] == '|' {
2785 end += 1; // skip '|'
2786 let filter_start = end;
2787 while end < chars.len()
2788 && chars[end] != '|'
2789 && chars[end] != ' '
2790 && chars[end] != ','
2791 && chars[end] != ']'
2792 && chars[end] != '}'
2796 let filter: String = chars[filter_start..end].iter().collect();
2797 if !filter.is_empty() {
2798 filters.push(filter);
2802 if let Some(value) = vars.get(&name) {
2803 let mut val = value.clone();
2804 for filter in &filters {
2805 val = apply_filter(&val, filter);
2807 result.push_str(&val);
2810 result.push_str(&name);
2814 result.push(chars[i]);
2822/// Parse a keyframe line in htmlang syntax: `from [opacity 0]` / `50% [transform scale(1.5)]`
2823fn parse_keyframe_line(line: &str) -> Option<String> {
2824 let (selector, rest) = if let Some(rest) = line.strip_prefix("from") {
2825 ("from", rest.trim())
2826 } else if let Some(rest) = line.strip_prefix("to") {
2828 } else if let Some(pct_end) = line.find('%') {
2829 let rest = line[pct_end + 1..].trim();
2830 let selector = &line[..pct_end + 1];
2836 if !rest.starts_with('[') || !rest.ends_with(']') {
2840 let inner = &rest[1..rest.len() - 1];
2841 // Parse comma-separated key-value pairs into CSS
2842 let mut css = String::new();
2843 for part in inner.split(',') {
2844 let part = part.trim();
2845 if part.is_empty() {
2848 if let Some((key, value)) = part.split_once(' ') {
2849 css.push_str(key.trim());
2851 css.push_str(value.trim());
2860 Some(format!("{}{{{}}}", selector, css))
2863/// Replace @slot placeholders in the base layout with content from @extends
2864fn replace_extends_slots(
2866 slot_nodes: &HashMap<String, Vec<Node>>,
2868 let mut result = Vec::new();
2871 Node::Element(elem) if matches!(&elem.kind, ElementKind::Slot(name) if !name.is_empty()) => {
2872 if let ElementKind::Slot(ref name) = elem.kind {
2873 if let Some(content) = slot_nodes.get(name) {
2874 result.extend(content.iter().cloned());
2875 } else if !elem.children.is_empty() {
2876 // Use slot's own children as default
2877 result.extend(elem.children);
2881 Node::Element(mut elem) => {
2882 elem.children = replace_extends_slots(elem.children, slot_nodes);
2883 result.push(Node::Element(elem));
2885 other => result.push(other),
2891fn apply_filter(value: &str, filter: &str) -> String {
2892 if let Some(arg) = filter.strip_prefix("truncate:") {
2893 if let Ok(n) = arg.parse::<usize>() {
2894 if value.len() > n {
2895 return format!("{}...", &value[..n]);
2898 return value.to_string();
2900 if let Some(rest) = filter.strip_prefix("replace:") {
2901 if let Some((old, new)) = rest.split_once(':') {
2902 return value.replace(old, new);
2904 return value.to_string();
2906 if let Some(arg) = filter.strip_prefix("default:") {
2907 if value.is_empty() {
2908 return arg.to_string();
2910 return value.to_string();
2912 // Color functions: lighten:N, darken:N, alpha:N, mix:COLOR:N
2913 if let Some(arg) = filter.strip_prefix("lighten:") {
2914 if let Ok(amount) = arg.parse::<f64>() {
2915 if let Some(rgb) = parse_hex_rgb(value) {
2916 let (r, g, b) = lighten_color(rgb, amount / 100.0);
2917 return format!("#{:02x}{:02x}{:02x}", r, g, b);
2920 return value.to_string();
2922 if let Some(arg) = filter.strip_prefix("darken:") {
2923 if let Ok(amount) = arg.parse::<f64>() {
2924 if let Some(rgb) = parse_hex_rgb(value) {
2925 let (r, g, b) = darken_color(rgb, amount / 100.0);
2926 return format!("#{:02x}{:02x}{:02x}", r, g, b);
2929 return value.to_string();
2931 if let Some(arg) = filter.strip_prefix("alpha:") {
2932 if let Ok(a) = arg.parse::<f64>() {
2933 if let Some((r, g, b)) = parse_hex_rgb(value) {
2934 let a8 = (a.clamp(0.0, 1.0) * 255.0) as u8;
2935 return format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a8);
2938 return value.to_string();
2940 if let Some(arg) = filter.strip_prefix("mix:") {
2941 // mix:COLOR:PERCENTAGE (e.g., mix:#ffffff:50)
2942 let parts: Vec<&str> = arg.splitn(2, ':').collect();
2943 if parts.len() == 2 {
2944 if let (Some(c1), Some(c2), Ok(pct)) = (
2945 parse_hex_rgb(value),
2946 parse_hex_rgb(parts[0]),
2947 parts[1].parse::<f64>(),
2949 let (r, g, b) = mix_colors(c1, c2, pct / 100.0);
2950 return format!("#{:02x}{:02x}{:02x}", r, g, b);
2953 return value.to_string();
2956 "uppercase" | "upper" => value.to_uppercase(),
2957 "lowercase" | "lower" => value.to_lowercase(),
2958 "capitalize" | "cap" => {
2959 let mut chars = value.chars();
2960 match chars.next() {
2961 Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
2962 None => String::new(),
2965 "trim" => value.trim().to_string(),
2966 "length" | "len" => value.len().to_string(),
2967 "reverse" => value.chars().rev().collect(),
2968 _ => value.to_string(),
2972// ---------------------------------------------------------------------------
2973// Variable usage tracking
2974// ---------------------------------------------------------------------------
2976/// Scan a string for $name references and record them in the used set.
2977fn track_var_refs(input: &str, used: &mut HashSet<String>) {
2978 if !input.contains('$') {
2981 let chars: Vec<char> = input.chars().collect();
2983 while i < chars.len() {
2985 && i + 1 < chars.len()
2986 && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
2989 let mut end = start;
2990 while end < chars.len()
2991 && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
2995 let name: String = chars[start..end].iter().collect();
3004// ---------------------------------------------------------------------------
3005// Unused definition warnings
3006// ---------------------------------------------------------------------------
3008fn check_unused(ctx: &mut ParseContext) {
3009 // Check unused @let variables
3010 for (name, &line) in &ctx.let_lines {
3011 if name.starts_with("--") {
3012 continue; // CSS vars are always used
3014 if !ctx.used_variables.contains(name) {
3015 ctx.diagnostics.push(Diagnostic {
3017 message: format!("unused variable '${}' (defined but never referenced)", name),
3018 severity: Severity::Warning,
3024 // Check unused @define bundles
3025 for (name, &line) in &ctx.define_lines {
3026 if !ctx.used_defines.contains(name) {
3027 ctx.diagnostics.push(Diagnostic {
3029 message: format!("unused define '${}' (defined but never referenced)", name),
3030 severity: Severity::Warning,
3036 // Check unused @fn functions
3037 for (name, &line) in &ctx.fn_lines {
3038 if !ctx.used_functions.contains(name) {
3039 ctx.diagnostics.push(Diagnostic {
3041 message: format!("unused function '@{}' (defined but never called)", name),
3042 severity: Severity::Warning,
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list