use hemplate_core::ast::build_ast; use hemplate_core::surface::{ extract_surface, AttributeOrigin, ControlKind, ScopeId, ScopeKind, SurfaceAttribute, SurfaceDocument, SurfaceNodeKind, SurfaceScope, }; use hemx_core::{EFFECT_BATCH_ABI_VERSION, RUNTIME_ABI_VERSION, SURFACE_SCHEMA_VERSION}; use quote::ToTokens; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DiagnosticSeverity { Error, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DiagnosticCode { UnkeyedGeneratedTarget, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct Diagnostic { pub code: DiagnosticCode, pub severity: DiagnosticSeverity, pub file: PathBuf, pub directive: String, pub target: String, pub message: String, pub expected: String, pub repair: String, } impl Diagnostic { pub fn to_io_error(&self) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, self.to_string()) } } impl std::fmt::Display for Diagnostic { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{}: {}; expected {}; repair: {}", self.file.display(), self.message, self.expected, self.repair ) } } #[derive(Clone, Debug)] pub struct AppBuilder { out_dir: Option, template_dir: PathBuf, global_exports: bool, surfaces: Vec<(PathBuf, SurfaceDocument)>, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct GeneratedTarget { pub kind: String, pub name: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TemplateContextFacts { pub context_type: String, pub self_fields: Vec, pub locals: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TemplateFieldFact { pub name: String, pub type_name: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TemplateLocalFact { pub name: String, pub type_name: String, pub fields: Vec, } #[derive(Debug, Clone)] struct RustStructFact { fields: Vec, derives_hemplate: bool, } pub fn diagnostics_for_heml_file(path: impl AsRef) -> io::Result> { let path = path.as_ref(); let source = std::fs::read_to_string(path)?; diagnostics_for_heml_source(path, source) } pub fn diagnostics_for_heml_source( path: impl AsRef, source: impl Into, ) -> io::Result> { let path = path.as_ref(); let surface = surface_for_heml_source(path, source.into())?; Ok(unkeyed_generated_target_diagnostics(path, &surface)) } pub fn generated_targets_for_heml_source( path: impl AsRef, source: impl Into, ) -> io::Result> { let path = path.as_ref(); let surface = surface_for_heml_source(path, source.into())?; Ok(generated_targets(&surface)) } pub fn template_context_facts_for_heml_file( path: impl AsRef, ) -> io::Result> { let path = path.as_ref(); let source = std::fs::read_to_string(path)?; template_context_facts_for_heml_source(path, source) } pub fn template_context_facts_for_heml_source( path: impl AsRef, source: impl Into, ) -> io::Result> { let path = path.as_ref(); let source = source.into(); let Some(context_type) = context_type_for_heml_path(path) else { return Ok(None); }; let Some(root) = nearest_dir_with(path, "Cargo.toml") else { return Ok(None); }; let structs = rust_struct_facts_in(&root)?; let Some(context) = structs.get(&context_type) else { return Ok(None); }; if !context.derives_hemplate { return Ok(None); } let surface = surface_for_heml_source(path, source)?; let locals = loop_locals_for_surface(&surface, &context.fields, &structs); Ok(Some(TemplateContextFacts { context_type, self_fields: context.fields.clone(), locals, })) } fn surface_for_heml_source(path: &Path, source: String) -> io::Result { let doc = build_ast(Arc::new(source)).map_err(|err| parse_error(path, err))?; let Some(doc) = doc else { return Ok(SurfaceDocument { nodes: Vec::new(), scopes: vec![SurfaceScope { parent: None, kind: ScopeKind::Root, }], forms: Vec::new(), }); }; Ok(extract_surface(&doc)) } impl AppBuilder { pub fn out_dir(mut self, out_dir: impl Into) -> Self { self.out_dir = Some(out_dir.into()); self } pub fn template_dir(mut self, template_dir: impl Into) -> Self { self.template_dir = template_dir.into(); self } pub fn global_exports(mut self, enabled: bool) -> Self { self.global_exports = enabled; self } pub fn surface(mut self, path: impl Into, surface: SurfaceDocument) -> Self { self.surfaces.push((path.into(), surface)); self } pub fn run(self) -> io::Result<()> { println!("cargo:rerun-if-changed={}", self.template_dir.display()); let out_dir = self .out_dir .or_else(|| std::env::var_os("OUT_DIR").map(PathBuf::from)); let Some(out_dir) = out_dir else { return Ok(()); }; std::fs::create_dir_all(&out_dir)?; let mut resources = Resources::default(); let input_paths = collect_input_files(&self.template_dir)?; if self.surfaces.is_empty() { for path in input_paths.iter().filter(|path| { path.extension().and_then(|extension| extension.to_str()) == Some("heml") }) { let source = std::fs::read_to_string(path)?; let surface = surface_for_heml_source(path, source)?; resources.add_surface(&self.template_dir, path, &surface)?; } } else { // req: surface/008 for (path, surface) in &self.surfaces { resources.add_surface(&self.template_dir, path, surface)?; } } for path in input_paths.iter().filter(|path| { matches!( path.extension().and_then(|extension| extension.to_str()), Some("css" | "scss") ) }) { let source = std::fs::read_to_string(path)?; resources.add_stylesheet(&self.template_dir, path, &source)?; } write_if_changed( &out_dir.join("hemx.generated.rs"), resources.generated_rs(self.global_exports).as_bytes(), )?; write_if_changed(&out_dir.join("hemx.syms"), resources.syms().as_bytes())?; write_if_changed( &out_dir.join("hemx.client.js"), resources.client_bootstrap()?.as_bytes(), )?; Ok(()) } } fn write_if_changed(path: &Path, contents: &[u8]) -> io::Result<()> { match std::fs::read(path) { Ok(existing) if existing == contents => return Ok(()), Ok(_) => {} Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(error) => return Err(error), } std::fs::write(path, contents) } pub fn app() -> AppBuilder { AppBuilder { out_dir: None, template_dir: PathBuf::from("templates"), global_exports: false, surfaces: Vec::new(), } } #[derive(Default)] struct Resources { slots: BTreeMap, handles: BTreeMap, handle_forms: BTreeMap, handle_params: BTreeMap>, forms: BTreeMap, atoms: BTreeMap, classes: BTreeMap, events: BTreeMap, client_handlers: BTreeSet, client_modules: BTreeSet, } #[derive(Clone, Debug)] struct Resource { symbol: String, ident: String, component: String, keyed: bool, id: u32, } #[derive(Clone, Debug)] struct FormResource { resource: Resource, controls: Vec, } #[derive(Clone, Debug)] struct GeneratedControl { name: String, kind: ControlKind, required: bool, } #[derive(Clone, Debug)] struct ClassToken { symbol: String, ident: String, component: String, token: String, } #[derive(Clone, Debug)] struct EventToken { symbol: String, ident: String, component: String, name: String, } impl Resources { fn add_surface( &mut self, root: &Path, path: &Path, surface: &SurfaceDocument, ) -> io::Result<()> { let component = component_ident(root, path)?; for node in &surface.nodes { let SurfaceNodeKind::Element { tag } = &node.kind else { continue; }; reject_selector_target_attrs(path, &node.attrs)?; reject_unknown_hemx_attrs(path, &node.attrs)?; reject_invalid_hemx_attr_values(path, &node.attrs)?; reject_invalid_hemx_attr_placement(path, tag, &node.attrs)?; if let Some(class_attr) = static_attr(&node.attrs, "class") { for token in class_tokens(&class_attr) { let canonical = canonical_symbol(root, path, token); self.insert_class(canonical, token.to_owned(), component.clone())?; } } if let Some(on_attr) = static_attr(&node.attrs, "data-hemx-on") { for event in event_tokens(&on_attr) { let canonical = canonical_symbol(root, path, event); self.insert_event(canonical, event.to_owned(), component.clone()); } } if let Some(handler) = static_attr(&node.attrs, "data-hemx-client") { let Some(handler) = rust_ident(&handler) else { return Err(invalid_hemx_value( path, "data-hemx-client", &handler, "expected a Rust handler identifier", )); }; self.client_handlers.insert(handler); } if let Some(module) = static_attr(&node.attrs, "data-hemx-client-module") { self.client_modules.insert(module); } if let Some(name) = static_attr(&node.attrs, "data-hemx-slot") { reject_unkeyed_loop(surface, node.scope, path, "slot", &name)?; let keyed = is_inside_keyed_for(surface, node.scope) || (can_host_keyed_collection(tag) && has_descendant_keyed_for_scope(surface, node.scope)); let canonical = canonical_symbol(root, path, &name); self.insert_slot(canonical, name, component.clone(), keyed)?; } if let Some(name) = static_attr(&node.attrs, "data-hemx-atom") { reject_unkeyed_loop(surface, node.scope, path, "atom", &name)?; let canonical = canonical_symbol(root, path, &name); self.insert_atom(canonical, name, component.clone())?; } if let Some(name) = static_attr(&node.attrs, "data-hemx-handle") { reject_unkeyed_loop(surface, node.scope, path, "handle", &name)?; let canonical = canonical_symbol(root, path, &name); self.insert_handle(canonical, name.clone(), component.clone())?; self.insert_handle_params(&name, &node.attrs)?; if tag == "form" { let form_name = static_attr(&node.attrs, "data-hemx-form").unwrap_or_else(|| name.clone()); let controls = surface .forms .iter() .find(|form| form.node == node.id) .map(|form| { form.controls .iter() .map(|control| GeneratedControl { name: control.name.clone(), kind: control.kind.clone(), required: control.required, }) .collect() }) .unwrap_or_default(); self.insert_form( canonical_symbol(root, path, &form_name), form_name.clone(), component.clone(), controls, )?; if let (Some(handle_ident), Some(form_ident)) = (rust_ident(&name), rust_ident(&form_name)) { self.handle_forms.insert(handle_ident, form_ident); } } } } Ok(()) } fn insert_slot( &mut self, symbol: String, name: String, component: String, keyed: bool, ) -> io::Result<()> { let slot = insert_resource(&mut self.slots, "slot", symbol, name, component)?; slot.keyed |= keyed; Ok(()) } fn insert_handle(&mut self, symbol: String, name: String, component: String) -> io::Result<()> { insert_resource(&mut self.handles, "handle", symbol, name, component).map(|_| ()) } fn insert_atom(&mut self, symbol: String, name: String, component: String) -> io::Result<()> { insert_resource(&mut self.atoms, "atom", symbol, name, component).map(|_| ()) } fn insert_class(&mut self, symbol: String, token: String, component: String) -> io::Result<()> { let ident = class_ident(&token).ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, format!( "invalid CSS class `{token}`; expected an ASCII class token usable from Rust" ), ) })?; let class = ClassToken { symbol, ident, component, token, }; if let Some(existing) = self .classes .values() .find(|existing| existing.ident == class.ident && existing.token != class.token) { return Err(io::Error::new( io::ErrorKind::InvalidData, format!( "duplicate generated class identifier `{}` for CSS classes `{}` and `{}`", class.ident, existing.token, class.token ), )); } self.classes.entry(class.symbol.clone()).or_insert(class); Ok(()) } fn insert_handle_params(&mut self, handle: &str, attrs: &[SurfaceAttribute]) -> io::Result<()> { let Some(handle_ident) = rust_ident(handle) else { return Ok(()); }; for attr in attrs { if !is_handle_param_attr(attr) { continue; } let Some(param) = data_param_ident(&attr.name) else { return Err(io::Error::new( io::ErrorKind::InvalidData, format!("invalid handler param attribute `{}`; expected data-* name usable from Rust", attr.name), )); }; self.handle_params .entry(handle_ident.clone()) .or_default() .insert(param); } Ok(()) } fn insert_event(&mut self, symbol: String, name: String, component: String) { self.events.entry(symbol.clone()).or_insert(EventToken { symbol, ident: name.clone(), component, name, }); } fn insert_form( &mut self, symbol: String, name: String, component: String, controls: Vec, ) -> io::Result<()> { let controls = controls .into_iter() .filter(|control| control.name != "__h") .collect(); let resource = make_resource("form", symbol, name, component)?; match self.forms.get(&resource.ident) { Some(existing) if existing.resource.symbol != resource.symbol => Err(io::Error::new( io::ErrorKind::InvalidData, format!( "duplicate generated identifier `{}` for `{}` and `{}`", resource.ident, existing.resource.symbol, resource.symbol ), )), Some(_) => Ok(()), None => { self.forms .insert(resource.ident.clone(), FormResource { resource, controls }); Ok(()) } } } fn add_stylesheet(&mut self, root: &Path, path: &Path, source: &str) -> io::Result<()> { let component = component_ident(root, path)?; for token in stylesheet_class_tokens(source) { let canonical = canonical_symbol(root, path, token); self.insert_class(canonical, token.to_owned(), component.clone())?; } Ok(()) } fn generated_rs(&self, global_exports: bool) -> String { let mut out = String::new(); out.push_str("// @generated by hemx-build. Do not edit.\n"); out.push_str(&format!( "pub const BUILD_FINGERPRINT: ::hemx::advanced::BuildFingerprint = ::hemx::advanced::BuildFingerprint::from_parts(&[{}]);\n\n", self.fingerprint_parts() .into_iter() .map(|part| part.to_string()) .collect::>() .join(", ") )); // req: build/001 req: component/003 let component_names: BTreeSet = self.component_names().into_iter().collect(); let root_indent = usize::default(); if global_exports { self.push_component_refs(&mut out); self.push_resource_modules(&mut out, &component_names, None, root_indent); } self.push_lowering_api(&mut out, None, root_indent); for component in self.component_names() { out.push_str("\n#[allow(non_upper_case_globals)]\n"); out.push_str(&format!("pub mod {component} {{\n")); out.push_str(" #[derive(Clone, Copy)]\n"); out.push_str(" pub struct Component;\n\n"); self.push_resource_modules(&mut out, &component_names, Some(&component), 1); self.push_lowering_api(&mut out, Some(&component), 1); out.push_str("}\n"); } self.push_lowering_helpers(&mut out); out } fn push_component_refs(&self, out: &mut String) { out.push_str("#[allow(non_upper_case_globals)]\npub mod components {\n"); for component in self.component_names() { out.push_str(&format!( " pub const {component}: ::hemx::ComponentRef = ::hemx::ComponentRef::new({});\n", rust_str(&component) )); } out.push_str("}\n"); } fn push_resource_modules( &self, out: &mut String, components: &BTreeSet, component: Option<&str>, indent: usize, ) { let pad = " ".repeat(indent); let inner = " ".repeat(indent + 1); let mut handle_ids = Vec::new(); let mut root_exports = BTreeSet::new(); if let Some(component) = component { out.push_str(&format!( "{pad}pub const COMPONENT: ::hemx::ComponentRef = ::hemx::ComponentRef::new({});\n", rust_str(component) )); } out.push_str(&format!("{pad}#[doc(hidden)]\n{pad}pub mod advanced {{\n")); out.push_str(&format!( "{inner}#[allow(non_upper_case_globals)]\n{inner}pub mod slots {{\n" )); let slot_inner = " ".repeat(indent + 2); for res in self .slots .values() .filter(|res| component_matches(res, component)) { if res.keyed { out.push_str(&format!( "{slot_inner}pub const {}: ::hemx::advanced::KeyedSlot<::std::string::String, ::std::string::String> = ::hemx::advanced::KeyedSlot::new({});\n", res.ident, res.id )); } else { out.push_str(&format!( "{slot_inner}pub const {}: ::hemx::advanced::Slot<::std::string::String> = ::hemx::advanced::Slot::new({});\n", res.ident, res.id )); } } out.push_str(&format!("{inner}}}\n")); out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!( "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod targets {{\n" )); let child_prefix = if component.is_some() { "super::super::" } else { "super::" }; out.push_str(&format!("{inner}#[derive(Clone, Copy)]\n")); out.push_str(&format!( "{inner}pub struct SlotTarget {{ slot: ::hemx::advanced::Slot, _marker: ::std::marker::PhantomData }}\n" )); out.push_str(&format!("{inner}#[allow(dead_code)]\n")); out.push_str(&format!("{inner}impl SlotTarget {{\n")); out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::Slot) -> Self {{ Self {{ slot, _marker: ::std::marker::PhantomData }} }}\n")); out.push_str(&format!("{inner} pub fn text(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.text(value) }}\n")); out.push_str(&format!("{inner} pub fn set(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.text(value) }}\n")); out.push_str(&format!("{inner}}}\n")); out.push_str(&format!("{inner}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!("{inner}impl SlotTarget {{\n")); out.push_str(&format!("{inner} pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n")); out.push_str(&format!("{inner} pub fn replace(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n")); out.push_str(&format!("{inner}}}\n")); for res in self .slots .values() .filter(|res| component_matches(res, component)) .filter(|res| components.contains(&res.ident) && Some(res.ident.as_str()) != component) { let child = &res.ident; out.push_str(&format!("{inner}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!( "{inner}impl SlotTarget {{\n" )); out.push_str(&format!("{inner} pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ {child_prefix}{child}::put(self.slot, view) }}\n")); out.push_str(&format!("{inner} pub fn replace(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ {child_prefix}{child}::put(self.slot, view) }}\n")); out.push_str(&format!("{inner}}}\n")); } out.push_str(&format!( "{inner}impl ::hemx::GeneratedTarget for SlotTarget {{\n" )); out.push_str(&format!("{inner} fn __hemx_resource_id(self) -> ::hemx::advanced::ResourceId {{ self.slot.id() }}\n")); out.push_str(&format!("{inner}}}\n")); out.push_str(&format!("{inner}#[derive(Clone, Copy)]\n")); out.push_str(&format!("{inner}pub struct KeyedSlotTarget {{ slot: ::hemx::advanced::KeyedSlot, _marker: ::std::marker::PhantomData }}\n")); out.push_str(&format!("{inner}#[allow(dead_code)]\n")); out.push_str(&format!("{inner}impl KeyedSlotTarget\n")); out.push_str(&format!("{inner}where\n")); out.push_str(&format!("{inner} K: ::std::string::ToString,\n")); out.push_str(&format!("{inner}{{\n")); out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::KeyedSlot) -> Self {{ Self {{ slot, _marker: ::std::marker::PhantomData }} }}\n")); out.push_str(&format!("{inner}}}\n")); out.push_str(&format!( "{inner}impl KeyedSlotTarget {{\n" )); out.push_str(&format!("{inner} pub fn move_before(self, key: K, before: K) -> ::hemx::advanced::Effect {{ self.slot.move_before(key, before) }}\n")); out.push_str(&format!("{inner} pub fn move_to_end(self, key: K) -> ::hemx::advanced::Effect {{ self.slot.move_to_end(key) }}\n")); out.push_str(&format!("{inner} pub fn remove_key(self, key: K) -> ::hemx::advanced::Effect {{ self.slot.remove(key) }}\n")); out.push_str(&format!("{inner}}}\n")); out.push_str(&format!("{inner}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!( "{inner}impl KeyedSlotTarget<::std::string::String, T, ()> {{\n" )); out.push_str(&format!("{inner} pub fn append(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.append_html(view.hemx_key(), super::render(&view)) }}\n")); out.push_str(&format!("{inner} pub fn prepend(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(view.hemx_key(), super::render(&view)) }}\n")); out.push_str(&format!("{inner} pub fn replace(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.replace_html(view.hemx_key(), super::render(&view)) }}\n")); out.push_str(&format!("{inner} pub fn append_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.append_html(key.to_string(), super::render(view)) }}\n")); out.push_str(&format!("{inner} pub fn prepend_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(key.to_string(), super::render(view)) }}\n")); out.push_str(&format!("{inner} pub fn replace_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.replace_html(key.to_string(), super::render(view)) }}\n")); out.push_str(&format!("{inner} pub fn remove(self, key: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.remove(key.to_string()) }}\n")); out.push_str(&format!("{inner}}}\n")); for res in self .slots .values() .filter(|res| component_matches(res, component)) .filter(|res| res.keyed) .filter(|res| components.contains(&res.ident) && Some(res.ident.as_str()) != component) { let child = &res.ident; out.push_str(&format!("{inner}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!( "{inner}impl KeyedSlotTarget<::std::string::String, T, {child_prefix}{child}::Component> {{\n" )); out.push_str(&format!("{inner} pub fn append(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.append_html(view.hemx_key(), {child_prefix}{child}::render(&view)) }}\n")); out.push_str(&format!("{inner} pub fn prepend(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(view.hemx_key(), {child_prefix}{child}::render(&view)) }}\n")); out.push_str(&format!("{inner} pub fn replace(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.replace_html(view.hemx_key(), {child_prefix}{child}::render(&view)) }}\n")); out.push_str(&format!("{inner} pub fn append_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.append_html(key.to_string(), {child_prefix}{child}::render(view)) }}\n")); out.push_str(&format!("{inner} pub fn prepend_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(key.to_string(), {child_prefix}{child}::render(view)) }}\n")); out.push_str(&format!("{inner} pub fn replace_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.replace_html(key.to_string(), {child_prefix}{child}::render(view)) }}\n")); out.push_str(&format!("{inner} pub fn remove(self, key: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.remove(key.to_string()) }}\n")); out.push_str(&format!("{inner}}}\n")); } out.push_str(&format!( "{inner}impl ::hemx::GeneratedTarget for KeyedSlotTarget {{\n" )); out.push_str(&format!("{inner} fn __hemx_resource_id(self) -> ::hemx::advanced::ResourceId {{ self.slot.id() }}\n")); out.push_str(&format!("{inner}}}\n")); for res in self .slots .values() .filter(|res| component_matches(res, component)) { let marker = if components.contains(&res.ident) && Some(res.ident.as_str()) != component { format!("{child_prefix}{}::Component", res.ident) } else { "()".to_string() }; if res.keyed { out.push_str(&format!( "{inner}pub const {}: KeyedSlotTarget<::std::string::String, ::std::string::String, {marker}> = KeyedSlotTarget::new(super::advanced::slots::{});\n", res.ident, res.ident )); } else { out.push_str(&format!( "{inner}pub const {}: SlotTarget<::std::string::String, {marker}> = SlotTarget::new(super::advanced::slots::{});\n", res.ident, res.ident )); } } out.push_str(&format!("{pad}}}\n")); for res in self .slots .values() .filter(|res| component_matches(res, component)) { root_exports.insert(res.ident.clone()); out.push_str(&format!("{pad}pub use self::targets::{};\n", res.ident)); } out.push('\n'); out.push_str(&format!( "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod handles {{\n" )); for res in self .handles .values() .filter(|res| component_matches(res, component)) { handle_ids.push(res.id); let input = if self.handle_forms.contains_key(&res.ident) { "::hemx::Form<::std::string::String>" } else { "()" }; out.push_str(&format!( "{inner}pub const {}: ::hemx::Handle<{input}> = ::hemx::Handle::new({});\n", res.ident, res.id )); } out.push_str(&format!( "{inner}pub const ALL_IDS: &[u32] = &[{}];\n", handle_ids .into_iter() .map(|id| id.to_string()) .collect::>() .join(", ") )); out.push_str(&format!("{pad}}}\n")); for res in self .handles .values() .filter(|res| component_matches(res, component)) { push_root_export( out, &pad, "handles", &mut root_exports, &res.ident, "handle", ); } out.push('\n'); out.push_str(&format!( "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod params {{\n" )); let mut emitted_params = BTreeSet::new(); for res in self .handles .values() .filter(|res| component_matches(res, component)) { if let Some(params) = self.handle_params.get(&res.ident) { for param in params { if emitted_params.insert(param.as_str()) { out.push_str(&format!( "{inner}pub const {param}: ::hemx::ParamName = ::hemx::ParamName::new({});\n", rust_str(param) )); } } } } out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!( "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod atoms {{\n" )); for res in self .atoms .values() .filter(|res| component_matches(res, component)) { out.push_str(&format!( "{inner}pub const {}: ::hemx::Atom<::std::string::String> = ::hemx::Atom::new({});\n", res.ident, res.id )); } out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!( "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod classes {{\n" )); let mut emitted_classes = BTreeSet::new(); for class in self .classes .values() .filter(|class| class_matches(class, component)) { if !emitted_classes.insert(class.ident.as_str()) { continue; } out.push_str(&format!( "{inner}pub const {}: ::hemx::CssClass = ::hemx::CssClass::new({});\n", class.ident, rust_str(&class.token) )); } out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!( "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod events {{\n" )); let mut emitted_events = BTreeSet::new(); for event in self .events .values() .filter(|event| event_matches(event, component)) { if !emitted_events.insert(event.ident.as_str()) { continue; } out.push_str(&format!( "{inner}pub const {}: ::hemx::EventName = ::hemx::EventName::new({});\n", event.ident, rust_str(&event.name) )); } out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!( "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod forms {{\n" )); for form in self .forms .values() .filter(|form| component_matches(&form.resource, component)) { let res = &form.resource; out.push_str(&format!( "{inner}pub const {}: ::hemx::Form<::std::string::String> = ::hemx::Form::new({});\n", res.ident, res.id )); out.push_str(&format!( "{inner}pub const {}_CONTRACT: ::hemx::FormContract = ::hemx::FormContract {{ fields: {}_FIELDS }};\n", res.ident.to_ascii_uppercase(), res.ident.to_ascii_uppercase() )); out.push_str(&format!( "{inner}pub const {}_FIELDS: &[::hemx::FormField] = &[\n", res.ident.to_ascii_uppercase() )); for control in &form.controls { out.push_str(&format!( "{inner} ::hemx::FormField {{ name: {}, kind: {}, required: {} }},\n", rust_str(&control.name), form_control_kind_expr(&control.kind), control.required )); } out.push_str(&format!("{inner}];\n")); } out.push_str(&format!("{pad}}}\n")); for form in self .forms .values() .filter(|form| component_matches(&form.resource, component)) { push_root_export( out, &pad, "forms", &mut root_exports, &form.resource.ident, "form", ); } } fn push_lowering_api(&self, out: &mut String, component: Option<&str>, indent: usize) { let pad = " ".repeat(indent); let table_name = match component { Some(component) => format!("__HEMX_LOWERING_TABLE_{}", component.to_ascii_uppercase()), None => "__HEMX_LOWERING_TABLE".to_string(), }; out.push_str(&format!("\n{pad}pub fn lower(html: impl ::std::convert::AsRef) -> ::std::string::String {{\n")); out.push_str(&format!("{pad} ")); if component.is_some() { out.push_str("super::"); } out.push_str(&format!("__hemx_lower_html(html.as_ref(), {table_name})\n")); out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!("{pad}pub fn lower_html(html: impl ::std::convert::AsRef) -> ::std::string::String {{ lower(html) }}\n\n")); out.push_str(&format!( "{pad}pub fn static_fragment(html: &'static str) -> ::hemx::Html {{\n" )); out.push_str(&format!( "{pad} ::hemx::__private::html_trusted(lower(html))\n" )); out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!( "{pad}pub fn render(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{\n" )); out.push_str(&format!( "{pad} let mut html = ::std::string::String::new();\n" )); out.push_str(&format!("{pad} ::hemplate::Hemplate::render_into(view, &mut html).expect(\"hemx hemplate view renders\");\n")); out.push_str(&format!( "{pad} ::hemx::__private::html_trusted(lower(html))\n" )); out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!( "{pad}#[cfg(not(target_arch = \"wasm32\"))]\n{pad}pub fn page(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{ render(view) }}\n\n" )); out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!("{pad}pub fn render_html(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{ render(view) }}\n\n")); out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!("{pad}pub fn put(slot: ::hemx::advanced::Slot, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{\n")); out.push_str(&format!("{pad} slot.html(render(view))\n")); out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!("{pad}pub fn append(slot: ::hemx::advanced::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\n")); out.push_str(&format!("{pad}where\n")); out.push_str(&format!("{pad} K: ::std::string::ToString,\n")); out.push_str(&format!("{pad}{{\n")); out.push_str(&format!("{pad} slot.append_html(key, render(view))\n")); out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!("{pad}pub fn prepend(slot: ::hemx::advanced::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\n")); out.push_str(&format!("{pad}where\n")); out.push_str(&format!("{pad} K: ::std::string::ToString,\n")); out.push_str(&format!("{pad}{{\n")); out.push_str(&format!("{pad} slot.prepend_html(key, render(view))\n")); out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!("{pad}pub fn replace(slot: ::hemx::advanced::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\n")); out.push_str(&format!("{pad}where\n")); out.push_str(&format!("{pad} K: ::std::string::ToString,\n")); out.push_str(&format!("{pad}{{\n")); out.push_str(&format!("{pad} slot.replace_html(key, render(view))\n")); out.push_str(&format!("{pad}}}\n\n")); self.push_lowering_table(out, component, indent, &table_name); } fn push_lowering_table( &self, out: &mut String, component: Option<&str>, indent: usize, name: &str, ) { let pad = " ".repeat(indent); out.push_str(&format!("{pad}const {name}: &[(&str, &str, u32)] = &[\n")); for res in self .slots .values() .filter(|res| component_matches(res, component)) { out.push_str(&format!( "{pad} (\"data-hemx-slot\", {}, {}),\n", rust_str(&res.ident), res.id )); } for res in self .handles .values() .filter(|res| component_matches(res, component)) { out.push_str(&format!( "{pad} (\"data-hemx-handle\", {}, {}),\n", rust_str(&res.ident), res.id )); } for form in self .forms .values() .filter(|form| component_matches(&form.resource, component)) { let res = &form.resource; out.push_str(&format!( "{pad} (\"data-hemx-form\", {}, {}),\n", rust_str(&res.ident), res.id )); } for res in self .atoms .values() .filter(|res| component_matches(res, component)) { out.push_str(&format!( "{pad} (\"data-hemx-atom\", {}, {}),\n", rust_str(&res.ident), res.id )); } out.push_str(&format!("{pad}];\n")); } fn push_lowering_helpers(&self, out: &mut String) { out.push_str(r#" fn __hemx_lower_html(html: &str, table: &[(&str, &str, u32)]) -> ::std::string::String { let mut out = html.to_owned(); for (attr, name, id) in table { let runtime_attr = match *attr { "data-hemx-slot" => "data-sid", "data-hemx-handle" => "data-hid", "data-hemx-form" => "data-fid", "data-hemx-revealed" => "data-hemx-revealed", "data-hemx-atom" => "data-aid", _ => continue, }; out = __hemx_replace_attr(out, attr, name, runtime_attr, *id); } out = __hemx_lower_static_attr(out, "data-hemx-key", "data-key"); out = __hemx_lower_static_attr(out, "h-key", "data-key"); __hemx_inject_handle_inputs(out) } fn __hemx_replace_attr(mut html: ::std::string::String, attr: &str, name: &str, runtime_attr: &str, id: u32) -> ::std::string::String { let replacement = if runtime_attr == "value" { ::std::format!("{attr}=\"{name}\" {runtime_attr}=\"{id}\"") } else { ::std::format!("{runtime_attr}=\"{id}\"") }; let double = ::std::format!("{attr}=\"{name}\""); html = html.replace(&double, &replacement); let single_replacement = replacement.replace('"', "'"); let single = ::std::format!("{attr}='{name}'"); html.replace(&single, &single_replacement) } fn __hemx_lower_static_attr(mut html: ::std::string::String, attr: &str, runtime_attr: &str) -> ::std::string::String { html = html.replace(&::std::format!("{attr}=\""), &::std::format!("{runtime_attr}=\"")); html.replace(&::std::format!("{attr}='"), &::std::format!("{runtime_attr}='")) } fn __hemx_inject_handle_inputs(html: ::std::string::String) -> ::std::string::String { let mut out = ::std::string::String::with_capacity(html.len()); let mut rest = html.as_str(); while let Some(start) = rest.find("') else { out.push_str(rest); return out; }; let opening = &rest[..=open_end]; out.push_str(opening); rest = &rest[open_end + 1..]; let Some(handle_id) = __hemx_attr(opening, "data-hid") else { continue; }; let form_body_end = rest.find("").unwrap_or(rest.len()); let form_body = &rest[..form_body_end]; if !__hemx_has_handle_input(form_body) { out.push_str(&::std::format!("", handle_id)); } } out.push_str(rest); out } fn __hemx_has_handle_input(html: &str) -> bool { html.contains("name=\"__h\"") || html.contains("name='__h'") } fn __hemx_attr(tag: &str, attr: &str) -> Option<::std::string::String> { let attr_at = tag.find(attr)?; let after_attr = &tag[attr_at + attr.len()..]; let after_equals = after_attr.trim_start().strip_prefix('=')?.trim_start(); let quote = after_equals.chars().next()?; if quote != '\"' && quote != '\'' { return None; } let value = &after_equals[quote.len_utf8()..]; let end = value.find(quote)?; Some(value[..end].to_owned()) } "#); } fn component_names(&self) -> Vec { let mut components = Vec::new(); for component in self .slots .values() .map(|res| &res.component) .chain(self.handles.values().map(|res| &res.component)) .chain(self.atoms.values().map(|res| &res.component)) .chain(self.forms.values().map(|form| &form.resource.component)) .chain(self.classes.values().map(|class| &class.component)) .chain(self.events.values().map(|event| &event.component)) { if !components.contains(component) { components.push(component.clone()); } } components.sort(); components } fn client_bootstrap(&self) -> io::Result { if self.client_handlers.is_empty() { return Ok(String::new()); } let module = match self.client_modules.len() { 1 => self.client_modules.iter().next().unwrap(), 0 => { return Err(io::Error::new( io::ErrorKind::InvalidInput, "client-local handlers require one data-hemx-client-module on a hemx root", )); } _ => { return Err(io::Error::new( io::ErrorKind::InvalidInput, "client-local handlers must share one data-hemx-client-module per generated application", )); } }; let exports = self .client_handlers .iter() .map(|handler| format!("__hemx_client_{handler}")) .collect::>(); let mut out = format!( "import init, {{ {} }} from {};\nawait init();\n", exports.join(", "), serde_json::to_string(module).expect("serialize client module") ); for handler in &self.client_handlers { out.push_str(&format!( "window.hemx.registerClientHandler({}, __hemx_client_{});\n", serde_json::to_string(handler).expect("serialize client handler"), handler )); } let fingerprint = hemx_core::BuildFingerprint::from_parts(&self.fingerprint_parts()).0; out.push_str(&format!( "document.querySelectorAll('[data-hemx-root]').forEach((root) => {{ root.setAttribute('data-hemx-build', '{}'); root.setAttribute('data-hemx-client-ready', ''); }});\n", fingerprint )); Ok(out) } fn syms(&self) -> String { let mut out = String::from("hemx-syms-v1\n"); for res in self.slots.values() { out.push_str(&format!( "slot\t{}\t{}\t{}\n", res.symbol, res.ident, res.id )); } for res in self.handles.values() { out.push_str(&format!( "handle\t{}\t{}\t{}\n", res.symbol, res.ident, res.id )); } for form in self.forms.values() { let res = &form.resource; out.push_str(&format!( "form\t{}\t{}\t{}\n", res.symbol, res.ident, res.id )); } for (handle_ident, form_ident) in &self.handle_forms { out.push_str(&format!("handle_form\t{handle_ident}\t{form_ident}\n")); } for form in self.forms.values() { let mut fields = BTreeMap::<&str, (bool, bool)>::new(); for control in &form.controls { let field = fields.entry(&control.name).or_default(); field.0 |= control.required; field.1 |= form_control_is_multiple(&control.kind); } for (name, (required, multiple)) in fields { out.push_str(&format!( "form_field\t{}\t{name}\t{required}\t{multiple}\n", form.resource.ident )); } } for (handle_ident, params) in &self.handle_params { for param in params { out.push_str(&format!("handle_param\t{handle_ident}\t{param}\n")); } } for res in self.atoms.values() { out.push_str(&format!( "atom\t{}\t{}\t{}\n", res.symbol, res.ident, res.id )); } for class in self.classes.values() { out.push_str(&format!( "class\t{}\t{}\t{}\n", class.symbol, class.ident, class.token )); } for event in self.events.values() { out.push_str(&format!( "event\t{}\t{}\t{}\n", event.symbol, event.ident, event.name )); } out } fn fingerprint_parts(&self) -> Vec { let mut parts = vec![ SURFACE_SCHEMA_VERSION, EFFECT_BATCH_ABI_VERSION, RUNTIME_ABI_VERSION, ]; for res in self.slots.values() { parts.push(0); parts.push(res.id); } for res in self.handles.values() { parts.push(1); parts.push(res.id); } for res in self.atoms.values() { parts.push(3); parts.push(res.id); } for form in self.forms.values() { parts.push(2); parts.push(form.resource.id); parts.push(form.controls.len() as u32); for control in &form.controls { parts.push(stable_id("form-field", &control.name)); parts.push(control.required as u32); } } parts } } fn insert_resource<'a>( map: &'a mut BTreeMap, kind: &str, symbol: String, name: String, component: String, ) -> io::Result<&'a mut Resource> { let resource = make_resource(kind, symbol, name, component)?; match map.entry(resource.ident.clone()) { std::collections::btree_map::Entry::Occupied(entry) if entry.get().symbol != resource.symbol => { let existing = entry.get(); Err(io::Error::new( io::ErrorKind::InvalidData, format!( "duplicate generated identifier `{}` for `{}` and `{}`", resource.ident, existing.symbol, resource.symbol ), )) } std::collections::btree_map::Entry::Occupied(entry) => Ok(entry.into_mut()), std::collections::btree_map::Entry::Vacant(entry) => Ok(entry.insert(resource)), } } fn make_resource( kind: &str, symbol: String, name: String, component: String, ) -> io::Result { let ident = rust_ident(&name).ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, format!("invalid hemx {kind} name `{name}`; expected a Rust identifier"), ) })?; let id = stable_id(kind, &symbol); Ok(Resource { symbol, ident, component, keyed: false, id, }) } fn collect_input_files(root: &Path) -> io::Result> { let mut paths = Vec::new(); if !root.exists() { return Ok(paths); } collect_input_files_into(root, &mut paths)?; paths.sort(); Ok(paths) } fn collect_input_files_into(dir: &Path, paths: &mut Vec) -> io::Result<()> { for entry in std::fs::read_dir(dir)? { let path = entry?.path(); if path.is_dir() { collect_input_files_into(&path, paths)?; } else { paths.push(path); } } Ok(()) } fn static_attr(attrs: &[SurfaceAttribute], name: &str) -> Option { attrs .iter() .find(|attr| attr.origin == AttributeOrigin::Static && attr.name == name) .and_then(|attr| attr.value.clone()) } fn push_root_export( out: &mut String, pad: &str, module: &str, used: &mut BTreeSet, ident: &str, suffix: &str, ) { if used.insert(ident.to_owned()) { out.push_str(&format!("{pad}pub use self::{module}::{ident};\n")); return; } let alias = format!("{ident}_{suffix}"); if used.insert(alias.clone()) { out.push_str(&format!( "{pad}pub use self::{module}::{ident} as {alias};\n" )); } } fn component_matches(res: &Resource, component: Option<&str>) -> bool { match component { Some(component) => res.component == component, None => true, } } fn class_matches(class: &ClassToken, component: Option<&str>) -> bool { match component { Some(component) => class.component == component, None => true, } } fn event_matches(event: &EventToken, component: Option<&str>) -> bool { match component { Some(component) => event.component == component, None => true, } } fn class_tokens(value: &str) -> impl Iterator { value .split_ascii_whitespace() .filter(|token| !token.is_empty()) } fn event_tokens(value: &str) -> impl Iterator { value .split_ascii_whitespace() .filter(|token| !token.is_empty()) } fn stylesheet_class_tokens(source: &str) -> Vec<&str> { let bytes = source.as_bytes(); let mut tokens = Vec::new(); let mut i = 0; while i < bytes.len() { if bytes[i] != b'.' { i += 1; continue; } let prev = i.checked_sub(1).map(|idx| bytes[idx]); if prev.is_some_and(|ch| ch == b'-' || ch == b'_' || ch.is_ascii_alphanumeric()) && !preceded_by_class_in_compound(bytes, i) { i += 1; continue; } let start = i + 1; if start >= bytes.len() || !is_class_start(bytes[start]) { i += 1; continue; } let mut end = start; while end < bytes.len() && is_class_continue(bytes[end]) { end += 1; } if let Ok(token) = std::str::from_utf8(&bytes[start..end]) { tokens.push(token); } i = end; } tokens.sort_unstable(); tokens.dedup(); tokens } fn preceded_by_class_in_compound(bytes: &[u8], dot: usize) -> bool { let mut i = dot; while let Some(prev) = i.checked_sub(1) { let byte = bytes[prev]; if byte == b'.' { return true; } if matches!( byte, b' ' | b'\n' | b'\r' | b'\t' | b',' | b'{' | b'}' | b'>' | b'+' | b'~' | b'(' | b')' ) { return false; } i = prev; } false } fn is_class_start(byte: u8) -> bool { byte == b'_' || byte == b'-' || byte.is_ascii_alphabetic() } fn is_class_continue(byte: u8) -> bool { is_class_start(byte) || byte.is_ascii_digit() } fn class_ident(token: &str) -> Option { rust_ident(&token.replace('-', "_")) } fn is_handle_param_attr(attr: &SurfaceAttribute) -> bool { matches!( attr.origin, AttributeOrigin::Static | AttributeOrigin::Dynamic ) && attr.name.starts_with("data-") && !attr.name.starts_with("data-hemx-") } fn data_param_ident(name: &str) -> Option { let data_name = name.strip_prefix("data-")?; rust_ident(&data_name.replace('-', "_")) } fn can_host_keyed_collection(tag: &str) -> bool { matches!( tag, "ul" | "ol" | "tbody" | "thead" | "tfoot" | "table" | "select" | "datalist" ) } fn has_descendant_keyed_for_scope(surface: &SurfaceDocument, scope: ScopeId) -> bool { surface.scopes.iter().enumerate().any(|(index, current)| { matches!( current.kind, ScopeKind::For { key_expr: Some(_), .. } ) && is_descendant_scope(surface, ScopeId(index as u32), scope) }) } fn is_descendant_scope(surface: &SurfaceDocument, mut scope: ScopeId, ancestor: ScopeId) -> bool { loop { let Some(current) = surface.scopes.get(scope.0 as usize) else { return false; }; let Some(parent) = current.parent else { return false; }; if parent == ancestor { return true; } scope = parent; } } fn is_inside_keyed_for(surface: &SurfaceDocument, mut scope: ScopeId) -> bool { loop { let Some(current) = surface.scopes.get(scope.0 as usize) else { return false; }; if matches!( current.kind, ScopeKind::For { key_expr: Some(_), .. } ) { return true; } let Some(parent) = current.parent else { return false; }; scope = parent; } } fn reject_selector_target_attrs(path: &Path, attrs: &[SurfaceAttribute]) -> io::Result<()> { for attr in attrs { let name = attr.name.as_str(); if matches!(name, "data-hemx-target" | "data-hemx-select") { return Err(io::Error::new( io::ErrorKind::InvalidData, format!( "{}: `{name}` is selector-style targeting; hemx uses generated resources instead. Add data-hemx-slot to the local element and return an effect for that generated slot.", path.display() ), )); } } Ok(()) } fn reject_unknown_hemx_attrs(path: &Path, attrs: &[SurfaceAttribute]) -> io::Result<()> { for attr in attrs { let name = attr.name.as_str(); if name.starts_with("data-hemx-") && !known_hemx_attr(name) { return Err(io::Error::new( io::ErrorKind::InvalidData, format!( "{}: unknown hemx attribute `{name}`; check the spelling or use a non-hemx data-* attribute for app-specific metadata", path.display() ), )); } } Ok(()) } fn known_hemx_attr(name: &str) -> bool { matches!( name, "data-hemx-root" | "data-hemx-sse" | "data-hemx-st" | "data-hemx-handle" | "data-hemx-slot" | "data-hemx-form" | "data-hemx-atom" | "data-hemx-key" | "data-hemx-on" | "data-hemx-client" | "data-hemx-client-event" | "data-hemx-client-fallback" | "data-hemx-client-module" | "data-hemx-client-policy" | "data-hemx-client-state-version" | "data-hemx-pending-class" | "data-hemx-indicator" | "data-hemx-confirm" | "data-hemx-debounce" | "data-hemx-delay" | "data-hemx-throttle" | "data-hemx-every" | "data-hemx-interval" | "data-hemx-revealed" | "data-hemx-revealed-ahead" | "data-hemx-disable-while-pending" | "data-hemx-policy" | "data-hemx-nav" | "data-hemx-history" | "data-hemx-boost" | "data-hemx-error-for" | "data-hemx-error" | "data-hemx-island" ) } fn reject_invalid_hemx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> io::Result<()> { for attr in attrs .iter() .filter(|attr| attr.origin == AttributeOrigin::Static) { let value = attr.value.as_deref().unwrap_or(""); match attr.name.as_str() { "data-hemx-policy" if !valid_policy(value) => { return Err(invalid_hemx_value( path, &attr.name, value, "expected one of `latest`, `queue`, `drop`, or `parallel`", )); } "data-hemx-history" if !matches!(value.trim(), "" | "push" | "replace") => { return Err(invalid_hemx_value( path, &attr.name, value, "expected `push`, `replace`, or empty for the default push behavior", )); } "data-hemx-client" if value.trim().is_empty() => { return Err(invalid_hemx_value( path, &attr.name, value, "expected a non-empty client handler name", )); } "data-hemx-client-policy" if !matches!(value.trim(), "latest" | "drop") => { return Err(invalid_hemx_value( path, &attr.name, value, "expected `latest` or `drop`", )); } "data-hemx-client-module" if !valid_client_module(value) => { return Err(invalid_hemx_value( path, &attr.name, value, "expected a same-origin module specifier beginning with `/`, `./`, or `../`", )); } "data-hemx-client-event" if !valid_event_list(value) => { return Err(invalid_hemx_value( path, &attr.name, value, "expected a runtime-supported event", )); } "data-hemx-client-state-version" if value .parse::() .ok() .filter(|version| *version > 0) .is_none() => { return Err(invalid_hemx_value( path, &attr.name, value, "expected a positive client state ABI version", )); } "data-hemx-on" if !valid_event_list(value) => { return Err(invalid_hemx_value( path, &attr.name, value, "expected runtime-supported events: `click`, `submit`, `input`, `change`, `keydown`, `dragstart`, `dragover`, or `drop`", )); } "data-hemx-confirm" if value.trim().is_empty() => { return Err(invalid_hemx_value( path, &attr.name, value, "expected a non-empty confirmation message", )); } "data-hemx-sse" if value.trim().is_empty() => { return Err(invalid_hemx_value( path, &attr.name, value, "expected a non-empty same-origin SSE URL", )); } "data-hemx-revealed-ahead" if !value .trim() .parse::() .ok() .is_some_and(|value| value.is_finite() && value >= 0.0) => { return Err(invalid_hemx_value( path, &attr.name, value, "expected a non-negative number of viewports", )); } "data-hemx-debounce" | "data-hemx-delay" | "data-hemx-throttle" | "data-hemx-every" | "data-hemx-interval" if !valid_duration(value) => { return Err(invalid_hemx_value( path, &attr.name, value, "expected milliseconds like `250`/`250ms` or seconds like `1s`", )); } _ => {} } } Ok(()) } fn reject_invalid_hemx_attr_placement( path: &Path, tag: &str, attrs: &[SurfaceAttribute], ) -> io::Result<()> { if has_attr(attrs, "data-hemx-nav") && (tag != "a" || !has_attr(attrs, "href") || static_attr(attrs, "href").is_some_and(|href| href.trim().is_empty())) { return Err(invalid_hemx_placement( path, "data-hemx-nav", "expected a real `` link so navigation works without JavaScript", )); } if has_attr(attrs, "data-hemx-boost") && matches!(tag, "a" | "form") { return Err(invalid_hemx_placement( path, "data-hemx-boost", "expected a container around descendant links/forms; use `data-hemx-nav` on anchors or `data-hemx-handle` on forms", )); } if has_attr(attrs, "data-hemx-client-module") && !has_attr(attrs, "data-hemx-root") { return Err(invalid_hemx_placement( path, "data-hemx-client-module", "expected placement on the same element as `data-hemx-root`", )); } if has_attr(attrs, "data-hemx-sse") && !has_attr(attrs, "data-hemx-root") { return Err(invalid_hemx_placement( path, "data-hemx-sse", "expected placement on the same element as `data-hemx-root`", )); } Ok(()) } fn has_attr(attrs: &[SurfaceAttribute], name: &str) -> bool { attrs.iter().any(|attr| attr.name == name) } fn invalid_hemx_placement(path: &Path, attr: &str, expectation: &str) -> io::Error { io::Error::new( io::ErrorKind::InvalidData, format!( "{}: invalid {attr} placement; {expectation}", path.display() ), ) } fn valid_policy(value: &str) -> bool { matches!(value.trim(), "latest" | "queue" | "drop" | "parallel") } fn valid_client_module(value: &str) -> bool { let value = value.trim(); !value.is_empty() && !value.starts_with("//") && !value.contains(':') && (value.starts_with('/') || value.starts_with("./") || value.starts_with("../")) } fn valid_event_list(value: &str) -> bool { let mut events = event_tokens(value).peekable(); events.peek().is_some() && events.all(valid_runtime_event) } fn valid_runtime_event(value: &str) -> bool { matches!( value, "click" | "submit" | "input" | "change" | "keydown" | "dragstart" | "dragover" | "drop" ) } fn valid_duration(value: &str) -> bool { let value = value.trim(); let digits = value .strip_suffix("ms") .or_else(|| value.strip_suffix('s')) .unwrap_or(value); !digits.is_empty() && digits.as_bytes().iter().all(u8::is_ascii_digit) } fn invalid_hemx_value(path: &Path, attr: &str, value: &str, expectation: &str) -> io::Error { io::Error::new( io::ErrorKind::InvalidData, format!( "{}: invalid {attr} value `{value}`; {expectation}", path.display() ), ) } fn reject_unkeyed_loop( surface: &SurfaceDocument, scope: ScopeId, path: &Path, kind: &str, name: &str, ) -> io::Result<()> { if let Some(diagnostic) = unkeyed_generated_target_diagnostic_for_scope(surface, scope, path, kind, name) { Err(diagnostic.to_io_error()) } else { Ok(()) } } fn context_type_for_heml_path(path: &Path) -> Option { let stem = path.file_stem()?.to_str()?; let mut out = String::new(); for word in stem.split(['_', '-']).filter(|word| !word.is_empty()) { let mut chars = word.chars(); out.extend(chars.next()?.to_uppercase()); out.extend(chars); } (!out.is_empty()).then_some(out) } fn nearest_dir_with(path: &Path, file_name: &str) -> Option { for dir in path.ancestors().filter(|path| path.is_dir()) { if dir.join(file_name).is_file() { return Some(dir.to_path_buf()); } } None } fn rust_struct_facts_in(root: &Path) -> io::Result> { let mut facts = HashMap::new(); collect_rust_struct_facts(&root.join("src"), &mut facts)?; Ok(facts) } fn collect_rust_struct_facts( dir: &Path, facts: &mut HashMap, ) -> io::Result<()> { let Ok(entries) = std::fs::read_dir(dir) else { return Ok(()); }; for entry in entries { let path = entry?.path(); if path.is_dir() { collect_rust_struct_facts(&path, facts)?; } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { collect_rust_struct_facts_from_file(&path, facts)?; } } Ok(()) } fn collect_rust_struct_facts_from_file( path: &Path, facts: &mut HashMap, ) -> io::Result<()> { let source = std::fs::read_to_string(path)?; let file = match syn::parse_file(&source) { Ok(file) => file, Err(error) => return Err(io::Error::new(io::ErrorKind::InvalidData, error)), }; collect_rust_struct_facts_from_items(&file.items, facts); Ok(()) } fn collect_rust_struct_facts_from_items( items: &[syn::Item], facts: &mut HashMap, ) { for item in items { match item { syn::Item::Struct(item) => { if let syn::Fields::Named(fields) = &item.fields { facts.insert( item.ident.to_string(), RustStructFact { fields: fields .named .iter() .map(|field| TemplateFieldFact { name: field.ident.as_ref().unwrap().to_string(), type_name: compact_tokens(&field.ty), }) .collect(), derives_hemplate: derives_hemplate(&item.attrs), }, ); } } syn::Item::Mod(item) => { if let Some((_, items)) = &item.content { collect_rust_struct_facts_from_items(items, facts); } } _ => {} } } } fn derives_hemplate(attrs: &[syn::Attribute]) -> bool { attrs .iter() .filter(|attr| attr.path().is_ident("derive")) .filter_map(|attr| { attr.parse_args_with( syn::punctuated::Punctuated::::parse_terminated, ) .ok() }) .flatten() .any(|path| { path.segments .last() .is_some_and(|segment| segment.ident == "Hemplate") }) } fn compact_tokens(tokens: &impl ToTokens) -> String { tokens .to_token_stream() .to_string() .replace(" :: ", "::") .replace(" < ", "<") .replace(" >", ">") .replace(" ,", ",") .replace(", ", ",") .replace(" & ", "&") .replace("& ", "&") } fn loop_locals_for_surface( surface: &SurfaceDocument, self_fields: &[TemplateFieldFact], structs: &HashMap, ) -> Vec { let mut locals = Vec::new(); for node in &surface.nodes { let Some(value) = node .attrs .iter() .find(|attr| attr.name == "h-for") .and_then(|attr| attr.value.as_deref()) else { continue; }; let Some((local, field)) = h_for_local_and_self_field(value) else { continue; }; let Some(self_field) = self_fields.iter().find(|candidate| candidate.name == field) else { continue; }; let Some(type_name) = vec_element_type(&self_field.type_name) else { continue; }; let fields = structs .get(&type_name) .map(|fact| fact.fields.clone()) .unwrap_or_default(); if !locals .iter() .any(|existing: &TemplateLocalFact| existing.name == local) { locals.push(TemplateLocalFact { name: local, type_name, fields, }); } } locals } fn h_for_local_and_self_field(value: &str) -> Option<(String, String)> { let (local, expr) = value.split_once(" in ")?; let local = rust_ident(local.trim())?; let expr = expr.trim().strip_prefix('&').unwrap_or(expr.trim()).trim(); let field = expr .strip_prefix("self.")? .split(['.', '(', '[']) .next() .filter(|field| !field.is_empty())?; Some((local, field.to_owned())) } fn vec_element_type(type_name: &str) -> Option { let inner = type_name .strip_prefix("Vec<") .or_else(|| type_name.strip_prefix("std::vec::Vec<"))? .strip_suffix('>')?; Some(inner.trim().to_owned()) } fn unkeyed_generated_target_diagnostics(path: &Path, surface: &SurfaceDocument) -> Vec { let mut diagnostics = Vec::new(); for (node_scope, target) in generated_targets_with_scope(surface) { if let Some(diagnostic) = unkeyed_generated_target_diagnostic_for_scope( surface, node_scope, path, &target.kind, &target.name, ) { diagnostics.push(diagnostic); } } diagnostics } fn generated_targets(surface: &SurfaceDocument) -> Vec { let mut targets = Vec::new(); for (_, target) in generated_targets_with_scope(surface) { if !targets.iter().any(|existing: &GeneratedTarget| { existing.kind == target.kind && existing.name == target.name }) { targets.push(target); } } targets } fn generated_targets_with_scope(surface: &SurfaceDocument) -> Vec<(ScopeId, GeneratedTarget)> { let mut targets = Vec::new(); for node in &surface.nodes { for attr in &node.attrs { let Some(kind) = attr.name.strip_prefix("data-hemx-") else { continue; }; if !matches!(kind, "slot" | "form" | "handle") { continue; } let Some(name) = &attr.value else { continue; }; targets.push(( node.scope, GeneratedTarget { kind: kind.to_owned(), name: name.to_owned(), }, )); } } targets } fn unkeyed_generated_target_diagnostic_for_scope( surface: &SurfaceDocument, mut scope: ScopeId, path: &Path, kind: &str, name: &str, ) -> Option { loop { let current = surface.scopes.get(scope.0 as usize)?; if let ScopeKind::For { pattern, expr, key_expr: None, } = ¤t.kind { return Some(unkeyed_generated_target_diagnostic( path, kind, name, pattern, expr, )); } let parent = current.parent?; scope = parent; } } fn unkeyed_generated_target_diagnostic( path: &Path, kind: &str, name: &str, pattern: &str, expr: &str, ) -> Diagnostic { Diagnostic { code: DiagnosticCode::UnkeyedGeneratedTarget, severity: DiagnosticSeverity::Error, file: path.to_path_buf(), directive: format!("data-hemx-{kind}"), target: name.to_owned(), message: format!( "data-hemx-{kind}=\"{name}\" is inside h-for=\"{pattern} in {expr}\" without h-key" ), expected: format!( "a stable template h-key on h-for=\"{pattern} in {expr}\" so generated keyed helpers such as ui::{name}.replace(row) can target this partial" ), repair: format!( "add h-key=\"{pattern}.id\" to that h-for; dynamic +data-key on the child is rendered HTML, not the template fact hemx uses for generated targets" ), } } fn canonical_symbol(root: &Path, path: &Path, name: &str) -> String { let rel = path.strip_prefix(root).unwrap_or(path); format!("{}::{name}", rel.to_string_lossy().replace('\\', "/")) } fn component_ident(root: &Path, path: &Path) -> io::Result { let rel = path.strip_prefix(root).unwrap_or(path); let stem = rel .file_stem() .and_then(|stem| stem.to_str()) .ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, format!("invalid template path `{}`", path.display()), ) })?; rust_ident(stem).ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, format!("invalid template name `{stem}`; expected a Rust module identifier"), ) }) } fn rust_ident(name: &str) -> Option { let mut chars = name.chars(); let first = chars.next()?; if !(first == '_' || first.is_ascii_alphabetic()) { return None; } if chars .clone() .any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric())) { return None; } Some(name.to_string()) } fn stable_id(kind: &str, symbol: &str) -> u32 { let mut hash = 0x811c9dc5u32; for byte in kind.bytes().chain([b':']).chain(symbol.bytes()) { hash ^= byte as u32; hash = hash.wrapping_mul(0x01000193); } hash } fn form_control_is_multiple(kind: &ControlKind) -> bool { matches!(kind, ControlKind::Select { multiple: true }) } fn form_control_kind_expr(kind: &ControlKind) -> String { match kind { ControlKind::Text => "::hemx::FormControlKind::Text".to_string(), ControlKind::Number { min, max, step } => format!( "::hemx::FormControlKind::Number {{ min: {}, max: {}, step: {} }}", rust_str_opt(min.as_deref()), rust_str_opt(max.as_deref()), rust_str_opt(step.as_deref()) ), ControlKind::Checkbox => "::hemx::FormControlKind::Checkbox".to_string(), ControlKind::Radio => "::hemx::FormControlKind::Radio".to_string(), ControlKind::Select { multiple } => { format!("::hemx::FormControlKind::Select {{ multiple: {multiple} }}") } ControlKind::TextArea => "::hemx::FormControlKind::TextArea".to_string(), ControlKind::File => "::hemx::FormControlKind::File".to_string(), ControlKind::Hidden => "::hemx::FormControlKind::Hidden".to_string(), ControlKind::Submit => "::hemx::FormControlKind::Submit".to_string(), ControlKind::Other { tag, input_type } => format!( "::hemx::FormControlKind::Other {{ tag: {}, input_type: {} }}", rust_str(tag), rust_str_opt(input_type.as_deref()) ), } } fn rust_str(value: &str) -> String { format!("{value:?}") } fn rust_str_opt(value: Option<&str>) -> String { match value { Some(value) => format!("Some({})", rust_str(value)), None => "None".to_string(), } } fn parse_error(path: &Path, err: impl std::fmt::Display) -> io::Error { io::Error::new( io::ErrorKind::InvalidData, format!("failed to parse {}: {err}", path.display()), ) } #[cfg(test)] mod tests { use super::*; static OUT_DIR_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); #[test] fn public_heml_inspection_entry_points_preserve_paths_and_io_diagnostics() { let root = std::env::temp_dir().join(format!( "hemx-build-inspection-{}-{}", std::process::id(), std::thread::current().name().unwrap_or("test") )); std::fs::create_dir_all(&root).unwrap(); let template = root.join("card.heml"); let source = r#""#; std::fs::write(&template, source).unwrap(); let from_source = diagnostics_for_heml_source(&template, source.to_owned()).unwrap(); let from_file = diagnostics_for_heml_file(&template).unwrap(); assert_eq!(from_file, from_source); assert_eq!(from_source.len(), 1); assert_eq!(from_source[0].file, template); assert_eq!(from_source[0].directive, "data-hemx-handle"); let targets = generated_targets_for_heml_source( &template, r#"
"#, ) .unwrap(); assert_eq!(targets.len(), 1); assert_eq!(targets[0].name, "save"); assert_eq!( template_context_facts_for_heml_source( &template, "
{{ self.title }}
".to_owned(), ) .unwrap(), None ); let crate_root = root.join("crate"); let templates = crate_root.join("templates"); std::fs::create_dir_all(crate_root.join("src")).unwrap(); std::fs::create_dir_all(&templates).unwrap(); std::fs::write( crate_root.join("Cargo.toml"), "[package]\nname='facts'\nversion='0.0.0'\n", ) .unwrap(); std::fs::write( crate_root.join("src/lib.rs"), "#[derive(Hemplate)] struct Profile { title: String }\n#[derive(Clone, hemplate::Hemplate)] struct ProfileCard { card_title: String }\n#[derive(NotHemplate)] struct ProfileDetails { hidden: String }\n#[derive(HemplateExtra)] struct Extra { hidden: String }\nmod inline { #[derive(Hemplate)] pub struct InlineProfile { pub inline_title: String } }\nmod external;\nstruct Plain { title: String }", ) .unwrap(); std::fs::create_dir_all(crate_root.join("src/nested")).unwrap(); std::fs::write( crate_root.join("src/nested/profile.rs"), "#[derive(Hemplate)] struct NestedProfile { nested_title: String }", ) .unwrap(); std::fs::write(crate_root.join("src/external.rs"), "struct External;").unwrap(); std::fs::write(crate_root.join("src/ignored.txt"), [0xff]).unwrap(); std::fs::write( crate_root.join("outside.rs"), "#[derive(Hemplate)] struct OutsideProfile { outside: String }", ) .unwrap(); let profile = templates.join("profile.heml"); let facts = template_context_facts_for_heml_source( &profile, "
{{ self.title }}
".to_owned(), ) .unwrap() .expect("Hemlate-derived context facts"); assert_eq!(facts.context_type, "Profile"); assert_eq!(facts.self_fields[0].name, "title"); for (template, context_type, field) in [ ("inline_profile.heml", "InlineProfile", "inline_title"), ("nested_profile.heml", "NestedProfile", "nested_title"), ] { let nested_facts = template_context_facts_for_heml_source( templates.join(template), format!("
{{{{ self.{field} }}}}
"), ) .unwrap() .expect("recursive Rust struct fact"); assert_eq!(nested_facts.context_type, context_type); assert_eq!(nested_facts.self_fields[0].name, field); } assert_eq!( template_context_facts_for_heml_source( templates.join("outside_profile.heml"), "
{{ self.outside }}
".to_owned(), ) .unwrap(), None, "Rust facts outside src must not become template authority" ); let card_facts = template_context_facts_for_heml_source( templates.join("profile-card.heml"), "
{{ self.card_title }}
".to_owned(), ) .unwrap() .expect("qualified Hemlate derive"); assert_eq!(card_facts.context_type, "ProfileCard"); assert_eq!(card_facts.self_fields[0].name, "card_title"); assert_eq!( template_context_facts_for_heml_source( templates.join("profile_details.heml"), "
{{ self.hidden }}
".to_owned(), ) .unwrap(), None, "derive names containing Hemlate must not grant context authority" ); assert_eq!( context_type_for_heml_path(Path::new("--profile__card--.heml")), Some("ProfileCard".into()) ); assert_eq!(context_type_for_heml_path(Path::new("---.heml")), None); assert_eq!(context_type_for_heml_path(Path::new("")), None); #[cfg(unix)] { use std::os::unix::ffi::OsStrExt; assert_eq!( context_type_for_heml_path(Path::new(std::ffi::OsStr::from_bytes(b"\xff.heml"))), None ); } std::fs::write(crate_root.join("src/broken.rs"), [0xff]).unwrap(); assert_eq!( template_context_facts_for_heml_source( &profile, "
{{ self.title }}
".to_owned(), ) .unwrap_err() .kind(), io::ErrorKind::InvalidData ); std::fs::remove_file(crate_root.join("src/broken.rs")).unwrap(); assert_eq!( template_context_facts_for_heml_source( templates.join("plain.heml"), "
{{ self.title }}
".to_owned(), ) .unwrap(), None ); let missing = root.join("missing.heml"); for error in [ diagnostics_for_heml_file(&missing).unwrap_err(), template_context_facts_for_heml_file(&missing).unwrap_err(), ] { assert_eq!(error.kind(), io::ErrorKind::NotFound); } let _ = std::fs::remove_dir_all(root); // test req: diagnostics/004 req: diagnostics/006 req: surface/008 } #[test] fn rust_type_facts_are_compact_and_vector_specific() { let qualified: syn::Type = syn::parse_str("std::collections::HashMap>") .unwrap(); assert_eq!( compact_tokens(&qualified), "std::collections::HashMap>" ); let mutable: syn::Type = syn::parse_str("&mut crate::Thing").unwrap(); assert_eq!(compact_tokens(&mutable), "&mut crate::Thing"); let tuple: syn::Type = syn::parse_str("(&crate::Thing, String)").unwrap(); assert_eq!(compact_tokens(&tuple), "(&crate::Thing,String)"); let function: syn::Type = syn::parse_str("fn(&crate::Thing) -> &crate::Thing").unwrap(); assert_eq!( compact_tokens(&function), "fn (&crate::Thing) ->&crate::Thing" ); assert_eq!(vec_element_type("Vec"), Some("String".into())); assert_eq!( vec_element_type("std::vec::Vec<&crate::Thing>"), Some("&crate::Thing".into()) ); assert_eq!(vec_element_type("Vec< String >"), Some("String".into())); for invalid in [ "", "String", "Vec", "alloc::vec::Vec", ] { assert_eq!( vec_element_type(invalid), None, "accepted non-vector type {invalid:?}" ); } // test req: diagnostics/006 } #[test] fn rust_fact_collection_propagates_parse_and_recursive_directory_errors() { let base = test_dir("hemx-build-rust-fact-errors"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(base.join("src")).unwrap(); let mut facts = HashMap::new(); std::fs::write(base.join("src/broken.rs"), "struct {").unwrap(); assert_eq!( collect_rust_struct_facts_from_file(&base.join("src/broken.rs"), &mut facts) .unwrap_err() .kind(), io::ErrorKind::InvalidData ); assert!(facts.is_empty()); std::fs::remove_file(base.join("src/broken.rs")).unwrap(); assert!(rust_struct_facts_in(&base.join("missing")) .unwrap() .is_empty()); let _ = std::fs::remove_dir_all(base); // test req: diagnostics/006 } #[test] fn identifier_and_literal_helpers_fail_closed_at_boundaries() { assert_eq!(rust_ident("alpha_9"), Some("alpha_9".into())); for invalid in ["", "9alpha", "alpha-beta", "alpha beta", "alphaé"] { assert_eq!( rust_ident(invalid), None, "accepted Rust identifier {invalid:?}" ); } assert_eq!(class_ident("alpha-beta_9"), Some("alpha_beta_9".into())); for invalid in ["", "9alpha", "alphaé", "alpha.beta", "alpha beta"] { assert_eq!(class_ident(invalid), None, "accepted CSS class {invalid:?}"); } assert_eq!(rust_str("a\n\"b\\c"), "\"a\\n\\\"b\\\\c\""); assert_eq!(rust_str_opt(None), "None"); assert_eq!(rust_str_opt(Some("a\n\"b\\c")), "Some(\"a\\n\\\"b\\\\c\")"); assert_eq!( canonical_symbol( Path::new("templates"), Path::new("templates/nested/panel.heml"), "save", ), "nested/panel.heml::save" ); let root = Path::new("templates"); assert_eq!( component_ident(root, Path::new("templates/nested/profile_card.heml")).unwrap(), "profile_card" ); assert_eq!( component_ident(root, Path::new("templates/9bad.heml")) .unwrap_err() .kind(), io::ErrorKind::InvalidData ); assert_eq!( component_ident(root, Path::new("outside.heml")).unwrap(), "outside" ); assert_eq!(data_param_ident("data-item-id"), Some("item_id".into())); assert_eq!(data_param_ident("aria-label"), None); assert_eq!(app().template_dir, PathBuf::from("templates")); assert_eq!(nearest_dir_with(Path::new("/"), "hemx-absent-marker"), None); let mut exports = String::new(); let mut used = BTreeSet::from(["shared".to_owned(), "shared_target".to_owned()]); push_root_export(&mut exports, "", "targets", &mut used, "shared", "target"); assert!( exports.is_empty(), "colliding alias must not be emitted twice" ); let mut resources = BTreeMap::new(); let first = insert_resource( &mut resources, "slot", "panel.heml::summary".into(), "summary".into(), "panel".into(), ) .unwrap(); first.keyed = true; assert!( insert_resource( &mut resources, "slot", "panel.heml::summary".into(), "summary".into(), "panel".into(), ) .unwrap() .keyed ); assert_eq!( insert_resource( &mut resources, "slot", "other.heml::summary".into(), "summary".into(), "other".into(), ) .unwrap_err() .to_string(), "duplicate generated identifier `summary` for `panel.heml::summary` and `other.heml::summary`" ); let invalid_surface = surface_for_heml_source( Path::new("123.heml"), r#"
"#.to_owned(), ) .unwrap(); let mut extracted = Resources::default(); assert_eq!( extracted .add_surface( Path::new("templates"), Path::new("templates/123.heml"), &invalid_surface, ) .unwrap_err() .kind(), io::ErrorKind::InvalidData ); for (kind, source) in [ ("atom", r#""#), ("handle", r#""#), ] { let first = surface_for_heml_source(Path::new("first.heml"), source.to_owned()).unwrap(); let second = surface_for_heml_source(Path::new("second.heml"), source.to_owned()).unwrap(); let mut duplicate = Resources::default(); duplicate .add_surface( Path::new("templates"), Path::new("templates/first.heml"), &first, ) .unwrap(); assert_eq!( duplicate .add_surface( Path::new("templates"), Path::new("templates/second.heml"), &second, ) .unwrap_err() .to_string(), format!( "duplicate generated identifier `shared` for `first.heml::shared` and `second.heml::shared`" ), "{kind} collision must propagate" ); } let mut form_without_controls = surface_for_heml_source( Path::new("form.heml"), r#"
"#.to_owned(), ) .unwrap(); form_without_controls.forms.clear(); let mut form_resources = Resources::default(); form_resources .add_surface( Path::new("templates"), Path::new("templates/form.heml"), &form_without_controls, ) .unwrap(); assert!(form_resources.forms["empty"].controls.is_empty()); #[cfg(unix)] { use std::os::unix::ffi::OsStrExt; let invalid = PathBuf::from("templates").join(std::ffi::OsStr::from_bytes(b"\xff.heml")); assert_eq!( component_ident(root, &invalid).unwrap_err().kind(), io::ErrorKind::InvalidData ); } // test req: codegen/005 req: diagnostics/004 } #[test] fn generated_contract_fingerprint_and_client_bootstrap_are_deterministic() { let resource = |symbol: &str, component: &str, id| Resource { symbol: symbol.into(), ident: symbol.into(), component: component.into(), keyed: false, id, }; let mut resources = Resources::default(); resources .slots .insert("slot".into(), resource("slot", "page", 11)); resources .handles .insert("save".into(), resource("save", "page", 12)); resources .atoms .insert("count".into(), resource("count", "page", 13)); resources.forms.insert( "profile".into(), FormResource { resource: resource("profile", "page", 14), controls: vec![ GeneratedControl { name: "email".into(), kind: ControlKind::Text, required: true, }, GeneratedControl { name: "nickname".into(), kind: ControlKind::Text, required: false, }, ], }, ); let expected_parts = vec![ SURFACE_SCHEMA_VERSION, EFFECT_BATCH_ABI_VERSION, RUNTIME_ABI_VERSION, 0, 11, 1, 12, 3, 13, 2, 14, 2, stable_id("form-field", "email"), 1, stable_id("form-field", "nickname"), 0, ]; assert_eq!(resources.fingerprint_parts(), expected_parts); let generated = resources.generated_rs(false); assert!(generated.starts_with("// @generated by hemx-build. Do not edit.\n")); assert!(generated.contains("pub const BUILD_FINGERPRINT")); assert!(generated.contains( "\n#[allow(non_upper_case_globals)]\npub mod page {\n #[derive(Clone, Copy)]\n pub struct Component;\n" )); assert!(generated.contains("pub const slot: SlotTarget")); assert!(generated.contains("pub const save: ::hemx::Handle")); assert!(generated.contains("pub const count: ::hemx::Atom")); assert!(generated.contains("pub const profile: ::hemx::Form")); assert!(generated.contains("\n pub mod targets {\n")); let generated_with_globals = resources.generated_rs(true); assert!(generated_with_globals.contains("pub mod advanced {")); assert!(generated_with_globals.contains("pub mod slots {")); let mut row_slot = resource("row", "row", 21); row_slot.keyed = true; resources.slots.insert("row".into(), row_slot); resources .handles .insert("edit".into(), resource("edit", "row", 22)); resources .atoms .insert("selection".into(), resource("selection", "row", 23)); resources .slots .insert("child".into(), resource("child", "page", 24)); resources .handles .insert("child_handle".into(), resource("child_handle", "child", 25)); resources .slots .insert("shared".into(), resource("shared", "page", 26)); resources .handles .insert("shared".into(), resource("shared", "page", 27)); resources.forms.insert( "shared".into(), FormResource { resource: resource("shared", "page", 28), controls: Vec::new(), }, ); for (symbol, ident, component, token) in [ ("a::card", "card", "page", "card"), ("b::card", "card", "row", "card"), ("z::last", "last", "page", "last"), ] { resources.classes.insert( symbol.into(), ClassToken { symbol: symbol.into(), ident: ident.into(), component: component.into(), token: token.into(), }, ); } for (component, name) in [("page", "click"), ("row", "click"), ("z", "submit")] { resources.events.insert( format!("{component}::{name}"), EventToken { symbol: format!("{component}::{name}"), ident: name.into(), component: component.into(), name: name.into(), }, ); } resources .handle_forms .insert("save".into(), "profile".into()); resources .handle_params .entry("save".into()) .or_default() .insert("item_id".into()); let canonical_generated = resources.generated_rs(false); let canonical_globals = resources.generated_rs(true); let canonical_syms = resources.syms(); assert_eq!( stable_id("generated-rs", &canonical_generated), 2_620_423_950, "canonical generated Rust changed" ); assert_eq!( stable_id("generated-rs-global", &canonical_globals), 2_559_847_213, "canonical global-export Rust changed" ); assert_eq!( stable_id("generated-syms", &canonical_syms), 984_222_700, "canonical symbol manifest changed" ); assert_eq!(Resources::default().client_bootstrap().unwrap(), ""); resources .client_handlers .extend(["save".into(), "toggle".into()]); assert_eq!( resources.client_bootstrap().unwrap_err().to_string(), "client-local handlers require one data-hemx-client-module on a hemx root" ); resources.client_modules.insert("/app.wasm".into()); let bootstrap = resources.client_bootstrap().unwrap(); assert!(bootstrap.starts_with( "import init, { __hemx_client_save, __hemx_client_toggle } from \"/app.wasm\";\nawait init();\n" )); assert!( bootstrap.contains("window.hemx.registerClientHandler(\"save\", __hemx_client_save);") ); assert!(bootstrap.contains("data-hemx-client-ready")); resources.client_modules.insert("/other.wasm".into()); assert_eq!( resources.client_bootstrap().unwrap_err().to_string(), "client-local handlers must share one data-hemx-client-module per generated application" ); // test req: build/007 req: abi/003 req: client_local/011 } #[test] fn app_builder_propagates_output_and_client_contract_errors_without_panicking() { let root = std::env::temp_dir().join(format!( "hemx-build-error-{}-{}", std::process::id(), std::thread::current().name().unwrap_or("test") )); let out = root.join("out"); std::fs::create_dir_all(out.join("hemx.generated.rs")).unwrap(); let result = std::panic::catch_unwind(|| app().out_dir(&out).run()); assert!(result.is_ok(), "ordinary output errors must not panic"); assert!(result.unwrap().is_err()); std::fs::remove_dir_all(out.join("hemx.generated.rs")).unwrap(); std::fs::create_dir_all(out.join("hemx.syms")).unwrap(); let result = std::panic::catch_unwind(|| app().out_dir(&out).run()); assert!(result.is_ok(), "symbol output errors must not panic"); assert!(result.unwrap().is_err()); std::fs::remove_dir_all(out.join("hemx.syms")).unwrap(); std::fs::create_dir_all(out.join("hemx.client.js")).unwrap(); let result = std::panic::catch_unwind(|| app().out_dir(&out).run()); assert!(result.is_ok(), "client output errors must not panic"); assert!(result.unwrap().is_err()); let templates = root.join("templates"); let invalid_out = root.join("invalid-out"); std::fs::create_dir_all(&templates).unwrap(); std::fs::write( templates.join("client.heml"), "", ) .unwrap(); let result = std::panic::catch_unwind(|| app().template_dir(&templates).out_dir(&invalid_out).run()); assert!(result.is_ok(), "invalid client contracts must not panic"); assert_eq!( result.unwrap().unwrap_err().to_string(), "client-local handlers require one data-hemx-client-module on a hemx root" ); let invalid_handler = templates.join("invalid_handler.heml"); std::fs::write( &invalid_handler, r#"
"#, ) .unwrap(); std::fs::remove_file(templates.join("client.heml")).unwrap(); let error = app() .template_dir(&templates) .out_dir(&invalid_out) .run() .unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::InvalidData); assert_eq!( error.to_string(), format!( "{}: invalid data-hemx-client value `save-item`; expected a Rust handler identifier", invalid_handler.display() ) ); std::fs::remove_file(invalid_handler).unwrap(); std::fs::write( templates.join("invalid_param.heml"), r#""#, ) .unwrap(); let error = app() .template_dir(&templates) .out_dir(&invalid_out) .run() .unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::InvalidData); assert_eq!( error.to_string(), "invalid handler param attribute `data-123`; expected data-* name usable from Rust" ); std::fs::remove_file(templates.join("invalid_param.heml")).unwrap(); std::fs::write( templates.join("invalid_slot.heml"), r#"
Invalid
"#, ) .unwrap(); let error = app() .template_dir(&templates) .out_dir(&invalid_out) .run() .unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::InvalidData); assert_eq!( error.to_string(), "invalid hemx slot name `123`; expected a Rust identifier" ); let _ = std::fs::remove_dir_all(root); // test req: build/004 req: client_local/011 req: diagnostics/004 } #[test] fn app_builder_discovers_nested_inputs_and_propagates_collection_errors() { let base = test_dir("hemx-build-collection"); let templates = base.join("templates"); let nested = templates.join("nested"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&nested).unwrap(); std::fs::write(templates.join("a_empty.heml"), "").unwrap(); std::fs::write( nested.join("panel.heml"), r#"
"#, ) .unwrap(); std::fs::write(nested.join("panel.css"), ".nested-css {}").unwrap(); std::fs::write(nested.join("theme.scss"), ".nested-scss {}").unwrap(); std::fs::write(nested.join("ignored.txt"), [0xff]).unwrap(); app().template_dir(&templates).out_dir(&out).run().unwrap(); let syms = std::fs::read_to_string(out.join("hemx.syms")).unwrap(); assert!(syms.contains("slot\tnested/panel.heml::nested_panel\tnested_panel\t")); assert!(syms.contains("class\tnested/panel.css::nested-css\tnested_css\tnested-css\n")); assert!(syms.contains("class\tnested/theme.scss::nested-scss\tnested_scss\tnested-scss\n")); let missing = base.join("missing"); let missing_out = base.join("missing-out"); app() .template_dir(&missing) .out_dir(&missing_out) .run() .unwrap(); assert_eq!( std::fs::read_to_string(missing_out.join("hemx.syms")).unwrap(), "hemx-syms-v1\n" ); let _out_dir_lock = OUT_DIR_LOCK.lock().unwrap(); let previous_out_dir = std::env::var_os("OUT_DIR"); let env_out = base.join("env-out"); std::env::set_var("OUT_DIR", &env_out); let env_result = app().template_dir(&missing).run(); match previous_out_dir { Some(value) => std::env::set_var("OUT_DIR", value), None => std::env::remove_var("OUT_DIR"), } env_result.unwrap(); assert!(env_out.join("hemx.generated.rs").is_file()); let template_file = base.join("not-a-directory"); std::fs::write(&template_file, "not a directory").unwrap(); let error = app() .template_dir(&template_file) .out_dir(base.join("file-root-out")) .run() .unwrap_err(); assert!(matches!( error.kind(), io::ErrorKind::NotADirectory | io::ErrorKind::Other )); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let restricted_root = base.join("restricted-root"); let restricted_nested = restricted_root.join("nested"); std::fs::create_dir_all(&restricted_nested).unwrap(); std::fs::set_permissions(&restricted_nested, std::fs::Permissions::from_mode(0o000)) .unwrap(); let error = app() .template_dir(&restricted_root) .out_dir(base.join("restricted-out")) .run() .unwrap_err(); std::fs::set_permissions(&restricted_nested, std::fs::Permissions::from_mode(0o700)) .unwrap(); assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); } let invalid_heml = base.join("invalid-heml"); std::fs::create_dir_all(&invalid_heml).unwrap(); std::fs::write(invalid_heml.join("bad.heml"), [0xff]).unwrap(); assert_eq!( app() .template_dir(&invalid_heml) .out_dir(base.join("invalid-heml-out")) .run() .unwrap_err() .kind(), io::ErrorKind::InvalidData ); let invalid_css = base.join("invalid-css"); std::fs::create_dir_all(&invalid_css).unwrap(); std::fs::write(invalid_css.join("bad.css"), [0xff]).unwrap(); assert_eq!( app() .template_dir(&invalid_css) .out_dir(base.join("invalid-css-out")) .run() .unwrap_err() .kind(), io::ErrorKind::InvalidData ); let blocked_out = base.join("blocked-out"); std::fs::write(&blocked_out, "not a directory").unwrap(); assert!(app() .template_dir(&missing) .out_dir(&blocked_out) .run() .is_err()); let explicit_surface = surface_for_heml_source( Path::new("explicit.heml"), r#"
"#.to_owned(), ) .unwrap(); assert_eq!( app() .surface("explicit.heml", explicit_surface) .out_dir(base.join("explicit-out")) .run() .unwrap_err() .to_string(), "invalid hemx slot name `123`; expected a Rust identifier" ); let ordered = base.join("ordered"); std::fs::create_dir_all(&ordered).unwrap(); std::fs::write(ordered.join("a.css"), ".foo-bar {}").unwrap(); std::fs::write(ordered.join("b.css"), ".foo_bar {}").unwrap(); assert_eq!( app() .template_dir(&ordered) .out_dir(base.join("ordered-out")) .run() .unwrap_err() .to_string(), "duplicate generated class identifier `foo_bar` for CSS classes `foo-bar` and `foo_bar`" ); let _ = std::fs::remove_dir_all(base); // test req: build/003 req: build/004 req: build/009 req: diagnostics/004 } #[test] fn no_op_build_preserves_generated_artifact_timestamps() { // req: build/009 let base = test_dir("hemx-build-no-op"); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); std::fs::write( templates.join("panel.heml"), r#""#, ) .unwrap(); app().template_dir(&templates).out_dir(&out).run().unwrap(); let generated = out.join("hemx.generated.rs"); let symbols = out.join("hemx.syms"); let generated_modified = std::fs::metadata(&generated).unwrap().modified().unwrap(); let symbols_modified = std::fs::metadata(&symbols).unwrap().modified().unwrap(); std::thread::sleep(std::time::Duration::from_millis(20)); app().template_dir(&templates).out_dir(&out).run().unwrap(); assert_eq!( std::fs::metadata(generated).unwrap().modified().unwrap(), generated_modified, "no-op codegen must not invalidate downstream Rust compilation" ); assert_eq!( std::fs::metadata(symbols).unwrap().modified().unwrap(), symbols_modified, "no-op symbol generation must preserve its artifact timestamp" ); let _ = std::fs::remove_dir_all(&base); } #[test] fn changed_template_refreshes_generated_artifacts() { let base = test_dir("hemx-build-refresh"); let templates = base.join("templates"); let out = base.join("out"); let template = templates.join("panel.heml"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); std::fs::write( &template, r#""#, ) .unwrap(); app().template_dir(&templates).out_dir(&out).run().unwrap(); let initial_generated = std::fs::read_to_string(out.join("hemx.generated.rs")).unwrap(); let initial_symbols = std::fs::read_to_string(out.join("hemx.syms")).unwrap(); assert!(initial_generated.contains("pub const save")); assert!(initial_symbols.contains("save")); std::fs::write( &template, r#""#, ) .unwrap(); app().template_dir(&templates).out_dir(&out).run().unwrap(); let refreshed_generated = std::fs::read_to_string(out.join("hemx.generated.rs")).unwrap(); let refreshed_symbols = std::fs::read_to_string(out.join("hemx.syms")).unwrap(); assert_ne!(refreshed_generated, initial_generated); assert_ne!(refreshed_symbols, initial_symbols); assert!(refreshed_generated.contains("pub const cancel")); assert!(!refreshed_generated.contains("pub const save")); assert!(refreshed_symbols.contains("cancel")); assert!(!refreshed_symbols.contains("save")); let _ = std::fs::remove_dir_all(&base); // test req: build/009 } #[test] fn emits_generated_resources_from_heml() { // req: codegen/003 let base = test_dir("hemx-build-test"); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); std::fs::write( templates.join("todo.heml"), r#"
    "#, ) .unwrap(); std::fs::write(templates.join("todo.css"), ".todo-card { display: block; }").unwrap(); app() .template_dir(&templates) .out_dir(&out) .global_exports(true) .run() .unwrap(); let generated = std::fs::read_to_string(out.join("hemx.generated.rs")).unwrap(); assert!(generated.contains("pub mod components")); assert!(generated.contains( "pub const todo: ::hemx::ComponentRef = ::hemx::ComponentRef::new(\"todo\")" )); assert!(generated.contains("pub mod advanced")); assert!(generated.contains("pub mod slots")); assert!(generated.contains("pub const todos")); assert!(generated.contains("pub mod targets")); assert!(generated.contains("pub struct SlotTarget")); assert!(generated.contains("impl ::hemx::GeneratedTarget for SlotTarget")); assert!(generated.contains( "impl ::hemx::GeneratedTarget for KeyedSlotTarget" )); assert!(generated.contains( "pub fn append(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect" )); assert!(generated.contains( "pub fn append_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect" )); assert!(generated.contains("pub const todos: SlotTarget<::std::string::String, ()> = SlotTarget::new(super::advanced::slots::todos);")); assert!(generated.contains("pub use self::targets::todos;")); assert!(generated.contains( "pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect" )); assert!(generated.contains( "pub fn replace(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect" )); assert!(generated.contains( "pub fn set(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect" )); assert!(generated.contains("pub mod handles")); assert!(generated .contains("pub const create: ::hemx::Handle<::hemx::Form<::std::string::String>>")); assert!(generated.contains("pub mod params")); assert!(generated.contains( "pub const todo_id: ::hemx::ParamName = ::hemx::ParamName::new(\"todo_id\")" )); assert!(generated.contains("pub mod forms")); assert!(generated.contains("pub mod atoms")); assert!(generated.contains("pub const filter")); assert!(generated.contains("pub mod events")); assert!(generated .contains("pub const click: ::hemx::EventName = ::hemx::EventName::new(\"click\")")); assert!(generated .contains("pub const change: ::hemx::EventName = ::hemx::EventName::new(\"change\")")); assert!(generated.contains("pub const new_todo")); assert!(generated.contains("pub mod todo")); assert!(generated.contains("pub const ALL_IDS")); assert!(generated.contains( "pub fn lower(html: impl ::std::convert::AsRef) -> ::std::string::String" )); assert!(generated.contains("#[doc(hidden)]\npub fn lower_html")); assert!(generated.contains("pub fn static_fragment(html: &'static str) -> ::hemx::Html")); assert!(generated.contains( "#[doc(hidden)]\npub fn render(view: &impl ::hemplate::Hemplate) -> ::hemx::Html" )); assert!(generated.contains("pub fn page(view: &impl ::hemplate::Hemplate) -> ::hemx::Html")); assert!(generated.contains( "#[doc(hidden)]\npub fn render_html(view: &impl ::hemplate::Hemplate) -> ::hemx::Html" )); assert!(generated.contains("pub fn put(slot: ::hemx::advanced::Slot, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect")); assert!(generated.contains("pub fn append(slot: ::hemx::advanced::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect")); assert!(generated.contains("pub fn prepend(slot: ::hemx::advanced::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect")); assert!(generated.contains("pub fn replace(slot: ::hemx::advanced::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect")); assert!(generated.contains("data-hemx-slot")); assert!(generated.contains("data-hemx-form")); assert!(generated.contains("data-sid")); assert!(generated.contains("data-fid")); assert!(generated.contains("__hemx_inject_handle_inputs")); let syms = std::fs::read_to_string(out.join("hemx.syms")).unwrap(); for line in [ format!( "slot\ttodo.heml::todos\ttodos\t{}\n", stable_id("slot", "todo.heml::todos") ), format!( "handle\ttodo.heml::create\tcreate\t{}\n", stable_id("handle", "todo.heml::create") ), format!( "handle\ttodo.heml::delete\tdelete\t{}\n", stable_id("handle", "todo.heml::delete") ), format!( "form\ttodo.heml::new_todo\tnew_todo\t{}\n", stable_id("form", "todo.heml::new_todo") ), "handle_form\tcreate\tnew_todo\n".to_owned(), "form_field\tnew_todo\tlabels\tfalse\ttrue\n".to_owned(), "form_field\tnew_todo\ttitle\ttrue\tfalse\n".to_owned(), "handle_param\tdelete\ttodo_id\n".to_owned(), format!( "atom\ttodo.heml::filter\tfilter\t{}\n", stable_id("atom", "todo.heml::filter") ), "class\ttodo.css::todo-card\ttodo_card\ttodo-card\n".to_owned(), "class\ttodo.heml::todo-card\ttodo_card\ttodo-card\n".to_owned(), "event\ttodo.heml::change\tchange\tchange\n".to_owned(), "event\ttodo.heml::click\tclick\tclick\n".to_owned(), ] { assert!( syms.contains(&line), "missing symbol line {line:?} in {syms}" ); } assert!(!syms.contains("form_field\tnew_todo\t__h\t")); let _ = std::fs::remove_dir_all(&base); } #[test] fn stylesheet_class_scanner_is_sorted_deduplicated_and_boundary_aware() { assert_eq!(stylesheet_class_tokens(""), Vec::<&str>::new()); assert_eq!(stylesheet_class_tokens(".a"), vec!["a"]); assert_eq!(stylesheet_class_tokens(".ab"), vec!["ab"]); assert_eq!(stylesheet_class_tokens("x.skip .kept"), vec!["kept"]); assert_eq!(stylesheet_class_tokens("x..kept"), vec!["kept"]); assert_eq!(stylesheet_class_tokens("..kept"), vec!["kept"]); assert_eq!(stylesheet_class_tokens(". .kept"), vec!["kept"]); assert_eq!(stylesheet_class_tokens(".1 .kept"), vec!["kept"]); assert_eq!(stylesheet_class_tokens(".é .kept"), vec!["kept"]); assert!(!preceded_by_class_in_compound(b"tag.", 3)); assert!(preceded_by_class_in_compound(b".first.", 6)); assert_eq!( stylesheet_class_tokens( ".z9, .alpha.alpha, ._private, .-prefixed, .compound.second, tag.skipped, name-.skipped, name_.skipped" ), vec!["-prefixed", "_private", "alpha", "compound", "second", "z9"] ); assert_eq!( stylesheet_class_tokens( " .space\n.newline\r.return\t.tab,.comma{.open}.close>.child+.adjacent~.sibling(.paren)" ), vec![ "adjacent", "child", "close", "comma", "newline", "open", "paren", "return", "sibling", "space", "tab" ] ); // test req: style/001 } #[test] fn class_and_form_resource_contracts_fail_closed_and_deduplicate() { let base = test_dir("hemx-build-resource-collisions"); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); let invalid_stylesheet = templates.join("123.css"); std::fs::write(&invalid_stylesheet, ".valid {}").unwrap(); let invalid_stylesheet_name = app() .template_dir(&templates) .out_dir(&out) .run() .unwrap_err(); assert_eq!(invalid_stylesheet_name.kind(), io::ErrorKind::InvalidData); assert_eq!( invalid_stylesheet_name.to_string(), "invalid template name `123`; expected a Rust module identifier" ); std::fs::remove_file(invalid_stylesheet).unwrap(); let invalid_class_template = templates.join("invalid_class.heml"); std::fs::write(&invalid_class_template, r#"
    "#).unwrap(); let invalid_class = app() .template_dir(&templates) .out_dir(&out) .run() .unwrap_err(); assert_eq!(invalid_class.kind(), io::ErrorKind::InvalidData); assert_eq!( invalid_class.to_string(), "invalid CSS class `123`; expected an ASCII class token usable from Rust" ); std::fs::remove_file(invalid_class_template).unwrap(); let stylesheet = templates.join("app.css"); std::fs::write(&stylesheet, ".foo-bar {} .foo_bar {}").unwrap(); let class_error = app() .template_dir(&templates) .out_dir(&out) .run() .unwrap_err(); assert_eq!(class_error.kind(), io::ErrorKind::InvalidData); assert_eq!( class_error.to_string(), "duplicate generated class identifier `foo_bar` for CSS classes `foo-bar` and `foo_bar`" ); std::fs::remove_file(stylesheet).unwrap(); let first_template = templates.join("a.heml"); std::fs::write( &first_template, r#"
    "#, ) .unwrap(); let invalid_form = app() .template_dir(&templates) .out_dir(&out) .run() .unwrap_err(); assert_eq!(invalid_form.kind(), io::ErrorKind::InvalidData); assert_eq!( invalid_form.to_string(), "invalid hemx form name `profile-card`; expected a Rust identifier" ); std::fs::write( &first_template, r#"
    "#, ) .unwrap(); std::fs::write( templates.join("b.heml"), r#"
    "#, ) .unwrap(); let form_error = app() .template_dir(&templates) .out_dir(&out) .run() .unwrap_err(); assert_eq!(form_error.kind(), io::ErrorKind::InvalidData); assert_eq!( form_error.to_string(), "duplicate generated identifier `profile` for `a.heml::profile` and `b.heml::profile`" ); std::fs::remove_file(templates.join("b.heml")).unwrap(); std::fs::write( templates.join("a.heml"), r#"
    "#, ) .unwrap(); app().template_dir(&templates).out_dir(&out).run().unwrap(); let syms = std::fs::read_to_string(out.join("hemx.syms")).unwrap(); assert_eq!( syms.matches("form_field\tprofile\tname\ttrue\tfalse\n") .count(), 1 ); let _ = std::fs::remove_dir_all(&base); // test req: style/001 req: form/001 req: diagnostics/004 } #[test] fn duplicate_collection_slot_upgrades_to_keyed_target() { // req: codegen/003 req: list/002 let base = test_dir("hemx-build-keyed-collection-test"); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); std::fs::write( templates.join("todos.heml"), r#"
    "#, ) .unwrap(); app().template_dir(&templates).out_dir(&out).run().unwrap(); let generated = std::fs::read_to_string(out.join("hemx.generated.rs")).unwrap(); assert!(generated.contains("pub const row: ::hemx::advanced::KeyedSlot")); assert!(generated.contains("pub const row: KeyedSlotTarget")); assert!(generated.contains("pub use self::targets::row;")); assert!(!generated.contains("pub const row: SlotTarget")); let _ = std::fs::remove_dir_all(&base); } #[test] fn app_can_consume_precomputed_surface_facts() { // req: surface/008 req: build/001 let base = test_dir("hemx-build-precomputed-surface-test"); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); let path = templates.join("todo.heml"); let source = Arc::new( r#"
    "# .to_string(), ); let ast = build_ast(source).unwrap().unwrap(); let surface = extract_surface(&ast); app() .template_dir(&templates) .out_dir(&out) .surface(&path, surface) .run() .unwrap(); let generated = std::fs::read_to_string(out.join("hemx.generated.rs")).unwrap(); assert!(generated.contains("pub mod todo")); assert!(generated.contains("pub const create: ::hemx::Handle<()> = ::hemx::Handle::new(")); assert!(generated.contains("pub const todos: ::hemx::advanced::Slot<::std::string::String> = ::hemx::advanced::Slot::new(")); assert!(generated.contains("pub const todos: SlotTarget<::std::string::String, ()> = SlotTarget::new(super::advanced::slots::todos);")); assert!(generated.contains("pub use self::targets::todos;")); assert!(generated .contains("pub const click: ::hemx::EventName = ::hemx::EventName::new(\"click\")")); let _ = std::fs::remove_dir_all(&base); } #[test] fn global_resource_exports_are_explicit_opt_in() { // req: component/003 let base = test_dir("hemx-build-no-global-exports-test"); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); std::fs::write( templates.join("todo.heml"), r#"
    "#, ) .unwrap(); app().template_dir(&templates).out_dir(&out).run().unwrap(); let generated = std::fs::read_to_string(out.join("hemx.generated.rs")).unwrap(); assert!(!generated.contains("\n#[allow(non_upper_case_globals)]\npub mod components")); assert!(!generated.contains("\n#[allow(non_upper_case_globals)]\npub mod slots")); assert!(generated.contains( "\npub fn lower(html: impl ::std::convert::AsRef) -> ::std::string::String" )); assert!(generated.contains("\n#[doc(hidden)]\npub fn lower_html")); assert!(generated.contains("\npub fn static_fragment(html: &'static str) -> ::hemx::Html")); assert!(generated.contains( "\n#[doc(hidden)]\npub fn render(view: &impl ::hemplate::Hemplate) -> ::hemx::Html" )); assert!( generated.contains("\npub fn page(view: &impl ::hemplate::Hemplate) -> ::hemx::Html") ); assert!(generated.contains("\n#[doc(hidden)]\npub fn render_html(view: &impl ::hemplate::Hemplate) -> ::hemx::Html")); assert!(generated.contains("\npub fn put(slot: ::hemx::advanced::Slot, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect")); assert!(generated.contains("\npub fn append(slot: ::hemx::advanced::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect")); assert!(generated.contains("pub mod todo")); assert!(generated.contains( " pub const COMPONENT: ::hemx::ComponentRef = ::hemx::ComponentRef::new(\"todo\")" )); assert!(generated.contains(" pub mod advanced")); assert!(generated.contains(" pub mod slots")); assert!(generated.contains(" pub mod targets")); assert!(generated.contains(" pub const todos: SlotTarget<::std::string::String, ()> = SlotTarget::new(super::advanced::slots::todos);")); assert!(generated.contains(" pub use self::targets::todos;")); assert!(generated.contains(" #[doc(hidden)]\n pub fn lower_html")); assert!(generated.contains( " #[doc(hidden)]\n pub fn render(view: &impl ::hemplate::Hemplate) -> ::hemx::Html" )); assert!( generated.contains(" pub fn page(view: &impl ::hemplate::Hemplate) -> ::hemx::Html") ); assert!(generated.contains(" #[doc(hidden)]\n pub fn render_html(view: &impl ::hemplate::Hemplate) -> ::hemx::Html")); assert!( generated.contains(" pub fn static_fragment(html: &'static str) -> ::hemx::Html") ); assert!(generated.contains(" pub fn put(slot: ::hemx::advanced::Slot, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect")); assert!(generated.contains(" pub fn append(slot: ::hemx::advanced::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect")); let _ = std::fs::remove_dir_all(&base); } #[test] fn emits_form_contract_metadata_from_surface_controls() { // req: codegen/004 req: form/001 let base = test_dir("hemx-build-form-contract-test"); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); std::fs::write( templates.join("profile.heml"), r#"
    "#, ) .unwrap(); app().template_dir(&templates).out_dir(&out).run().unwrap(); let generated = std::fs::read_to_string(out.join("hemx.generated.rs")).unwrap(); assert!(generated.contains("pub const PROFILE_CONTRACT: ::hemx::FormContract")); assert!(generated.contains("pub const PROFILE_FIELDS: &[::hemx::FormField]")); assert!(generated.contains( "::hemx::FormField { name: \"title\", kind: ::hemx::FormControlKind::Text, required: true }" )); assert!(generated.contains( "::hemx::FormField { name: \"count\", kind: ::hemx::FormControlKind::Number { min: Some(\"1\"), max: Some(\"10\"), step: Some(\"1\") }, required: false }" )); assert!(generated.contains( "::hemx::FormField { name: \"labels\", kind: ::hemx::FormControlKind::Select { multiple: true }, required: false }" )); assert!(generated.contains( "::hemx::FormField { name: \"avatar\", kind: ::hemx::FormControlKind::File, required: false }" )); for field in [ "::hemx::FormField { name: \"token\", kind: ::hemx::FormControlKind::Hidden, required: false }", "::hemx::FormField { name: \"enabled\", kind: ::hemx::FormControlKind::Checkbox, required: false }", "::hemx::FormField { name: \"mode\", kind: ::hemx::FormControlKind::Radio, required: false }", "::hemx::FormField { name: \"notes\", kind: ::hemx::FormControlKind::TextArea, required: false }", "::hemx::FormField { name: \"commit\", kind: ::hemx::FormControlKind::Submit, required: false }", "::hemx::FormField { name: \"birthday\", kind: ::hemx::FormControlKind::Other { tag: \"input\", input_type: Some(\"date\") }, required: false }", "::hemx::FormField { name: \"action\", kind: ::hemx::FormControlKind::Other { tag: \"button\", input_type: None }, required: false }", ] { assert!(generated.contains(field), "missing form field metadata {field}"); } let syms = std::fs::read_to_string(out.join("hemx.syms")).unwrap(); assert!(syms.contains("handle_form\tsave\tprofile\n")); assert!(!syms.contains("form_field\tprofile\t__h\t")); assert_eq!( syms.matches("form_field\tprofile\ttitle\ttrue\tfalse\n") .count(), 1, "conditional controls with one submitted name are one Rust form field" ); // req: form/004 test assert!(syms.contains("form_field\tprofile\tlabels\tfalse\ttrue\n")); let _ = std::fs::remove_dir_all(&base); } #[test] fn emits_checked_css_class_tokens_from_templates_and_stylesheets() { // req: style/001, req: codegen/001 let base = test_dir("hemx-build-classes-test"); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); std::fs::write( templates.join("card.heml"), r#"
    "#, ) .unwrap(); std::fs::write( templates.join("card.css"), r#".card { padding: 1rem; } .is-active:hover, .drag-handle { cursor: grab; } .work-card.is-selected { outline: 1px solid currentColor; } "#, ) .unwrap(); std::fs::write( templates.join("panel.scss"), r#".panel-shell { &.is-open { display: block; } }"#, ) .unwrap(); app() .template_dir(&templates) .out_dir(&out) .global_exports(true) .run() .unwrap(); let generated = std::fs::read_to_string(out.join("hemx.generated.rs")).unwrap(); assert!(generated.contains("pub mod classes")); assert!(generated .contains("pub const card: ::hemx::CssClass = ::hemx::CssClass::new(\"card\")")); assert!(generated.contains( "pub const is_active: ::hemx::CssClass = ::hemx::CssClass::new(\"is-active\")" )); assert!(generated.contains( "pub const drag_handle: ::hemx::CssClass = ::hemx::CssClass::new(\"drag-handle\")" )); assert!(generated.contains( "pub const work_card: ::hemx::CssClass = ::hemx::CssClass::new(\"work-card\")" )); assert!(generated.contains( "pub const is_selected: ::hemx::CssClass = ::hemx::CssClass::new(\"is-selected\")" )); assert!(generated.contains("pub mod card")); assert!(generated.contains("pub mod panel")); let syms = std::fs::read_to_string(out.join("hemx.syms")).unwrap(); assert!(syms.contains("class\t")); assert!(syms.contains("\tdrag_handle\tdrag-handle")); let source = format!( r###" #![allow(dead_code)] mod hemx {{ #[derive(Clone, Copy)] pub struct BuildFingerprint; impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }} pub struct Effect; #[derive(Clone, Copy)] pub struct ResourceId; pub trait IntoEffect {{}} impl IntoEffect for Effect {{}} pub trait GeneratedTarget {{ fn __hemx_resource_id(self) -> ResourceId; }} pub trait KeyedPartial {{ fn hemx_key(&self) -> String; }} #[derive(Clone, Copy)] pub struct Slot(::std::marker::PhantomData); impl Slot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} pub const fn id(self) -> ResourceId {{ ResourceId }} pub fn html(self, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn text(self, _: impl ::std::string::ToString) -> Effect {{ Effect }} }} #[derive(Clone, Copy)] pub struct KeyedSlot(::std::marker::PhantomData<(K, T)>); impl KeyedSlot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} pub const fn id(self) -> ResourceId {{ ResourceId }} pub fn append_html(self, _: K, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn prepend_html(self, _: K, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn replace_html(self, _: K, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn remove(self, _: K) -> Effect {{ Effect }} pub fn move_before(self, _: K, _: K) -> Effect {{ Effect }} pub fn move_to_end(self, _: K) -> Effect {{ Effect }} }} #[derive(Clone, Copy)] pub struct Handle(::std::marker::PhantomData); impl Handle {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[derive(Clone, Copy)] pub struct Atom(::std::marker::PhantomData); impl Atom {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[derive(Clone, Copy)] pub struct Form(::std::marker::PhantomData); impl Form {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[derive(Clone, Copy)] pub struct CssClass(&'static str); impl CssClass {{ pub const fn new(name: &'static str) -> Self {{ Self(name) }} pub const fn as_str(self) -> &'static str {{ self.0 }} }} #[derive(Clone, Copy)] pub struct ComponentRef(&'static str); impl ComponentRef {{ pub const fn new(name: &'static str) -> Self {{ Self(name) }} pub const fn as_str(self) -> &'static str {{ self.0 }} }} pub struct SafeHtml(String); impl SafeHtml {{ pub fn trusted(html: impl Into) -> Self {{ Self(html.into()) }} }} pub struct Html(SafeHtml); impl ::std::convert::From for SafeHtml {{ fn from(value: Html) -> Self {{ value.0 }} }} impl ::std::convert::AsRef for Html {{ fn as_ref(&self) -> &str {{ "" }} }} pub mod __private {{ pub fn html_trusted(html: impl Into) -> super::Html {{ super::Html(super::SafeHtml::trusted(html)) }} }} pub mod advanced {{ pub use super::*; }} pub struct FormContract {{ pub fields: &'static [FormField] }} pub struct FormField {{ pub name: &'static str, pub kind: FormControlKind, pub required: bool }} pub enum FormControlKind {{ Text, Number {{ min: Option<&'static str>, max: Option<&'static str>, step: Option<&'static str> }}, Checkbox, Radio, Select {{ multiple: bool }}, TextArea, File, Hidden, Submit, Other {{ tag: &'static str, input_type: Option<&'static str> }} }} }} mod hemplate {{ pub trait Hemplate {{ fn render_into(&self, out: &mut String) -> Result<(), ()>; }} }} {generated} fn main() {{ assert_eq!(classes::drag_handle.as_str(), "drag-handle"); assert_eq!(card::classes::is_active.as_str(), "is-active"); assert_eq!(panel::classes::panel_shell.as_str(), "panel-shell"); }} "###, ); let source_path = base.join("classes.rs"); let bin_path = base.join("classes-bin"); std::fs::write(&source_path, source).unwrap(); let status = std::process::Command::new("rustc") .arg(&source_path) .arg("-o") .arg(&bin_path) .status() .unwrap(); assert!(status.success()); let status = std::process::Command::new(&bin_path).status().unwrap(); assert!(status.success()); let _ = std::fs::remove_dir_all(&base); } #[test] fn diagnostics_for_heml_file_reports_unkeyed_generated_target() { // req: diagnostics/002 req: diagnostics/004 let dir = test_dir("diagnostics_for_heml_file_reports_unkeyed_generated_target"); std::fs::create_dir_all(&dir).expect("create test dir"); let template = dir.join("todo.heml"); std::fs::write( &template, r#"
    "#, ) .expect("write template"); let diagnostics = diagnostics_for_heml_file(&template).expect("diagnostics"); assert_eq!(diagnostics.len(), 1); let diagnostic = &diagnostics[0]; assert_eq!(diagnostic.code, DiagnosticCode::UnkeyedGeneratedTarget); assert_eq!(diagnostic.directive, "data-hemx-slot"); assert_eq!(diagnostic.target, "todo_row"); assert!(diagnostic.repair.contains("h-key=\"todo.id\"")); } #[test] fn generated_targets_for_heml_source_uses_surface_facts() { // req: diagnostics/004 req: diagnostics/005 let targets = generated_targets_for_heml_source( "inline.heml", r#"
    "#, ) .expect("generated targets"); assert_eq!( targets, vec![ GeneratedTarget { kind: "slot".into(), name: "summary".into(), }, GeneratedTarget { kind: "slot".into(), name: "after-root".into(), }, GeneratedTarget { kind: "form".into(), name: "summary".into(), }, GeneratedTarget { kind: "slot".into(), name: "after-missing".into(), }, GeneratedTarget { kind: "handle".into(), name: "submit".into(), }, GeneratedTarget { kind: "slot".into(), name: "later".into(), }, ] ); } #[test] fn template_context_facts_include_self_fields_and_h_for_local_fields() { // req: diagnostics/006 let dir = test_dir("template_context_facts_include_self_fields_and_h_for_local_fields"); std::fs::create_dir_all(dir.join("src")).expect("create src dir"); std::fs::write( dir.join("Cargo.toml"), "[package]\nname = \"facts\"\nversion = \"0.1.0\"\n", ) .expect("write manifest"); std::fs::write( dir.join("src/lib.rs"), r#" #[derive(Clone)] pub struct ExercisePlan { pub name: String, pub kg: f32 } #[derive(Hemplate)] pub struct Workout { pub plan: Vec, pub alternate: std::vec::Vec, pub mysteries: Vec, pub progress: String, } "#, ) .expect("write lib"); let template = dir.join("workout.heml"); let source = r#"

    {+ self.progress +}

    ignored

    ignored

    ignored

  • {+ exercise.name +}
  • {+ exercise.kg +}
  • {+ mystery.anything +}
  • {+ later.name +}
  • "#; std::fs::write(&template, source).expect("write heml"); let facts = template_context_facts_for_heml_source(&template, source) .expect("facts") .expect("derive facts"); assert_eq!(facts.context_type, "Workout"); assert!(facts .self_fields .iter() .any(|field| field.name == "progress" && field.type_name == "String")); let local = facts .locals .iter() .find(|local| local.name == "exercise") .expect("exercise local"); assert_eq!(local.type_name, "ExercisePlan"); assert!(local .fields .iter() .any(|field| field.name == "name" && field.type_name == "String")); assert_eq!( facts .locals .iter() .filter(|local| local.name == "exercise") .count(), 1 ); let mystery = facts .locals .iter() .find(|local| local.name == "mystery") .expect("unknown element type still exposes the local"); assert_eq!(mystery.type_name, "MissingFact"); assert!(mystery.fields.is_empty()); assert!(facts.locals.iter().any(|local| { local.name == "later" && local.type_name == "ExercisePlan" && local.fields.iter().any(|field| field.name == "kg") })); for rejected in ["ghost", "tuple", "item", "unknown", "scalar"] { assert!( facts.locals.iter().all(|local| local.name != rejected), "unexpected loop local {rejected}" ); } assert_eq!( h_for_local_and_self_field(" item in &self.plan "), Some(("item".into(), "plan".into())) ); assert_eq!( h_for_local_and_self_field("item in self.plan.iter()"), Some(("item".into(), "plan".into())) ); for invalid in [ "item:self.plan", " in self.plan", "1item in self.plan", "item-name in self.plan", "(item, index) in self.plan", "item in plan", "item in &plan", "item in self.", ] { assert_eq!( h_for_local_and_self_field(invalid), None, "accepted invalid h-for {invalid:?}" ); } } #[test] fn unkeyed_generated_target_diagnostic_is_structured() { // req: diagnostics/002 let diagnostic = unkeyed_generated_target_diagnostic( Path::new("templates/todo.heml"), "slot", "todo_row", "todo", "&self.todos", ); assert_eq!(diagnostic.code, DiagnosticCode::UnkeyedGeneratedTarget); assert_eq!(diagnostic.severity, DiagnosticSeverity::Error); assert_eq!(diagnostic.file, PathBuf::from("templates/todo.heml")); assert_eq!(diagnostic.directive, "data-hemx-slot"); assert_eq!(diagnostic.target, "todo_row"); assert!(diagnostic .message .contains("h-for=\"todo in &self.todos\" without h-key")); assert!(diagnostic.expected.contains("ui::todo_row.replace(row)")); assert!(diagnostic.repair.contains("h-key=\"todo.id\"")); assert!(diagnostic.repair.contains("dynamic +data-key on the child")); assert!(diagnostic.to_string().contains("templates/todo.heml")); } #[test] fn rejects_hemx_resources_inside_unkeyed_for() { // req: scope/001 req: diagnostics/002 for (case, template, resource, helper) in [ ( "slot", r#""#, "data-hemx-slot=\"todo_row\"", "ui::todo_row", ), ( "slot_data_key", r#""#, "data-hemx-slot=\"todo_row\"", "ui::todo_row", ), ( "handle", r#""#, "data-hemx-handle=\"delete\"", "ui::delete", ), ( "nested-handle", r#""#, "data-hemx-handle=\"delete\"", "ui::delete", ), ( "atom", r#""#, "data-hemx-atom=\"selected\"", "ui::selected", ), ] { let base = test_dir(&format!("hemx-build-unkeyed-for-{case}-test")); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); std::fs::write(templates.join("todo.heml"), template).unwrap(); let err = app() .template_dir(&templates) .out_dir(&out) .run() .unwrap_err(); let err = err.to_string(); assert!(err.contains("inside h-for=\"todo in &self.todos\" without h-key")); assert!(err.contains(resource)); assert!(err.contains("h-key=\"todo.id\"")); assert!(err.contains("generated keyed helpers")); assert!(err.contains(helper)); assert!(err.contains("dynamic +data-key on the child")); let _ = std::fs::remove_dir_all(&base); } let base = test_dir("hemx-build-keyed-nested-resources-test"); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); let keyed_source = r#"
    "#; std::fs::write(templates.join("todo.heml"), keyed_source).unwrap(); app().template_dir(&templates).out_dir(&out).run().unwrap(); let syms = std::fs::read_to_string(out.join("hemx.syms")).unwrap(); assert!(syms.contains("slot\ttodo.heml::todos\ttodos\t")); assert!(syms.contains("slot\ttodo.heml::todo_row\ttodo_row\t")); assert!(syms.contains("handle\ttodo.heml::delete\tdelete\t")); assert!(syms.contains("atom\ttodo.heml::selected\tselected\t")); assert!(syms.contains("slot\ttodo.heml::summary\tsummary\t")); let generated = std::fs::read_to_string(out.join("hemx.generated.rs")).unwrap(); assert!(generated.contains("pub const todo_row: ::hemx::advanced::KeyedSlot")); assert!(generated.contains("pub const todos: ::hemx::advanced::KeyedSlot")); assert!(generated.contains("pub const summary: ::hemx::advanced::Slot")); assert!(!generated.contains("pub const summary: ::hemx::advanced::KeyedSlot")); let surface = surface_for_heml_source(Path::new("todo.heml"), keyed_source.to_owned()).unwrap(); let keyed_scope = surface .scopes .iter() .position(|scope| { matches!( scope.kind, ScopeKind::For { key_expr: Some(_), .. } ) }) .map(|index| ScopeId(index as u32)) .unwrap(); let nested_scope = surface .scopes .iter() .enumerate() .find(|(_, scope)| scope.parent == Some(keyed_scope)) .map(|(index, _)| ScopeId(index as u32)) .unwrap(); let sibling_scope = surface .scopes .iter() .enumerate() .find(|(_, scope)| { matches!(scope.kind, ScopeKind::For { key_expr: None, .. }) && scope.parent != Some(keyed_scope) }) .map(|(index, _)| ScopeId(index as u32)) .unwrap(); assert!(is_descendant_scope(&surface, nested_scope, keyed_scope)); assert!(!is_descendant_scope(&surface, keyed_scope, keyed_scope)); assert!(!is_descendant_scope(&surface, sibling_scope, keyed_scope)); assert!(!is_descendant_scope( &surface, ScopeId(u32::MAX), keyed_scope )); assert!(is_inside_keyed_for(&surface, nested_scope)); assert!(!is_inside_keyed_for(&surface, sibling_scope)); assert!(!is_inside_keyed_for(&surface, ScopeId(u32::MAX))); assert!(has_descendant_keyed_for_scope(&surface, ScopeId(0))); assert!(!has_descendant_keyed_for_scope(&surface, sibling_scope)); assert!(unkeyed_generated_target_diagnostic_for_scope( &surface, ScopeId(u32::MAX), Path::new("todo.heml"), "slot", "missing", ) .is_none()); let _ = std::fs::remove_dir_all(base); } #[test] fn rejects_selector_style_targeting_attrs() { // req: locality/001 req: locality/002 req: htmx_equivalents/003 for (case, template, attr) in [ ( "hemx-target", r##""##, "data-hemx-target", ), ( "hemx-select", r##""##, "data-hemx-select", ), ] { let base = test_dir(&format!("hemx-build-selector-target-{case}-test")); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); let template_path = templates.join("target.heml"); std::fs::write(&template_path, template).unwrap(); let err = app() .template_dir(&templates) .out_dir(&out) .run() .unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::InvalidData); assert_eq!( err.to_string(), format!( "{}: `{attr}` is selector-style targeting; hemx uses generated resources instead. Add data-hemx-slot to the local element and return an effect for that generated slot.", template_path.display() ) ); let _ = std::fs::remove_dir_all(&base); } } #[test] fn rejects_unknown_hemx_authoring_attrs() { // req: convention/009 req: diagnostics/002 let base = test_dir("hemx-build-unknown-hemx-attr-test"); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); let template_path = templates.join("typo.heml"); std::fs::write( &template_path, r#""#, ) .unwrap(); let err = app() .template_dir(&templates) .out_dir(&out) .run() .unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::InvalidData); assert_eq!( err.to_string(), format!( "{}: unknown hemx attribute `data-hemx-pendig-class`; check the spelling or use a non-hemx data-* attribute for app-specific metadata", template_path.display() ) ); let _ = std::fs::remove_dir_all(&base); } #[test] fn rejects_invalid_static_hemx_convention_values() { // req: convention/002 req: convention/003 req: convention/004 req: convention/005 req: convention/006 req: diagnostics/002 for (case, template, attr, guidance) in [ ( "policy", r#""#, "data-hemx-policy", "latest", ), ( "policy-missing", r#""#, "data-hemx-policy", "latest", ), ( "empty-client", r#""#, "data-hemx-client", "non-empty", ), ( "client-module-scheme", r#"
    "#, "data-hemx-client-module", "same-origin", ), ( "client-event", r#""#, "data-hemx-client-event", "runtime-supported", ), ( "client-version-zero", r#""#, "data-hemx-client-state-version", "positive", ), ( "client-version-invalid", r#""#, "data-hemx-client-state-version", "positive", ), ( "client-policy-invalid", r#""#, "data-hemx-client-policy", "latest", ), ( "events-mixed", r#""#, "data-hemx-on", "runtime-supported", ), ( "confirm-empty", r#""#, "data-hemx-confirm", "non-empty", ), ( "sse-empty", r#"
    "#, "data-hemx-sse", "non-empty", ), ( "delay-empty", r#""#, "data-hemx-delay", "milliseconds", ), ( "revealed-ahead-negative", r#"
    "#, "data-hemx-revealed-ahead", "non-negative", ), ( "throttle-invalid", r#""#, "data-hemx-throttle", "milliseconds", ), ( "duration-suffix-only", r#""#, "data-hemx-delay", "milliseconds", ), ( "policy-duplicate-anchor", r#""#, "data-hemx-policy", "latest", ), ( "debounce", r#""#, "data-hemx-debounce", "250ms", ), ( "delay", r#""#, "data-hemx-delay", "250ms", ), ( "every", r#""#, "data-hemx-every", "1s", ), ( "interval", r#""#, "data-hemx-interval", "1s", ), ( "history", r#"
    "#, "data-hemx-history", "replace", ), ( "event", r#""#, "data-hemx-on", "click", ), ( "empty-event", r#""#, "data-hemx-on", "click", ), ( "empty-confirm", r#""#, "data-hemx-confirm", "non-empty", ), ( "empty-sse", r#"
    "#, "data-hemx-sse", "same-origin SSE URL", ), ] { let base = test_dir(&format!("hemx-build-invalid-convention-{case}-test")); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); let template_path = templates.join("invalid.heml"); std::fs::write(&template_path, template).unwrap(); let err = app() .template_dir(&templates) .out_dir(&out) .run() .unwrap_err(); let surface = surface_for_heml_source(&template_path, template.to_owned()).unwrap(); let value = surface .nodes .iter() .flat_map(|node| &node.attrs) .find(|candidate| candidate.name == attr) .and_then(|candidate| candidate.value.as_deref()) .unwrap_or(""); let expectation = match attr { "data-hemx-policy" => { "expected one of `latest`, `queue`, `drop`, or `parallel`" } "data-hemx-history" => { "expected `push`, `replace`, or empty for the default push behavior" } "data-hemx-client" => "expected a non-empty client handler name", "data-hemx-client-policy" => "expected `latest` or `drop`", "data-hemx-client-module" => { "expected a same-origin module specifier beginning with `/`, `./`, or `../`" } "data-hemx-client-event" => "expected a runtime-supported event", "data-hemx-client-state-version" => { "expected a positive client state ABI version" } "data-hemx-revealed-ahead" => "expected a non-negative number of viewports", "data-hemx-on" => "expected runtime-supported events: `click`, `submit`, `input`, `change`, `keydown`, `dragstart`, `dragover`, or `drop`", "data-hemx-confirm" => "expected a non-empty confirmation message", "data-hemx-sse" => "expected a non-empty same-origin SSE URL", "data-hemx-debounce" | "data-hemx-delay" | "data-hemx-throttle" | "data-hemx-every" | "data-hemx-interval" => { "expected milliseconds like `250`/`250ms` or seconds like `1s`" } _ => panic!("missing expectation for {attr}"), }; assert_eq!(err.kind(), io::ErrorKind::InvalidData); assert_eq!( err.to_string(), format!( "{}: invalid {attr} value `{value}`; {expectation}", template_path.display() ), "guidance anchor {guidance:?}" ); let _ = std::fs::remove_dir_all(&base); } } #[test] fn static_attribute_value_validators_cover_every_boundary() { for valid in ["latest", " queue ", "drop", "parallel"] { assert!(valid_policy(valid), "rejected policy {valid:?}"); } for invalid in ["", "newest", "latest queue", "LATEST"] { assert!(!valid_policy(invalid), "accepted policy {invalid:?}"); } for valid in ["/app.js", " ./app.js ", "../app.js"] { assert!(valid_client_module(valid), "rejected module {valid:?}"); } for invalid in [ "", " ", "//cdn/app.js", "https://example.com/app.js", "app.js", ] { assert!(!valid_client_module(invalid), "accepted module {invalid:?}"); } for valid in [ "click", "submit input change keydown dragstart dragover drop", " click change ", ] { assert!(valid_event_list(valid), "rejected event list {valid:?}"); } for invalid in ["", " ", "blur", "click blur"] { assert!( !valid_event_list(invalid), "accepted event list {invalid:?}" ); } for valid in [ "click", "submit", "input", "change", "keydown", "dragstart", "dragover", "drop", ] { assert!(valid_runtime_event(valid), "rejected event {valid:?}"); } for invalid in ["", "blur", "Click"] { assert!(!valid_runtime_event(invalid), "accepted event {invalid:?}"); } for valid in ["0", "250", "250ms", "1s", " 5s "] { assert!(valid_duration(valid), "rejected duration {valid:?}"); } for invalid in ["", " ", "ms", "s", "-1", "1sec", "1.5s", "1 ms"] { assert!(!valid_duration(invalid), "accepted duration {invalid:?}"); } let valid_source = r#"
    "#; let base = test_dir("hemx-build-valid-static-values"); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); std::fs::write(templates.join("valid.heml"), valid_source).unwrap(); app().template_dir(&templates).out_dir(&out).run().unwrap(); let _ = std::fs::remove_dir_all(base); // test req: convention/002 req: convention/003 req: convention/004 req: convention/005 req: convention/006 } #[test] fn placement_contracts_fail_closed_with_exact_diagnostics() { // req: page_swap/001 req: page_swap/007 req: push/006 req: diagnostics/002 for (case, template, attr, expectation) in [ ( "nav-button", r#""#, "data-hemx-nav", "expected a real `
    ` link so navigation works without JavaScript", ), ( "nav-missing-href", r#"Docs"#, "data-hemx-nav", "expected a real `` link so navigation works without JavaScript", ), ( "nav-empty-href", r#"Docs"#, "data-hemx-nav", "expected a real `` link so navigation works without JavaScript", ), ( "boost-anchor", r#"Docs"#, "data-hemx-boost", "expected a container around descendant links/forms; use `data-hemx-nav` on anchors or `data-hemx-handle` on forms", ), ( "boost-form", r#"
    "#, "data-hemx-boost", "expected a container around descendant links/forms; use `data-hemx-nav` on anchors or `data-hemx-handle` on forms", ), ( "client-module-child", r#"
    "#, "data-hemx-client-module", "expected placement on the same element as `data-hemx-root`", ), ( "sse-child", r#"
    "#, "data-hemx-sse", "expected placement on the same element as `data-hemx-root`", ), ] { let base = test_dir(&format!("hemx-build-invalid-placement-{case}-test")); let templates = base.join("templates"); let out = base.join("out"); let template_path = templates.join("placement.heml"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); std::fs::write(&template_path, template).unwrap(); let error = app() .template_dir(&templates) .out_dir(&out) .run() .unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::InvalidData); assert_eq!( error.to_string(), format!( "{}: invalid {attr} placement; {expectation}", template_path.display() ) ); let _ = std::fs::remove_dir_all(&base); } for (case, template) in [ ("nav-link", r#"Docs"#), ( "boost-container", r#""#, ), ( "client-module-root", r#"
    "#, ), ( "sse-root", r#"
    "#, ), ] { let base = test_dir(&format!("hemx-build-valid-placement-{case}-test")); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); std::fs::write(templates.join("placement.heml"), template).unwrap(); app().template_dir(&templates).out_dir(&out).run().unwrap(); let _ = std::fs::remove_dir_all(base); } } #[test] fn generated_lowering_injects_progressive_form_handle_and_key() { // req: form/002, req: wire/001 let base = test_dir("hemx-build-lowering-test"); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); std::fs::write( templates.join("todo.heml"), r#"
  • "#, ) .unwrap(); app().template_dir(&templates).out_dir(&out).run().unwrap(); let generated = std::fs::read_to_string(out.join("hemx.generated.rs")).unwrap(); assert!(generated.contains("fn __hemx_lower_html")); let source = format!( r###" #![allow(dead_code)] mod hemx {{ #[derive(Clone, Copy)] pub struct BuildFingerprint; impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }} pub struct Effect; #[derive(Clone, Copy)] pub struct ResourceId; pub trait IntoEffect {{}} impl IntoEffect for Effect {{}} pub trait GeneratedTarget {{ fn __hemx_resource_id(self) -> ResourceId; }} pub trait KeyedPartial {{ fn hemx_key(&self) -> String; }} #[derive(Clone, Copy)] pub struct Slot(::std::marker::PhantomData); impl Slot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} pub const fn id(self) -> ResourceId {{ ResourceId }} pub fn html(self, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn text(self, _: impl ::std::string::ToString) -> Effect {{ Effect }} }} #[derive(Clone, Copy)] pub struct KeyedSlot(::std::marker::PhantomData<(K, T)>); impl KeyedSlot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} pub const fn id(self) -> ResourceId {{ ResourceId }} pub fn append_html(self, _: K, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn prepend_html(self, _: K, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn replace_html(self, _: K, _: impl ::std::convert::Into) -> Effect {{ Effect }} pub fn remove(self, _: K) -> Effect {{ Effect }} pub fn move_before(self, _: K, _: K) -> Effect {{ Effect }} pub fn move_to_end(self, _: K) -> Effect {{ Effect }} }} #[derive(Clone, Copy)] pub struct Handle(::std::marker::PhantomData); impl Handle {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[derive(Clone, Copy)] pub struct Atom(::std::marker::PhantomData); impl Atom {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[derive(Clone, Copy)] pub struct Form(::std::marker::PhantomData); impl Form {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[derive(Clone, Copy)] pub struct ComponentRef(&'static str); impl ComponentRef {{ pub const fn new(name: &'static str) -> Self {{ Self(name) }} pub const fn as_str(self) -> &'static str {{ self.0 }} }} pub struct SafeHtml(String); impl SafeHtml {{ pub fn trusted(html: impl Into) -> Self {{ Self(html.into()) }} }} pub struct Html(SafeHtml); impl ::std::convert::From for SafeHtml {{ fn from(value: Html) -> Self {{ value.0 }} }} impl ::std::convert::AsRef for Html {{ fn as_ref(&self) -> &str {{ "" }} }} pub mod __private {{ pub fn html_trusted(html: impl Into) -> super::Html {{ super::Html(super::SafeHtml::trusted(html)) }} }} pub mod advanced {{ pub use super::*; }} pub struct FormContract {{ pub fields: &'static [FormField] }} pub struct FormField {{ pub name: &'static str, pub kind: FormControlKind, pub required: bool }} pub enum FormControlKind {{ Text, Number {{ min: Option<&'static str>, max: Option<&'static str>, step: Option<&'static str> }}, Checkbox, Radio, Select {{ multiple: bool }}, TextArea, File, Hidden, Submit, Other {{ tag: &'static str, input_type: Option<&'static str> }} }} }} mod hemplate {{ pub trait Hemplate {{ fn render_into(&self, out: &mut String) -> Result<(), ()>; }} }} {generated} fn main() {{ let html = todo::lower_html(r#"
  • "#); assert!(html.contains(r#"data-hid="#)); assert!(html.contains(r#"name="__h""#)); assert!(html.contains(r#"data-key="7""#)); // req: list/002 test assert!(!html.contains("h-key")); }} "###, ); let source_path = base.join("lowering.rs"); let bin_path = base.join("lowering-bin"); std::fs::write(&source_path, source).unwrap(); let status = std::process::Command::new("rustc") .arg(&source_path) .arg("-o") .arg(&bin_path) .status() .unwrap(); assert!(status.success()); let status = std::process::Command::new(&bin_path).status().unwrap(); assert!(status.success()); let _ = std::fs::remove_dir_all(&base); } fn test_dir(prefix: &str) -> PathBuf { std::env::temp_dir().join(format!("{prefix}-{}", std::process::id())) } }