on master help sign in
Add 6 improvements: deploy error safety, severity dedup, parser diagnostics, style cache optimization, @import performance, and filesystem tests
commit78f1ddaf07c48b342ead65847c2757c95af9d0d6 authoriko <mail@iko.soy> dateThu 16 Apr 2026, 07:37 (6 months ago) parent0a07980
4 files changed+194−114
crates/htmlang-core/src/codegen.rs+37−20 crates/htmlang-core/src/parser.rs+54−42 src/main.rs+39−52 tests/snapshots.rs+64
crates/htmlang-core/src/codegen.rs+37−20
@@ -1,4 +1,5 @@
11use std::collections::HashMap;
2+use std::hash::{Hash, Hasher};
23
34use crate::ast::*;
45
@@ -55,7 +56,10 @@ struct StyleEntry {
5556
5657struct StyleCollector {
5758 entries: Vec<StyleEntry>,
58− index: HashMap<String, usize>,
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.
62+ index: HashMap<u64, Vec<usize>>,
5963}
6064
6165impl StyleCollector {
@@ -93,24 +97,37 @@ impl StyleCollector {
9397 {
9498 return None;
9599 }
96− let pseudo_key: String = pseudo
97− .iter()
98− .map(|(sel, css)| format!("{}={}", sel, css))
99− .collect::<Vec<_>>()
100− .join("|");
101− let resp_key: String = responsive
102− .iter()
103− .map(|(bp, css)| format!("{}={}", bp, css))
104− .collect::<Vec<_>>()
105− .join("|");
106− let cq_key: String = container
107− .iter()
108− .map(|(bp, css)| format!("{}={}", bp, css))
109− .collect::<Vec<_>>()
110− .join("|");
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) {
113− return Some(self.entries[idx].class_name.clone());
100+ use std::collections::hash_map::DefaultHasher;
101+ let mut h = DefaultHasher::new();
102+ base.hash(&mut h);
103+ pseudo.hash(&mut h);
104+ responsive.hash(&mut h);
105+ dark.hash(&mut h);
106+ print.hash(&mut h);
107+ motion_safe.hash(&mut h);
108+ motion_reduce.hash(&mut h);
109+ landscape.hash(&mut h);
110+ portrait.hash(&mut h);
111+ container.hash(&mut h);
112+ let sig = h.finish();
113+
114+ if let Some(indices) = self.index.get(&sig) {
115+ for &idx in indices {
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+ }
114131 }
115132 let idx = self.entries.len();
116133 let name = short_class_name(idx);
@@ -127,7 +144,7 @@ impl StyleCollector {
127144 portrait,
128145 container,
129146 });
130− self.index.insert(key, idx);
147+ self.index.entry(sig).or_default().push(idx);
131148 Some(name)
132149 }
133150
crates/htmlang-core/src/parser.rs+54−42
@@ -8,7 +8,7 @@ use crate::ast::*;
88// Public types
99// ---------------------------------------------------------------------------
1010
11−#[derive(Debug, Clone, PartialEq)]
11+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1212pub enum Severity {
1313 Error,
1414 Warning,
@@ -409,11 +409,16 @@ impl Parser {
409409 return Ok(Some(vec![Node::Raw(content)]));
410410 }
411411
412− // Normal content — clone to release borrow
413− let content = match &self.lines[self.pos].content {
414− LineContent::Normal(s) => s.clone(),
415− _ => unreachable!(),
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.
415+ let LineContent::Normal(content) = &self.lines[self.pos].content else {
416+ return Err(ParseError {
417+ line: line_num,
418+ message: "internal: unexpected line content variant".to_string(),
419+ });
416420 };
421+ let content = content.clone();
417422 self.pos += 1;
418423
419424 // --- Directives ---
@@ -967,11 +972,17 @@ impl Parser {
967972 let diag_count_before = ctx.diagnostics.len();
968973
969974 if let Some(ref prefix) = alias {
970− // Parse into a temporary context, then copy definitions with prefix
971− let saved_fns = ctx.functions.clone();
972− let saved_defines = ctx.defines.clone();
973− let saved_mixins = ctx.mixins.clone();
974− let saved_vars = ctx.variables.clone();
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> =
979+ ctx.functions.keys().cloned().collect();
980+ let define_keys_before: std::collections::HashSet<String> =
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> =
985+ ctx.variables.keys().cloned().collect();
975986
976987 let imported_lines = preprocess(&imported_text);
977988 let mut imported_parser = Parser {
@@ -980,42 +991,43 @@ impl Parser {
980991 };
981992 let _discarded_nodes = imported_parser.parse_children(0, ctx);
982993
983− // Find newly added definitions and re-register with prefix
984− let new_fns: Vec<(String, FnDef)> = ctx.functions.iter()
985− .filter(|(k, _)| !saved_fns.contains_key(*k))
986− .map(|(k, v)| (k.clone(), v.clone()))
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()))
994+ // Remove newly added entries and re-insert them under the prefix.
995+ let new_fn_keys: Vec<String> = ctx.functions.keys()
996+ .filter(|k| !fn_keys_before.contains(*k))
997+ .cloned()
991998 .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 {
1008− ctx.functions.insert(format!("{}.{}", prefix, name), def);
999+ for name in new_fn_keys {
1000+ if let Some(def) = ctx.functions.remove(&name) {
1001+ ctx.functions.insert(format!("{}.{}", prefix, name), def);
1002+ }
10091003 }
1010− for (name, attrs) in new_defines {
1011− ctx.defines.insert(format!("{}.{}", prefix, name), attrs);
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) {
1010+ ctx.defines.insert(format!("{}.{}", prefix, name), attrs);
1011+ }
10121012 }
1013− for (name, attrs) in new_mixins {
1014− ctx.mixins.insert(format!("{}.{}", prefix, name), attrs);
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) {
1019+ ctx.mixins.insert(format!("{}.{}", prefix, name), attrs);
1020+ }
10151021 }
1016− for (name, val) in new_vars {
1017− if !name.starts_with("__") {
1018− ctx.variables.insert(format!("{}.{}", prefix, name), val);
1022+ let new_var_keys: Vec<String> = ctx.variables.keys()
1023+ .filter(|k| !var_keys_before.contains(*k))
1024+ .cloned()
1025+ .collect();
1026+ for name in new_var_keys {
1027+ if let Some(val) = ctx.variables.remove(&name) {
1028+ if !name.starts_with("__") {
1029+ ctx.variables.insert(format!("{}.{}", prefix, name), val);
1030+ }
10191031 }
10201032 }
10211033 } else {
src/main.rs+39−52
@@ -15,6 +15,15 @@ struct DiagnosticJson {
1515 message: String,
1616}
1717
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+
1827#[derive(Default)]
1928struct CompileConfig<'a> {
2029 dev: bool,
@@ -56,28 +65,17 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
5665 if let Some(collector) = cfg.json_collector {
5766 let mut collected = collector.lock().unwrap();
5867 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− };
6568 collected.push(DiagnosticJson {
6669 file: input_path.to_string(),
6770 line: d.line,
68− severity: severity.to_string(),
71+ severity: severity_label(d.severity).to_string(),
6972 message: d.message.clone(),
7073 });
7174 }
7275 }
7376 } else {
7477 for d in &result.diagnostics {
75− let prefix = match 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− };
78+ let prefix = severity_label(d.severity);
8179 if let Some(col) = d.column {
8280 eprintln!("{}: line {}:{}: {}", prefix, d.line, col, d.message);
8381 } else {
@@ -313,12 +311,7 @@ fn copy_non_hl_recursive(base: &Path, dir: &Path, out_dir: &Path) {
313311fn generate_error_overlay(diagnostics: &[htmlang::parser::Diagnostic], file: &str) -> String {
314312 let mut errors = String::new();
315313 for d in diagnostics {
316− let prefix = match 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− };
314+ let prefix = severity_label(d.severity);
322315 let escaped = d.message
323316 .replace('&', "&amp;")
324317 .replace('<', "&lt;")
@@ -616,12 +609,7 @@ fn lint_file(path: &str) -> Vec<String> {
616609
617610 // Report parser diagnostics
618611 for d in &result.diagnostics {
619− let prefix = match 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− };
612+ let prefix = severity_label(d.severity);
625613 warnings.push(format!("{}:{}:{}: {}", path, d.line, prefix, d.message));
626614 }
627615
@@ -1818,12 +1806,7 @@ fn main() {
18181806 }
18191807 let result = htmlang::parser::parse(&buffer);
18201808 for d in &result.diagnostics {
1821− let prefix = match 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− };
1809+ let prefix = severity_label(d.severity);
18271810 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
18281811 }
18291812 if !result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
@@ -2244,11 +2227,14 @@ fn main() {
22442227 }
22452228
22462229 // Deploy via gh-pages push
2247− let status = process::Command::new("git")
2248− .args(&["init"])
2249− .current_dir(&deploy_dir)
2250− .status();
2251− if status.is_err() || !status.unwrap().success() {
2230+ let init_ok = matches!(
2231+ process::Command::new("git")
2232+ .args(&["init"])
2233+ .current_dir(&deploy_dir)
2234+ .status(),
2235+ Ok(s) if s.success()
2236+ );
2237+ if !init_ok {
22522238 eprintln!("error: git init failed in deploy directory");
22532239 process::exit(1);
22542240 }
@@ -2260,11 +2246,14 @@ fn main() {
22602246 .args(&["add", "."])
22612247 .current_dir(&deploy_dir)
22622248 .status();
2263− let status = process::Command::new("git")
2264− .args(&["commit", "-m", "deploy"])
2265− .current_dir(&deploy_dir)
2266− .status();
2267− if status.is_err() || !status.unwrap().success() {
2249+ let commit_ok = matches!(
2250+ process::Command::new("git")
2251+ .args(&["commit", "-m", "deploy"])
2252+ .current_dir(&deploy_dir)
2253+ .status(),
2254+ Ok(s) if s.success()
2255+ );
2256+ if !commit_ok {
22682257 eprintln!("error: git commit failed");
22692258 process::exit(1);
22702259 }
@@ -2280,11 +2269,14 @@ fn main() {
22802269 .current_dir(&deploy_dir)
22812270 .status();
22822271 eprintln!("pushing to gh-pages branch at {}...", remote_url);
2283− let push_status = process::Command::new("git")
2284− .args(&["push", "-f", "origin", "gh-pages"])
2285− .current_dir(&deploy_dir)
2286− .status();
2287− if push_status.is_err() || !push_status.unwrap().success() {
2272+ let push_ok = matches!(
2273+ process::Command::new("git")
2274+ .args(&["push", "-f", "origin", "gh-pages"])
2275+ .current_dir(&deploy_dir)
2276+ .status(),
2277+ Ok(s) if s.success()
2278+ );
2279+ if !push_ok {
22882280 eprintln!("error: push failed — run 'git push -f origin gh-pages' manually from {}", deploy_dir.display());
22892281 process::exit(1);
22902282 }
@@ -2825,12 +2817,7 @@ compile();
28252817 let base = Path::new(file).parent();
28262818 let result = htmlang::parser::parse_with_base(&input, base);
28272819 for d in &result.diagnostics {
2828− let prefix = match 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− };
2820+ let prefix = severity_label(d.severity);
28342821 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
28352822 }
28362823 if result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
tests/snapshots.rs+64
@@ -4078,3 +4078,67 @@ fn perf_large_document() {
40784078 elapsed.as_millis()
40794079 );
40804080}
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+}