on master help sign in
Add 17 improvements: @each $_index, @import as, @children fallback, @log directive, bundle/size/--strict CLI, short CSS names, LSP snippets/auto-import, and comprehensive tests
commit2851142eb42f1d39976571b72e196b1fa94a3116 authoriko <mail@iko.soy> dateWed 15 Apr 2026, 22:12 (6 months ago) parentf281deb
5 files changed+604−18
src/bin/htmlang-lsp.rs+58 src/codegen.rs+20−1 src/main.rs+245−6 src/parser.rs+130−11 tests/snapshots.rs+151
src/bin/htmlang-lsp.rs+58
@@ -798,6 +798,41 @@ fn snippet_completions(range: Range) -> Vec<CompletionItem> {
798798 "Text with ellipsis overflow",
799799 "@text [max-width ${1:200}, truncate] ${2:Long text that will be truncated...}",
800800 ),
801+ (
802+ "@fn definition",
803+ "Define a reusable function/component",
804+ "@fn ${1:name} \\$${2:param}\n @el [${3:padding 16}]\n @children",
805+ ),
806+ (
807+ "@each loop",
808+ "Iterate over a list of items",
809+ "@each \\$${1:item} in ${2:items}\n @text \\$${1:item}",
810+ ),
811+ (
812+ "@if conditional",
813+ "Conditional rendering block",
814+ "@if ${1:condition}\n ${2:content}\n@else\n ${3:fallback}",
815+ ),
816+ (
817+ "@match pattern",
818+ "Pattern matching block",
819+ "@match \\$${1:value}\n @case ${2:option1}\n ${3:content}\n @default\n ${4:fallback}",
820+ ),
821+ (
822+ "@for range loop",
823+ "Loop over a numeric range",
824+ "@for \\$${1:i} in ${2:0}..${3:10}\n @text \\$${1:i}",
825+ ),
826+ (
827+ "@data JSON load",
828+ "Load variables from a JSON file",
829+ "@data ${1:data.json}",
830+ ),
831+ (
832+ "@component scoped",
833+ "Define a scoped component with styles",
834+ "@component ${1:name} \\$${2:param}\n @style\n .inner { ${3:padding: 16px;} }\n @el [class inner]\n @children",
835+ ),
801836 ];
802837
803838 snippets
@@ -2848,6 +2883,7 @@ fn code_actions(
28482883 || t == format!("@include {}", rel)
28492884 });
28502885 if !already_imported {
2886+ // Offer @import (all definitions)
28512887 let import_line = format!("@import {}\n", rel);
28522888 let edit = TextEdit {
28532889 range: Range::new(
@@ -2868,6 +2904,28 @@ fn code_actions(
28682904 }),
28692905 ..Default::default()
28702906 }));
2907+
2908+ // Also offer @use (selective import)
2909+ let use_line = format!("@use \"{}\" {}\n", rel, fn_name);
2910+ let use_edit = TextEdit {
2911+ range: Range::new(
2912+ Position::new(0, 0),
2913+ Position::new(0, 0),
2914+ ),
2915+ new_text: use_line,
2916+ };
2917+ let mut use_changes = HashMap::new();
2918+ use_changes.insert(uri.clone(), vec![use_edit]);
2919+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2920+ title: format!("Add '@use \"{}\" {}' (selective)", rel, fn_name),
2921+ kind: Some(CodeActionKind::QUICKFIX),
2922+ diagnostics: Some(vec![diag.clone()]),
2923+ edit: Some(WorkspaceEdit {
2924+ changes: Some(use_changes),
2925+ ..Default::default()
2926+ }),
2927+ ..Default::default()
2928+ }));
28712929 }
28722930 }
28732931 }
src/codegen.rs+20−1
@@ -15,6 +15,25 @@ const BREAKPOINTS: &[(&str, &str)] = &[
1515 ("2xl", "1536px"),
1616];
1717
18+/// Generate short CSS class names: a, b, ..., z, a0, a1, ..., z9, aa, ab, ...
19+fn short_class_name(idx: usize) -> String {
20+ if idx < 26 {
21+ return String::from((b'a' + idx as u8) as char);
22+ }
23+ let mut n = idx - 26;
24+ let mut name = String::new();
25+ // First char is always a letter
26+ name.push((b'a' + (n % 26) as u8) as char);
27+ n /= 26;
28+ loop {
29+ name.push((b'a' + (n % 36).min(25) as u8) as char);
30+ if n < 36 { break; }
31+ n /= 36;
32+ }
33+ // Reverse so it reads naturally
34+ name.chars().rev().collect()
35+}
36+
1837struct StyleEntry {
1938 class_name: String,
2039 base: String,
@@ -93,8 +112,8 @@ impl StyleCollector {
93112 if let Some(&idx) = self.index.get(&key) {
94113 return Some(self.entries[idx].class_name.clone());
95114 }
96− let name = format!("_{}", self.entries.len());
97115 let idx = self.entries.len();
116+ let name = short_class_name(idx);
98117 self.entries.push(StyleEntry {
99118 class_name: name.clone(),
100119 base,
src/main.rs+245−6
@@ -14,10 +14,10 @@ struct DiagnosticJson {
1414}
1515
1616fn compile(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>, format_json: bool, json_collector: Option<&Mutex<Vec<DiagnosticJson>>>) -> (bool, Vec<PathBuf>) {
17− compile_inner(input_path, dev, error_overlay, check_only, output_path, format_json, json_collector, false, false)
17+ compile_inner(input_path, dev, error_overlay, check_only, output_path, format_json, json_collector, false, false, false)
1818}
1919
20−fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>, format_json: bool, json_collector: Option<&Mutex<Vec<DiagnosticJson>>>, minify: bool, compat: bool) -> (bool, Vec<PathBuf>) {
20+fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>, format_json: bool, json_collector: Option<&Mutex<Vec<DiagnosticJson>>>, minify: bool, compat: bool, strict: bool) -> (bool, Vec<PathBuf>) {
2121 let input = match fs::read_to_string(input_path) {
2222 Ok(s) => s,
2323 Err(e) => {
@@ -72,7 +72,8 @@ fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: b
7272 let has_errors = result
7373 .diagnostics
7474 .iter()
75− .any(|d| d.severity == htmlang::parser::Severity::Error);
75+ .any(|d| d.severity == htmlang::parser::Severity::Error
76+ || (strict && d.severity == htmlang::parser::Severity::Warning));
7677
7778 let out_path = match output_path {
7879 Some(p) => PathBuf::from(p),
@@ -143,6 +144,107 @@ fn kebab_to_title(s: &str) -> String {
143144 .join(" ")
144145}
145146
147+fn format_bytes(bytes: usize) -> String {
148+ if bytes >= 1_048_576 {
149+ format!("{:.1}MB", bytes as f64 / 1_048_576.0)
150+ } else if bytes >= 1024 {
151+ format!("{:.1}KB", bytes as f64 / 1024.0)
152+ } else {
153+ format!("{}B", bytes)
154+ }
155+}
156+
157+fn bundle_assets(html: &str, base_dir: &Path) -> String {
158+ use std::io::Read;
159+ let mut result = html.to_string();
160+
161+ // Inline images referenced as src="..." in <img> tags
162+ let img_re_patterns = ["src=\"", "url("];
163+ for pattern in &img_re_patterns {
164+ let mut output = String::new();
165+ let mut remaining = result.as_str();
166+ while let Some(pos) = remaining.find(pattern) {
167+ output.push_str(&remaining[..pos + pattern.len()]);
168+ remaining = &remaining[pos + pattern.len()..];
169+
170+ // Find the closing delimiter
171+ let close_char = if *pattern == "src=\"" { '"' } else { ')' };
172+ if let Some(end) = remaining.find(close_char) {
173+ let path_str = remaining[..end].trim_matches(|c| c == '\'' || c == '"');
174+
175+ // Skip data URIs, remote URLs, and anchors
176+ if !path_str.starts_with("data:")
177+ && !path_str.starts_with("http://")
178+ && !path_str.starts_with("https://")
179+ && !path_str.starts_with('#')
180+ && !path_str.is_empty()
181+ {
182+ let asset_path = base_dir.join(path_str);
183+ if asset_path.exists() {
184+ if let Ok(mut file) = std::fs::File::open(&asset_path) {
185+ let mut buf = Vec::new();
186+ if file.read_to_end(&mut buf).is_ok() {
187+ let mime = match asset_path.extension().and_then(|e| e.to_str()) {
188+ Some("png") => "image/png",
189+ Some("jpg") | Some("jpeg") => "image/jpeg",
190+ Some("gif") => "image/gif",
191+ Some("svg") => "image/svg+xml",
192+ Some("webp") => "image/webp",
193+ Some("ico") => "image/x-icon",
194+ Some("woff2") => "font/woff2",
195+ Some("woff") => "font/woff",
196+ Some("ttf") => "font/ttf",
197+ Some("otf") => "font/otf",
198+ Some("avif") => "image/avif",
199+ _ => "application/octet-stream",
200+ };
201+ use std::fmt::Write as FmtWrite;
202+ let mut b64 = String::new();
203+ // Simple base64 encoding
204+ let encoded = base64_encode(&buf);
205+ let _ = write!(b64, "data:{};base64,{}", mime, encoded);
206+ output.push_str(&b64);
207+ remaining = &remaining[end..];
208+ continue;
209+ }
210+ }
211+ }
212+ }
213+ // If we couldn't inline, keep original path
214+ output.push_str(&remaining[..end]);
215+ remaining = &remaining[end..];
216+ }
217+ }
218+ output.push_str(remaining);
219+ result = output;
220+ }
221+ result
222+}
223+
224+fn base64_encode(data: &[u8]) -> String {
225+ const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
226+ let mut result = String::with_capacity((data.len() + 2) / 3 * 4);
227+ for chunk in data.chunks(3) {
228+ let b0 = chunk[0] as u32;
229+ let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
230+ let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
231+ let n = (b0 << 16) | (b1 << 8) | b2;
232+ result.push(CHARS[((n >> 18) & 63) as usize] as char);
233+ result.push(CHARS[((n >> 12) & 63) as usize] as char);
234+ if chunk.len() > 1 {
235+ result.push(CHARS[((n >> 6) & 63) as usize] as char);
236+ } else {
237+ result.push('=');
238+ }
239+ if chunk.len() > 2 {
240+ result.push(CHARS[(n & 63) as usize] as char);
241+ } else {
242+ result.push('=');
243+ }
244+ }
245+ result
246+}
247+
146248fn copy_non_hl_files(src_dir: &Path, out_dir: &Path) {
147249 copy_non_hl_recursive(src_dir, src_dir, out_dir);
148250}
@@ -239,6 +341,8 @@ Commands:
239341 outline <file.hl> Show document structure tree
240342 doctor Check toolchain health
241343 migrate [dir|file] Auto-upgrade deprecated syntax
344+ bundle <file|dir> [-o <out>] Compile and inline all assets as data URIs
345+ size <file|dir> Report output sizes (raw, minified, ~gzip)
242346 benchmark <file|dir> Measure compile time and output size
243347
244348Options:
@@ -250,6 +354,7 @@ Options:
250354 -d, --dev Development mode
251355 -c, --check Check for errors without writing output
252356 --compat Add vendor prefixes for broader browser support
357+ --strict Treat warnings as errors (useful for CI)
253358 --format json Output diagnostics as JSON to stdout
254359 -h, --help Show this help
255360 -V, --version Show version
@@ -681,6 +786,7 @@ fn main() {
681786 let mut check = false;
682787 let mut format_json = false;
683788 let mut compat = false;
789+ let mut strict = false;
684790 let mut open_browser = false;
685791 let mut port: u16 = 3000;
686792 let mut output_path: Option<String> = None;
@@ -789,7 +895,7 @@ fn main() {
789895 }
790896 let path_str = file.to_string_lossy().to_string();
791897 let (has_errors, _) = if build_minify || build_compat {
792− compile_inner(&path_str, false, false, false, effective_out.as_deref(), false, None, build_minify, build_compat)
898+ compile_inner(&path_str, false, false, false, effective_out.as_deref(), false, None, build_minify, build_compat, false)
793899 } else {
794900 compile(&path_str, false, false, false, effective_out.as_deref(), false, None)
795901 };
@@ -2171,6 +2277,138 @@ compile();
21712277 return;
21722278 }
21732279
2280+ // Handle "bundle" subcommand — inline images/fonts as data URIs
2281+ if args.len() >= 2 && args[1] == "bundle" {
2282+ if args.len() < 3 {
2283+ eprintln!("usage: htmlang bundle <file.hl | dir> [-o <out>]");
2284+ process::exit(1);
2285+ }
2286+ let mut bundle_target = None;
2287+ let mut bundle_out = None;
2288+ let mut bi = 2;
2289+ while bi < args.len() {
2290+ match args[bi].as_str() {
2291+ "-o" | "--output" => {
2292+ bi += 1;
2293+ bundle_out = args.get(bi).map(|s| s.as_str());
2294+ }
2295+ _ if bundle_target.is_none() => bundle_target = Some(args[bi].as_str()),
2296+ _ => {
2297+ eprintln!("unknown argument: {}", args[bi]);
2298+ process::exit(1);
2299+ }
2300+ }
2301+ bi += 1;
2302+ }
2303+ let target = bundle_target.unwrap_or(".");
2304+ let path = Path::new(target);
2305+ let hl_files = if path.is_dir() {
2306+ collect_hl_files_recursive(path)
2307+ } else {
2308+ vec![PathBuf::from(target)]
2309+ };
2310+ if hl_files.is_empty() {
2311+ eprintln!("no .hl files found in {}", target);
2312+ process::exit(1);
2313+ }
2314+ for file in &hl_files {
2315+ let input = match fs::read_to_string(file) {
2316+ Ok(s) => s,
2317+ Err(e) => {
2318+ eprintln!("error: {}: {}", file.display(), e);
2319+ continue;
2320+ }
2321+ };
2322+ let base = file.parent();
2323+ let result = htmlang::parser::parse_with_base(&input, base);
2324+ if result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
2325+ for d in &result.diagnostics {
2326+ if d.severity == htmlang::parser::Severity::Error {
2327+ eprintln!("error: line {}: {}", d.line, d.message);
2328+ }
2329+ }
2330+ continue;
2331+ }
2332+ let html = htmlang::codegen::generate(&result.document);
2333+ // Inline external assets: images and fonts referenced in the HTML
2334+ let bundled = bundle_assets(&html, base.unwrap_or(Path::new(".")));
2335+ let out_path = match bundle_out {
2336+ Some(o) => {
2337+ let p = Path::new(o);
2338+ if p.is_dir() || hl_files.len() > 1 {
2339+ let _ = fs::create_dir_all(p);
2340+ let rel = file.strip_prefix(path).unwrap_or(file);
2341+ p.join(rel).with_extension("html")
2342+ } else {
2343+ PathBuf::from(o)
2344+ }
2345+ }
2346+ None => file.with_extension("html"),
2347+ };
2348+ match fs::write(&out_path, &bundled) {
2349+ Ok(()) => eprintln!("bundled {}", out_path.display()),
2350+ Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
2351+ }
2352+ }
2353+ return;
2354+ }
2355+
2356+ // Handle "size" subcommand — report output sizes with gzip estimates
2357+ if args.len() >= 2 && args[1] == "size" {
2358+ let target = if args.len() >= 3 { &args[2] } else { "." };
2359+ let path = Path::new(target);
2360+ let hl_files = if path.is_dir() {
2361+ collect_hl_files_recursive(path)
2362+ } else {
2363+ vec![PathBuf::from(target)]
2364+ };
2365+ if hl_files.is_empty() {
2366+ eprintln!("no .hl files found in {}", target);
2367+ process::exit(1);
2368+ }
2369+ let mut total_source = 0usize;
2370+ let mut total_output = 0usize;
2371+ let mut total_minified = 0usize;
2372+ for file in &hl_files {
2373+ let input = match fs::read_to_string(file) {
2374+ Ok(s) => s,
2375+ Err(_) => continue,
2376+ };
2377+ total_source += input.len();
2378+ let base = file.parent();
2379+ let result = htmlang::parser::parse_with_base(&input, base);
2380+ if result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
2381+ continue;
2382+ }
2383+ let html = htmlang::codegen::generate(&result.document);
2384+ let minified = htmlang::codegen::generate_minified(&result.document);
2385+ let html_len = html.len();
2386+ let min_len = minified.len();
2387+ // Estimate gzip size as ~30% of minified (rough heuristic for HTML)
2388+ let gzip_est = min_len * 30 / 100;
2389+ total_output += html_len;
2390+ total_minified += min_len;
2391+ eprintln!(
2392+ " {}: {} → {} (minified: {}, ~gzip: {})",
2393+ file.display(),
2394+ format_bytes(input.len()),
2395+ format_bytes(html_len),
2396+ format_bytes(min_len),
2397+ format_bytes(gzip_est),
2398+ );
2399+ }
2400+ eprintln!("---");
2401+ let total_gzip = total_minified * 30 / 100;
2402+ eprintln!(
2403+ " total: {} source → {} output (minified: {}, ~gzip: {})",
2404+ format_bytes(total_source),
2405+ format_bytes(total_output),
2406+ format_bytes(total_minified),
2407+ format_bytes(total_gzip),
2408+ );
2409+ return;
2410+ }
2411+
21742412 // Handle "benchmark" subcommand — measure compile time and output size
21752413 if args.len() >= 2 && args[1] == "benchmark" {
21762414 if args.len() < 3 {
@@ -2427,6 +2665,7 @@ compile();
24272665 "--dev" | "-d" => dev = true,
24282666 "--check" | "-c" => check = true,
24292667 "--compat" => compat = true,
2668+ "--strict" => strict = true,
24302669 "--open" => open_browser = true,
24312670 "--format" => {
24322671 i += 1;
@@ -2517,7 +2756,7 @@ compile();
25172756 }
25182757 out_p.to_string_lossy().to_string()
25192758 });
2520− let (has_errors, included) = compile_inner(&path_str, dev, serve, check, effective_out.as_deref(), format_json, json_collector.as_ref(), false, compat);
2759+ let (has_errors, included) = compile_inner(&path_str, dev, serve, check, effective_out.as_deref(), format_json, json_collector.as_ref(), false, compat, strict);
25212760 if has_errors {
25222761 any_errors = true;
25232762 }
@@ -2560,7 +2799,7 @@ compile();
25602799
25612800 // --- Single file mode ---
25622801 let json_collector_single = if format_json { Some(Mutex::new(Vec::new())) } else { None };
2563− let (has_errors, included_files) = compile_inner(&input_path, dev, serve, check, output_path.as_deref(), format_json, json_collector_single.as_ref(), false, compat);
2802+ let (has_errors, included_files) = compile_inner(&input_path, dev, serve, check, output_path.as_deref(), format_json, json_collector_single.as_ref(), false, compat, strict);
25642803 if format_json {
25652804 if let Some(ref collector) = json_collector_single {
25662805 print_json_diagnostics(&collector.lock().unwrap());
src/parser.rs+130−11
@@ -577,7 +577,20 @@ impl Parser {
577577 // --- @import (definitions only, no DOM nodes) ---
578578
579579 if let Some(rest) = content.strip_prefix("@import ") {
580− let filename = substitute_vars(rest.trim(), &ctx.variables);
580+ let rest = rest.trim();
581+ // Support @import "file.hl" as prefix — namespace imported definitions
582+ let (filename, alias) = if let Some((file_part, alias_part)) = rest.rsplit_once(" as ") {
583+ (file_part.trim().to_string(), Some(alias_part.trim().to_string()))
584+ } else {
585+ (rest.to_string(), None)
586+ };
587+ // Strip quotes from filename
588+ let filename = if filename.starts_with('"') && filename.ends_with('"') && filename.len() >= 2 {
589+ filename[1..filename.len()-1].to_string()
590+ } else {
591+ filename
592+ };
593+ let filename = substitute_vars(&filename, &ctx.variables);
581594 let resolved = match &ctx.base_path {
582595 Some(base) => base.join(&filename),
583596 None => PathBuf::from(&filename),
@@ -618,13 +631,67 @@ impl Parser {
618631 let saved_base = ctx.base_path.clone();
619632 ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
620633
621− // Parse the file but discard DOM nodes — only keep definitions
622− let imported_lines = preprocess(&imported_text);
623− let mut imported_parser = Parser {
624− lines: imported_lines,
625− pos: 0,
626− };
627− let _discarded_nodes = imported_parser.parse_children(0, ctx);
634+ if let Some(ref prefix) = alias {
635+ // Parse into a temporary context, then copy definitions with prefix
636+ let saved_fns = ctx.functions.clone();
637+ let saved_defines = ctx.defines.clone();
638+ let saved_mixins = ctx.mixins.clone();
639+ let saved_vars = ctx.variables.clone();
640+
641+ let imported_lines = preprocess(&imported_text);
642+ let mut imported_parser = Parser {
643+ lines: imported_lines,
644+ pos: 0,
645+ };
646+ let _discarded_nodes = imported_parser.parse_children(0, ctx);
647+
648+ // Find newly added definitions and re-register with prefix
649+ let new_fns: Vec<(String, FnDef)> = ctx.functions.iter()
650+ .filter(|(k, _)| !saved_fns.contains_key(*k))
651+ .map(|(k, v)| (k.clone(), v.clone()))
652+ .collect();
653+ let new_defines: Vec<(String, Vec<Attribute>)> = ctx.defines.iter()
654+ .filter(|(k, _)| !saved_defines.contains_key(*k))
655+ .map(|(k, v)| (k.clone(), v.clone()))
656+ .collect();
657+ let new_mixins: Vec<(String, Vec<Attribute>)> = ctx.mixins.iter()
658+ .filter(|(k, _)| !saved_mixins.contains_key(*k))
659+ .map(|(k, v)| (k.clone(), v.clone()))
660+ .collect();
661+ let new_vars: Vec<(String, String)> = ctx.variables.iter()
662+ .filter(|(k, _)| !saved_vars.contains_key(*k))
663+ .map(|(k, v)| (k.clone(), v.clone()))
664+ .collect();
665+
666+ // Restore originals, then add prefixed versions
667+ ctx.functions = saved_fns;
668+ ctx.defines = saved_defines;
669+ ctx.mixins = saved_mixins;
670+ ctx.variables = saved_vars;
671+
672+ for (name, def) in new_fns {
673+ ctx.functions.insert(format!("{}.{}", prefix, name), def);
674+ }
675+ for (name, attrs) in new_defines {
676+ ctx.defines.insert(format!("{}.{}", prefix, name), attrs);
677+ }
678+ for (name, attrs) in new_mixins {
679+ ctx.mixins.insert(format!("{}.{}", prefix, name), attrs);
680+ }
681+ for (name, val) in new_vars {
682+ if !name.starts_with("__") {
683+ ctx.variables.insert(format!("{}.{}", prefix, name), val);
684+ }
685+ }
686+ } else {
687+ // Parse the file but discard DOM nodes — only keep definitions
688+ let imported_lines = preprocess(&imported_text);
689+ let mut imported_parser = Parser {
690+ lines: imported_lines,
691+ pos: 0,
692+ };
693+ let _discarded_nodes = imported_parser.parse_children(0, ctx);
694+ }
628695
629696 ctx.base_path = saved_base;
630697 ctx.include_stack.pop();
@@ -1044,6 +1111,8 @@ impl Parser {
10441111 || (var_names.len() == 2 && items.first().map_or(false, |it| it.contains(' ')));
10451112
10461113 for (i, item) in items.iter().enumerate() {
1114+ // Always expose $_index for the current iteration
1115+ ctx.variables.insert("_index".to_string(), i.to_string());
10471116 if has_extra_vars {
10481117 // Destructuring: split item by spaces and assign to each variable
10491118 let parts: Vec<&str> = item.splitn(var_names.len(), ' ').collect();
@@ -1358,6 +1427,46 @@ impl Parser {
13581427 return Ok(None);
13591428 }
13601429
1430+ // --- @log (compile-time variable inspection) ---
1431+
1432+ if let Some(rest) = content.strip_prefix("@log ") {
1433+ let rest = rest.trim();
1434+ // @log $var — shows variable name, value, and type info
1435+ let var_names: Vec<&str> = rest.split_whitespace().collect();
1436+ for var_name in &var_names {
1437+ let name = var_name.strip_prefix('$').unwrap_or(var_name);
1438+ ctx.used_variables.insert(name.to_string());
1439+ if let Some(val) = ctx.variables.get(name) {
1440+ let kind = if val.parse::<f64>().is_ok() {
1441+ "number"
1442+ } else if val.starts_with('#') && is_valid_hex_color(val) {
1443+ "color"
1444+ } else if val.contains('/') || val.ends_with(".hl") || val.ends_with(".html") {
1445+ "path"
1446+ } else {
1447+ "string"
1448+ };
1449+ eprintln!("log: line {}: ${} = \"{}\" ({})", line_num, name, val, kind);
1450+ } else if ctx.functions.contains_key(name) {
1451+ let def = &ctx.functions[name];
1452+ let params: Vec<String> = def.params.iter().map(|p| format!("${}", p)).collect();
1453+ eprintln!("log: line {}: @{} ({}) — {} line(s)", line_num, name, params.join(", "), def.body_lines.len());
1454+ } else if ctx.defines.contains_key(name) {
1455+ let attrs = &ctx.defines[name];
1456+ let attr_strs: Vec<String> = attrs.iter().map(|a| {
1457+ match &a.value {
1458+ Some(v) => format!("{} {}", a.key, v),
1459+ None => a.key.clone(),
1460+ }
1461+ }).collect();
1462+ eprintln!("log: line {}: ${} = [{}]", line_num, name, attr_strs.join(", "));
1463+ } else {
1464+ eprintln!("log: line {}: ${} is undefined", line_num, name);
1465+ }
1466+ }
1467+ return Ok(None);
1468+ }
1469+
13611470 // --- @keyframes directive ---
13621471
13631472 if let Some(rest) = content.strip_prefix("@keyframes ") {
@@ -1871,7 +1980,12 @@ fn replace_children_and_slots(
18711980 for node in nodes {
18721981 match node {
18731982 Node::Element(elem) if elem.kind == ElementKind::Children => {
1874− result.extend(caller_children.iter().cloned());
1983+ if caller_children.is_empty() && !elem.children.is_empty() {
1984+ // Use @children's own children as default/fallback content
1985+ result.extend(elem.children);
1986+ } else {
1987+ result.extend(caller_children.iter().cloned());
1988+ }
18751989 }
18761990 Node::Element(elem) if matches!(&elem.kind, ElementKind::Slot(name) if !name.is_empty()) => {
18771991 if let ElementKind::Slot(ref name) = elem.kind {
@@ -2006,6 +2120,7 @@ const KNOWN_DIRECTIVES: &[&str] = &[
20062120 "canonical", "base", "font-face", "json-ld",
20072121 "mixin", "assert",
20082122 "for", "component", "switch",
2123+ "log",
20092124];
20102125
20112126fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
@@ -2431,7 +2546,7 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
24312546 continue;
24322547 }
24332548
2434− // ...$mixin spread — expand mixin attributes
2549+ // ...$name spread — expand mixin or define attributes
24352550 if part.starts_with("...$") {
24362551 let name = &part[4..];
24372552 if let Some(mixin_attrs) = ctx.mixins.get(name) {
@@ -2439,7 +2554,11 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
24392554 attrs.extend(mixin_attrs.clone());
24402555 continue;
24412556 }
2442− // Fall through to try as define
2557+ if let Some(define_attrs) = ctx.defines.get(name) {
2558+ ctx.used_defines.insert(name.to_string());
2559+ attrs.extend(define_attrs.clone());
2560+ continue;
2561+ }
24432562 }
24442563
24452564 // $define reference — expand
tests/snapshots.rs+151
@@ -3299,3 +3299,154 @@ fn test_compat_vendor_prefixes() {
32993299 assert!(html.contains("-webkit-user-select"), "should add webkit prefix for user-select");
33003300 assert!(html.contains("-moz-user-select"), "should add moz prefix for user-select");
33013301}
3302+
3303+// ---------------------------------------------------------------------------
3304+// Error snapshot tests — verify expected error messages on invalid input
3305+// ---------------------------------------------------------------------------
3306+
3307+#[test]
3308+fn test_error_unknown_element() {
3309+ let diags = parse_diagnostics("@bogus\n Hello");
3310+ assert!(
3311+ diags.iter().any(|d| d.message.contains("unknown element")),
3312+ "should report unknown element error"
3313+ );
3314+}
3315+
3316+#[test]
3317+fn test_error_unclosed_brackets() {
3318+ let diags = parse_diagnostics("@el [padding 10\n Hello");
3319+ assert!(
3320+ diags.iter().any(|d| d.message.contains("unclosed '['") || d.message.contains("unclosed")),
3321+ "should report unclosed bracket error"
3322+ );
3323+}
3324+
3325+#[test]
3326+fn test_error_each_missing_in() {
3327+ let diags = parse_diagnostics("@each $item\n @text $item");
3328+ assert!(
3329+ diags.iter().any(|d| d.message.contains("@each requires")),
3330+ "should report @each syntax error"
3331+ );
3332+}
3333+
3334+#[test]
3335+fn test_error_for_missing_range() {
3336+ let diags = parse_diagnostics("@for $i\n @text $i");
3337+ assert!(
3338+ diags.iter().any(|d| d.message.contains("@for requires")),
3339+ "should report @for syntax error"
3340+ );
3341+}
3342+
3343+#[test]
3344+fn test_error_circular_include() {
3345+ // A file including itself would be circular, but we test via in-memory parse
3346+ // by testing that the parser detects self-referential definitions
3347+ let diags = parse_diagnostics("@fn recursive $x\n @recursive [$x]\n\n@recursive [hello]");
3348+ assert!(
3349+ diags.iter().any(|d| d.message.contains("recursive")),
3350+ "should report recursive function call"
3351+ );
3352+}
3353+
3354+#[test]
3355+fn test_error_assert_failure() {
3356+ let diags = parse_diagnostics("@let x 5\n@assert $x == 10");
3357+ assert!(
3358+ diags.iter().any(|d| d.message.contains("assertion failed") && d.severity == htmlang::parser::Severity::Error),
3359+ "should report assertion failure as error"
3360+ );
3361+}
3362+
3363+#[test]
3364+fn test_error_duplicate_attribute() {
3365+ let diags = parse_diagnostics("@el [padding 10, padding 20]\n Hello");
3366+ assert!(
3367+ diags.iter().any(|d| d.message.contains("duplicate attribute")),
3368+ "should warn on duplicate attribute"
3369+ );
3370+}
3371+
3372+#[test]
3373+fn test_warning_unused_variable() {
3374+ let diags = parse_diagnostics("@let unused_var hello\n@text Hello");
3375+ assert!(
3376+ diags.iter().any(|d| d.message.contains("unused variable") && d.severity == htmlang::parser::Severity::Warning),
3377+ "should warn about unused variable"
3378+ );
3379+}
3380+
3381+#[test]
3382+fn test_warning_unused_function() {
3383+ let diags = parse_diagnostics("@fn unused_fn\n @text Hello\n\n@text World");
3384+ assert!(
3385+ diags.iter().any(|d| d.message.contains("unused function") && d.severity == htmlang::parser::Severity::Warning),
3386+ "should warn about unused function"
3387+ );
3388+}
3389+
3390+// ---------------------------------------------------------------------------
3391+// New feature tests
3392+// ---------------------------------------------------------------------------
3393+
3394+#[test]
3395+fn test_each_index_variable() {
3396+ let html = compile("@each $item in A, B, C\n @text $_index");
3397+ assert!(html.contains(">0<"), "first item should have $_index = 0");
3398+ assert!(html.contains(">1<"), "second item should have $_index = 1");
3399+ assert!(html.contains(">2<"), "third item should have $_index = 2");
3400+}
3401+
3402+#[test]
3403+fn test_children_fallback_content() {
3404+ let html = compile("@fn wrapper\n @el [padding 10]\n @children\n @text Default content\n\n@wrapper");
3405+ assert!(html.contains("Default content"), "should use @children fallback when no children provided");
3406+}
3407+
3408+#[test]
3409+fn test_spread_define() {
3410+ let html = compile("@define btn [padding 12, bold]\n@el [...$btn]\n Click");
3411+ assert!(html.contains("padding:12px"), "spread define should apply padding");
3412+ assert!(html.contains("font-weight:bold") || html.contains("font-weight:700"), "spread define should apply bold");
3413+}
3414+
3415+#[test]
3416+fn test_log_directive() {
3417+ // @log should not produce errors and should be consumed without output nodes
3418+ let result = htmlang::parser::parse("@let x hello\n@log $x\n@text $x");
3419+ assert!(
3420+ result.diagnostics.iter().all(|d| d.severity != htmlang::parser::Severity::Error),
3421+ "@log should not produce errors"
3422+ );
3423+ let html = htmlang::codegen::generate(&result.document);
3424+ assert!(!html.contains("@log"), "@log should not appear in output");
3425+}
3426+
3427+#[test]
3428+fn test_short_class_names() {
3429+ let html = compile("@el [padding 10]\n @el [padding 20]\n Hello");
3430+ // Class names should be short single letters, not _0, _1
3431+ assert!(!html.contains("class=\"_0\""), "should use short class names, not _0");
3432+ assert!(html.contains("class=\"a\"") || html.contains("class=\"b\""), "should use short alphabetic class names");
3433+}
3434+
3435+// ---------------------------------------------------------------------------
3436+// Round-trip test: compile -> convert -> compile -> diff
3437+// ---------------------------------------------------------------------------
3438+
3439+#[test]
3440+fn test_round_trip() {
3441+ let input = "@page Round Trip Test\n\n@column [padding 20, spacing 10]\n @text [bold, size 24] Hello\n @paragraph [color #666]\n This is a test.";
3442+ let html1 = compile(input);
3443+ // Convert back to .hl
3444+ let hl = htmlang::convert::convert(&html1);
3445+ // Compile the converted .hl
3446+ let result2 = htmlang::parser::parse(&hl);
3447+ // May have warnings (e.g. unknown attrs from raw HTML) but should not have errors that prevent output
3448+ let html2 = htmlang::codegen::generate(&result2.document);
3449+ // Both HTML outputs should contain the same text content
3450+ assert!(html2.contains("Hello"), "round-trip should preserve text content 'Hello'");
3451+ assert!(html2.contains("This is a test"), "round-trip should preserve text content 'This is a test'");
3452+}