on 39f698d help sign in
htmlang/src/parser.rs
3047 lines 114 kB history raw
1use std::collections::{HashMap, HashSet};
2use std::fmt;
3use std::path::{Path, PathBuf};
5use crate::ast::*;
7// ---------------------------------------------------------------------------
8// Public types
9// ---------------------------------------------------------------------------
11#[derive(Debug, Clone, PartialEq)]
12pub enum Severity {
13 Error,
14 Warning,
17#[derive(Debug, Clone)]
18pub struct Diagnostic {
19 pub line: usize,
20 pub message: String,
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>,
31#[derive(Debug)]
32struct ParseError {
33 line: usize,
34 message: String,
37impl fmt::Display for ParseError {
38 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
39 write!(f, "line {}: {}", self.line, self.message)
40 }
43// ---------------------------------------------------------------------------
44// Internal types
45// ---------------------------------------------------------------------------
47#[derive(Clone)]
48enum LineContent {
49 Normal(String),
50 Raw(String),
53#[derive(Clone)]
54struct Line {
55 indent: usize,
56 content: LineContent,
57 line_num: usize,
60#[derive(Clone)]
61struct FnDef {
62 params: Vec<String>,
63 defaults: HashMap<String, String>,
64 body_lines: Vec<Line>,
67struct ParseContext {
68 page_title: Option<String>,
69 lang: 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)>,
103 /// Canonical URL
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)>,
109 /// @json-ld blocks
110 json_ld_blocks: Vec<String>,
113struct Parser {
114 lines: Vec<Line>,
115 pos: usize,
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 {
126 page_title: None,
127 lang: None,
128 favicon: None,
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(),
137 og_tags: 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(),
153 canonical: None,
154 base_url: None,
155 font_faces: Vec::new(),
156 json_ld_blocks: Vec::new(),
157 };
158 let nodes = parser.parse_children(0, &mut ctx);
159 validate_tree(&nodes, None, &mut ctx.diagnostics);
160 check_unused(&mut ctx);
161 ParseResult {
162 document: Document {
163 page_title: ctx.page_title,
164 lang: ctx.lang,
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,
180 nodes,
181 },
182 diagnostics: ctx.diagnostics,
183 included_files: ctx.included_files,
184 }
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();
194 let mut i = 0;
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("--") {
201 i += 1;
202 continue;
203 }
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];
216 lines.push(Line {
217 indent,
218 content: LineContent::Raw(content.to_string()),
219 line_num: i + 1,
220 });
221 i += 1;
222 continue;
223 }
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');
230 }
231 i += 1;
232 while i < raw_lines.len() {
233 if raw_lines[i].trim() == "\"\"\"" {
234 i += 1;
235 break;
236 }
237 raw_content.push_str(raw_lines[i]);
238 raw_content.push('\n');
239 i += 1;
240 }
242 lines.push(Line {
243 indent,
244 content: LineContent::Raw(
245 raw_content.trim_end_matches('\n').to_string(),
246 ),
247 line_num: i,
248 });
249 continue;
250 }
251 }
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() {
259 i += 1;
260 let next = raw_lines[i].trim();
261 if next.is_empty() || next.starts_with("--") {
262 continue;
263 }
264 full.push(' ');
265 full.push_str(next);
266 depth += next.chars().filter(|&c| c == '[').count() as i32;
267 depth -= next.chars().filter(|&c| c == ']').count() as i32;
268 }
270 lines.push(Line {
271 indent,
272 content: LineContent::Normal(full),
273 line_num: first_line_num,
274 });
275 i += 1;
276 }
278 lines
281// ---------------------------------------------------------------------------
282// Parser
283// ---------------------------------------------------------------------------
285impl Parser {
286 fn parse_children(
287 &mut self,
288 min_indent: usize,
289 ctx: &mut ParseContext,
290 ) -> Vec<Node> {
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 {
296 break;
297 }
299 match self.parse_line(ctx) {
300 Ok(Some(new_nodes)) => nodes.extend(new_nodes),
301 Ok(None) => {}
302 Err(e) => {
303 ctx.diagnostics.push(Diagnostic {
304 line: e.line,
305 message: e.message,
306 severity: Severity::Error,
307 source_line: None,
308 });
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
312 {
313 self.pos += 1;
314 }
315 }
316 }
317 }
319 nodes
320 }
322 fn parse_line(
323 &mut self,
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();
332 self.pos += 1;
333 return Ok(Some(vec![Node::Raw(content)]));
334 }
336 // Normal content — clone to release borrow
337 let content = match &self.lines[self.pos].content {
338 LineContent::Normal(s) => s.clone(),
339 _ => unreachable!(),
340 };
341 self.pos += 1;
343 // --- Directives ---
345 if let Some(rest) = content.strip_prefix("@page ") {
346 ctx.page_title = Some(substitute_vars(rest, &ctx.variables));
347 return Ok(None);
348 }
350 if let Some(rest) = content.strip_prefix("@lang ") {
351 ctx.lang = Some(substitute_vars(rest.trim(), &ctx.variables));
352 return Ok(None);
353 }
355 if let Some(rest) = content.strip_prefix("@favicon ") {
356 ctx.favicon = Some(substitute_vars(rest.trim(), &ctx.variables));
357 return Ok(None);
358 }
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("= ") {
366 value[2..].trim()
367 } else if value == "=" {
368 ""
369 } else {
370 value
371 };
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]
375 } else {
376 value
377 };
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()));
384 }
385 ctx.variables.insert(name.to_string(), value);
386 ctx.let_lines.entry(name.to_string()).or_insert(line_num);
387 }
388 return Ok(None);
389 }
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));
396 }
397 return Ok(None);
398 }
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()
407 } else {
408 value
409 };
410 ctx.og_tags.push((parts[0].to_string(), value));
411 }
412 return Ok(None);
413 }
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)));
419 }
420 return Ok(None);
421 }
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');
431 }
432 LineContent::Raw(s) => {
433 head_content.push_str(s);
434 head_content.push('\n');
435 }
436 }
437 self.pos += 1;
438 }
439 let trimmed = head_content.trim().to_string();
440 if !trimmed.is_empty() {
441 ctx.head_blocks.push(trimmed);
442 }
443 return Ok(None);
444 }
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');
454 }
455 LineContent::Raw(s) => {
456 style_content.push_str(s);
457 style_content.push('\n');
458 }
459 }
460 self.pos += 1;
461 }
462 let trimmed = style_content.trim().to_string();
463 if !trimmed.is_empty() {
464 ctx.custom_css.push(trimmed);
465 }
466 return Ok(None);
467 }
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);
477 }
478 return Ok(None);
479 }
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),
486 };
488 // Circular include check
489 if ctx.include_stack.contains(&resolved) {
490 ctx.diagnostics.push(Diagnostic {
491 line: line_num,
492 message: format!("circular include '{}'", filename),
493 severity: Severity::Error,
494 source_line: Some(content.clone()),
495 });
496 return Ok(None);
497 }
499 // Use file cache to avoid redundant reads
500 let included_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
501 cached.clone()
502 } else {
503 match std::fs::read_to_string(&resolved) {
504 Ok(text) => {
505 ctx.file_cache.insert(resolved.clone(), text.clone());
506 text
507 }
508 Err(e) => {
509 ctx.diagnostics.push(Diagnostic {
510 line: line_num,
511 message: format!("cannot include '{}': {}", filename, e),
512 severity: Severity::Error,
513 source_line: Some(content.clone()),
514 });
515 return Ok(None);
516 }
517 }
518 };
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,
528 pos: 0,
529 };
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));
535 }
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),
544 };
546 if ctx.include_stack.contains(&resolved) {
547 ctx.diagnostics.push(Diagnostic {
548 line: line_num,
549 message: format!("circular import '{}'", filename),
550 severity: Severity::Error,
551 source_line: Some(content.clone()),
552 });
553 return Ok(None);
554 }
556 let imported_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
557 cached.clone()
558 } else {
559 match std::fs::read_to_string(&resolved) {
560 Ok(text) => {
561 ctx.file_cache.insert(resolved.clone(), text.clone());
562 text
563 }
564 Err(e) => {
565 ctx.diagnostics.push(Diagnostic {
566 line: line_num,
567 message: format!("cannot import '{}': {}", filename, e),
568 severity: Severity::Error,
569 source_line: Some(content.clone()),
570 });
571 return Ok(None);
572 }
573 }
574 };
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,
585 pos: 0,
586 };
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
592 }
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())
604 } else {
605 return Err(ParseError {
606 line: line_num,
607 message: "@use requires: @use \"file.hl\" name1, name2".to_string(),
608 });
609 }
610 } else if let Some((filename, names)) = rest.split_once(' ') {
611 (filename.to_string(), names.to_string())
612 } else {
613 return Err(ParseError {
614 line: line_num,
615 message: "@use requires: @use file.hl name1, name2".to_string(),
616 });
617 };
619 let wanted: HashSet<String> = names_str
620 .split(',')
621 .map(|s| s.trim().to_string())
622 .filter(|s| !s.is_empty())
623 .collect();
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),
629 };
631 if ctx.include_stack.contains(&resolved) {
632 ctx.diagnostics.push(Diagnostic {
633 line: line_num,
634 message: format!("circular use '{}'", filename),
635 severity: Severity::Error,
636 source_line: Some(content.clone()),
637 });
638 return Ok(None);
639 }
641 let use_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
642 cached.clone()
643 } else {
644 match std::fs::read_to_string(&resolved) {
645 Ok(text) => {
646 ctx.file_cache.insert(resolved.clone(), text.clone());
647 text
648 }
649 Err(e) => {
650 ctx.diagnostics.push(Diagnostic {
651 line: line_num,
652 message: format!("cannot use '{}': {}", filename, e),
653 severity: Severity::Error,
654 source_line: Some(content.clone()),
655 });
656 return Ok(None);
657 }
658 }
659 };
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());
680 }
681 if let Some(d) = ctx.defines.get(name) {
682 new_defines.insert(name.clone(), d.clone());
683 }
684 }
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();
692 return Ok(None);
693 }
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());
706 self.pos += 1;
707 }
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
713 loop {
714 if self.pos >= self.lines.len() || self.lines[self.pos].indent != current_indent {
715 break;
716 }
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
726 {
727 body.push(self.lines[self.pos].clone());
728 self.pos += 1;
729 }
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
736 {
737 body.push(self.lines[self.pos].clone());
738 self.pos += 1;
739 }
740 // @else is always true (fallback)
741 branches.push((true, body));
742 break;
743 } else {
744 break;
745 }
746 } else {
747 break;
748 }
749 }
751 // Pick the first branch whose condition is true
752 let body_lines = branches
753 .into_iter()
754 .find(|(cond, _)| *cond)
755 .map(|(_, lines)| lines)
756 .unwrap_or_default();
758 if body_lines.is_empty() {
759 return Ok(None);
760 }
762 let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
763 let adjusted: Vec<Line> = body_lines
764 .iter()
765 .map(|l| Line {
766 indent: l.indent - min_indent,
767 content: l.content.clone(),
768 line_num: l.line_num,
769 })
770 .collect();
772 // Scope variables: @let inside @if doesn't leak out
773 let saved_vars = ctx.variables.clone();
774 let mut body_parser = Parser {
775 lines: adjusted,
776 pos: 0,
777 };
778 let nodes = body_parser.parse_children(0, ctx);
779 ctx.variables = saved_vars;
780 return Ok(Some(nodes));
781 }
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());
790 self.pos += 1;
791 }
792 if !result || body_lines.is_empty() {
793 return Ok(None);
794 }
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,
800 }).collect();
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));
806 }
808 if content.trim() == "@else" || content.trim().starts_with("@else if ") {
809 return Err(ParseError {
810 line: line_num,
811 message: "@else without matching @if".to_string(),
812 });
813 }
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())
825 .collect();
826 (vars, after_in.trim().to_string())
827 } else {
828 return Err(ParseError {
829 line: line_num,
830 message: "@each requires: @each $var in list".to_string(),
831 });
832 };
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))
842 } else {
843 (rest.trim(), 1i64)
844 };
845 if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>()) {
846 if start <= end {
847 let mut items = Vec::new();
848 let mut n = start;
849 while n <= end { items.push(n.to_string()); n += step; }
850 items
851 } else {
852 let mut items = Vec::new();
853 let mut n = start;
854 while n >= end { items.push(n.to_string()); n -= step; }
855 items
856 }
857 } else {
858 list_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
859 }
860 } else {
861 list_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
862 };
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());
868 self.pos += 1;
869 }
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());
879 self.pos += 1;
880 }
881 }
882 }
883 }
885 if body_lines.is_empty() {
886 return Ok(None);
887 }
889 // If list is empty, render @else body
890 if items.is_empty() {
891 if else_lines.is_empty() {
892 return Ok(None);
893 }
894 let min_indent = else_lines.iter().map(|l| l.indent).min().unwrap_or(0);
895 let adjusted: Vec<Line> = else_lines
896 .iter()
897 .map(|l| Line {
898 indent: l.indent - min_indent,
899 content: l.content.clone(),
900 line_num: l.line_num,
901 })
902 .collect();
903 let mut body_parser = Parser {
904 lines: adjusted,
905 pos: 0,
906 };
907 let nodes = body_parser.parse_children(0, ctx);
908 return Ok(Some(nodes));
909 }
911 let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
912 let adjusted: Vec<Line> = body_lines
913 .iter()
914 .map(|l| Line {
915 indent: l.indent - min_indent,
916 content: l.content.clone(),
917 line_num: l.line_num,
918 })
919 .collect();
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() {
928 if has_extra_vars {
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);
934 }
935 } else {
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());
939 }
940 }
941 let mut body_parser = Parser {
942 lines: adjusted.clone(),
943 pos: 0,
944 };
945 let nodes = body_parser.parse_children(0, ctx);
946 all_nodes.extend(nodes);
947 }
949 ctx.variables = saved_vars;
950 return Ok(Some(all_nodes));
951 }
953 // --- @match ---
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());
963 self.pos += 1;
964 }
966 if match_lines.is_empty() {
967 return Ok(None);
968 }
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();
974 let mut mi = 0;
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);
981 mi += 1;
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());
985 mi += 1;
986 }
987 cases.push((Some(case_val), body));
988 } else if trimmed == "@default" {
989 mi += 1;
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());
993 mi += 1;
994 }
995 cases.push((None, body));
996 } else {
997 mi += 1;
998 }
999 } else {
1000 mi += 1;
1002 } else {
1003 mi += 1;
1007 // Find first matching case or @default
1008 let body_lines = cases
1009 .into_iter()
1010 .find(|(case_val, _)| match case_val {
1011 Some(v) => *v == match_val,
1012 None => true,
1013 })
1014 .map(|(_, lines)| lines)
1015 .unwrap_or_default();
1017 if body_lines.is_empty() {
1018 return Ok(None);
1021 let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1022 let adjusted: Vec<Line> = body_lines
1023 .iter()
1024 .map(|l| Line {
1025 indent: l.indent - min_indent,
1026 content: l.content.clone(),
1027 line_num: l.line_num,
1028 })
1029 .collect();
1031 let saved_vars = ctx.variables.clone();
1032 let mut body_parser = Parser {
1033 lines: adjusted,
1034 pos: 0,
1035 };
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 {
1046 line: line_num,
1047 message: msg,
1048 severity: Severity::Warning,
1049 source_line: Some(content.clone()),
1050 });
1051 return Ok(None);
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);
1057 return Ok(None);
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 {
1066 line: line_num,
1067 message: "@keyframes requires a name".to_string(),
1068 });
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);
1078 } else {
1079 body.push_str(trimmed);
1082 self.pos += 1;
1084 ctx.keyframes.push((name, body));
1085 return Ok(None);
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));
1106 self.pos += 1;
1108 ctx.theme_tokens = tokens;
1109 return Ok(None);
1112 // --- @canonical directive ---
1114 if let Some(rest) = content.strip_prefix("@canonical ") {
1115 ctx.canonical = Some(substitute_vars(rest.trim(), &ctx.variables));
1116 return Ok(None);
1119 // --- @base directive ---
1121 if let Some(rest) = content.strip_prefix("@base ") {
1122 ctx.base_url = Some(substitute_vars(rest.trim(), &ctx.variables));
1123 return Ok(None);
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),
1134 ));
1136 return Ok(None);
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());
1147 block.push('\n');
1149 LineContent::Raw(s) => {
1150 block.push_str(s);
1151 block.push('\n');
1154 self.pos += 1;
1156 let trimmed = block.trim().to_string();
1157 if !trimmed.is_empty() {
1158 ctx.json_ld_blocks.push(trimmed);
1160 return Ok(None);
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);
1178 return Ok(None);
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),
1188 };
1190 if ctx.include_stack.contains(&resolved) {
1191 ctx.diagnostics.push(Diagnostic {
1192 line: line_num,
1193 message: format!("circular extends '{}'", filename),
1194 severity: Severity::Error,
1195 source_line: Some(content.clone()),
1196 });
1197 return Ok(None);
1200 let extends_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
1201 cached.clone()
1202 } else {
1203 match std::fs::read_to_string(&resolved) {
1204 Ok(text) => {
1205 ctx.file_cache.insert(resolved.clone(), text.clone());
1206 text
1208 Err(e) => {
1209 ctx.diagnostics.push(Diagnostic {
1210 line: line_num,
1211 message: format!("cannot extend '{}': {}", filename, e),
1212 severity: Severity::Error,
1213 source_line: Some(content.clone()),
1214 });
1215 return Ok(None);
1218 };
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 {
1225 break;
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();
1231 self.pos += 1;
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());
1235 self.pos += 1;
1237 slot_contents.insert(slot_name, slot_lines);
1238 continue;
1241 self.pos += 1;
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() {
1248 continue;
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,
1255 }).collect();
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 {
1285 line: line_num,
1286 message: "@fn requires a name".to_string(),
1287 });
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());
1297 } else {
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());
1306 self.pos += 1;
1309 ctx.fn_lines.entry(name.clone()).or_insert(line_num);
1310 ctx.functions.insert(name, FnDef { params, defaults, body_lines });
1311 return Ok(None);
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 {
1323 line: line_num,
1324 message: format!("@{} is deprecated: {}", name, msg),
1325 severity: Severity::Warning,
1326 source_line: Some(content.clone()),
1327 });
1329 let nodes =
1330 self.expand_fn_call(name, &content, current_indent, line_num, ctx)?;
1331 return Ok(Some(nodes));
1335 // --- Elements ---
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)]))
1348 fn expand_fn_call(
1349 &mut self,
1350 name: &str,
1351 content: &str,
1352 current_indent: usize,
1353 line_num: 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 {
1359 line: line_num,
1360 message: format!(
1361 "recursive function call to @{} (call stack: {})",
1362 name,
1363 ctx.fn_call_stack.join(" -> ")
1364 ),
1365 });
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)?;
1375 attrs
1376 } else {
1377 Vec::new()
1378 };
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());
1392 continue;
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() {
1402 let value = args
1403 .iter()
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
1414 .body_lines
1415 .iter()
1416 .map(|l| l.indent)
1417 .min()
1418 .unwrap_or(0);
1419 let adjusted: Vec<Line> = fn_def
1420 .body_lines
1421 .iter()
1422 .map(|l| Line {
1423 indent: l.indent - min_indent,
1424 content: l.content.clone(),
1425 line_num: l.line_num,
1426 })
1427 .collect();
1429 // Parse body with params in scope
1430 let mut body_parser = Parser {
1431 lines: adjusted,
1432 pos: 0,
1433 };
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(
1445 &mut self,
1446 content: &str,
1447 current_indent: usize,
1448 line_num: 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(
1479 nodes: Vec<Node>,
1480 caller_children: &[Node],
1481 slot_contents: &HashMap<String, Vec<Node>>,
1482) -> Vec<Node> {
1483 let mut result = Vec::new();
1484 for node in nodes {
1485 match node {
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),
1507 result
1510// ---------------------------------------------------------------------------
1511// Element parsing
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],
1518 None => without_at,
1522fn parse_single_element(
1523 content: &str,
1524 line_num: usize,
1525 ctx: &mut ParseContext,
1526) -> Result<Element, ParseError> {
1527 let (kind, rest) = if content.starts_with('[') {
1528 // Implicit @el
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 == '[') {
1533 Some(i) => {
1534 let kind_str = &without_at[..i];
1535 let rest = if without_at.as_bytes()[i] == b'[' {
1536 without_at[i..].to_string()
1537 } else {
1538 without_at[i + 1..].to_string()
1539 };
1540 (parse_element_kind(kind_str, line_num)?, rest)
1542 None => (parse_element_kind(without_at, line_num)?, String::new()),
1544 } else {
1545 return Err(ParseError {
1546 line: line_num,
1547 message: format!("expected @element or [attrs], got: {}", content),
1548 });
1549 };
1551 // Parse optional [attrs]
1552 let (attrs, rest) = if rest.starts_with('[') {
1553 parse_attr_brackets(&rest, line_num, ctx)?
1554 } else {
1555 (Vec::new(), rest)
1556 };
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() {
1563 None
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())
1572 } else {
1573 Some(rest_sub)
1575 } else {
1576 Some(substitute_vars(&rest, &ctx.variables))
1577 };
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())
1582 } else {
1583 kind
1584 };
1586 Ok(Element {
1587 kind,
1588 attrs,
1589 argument,
1590 children,
1591 line_num,
1592 })
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",
1601 "video", "audio",
1602 "form", "details", "summary", "blockquote", "cite", "code", "pre", "hr", "divider",
1603 "figure", "figcaption", "progress", "meter",
1604 "fragment",
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> {
1623 match s {
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),
1700 _ => {
1701 let all_known: Vec<&str> = KNOWN_ELEMENTS
1702 .iter()
1703 .chain(KNOWN_DIRECTIVES.iter())
1704 .copied()
1705 .collect();
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),
1710 };
1711 Err(ParseError {
1712 line: line_num,
1713 message: msg,
1714 })
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() {
1724 dp[i][0] = i;
1726 for j in 0..=b.len() {
1727 dp[0][j] = j;
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() {
1746 best_dist = dist;
1747 best = Some(candidate);
1750 best
1753// ---------------------------------------------------------------------------
1754// Attribute parsing
1755// ---------------------------------------------------------------------------
1757const KNOWN_ATTRS: &[&str] = &[
1758 // Layout
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",
1762 // Style
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",
1773 // Grid
1774 "grid", "grid-cols", "grid-rows", "col-span", "row-span",
1775 // Container queries
1776 "container", "container-name", "container-type",
1777 // Identity
1778 "id", "class",
1779 // Animation
1780 "animation",
1781 // Form
1782 "type", "placeholder", "name", "value", "disabled", "required", "checked",
1783 "for", "action", "method", "autocomplete",
1784 "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
1785 // Accessibility
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",
1791 // Media attributes
1792 "controls", "autoplay", "loop", "muted", "poster", "preload",
1793 "loading", "decoding",
1794 // List
1795 "ordered",
1796 // Media src (explicit attribute form)
1797 "src",
1798 // New elements
1799 "open", "novalidate", "low", "high", "optimum",
1800 "colspan", "rowspan", "scope",
1801 // Margin
1802 "margin", "margin-x", "margin-y",
1803 // Filter & object
1804 "filter", "object-fit", "object-position",
1805 // Text extras
1806 "text-shadow", "text-overflow",
1807 // Interaction
1808 "pointer-events", "user-select",
1809 // Flexbox/grid alignment
1810 "justify-content", "align-items",
1811 // Flex item
1812 "order",
1813 // Background extras
1814 "background-size", "background-position", "background-repeat",
1815 // Text wrapping
1816 "word-break", "overflow-wrap",
1817 // Asset inlining
1818 "inline",
1819 // Hidden
1820 "hidden",
1821 // Overflow directional
1822 "overflow-x", "overflow-y",
1823 // Inset shorthand
1824 "inset",
1825 // Modern form theming
1826 "accent-color", "caret-color",
1827 // List styling
1828 "list-style",
1829 // Table styling
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",
1837 // Scroll behavior
1838 "scroll-behavior",
1839 // Resize
1840 "resize",
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
1857 "content",
1858 // CSS shorthands
1859 "truncate", "line-clamp", "blur", "backdrop-blur", "no-scrollbar", "skeleton", "gradient",
1860 // Grid areas
1861 "grid-template-areas", "grid-area",
1862 // View transitions
1863 "view-transition-name",
1864 // Animate shorthand
1865 "animate",
1866 // Critical CSS hint
1867 "critical",
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 {
1902 line: line_num,
1903 message: format!(
1904 "'{}' expects a numeric value (with optional unit), got '{}'",
1905 attr.key, val
1906 ),
1907 severity: Severity::Warning,
1908 source_line: None,
1909 });
1910 return;
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 {
1919 line: line_num,
1920 message: format!(
1921 "'{}' expects a number or one of [{}], got '{}'",
1922 attr.key,
1923 SIZE_KEYWORDS.join(", "),
1924 val
1925 ),
1926 severity: Severity::Warning,
1927 source_line: None,
1928 });
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 {
1934 line: line_num,
1935 message: format!("'opacity' should be between 0 and 1, got '{}'", val),
1936 severity: Severity::Warning,
1937 source_line: None,
1938 });
1940 } else {
1941 ctx.diagnostics.push(Diagnostic {
1942 line: line_num,
1943 message: format!("'opacity' expects a numeric value, got '{}'", val),
1944 severity: Severity::Warning,
1945 source_line: None,
1946 });
1948 } else if base_key == "z-index" {
1949 if val.parse::<i32>().is_err() {
1950 ctx.diagnostics.push(Diagnostic {
1951 line: line_num,
1952 message: format!("'z-index' expects an integer, got '{}'", val),
1953 severity: Severity::Warning,
1954 source_line: None,
1955 });
1961fn parse_attr_brackets(
1962 input: &str,
1963 line_num: usize,
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(
1970 input: &str,
1971 line_num: usize,
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(
1978 input: &str,
1979 line_num: usize,
1980 ctx: &mut ParseContext,
1981 validate: bool,
1982) -> Result<(Vec<Attribute>, String), ParseError> {
1983 let mut depth = 0;
1984 let mut end_pos = 0;
1986 for (i, c) in input.char_indices() {
1987 match c {
1988 '[' => depth += 1,
1989 ']' => {
1990 depth -= 1;
1991 if depth == 0 {
1992 end_pos = i;
1993 break;
1996 _ => {}
2000 if depth != 0 {
2001 return Err(ParseError {
2002 line: line_num,
2003 message: "unclosed '[' in attribute list".to_string(),
2004 });
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
2018 let hex = &s[1..];
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() {
2029 continue;
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());
2038 continue;
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());
2048 Attribute {
2049 key: key.trim().to_string(),
2050 value: Some(value),
2052 } else {
2053 Attribute {
2054 key: part.to_string(),
2055 value: None,
2057 };
2059 // Warn on duplicate attributes
2060 if validate {
2061 let stripped = strip_all_prefixes(&attr.key).to_string();
2062 if seen_keys.contains(&stripped) {
2063 ctx.diagnostics.push(Diagnostic {
2064 line: line_num,
2065 message: format!("duplicate attribute '{}'", attr.key),
2066 severity: Severity::Warning,
2067 source_line: None,
2068 });
2069 } else {
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 {
2078 line: line_num,
2079 message: format!("invalid hex color '{}'", val),
2080 severity: Severity::Warning,
2081 source_line: None,
2082 });
2088 // Warn on unknown attributes
2089 if validate {
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 {
2097 Some(closest) => {
2098 format!("unknown attribute '{}', did you mean '{}'?", attr.key, closest)
2100 None => format!("unknown attribute '{}'", attr.key),
2101 };
2102 ctx.diagnostics.push(Diagnostic {
2103 line: line_num,
2104 message: msg,
2105 severity: Severity::Warning,
2106 source_line: None,
2107 });
2108 } else {
2109 validate_attr_value(&attr, line_num, ctx);
2113 attrs.push(attr);
2116 attrs
2119fn split_commas(input: &str) -> Vec<&str> {
2120 let mut parts = Vec::new();
2121 let mut start = 0;
2122 let mut depth = 0;
2124 for (i, c) in input.char_indices() {
2125 match c {
2126 '[' | '(' => depth += 1,
2127 ']' | ')' => depth -= 1,
2128 ',' if depth == 0 => {
2129 parts.push(&input[start..i]);
2130 start = i + 1;
2132 _ => {}
2135 parts.push(&input[start..]);
2136 parts
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();
2147 let mut i = 0;
2148 let mut bracket_depth = 0;
2150 while i < chars.len() {
2151 if chars[i] == '[' {
2152 bracket_depth += 1;
2154 if chars[i] == ']' {
2155 bracket_depth -= 1;
2158 // Match " > @" outside brackets
2159 if bracket_depth == 0
2160 && i + 3 < chars.len()
2161 && chars[i] == ' '
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 "@"
2169 continue;
2172 current.push(chars[i]);
2173 i += 1;
2176 if !current.trim().is_empty() {
2177 segments.push(current.trim().to_string());
2180 segments
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();
2191 let mut i = 0;
2193 while i < chars.len() {
2194 if chars[i] == '{'
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(
2201 &current_text,
2202 &ctx.variables,
2203 )));
2204 current_text.clear();
2207 // Find matching }
2208 let mut depth = 0;
2209 let start = i + 1; // after {
2210 i += 1;
2211 loop {
2212 if i >= chars.len() {
2213 break;
2215 if chars[i] == '{' {
2216 depth += 1;
2217 } else if chars[i] == '}' {
2218 if depth == 0 {
2219 break;
2221 depth -= 1;
2223 i += 1;
2226 let inner: String = chars[start..i].iter().collect();
2227 i += 1; // skip }
2229 if let Ok(elem) = parse_single_element(&inner, 0, ctx) {
2230 segments.push(TextSegment::Inline(elem));
2231 } else {
2232 current_text.push('{');
2233 current_text.push_str(&inner);
2234 current_text.push('}');
2236 } else {
2237 current_text.push(chars[i]);
2238 i += 1;
2242 if !current_text.is_empty() {
2243 segments.push(TextSegment::Plain(substitute_vars(
2244 &current_text,
2245 &ctx.variables,
2246 )));
2249 segments
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()
2265 } else {
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()
2289 } else {
2290 false_val.to_string()
2294fn split_if_args(input: &str) -> Vec<&str> {
2295 let mut parts = Vec::new();
2296 let mut start = 0;
2297 let mut depth = 0;
2298 for (i, c) in input.char_indices() {
2299 match c {
2300 '(' => depth += 1,
2301 ')' => depth -= 1,
2302 ',' if depth == 0 => {
2303 parts.push(&input[start..i]);
2304 start = i + 1;
2306 _ => {}
2309 parts.push(&input[start..]);
2310 parts
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 {
2322 '+' => l + r,
2323 '-' => l - r,
2324 '*' => l * r,
2325 '/' => if r != 0.0 { l / r } else { return input.to_string() },
2326 _ => return input.to_string(),
2327 };
2328 if result == result.floor() && result.abs() < i64::MAX as f64 {
2329 return format!("{}", result as i64);
2331 return format!("{}", result);
2335 input.to_string()
2338// ---------------------------------------------------------------------------
2339// Post-parse validation (context-dependent warnings)
2340// ---------------------------------------------------------------------------
2342fn strip_all_prefixes(key: &str) -> &str {
2343 // Strip pseudo-state prefixes
2344 let key = key
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:"))
2365 .or_else(|| {
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..])
2370 } else {
2371 None
2373 })
2374 .or_else(|| {
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..])
2379 } else {
2380 None
2382 } else {
2383 None
2385 })
2386 .unwrap_or(key);
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:"))
2393 .unwrap_or(key);
2394 // Strip media prefixes
2395 let key = key.strip_prefix("dark:")
2396 .or_else(|| key.strip_prefix("print:"))
2397 .unwrap_or(key);
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:"))
2402 .unwrap_or(key);
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:"))
2409 .unwrap_or(key);
2410 key
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 {
2421 match kind {
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 {
2501 matches!(kind,
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
2516fn validate_tree(
2517 nodes: &[Node],
2518 parent_kind: Option<&ElementKind>,
2519 diagnostics: &mut Vec<Diagnostic>,
2520) {
2521 for node in nodes {
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"
2530 .to_string(),
2531 severity: Severity::Warning,
2532 source_line: None,
2533 });
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,
2540 message:
2541 "'height fill' works best inside @column; using 100% as fallback"
2542 .to_string(),
2543 severity: Severity::Warning,
2544 source_line: None,
2545 });
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,
2553 message: format!(
2554 "'{}' has no effect on {} (only works on @row, @column, @el)",
2555 base, element_kind_name(&elem.kind)
2556 ),
2557 severity: Severity::Warning,
2558 source_line: None,
2559 });
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,
2568 message: format!(
2569 "'placeholder' has no effect on {} (only works on @input, @textarea)",
2570 element_kind_name(&elem.kind)
2571 ),
2572 severity: Severity::Warning,
2573 source_line: None,
2574 });
2577 // 'for' only on @label
2578 if base == "for" && !matches!(elem.kind, ElementKind::Label) {
2579 diagnostics.push(Diagnostic {
2580 line: elem.line_num,
2581 message: format!(
2582 "'for' has no effect on {} (only works on @label)",
2583 element_kind_name(&elem.kind)
2584 ),
2585 severity: Severity::Warning,
2586 source_line: None,
2587 });
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,
2596 message: format!(
2597 "'{}' has no effect on {} (only works on @textarea)",
2598 base, element_kind_name(&elem.kind)
2599 ),
2600 severity: Severity::Warning,
2601 source_line: None,
2602 });
2605 // 'ordered' only on @list
2606 if base == "ordered" && !matches!(elem.kind, ElementKind::List) {
2607 diagnostics.push(Diagnostic {
2608 line: elem.line_num,
2609 message: format!(
2610 "'ordered' has no effect on {} (only works on @list)",
2611 element_kind_name(&elem.kind)
2612 ),
2613 severity: Severity::Warning,
2614 source_line: None,
2615 });
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,
2624 message: format!(
2625 "'{}' has no effect on {} (only works on @video, @audio)",
2626 base, element_kind_name(&elem.kind)
2627 ),
2628 severity: Severity::Warning,
2629 source_line: None,
2630 });
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,
2640 source_line: None,
2641 });
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,
2650 source_line: None,
2651 });
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,
2662 source_line: None,
2663 });
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);
2678 if ratio < 4.5 {
2679 diagnostics.push(Diagnostic {
2680 line: elem.line_num,
2681 message: format!(
2682 "low contrast ratio {:.1}:1 between '{}' and '{}' (WCAG AA requires 4.5:1)",
2683 ratio, fg, bg
2684 ),
2685 severity: Severity::Warning,
2686 source_line: None,
2687 });
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('#')?;
2700 match s.len() {
2701 3 => {
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))
2707 6 => {
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()?;
2711 Some((r, g, b))
2713 _ => None,
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;
2744 (r, g, b)
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;
2752 (r, g, b)
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();
2766 let mut i = 0;
2768 while i < chars.len() {
2769 if chars[i] == '$'
2770 && i + 1 < chars.len()
2771 && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
2773 let start = i + 1;
2774 let mut end = start;
2775 while end < chars.len()
2776 && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
2778 end += 1;
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] != '}'
2794 end += 1;
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);
2808 } else {
2809 result.push('$');
2810 result.push_str(&name);
2812 i = end;
2813 } else {
2814 result.push(chars[i]);
2815 i += 1;
2819 result
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") {
2827 ("to", rest.trim())
2828 } else if let Some(pct_end) = line.find('%') {
2829 let rest = line[pct_end + 1..].trim();
2830 let selector = &line[..pct_end + 1];
2831 (selector, rest)
2832 } else {
2833 return None;
2834 };
2836 if !rest.starts_with('[') || !rest.ends_with(']') {
2837 return None;
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() {
2846 continue;
2848 if let Some((key, value)) = part.split_once(' ') {
2849 css.push_str(key.trim());
2850 css.push(':');
2851 css.push_str(value.trim());
2852 css.push(';');
2856 if css.is_empty() {
2857 return None;
2860 Some(format!("{}{{{}}}", selector, css))
2863/// Replace @slot placeholders in the base layout with content from @extends
2864fn replace_extends_slots(
2865 nodes: Vec<Node>,
2866 slot_nodes: &HashMap<String, Vec<Node>>,
2867) -> Vec<Node> {
2868 let mut result = Vec::new();
2869 for node in nodes {
2870 match node {
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),
2888 result
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>(),
2948 ) {
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();
2955 match filter {
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('$') {
2979 return;
2981 let chars: Vec<char> = input.chars().collect();
2982 let mut i = 0;
2983 while i < chars.len() {
2984 if chars[i] == '$'
2985 && i + 1 < chars.len()
2986 && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
2988 let start = i + 1;
2989 let mut end = start;
2990 while end < chars.len()
2991 && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
2993 end += 1;
2995 let name: String = chars[start..end].iter().collect();
2996 used.insert(name);
2997 i = end;
2998 } else {
2999 i += 1;
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 {
3016 line,
3017 message: format!("unused variable '${}' (defined but never referenced)", name),
3018 severity: Severity::Warning,
3019 source_line: None,
3020 });
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 {
3028 line,
3029 message: format!("unused define '${}' (defined but never referenced)", name),
3030 severity: Severity::Warning,
3031 source_line: None,
3032 });
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 {
3040 line,
3041 message: format!("unused function '@{}' (defined but never called)", name),
3042 severity: Severity::Warning,
3043 source_line: None,
3044 });