on v2026.4.26151538 help sign in
htmlang/src/main.rs
4080 lines 153 kB history raw
1mod cli;
3use std::collections::{HashMap, HashSet};
4use std::env;
5use std::fs;
6use std::hash::{Hash, Hasher};
7use std::path::{Path, PathBuf};
8use std::process;
9use std::sync::Mutex;
11struct DiagnosticJson {
12 file: String,
13 line: usize,
14 severity: String,
15 message: String,
18fn severity_label(s: htmlang::parser::Severity) -> &'static str {
19 match s {
20 htmlang::parser::Severity::Error => "error",
21 htmlang::parser::Severity::Warning => "warning",
22 htmlang::parser::Severity::Info => "info",
23 htmlang::parser::Severity::Help => "help",
24 }
27#[derive(Default)]
28struct CompileConfig<'a> {
29 dev: bool,
30 error_overlay: bool,
31 check_only: bool,
32 output_path: Option<&'a str>,
33 format_json: bool,
34 json_collector: Option<&'a Mutex<Vec<DiagnosticJson>>>,
35 minify: bool,
36 compat: bool,
37 strict: bool,
38 partial: bool,
41fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
42 let input = match fs::read_to_string(input_path) {
43 Ok(s) => s,
44 Err(e) => {
45 if cfg.format_json {
46 if let Some(collector) = cfg.json_collector {
47 collector.lock().unwrap().push(DiagnosticJson {
48 file: input_path.to_string(),
49 line: 0,
50 severity: "error".to_string(),
51 message: format!("{}", e),
52 });
53 }
54 } else {
55 eprintln!("error: {}: {}", input_path, e);
56 }
57 return (true, vec![]);
58 }
59 };
61 let base = Path::new(input_path).parent();
62 let result = htmlang::parser::parse_with_base(&input, base);
64 if cfg.format_json {
65 if let Some(collector) = cfg.json_collector {
66 let mut collected = collector.lock().unwrap();
67 for d in &result.diagnostics {
68 collected.push(DiagnosticJson {
69 file: input_path.to_string(),
70 line: d.line,
71 severity: severity_label(d.severity).to_string(),
72 message: d.message.clone(),
73 });
74 }
75 }
76 } else {
77 for d in &result.diagnostics {
78 let prefix = severity_label(d.severity);
79 if let Some(col) = d.column {
80 eprintln!("{}: line {}:{}: {}", prefix, d.line, col, d.message);
81 } else {
82 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
83 }
84 if let Some(ref src) = d.source_line {
85 eprintln!(" | {}", src);
86 if let Some(col) = d.column {
87 eprintln!(" | {}^", " ".repeat(col));
88 }
89 }
90 }
91 }
93 let has_errors = result.diagnostics.iter().any(|d| {
94 d.severity == htmlang::parser::Severity::Error
95 || (cfg.strict && d.severity == htmlang::parser::Severity::Warning)
96 });
98 let out_path = match cfg.output_path {
99 Some(p) => PathBuf::from(p),
100 None => Path::new(input_path).with_extension("html"),
101 };
103 if !cfg.check_only {
104 if has_errors {
105 if cfg.error_overlay {
106 let error_html = generate_error_overlay(&result.diagnostics, input_path);
107 let _ = fs::write(&out_path, &error_html);
108 }
109 } else {
110 let html = htmlang::codegen::generate_with(
111 &result.document,
112 &htmlang::codegen::CodegenOptions {
113 dev: cfg.dev,
114 partial: cfg.partial,
115 minify: cfg.minify,
116 compat: cfg.compat,
117 },
118 );
119 match fs::write(&out_path, &html) {
120 Ok(()) => eprintln!("wrote {}", out_path.display()),
121 Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
122 }
123 // Generate source map alongside HTML
124 if cfg.dev {
125 let map_path = out_path.with_extension("html.map");
126 let source_map = htmlang::codegen::generate_source_map(
127 &result.document,
128 &Path::new(input_path)
129 .file_name()
130 .unwrap_or_default()
131 .to_string_lossy(),
132 );
133 let _ = fs::write(&map_path, &source_map);
134 }
135 }
136 }
138 (has_errors, result.included_files)
141fn json_escape_string(s: &str) -> String {
142 let escaped = s
143 .replace('\\', "\\\\")
144 .replace('"', "\\\"")
145 .replace('\n', "\\n");
146 format!("\"{}\"", escaped)
149fn json_array(items: impl Iterator<Item = String>) -> String {
150 let inner: Vec<String> = items.collect();
151 format!("[{}]", inner.join(","))
154fn json_object(fields: &[(&str, String)]) -> String {
155 let inner: Vec<String> = fields
156 .iter()
157 .map(|(k, v)| format!("{}:{}", json_escape_string(k), v))
158 .collect();
159 format!("{{{}}}", inner.join(","))
162fn print_json_diagnostics(diagnostics: &[DiagnosticJson]) {
163 let arr = json_array(diagnostics.iter().map(|d| {
164 json_object(&[
165 ("file", json_escape_string(&d.file)),
166 ("line", d.line.to_string()),
167 ("severity", json_escape_string(&d.severity)),
168 ("message", json_escape_string(&d.message)),
169 ])
170 }));
171 println!("{}", json_object(&[("diagnostics", arr)]));
174fn kebab_to_title(s: &str) -> String {
175 s.split('-')
176 .map(|word| {
177 let mut chars = word.chars();
178 match chars.next() {
179 Some(c) => {
180 let mut title = c.to_uppercase().to_string();
181 title.extend(chars);
182 title
183 }
184 None => String::new(),
185 }
186 })
187 .collect::<Vec<_>>()
188 .join(" ")
191fn format_bytes(bytes: usize) -> String {
192 if bytes >= 1_048_576 {
193 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
194 } else if bytes >= 1024 {
195 format!("{:.1}KB", bytes as f64 / 1024.0)
196 } else {
197 format!("{}B", bytes)
198 }
201fn bundle_assets(html: &str, base_dir: &Path) -> String {
202 use std::io::Read;
203 let mut result = html.to_string();
205 // Inline images referenced as src="..." in <img> tags
206 let img_re_patterns = ["src=\"", "url("];
207 for pattern in &img_re_patterns {
208 let mut output = String::new();
209 let mut remaining = result.as_str();
210 while let Some(pos) = remaining.find(pattern) {
211 output.push_str(&remaining[..pos + pattern.len()]);
212 remaining = &remaining[pos + pattern.len()..];
214 // Find the closing delimiter
215 let close_char = if *pattern == "src=\"" { '"' } else { ')' };
216 if let Some(end) = remaining.find(close_char) {
217 let path_str = remaining[..end].trim_matches(|c| c == '\'' || c == '"');
219 // Skip data URIs, remote URLs, and anchors
220 if !path_str.starts_with("data:")
221 && !path_str.starts_with("http://")
222 && !path_str.starts_with("https://")
223 && !path_str.starts_with('#')
224 && !path_str.is_empty()
225 {
226 let asset_path = base_dir.join(path_str);
227 if asset_path.exists()
228 && let Ok(mut file) = std::fs::File::open(&asset_path)
229 {
230 let mut buf = Vec::new();
231 if file.read_to_end(&mut buf).is_ok() {
232 let mime = match asset_path.extension().and_then(|e| e.to_str()) {
233 Some("png") => "image/png",
234 Some("jpg") | Some("jpeg") => "image/jpeg",
235 Some("gif") => "image/gif",
236 Some("svg") => "image/svg+xml",
237 Some("webp") => "image/webp",
238 Some("ico") => "image/x-icon",
239 Some("woff2") => "font/woff2",
240 Some("woff") => "font/woff",
241 Some("ttf") => "font/ttf",
242 Some("otf") => "font/otf",
243 Some("avif") => "image/avif",
244 _ => "application/octet-stream",
245 };
246 use std::fmt::Write as FmtWrite;
247 let mut b64 = String::new();
248 // Simple base64 encoding
249 let encoded = base64_encode(&buf);
250 let _ = write!(b64, "data:{};base64,{}", mime, encoded);
251 output.push_str(&b64);
252 remaining = &remaining[end..];
253 continue;
254 }
255 }
256 }
257 // If we couldn't inline, keep original path
258 output.push_str(&remaining[..end]);
259 remaining = &remaining[end..];
260 }
261 }
262 output.push_str(remaining);
263 result = output;
264 }
265 result
268fn base64_encode(data: &[u8]) -> String {
269 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
270 let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
271 for chunk in data.chunks(3) {
272 let b0 = chunk[0] as u32;
273 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
274 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
275 let n = (b0 << 16) | (b1 << 8) | b2;
276 result.push(CHARS[((n >> 18) & 63) as usize] as char);
277 result.push(CHARS[((n >> 12) & 63) as usize] as char);
278 if chunk.len() > 1 {
279 result.push(CHARS[((n >> 6) & 63) as usize] as char);
280 } else {
281 result.push('=');
282 }
283 if chunk.len() > 2 {
284 result.push(CHARS[(n & 63) as usize] as char);
285 } else {
286 result.push('=');
287 }
288 }
289 result
292fn copy_non_hl_files(src_dir: &Path, out_dir: &Path) {
293 let skip = out_dir.canonicalize().ok();
294 copy_non_hl_recursive(src_dir, src_dir, out_dir, skip.as_deref());
297fn copy_non_hl_recursive(base: &Path, dir: &Path, out_dir: &Path, skip_canonical: Option<&Path>) {
298 if let Ok(entries) = fs::read_dir(dir) {
299 for entry in entries.flatten() {
300 let path = entry.path();
301 if path.is_dir() {
302 // Skip hidden directories (.htmlang-cache, .git, etc.)
303 if path
304 .file_name()
305 .is_some_and(|n| n.to_str().is_some_and(|s| s.starts_with('.')))
306 {
307 continue;
308 }
309 // Skip the output directory to avoid copying it into itself
310 if let Some(skip) = skip_canonical {
311 if path.canonicalize().ok().as_deref() == Some(skip) {
312 continue;
313 }
314 }
315 copy_non_hl_recursive(base, &path, out_dir, skip_canonical);
316 } else if path.is_file() && path.extension().is_none_or(|e| e != "hl") {
317 let rel = path.strip_prefix(base).unwrap_or(&path);
318 let dest = out_dir.join(rel);
319 if let Some(parent) = dest.parent() {
320 let _ = fs::create_dir_all(parent);
321 }
322 match fs::copy(&path, &dest) {
323 Ok(_) => eprintln!("copied {}", dest.display()),
324 Err(e) => eprintln!("error: copy {}: {}", dest.display(), e),
325 }
326 }
327 }
328 }
331fn generate_error_overlay(diagnostics: &[htmlang::parser::Diagnostic], file: &str) -> String {
332 let mut errors = String::new();
333 for d in diagnostics {
334 let prefix = severity_label(d.severity);
335 let escaped = d
336 .message
337 .replace('&', "&amp;")
338 .replace('<', "&lt;")
339 .replace('>', "&gt;");
340 let location = match d.column {
341 Some(col) => format!("line {}:{}", d.line, col),
342 None => format!("line {}", d.line),
343 };
344 errors.push_str(&format!(
345 "<div class=\"entry\"><span class=\"badge {}\">{}</span> <span class=\"loc\">{}</span> {}",
346 prefix, prefix, location, escaped
347 ));
348 if let Some(ref src) = d.source_line {
349 let src_esc = src
350 .replace('&', "&amp;")
351 .replace('<', "&lt;")
352 .replace('>', "&gt;");
353 errors.push_str(&format!("<pre class=\"src\">{}</pre>", src_esc));
354 if let Some(col) = d.column {
355 // Render a caret indicator underneath the source line.
356 let caret = format!("{}^", " ".repeat(col));
357 errors.push_str(&format!("<pre class=\"caret\">{}</pre>", caret));
358 }
359 }
360 errors.push_str("</div>");
361 }
362 format!(
363 r#"<!DOCTYPE html><html><head><meta charset="utf-8"><title>Build Error</title><style>
364*{{margin:0;box-sizing:border-box}}
365body{{background:#1a1a2e;color:#eee;font-family:ui-monospace,monospace;padding:2rem}}
366h1{{color:#ff6b6b;margin-bottom:1rem;font-size:1.5rem}}
367.file{{color:#888;margin-bottom:1.5rem;font-size:0.9rem}}
368.entry{{padding:0.75rem 0;border-bottom:1px solid #333}}
369.loc{{color:#9ca3af;margin-right:6px}}
370.badge{{display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;margin-right:8px}}
371.badge.error{{background:#c0392b;color:white}}
372.badge.warning{{background:#f39c12;color:white}}
373.badge.info{{background:#2563eb;color:white}}
374.badge.help{{background:#16a34a;color:white}}
375.src{{margin-top:0.5rem;padding:0.4rem 0.6rem;background:#0f0f1e;border-radius:4px;color:#ddd;white-space:pre-wrap}}
376.caret{{margin:0;padding:0 0.6rem;color:#ff6b6b;white-space:pre}}
377</style></head><body>
378<h1>Build Error</h1>
379<div class="file">{file}</div>
380{errors}
381</body></html>"#,
382 file = file,
383 errors = errors,
384 )
387fn init_project(dir: &str, template_name: Option<&str>) {
388 let dir = Path::new(dir);
389 if dir.to_str() != Some(".")
390 && let Err(e) = fs::create_dir_all(dir)
391 {
392 eprintln!("error: cannot create directory '{}': {}", dir.display(), e);
393 process::exit(1);
394 }
396 let index_path = dir.join("index.hl");
397 if index_path.exists() {
398 eprintln!("error: {} already exists", index_path.display());
399 process::exit(1);
400 }
402 let template = match template_name {
403 Some("blog") => {
404 r#"@page My Blog
405@let primary #3b82f6
406@let bg-dark #1a1a2e
408@column [max-width 720, center-x, padding 40, spacing 30]
409 @header [spacing 10]
410 @text [bold, size 36] My Blog
411 @paragraph [color #666, line-height 1.6] Thoughts and ideas.
413 @main [spacing 40]
414 @article [spacing 12, padding-bottom 30, border-bottom 1 #eee]
415 @text [bold, size 24] First Post
416 @text [color #888, size 14] 2024-01-15
417 @paragraph [line-height 1.8]
418 Welcome to my blog. This is the first post.
420 @article [spacing 12, padding-bottom 30, border-bottom 1 #eee]
421 @text [bold, size 24] Another Post
422 @text [color #888, size 14] 2024-01-10
423 @paragraph [line-height 1.8]
424 Here is another post with more content.
426 @footer [padding-top 20, border-top 1 #eee]
427 @text [color #888, size 14, text-align center] Built with htmlang
428"#
429 }
430 Some("docs") => {
431 r#"@page Documentation
432@let primary #3b82f6
433@let sidebar-width 250
435@row [min-height 100vh]
436 @aside [width $sidebar-width, padding 20, background #f8f9fa, border-right 1 #e0e0e0, spacing 8]
437 @text [bold, size 18, padding-bottom 10] Docs
438 @nav [spacing 4]
439 @link # Getting Started
440 @link # Installation
441 @link # Configuration
442 @link # API Reference
444 @main [width fill, padding 40, max-width 800, spacing 20]
445 @text [bold, size 32] Getting Started
446 @paragraph [line-height 1.8]
447 Welcome to the documentation. Use the sidebar to navigate.
449 @text [bold, size 24] Installation
450 @code [padding 16, background #f5f5f5, rounded 8]
451 npm install my-package
453 @text [bold, size 24] Usage
454 @paragraph [line-height 1.8]
455 Import and use the library in your project.
456"#
457 }
458 Some("portfolio") => {
459 r#"@page Portfolio
460@let primary #3b82f6
461@let accent #8b5cf6
463@column [min-height 100vh, spacing 0]
464 @header [padding 20 40, background white, border-bottom 1 #eee]
465 @row [max-width 1200, center-x, width fill, justify-content space-between, align-items center]
466 @text [bold, size 20] Jane Doe
467 @nav
468 @row [spacing 20]
469 @link # Work
470 @link # About
471 @link # Contact
473 @main [max-width 1200, center-x, padding 60 40, spacing 60]
474 @column [spacing 10, center-x, text-align center, max-width 600]
475 @text [bold, size 48] Designer & Developer
476 @paragraph [color #666, size 18, line-height 1.6]
477 I create beautiful, functional digital experiences.
479 @grid [grid-cols 3, gap 20]
480 @el [aspect-ratio 1, background #f0f0f0, rounded 12, padding 20, spacing 10]
481 @text [bold] Project One
482 @text [color #666, size 14] Web Design
484 @el [aspect-ratio 1, background #f0f0f0, rounded 12, padding 20, spacing 10]
485 @text [bold] Project Two
486 @text [color #666, size 14] Branding
488 @el [aspect-ratio 1, background #f0f0f0, rounded 12, padding 20, spacing 10]
489 @text [bold] Project Three
490 @text [color #666, size 14] Development
492 @footer [padding 30 40, background #1a1a2e, color white, text-align center]
493 @text [size 14] Built with htmlang
494"#
495 }
496 _ => {
497 r#"@page My Site
498@let primary #3b82f6
500@column [max-width 800, center-x, padding 40, spacing 20]
501 @text [bold, size 32] Hello, htmlang!
503 @paragraph [line-height 1.6]
504 Edit {@text [bold, color $primary] index.hl} and run
505 {@text [font monospace, size 14] htmlang -s .} to get started.
507 @row [spacing 10]
508 @el [padding 12 24, background $primary, rounded 8, cursor pointer, hover:background #2563eb, transition all 0.15s ease] > @link https://github.com/nicholasgasior/htmlang
509 @text [color white, bold] Documentation
510"#
511 }
512 };
514 match fs::write(&index_path, template) {
515 Ok(()) => eprintln!("created {}", index_path.display()),
516 Err(e) => {
517 eprintln!("error: {}: {}", index_path.display(), e);
518 process::exit(1);
519 }
520 }
523fn collect_hl_files_recursive(dir: &Path) -> Vec<PathBuf> {
524 let mut files = Vec::new();
525 collect_hl_recursive_inner(dir, &mut files);
526 files.sort();
527 files
530fn collect_hl_recursive_inner(dir: &Path, files: &mut Vec<PathBuf>) {
531 if let Ok(entries) = fs::read_dir(dir) {
532 for entry in entries.flatten() {
533 let path = entry.path();
534 if path.is_dir() {
535 // Skip hidden directories (.htmlang-cache, .git, etc.)
536 if path
537 .file_name()
538 .is_some_and(|n| n.to_str().is_some_and(|s| s.starts_with('.')))
539 {
540 continue;
541 }
542 collect_hl_recursive_inner(&path, files);
543 } else if path.is_file() && path.extension().is_some_and(|e| e == "hl") {
544 files.push(path);
545 }
546 }
547 }
550fn collect_all_files_recursive(dir: &Path) -> Vec<PathBuf> {
551 let mut files = Vec::new();
552 fn walk(dir: &Path, files: &mut Vec<PathBuf>) {
553 if let Ok(entries) = fs::read_dir(dir) {
554 for entry in entries.flatten() {
555 let path = entry.path();
556 if path.is_dir() {
557 walk(&path, files);
558 } else if path.is_file() {
559 files.push(path);
560 }
561 }
562 }
563 }
564 walk(dir, &mut files);
565 files.sort();
566 files
569fn generate_sitemap(dir: &str, base_url: &str) {
570 let dir = Path::new(dir);
571 let hl_files = collect_hl_files_recursive(dir);
572 if hl_files.is_empty() {
573 eprintln!("no .hl files found in {}", dir.display());
574 process::exit(1);
575 }
576 let mut xml = String::from(
577 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n",
578 );
579 for file in &hl_files {
580 let rel = file.strip_prefix(dir).unwrap_or(file);
581 let url_path = rel
582 .with_extension("html")
583 .to_string_lossy()
584 .replace('\\', "/");
585 let url = if url_path == "index.html" {
586 format!("{}/", base_url.trim_end_matches('/'))
587 } else {
588 format!("{}/{}", base_url.trim_end_matches('/'), url_path)
589 };
591 // Get file modification time for <lastmod>
592 let lastmod = file
593 .metadata()
594 .ok()
595 .and_then(|m| m.modified().ok())
596 .map(|t| {
597 let secs = t
598 .duration_since(std::time::UNIX_EPOCH)
599 .unwrap_or_default()
600 .as_secs();
601 let days = secs / 86400;
602 // Simple date calculation from epoch days
603 let (y, m, d) = epoch_days_to_date(days);
604 format!("{:04}-{:02}-{:02}", y, m, d)
605 });
607 // Heuristic priority: index pages get higher priority
608 let priority = if url_path == "index.html" {
609 "1.0"
610 } else if rel.components().count() <= 2 {
611 "0.8"
612 } else {
613 "0.5"
614 };
616 xml.push_str(" <url>\n");
617 xml.push_str(&format!(" <loc>{}</loc>\n", url));
618 if let Some(ref date) = lastmod {
619 xml.push_str(&format!(" <lastmod>{}</lastmod>\n", date));
620 }
621 xml.push_str(&format!(" <priority>{}</priority>\n", priority));
622 xml.push_str(" </url>\n");
623 }
624 xml.push_str("</urlset>\n");
625 let out_path = dir.join("sitemap.xml");
626 match fs::write(&out_path, &xml) {
627 Ok(()) => eprintln!("wrote {} ({} URLs)", out_path.display(), hl_files.len()),
628 Err(e) => {
629 eprintln!("error: {}: {}", out_path.display(), e);
630 process::exit(1);
631 }
632 }
635fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
636 // Algorithm from http://howardhinnant.github.io/date_algorithms.html
637 let z = days + 719468;
638 let era = z / 146097;
639 let doe = z - era * 146097;
640 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
641 let y = yoe + era * 400;
642 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
643 let mp = (5 * doy + 2) / 153;
644 let d = doy - (153 * mp + 2) / 5 + 1;
645 let m = if mp < 10 { mp + 3 } else { mp - 9 };
646 let y = if m <= 2 { y + 1 } else { y };
647 (y, m, d)
650fn lint_file(path: &str) -> Vec<String> {
651 let input = match fs::read_to_string(path) {
652 Ok(s) => s,
653 Err(e) => return vec![format!("error: {}: {}", path, e)],
654 };
655 let base = Path::new(path).parent();
656 let result = htmlang::parser::parse_with_base(&input, base);
657 let mut warnings = Vec::new();
659 // Report parser diagnostics
660 for d in &result.diagnostics {
661 let prefix = severity_label(d.severity);
662 warnings.push(format!("{}:{}:{}: {}", path, d.line, prefix, d.message));
663 }
665 // Additional lint checks on the AST
666 lint_nodes(&result.document.nodes, path, 0, &mut warnings);
667 warnings
670fn lint_nodes(nodes: &[htmlang::ast::Node], path: &str, depth: usize, warnings: &mut Vec<String>) {
671 for node in nodes {
672 if let htmlang::ast::Node::Element(elem) = node {
673 // Deeply nested elements (>10 levels)
674 if depth > 10 {
675 warnings.push(format!(
676 "{}:{}:lint: deeply nested element ({} levels) — consider simplifying",
677 path, elem.line_num, depth
678 ));
679 }
681 // @image without alt
682 if elem.kind == htmlang::ast::ElementKind::Image
683 && !elem.attrs.iter().any(|a| a.key == "alt")
684 {
685 warnings.push(format!(
686 "{}:{}:lint: @image missing 'alt' attribute (accessibility)",
687 path, elem.line_num
688 ));
689 }
691 // @link without content or aria-label
692 if elem.kind == htmlang::ast::ElementKind::Link {
693 let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label");
694 let has_children = !elem.children.is_empty();
695 let has_arg_text = elem.argument.as_ref().is_some_and(|_| false);
696 if !has_aria && !has_children && !has_arg_text {
697 warnings.push(format!(
698 "{}:{}:lint: @link has no visible text or aria-label (accessibility)",
699 path, elem.line_num
700 ));
701 }
702 }
704 // @input without type
705 if elem.kind == htmlang::ast::ElementKind::Input
706 && !elem.attrs.iter().any(|a| a.key == "type")
707 {
708 warnings.push(format!(
709 "{}:{}:lint: @input missing 'type' attribute",
710 path, elem.line_num
711 ));
712 }
714 // Empty containers (no children, no text)
715 if matches!(
716 elem.kind,
717 htmlang::ast::ElementKind::Row
718 | htmlang::ast::ElementKind::Column
719 | htmlang::ast::ElementKind::El
720 ) && elem.children.is_empty()
721 {
722 warnings.push(format!(
723 "{}:{}:lint: empty container (@{}) has no children",
724 path,
725 elem.line_num,
726 match elem.kind {
727 htmlang::ast::ElementKind::Row => "row",
728 htmlang::ast::ElementKind::Column => "column",
729 _ => "el",
730 }
731 ));
732 }
734 // @button without type
735 if elem.kind == htmlang::ast::ElementKind::Button
736 && !elem.attrs.iter().any(|a| a.key == "type")
737 {
738 warnings.push(format!(
739 "{}:{}:lint: @button missing 'type' attribute (defaults to submit)",
740 path, elem.line_num
741 ));
742 }
744 lint_nodes(&elem.children, path, depth + 1, warnings);
745 }
746 }
749fn stats_file(path: &str) {
750 let input = match fs::read_to_string(path) {
751 Ok(s) => s,
752 Err(e) => {
753 eprintln!("error: {}: {}", path, e);
754 process::exit(1);
755 }
756 };
757 let base = Path::new(path).parent();
758 let result = htmlang::parser::parse_with_base(&input, base);
759 let html = htmlang::codegen::generate(&result.document);
761 let mut element_count = 0;
762 let mut colors = std::collections::HashSet::new();
763 let mut fonts = std::collections::HashSet::new();
764 count_elements(
765 &result.document.nodes,
766 &mut element_count,
767 &mut colors,
768 &mut fonts,
769 );
771 // Count CSS rules (approximate from generated style block)
772 let css_rules = html.matches('{').count().saturating_sub(1); // subtract the html/head/body structure
774 let source_bytes = input.len();
775 let output_bytes = html.len();
777 eprintln!("--- {} ---", path);
778 eprintln!(
779 " source size: {} bytes ({} lines)",
780 source_bytes,
781 input.lines().count()
782 );
783 eprintln!(" output size: {} bytes", output_bytes);
784 eprintln!(" elements: {}", element_count);
785 eprintln!(" CSS rules: ~{}", css_rules);
786 eprintln!(" unique colors: {}", colors.len());
787 if !colors.is_empty() {
788 let mut sorted: Vec<_> = colors.iter().collect();
789 sorted.sort();
790 eprintln!(
791 " {}",
792 sorted
793 .iter()
794 .map(|s| s.as_str())
795 .collect::<Vec<_>>()
796 .join(", ")
797 );
798 }
799 eprintln!(" unique fonts: {}", fonts.len());
800 if !fonts.is_empty() {
801 let mut sorted: Vec<_> = fonts.iter().collect();
802 sorted.sort();
803 eprintln!(
804 " {}",
805 sorted
806 .iter()
807 .map(|s| s.as_str())
808 .collect::<Vec<_>>()
809 .join(", ")
810 );
811 }
812 if result
813 .diagnostics
814 .iter()
815 .any(|d| d.severity == htmlang::parser::Severity::Error)
816 {
817 eprintln!(
818 " errors: {}",
819 result
820 .diagnostics
821 .iter()
822 .filter(|d| d.severity == htmlang::parser::Severity::Error)
823 .count()
824 );
825 }
826 let warn_count = result
827 .diagnostics
828 .iter()
829 .filter(|d| d.severity == htmlang::parser::Severity::Warning)
830 .count();
831 if warn_count > 0 {
832 eprintln!(" warnings: {}", warn_count);
833 }
836fn count_elements(
837 nodes: &[htmlang::ast::Node],
838 count: &mut usize,
839 colors: &mut std::collections::HashSet<String>,
840 fonts: &mut std::collections::HashSet<String>,
841) {
842 for node in nodes {
843 if let htmlang::ast::Node::Element(elem) = node {
844 *count += 1;
845 for attr in &elem.attrs {
846 let key = attr.key.as_str();
847 // Strip pseudo/media prefixes for color/font detection
848 let base_key = key.split(':').next_back().unwrap_or(key);
849 if matches!(base_key, "color" | "background")
850 && let Some(ref v) = attr.value
851 {
852 colors.insert(v.clone());
853 }
854 if base_key == "font"
855 && let Some(ref v) = attr.value
856 {
857 fonts.insert(v.clone());
858 }
859 }
860 count_elements(&elem.children, count, colors, fonts);
861 }
862 }
865/// Extract shared CSS rules across multiple HTML files and write shared.css
866fn extract_shared_css(html_files: &[PathBuf], out_dir: &Path) {
867 // Parse <style> blocks from each HTML file and count rule occurrences
868 let mut rule_counts: HashMap<String, usize> = HashMap::new();
869 let total = html_files.len();
870 for file in html_files {
871 if let Ok(html) = fs::read_to_string(file) {
872 // Extract CSS between <style> and </style>
873 if let Some(start) = html.find("<style>")
874 && let Some(end) = html[start..].find("</style>")
875 {
876 let css = &html[start + 7..start + end];
877 // Extract individual rules (class-based)
878 let mut seen_in_file = std::collections::HashSet::new();
879 let mut i = 0;
880 let bytes = css.as_bytes();
881 while i < bytes.len() {
882 if bytes[i] == b'.' || bytes[i] == b'@' {
883 // Find end of rule block
884 let start_pos = i;
885 let mut depth = 0;
886 let mut found_open = false;
887 while i < bytes.len() {
888 if bytes[i] == b'{' {
889 depth += 1;
890 found_open = true;
891 } else if bytes[i] == b'}' {
892 depth -= 1;
893 if found_open && depth == 0 {
894 i += 1;
895 break;
896 }
897 }
898 i += 1;
899 }
900 let rule = &css[start_pos..i];
901 if !rule.is_empty() && !seen_in_file.contains(rule) {
902 seen_in_file.insert(rule.to_string());
903 *rule_counts.entry(rule.to_string()).or_insert(0) += 1;
904 }
905 } else {
906 i += 1;
907 }
908 }
909 }
910 }
911 }
912 // Rules appearing in ALL files are shared
913 let shared_rules: Vec<&String> = rule_counts
914 .iter()
915 .filter(|(_, count)| **count == total)
916 .map(|(rule, _)| rule)
917 .collect();
918 if shared_rules.is_empty() {
919 return;
920 }
921 let shared_set: std::collections::HashSet<&String> = shared_rules.iter().copied().collect();
922 let shared_css_path = out_dir.join("shared.css");
923 let shared_css: String = shared_rules
924 .iter()
925 .map(|r| r.as_str())
926 .collect::<Vec<_>>()
927 .join("\n");
928 if fs::write(&shared_css_path, &shared_css).is_err() {
929 return;
930 }
931 eprintln!(
932 "extracted {} shared CSS rules to {}",
933 shared_rules.len(),
934 shared_css_path.display()
935 );
937 // Remove shared rules from individual files and inject <link> tag
938 for file in html_files {
939 if let Ok(html) = fs::read_to_string(file)
940 && let Some(style_start) = html.find("<style>")
941 && let Some(style_end_rel) = html[style_start..].find("</style>")
942 {
943 let css = &html[style_start + 7..style_start + style_end_rel];
944 // Rebuild CSS without shared rules
945 let mut filtered = String::new();
946 let mut i = 0;
947 let bytes = css.as_bytes();
948 while i < bytes.len() {
949 if bytes[i] == b'.' || bytes[i] == b'@' {
950 let start_pos = i;
951 let mut depth = 0;
952 let mut found_open = false;
953 while i < bytes.len() {
954 if bytes[i] == b'{' {
955 depth += 1;
956 found_open = true;
957 } else if bytes[i] == b'}' {
958 depth -= 1;
959 if found_open && depth == 0 {
960 i += 1;
961 break;
962 }
963 }
964 i += 1;
965 }
966 let rule = &css[start_pos..i];
967 if !shared_set.contains(&rule.to_string()) {
968 filtered.push_str(rule);
969 }
970 } else {
971 filtered.push(css.as_bytes()[i] as char);
972 i += 1;
973 }
974 }
975 let link_tag = "<link rel=\"stylesheet\" href=\"shared.css\">";
976 let new_html = format!(
977 "{}{}<style>{}</style>{}",
978 &html[..style_start],
979 link_tag,
980 filtered,
981 &html[style_start + style_end_rel + 8..],
982 );
983 let _ = fs::write(file, new_html);
984 }
985 }
988fn open_in_browser(port: u16) {
989 let url = format!("http://127.0.0.1:{}", port);
990 let cmd = if cfg!(target_os = "macos") {
991 "open"
992 } else {
993 "xdg-open"
994 };
995 let _ = std::process::Command::new(cmd).arg(&url).spawn();
998// ---------------------------------------------------------------------------
999// Config file support (htmlang.toml)
1000// ---------------------------------------------------------------------------
1002struct ProjectConfig {
1003 output: Option<String>,
1004 port: u16,
1005 variables: Vec<(String, String)>,
1006 breakpoints: Vec<(String, String)>,
1007 // Build options (can be overridden by CLI flags)
1008 dev: Option<bool>,
1009 minify: Option<bool>,
1010 compat: Option<bool>,
1011 strict: Option<bool>,
1012 // Watch options
1013 debounce_ms: u64,
1016fn load_config(target: &Path) -> ProjectConfig {
1017 let mut config = ProjectConfig {
1018 output: None,
1019 port: 3000,
1020 variables: Vec::new(),
1021 breakpoints: Vec::new(),
1022 dev: None,
1023 minify: None,
1024 compat: None,
1025 strict: None,
1026 debounce_ms: 50,
1027 };
1029 let config_path = if target.is_dir() {
1030 target.join("htmlang.toml")
1031 } else {
1032 target
1033 .parent()
1034 .unwrap_or(Path::new("."))
1035 .join("htmlang.toml")
1036 };
1038 let content = match fs::read_to_string(&config_path) {
1039 Ok(s) => s,
1040 Err(_) => return config,
1041 };
1043 let mut section = "";
1044 for (line_num, line) in content.lines().enumerate() {
1045 let trimmed = line.trim();
1046 if trimmed.is_empty() || trimmed.starts_with('#') {
1047 continue;
1049 if trimmed.starts_with('[') && trimmed.ends_with(']') {
1050 section = &trimmed[1..trimmed.len() - 1];
1051 if !matches!(section, "variables" | "breakpoints" | "build" | "watch") {
1052 eprintln!(
1053 "warning: {}:{}: unknown section '[{}]' (expected: variables, breakpoints, build, watch)",
1054 config_path.display(),
1055 line_num + 1,
1056 section
1057 );
1059 continue;
1061 if let Some((key, value)) = trimmed.split_once('=') {
1062 let key = key.trim();
1063 let value = value.trim().trim_matches('"');
1064 match section {
1065 "" => match key {
1066 "output" => config.output = Some(value.to_string()),
1067 "port" => config.port = value.parse().unwrap_or(3000),
1068 _ => {
1069 eprintln!(
1070 "warning: {}:{}: unknown key '{}' (expected: output, port)",
1071 config_path.display(),
1072 line_num + 1,
1073 key
1074 );
1076 },
1077 "build" => match key {
1078 "dev" => config.dev = Some(value == "true"),
1079 "minify" => config.minify = Some(value == "true"),
1080 "compat" => config.compat = Some(value == "true"),
1081 "strict" => config.strict = Some(value == "true"),
1082 _ => {
1083 eprintln!(
1084 "warning: {}:{}: unknown build key '{}' (expected: dev, minify, compat, strict)",
1085 config_path.display(),
1086 line_num + 1,
1087 key
1088 );
1090 },
1091 "watch" => match key {
1092 "debounce_ms" => config.debounce_ms = value.parse().unwrap_or(50),
1093 _ => {
1094 eprintln!(
1095 "warning: {}:{}: unknown watch key '{}' (expected: debounce_ms)",
1096 config_path.display(),
1097 line_num + 1,
1098 key
1099 );
1101 },
1102 "variables" => {
1103 config.variables.push((key.to_string(), value.to_string()));
1105 "breakpoints" => {
1106 config
1107 .breakpoints
1108 .push((key.to_string(), value.to_string()));
1110 _ => {} // already warned about unknown section
1115 config
1118fn find_wasm_pkg() -> Option<(Vec<u8>, String)> {
1119 // Look for pre-built WASM binary and JS glue in known locations
1120 let candidates = [
1121 // Relative to current executable
1122 std::env::current_exe()
1123 .ok()
1124 .and_then(|p| p.parent().map(|d| d.join("..").join("wasm-pkg"))),
1125 // In the project build directory (development)
1126 Some(PathBuf::from("target/wasm-pkg")),
1127 ];
1128 for dir in candidates.iter().flatten() {
1129 let wasm = dir.join("htmlang_wasm_bg.wasm");
1130 let js = dir.join("htmlang_wasm.js");
1131 if let (Ok(wasm_bytes), Ok(js_str)) = (fs::read(&wasm), fs::read_to_string(&js)) {
1132 return Some((wasm_bytes, js_str));
1135 None
1138fn main() {
1139 let args: Vec<String> = env::args().collect();
1140 let mut watch = false;
1141 let mut serve = false;
1142 let mut dev = false;
1143 let mut check = false;
1144 let mut format_json = false;
1145 let mut compat = false;
1146 let mut strict = false;
1147 let mut open_browser = false;
1148 let mut partial = false;
1149 let mut port: u16 = 3000;
1150 let mut output_path: Option<String> = None;
1151 let mut input_path = None;
1153 // Handle "lsp" subcommand — launch the LSP server from the main binary
1154 if args.len() >= 2 && args[1] == "lsp" {
1155 cli::run_lsp();
1156 return;
1159 // Handle "completions" subcommand — generate shell completions
1160 if args.len() >= 2 && args[1] == "completions" {
1161 let shell = args.get(2).map(|s| s.as_str()).unwrap_or("bash");
1162 cli::print_shell_completions(shell);
1163 return;
1166 // Handle "init" subcommand
1167 if args.len() >= 2 && args[1] == "init" {
1168 let mut init_dir = ".";
1169 let mut init_template: Option<&str> = None;
1170 let mut i = 2;
1171 while i < args.len() {
1172 match args[i].as_str() {
1173 "--template" | "-t" => {
1174 i += 1;
1175 init_template = args.get(i).map(|s| s.as_str());
1177 _ if init_dir == "." => init_dir = &args[i],
1178 _ => {}
1180 i += 1;
1182 init_project(init_dir, init_template);
1183 return;
1186 // Handle "fmt" subcommand
1187 if args.len() >= 3 && args[1] == "fmt" {
1188 let file = &args[2];
1189 match fs::read_to_string(file) {
1190 Ok(input) => {
1191 let formatted = htmlang::fmt::format(&input);
1192 match fs::write(file, &formatted) {
1193 Ok(()) => eprintln!("formatted {}", file),
1194 Err(e) => {
1195 eprintln!("error: {}: {}", file, e);
1196 process::exit(1);
1200 Err(e) => {
1201 eprintln!("error: {}: {}", file, e);
1202 process::exit(1);
1205 return;
1208 // Handle "build" subcommand
1209 if args.len() >= 2 && args[1] == "build" {
1210 let mut src_dir = None;
1211 let mut out_dir = None;
1212 let mut build_minify = false;
1213 let mut build_compat = false;
1214 let mut build_strict = false;
1215 let mut i = 2;
1216 while i < args.len() {
1217 match args[i].as_str() {
1218 "-o" | "--output" => {
1219 i += 1;
1220 out_dir = args.get(i).map(|s| s.as_str());
1222 "--minify" => build_minify = true,
1223 "--compat" => build_compat = true,
1224 "--strict" => build_strict = true,
1225 _ if src_dir.is_none() => src_dir = Some(args[i].as_str()),
1226 _ => {
1227 eprintln!("unknown argument: {}", args[i]);
1228 process::exit(1);
1231 i += 1;
1233 let src = src_dir.unwrap_or(".");
1234 let dir = Path::new(src);
1235 if !dir.is_dir() {
1236 eprintln!("error: '{}' is not a directory", src);
1237 process::exit(1);
1239 // Load project config — CLI flags override config file
1240 let config = load_config(dir);
1241 let build_minify = build_minify || config.minify.unwrap_or(false);
1242 let build_compat = build_compat || config.compat.unwrap_or(false);
1243 let build_strict = build_strict || config.strict.unwrap_or(false);
1244 let out_dir = out_dir.or(config.output.as_deref()).or(Some("out"));
1245 let hl_files = collect_hl_files_recursive(dir);
1246 if hl_files.is_empty() {
1247 eprintln!("no .hl files found in {}", src);
1248 process::exit(1);
1250 // Create output dir if needed
1251 if let Some(out) = out_dir {
1252 let _ = fs::create_dir_all(out);
1254 // Pre-create output directories for each file (must be done before parallel compilation)
1255 let effective_outs: Vec<Option<String>> = hl_files
1256 .iter()
1257 .map(|file| {
1258 out_dir.map(|o| {
1259 let rel = file.strip_prefix(dir).unwrap_or(file);
1260 let out_path = Path::new(o).join(rel).with_extension("html");
1261 if let Some(parent) = out_path.parent() {
1262 let _ = fs::create_dir_all(parent);
1264 out_path.to_string_lossy().to_string()
1265 })
1266 })
1267 .collect();
1269 // Build content hash cache for incremental compilation
1270 let cache_dir = dir.join(".htmlang-cache");
1271 let _ = fs::create_dir_all(&cache_dir);
1273 // Compile files in parallel (with incremental skip for unchanged files)
1274 let build_start = std::time::Instant::now();
1275 let any_errors = std::sync::atomic::AtomicBool::new(false);
1276 let skipped = std::sync::atomic::AtomicUsize::new(0);
1277 std::thread::scope(|s| {
1278 for (file, effective_out) in hl_files.iter().zip(effective_outs.iter()) {
1279 let any_errors = &any_errors;
1280 let skipped = &skipped;
1281 let cache_dir = &cache_dir;
1282 s.spawn(move || {
1283 // Content hash-based caching: skip if file content hasn't changed
1284 let hash_file_name = file
1285 .file_name()
1286 .unwrap_or_default()
1287 .to_string_lossy()
1288 .to_string()
1289 + ".hash";
1290 let hash_path = cache_dir.join(&hash_file_name);
1291 if let Ok(content) = fs::read(file) {
1292 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1293 content.hash(&mut hasher);
1294 let current_hash = hasher.finish().to_string();
1295 if let Ok(cached_hash) = fs::read_to_string(&hash_path)
1296 && cached_hash.trim() == current_hash
1298 // Also verify output exists
1299 let out_exists = effective_out
1300 .as_ref()
1301 .map_or(file.with_extension("html").exists(), |p| {
1302 Path::new(p.as_str()).exists()
1303 });
1304 if out_exists {
1305 skipped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1306 return;
1309 // Update hash cache after compilation
1310 let _ = fs::write(&hash_path, &current_hash);
1312 let path_str = file.to_string_lossy().to_string();
1313 let (has_errors, _) = compile(
1314 &path_str,
1315 &CompileConfig {
1316 output_path: effective_out.as_deref(),
1317 minify: build_minify,
1318 compat: build_compat,
1319 strict: build_strict,
1320 ..Default::default()
1321 },
1322 );
1323 if has_errors {
1324 any_errors.store(true, std::sync::atomic::Ordering::Relaxed);
1326 });
1328 });
1329 let build_elapsed = build_start.elapsed();
1330 let skipped_count = skipped.load(std::sync::atomic::Ordering::Relaxed);
1331 let compiled_count = hl_files.len() - skipped_count;
1333 // Report build performance
1334 let total_output_size: usize = effective_outs
1335 .iter()
1336 .filter_map(|p| p.as_ref())
1337 .filter_map(|p| fs::metadata(p).ok())
1338 .map(|m| m.len() as usize)
1339 .sum();
1340 eprintln!(
1341 "built {} files in {:.2}s ({}){}",
1342 compiled_count,
1343 build_elapsed.as_secs_f64(),
1344 format_bytes(total_output_size),
1345 if skipped_count > 0 {
1346 format!(", {} skipped", skipped_count)
1347 } else {
1348 String::new()
1349 },
1350 );
1351 if any_errors.load(std::sync::atomic::Ordering::Relaxed) {
1352 process::exit(1);
1355 // Copy non-.hl static assets to output directory
1356 if let Some(out) = out_dir {
1357 copy_non_hl_files(dir, Path::new(out));
1359 // Shared CSS extraction: find duplicate CSS rules across pages
1360 let out_path = Path::new(out);
1361 let html_files: Vec<PathBuf> = hl_files
1362 .iter()
1363 .map(|f| {
1364 let rel = f.strip_prefix(dir).unwrap_or(f);
1365 out_path.join(rel).with_extension("html")
1366 })
1367 .filter(|p| p.exists())
1368 .collect();
1369 if html_files.len() > 1 {
1370 extract_shared_css(&html_files, out_path);
1373 return;
1376 // Handle "sitemap" subcommand
1377 if args.len() >= 2 && args[1] == "sitemap" {
1378 let dir = if args.len() >= 3 { &args[2] } else { "." };
1379 let base_url = args
1380 .iter()
1381 .position(|a| a == "--base-url" || a == "-b")
1382 .and_then(|i| args.get(i + 1))
1383 .map(|s| s.as_str())
1384 .unwrap_or("https://example.com");
1385 generate_sitemap(dir, base_url);
1386 return;
1389 // Handle "lint" subcommand
1390 if args.len() >= 2 && args[1] == "lint" {
1391 let mut lint_target = ".";
1392 let mut lint_json = false;
1393 let mut i = 2;
1394 while i < args.len() {
1395 match args[i].as_str() {
1396 "--format" => {
1397 i += 1;
1398 if args.get(i).map(|s| s.as_str()) == Some("json") {
1399 lint_json = true;
1402 _ if lint_target == "." => lint_target = &args[i],
1403 _ => {}
1405 i += 1;
1407 let path = Path::new(lint_target);
1408 let mut all_warnings = Vec::new();
1409 if path.is_dir() {
1410 let hl_files = collect_hl_files_recursive(path);
1411 if hl_files.is_empty() {
1412 eprintln!("no .hl files found in {}", lint_target);
1413 process::exit(1);
1415 for file in &hl_files {
1416 let path_str = file.to_string_lossy().to_string();
1417 all_warnings.extend(lint_file(&path_str));
1419 } else {
1420 all_warnings.extend(lint_file(lint_target));
1422 if lint_json {
1423 let arr = json_array(all_warnings.iter().map(|w| {
1424 json_object(&[
1425 ("severity", json_escape_string("warning")),
1426 ("message", json_escape_string(w)),
1427 ])
1428 }));
1429 println!("{}", json_object(&[("diagnostics", arr)]));
1430 } else if all_warnings.is_empty() {
1431 eprintln!("no issues found");
1432 } else {
1433 for w in &all_warnings {
1434 eprintln!("{}", w);
1436 process::exit(1);
1438 return;
1441 // Handle "stats" subcommand
1442 if args.len() >= 2 && args[1] == "stats" {
1443 let target = if args.len() >= 3 { &args[2] } else { "." };
1444 let path = Path::new(target);
1445 if path.is_dir() {
1446 let hl_files = collect_hl_files_recursive(path);
1447 if hl_files.is_empty() {
1448 eprintln!("no .hl files found in {}", target);
1449 process::exit(1);
1451 for file in &hl_files {
1452 stats_file(&file.to_string_lossy());
1454 } else {
1455 stats_file(target);
1457 return;
1460 // Handle "check" subcommand
1461 if args.len() >= 2 && args[1] == "check" {
1462 let mut check_target = None;
1463 let mut check_format_json = false;
1464 let mut ci = 2;
1465 while ci < args.len() {
1466 match args[ci].as_str() {
1467 "--format" => {
1468 ci += 1;
1469 if args.get(ci).is_some_and(|v| v == "json") {
1470 check_format_json = true;
1473 _ if check_target.is_none() => check_target = Some(args[ci].as_str()),
1474 _ => {
1475 eprintln!("unknown argument: {}", args[ci]);
1476 process::exit(1);
1479 ci += 1;
1481 let target = check_target.unwrap_or(".");
1482 let json_collector = if check_format_json {
1483 Some(Mutex::new(Vec::new()))
1484 } else {
1485 None
1486 };
1487 let path = Path::new(target);
1488 let mut any_errors = false;
1489 if path.is_dir() {
1490 let hl_files = collect_hl_files_recursive(path);
1491 if hl_files.is_empty() {
1492 eprintln!("no .hl files found in {}", target);
1493 process::exit(1);
1495 for file in &hl_files {
1496 let path_str = file.to_string_lossy().to_string();
1497 let (has_errors, _) = compile(
1498 &path_str,
1499 &CompileConfig {
1500 check_only: true,
1501 format_json: check_format_json,
1502 json_collector: json_collector.as_ref(),
1503 ..Default::default()
1504 },
1505 );
1506 if has_errors {
1507 any_errors = true;
1510 } else {
1511 let (has_errors, _) = compile(
1512 target,
1513 &CompileConfig {
1514 check_only: true,
1515 format_json: check_format_json,
1516 json_collector: json_collector.as_ref(),
1517 ..Default::default()
1518 },
1519 );
1520 if has_errors {
1521 any_errors = true;
1524 if check_format_json && let Some(collector) = json_collector {
1525 print_json_diagnostics(&collector.lock().unwrap());
1527 if any_errors {
1528 process::exit(1);
1530 return;
1533 // Handle "test" subcommand (run @assert directives across a project)
1534 if args.len() >= 2 && args[1] == "test" {
1535 let target = if args.len() >= 3 { &args[2] } else { "." };
1536 let path = Path::new(target);
1537 let hl_files = if path.is_dir() {
1538 collect_hl_files_recursive(path)
1539 } else {
1540 vec![PathBuf::from(target)]
1541 };
1542 if hl_files.is_empty() {
1543 eprintln!("no .hl files found in {}", target);
1544 process::exit(1);
1546 let mut total_asserts = 0usize;
1547 let mut failed_asserts = 0usize;
1548 let mut files_with_errors = 0usize;
1549 for file in &hl_files {
1550 let input = match fs::read_to_string(file) {
1551 Ok(s) => s,
1552 Err(e) => {
1553 eprintln!("error: {}: {}", file.display(), e);
1554 files_with_errors += 1;
1555 continue;
1557 };
1558 let base = file.parent();
1559 let result = htmlang::parser::parse_with_base(&input, base);
1560 let file_errors: Vec<_> = result
1561 .diagnostics
1562 .iter()
1563 .filter(|d| d.severity == htmlang::parser::Severity::Error)
1564 .collect();
1565 let assert_failures: Vec<_> = file_errors
1566 .iter()
1567 .filter(|d| d.message.starts_with("assertion failed:"))
1568 .collect();
1569 total_asserts += assert_failures.len();
1570 // Count @assert lines (passes + failures)
1571 let assert_count = input
1572 .lines()
1573 .filter(|l| l.trim().starts_with("@assert "))
1574 .count();
1575 total_asserts += assert_count.saturating_sub(assert_failures.len());
1576 if !assert_failures.is_empty() {
1577 files_with_errors += 1;
1578 failed_asserts += assert_failures.len();
1579 for d in &assert_failures {
1580 eprintln!("FAIL {}: line {}: {}", file.display(), d.line, d.message);
1584 let passed = total_asserts.saturating_sub(failed_asserts);
1585 eprintln!(
1586 "\n{} assertions: {} passed, {} failed ({} files)",
1587 total_asserts,
1588 passed,
1589 failed_asserts,
1590 hl_files.len()
1591 );
1592 if failed_asserts > 0 || files_with_errors > 0 {
1593 process::exit(1);
1595 return;
1598 // Handle "new" subcommand
1599 if args.len() >= 2 && args[1] == "new" {
1600 if args.len() < 3 {
1601 eprintln!("usage: htmlang new <page-name>");
1602 process::exit(1);
1604 let page_name = &args[2];
1605 let file_name = format!("{}.hl", page_name);
1606 let file_path = Path::new(&file_name);
1607 if file_path.exists() {
1608 eprintln!("error: {} already exists", file_name);
1609 process::exit(1);
1611 let title = kebab_to_title(page_name);
1612 let template = format!(
1613 "@page {title}\n\
1614 @let primary #3b82f6\n\
1615 \n\
1616 @column [max-width 800, center-x, padding 40, spacing 20]\n\
1617 \x20\x20@text [bold, size 32] {title}\n\
1618 \x20\x20@paragraph [line-height 1.6]\n\
1619 \x20\x20\x20\x20Content goes here.\n",
1620 title = title,
1621 );
1622 match fs::write(file_path, &template) {
1623 Ok(()) => eprintln!("created {}", file_name),
1624 Err(e) => {
1625 eprintln!("error: {}: {}", file_name, e);
1626 process::exit(1);
1629 return;
1632 // Handle "preview" subcommand
1633 if args.len() >= 2 && args[1] == "preview" {
1634 if args.len() < 3 {
1635 eprintln!("usage: htmlang preview <file.hl>");
1636 process::exit(1);
1638 let file = &args[2];
1639 let tmp_dir = env::temp_dir().join("htmlang-preview");
1640 let _ = fs::create_dir_all(&tmp_dir);
1641 let out_path = tmp_dir.join("preview.html");
1642 let (has_errors, _) = compile(
1643 file,
1644 &CompileConfig {
1645 dev: true,
1646 output_path: Some(&out_path.to_string_lossy()),
1647 ..Default::default()
1648 },
1649 );
1650 if has_errors {
1651 process::exit(1);
1653 let url = format!("file://{}", out_path.display());
1654 let cmd = if cfg!(target_os = "macos") {
1655 "open"
1656 } else {
1657 "xdg-open"
1658 };
1659 let _ = std::process::Command::new(cmd).arg(&url).spawn();
1660 eprintln!("opened {}", out_path.display());
1661 return;
1664 // Handle "diff" subcommand
1665 if args.len() >= 2 && args[1] == "diff" {
1666 if args.len() < 4 {
1667 eprintln!("usage: htmlang diff <file1.hl> <file2.hl>");
1668 process::exit(1);
1670 let file1 = &args[2];
1671 let file2 = &args[3];
1672 let compile_to_string = |path: &str| -> Result<String, String> {
1673 let input = fs::read_to_string(path).map_err(|e| format!("error: {}: {}", path, e))?;
1674 let base = Path::new(path).parent();
1675 let result = htmlang::parser::parse_with_base(&input, base);
1676 if result
1677 .diagnostics
1678 .iter()
1679 .any(|d| d.severity == htmlang::parser::Severity::Error)
1681 return Err(format!("error: {} has parse errors", path));
1683 Ok(htmlang::codegen::generate_dev(&result.document))
1684 };
1685 let html1 = match compile_to_string(file1) {
1686 Ok(h) => h,
1687 Err(e) => {
1688 eprintln!("{}", e);
1689 process::exit(1);
1691 };
1692 let html2 = match compile_to_string(file2) {
1693 Ok(h) => h,
1694 Err(e) => {
1695 eprintln!("{}", e);
1696 process::exit(1);
1698 };
1699 let lines1: Vec<&str> = html1.lines().collect();
1700 let lines2: Vec<&str> = html2.lines().collect();
1701 let mut has_diff = false;
1702 let max_len = lines1.len().max(lines2.len());
1703 for i in 0..max_len {
1704 let l1 = lines1.get(i).unwrap_or(&"");
1705 let l2 = lines2.get(i).unwrap_or(&"");
1706 if l1 != l2 {
1707 has_diff = true;
1708 if !l1.is_empty() {
1709 println!("- {}", l1);
1711 if !l2.is_empty() {
1712 println!("+ {}", l2);
1716 if !has_diff {
1717 eprintln!("no differences");
1719 return;
1722 // Handle "export" subcommand
1723 if args.len() >= 2 && args[1] == "export" {
1724 let mut src_dir = None;
1725 let mut out_file = None;
1726 let mut ei = 2;
1727 while ei < args.len() {
1728 match args[ei].as_str() {
1729 "-o" | "--output" => {
1730 ei += 1;
1731 out_file = args.get(ei).map(|s| s.as_str());
1733 _ if src_dir.is_none() => src_dir = Some(args[ei].as_str()),
1734 _ => {
1735 eprintln!("unknown argument: {}", args[ei]);
1736 process::exit(1);
1739 ei += 1;
1741 let src = src_dir.unwrap_or(".");
1742 let dir = Path::new(src);
1743 if !dir.is_dir() {
1744 eprintln!("error: '{}' is not a directory", src);
1745 process::exit(1);
1747 let hl_files = collect_hl_files_recursive(dir);
1748 if hl_files.is_empty() {
1749 eprintln!("no .hl files found in {}", src);
1750 process::exit(1);
1752 let zip_path = out_file.unwrap_or("site.zip");
1753 // Build to temp directory, then create zip
1754 let tmp_dir = env::temp_dir().join("htmlang-export");
1755 let _ = fs::remove_dir_all(&tmp_dir);
1756 let _ = fs::create_dir_all(&tmp_dir);
1757 let mut any_errors = false;
1758 for file in &hl_files {
1759 let rel = file.strip_prefix(dir).unwrap_or(file);
1760 let out_path = tmp_dir.join(rel).with_extension("html");
1761 if let Some(parent) = out_path.parent() {
1762 let _ = fs::create_dir_all(parent);
1764 let path_str = file.to_string_lossy().to_string();
1765 let (has_errors, _) = compile(
1766 &path_str,
1767 &CompileConfig {
1768 output_path: Some(&out_path.to_string_lossy()),
1769 ..Default::default()
1770 },
1771 );
1772 if has_errors {
1773 any_errors = true;
1776 // Copy non-.hl assets
1777 copy_non_hl_files(dir, &tmp_dir);
1778 if any_errors {
1779 eprintln!("export completed with errors");
1781 // Create a simple tar-like zip (concatenated files with headers)
1782 // Since we can't use external crates, we'll create a self-contained directory listing
1783 let export_files = collect_all_files_recursive(&tmp_dir);
1784 let zip_file = std::fs::File::create(zip_path);
1785 match zip_file {
1786 Ok(mut f) => {
1787 use std::io::Write;
1788 // Write a simple archive format: filename\0length\0content
1789 for file in &export_files {
1790 let rel = file.strip_prefix(&tmp_dir).unwrap_or(file);
1791 if let Ok(data) = fs::read(file) {
1792 let _ = write!(f, "FILE:{}:{}:", rel.display(), data.len());
1793 let _ = f.write_all(&data);
1796 eprintln!("exported {} files to {}", export_files.len(), zip_path);
1798 Err(e) => {
1799 eprintln!("error: {}: {}", zip_path, e);
1800 // Fallback: just report the built files in tmp_dir
1801 eprintln!("files available in {}", tmp_dir.display());
1804 return;
1807 // Handle "serve" standalone subcommand
1808 if args.len() >= 2 && args[1] == "serve" {
1809 let mut serve_target = None;
1810 let mut serve_port: u16 = 3000;
1811 let mut serve_open = false;
1812 let mut serve_https = false;
1813 let mut cert_path: Option<String> = None;
1814 let mut key_path: Option<String> = None;
1815 let mut _proxy_routes: Vec<(String, String)> = Vec::new();
1816 let mut si = 2;
1817 while si < args.len() {
1818 match args[si].as_str() {
1819 "-p" | "--port" => {
1820 si += 1;
1821 serve_port = args.get(si).and_then(|p| p.parse().ok()).unwrap_or(3000);
1823 "--open" => serve_open = true,
1824 "--https" => serve_https = true,
1825 "--cert" => {
1826 si += 1;
1827 cert_path = args.get(si).cloned();
1829 "--key" => {
1830 si += 1;
1831 key_path = args.get(si).cloned();
1833 "--proxy" => {
1834 // --proxy /api http://localhost:3001
1835 si += 1;
1836 let prefix = args.get(si).cloned().unwrap_or_default();
1837 si += 1;
1838 let target_url = args.get(si).cloned().unwrap_or_default();
1839 if !prefix.is_empty() && !target_url.is_empty() {
1840 _proxy_routes.push((prefix, target_url));
1841 eprintln!(
1842 "proxy: {} -> {}",
1843 _proxy_routes.last().unwrap().0,
1844 _proxy_routes.last().unwrap().1
1845 );
1848 _ if serve_target.is_none() => serve_target = Some(args[si].clone()),
1849 _ => {
1850 eprintln!("unknown argument: {}", args[si]);
1851 process::exit(1);
1854 si += 1;
1856 let target = serve_target.unwrap_or_else(|| ".".to_string());
1857 let target_path = Path::new(&target);
1858 // Load config
1859 let config = load_config(target_path);
1860 let effective_port = if serve_port != 3000 {
1861 serve_port
1862 } else {
1863 config.port
1864 };
1866 // Resolve optional TLS config. Without both --cert and --key, --https is
1867 // an error — we intentionally do not generate self-signed certificates
1868 // to avoid surprising the user with unpinned trust anchors.
1869 let tls_config = if serve_https {
1870 let cert = cert_path.clone().unwrap_or_else(|| {
1871 eprintln!("error: --https requires --cert <path> and --key <path>");
1872 eprintln!(" generate a dev cert with: mkcert localhost 127.0.0.1");
1873 process::exit(1);
1874 });
1875 let key = key_path.clone().unwrap_or_else(|| {
1876 eprintln!("error: --https requires --cert <path> and --key <path>");
1877 process::exit(1);
1878 });
1879 match htmlang::serve::load_tls_config(Path::new(&cert), Path::new(&key)) {
1880 Ok(cfg) => Some(cfg),
1881 Err(e) => {
1882 eprintln!("error: failed to load TLS config: {}", e);
1883 process::exit(1);
1886 } else {
1887 None
1888 };
1889 let scheme = if tls_config.is_some() {
1890 "https"
1891 } else {
1892 "http"
1893 };
1894 let _ = scheme; // announced by watch_loop / open_in_browser downstream
1896 // Do initial compile
1897 if target_path.is_dir() {
1898 let hl_files = collect_hl_files_recursive(target_path);
1899 let mut all_included: Vec<PathBuf> = Vec::new();
1900 for file in &hl_files {
1901 let path_str = file.to_string_lossy().to_string();
1902 let effective_out = config.output.as_ref().map(|o| {
1903 let rel = file.strip_prefix(target_path).unwrap_or(file);
1904 let out_p = Path::new(o).join(rel).with_extension("html");
1905 if let Some(parent) = out_p.parent() {
1906 let _ = fs::create_dir_all(parent);
1908 out_p.to_string_lossy().to_string()
1909 });
1910 let (_, included) = compile(
1911 &path_str,
1912 &CompileConfig {
1913 dev: true,
1914 error_overlay: true,
1915 output_path: effective_out.as_deref(),
1916 ..Default::default()
1917 },
1918 );
1919 all_included.extend(included);
1921 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
1922 let serve_dir = config
1923 .output
1924 .as_ref()
1925 .map(PathBuf::from)
1926 .unwrap_or_else(|| target_path.to_path_buf());
1927 let index_path = serve_dir.join("index.html");
1928 let server_tx = tx.clone();
1929 let tls_for_thread = tls_config;
1930 std::thread::spawn(move || {
1931 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
1932 match tls_for_thread {
1933 Some(tls) => rt.block_on(htmlang::serve::run_https(
1934 effective_port,
1935 index_path,
1936 server_tx,
1937 tls,
1938 )),
1939 None => rt.block_on(htmlang::serve::run(effective_port, index_path, server_tx)),
1941 });
1942 if serve_open {
1943 open_in_browser(effective_port);
1945 watch_loop(
1946 target_path,
1947 &hl_files,
1948 &all_included,
1949 true,
1950 true,
1951 Some(tx),
1952 effective_port,
1953 config.debounce_ms,
1954 );
1955 } else {
1956 let (_, included) = compile(
1957 &target,
1958 &CompileConfig {
1959 dev: true,
1960 error_overlay: true,
1961 ..Default::default()
1962 },
1963 );
1964 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
1965 let out_path = Path::new(&target).with_extension("html");
1966 let server_tx = tx.clone();
1967 let tls_for_thread = tls_config;
1968 std::thread::spawn(move || {
1969 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
1970 match tls_for_thread {
1971 Some(tls) => rt.block_on(htmlang::serve::run_https(
1972 effective_port,
1973 out_path,
1974 server_tx,
1975 tls,
1976 )),
1977 None => rt.block_on(htmlang::serve::run(effective_port, out_path, server_tx)),
1979 });
1980 if serve_open {
1981 open_in_browser(effective_port);
1983 let files = vec![PathBuf::from(&target)];
1984 watch_loop(
1985 Path::new(&target).parent().unwrap_or(Path::new(".")),
1986 &files,
1987 &included,
1988 true,
1989 true,
1990 Some(tx),
1991 effective_port,
1992 config.debounce_ms,
1993 );
1995 return;
1998 // Handle "watch" standalone subcommand
1999 if args.len() >= 2 && args[1] == "watch" {
2000 let mut watch_target = None;
2001 let mut watch_output = None;
2002 let mut wi = 2;
2003 while wi < args.len() {
2004 match args[wi].as_str() {
2005 "-o" | "--output" => {
2006 wi += 1;
2007 watch_output = args.get(wi).cloned();
2009 _ if watch_target.is_none() => watch_target = Some(args[wi].clone()),
2010 _ => {
2011 eprintln!("unknown argument: {}", args[wi]);
2012 process::exit(1);
2015 wi += 1;
2017 let target = watch_target.unwrap_or_else(|| ".".to_string());
2018 let target_path = Path::new(&target);
2019 let config = load_config(target_path);
2020 let effective_output = watch_output.or(config.output);
2022 if target_path.is_dir() {
2023 let hl_files = collect_hl_files_recursive(target_path);
2024 if let Some(ref out) = effective_output {
2025 let _ = fs::create_dir_all(out);
2027 let mut all_included = Vec::new();
2028 for file in &hl_files {
2029 let path_str = file.to_string_lossy().to_string();
2030 let effective_out = effective_output.as_ref().map(|o| {
2031 let rel = file.strip_prefix(target_path).unwrap_or(file);
2032 let out_p = Path::new(o).join(rel).with_extension("html");
2033 if let Some(parent) = out_p.parent() {
2034 let _ = fs::create_dir_all(parent);
2036 out_p.to_string_lossy().to_string()
2037 });
2038 let (_, included) = compile(
2039 &path_str,
2040 &CompileConfig {
2041 output_path: effective_out.as_deref(),
2042 ..Default::default()
2043 },
2044 );
2045 all_included.extend(included);
2047 watch_loop(
2048 target_path,
2049 &hl_files,
2050 &all_included,
2051 false,
2052 false,
2053 None,
2054 0,
2055 config.debounce_ms,
2056 );
2057 } else {
2058 let (_, included) = compile(
2059 &target,
2060 &CompileConfig {
2061 output_path: effective_output.as_deref(),
2062 ..Default::default()
2063 },
2064 );
2065 let files = vec![PathBuf::from(&target)];
2066 watch_loop(
2067 Path::new(&target).parent().unwrap_or(Path::new(".")),
2068 &files,
2069 &included,
2070 false,
2071 false,
2072 None,
2073 0,
2074 config.debounce_ms,
2075 );
2077 return;
2080 // Handle "repl" subcommand
2081 if args.len() >= 2 && args[1] == "repl" {
2082 use std::io::{self, BufRead, Write};
2083 eprintln!("htmlang repl — type .hl code, then a blank line to compile (Ctrl+D to exit)");
2084 let stdin = io::stdin();
2085 let stdout = io::stdout();
2086 loop {
2087 eprint!("> ");
2088 let _ = io::stderr().flush();
2089 let mut buffer = String::new();
2090 loop {
2091 let mut line = String::new();
2092 match stdin.lock().read_line(&mut line) {
2093 Ok(0) => {
2094 if buffer.is_empty() {
2095 return;
2097 break;
2099 Ok(_) => {
2100 if line.trim().is_empty() && !buffer.is_empty() {
2101 break;
2103 buffer.push_str(&line);
2105 Err(_) => return,
2108 if buffer.trim().is_empty() {
2109 continue;
2111 let result = htmlang::parser::parse(&buffer);
2112 for d in &result.diagnostics {
2113 let prefix = severity_label(d.severity);
2114 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
2116 if !result
2117 .diagnostics
2118 .iter()
2119 .any(|d| d.severity == htmlang::parser::Severity::Error)
2121 let html = htmlang::codegen::generate_dev(&result.document);
2122 let _ = stdout.lock().write_all(html.as_bytes());
2123 let _ = stdout.lock().write_all(b"\n");
2128 // Handle "feed" subcommand
2129 if args.len() >= 2 && args[1] == "feed" {
2130 let dir = if args.len() >= 3 && !args[2].starts_with('-') {
2131 &args[2]
2132 } else {
2133 "."
2134 };
2135 let base_url = args
2136 .iter()
2137 .position(|a| a == "--base-url" || a == "-b")
2138 .and_then(|i| args.get(i + 1))
2139 .map(|s| s.as_str())
2140 .unwrap_or("https://example.com");
2141 let dir_path = Path::new(dir);
2142 let hl_files = collect_hl_files_recursive(dir_path);
2143 if hl_files.is_empty() {
2144 eprintln!("no .hl files found in {}", dir);
2145 process::exit(1);
2147 let mut items = Vec::new();
2148 for file in &hl_files {
2149 let input = match fs::read_to_string(file) {
2150 Ok(s) => s,
2151 Err(_) => continue,
2152 };
2153 let base = file.parent();
2154 let result = htmlang::parser::parse_with_base(&input, base);
2155 if let Some(ref title) = result.document.page_title {
2156 let rel = file.strip_prefix(dir_path).unwrap_or(file);
2157 let url_path = rel
2158 .with_extension("html")
2159 .to_string_lossy()
2160 .replace('\\', "/");
2161 let url = if url_path == "index.html" {
2162 format!("{}/", base_url.trim_end_matches('/'))
2163 } else {
2164 format!("{}/{}", base_url.trim_end_matches('/'), url_path)
2165 };
2166 // Get description from meta tags if available
2167 let description = result
2168 .document
2169 .meta_tags
2170 .iter()
2171 .find(|(k, _)| k == "description")
2172 .map(|(_, v)| v.clone())
2173 .unwrap_or_default();
2174 items.push((title.clone(), url, description));
2177 // Generate RSS 2.0 feed
2178 let site_title = items
2179 .first()
2180 .map(|(t, _, _)| t.clone())
2181 .unwrap_or_else(|| "My Site".to_string());
2182 let mut rss = format!(
2183 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
2184 <rss version=\"2.0\">\n\
2185 <channel>\n\
2186 <title>{}</title>\n\
2187 <link>{}</link>\n\
2188 <description>RSS feed generated by htmlang</description>\n",
2189 site_title, base_url
2190 );
2191 for (title, url, description) in &items {
2192 rss.push_str(&format!(
2193 "<item>\n<title>{}</title>\n<link>{}</link>\n<description>{}</description>\n</item>\n",
2194 title, url, description
2195 ));
2197 rss.push_str("</channel>\n</rss>\n");
2198 let out_path = dir_path.join("feed.xml");
2199 match fs::write(&out_path, &rss) {
2200 Ok(()) => eprintln!("wrote {} ({} items)", out_path.display(), items.len()),
2201 Err(e) => {
2202 eprintln!("error: {}: {}", out_path.display(), e);
2203 process::exit(1);
2206 return;
2209 // Handle "components" subcommand
2210 if args.len() >= 2 && args[1] == "components" {
2211 let target = if args.len() >= 3 { &args[2] } else { "." };
2212 let path = Path::new(target);
2213 let hl_files = if path.is_dir() {
2214 collect_hl_files_recursive(path)
2215 } else {
2216 vec![PathBuf::from(target)]
2217 };
2218 if hl_files.is_empty() {
2219 eprintln!("no .hl files found in {}", target);
2220 process::exit(1);
2222 let mut total = 0usize;
2223 for file in &hl_files {
2224 let input = match fs::read_to_string(file) {
2225 Ok(s) => s,
2226 Err(_) => continue,
2227 };
2228 let mut found = Vec::new();
2229 for (line_num, line) in input.lines().enumerate() {
2230 let trimmed = line.trim();
2231 if let Some(rest) = trimmed.strip_prefix("@let ") {
2232 // Detect function definitions: @let name with indented body
2233 let parts: Vec<&str> = rest.split_whitespace().collect();
2234 if let Some(&name) = parts.first() {
2235 // Check if next line is indented (indicating a function body)
2236 let next_line = input.lines().nth(line_num + 1);
2237 let is_fn = next_line
2238 .map(|l| l.starts_with(" ") || l.starts_with('\t'))
2239 .unwrap_or(false);
2240 // Also include if it has $params (even without checking body)
2241 let has_params =
2242 parts.len() > 1 && parts[1..].iter().any(|p| p.starts_with('$'));
2243 if is_fn || has_params {
2244 let params: Vec<&str> = parts[1..]
2245 .iter()
2246 .filter(|p| p.starts_with('$'))
2247 .map(|p| p.strip_prefix('$').unwrap_or(p))
2248 .collect();
2249 let param_str = if params.is_empty() {
2250 String::new()
2251 } else {
2252 format!(" ({})", params.join(", "))
2253 };
2254 found.push((line_num + 1, name.to_string(), param_str));
2259 if !found.is_empty() {
2260 let rel = file.strip_prefix(path).unwrap_or(file);
2261 for (line, name, params) in &found {
2262 println!(" @{}{} — {}:{}", name, params, rel.display(), line);
2263 total += 1;
2267 if total == 0 {
2268 eprintln!("no component definitions found");
2269 } else {
2270 eprintln!("\n{} component(s) found", total);
2272 return;
2275 // Handle "deps" subcommand — show dependency graph
2276 if args.len() >= 2 && args[1] == "deps" {
2277 let target = if args.len() >= 3 { &args[2] } else { "." };
2278 let path = Path::new(target);
2279 let hl_files = if path.is_dir() {
2280 collect_hl_files_recursive(path)
2281 } else {
2282 vec![PathBuf::from(target)]
2283 };
2284 if hl_files.is_empty() {
2285 eprintln!("no .hl files found in {}", target);
2286 process::exit(1);
2288 let mut graph: Vec<(String, Vec<String>)> = Vec::new();
2289 for file in &hl_files {
2290 let input = match fs::read_to_string(file) {
2291 Ok(s) => s,
2292 Err(_) => continue,
2293 };
2294 let rel = file.strip_prefix(path).unwrap_or(file);
2295 let rel_str = rel.display().to_string();
2296 let mut deps = Vec::new();
2297 for line in input.lines() {
2298 let trimmed = line.trim();
2299 for directive in &["@include ", "@import ", "@extends ", "@use "] {
2300 if let Some(rest) = trimmed.strip_prefix(directive) {
2301 let dep = rest.split_whitespace().next().unwrap_or("").to_string();
2302 if !dep.is_empty() {
2303 deps.push(dep);
2308 graph.push((rel_str, deps));
2310 for (file, deps) in &graph {
2311 if deps.is_empty() {
2312 println!("{} (no dependencies)", file);
2313 } else {
2314 println!("{}", file);
2315 for (i, dep) in deps.iter().enumerate() {
2316 let prefix = if i == deps.len() - 1 {
2317 "└── "
2318 } else {
2319 "├── "
2320 };
2321 println!(" {}{}", prefix, dep);
2325 return;
2328 // Handle "dead-code" subcommand — project-wide unused definitions
2329 if args.len() >= 2 && args[1] == "dead-code" {
2330 let mut dc_json = false;
2331 let mut dc_target = ".".to_string();
2332 let mut i = 2;
2333 while i < args.len() {
2334 match args[i].as_str() {
2335 "--format" => {
2336 i += 1;
2337 if args.get(i).map(|s| s.as_str()) == Some("json") {
2338 dc_json = true;
2341 _ if dc_target == "." => dc_target = args[i].clone(),
2342 _ => {}
2344 i += 1;
2346 let target = dc_target.as_str();
2347 let path = Path::new(target);
2348 let hl_files = if path.is_dir() {
2349 collect_hl_files_recursive(path)
2350 } else {
2351 vec![PathBuf::from(target)]
2352 };
2353 if hl_files.is_empty() {
2354 eprintln!("no .hl files found in {}", target);
2355 process::exit(1);
2357 // Pass 1: collect all definitions and usages across all files
2358 let mut all_let_defs: Vec<(String, String, usize)> = Vec::new(); // (name, file, line)
2359 let mut all_refs: std::collections::HashSet<String> = std::collections::HashSet::new();
2360 for file in &hl_files {
2361 let input = match fs::read_to_string(file) {
2362 Ok(s) => s,
2363 Err(_) => continue,
2364 };
2365 let rel = file
2366 .strip_prefix(path)
2367 .unwrap_or(file)
2368 .display()
2369 .to_string();
2370 for (line_num, line) in input.lines().enumerate() {
2371 let trimmed = line.trim();
2372 if let Some(rest) = trimmed.strip_prefix("@let ")
2373 && let Some(name) = rest.split_whitespace().next()
2375 let name = name.trim_end_matches('[');
2376 all_let_defs.push((name.to_string(), rel.clone(), line_num + 1));
2378 // Collect references: @name calls and $name usages
2379 if trimmed.starts_with('@')
2380 && !trimmed.starts_with("@let ")
2381 && !trimmed.starts_with("@include ")
2382 && !trimmed.starts_with("@import ")
2383 && !trimmed.starts_with("@extends ")
2384 && !trimmed.starts_with("@page")
2385 && !trimmed.starts_with("@meta ")
2386 && !trimmed.starts_with("@og ")
2387 && !trimmed.starts_with("@head")
2388 && !trimmed.starts_with("@style")
2389 && !trimmed.starts_with("@keyframes ")
2390 && !trimmed.starts_with("@theme")
2391 && !trimmed.starts_with("@deprecated ")
2392 && !trimmed.starts_with("@breakpoint ")
2393 && !trimmed.starts_with("@use ")
2394 && !trimmed.starts_with("@slot ")
2395 && !trimmed.starts_with("@lang ")
2396 && !trimmed.starts_with("@favicon ")
2397 && !trimmed.starts_with("--")
2398 && let Some(name) = trimmed[1..].split([' ', '[']).next()
2400 all_refs.insert(name.to_string());
2402 // Collect $var references
2403 let mut rest = trimmed;
2404 while let Some(pos) = rest.find('$') {
2405 let after = &rest[pos + 1..];
2406 let end = after
2407 .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
2408 .unwrap_or(after.len());
2409 if end > 0 {
2410 all_refs.insert(after[..end].to_string());
2412 rest = if end < after.len() { &after[end..] } else { "" };
2416 // Pass 2: report unused
2417 let mut unused: Vec<(String, String, String, usize)> = Vec::new(); // (kind, name, file, line)
2418 for (name, file, line) in &all_let_defs {
2419 if !all_refs.contains(name) && !name.starts_with("--") {
2420 unused.push(("@let".to_string(), name.clone(), file.clone(), *line));
2423 if dc_json {
2424 let arr = json_array(unused.iter().map(|(kind, name, file, line)| {
2425 json_object(&[
2426 ("kind", json_escape_string(kind)),
2427 ("name", json_escape_string(name)),
2428 ("file", json_escape_string(file)),
2429 ("line", line.to_string()),
2430 ])
2431 }));
2432 println!("{}", json_object(&[("unused", arr)]));
2433 } else if unused.is_empty() {
2434 eprintln!("no unused definitions found");
2435 } else {
2436 for (kind, name, file, line) in &unused {
2437 println!("unused {} '{}' — {}:{}", kind, name, file, line);
2439 eprintln!("\n{} unused definition(s) found", unused.len());
2441 return;
2444 // Handle "deploy" subcommand — deploy to GitHub Pages or other providers
2445 if args.len() >= 2 && args[1] == "deploy" {
2446 let mut deploy_target = ".";
2447 let mut provider = "github-pages";
2448 let mut di = 2;
2449 while di < args.len() {
2450 match args[di].as_str() {
2451 "--provider" | "-P" => {
2452 di += 1;
2453 if let Some(p) = args.get(di) {
2454 provider = match p.as_str() {
2455 "netlify" | "vercel" | "cloudflare" | "github-pages" => {
2456 // Leak to get &str lifetime — fine for CLI
2457 args[di].as_str()
2459 _ => {
2460 eprintln!(
2461 "unknown provider: {} (supported: github-pages, netlify, vercel, cloudflare)",
2463 );
2464 process::exit(1);
2466 };
2469 _ if deploy_target == "." => deploy_target = &args[di],
2470 _ => {}
2472 di += 1;
2474 let target = deploy_target;
2475 let path = Path::new(target);
2476 if !path.is_dir() {
2477 eprintln!("error: '{}' is not a directory", target);
2478 process::exit(1);
2480 // Build first
2481 let hl_files = collect_hl_files_recursive(path);
2482 if hl_files.is_empty() {
2483 eprintln!("no .hl files found in {}", target);
2484 process::exit(1);
2486 let deploy_dir = path.join("_deploy");
2487 let _ = fs::create_dir_all(&deploy_dir);
2488 let any_errors = std::sync::atomic::AtomicBool::new(false);
2489 std::thread::scope(|s| {
2490 for file in &hl_files {
2491 let any_errors = &any_errors;
2492 let deploy_dir = &deploy_dir;
2493 s.spawn(move || {
2494 let rel = file.strip_prefix(path).unwrap_or(file);
2495 let out_path = deploy_dir.join(rel).with_extension("html");
2496 if let Some(parent) = out_path.parent() {
2497 let _ = fs::create_dir_all(parent);
2499 let out_str = out_path.to_string_lossy().to_string();
2500 let path_str = file.to_string_lossy().to_string();
2501 let (has_errors, _) = compile(
2502 &path_str,
2503 &CompileConfig {
2504 output_path: Some(&out_str),
2505 ..Default::default()
2506 },
2507 );
2508 if has_errors {
2509 any_errors.store(true, std::sync::atomic::Ordering::Relaxed);
2511 });
2513 });
2514 if any_errors.load(std::sync::atomic::Ordering::Relaxed) {
2515 eprintln!("error: build failed, not deploying");
2516 process::exit(1);
2518 copy_non_hl_files(path, &deploy_dir);
2519 eprintln!("built {} files to {}", hl_files.len(), deploy_dir.display());
2521 // Provider-specific deploy
2522 match provider {
2523 "netlify" => {
2524 eprintln!("deploying to Netlify...");
2525 let status = process::Command::new("npx")
2526 .args([
2527 "netlify-cli",
2528 "deploy",
2529 "--prod",
2530 "--dir",
2531 &deploy_dir.to_string_lossy(),
2532 ])
2533 .status();
2534 match status {
2535 Ok(s) if s.success() => eprintln!("deployed to Netlify!"),
2536 _ => {
2537 eprintln!(
2538 "netlify deploy failed — ensure netlify-cli is installed (npx netlify-cli deploy --prod --dir {})",
2539 deploy_dir.display()
2540 );
2541 process::exit(1);
2544 return;
2546 "vercel" => {
2547 eprintln!("deploying to Vercel...");
2548 let status = process::Command::new("npx")
2549 .args(["vercel", "--prod", deploy_dir.to_string_lossy().as_ref()])
2550 .status();
2551 match status {
2552 Ok(s) if s.success() => eprintln!("deployed to Vercel!"),
2553 _ => {
2554 eprintln!(
2555 "vercel deploy failed — ensure vercel CLI is installed (npx vercel --prod {})",
2556 deploy_dir.display()
2557 );
2558 process::exit(1);
2561 return;
2563 "cloudflare" => {
2564 eprintln!("deploying to Cloudflare Pages...");
2565 let status = process::Command::new("npx")
2566 .args([
2567 "wrangler",
2568 "pages",
2569 "deploy",
2570 deploy_dir.to_string_lossy().as_ref(),
2571 ])
2572 .status();
2573 match status {
2574 Ok(s) if s.success() => eprintln!("deployed to Cloudflare Pages!"),
2575 _ => {
2576 eprintln!(
2577 "cloudflare deploy failed — ensure wrangler is installed (npx wrangler pages deploy {})",
2578 deploy_dir.display()
2579 );
2580 process::exit(1);
2583 return;
2585 _ => {} // fall through to GitHub Pages
2588 // Deploy via gh-pages push
2589 let init_ok = matches!(
2590 process::Command::new("git")
2591 .args(["init"])
2592 .current_dir(&deploy_dir)
2593 .status(),
2594 Ok(s) if s.success()
2595 );
2596 if !init_ok {
2597 eprintln!("error: git init failed in deploy directory");
2598 process::exit(1);
2600 let _ = process::Command::new("git")
2601 .args(["checkout", "-b", "gh-pages"])
2602 .current_dir(&deploy_dir)
2603 .status();
2604 let _ = process::Command::new("git")
2605 .args(["add", "."])
2606 .current_dir(&deploy_dir)
2607 .status();
2608 let commit_ok = matches!(
2609 process::Command::new("git")
2610 .args(["commit", "-m", "deploy"])
2611 .current_dir(&deploy_dir)
2612 .status(),
2613 Ok(s) if s.success()
2614 );
2615 if !commit_ok {
2616 eprintln!("error: git commit failed");
2617 process::exit(1);
2619 // Check if remote origin exists in parent
2620 let remote_output = process::Command::new("git")
2621 .args(["remote", "get-url", "origin"])
2622 .output();
2623 if let Ok(output) = remote_output {
2624 if output.status.success() {
2625 let remote_url = String::from_utf8_lossy(&output.stdout).trim().to_string();
2626 let _ = process::Command::new("git")
2627 .args(["remote", "add", "origin", &remote_url])
2628 .current_dir(&deploy_dir)
2629 .status();
2630 eprintln!("pushing to gh-pages branch at {}...", remote_url);
2631 let push_ok = matches!(
2632 process::Command::new("git")
2633 .args(["push", "-f", "origin", "gh-pages"])
2634 .current_dir(&deploy_dir)
2635 .status(),
2636 Ok(s) if s.success()
2637 );
2638 if !push_ok {
2639 eprintln!(
2640 "error: push failed — run 'git push -f origin gh-pages' manually from {}",
2641 deploy_dir.display()
2642 );
2643 process::exit(1);
2645 eprintln!("deployed to GitHub Pages!");
2646 } else {
2647 eprintln!(
2648 "no git remote found — deploy directory ready at {}",
2649 deploy_dir.display()
2650 );
2651 eprintln!(
2652 "push manually: cd {} && git remote add origin <url> && git push -f origin gh-pages",
2653 deploy_dir.display()
2654 );
2657 return;
2660 // Handle "playground" subcommand — generate self-contained HTML playground
2661 if args.len() >= 2 && args[1] == "playground" {
2662 let out = if args.len() >= 3 {
2663 &args[2]
2664 } else {
2665 "playground.html"
2666 };
2668 // Load pre-built WASM binary and JS glue
2669 let (wasm_bytes, js_glue) = match find_wasm_pkg() {
2670 Some(pkg) => pkg,
2671 None => {
2672 eprintln!("error: WASM module not found. Build it first:");
2673 eprintln!(
2674 " cargo build --release --target wasm32-unknown-unknown -p htmlang-wasm"
2675 );
2676 eprintln!(
2677 " wasm-bindgen --target no-modules --no-typescript --out-dir target/wasm-pkg \\"
2678 );
2679 eprintln!(" target/wasm32-unknown-unknown/release/htmlang_wasm.wasm");
2680 process::exit(1);
2682 };
2683 let b64 = base64_encode(&wasm_bytes);
2684 let wasm_section = format!(
2685 "<script>\n{}\n</script>\n<script>\n\
2686 (function(){{\n\
2687 var b64=\"{}\";\n\
2688 var raw=atob(b64);\n\
2689 var bytes=new Uint8Array(raw.length);\n\
2690 for(var i=0;i<raw.length;i++)bytes[i]=raw.charCodeAt(i);\n\
2691 wasm_bindgen.initSync(bytes);\n\
2692 window._wasmCompile=wasm_bindgen.compile;\n\
2693 }})();\n\
2694 </script>",
2695 js_glue, b64,
2696 );
2697 let compile_fn = "function compileSource(src){return window._wasmCompile(src);}";
2698 let editor_content = include_str!("playground_default.hl");
2700 let playground_html = format!(
2701 r##"<!DOCTYPE html>
2702<html lang="en">
2703<head>
2704<meta charset="utf-8">
2705<meta name="viewport" content="width=device-width, initial-scale=1">
2706<title>htmlang Playground</title>
2707<style>
2708*{{box-sizing:border-box;margin:0}}
2709body{{font-family:system-ui,-apple-system,sans-serif;display:flex;height:100vh;background:#1a1a2e;color:#eee}}
2710.panel{{flex:1;display:flex;flex-direction:column;min-width:0}}
2711.header{{padding:12px 16px;background:#16213e;border-bottom:1px solid #0f3460;display:flex;align-items:center;gap:12px}}
2712.header h1{{font-size:14px;font-weight:600;color:#e94560}}
2713.header .tag{{font-size:11px;padding:2px 8px;background:#0f3460;border-radius:4px;color:#a8b2d1}}
2714.editor-wrap{{flex:1;position:relative;overflow:hidden}}
2715textarea{{width:100%;height:100%;resize:none;border:none;outline:none;padding:16px;font-family:ui-monospace,monospace;font-size:14px;line-height:1.6;background:#1a1a2e;color:#e6e6e6;tab-size:2;position:absolute;top:0;left:0}}
2716iframe{{flex:1;border:none;background:white}}
2717.divider{{width:1px;background:#0f3460;cursor:col-resize}}
2718.divider:hover{{background:#e94560}}
2719.toolbar{{padding:8px 16px;background:#16213e;border-top:1px solid #0f3460;display:flex;gap:8px;align-items:center}}
2720button{{padding:6px 16px;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500}}
2721.btn-run{{background:#e94560;color:white}}
2722.btn-run:hover{{background:#c81e45}}
2723.btn-copy{{background:#0f3460;color:#a8b2d1}}
2724.btn-share{{background:#0f3460;color:#a8b2d1}}
2725.status{{margin-left:auto;font-size:12px;color:#666}}
2726</style>
2727</head>
2728<body>
2729<div class="panel" id="editor-panel">
2730<div class="header"><h1>htmlang</h1><span class="tag">playground</span></div>
2731<div class="editor-wrap">
2732<textarea id="editor" spellcheck="false">{editor_content}</textarea>
2733</div>
2734<div class="toolbar">
2735<button class="btn-run" onclick="compile()">&#9654; Run (Ctrl+Enter)</button>
2736<button class="btn-copy" onclick="copyOutput()">Copy HTML</button>
2737<button class="btn-share" onclick="share()">Share</button>
2738<span class="status" id="status">Ready</span>
2739</div>
2740</div>
2741<div class="divider" id="divider"></div>
2742<div class="panel" id="preview-panel">
2743<div class="header"><span class="tag">preview</span></div>
2744<iframe id="preview"></iframe>
2745</div>
2746{wasm_section}
2747<script>
2748{compile_fn}
2749var lastOutput='';
2750function compile(){{
2751 var src=document.getElementById('editor').value;
2752 var status=document.getElementById('status');
2753 var start=performance.now();
2754 var html=compileSource(src);
2755 var ms=(performance.now()-start).toFixed(1);
2756 lastOutput=html;
2757 document.getElementById('preview').srcdoc=html;
2758 status.textContent='Compiled in '+ms+'ms';
2760function copyOutput(){{if(lastOutput)navigator.clipboard.writeText(lastOutput).then(function(){{document.getElementById('status').textContent='Copied to clipboard!'}});}}
2761function share(){{
2762 var src=document.getElementById('editor').value;
2763 var encoded=btoa(unescape(encodeURIComponent(src)));
2764 var url=location.origin+location.pathname+'#'+encoded;
2765 navigator.clipboard.writeText(url).then(function(){{document.getElementById('status').textContent='Share URL copied to clipboard!'}});
2766 history.replaceState(null,'','#'+encoded);
2768function loadFromHash(){{
2769 if(location.hash.length>1){{
2770 try{{
2771 var decoded=decodeURIComponent(escape(atob(location.hash.slice(1))));
2772 document.getElementById('editor').value=decoded;
2773 }}catch(e){{}}
2774 }}
2776// Resizable divider
2777var divider=document.getElementById('divider');
2778var editorPanel=document.getElementById('editor-panel');
2779var dragging=false;
2780divider.addEventListener('mousedown',function(){{dragging=true;document.body.style.cursor='col-resize';document.body.style.userSelect='none';}});
2781document.addEventListener('mousemove',function(e){{if(!dragging)return;var pct=(e.clientX/window.innerWidth)*100;editorPanel.style.flex='none';editorPanel.style.width=pct+'%';}});
2782document.addEventListener('mouseup',function(){{if(dragging){{dragging=false;document.body.style.cursor='';document.body.style.userSelect='';}}}});
2783// Keyboard shortcuts and auto-compile
2784var editor=document.getElementById('editor');
2785editor.addEventListener('keydown',function(e){{
2786 if(e.key==='Enter'&&(e.ctrlKey||e.metaKey)){{e.preventDefault();compile();}}
2787 if(e.key==='Tab'){{e.preventDefault();var s=e.target;var start=s.selectionStart;s.value=s.value.substring(0,start)+' '+s.value.substring(s.selectionEnd);s.selectionStart=s.selectionEnd=start+2;}}
2788}});
2789loadFromHash();
2790compile();
2791</script>
2792</body>
2793</html>"##,
2794 wasm_section = wasm_section,
2795 compile_fn = compile_fn
2796 );
2797 if let Err(e) = fs::write(out, playground_html) {
2798 eprintln!("error writing {}: {}", out, e);
2799 process::exit(1);
2801 eprintln!("open in browser: open {}", out);
2802 return;
2805 // Handle "clean" subcommand — remove generated .html files
2806 if args.len() >= 2 && args[1] == "clean" {
2807 let target = if args.len() >= 3 { &args[2] } else { "." };
2808 let dir = Path::new(target);
2809 if !dir.is_dir() {
2810 eprintln!("error: '{}' is not a directory", target);
2811 process::exit(1);
2813 let hl_files = collect_hl_files_recursive(dir);
2814 let mut removed = 0;
2815 for hl_file in &hl_files {
2816 let html_file = hl_file.with_extension("html");
2817 if html_file.exists() {
2818 match fs::remove_file(&html_file) {
2819 Ok(()) => {
2820 eprintln!("removed {}", html_file.display());
2821 removed += 1;
2823 Err(e) => eprintln!("error: {}: {}", html_file.display(), e),
2827 // Also remove sitemap.xml if present
2828 let sitemap = dir.join("sitemap.xml");
2829 if sitemap.exists()
2830 && let Ok(()) = fs::remove_file(&sitemap)
2832 eprintln!("removed {}", sitemap.display());
2833 removed += 1;
2835 eprintln!(
2836 "cleaned {} file{}",
2837 removed,
2838 if removed == 1 { "" } else { "s" }
2839 );
2840 return;
2843 // Handle "outline" subcommand — show document structure
2844 if args.len() >= 2 && args[1] == "outline" {
2845 if args.len() < 3 {
2846 eprintln!("usage: htmlang outline <file.hl>");
2847 process::exit(1);
2849 let path = &args[2];
2850 let input = match fs::read_to_string(path) {
2851 Ok(s) => s,
2852 Err(e) => {
2853 eprintln!("error: {}: {}", path, e);
2854 process::exit(1);
2856 };
2857 let base = Path::new(path).parent();
2858 let result = htmlang::parser::parse_with_base(&input, base);
2859 if let Some(title) = &result.document.page_title {
2860 eprintln!("@page {}", title);
2862 fn print_outline(nodes: &[htmlang::ast::Node], depth: usize) {
2863 let indent = " ".repeat(depth);
2864 for node in nodes {
2865 match node {
2866 htmlang::ast::Node::Element(elem) => {
2867 let kind = format!("{:?}", elem.kind);
2868 let kind = kind.split('(').next().unwrap_or(&kind);
2869 let label = if let Some(arg) = &elem.argument {
2870 format!("@{} {}", kind.to_lowercase(), arg)
2871 } else {
2872 format!("@{}", kind.to_lowercase())
2873 };
2874 eprintln!("{}{}", indent, label);
2875 print_outline(&elem.children, depth + 1);
2877 htmlang::ast::Node::Text(segs) => {
2878 let text: String = segs
2879 .iter()
2880 .filter_map(|s| match s {
2881 htmlang::ast::TextSegment::Plain(t) => Some(t.as_str()),
2882 _ => None,
2883 })
2884 .collect();
2885 if !text.trim().is_empty() {
2886 let display = if text.len() > 40 {
2887 format!("{}...", &text[..37])
2888 } else {
2889 text
2890 };
2891 eprintln!("{}\"{}\"", indent, display.trim());
2894 htmlang::ast::Node::Raw(_) => {
2895 eprintln!("{}@raw ...", indent);
2900 print_outline(&result.document.nodes, 0);
2901 return;
2904 // Handle "doctor" subcommand — check toolchain health
2905 if args.len() >= 2 && args[1] == "doctor" {
2906 eprintln!("htmlang doctor — checking toolchain health\n");
2907 let mut ok = true;
2909 // Check htmlang version
2910 eprintln!(" htmlang: v{}", env!("CARGO_PKG_VERSION"));
2912 // Check if htmlang-lsp binary is available
2913 let lsp_status = process::Command::new("htmlang-lsp")
2914 .arg("--version")
2915 .output();
2916 match lsp_status {
2917 Ok(output) if output.status.success() => {
2918 let ver = String::from_utf8_lossy(&output.stdout);
2919 eprintln!(" htmlang-lsp: {}", ver.trim());
2921 _ => {
2922 eprintln!(" htmlang-lsp: not found (optional — needed for editor integration)");
2926 // Check if cargo is available (for building from source)
2927 let cargo_status = process::Command::new("cargo").arg("--version").output();
2928 match cargo_status {
2929 Ok(output) if output.status.success() => {
2930 let ver = String::from_utf8_lossy(&output.stdout);
2931 eprintln!(" cargo: {}", ver.trim());
2933 _ => {
2934 eprintln!(" cargo: not found");
2935 ok = false;
2939 // Check if git is available (for deploy)
2940 let git_status = process::Command::new("git").arg("--version").output();
2941 match git_status {
2942 Ok(output) if output.status.success() => {
2943 let ver = String::from_utf8_lossy(&output.stdout);
2944 eprintln!(" git: {}", ver.trim());
2946 _ => {
2947 eprintln!(" git: not found (optional — needed for deploy)");
2951 // Check for htmlang.toml in current directory
2952 if Path::new("htmlang.toml").exists() {
2953 eprintln!(" config: htmlang.toml found");
2954 } else {
2955 eprintln!(" config: no htmlang.toml (using defaults)");
2958 // Check for .hl files
2959 let hl_count = collect_hl_files_recursive(Path::new(".")).len();
2960 eprintln!(" .hl files: {} found in current directory", hl_count);
2962 eprintln!();
2963 if ok {
2964 eprintln!("all checks passed");
2965 } else {
2966 eprintln!("some checks failed — see above");
2967 process::exit(1);
2969 return;
2972 // Handle "migrate" subcommand — auto-upgrade deprecated syntax
2973 if args.len() >= 2 && args[1] == "migrate" {
2974 let target = if args.len() >= 3 { &args[2] } else { "." };
2975 let path = Path::new(target);
2976 let hl_files = if path.is_dir() {
2977 collect_hl_files_recursive(path)
2978 } else {
2979 vec![PathBuf::from(target)]
2980 };
2981 if hl_files.is_empty() {
2982 eprintln!("no .hl files found in {}", target);
2983 process::exit(1);
2985 let mut total_changes = 0usize;
2986 for file in &hl_files {
2987 let input = match fs::read_to_string(file) {
2988 Ok(s) => s,
2989 Err(_) => continue,
2990 };
2991 let mut output = String::new();
2992 let mut changes = 0usize;
2993 for line in input.lines() {
2994 let mut migrated = line.to_string();
2995 // Migrate @divider -> @hr
2996 if migrated.trim().starts_with("@divider") {
2997 migrated = migrated.replace("@divider", "@hr");
2998 changes += 1;
3000 // Migrate @ul -> @list
3001 if migrated.trim().starts_with("@ul") && !migrated.trim().starts_with("@unless") {
3002 migrated = migrated.replacen("@ul", "@list", 1);
3003 changes += 1;
3005 // Migrate align-center -> center-x (common mistake)
3006 if migrated.contains("align-center") {
3007 migrated = migrated.replace("align-center", "center-x");
3008 changes += 1;
3010 output.push_str(&migrated);
3011 output.push('\n');
3013 // Remove trailing extra newline if original didn't end with one
3014 if !input.ends_with('\n') && output.ends_with('\n') {
3015 output.pop();
3017 if changes > 0 {
3018 match fs::write(file, &output) {
3019 Ok(()) => {
3020 eprintln!(
3021 "migrated {} ({} change{})",
3022 file.display(),
3023 changes,
3024 if changes == 1 { "" } else { "s" }
3025 );
3026 total_changes += changes;
3028 Err(e) => eprintln!("error: {}: {}", file.display(), e),
3032 if total_changes == 0 {
3033 eprintln!("no migrations needed");
3034 } else {
3035 eprintln!(
3036 "\n{} total change(s) across {} file(s)",
3037 total_changes,
3038 hl_files.len()
3039 );
3041 return;
3044 // Handle "bundle" subcommand — inline images/fonts as data URIs
3045 if args.len() >= 2 && args[1] == "bundle" {
3046 if args.len() < 3 {
3047 eprintln!("usage: htmlang bundle <file.hl | dir> [-o <out>]");
3048 process::exit(1);
3050 let mut bundle_target = None;
3051 let mut bundle_out = None;
3052 let mut bi = 2;
3053 while bi < args.len() {
3054 match args[bi].as_str() {
3055 "-o" | "--output" => {
3056 bi += 1;
3057 bundle_out = args.get(bi).map(|s| s.as_str());
3059 _ if bundle_target.is_none() => bundle_target = Some(args[bi].as_str()),
3060 _ => {
3061 eprintln!("unknown argument: {}", args[bi]);
3062 process::exit(1);
3065 bi += 1;
3067 let target = bundle_target.unwrap_or(".");
3068 let path = Path::new(target);
3069 let hl_files = if path.is_dir() {
3070 collect_hl_files_recursive(path)
3071 } else {
3072 vec![PathBuf::from(target)]
3073 };
3074 if hl_files.is_empty() {
3075 eprintln!("no .hl files found in {}", target);
3076 process::exit(1);
3078 for file in &hl_files {
3079 let input = match fs::read_to_string(file) {
3080 Ok(s) => s,
3081 Err(e) => {
3082 eprintln!("error: {}: {}", file.display(), e);
3083 continue;
3085 };
3086 let base = file.parent();
3087 let result = htmlang::parser::parse_with_base(&input, base);
3088 if result
3089 .diagnostics
3090 .iter()
3091 .any(|d| d.severity == htmlang::parser::Severity::Error)
3093 for d in &result.diagnostics {
3094 if d.severity == htmlang::parser::Severity::Error {
3095 eprintln!("error: line {}: {}", d.line, d.message);
3098 continue;
3100 let html = htmlang::codegen::generate(&result.document);
3101 // Inline external assets: images and fonts referenced in the HTML
3102 let bundled = bundle_assets(&html, base.unwrap_or(Path::new(".")));
3103 let out_path = match bundle_out {
3104 Some(o) => {
3105 let p = Path::new(o);
3106 if p.is_dir() || hl_files.len() > 1 {
3107 let _ = fs::create_dir_all(p);
3108 let rel = file.strip_prefix(path).unwrap_or(file);
3109 p.join(rel).with_extension("html")
3110 } else {
3111 PathBuf::from(o)
3114 None => file.with_extension("html"),
3115 };
3116 match fs::write(&out_path, &bundled) {
3117 Ok(()) => eprintln!("bundled {}", out_path.display()),
3118 Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
3121 return;
3124 // Handle "size" subcommand — report output sizes with gzip estimates
3125 if args.len() >= 2 && args[1] == "size" {
3126 let target = if args.len() >= 3 { &args[2] } else { "." };
3127 let path = Path::new(target);
3128 let hl_files = if path.is_dir() {
3129 collect_hl_files_recursive(path)
3130 } else {
3131 vec![PathBuf::from(target)]
3132 };
3133 if hl_files.is_empty() {
3134 eprintln!("no .hl files found in {}", target);
3135 process::exit(1);
3137 let mut total_source = 0usize;
3138 let mut total_output = 0usize;
3139 let mut total_minified = 0usize;
3140 for file in &hl_files {
3141 let input = match fs::read_to_string(file) {
3142 Ok(s) => s,
3143 Err(_) => continue,
3144 };
3145 total_source += input.len();
3146 let base = file.parent();
3147 let result = htmlang::parser::parse_with_base(&input, base);
3148 if result
3149 .diagnostics
3150 .iter()
3151 .any(|d| d.severity == htmlang::parser::Severity::Error)
3153 continue;
3155 let html = htmlang::codegen::generate(&result.document);
3156 let minified = htmlang::codegen::generate_minified(&result.document);
3157 let html_len = html.len();
3158 let min_len = minified.len();
3159 // Estimate gzip size as ~30% of minified (rough heuristic for HTML)
3160 let gzip_est = min_len * 30 / 100;
3161 total_output += html_len;
3162 total_minified += min_len;
3163 eprintln!(
3164 " {}: {} → {} (minified: {}, ~gzip: {})",
3165 file.display(),
3166 format_bytes(input.len()),
3167 format_bytes(html_len),
3168 format_bytes(min_len),
3169 format_bytes(gzip_est),
3170 );
3172 eprintln!("---");
3173 let total_gzip = total_minified * 30 / 100;
3174 eprintln!(
3175 " total: {} source → {} output (minified: {}, ~gzip: {})",
3176 format_bytes(total_source),
3177 format_bytes(total_output),
3178 format_bytes(total_minified),
3179 format_bytes(total_gzip),
3180 );
3181 return;
3184 // Handle "explain" subcommand — show CSS mapping for each line
3185 if args.len() >= 2 && args[1] == "explain" {
3186 if args.len() < 3 {
3187 eprintln!("usage: htmlang explain <file.hl>");
3188 process::exit(1);
3190 let file = &args[2];
3191 let input = match fs::read_to_string(file) {
3192 Ok(s) => s,
3193 Err(e) => {
3194 eprintln!("error: {}: {}", file, e);
3195 process::exit(1);
3197 };
3198 let base = Path::new(file).parent();
3199 let result = htmlang::parser::parse_with_base(&input, base);
3200 for d in &result.diagnostics {
3201 let prefix = severity_label(d.severity);
3202 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
3204 if result
3205 .diagnostics
3206 .iter()
3207 .any(|d| d.severity == htmlang::parser::Severity::Error)
3209 process::exit(1);
3211 let html = htmlang::codegen::generate_dev(&result.document);
3212 // Extract CSS class mappings from <style> block
3213 let mut class_css: HashMap<String, String> = HashMap::new();
3214 if let Some(style_start) = html.find("<style>")
3215 && let Some(style_end) = html[style_start..].find("</style>")
3217 let css_block = &html[style_start + 7..style_start + style_end];
3218 // Parse each CSS rule: .className { rules }
3219 let mut pos = 0;
3220 let css_bytes = css_block.as_bytes();
3221 while pos < css_bytes.len() {
3222 if css_bytes[pos] == b'.' {
3223 let rule_start = pos;
3224 // Find class name end
3225 pos += 1;
3226 while pos < css_bytes.len()
3227 && css_bytes[pos] != b' '
3228 && css_bytes[pos] != b'{'
3229 && css_bytes[pos] != b':'
3230 && css_bytes[pos] != b','
3232 pos += 1;
3234 let class_name = &css_block[rule_start + 1..pos];
3235 // Find rule body
3236 while pos < css_bytes.len() && css_bytes[pos] != b'{' {
3237 pos += 1;
3239 if pos < css_bytes.len() {
3240 pos += 1; // skip '{'
3241 let body_start = pos;
3242 let mut depth = 1;
3243 while pos < css_bytes.len() && depth > 0 {
3244 if css_bytes[pos] == b'{' {
3245 depth += 1;
3247 if css_bytes[pos] == b'}' {
3248 depth -= 1;
3250 pos += 1;
3252 let body = css_block[body_start..pos.saturating_sub(1)].trim();
3253 if !body.is_empty() {
3254 class_css
3255 .entry(class_name.to_string())
3256 .and_modify(|existing| {
3257 existing.push_str("; ");
3258 existing.push_str(body);
3259 })
3260 .or_insert_with(|| body.to_string());
3263 } else {
3264 pos += 1;
3268 // Extract data-hl-line attributes from dev HTML to map classes to source lines
3269 let mut line_classes: HashMap<usize, Vec<String>> = HashMap::new();
3270 let html_body = if let Some(body_start) = html.find("<body") {
3271 &html[body_start..]
3272 } else {
3273 &html
3274 };
3275 // Scan for elements with data-hl-line="N" and class="X"
3276 // Dev mode outputs: <div class="a" data-hl-line="2" data-hl-el="el">
3277 let mut scan_pos = 0;
3278 while scan_pos < html_body.len() {
3279 // Find each opening tag
3280 if html_body.as_bytes()[scan_pos] == b'<'
3281 && scan_pos + 1 < html_body.len()
3282 && html_body.as_bytes()[scan_pos + 1].is_ascii_alphabetic()
3284 // Find end of tag
3285 let tag_end = html_body[scan_pos..]
3286 .find('>')
3287 .map(|p| scan_pos + p)
3288 .unwrap_or(html_body.len());
3289 let tag = &html_body[scan_pos..tag_end];
3290 // Extract data-hl-line
3291 if let Some(hl_pos) = tag.find("data-hl-line=\"") {
3292 let after = &tag[hl_pos + 14..];
3293 if let Some(end) = after.find('"')
3294 && let Ok(ln) = after[..end].parse::<usize>()
3296 // Extract class
3297 if let Some(cls_pos) = tag.find("class=\"") {
3298 let cls_after = &tag[cls_pos + 7..];
3299 if let Some(cls_end) = cls_after.find('"') {
3300 for cls in cls_after[..cls_end].split_whitespace() {
3301 line_classes.entry(ln).or_default().push(cls.to_string());
3307 scan_pos = tag_end;
3308 } else {
3309 scan_pos += 1;
3312 // Print explanation
3313 for (line_num, line) in input.lines().enumerate() {
3314 let ln = line_num + 1;
3315 let trimmed = line.trim();
3316 if trimmed.is_empty() || trimmed.starts_with("--") {
3317 continue;
3319 print!("{:>4} | {}", ln, line);
3320 if let Some(classes) = line_classes.get(&ln) {
3321 let mut css_parts: Vec<String> = Vec::new();
3322 for cls in classes {
3323 if let Some(css) = class_css.get(cls) {
3324 css_parts.push(css.clone());
3327 if !css_parts.is_empty() {
3328 println!();
3329 println!(" → {}", css_parts.join("; "));
3330 } else {
3331 println!();
3333 } else {
3334 println!();
3337 return;
3340 // Handle "benchmark" subcommand — measure compile time and output size
3341 if args.len() >= 2 && args[1] == "benchmark" {
3342 if args.len() < 3 {
3343 eprintln!("usage: htmlang benchmark <file.hl | dir>");
3344 process::exit(1);
3346 let target = &args[2];
3347 let path = Path::new(target);
3348 let hl_files = if path.is_dir() {
3349 collect_hl_files_recursive(path)
3350 } else {
3351 vec![PathBuf::from(target)]
3352 };
3353 if hl_files.is_empty() {
3354 eprintln!("no .hl files found in {}", target);
3355 process::exit(1);
3358 let mut total_source = 0usize;
3359 let mut total_output = 0usize;
3360 let mut total_css_rules = 0usize;
3361 let mut total_elements = 0usize;
3362 let mut total_errors = 0usize;
3364 let start = std::time::Instant::now();
3365 for file in &hl_files {
3366 let input = match fs::read_to_string(file) {
3367 Ok(s) => s,
3368 Err(_) => continue,
3369 };
3370 total_source += input.len();
3371 let base = file.parent();
3372 let result = htmlang::parser::parse_with_base(&input, base);
3373 let has_errors = result
3374 .diagnostics
3375 .iter()
3376 .any(|d| d.severity == htmlang::parser::Severity::Error);
3377 if has_errors {
3378 total_errors += 1;
3379 continue;
3381 let html = htmlang::codegen::generate(&result.document);
3382 total_output += html.len();
3383 total_css_rules += html.matches('{').count().saturating_sub(1);
3384 let mut ec = 0;
3385 let mut colors = std::collections::HashSet::new();
3386 let mut fonts = std::collections::HashSet::new();
3387 count_elements(&result.document.nodes, &mut ec, &mut colors, &mut fonts);
3388 total_elements += ec;
3390 let elapsed = start.elapsed();
3392 eprintln!("--- benchmark results ---");
3393 eprintln!(" files: {}", hl_files.len());
3394 eprintln!(" compile time: {:.1}ms", elapsed.as_secs_f64() * 1000.0);
3395 eprintln!(" source size: {} bytes", total_source);
3396 eprintln!(" output size: {} bytes", total_output);
3397 if total_source > 0 {
3398 eprintln!(
3399 " ratio: {:.1}x",
3400 total_output as f64 / total_source as f64
3401 );
3403 eprintln!(" elements: {}", total_elements);
3404 eprintln!(" CSS rules: ~{}", total_css_rules);
3405 if total_errors > 0 {
3406 eprintln!(" errors: {} file(s) had errors", total_errors);
3408 if hl_files.len() > 1 {
3409 let per_file = elapsed.as_secs_f64() * 1000.0 / hl_files.len() as f64;
3410 eprintln!(" per file: {:.2}ms", per_file);
3412 return;
3415 // Handle "upgrade" subcommand — comprehensive version-to-version migration
3416 if args.len() >= 2 && args[1] == "upgrade" {
3417 let target = if args.len() >= 3 { &args[2] } else { "." };
3418 let path = Path::new(target);
3419 let hl_files = if path.is_dir() {
3420 collect_hl_files_recursive(path)
3421 } else {
3422 vec![PathBuf::from(target)]
3423 };
3424 if hl_files.is_empty() {
3425 eprintln!("no .hl files found in {}", target);
3426 process::exit(1);
3428 let mut total_changes = 0usize;
3429 for file in &hl_files {
3430 let input = match fs::read_to_string(file) {
3431 Ok(s) => s,
3432 Err(_) => continue,
3433 };
3434 let mut output = String::new();
3435 let mut changes = 0usize;
3436 for line in input.lines() {
3437 let mut migrated = line.to_string();
3438 // Migrate @divider -> @hr
3439 if migrated.trim().starts_with("@divider") {
3440 migrated = migrated.replace("@divider", "@hr");
3441 changes += 1;
3443 // Migrate @ul -> @list
3444 if migrated.trim().starts_with("@ul") && !migrated.trim().starts_with("@unless") {
3445 migrated = migrated.replacen("@ul", "@list", 1);
3446 changes += 1;
3448 // Migrate @col -> @column (full form)
3449 if migrated.trim().starts_with("@col ") || migrated.trim() == "@col" {
3450 migrated = migrated.replacen("@col", "@column", 1);
3451 changes += 1;
3453 // Migrate @img -> @image (full form)
3454 if migrated.trim().starts_with("@img ") || migrated.trim() == "@img" {
3455 migrated = migrated.replacen("@img", "@image", 1);
3456 changes += 1;
3458 // Migrate @p -> @paragraph (full form)
3459 if migrated.trim().starts_with("@p ") || migrated.trim() == "@p" {
3460 migrated = migrated.replacen("@p ", "@paragraph ", 1);
3461 changes += 1;
3463 // Migrate @btn -> @button (full form)
3464 if migrated.trim().starts_with("@btn ") || migrated.trim() == "@btn" {
3465 migrated = migrated.replacen("@btn", "@button", 1);
3466 changes += 1;
3468 // Migrate @li -> @item (full form)
3469 if migrated.trim().starts_with("@li ") || migrated.trim() == "@li" {
3470 migrated = migrated.replacen("@li", "@item", 1);
3471 changes += 1;
3473 // Migrate align-center -> center-x
3474 if migrated.contains("align-center") {
3475 migrated = migrated.replace("align-center", "center-x");
3476 changes += 1;
3478 // Migrate spacing -> gap (modern naming)
3479 if migrated.contains("spacing ") && migrated.contains('[') {
3480 migrated = migrated.replace("spacing ", "gap ");
3481 changes += 1;
3483 output.push_str(&migrated);
3484 output.push('\n');
3486 if !input.ends_with('\n') && output.ends_with('\n') {
3487 output.pop();
3489 if changes > 0 {
3490 match fs::write(file, &output) {
3491 Ok(()) => {
3492 eprintln!(
3493 "upgraded {} ({} change{})",
3494 file.display(),
3495 changes,
3496 if changes == 1 { "" } else { "s" }
3497 );
3498 total_changes += changes;
3500 Err(e) => eprintln!("error: {}: {}", file.display(), e),
3504 if total_changes == 0 {
3505 eprintln!("no upgrades needed — all files are up to date");
3506 } else {
3507 eprintln!("\n{} total change(s) applied", total_changes);
3509 return;
3512 // Handle "create-component" subcommand — scaffold a new component
3513 if args.len() >= 2 && args[1] == "create-component" {
3514 if args.len() < 3 {
3515 eprintln!("usage: htmlang create-component <name> [param1] [param2] ...");
3516 process::exit(1);
3518 let comp_name = &args[2];
3519 let params: Vec<&str> = args[3..].iter().map(|s| s.as_str()).collect();
3520 let file_name = format!("{}.hl", comp_name);
3521 let file_path = Path::new(&file_name);
3522 if file_path.exists() {
3523 eprintln!("error: {} already exists", file_name);
3524 process::exit(1);
3527 let mut template = String::new();
3528 template.push_str(&format!("-- {} component\n", comp_name));
3529 template.push_str("-- Usage: @");
3530 template.push_str(comp_name);
3531 if !params.is_empty() {
3532 template.push_str(" [");
3533 for (i, p) in params.iter().enumerate() {
3534 if i > 0 {
3535 template.push_str(", ");
3537 template.push_str(p);
3538 template.push_str(" value");
3540 template.push(']');
3542 template.push('\n');
3543 template.push('\n');
3545 // Generate @let component definition
3546 template.push_str("@let ");
3547 template.push_str(comp_name);
3548 for p in &params {
3549 template.push(' ');
3550 template.push('$');
3551 template.push_str(p);
3553 template.push('\n');
3554 template.push_str(" @el [padding 16, rounded 8, border 1 #e5e7eb]\n");
3555 if !params.is_empty() {
3556 for p in &params {
3557 template.push_str(&format!(" @text ${}\\n", p));
3560 template.push_str(" @children\n");
3562 match fs::write(file_path, &template) {
3563 Ok(()) => {
3564 eprintln!("created {}", file_name);
3565 eprintln!("import with: @use \"{}\" {}", file_name, comp_name);
3567 Err(e) => {
3568 eprintln!("error: {}: {}", file_name, e);
3569 process::exit(1);
3572 return;
3575 // Handle "convert" subcommand
3576 if args.len() >= 2 && args[1] == "convert" {
3577 if args.len() < 3 {
3578 eprintln!("usage: htmlang convert <file.html>");
3579 process::exit(1);
3581 let html_file = &args[2];
3582 let html = match fs::read_to_string(html_file) {
3583 Ok(s) => s,
3584 Err(e) => {
3585 eprintln!("error: {}: {}", html_file, e);
3586 process::exit(1);
3588 };
3589 let hl_output = htmlang::convert::convert(&html);
3590 print!("{}", hl_output);
3591 return;
3594 let mut i = 1;
3595 while i < args.len() {
3596 match args[i].as_str() {
3597 "--help" | "-h" => {
3598 cli::print_help();
3599 process::exit(0);
3601 "--version" | "-V" => {
3602 println!("htmlang {}", env!("CARGO_PKG_VERSION"));
3603 process::exit(0);
3605 "--watch" | "-w" => watch = true,
3606 "--dev" | "-d" => dev = true,
3607 "--check" | "-c" => check = true,
3608 "--compat" => compat = true,
3609 "--strict" => strict = true,
3610 "--open" => open_browser = true,
3611 "--partial" => partial = true,
3612 "--format" => {
3613 i += 1;
3614 match args.get(i) {
3615 Some(f) if f == "json" => format_json = true,
3616 Some(f) => {
3617 eprintln!("unknown format: {}", f);
3618 process::exit(1);
3620 None => {
3621 eprintln!("--format requires a value");
3622 process::exit(1);
3626 "--serve" | "-s" => {
3627 serve = true;
3628 watch = true;
3630 "--port" | "-p" => {
3631 i += 1;
3632 match args.get(i) {
3633 Some(p) => {
3634 port = p.parse().unwrap_or_else(|_| {
3635 eprintln!("invalid port: {}", p);
3636 process::exit(1);
3637 });
3639 None => {
3640 eprintln!("--port requires a value");
3641 process::exit(1);
3645 "--output" | "-o" => {
3646 i += 1;
3647 match args.get(i) {
3648 Some(p) => output_path = Some(p.clone()),
3649 None => {
3650 eprintln!("--output requires a value");
3651 process::exit(1);
3655 _ if input_path.is_none() => input_path = Some(args[i].clone()),
3656 _ => {
3657 eprintln!("unknown argument: {}", args[i]);
3658 process::exit(1);
3661 i += 1;
3664 let input_path = match input_path {
3665 Some(p) => p,
3666 None => {
3667 cli::print_help();
3668 process::exit(1);
3670 };
3672 let is_dir = Path::new(&input_path).is_dir();
3674 // --- Directory mode: compile all .hl files ---
3675 if is_dir {
3676 let dir = Path::new(&input_path);
3677 let hl_files = collect_hl_files(dir);
3678 if hl_files.is_empty() {
3679 eprintln!("no .hl files found in {}", input_path);
3680 process::exit(1);
3683 // Create output dir if needed
3684 if let Some(ref out) = output_path {
3685 let _ = fs::create_dir_all(out);
3688 let json_collector = if format_json {
3689 Some(Mutex::new(Vec::new()))
3690 } else {
3691 None
3692 };
3693 let mut any_errors = false;
3694 let mut all_included: Vec<PathBuf> = Vec::new();
3695 for file in &hl_files {
3696 let path_str = file.to_string_lossy().to_string();
3697 let effective_out = output_path.as_ref().map(|o| {
3698 let rel = file.strip_prefix(dir).unwrap_or(file);
3699 let out_p = Path::new(o).join(rel).with_extension("html");
3700 if let Some(parent) = out_p.parent() {
3701 let _ = fs::create_dir_all(parent);
3703 out_p.to_string_lossy().to_string()
3704 });
3705 let (has_errors, included) = compile(
3706 &path_str,
3707 &CompileConfig {
3708 dev,
3709 error_overlay: serve,
3710 check_only: check,
3711 output_path: effective_out.as_deref(),
3712 format_json,
3713 json_collector: json_collector.as_ref(),
3714 compat,
3715 strict,
3716 partial,
3717 ..Default::default()
3718 },
3719 );
3720 if has_errors {
3721 any_errors = true;
3723 all_included.extend(included);
3726 if format_json && let Some(collector) = json_collector {
3727 print_json_diagnostics(&collector.lock().unwrap());
3730 if !watch {
3731 if any_errors {
3732 process::exit(1);
3734 return;
3737 // For directory serve mode, serve the directory with route mapping
3738 let reload_tx = if serve {
3739 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
3740 let serve_dir = dir.to_path_buf();
3741 let server_tx = tx.clone();
3742 std::thread::spawn(move || {
3743 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
3744 rt.block_on(htmlang::serve::run_dir(port, serve_dir, server_tx));
3745 });
3746 if open_browser {
3747 open_in_browser(port);
3749 Some(tx)
3750 } else {
3751 None
3752 };
3754 watch_loop(
3755 dir,
3756 &hl_files,
3757 &all_included,
3758 dev,
3759 serve,
3760 reload_tx,
3761 port,
3762 50,
3763 );
3764 return;
3767 // --- Single file mode ---
3768 let json_collector_single = if format_json {
3769 Some(Mutex::new(Vec::new()))
3770 } else {
3771 None
3772 };
3773 let (has_errors, included_files) = compile(
3774 &input_path,
3775 &CompileConfig {
3776 dev,
3777 error_overlay: serve,
3778 check_only: check,
3779 output_path: output_path.as_deref(),
3780 format_json,
3781 json_collector: json_collector_single.as_ref(),
3782 compat,
3783 strict,
3784 partial,
3785 ..Default::default()
3786 },
3787 );
3788 if format_json && let Some(ref collector) = json_collector_single {
3789 print_json_diagnostics(&collector.lock().unwrap());
3792 if !watch {
3793 if has_errors {
3794 process::exit(1);
3796 return;
3799 // Start dev server if requested
3800 let reload_tx = if serve {
3801 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
3802 let out_path = match output_path {
3803 Some(ref p) => PathBuf::from(p),
3804 None => Path::new(&input_path).with_extension("html"),
3805 };
3806 let server_tx = tx.clone();
3807 std::thread::spawn(move || {
3808 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
3809 rt.block_on(htmlang::serve::run(port, out_path, server_tx));
3810 });
3811 if open_browser {
3812 open_in_browser(port);
3814 Some(tx)
3815 } else {
3816 None
3817 };
3819 let files = vec![PathBuf::from(&input_path)];
3820 watch_loop(
3821 Path::new(&input_path).parent().unwrap_or(Path::new(".")),
3822 &files,
3823 &included_files,
3824 dev,
3825 serve,
3826 reload_tx,
3827 port,
3828 50,
3829 );
3832// (CLI help, LSP launcher, and shell completions moved to cli.rs)
3834fn collect_hl_files(dir: &Path) -> Vec<PathBuf> {
3835 let mut files = Vec::new();
3836 if let Ok(entries) = fs::read_dir(dir) {
3837 for entry in entries.flatten() {
3838 let path = entry.path();
3839 if path.is_file() && path.extension().is_some_and(|e| e == "hl") {
3840 files.push(path);
3844 files.sort();
3845 files
3848#[allow(clippy::too_many_arguments)]
3849fn watch_loop(
3850 watch_dir: &Path,
3851 source_files: &[PathBuf],
3852 included_files: &[PathBuf],
3853 dev: bool,
3854 serve: bool,
3855 reload_tx: Option<tokio::sync::broadcast::Sender<()>>,
3856 serve_port: u16,
3857 debounce_ms: u64,
3858) {
3859 use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
3860 use std::sync::mpsc;
3862 let (tx, rx) = mpsc::channel();
3863 let mut watcher = RecommendedWatcher::new(
3864 move |res| {
3865 if let Ok(event) = res {
3866 let _ = tx.send(event);
3868 },
3869 Config::default(),
3871 .expect("failed to create file watcher");
3873 // Watch all source files
3874 for file in source_files {
3875 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3876 watcher
3877 .watch(&canonical, RecursiveMode::NonRecursive)
3878 .unwrap_or_else(|_| eprintln!("warning: could not watch {}", file.display()));
3880 for inc in included_files {
3881 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
3884 // Also watch the directory itself for new files
3885 let _ = watcher.watch(watch_dir, RecursiveMode::NonRecursive);
3887 if serve {
3888 eprintln!("watching for changes at http://127.0.0.1:{}", serve_port);
3889 } else {
3890 eprintln!("watching for changes...");
3893 // Track content hashes for incremental rebuilds
3894 let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
3895 // Dependency map: source file -> list of included/imported files. Maintained
3896 // across rebuilds so we always know the current dep graph.
3897 let mut dep_map: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
3898 // Set of currently-watched include paths. Used to unwatch files that are
3899 // no longer referenced after a rebuild changes the dep graph.
3900 let mut watched_includes: HashSet<PathBuf> = HashSet::new();
3902 fn hash_file(path: &Path) -> Option<u64> {
3903 let content = fs::read(path).ok()?;
3904 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3905 content.hash(&mut hasher);
3906 Some(hasher.finish())
3909 // Seed initial hashes and dependency map
3910 for file in source_files {
3911 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3912 if let Some(h) = hash_file(&canonical) {
3913 content_hashes.insert(canonical, h);
3916 // Seed dep_map from the initial compile's include list so that the first
3917 // change event correctly picks up include-file modifications even before
3918 // the first recompile replaces the entry.
3919 let initial_includes_canonical: Vec<PathBuf> = included_files
3920 .iter()
3921 .map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
3922 .collect();
3923 for inc in &initial_includes_canonical {
3924 if let Some(h) = hash_file(inc) {
3925 content_hashes.insert(inc.clone(), h);
3927 watched_includes.insert(inc.clone());
3929 // Attach the initial include list to every source file so a change to any
3930 // of them triggers a rebuild of any source. Per-file dep refinement happens
3931 // on the next recompile.
3932 if !initial_includes_canonical.is_empty() {
3933 for file in source_files {
3934 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3935 dep_map.insert(canonical, initial_includes_canonical.clone());
3939 while rx.recv().is_ok() {
3940 // Drain additional events (debounce) with configurable delay
3941 std::thread::sleep(std::time::Duration::from_millis(debounce_ms));
3942 while rx.try_recv().is_ok() {}
3944 // Collect which files actually changed (HashSet for O(1) lookups)
3945 let mut changed_files: HashSet<PathBuf> = HashSet::new();
3946 let check_path = |path: &Path, hashes: &mut HashMap<PathBuf, u64>| -> bool {
3947 if let Some(h) = hash_file(path)
3948 && hashes.get(path) != Some(&h)
3950 hashes.insert(path.to_path_buf(), h);
3951 return true;
3953 false
3954 };
3956 for file in source_files {
3957 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3958 if check_path(&canonical, &mut content_hashes) {
3959 changed_files.insert(canonical);
3963 // Check included files for changes
3964 for deps in dep_map.values() {
3965 for dep in deps {
3966 if check_path(dep, &mut content_hashes) {
3967 changed_files.insert(dep.clone());
3972 // Check for new .hl files in directory
3973 if watch_dir.is_dir() {
3974 let current_files = collect_hl_files(watch_dir);
3975 for file in &current_files {
3976 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3977 if check_path(&canonical, &mut content_hashes) {
3978 changed_files.insert(canonical.clone());
3979 let _ = watcher.watch(&canonical, RecursiveMode::NonRecursive);
3984 if changed_files.is_empty() {
3985 continue;
3988 eprintln!("\nrecompiling...");
3990 // Determine which source files need recompilation:
3991 // 1. Source files that changed directly
3992 // 2. Source files whose dependencies changed
3993 let mut files_to_compile: Vec<PathBuf> = Vec::new();
3994 let all_sources: Vec<PathBuf> = {
3995 let mut s: Vec<PathBuf> = source_files
3996 .iter()
3997 .map(|f| fs::canonicalize(f).unwrap_or_else(|_| f.clone()))
3998 .collect();
3999 if watch_dir.is_dir() {
4000 for file in &collect_hl_files(watch_dir) {
4001 let c = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
4002 if !s.contains(&c) {
4003 s.push(c);
4008 };
4010 for source in &all_sources {
4011 // Recompile if the source itself changed
4012 if changed_files.contains(source) {
4013 if !files_to_compile.contains(source) {
4014 files_to_compile.push(source.clone());
4016 continue;
4018 // Recompile if any of its dependencies changed
4019 if let Some(deps) = dep_map.get(source)
4020 && deps.iter().any(|d| changed_files.contains(d))
4021 && !files_to_compile.contains(source)
4023 files_to_compile.push(source.clone());
4027 // If no specific files identified (e.g., first run), compile all
4028 if files_to_compile.is_empty() {
4029 files_to_compile = all_sources;
4032 let mut recompiled = 0usize;
4033 for file in &files_to_compile {
4034 let path_str = file.to_string_lossy().to_string();
4035 let (_, new_includes) = compile(
4036 &path_str,
4037 &CompileConfig {
4038 dev,
4039 error_overlay: serve,
4040 ..Default::default()
4041 },
4042 );
4043 recompiled += 1;
4044 // Update dependency map
4045 let canonical_deps: Vec<PathBuf> = new_includes
4046 .iter()
4047 .map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
4048 .collect();
4049 for inc in &canonical_deps {
4050 if watched_includes.insert(inc.clone()) {
4051 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
4053 if let Some(h) = hash_file(inc) {
4054 content_hashes.insert(inc.clone(), h);
4057 dep_map.insert(file.clone(), canonical_deps);
4060 // Unwatch include files that are no longer referenced by any
4061 // source. Prevents file-descriptor leaks when @include edges
4062 // are removed mid-session.
4063 let still_needed: HashSet<PathBuf> = dep_map.values().flatten().cloned().collect();
4064 let to_drop: Vec<PathBuf> = watched_includes
4065 .difference(&still_needed)
4066 .cloned()
4067 .collect();
4068 for path in to_drop {
4069 let _ = watcher.unwatch(&path);
4070 watched_includes.remove(&path);
4071 content_hashes.remove(&path);
4074 eprintln!("recompiled {} file(s)", recompiled);
4076 if let Some(ref tx) = reload_tx {
4077 let _ = tx.send(());