on master help sign in
Add 6 improvements: deploy error safety, severity dedup, parser diagnostics, style cache optimization, @import performance, and filesystem tests
78f1ddaiko
crates/htmlang-core/src/codegen.rs+37−20
@@ -1,4 +1,5 @@
1use std::collections::HashMap;
1use std::collections::HashMap;
2+use std::hash::{Hash, Hasher};
2
3
3use crate::ast::*;
4use crate::ast::*;
4
5
@@ -55,7 +56,10 @@ struct StyleEntry {
55
56
56struct StyleCollector {
57struct StyleCollector {
57 entries: Vec<StyleEntry>,
58 entries: Vec<StyleEntry>,
59+ /// Maps a pre-hashed style signature to an index into `entries`.
60+ /// Using u64 as the key keeps lookups allocation-free; on the rare case of
61+ /// a hash collision we fall back to a full equality check against the entry.
58− index: HashMap<String, usize>,
62+ index: HashMap<u64, Vec<usize>>,
59}
63}
60
64
61impl StyleCollector {
65impl StyleCollector {
@@ -93,24 +97,37 @@ impl StyleCollector {
93 {
97 {
94 return None;
98 return None;
95 }
99 }
96− let pseudo_key: String = pseudo
100+ use std::collections::hash_map::DefaultHasher;
97− .iter()
101+ let mut h = DefaultHasher::new();
98− .map(|(sel, css)| format!("{}={}", sel, css))
102+ base.hash(&mut h);
99− .collect::<Vec<_>>()
103+ pseudo.hash(&mut h);
100− .join("|");
101− let resp_key: String = responsive
104+ responsive.hash(&mut h);
102− .iter()
105+ dark.hash(&mut h);
103− .map(|(bp, css)| format!("{}={}", bp, css))
106+ print.hash(&mut h);
104− .collect::<Vec<_>>()
107+ motion_safe.hash(&mut h);
105− .join("|");
108+ motion_reduce.hash(&mut h);
106− let cq_key: String = container
109+ landscape.hash(&mut h);
107− .iter()
110+ portrait.hash(&mut h);
108− .map(|(bp, css)| format!("{}={}", bp, css))
111+ container.hash(&mut h);
109− .collect::<Vec<_>>()
112+ let sig = h.finish();
110− .join("|");
113+
111− let key = format!("{}|{}|{}|{}|{}|{}|{}|{}|{}|{}", base, pseudo_key, resp_key, dark, print, motion_safe, motion_reduce, landscape, portrait, cq_key);
112− if let Some(&idx) = self.index.get(&key) {
114+ if let Some(indices) = self.index.get(&sig) {
115+ for &idx in indices {
113− return Some(self.entries[idx].class_name.clone());
116+ let e = &self.entries[idx];
117+ if e.base == base
118+ && e.pseudo == pseudo
119+ && e.responsive == responsive
120+ && e.dark == dark
121+ && e.print == print
122+ && e.motion_safe == motion_safe
123+ && e.motion_reduce == motion_reduce
124+ && e.landscape == landscape
125+ && e.portrait == portrait
126+ && e.container == container
127+ {
128+ return Some(e.class_name.clone());
129+ }
130+ }
114 }
131 }
115 let idx = self.entries.len();
132 let idx = self.entries.len();
116 let name = short_class_name(idx);
133 let name = short_class_name(idx);
@@ -127,7 +144,7 @@ impl StyleCollector {
127 portrait,
144 portrait,
128 container,
145 container,
129 });
146 });
130− self.index.insert(key, idx);
147+ self.index.entry(sig).or_default().push(idx);
131 Some(name)
148 Some(name)
132 }
149 }
133
150
crates/htmlang-core/src/parser.rs+54−42
@@ -8,7 +8,7 @@ use crate::ast::*;
8// Public types
8// Public types
9// ---------------------------------------------------------------------------
9// ---------------------------------------------------------------------------
10
10
11−#[derive(Debug, Clone, PartialEq)]
11+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Severity {
12pub enum Severity {
13 Error,
13 Error,
14 Warning,
14 Warning,
@@ -409,11 +409,16 @@ impl Parser {
409 return Ok(Some(vec![Node::Raw(content)]));
409 return Ok(Some(vec![Node::Raw(content)]));
410 }
410 }
411
411
412− // Normal content — clone to release borrow
412+ // Normal content — clone to release borrow. Raw is already handled
413+ // above; a let-else pattern avoids a panic path if new LineContent
414+ // variants are added in the future.
413− let content = match &self.lines[self.pos].content {
415+ let LineContent::Normal(content) = &self.lines[self.pos].content else {
414− LineContent::Normal(s) => s.clone(),
416+ return Err(ParseError {
415− _ => unreachable!(),
417+ line: line_num,
418+ message: "internal: unexpected line content variant".to_string(),
419+ });
416 };
420 };
421+ let content = content.clone();
417 self.pos += 1;
422 self.pos += 1;
418
423
419 // --- Directives ---
424 // --- Directives ---
@@ -967,11 +972,17 @@ impl Parser {
967 let diag_count_before = ctx.diagnostics.len();
972 let diag_count_before = ctx.diagnostics.len();
968
973
969 if let Some(ref prefix) = alias {
974 if let Some(ref prefix) = alias {
970− // Parse into a temporary context, then copy definitions with prefix
975+ // Snapshot just the *key sets* before parsing — much cheaper
976+ // than cloning the full HashMaps, since we only need to know
977+ // which definitions are new afterwards.
978+ let fn_keys_before: std::collections::HashSet<String> =
971− let saved_fns = ctx.functions.clone();
979+ ctx.functions.keys().cloned().collect();
972− let saved_defines = ctx.defines.clone();
980+ let define_keys_before: std::collections::HashSet<String> =
973− let saved_mixins = ctx.mixins.clone();
981+ ctx.defines.keys().cloned().collect();
982+ let mixin_keys_before: std::collections::HashSet<String> =
983+ ctx.mixins.keys().cloned().collect();
984+ let var_keys_before: std::collections::HashSet<String> =
974− let saved_vars = ctx.variables.clone();
985+ ctx.variables.keys().cloned().collect();
975
986
976 let imported_lines = preprocess(&imported_text);
987 let imported_lines = preprocess(&imported_text);
977 let mut imported_parser = Parser {
988 let mut imported_parser = Parser {
@@ -980,42 +991,43 @@ impl Parser {
980 };
991 };
981 let _discarded_nodes = imported_parser.parse_children(0, ctx);
992 let _discarded_nodes = imported_parser.parse_children(0, ctx);
982
993
983− // Find newly added definitions and re-register with prefix
994+ // Remove newly added entries and re-insert them under the prefix.
984− let new_fns: Vec<(String, FnDef)> = ctx.functions.iter()
995+ let new_fn_keys: Vec<String> = ctx.functions.keys()
985− .filter(|(k, _)| !saved_fns.contains_key(*k))
996+ .filter(|k| !fn_keys_before.contains(*k))
986− .map(|(k, v)| (k.clone(), v.clone()))
997+ .cloned()
987− .collect();
988− let new_defines: Vec<(String, Vec<Attribute>)> = ctx.defines.iter()
989− .filter(|(k, _)| !saved_defines.contains_key(*k))
990− .map(|(k, v)| (k.clone(), v.clone()))
991 .collect();
998 .collect();
992− let new_mixins: Vec<(String, Vec<Attribute>)> = ctx.mixins.iter()
993− .filter(|(k, _)| !saved_mixins.contains_key(*k))
994− .map(|(k, v)| (k.clone(), v.clone()))
995− .collect();
996− let new_vars: Vec<(String, String)> = ctx.variables.iter()
997− .filter(|(k, _)| !saved_vars.contains_key(*k))
998− .map(|(k, v)| (k.clone(), v.clone()))
999− .collect();
1000−
1001− // Restore originals, then add prefixed versions
1002− ctx.functions = saved_fns;
1003− ctx.defines = saved_defines;
1004− ctx.mixins = saved_mixins;
1005− ctx.variables = saved_vars;
1006−
1007− for (name, def) in new_fns {
999+ for name in new_fn_keys {
1000+ if let Some(def) = ctx.functions.remove(&name) {
1008− ctx.functions.insert(format!("{}.{}", prefix, name), def);
1001+ ctx.functions.insert(format!("{}.{}", prefix, name), def);
1002+ }
1009 }
1003 }
1010− for (name, attrs) in new_defines {
1004+ let new_define_keys: Vec<String> = ctx.defines.keys()
1005+ .filter(|k| !define_keys_before.contains(*k))
1006+ .cloned()
1007+ .collect();
1008+ for name in new_define_keys {
1009+ if let Some(attrs) = ctx.defines.remove(&name) {
1011− ctx.defines.insert(format!("{}.{}", prefix, name), attrs);
1010+ ctx.defines.insert(format!("{}.{}", prefix, name), attrs);
1011+ }
1012 }
1012 }
1013− for (name, attrs) in new_mixins {
1013+ let new_mixin_keys: Vec<String> = ctx.mixins.keys()
1014+ .filter(|k| !mixin_keys_before.contains(*k))
1015+ .cloned()
1016+ .collect();
1017+ for name in new_mixin_keys {
1018+ if let Some(attrs) = ctx.mixins.remove(&name) {
1014− ctx.mixins.insert(format!("{}.{}", prefix, name), attrs);
1019+ ctx.mixins.insert(format!("{}.{}", prefix, name), attrs);
1020+ }
1015 }
1021 }
1022+ let new_var_keys: Vec<String> = ctx.variables.keys()
1023+ .filter(|k| !var_keys_before.contains(*k))
1024+ .cloned()
1025+ .collect();
1016− for (name, val) in new_vars {
1026+ for name in new_var_keys {
1027+ if let Some(val) = ctx.variables.remove(&name) {
1017− if !name.starts_with("__") {
1028+ if !name.starts_with("__") {
1018− ctx.variables.insert(format!("{}.{}", prefix, name), val);
1029+ ctx.variables.insert(format!("{}.{}", prefix, name), val);
1030+ }
1019 }
1031 }
1020 }
1032 }
1021 } else {
1033 } else {
src/main.rs+39−52
@@ -15,6 +15,15 @@ struct DiagnosticJson {
15 message: String,
15 message: String,
16}
16}
17
17
18+fn 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+ }
25+}
26+
18#[derive(Default)]
27#[derive(Default)]
19struct CompileConfig<'a> {
28struct CompileConfig<'a> {
20 dev: bool,
29 dev: bool,
@@ -56,28 +65,17 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
56 if let Some(collector) = cfg.json_collector {
65 if let Some(collector) = cfg.json_collector {
57 let mut collected = collector.lock().unwrap();
66 let mut collected = collector.lock().unwrap();
58 for d in &result.diagnostics {
67 for d in &result.diagnostics {
59− let severity = match d.severity {
60− htmlang::parser::Severity::Error => "error",
61− htmlang::parser::Severity::Warning => "warning",
62− htmlang::parser::Severity::Info => "info",
63− htmlang::parser::Severity::Help => "help",
64− };
65 collected.push(DiagnosticJson {
68 collected.push(DiagnosticJson {
66 file: input_path.to_string(),
69 file: input_path.to_string(),
67 line: d.line,
70 line: d.line,
68− severity: severity.to_string(),
71+ severity: severity_label(d.severity).to_string(),
69 message: d.message.clone(),
72 message: d.message.clone(),
70 });
73 });
71 }
74 }
72 }
75 }
73 } else {
76 } else {
74 for d in &result.diagnostics {
77 for d in &result.diagnostics {
75− let prefix = match d.severity {
78+ let prefix = severity_label(d.severity);
76− htmlang::parser::Severity::Error => "error",
77− htmlang::parser::Severity::Warning => "warning",
78− htmlang::parser::Severity::Info => "info",
79− htmlang::parser::Severity::Help => "help",
80− };
81 if let Some(col) = d.column {
79 if let Some(col) = d.column {
82 eprintln!("{}: line {}:{}: {}", prefix, d.line, col, d.message);
80 eprintln!("{}: line {}:{}: {}", prefix, d.line, col, d.message);
83 } else {
81 } else {
@@ -313,12 +311,7 @@ fn copy_non_hl_recursive(base: &Path, dir: &Path, out_dir: &Path) {
313fn generate_error_overlay(diagnostics: &[htmlang::parser::Diagnostic], file: &str) -> String {
311fn generate_error_overlay(diagnostics: &[htmlang::parser::Diagnostic], file: &str) -> String {
314 let mut errors = String::new();
312 let mut errors = String::new();
315 for d in diagnostics {
313 for d in diagnostics {
316− let prefix = match d.severity {
314+ let prefix = severity_label(d.severity);
317− htmlang::parser::Severity::Error => "error",
318− htmlang::parser::Severity::Warning => "warning",
319− htmlang::parser::Severity::Info => "info",
320− htmlang::parser::Severity::Help => "help",
321− };
322 let escaped = d.message
315 let escaped = d.message
323 .replace('&', "&amp;")
316 .replace('&', "&amp;")
324 .replace('<', "&lt;")
317 .replace('<', "&lt;")
@@ -616,12 +609,7 @@ fn lint_file(path: &str) -> Vec<String> {
616
609
617 // Report parser diagnostics
610 // Report parser diagnostics
618 for d in &result.diagnostics {
611 for d in &result.diagnostics {
619− let prefix = match d.severity {
612+ let prefix = severity_label(d.severity);
620− htmlang::parser::Severity::Error => "error",
621− htmlang::parser::Severity::Warning => "warning",
622− htmlang::parser::Severity::Info => "info",
623− htmlang::parser::Severity::Help => "help",
624− };
625 warnings.push(format!("{}:{}:{}: {}", path, d.line, prefix, d.message));
613 warnings.push(format!("{}:{}:{}: {}", path, d.line, prefix, d.message));
626 }
614 }
627
615
@@ -1818,12 +1806,7 @@ fn main() {
1818 }
1806 }
1819 let result = htmlang::parser::parse(&buffer);
1807 let result = htmlang::parser::parse(&buffer);
1820 for d in &result.diagnostics {
1808 for d in &result.diagnostics {
1821− let prefix = match d.severity {
1809+ let prefix = severity_label(d.severity);
1822− htmlang::parser::Severity::Error => "error",
1823− htmlang::parser::Severity::Warning => "warning",
1824− htmlang::parser::Severity::Info => "info",
1825− htmlang::parser::Severity::Help => "help",
1826− };
1827 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
1810 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
1828 }
1811 }
1829 if !result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
1812 if !result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
@@ -2244,11 +2227,14 @@ fn main() {
2244 }
2227 }
2245
2228
2246 // Deploy via gh-pages push
2229 // Deploy via gh-pages push
2230+ let init_ok = matches!(
2247− let status = process::Command::new("git")
2231+ process::Command::new("git")
2248− .args(&["init"])
2232+ .args(&["init"])
2249− .current_dir(&deploy_dir)
2233+ .current_dir(&deploy_dir)
2250− .status();
2234+ .status(),
2251− if status.is_err() || !status.unwrap().success() {
2235+ Ok(s) if s.success()
2236+ );
2237+ if !init_ok {
2252 eprintln!("error: git init failed in deploy directory");
2238 eprintln!("error: git init failed in deploy directory");
2253 process::exit(1);
2239 process::exit(1);
2254 }
2240 }
@@ -2260,11 +2246,14 @@ fn main() {
2260 .args(&["add", "."])
2246 .args(&["add", "."])
2261 .current_dir(&deploy_dir)
2247 .current_dir(&deploy_dir)
2262 .status();
2248 .status();
2249+ let commit_ok = matches!(
2263− let status = process::Command::new("git")
2250+ process::Command::new("git")
2264− .args(&["commit", "-m", "deploy"])
2251+ .args(&["commit", "-m", "deploy"])
2265− .current_dir(&deploy_dir)
2252+ .current_dir(&deploy_dir)
2266− .status();
2253+ .status(),
2267− if status.is_err() || !status.unwrap().success() {
2254+ Ok(s) if s.success()
2255+ );
2256+ if !commit_ok {
2268 eprintln!("error: git commit failed");
2257 eprintln!("error: git commit failed");
2269 process::exit(1);
2258 process::exit(1);
2270 }
2259 }
@@ -2280,11 +2269,14 @@ fn main() {
2280 .current_dir(&deploy_dir)
2269 .current_dir(&deploy_dir)
2281 .status();
2270 .status();
2282 eprintln!("pushing to gh-pages branch at {}...", remote_url);
2271 eprintln!("pushing to gh-pages branch at {}...", remote_url);
2272+ let push_ok = matches!(
2283− let push_status = process::Command::new("git")
2273+ process::Command::new("git")
2284− .args(&["push", "-f", "origin", "gh-pages"])
2274+ .args(&["push", "-f", "origin", "gh-pages"])
2285− .current_dir(&deploy_dir)
2275+ .current_dir(&deploy_dir)
2286− .status();
2276+ .status(),
2287− if push_status.is_err() || !push_status.unwrap().success() {
2277+ Ok(s) if s.success()
2278+ );
2279+ if !push_ok {
2288 eprintln!("error: push failed — run 'git push -f origin gh-pages' manually from {}", deploy_dir.display());
2280 eprintln!("error: push failed — run 'git push -f origin gh-pages' manually from {}", deploy_dir.display());
2289 process::exit(1);
2281 process::exit(1);
2290 }
2282 }
@@ -2825,12 +2817,7 @@ compile();
2825 let base = Path::new(file).parent();
2817 let base = Path::new(file).parent();
2826 let result = htmlang::parser::parse_with_base(&input, base);
2818 let result = htmlang::parser::parse_with_base(&input, base);
2827 for d in &result.diagnostics {
2819 for d in &result.diagnostics {
2828− let prefix = match d.severity {
2820+ let prefix = severity_label(d.severity);
2829− htmlang::parser::Severity::Error => "error",
2830− htmlang::parser::Severity::Warning => "warning",
2831− htmlang::parser::Severity::Info => "info",
2832− htmlang::parser::Severity::Help => "help",
2833− };
2834 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
2821 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
2835 }
2822 }
2836 if result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
2823 if result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
tests/snapshots.rs+64
@@ -4078,3 +4078,67 @@ fn perf_large_document() {
4078 elapsed.as_millis()
4078 elapsed.as_millis()
4079 );
4079 );
4080}
4080}
4081+
4082+// ---------------------------------------------------------------------------
4083+// Filesystem-based error tests for @include / @import / @data
4084+// ---------------------------------------------------------------------------
4085+
4086+#[test]
4087+fn error_circular_include_filesystem() {
4088+ // a.hl includes b.hl, which includes a.hl — expect a cycle diagnostic.
4089+ let dir = std::env::temp_dir().join("htmlang_test_circular_include");
4090+ let _ = std::fs::remove_dir_all(&dir);
4091+ std::fs::create_dir_all(&dir).unwrap();
4092+ let a_path = dir.join("a.hl");
4093+ let b_path = dir.join("b.hl");
4094+ std::fs::write(&a_path, "@include b.hl\n").unwrap();
4095+ std::fs::write(&b_path, "@include a.hl\n").unwrap();
4096+
4097+ let input = std::fs::read_to_string(&a_path).unwrap();
4098+ let result = htmlang::parser::parse_with_base(&input, Some(&dir));
4099+ let has_cycle = result
4100+ .diagnostics
4101+ .iter()
4102+ .any(|d| d.severity == htmlang::parser::Severity::Error && d.message.contains("circular"));
4103+ let _ = std::fs::remove_dir_all(&dir);
4104+ assert!(has_cycle, "expected circular include error, got: {:?}", result.diagnostics);
4105+}
4106+
4107+#[test]
4108+fn import_with_alias_prefixes_definitions() {
4109+ // Verify that @import with alias registers imported fns under `alias.name`.
4110+ let dir = std::env::temp_dir().join("htmlang_test_import_alias");
4111+ let _ = std::fs::remove_dir_all(&dir);
4112+ std::fs::create_dir_all(&dir).unwrap();
4113+ let lib_path = dir.join("lib.hl");
4114+ std::fs::write(&lib_path, "@fn card\n @el\n @text card-body\n").unwrap();
4115+
4116+ let input = "@import lib.hl as ui\n@ui.card\n";
4117+ let result = htmlang::parser::parse_with_base(input, Some(&dir));
4118+ let html = htmlang::codegen::generate(&result.document);
4119+ let _ = std::fs::remove_dir_all(&dir);
4120+
4121+ assert!(
4122+ result.diagnostics.iter().all(|d| d.severity != htmlang::parser::Severity::Error),
4123+ "no errors expected, got: {:?}", result.diagnostics
4124+ );
4125+ assert!(html.contains("card-body"), "alias prefixed call should expand, got: {}", html);
4126+}
4127+
4128+#[test]
4129+fn error_invalid_json_in_data_directive() {
4130+ let dir = std::env::temp_dir().join("htmlang_test_bad_json");
4131+ let _ = std::fs::remove_dir_all(&dir);
4132+ std::fs::create_dir_all(&dir).unwrap();
4133+ let data_path = dir.join("bad.json");
4134+ std::fs::write(&data_path, "{ not: valid json }").unwrap();
4135+
4136+ let input = "@data bad.json\n";
4137+ let result = htmlang::parser::parse_with_base(input, Some(&dir));
4138+ let has_err = result
4139+ .diagnostics
4140+ .iter()
4141+ .any(|d| d.severity == htmlang::parser::Severity::Error && d.message.contains("invalid JSON"));
4142+ let _ = std::fs::remove_dir_all(&dir);
4143+ assert!(has_err, "expected invalid JSON error, got: {:?}", result.diagnostics);
4144+}