Files
hemx/hemx-build/src/lib.rs
T
slhx agent 216465f779 test(build): close residual helper mutants
Prove compact Rust type and vector facts plus default template paths, alias collision suppression, data parameter prefixes, keyed descendants, and absent Cargo roots. Classify only hemplate's infallible surface parser seam.

req: diagnostics/006

req: diagnostics/004

req: codegen/005

req: test/021
2026-07-17 07:59:26 +02:00

4599 lines
179 KiB
Rust

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<PathBuf>,
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<TemplateFieldFact>,
pub locals: Vec<TemplateLocalFact>,
}
#[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<TemplateFieldFact>,
}
#[derive(Debug, Clone)]
struct RustStructFact {
fields: Vec<TemplateFieldFact>,
derives_hemplate: bool,
}
pub fn diagnostics_for_heml_file(path: impl AsRef<Path>) -> io::Result<Vec<Diagnostic>> {
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<Path>,
source: impl Into<String>,
) -> io::Result<Vec<Diagnostic>> {
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<Path>,
source: impl Into<String>,
) -> io::Result<Vec<GeneratedTarget>> {
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<Path>,
) -> io::Result<Option<TemplateContextFacts>> {
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<Path>,
source: impl Into<String>,
) -> io::Result<Option<TemplateContextFacts>> {
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<SurfaceDocument> {
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<PathBuf>) -> Self {
self.out_dir = Some(out_dir.into());
self
}
pub fn template_dir(mut self, template_dir: impl Into<PathBuf>) -> 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<PathBuf>, 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<String, Resource>,
handles: BTreeMap<String, Resource>,
handle_forms: BTreeMap<String, String>,
handle_params: BTreeMap<String, BTreeSet<String>>,
forms: BTreeMap<String, FormResource>,
atoms: BTreeMap<String, Resource>,
classes: BTreeMap<String, ClassToken>,
events: BTreeMap<String, EventToken>,
client_handlers: BTreeSet<String>,
client_modules: BTreeSet<String>,
}
#[derive(Clone, Debug)]
struct Resource {
symbol: String,
ident: String,
component: String,
keyed: bool,
id: u32,
}
#[derive(Clone, Debug)]
struct FormResource {
resource: Resource,
controls: Vec<GeneratedControl>,
}
#[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<GeneratedControl>,
) -> 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::<Vec<_>>()
.join(", ")
));
// req: build/001 req: component/003
let component_names: BTreeSet<String> = 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<String>,
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<T, C = ()> {{ slot: ::hemx::advanced::Slot<T>, _marker: ::std::marker::PhantomData<C> }}\n"
));
out.push_str(&format!("{inner}#[allow(dead_code)]\n"));
out.push_str(&format!("{inner}impl<T, C> SlotTarget<T, C> {{\n"));
out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::Slot<T>) -> 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<T> SlotTarget<T, ()> {{\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<T> SlotTarget<T, {child_prefix}{child}::Component> {{\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<T, C> ::hemx::GeneratedTarget for SlotTarget<T, C> {{\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<K, T, C = ()> {{ slot: ::hemx::advanced::KeyedSlot<K, T>, _marker: ::std::marker::PhantomData<C> }}\n"));
out.push_str(&format!("{inner}#[allow(dead_code)]\n"));
out.push_str(&format!("{inner}impl<K, T, C> KeyedSlotTarget<K, T, C>\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<K, T>) -> Self {{ Self {{ slot, _marker: ::std::marker::PhantomData }} }}\n"));
out.push_str(&format!("{inner}}}\n"));
out.push_str(&format!(
"{inner}impl<K: ::std::string::ToString, T, C> KeyedSlotTarget<K, T, C> {{\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<T> 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<T> 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<K: ::std::string::ToString, T, C> ::hemx::GeneratedTarget for KeyedSlotTarget<K, T, C> {{\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::<Vec<_>>()
.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<str>) -> ::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<str>) -> ::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<T>(slot: ::hemx::advanced::Slot<T>, 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<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, 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<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, 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<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, 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");
__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("<form") {
out.push_str(&rest[..start]);
rest = &rest[start..];
let Some(open_end) = 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("</form>").unwrap_or(rest.len());
let form_body = &rest[..form_body_end];
if !__hemx_has_handle_input(form_body) {
out.push_str(&::std::format!("<input type=\"hidden\" name=\"__h\" value=\"{}\">", 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<String> {
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<String> {
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::<Vec<_>>();
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<u32> {
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<String, Resource>,
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<Resource> {
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<Vec<PathBuf>> {
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<PathBuf>) -> 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<String> {
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<String>,
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<Item = &str> {
value
.split_ascii_whitespace()
.filter(|token| !token.is_empty())
}
fn event_tokens(value: &str) -> impl Iterator<Item = &str> {
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<String> {
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<String> {
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-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::<u32>()
.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-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 `<a href=...>` 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<String> {
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<PathBuf> {
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<HashMap<String, RustStructFact>> {
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<String, RustStructFact>,
) -> 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<String, RustStructFact>,
) -> 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<String, RustStructFact>,
) {
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::<syn::Path, syn::Token![,]>::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<String, RustStructFact>,
) -> Vec<TemplateLocalFact> {
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<String> {
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<Diagnostic> {
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<GeneratedTarget> {
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<Diagnostic> {
loop {
let current = surface.scopes.get(scope.0 as usize)?;
if let ScopeKind::For {
pattern,
expr,
key_expr: None,
} = &current.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<String> {
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<String> {
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#"<template h-for="todo in &self.todos"><button data-hemx-handle="save">Save</button></template>"#;
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#"<main data-hemx-root="app"><section h-slot="notice"></section><button data-hemx-handle="save">Save</button></main>"#,
)
.unwrap();
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].name, "save");
assert_eq!(
template_context_facts_for_heml_source(
&template,
"<main>{{ self.title }}</main>".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,
"<main>{{ self.title }}</main>".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!("<main>{{{{ self.{field} }}}}</main>"),
)
.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"),
"<main>{{ self.outside }}</main>".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"),
"<main>{{ self.card_title }}</main>".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"),
"<main>{{ self.hidden }}</main>".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,
"<main>{{ self.title }}</main>".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"),
"<main>{{ self.title }}</main>".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<String, &crate::Thing<Left, Right>>")
.unwrap();
assert_eq!(
compact_tokens(&qualified),
"std::collections::HashMap<String,&crate::Thing<Left,Right>>"
);
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<String>"), Some("String".into()));
assert_eq!(
vec_element_type("std::vec::Vec<&crate::Thing<Left,Right>>"),
Some("&crate::Thing<Left,Right>".into())
);
assert_eq!(vec_element_type("Vec< String >"), Some("String".into()));
for invalid in [
"",
"String",
"Vec<String",
"VecString>",
"alloc::vec::Vec<String>",
] {
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#"<section data-hemx-slot="summary"></section>"#.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#"<span data-hemx-atom="shared"></span>"#),
("handle", r#"<button data-hemx-handle="shared"></button>"#),
] {
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#"<form data-hemx-handle="save" data-hemx-form="empty"></form>"#.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),
3_748_714_336,
"canonical generated Rust changed"
);
assert_eq!(
stable_id("generated-rs-global", &canonical_globals),
2_783_766_475,
"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"),
"<button data-hemx-client=\"save\">Save</button>",
)
.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#"<main data-hemx-root="app" data-hemx-client-module="/app.js"><button data-hemx-client="save-item">Save</button></main>"#,
)
.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#"<button data-hemx-handle="save" data-123="value">Save</button>"#,
)
.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#"<section data-hemx-slot="123">Invalid</section>"#,
)
.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#"<section data-hemx-slot="nested_panel"></section>"#,
)
.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#"<section data-hemx-slot="123"></section>"#.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#"<button data-hemx-handle="save">Save</button>"#,
)
.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#"<button data-hemx-handle="save">Save</button>"#,
)
.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#"<button data-hemx-handle="cancel">Cancel</button>"#,
)
.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#"<form class="todo-card" data-hemx-handle="create" data-hemx-form="new_todo"><input name="title" required><select name="labels" multiple><option value="urgent">Urgent</option></select><input name="__h" value="reserved"></form><button data-hemx-handle="delete" data-todo-id="7" data-hemx-on="click change">Delete</button><ul data-hemx-slot="todos"></ul><section data-hemx-atom="filter"></section>"#,
)
.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<T, C = ()>"));
assert!(generated.contains("impl<T, C> ::hemx::GeneratedTarget for SlotTarget<T, C>"));
assert!(generated.contains(
"impl<K: ::std::string::ToString, T, C> ::hemx::GeneratedTarget for KeyedSlotTarget<K, T, C>"
));
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<str>) -> ::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<T>(slot: ::hemx::advanced::Slot<T>, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect"));
assert!(generated.contains("pub fn append<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect"));
assert!(generated.contains("pub fn prepend<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect"));
assert!(generated.contains("pub fn replace<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, 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#"<div class="123"></div>"#).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#"<form data-hemx-handle="save_a" data-hemx-form="profile-card"><input name="name"></form>"#,
)
.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#"<form data-hemx-handle="save_a" data-hemx-form="profile"><input name="name"></form>"#,
)
.unwrap();
std::fs::write(
templates.join("b.heml"),
r#"<form data-hemx-handle="save_b" data-hemx-form="profile"><input name="name"></form>"#,
)
.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#"<form data-hemx-handle="save_name" data-hemx-form="profile"><input name="name" required></form><form data-hemx-handle="save_again" data-hemx-form="profile"><input name="name" required></form>"#,
)
.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#"<ul data-hemx-slot="row"><template h-for="todo in &self.todos" h-key="todo.id"><li +data-key="todo.id">{+ todo +}</li></template></ul>"#,
)
.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#"<button data-hemx-handle="create" data-hemx-on="click">Create</button><div data-hemx-slot="todos"></div>"#
.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#"<button data-hemx-handle="create">Create</button><div data-hemx-slot="todos"></div>"#,
)
.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<str>) -> ::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<T>(slot: ::hemx::advanced::Slot<T>, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect"));
assert!(generated.contains("\npub fn append<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, 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<T>(slot: ::hemx::advanced::Slot<T>, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect"));
assert!(generated.contains(" pub fn append<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, 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#"<form data-hemx-handle="save" data-hemx-form="profile">
<input type="hidden" name="__h" value="123">
<input name="title" required>
<template h-if="alternate"><input name="title"></template>
<input name="count" type="number" min="1" max="10" step="1">
<select name="labels" multiple></select>
<input name="avatar" type="file">
</form>"#,
)
.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 }"
));
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#"<article class="card is-active" data-hemx-slot="card_body"></article>"#,
)
.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<T>(::std::marker::PhantomData<T>);
impl<T> Slot<T> {{ 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<SafeHtml>) -> Effect {{ Effect }} pub fn text(self, _: impl ::std::string::ToString) -> Effect {{ Effect }} }}
#[derive(Clone, Copy)] pub struct KeyedSlot<K, T>(::std::marker::PhantomData<(K, T)>);
impl<K, T> KeyedSlot<K, T> {{ 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<SafeHtml>) -> Effect {{ Effect }} pub fn prepend_html(self, _: K, _: impl ::std::convert::Into<SafeHtml>) -> Effect {{ Effect }} pub fn replace_html(self, _: K, _: impl ::std::convert::Into<SafeHtml>) -> 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<T>(::std::marker::PhantomData<T>);
impl<T> Handle<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct Atom<T>(::std::marker::PhantomData<T>);
impl<T> Atom<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct Form<T>(::std::marker::PhantomData<T>);
impl<T> Form<T> {{ 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<String>) -> Self {{ Self(html.into()) }} }}
pub struct Html(SafeHtml);
impl ::std::convert::From<Html> for SafeHtml {{ fn from(value: Html) -> Self {{ value.0 }} }}
impl ::std::convert::AsRef<str> for Html {{ fn as_ref(&self) -> &str {{ "" }} }}
pub mod __private {{ pub fn html_trusted(html: impl Into<String>) -> 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#"<main data-hemx-root="todos"><template h-for="todo in &self.todos"><li data-hemx-slot="todo_row">{+ todo.title +}</li></template></main>"#,
)
.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#"
<main data-hemx-root="todos">
<section class="card" data-hemx-slot="summary"></section>
<section data-hemx-root="nested" data-hemx-slot="after-root"></section>
<section data-hemx-slot="summary"></section>
<form data-hemx-form="summary"></form>
<span data-hemx-handle data-hemx-slot="after-missing"></span>
<button data-hemx-handle="submit">Save</button>
<aside data-hemx-slot="later"></aside>
</main>
"#,
)
.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<ExercisePlan>,
pub alternate: std::vec::Vec<ExercisePlan>,
pub mysteries: Vec<MissingFact>,
pub progress: String,
}
"#,
)
.expect("write lib");
let template = dir.join("workout.heml");
let source = r#"
<main title="ghost in self.plan">
<p h-for="malformed">{+ self.progress +}</p>
<p h-for="(tuple, item) in self.plan">ignored</p>
<p h-for="unknown in self.unknown">ignored</p>
<p h-for="scalar in self.progress">ignored</p>
<li h-for="exercise in &self.plan">{+ exercise.name +}</li>
<li h-for="exercise in self.alternate">{+ exercise.kg +}</li>
<li h-for="mystery in self.mysteries">{+ mystery.anything +}</li>
<li h-for="later in self.plan[0]">{+ later.name +}</li>
</main>
"#;
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#"<template h-for="todo in &self.todos"><li data-hemx-slot="todo_row">{+ todo.title +}</li></template>"#,
"data-hemx-slot=\"todo_row\"",
"ui::todo_row",
),
(
"slot_data_key",
r#"<template h-for="todo in &self.todos"><li data-hemx-slot="todo_row" +data-key="todo.id">{+ todo.title +}</li></template>"#,
"data-hemx-slot=\"todo_row\"",
"ui::todo_row",
),
(
"handle",
r#"<template h-for="todo in &self.todos"><button data-hemx-handle="delete">Delete</button></template>"#,
"data-hemx-handle=\"delete\"",
"ui::delete",
),
(
"nested-handle",
r#"<template h-for="todo in &self.todos"><template h-if="todo.visible"><button data-hemx-handle="delete">Delete</button></template></template>"#,
"data-hemx-handle=\"delete\"",
"ui::delete",
),
(
"atom",
r#"<template h-for="todo in &self.todos"><span data-hemx-atom="selected">Selected</span></template>"#,
"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#"
<main>
<ul data-hemx-slot="todos">
<template h-for="todo in &self.todos" h-key="todo.id">
<li data-hemx-slot="todo_row">
<template h-for="child in &todo.children" h-key="child.id">
<button data-hemx-handle="delete">Delete</button>
<span data-hemx-atom="selected">Selected</span>
</template>
</li>
</template>
<template h-for="other in &self.others"><li>Unaddressed sibling</li></template>
</ul>
<aside data-hemx-slot="summary"></aside>
</main>
"#;
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##"<button data-hemx-handle="save" data-hemx-target="#row">Save</button>"##,
"data-hemx-target",
),
(
"hemx-select",
r##"<button data-hemx-handle="save" data-hemx-select="closest tr">Save</button>"##,
"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#"<button data-hemx-handle="save" data-hemx-pendig-class="busy">Save</button>"#,
)
.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#"<button data-hemx-handle="save" data-hemx-policy="newest">Save</button>"#,
"data-hemx-policy",
"latest",
),
(
"policy-missing",
r#"<button data-hemx-handle="save" data-hemx-policy>Save</button>"#,
"data-hemx-policy",
"latest",
),
(
"empty-client",
r#"<button data-hemx-client="">Save</button>"#,
"data-hemx-client",
"non-empty",
),
(
"client-module-scheme",
r#"<main data-hemx-root="app" data-hemx-client-module="https://example.com/app.js"></main>"#,
"data-hemx-client-module",
"same-origin",
),
(
"client-event",
r#"<button data-hemx-client="save" data-hemx-client-event="blur">Save</button>"#,
"data-hemx-client-event",
"runtime-supported",
),
(
"client-version-zero",
r#"<button data-hemx-client="save" data-hemx-client-state-version="0">Save</button>"#,
"data-hemx-client-state-version",
"positive",
),
(
"client-version-invalid",
r#"<button data-hemx-client="save" data-hemx-client-state-version="one">Save</button>"#,
"data-hemx-client-state-version",
"positive",
),
(
"client-policy-invalid",
r#"<button data-hemx-client="save" data-hemx-client-policy="queue">Save</button>"#,
"data-hemx-client-policy",
"latest",
),
(
"events-mixed",
r#"<button data-hemx-handle="save" data-hemx-on="click blur">Save</button>"#,
"data-hemx-on",
"runtime-supported",
),
(
"confirm-empty",
r#"<button data-hemx-handle="save" data-hemx-confirm=" ">Save</button>"#,
"data-hemx-confirm",
"non-empty",
),
(
"sse-empty",
r#"<main data-hemx-root="app" data-hemx-sse=" "></main>"#,
"data-hemx-sse",
"non-empty",
),
(
"delay-empty",
r#"<button data-hemx-handle="save" data-hemx-delay="">Save</button>"#,
"data-hemx-delay",
"milliseconds",
),
(
"throttle-invalid",
r#"<button data-hemx-handle="save" data-hemx-throttle="ms">Save</button>"#,
"data-hemx-throttle",
"milliseconds",
),
(
"duration-suffix-only",
r#"<button data-hemx-handle="save" data-hemx-delay="s">Save</button>"#,
"data-hemx-delay",
"milliseconds",
),
(
"policy-duplicate-anchor",
r#"<button data-hemx-handle="save" data-hemx-policy="newest">Save</button>"#,
"data-hemx-policy",
"latest",
),
(
"debounce",
r#"<button data-hemx-handle="save" data-hemx-debounce="soon">Save</button>"#,
"data-hemx-debounce",
"250ms",
),
(
"delay",
r#"<button data-hemx-handle="save" data-hemx-delay="soon">Save</button>"#,
"data-hemx-delay",
"250ms",
),
(
"every",
r#"<button data-hemx-handle="save" data-hemx-every="1sec">Save</button>"#,
"data-hemx-every",
"1s",
),
(
"interval",
r#"<button data-hemx-handle="save" data-hemx-interval="often">Save</button>"#,
"data-hemx-interval",
"1s",
),
(
"history",
r#"<form method="get" action="/search" data-hemx-history="append"><input name="q"></form>"#,
"data-hemx-history",
"replace",
),
(
"event",
r#"<button data-hemx-handle="save" data-hemx-on="blur">Save</button>"#,
"data-hemx-on",
"click",
),
(
"empty-event",
r#"<button data-hemx-handle="save" data-hemx-on="">Save</button>"#,
"data-hemx-on",
"click",
),
(
"empty-confirm",
r#"<button data-hemx-handle="delete" data-hemx-confirm="">Delete</button>"#,
"data-hemx-confirm",
"non-empty",
),
(
"empty-sse",
r#"<section data-hemx-root="notifications" data-hemx-sse=""></section>"#,
"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-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#"
<main data-hemx-root="app" data-hemx-client-module="/app.js" data-hemx-sse="/events">
<button data-hemx-handle="save" data-hemx-policy="latest" data-hemx-on="click change" data-hemx-confirm="Save?" data-hemx-delay="250ms" data-hemx-throttle="1s">Save</button>
<button data-hemx-client="save" data-hemx-client-event="click" data-hemx-client-policy="drop" data-hemx-client-state-version="1">Client</button>
<form method="get" action="/search" data-hemx-history="replace"><input name="q"></form>
</main>
"#;
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#"<button data-hemx-nav="" data-hemx-handle="go">Go</button>"#,
"data-hemx-nav",
"expected a real `<a href=...>` link so navigation works without JavaScript",
),
(
"nav-missing-href",
r#"<a data-hemx-nav="">Docs</a>"#,
"data-hemx-nav",
"expected a real `<a href=...>` link so navigation works without JavaScript",
),
(
"nav-empty-href",
r#"<a href=" " data-hemx-nav="">Docs</a>"#,
"data-hemx-nav",
"expected a real `<a href=...>` link so navigation works without JavaScript",
),
(
"boost-anchor",
r#"<a href="/docs" data-hemx-boost="">Docs</a>"#,
"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#"<form data-hemx-boost=""><input name="q"></form>"#,
"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#"<section data-hemx-root="app"><div data-hemx-client-module="/app.js"></div></section>"#,
"data-hemx-client-module",
"expected placement on the same element as `data-hemx-root`",
),
(
"sse-child",
r#"<section data-hemx-root="feed"><div data-hemx-sse="/events"></div></section>"#,
"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#"<a href="/docs" data-hemx-nav="">Docs</a>"#),
(
"boost-container",
r#"<nav data-hemx-boost=""><a href="/docs">Docs</a></nav>"#,
),
(
"client-module-root",
r#"<main data-hemx-root="app" data-hemx-client-module="/app.js"></main>"#,
),
(
"sse-root",
r#"<main data-hemx-root="feed" data-hemx-sse="/events"></main>"#,
),
] {
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#"<form data-hemx-handle="create"><input name="title"></form><li data-hemx-slot="row" data-hemx-key="7"></li>"#,
)
.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<T>(::std::marker::PhantomData<T>);
impl<T> Slot<T> {{ 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<SafeHtml>) -> Effect {{ Effect }} pub fn text(self, _: impl ::std::string::ToString) -> Effect {{ Effect }} }}
#[derive(Clone, Copy)] pub struct KeyedSlot<K, T>(::std::marker::PhantomData<(K, T)>);
impl<K, T> KeyedSlot<K, T> {{ 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<SafeHtml>) -> Effect {{ Effect }} pub fn prepend_html(self, _: K, _: impl ::std::convert::Into<SafeHtml>) -> Effect {{ Effect }} pub fn replace_html(self, _: K, _: impl ::std::convert::Into<SafeHtml>) -> 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<T>(::std::marker::PhantomData<T>);
impl<T> Handle<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct Atom<T>(::std::marker::PhantomData<T>);
impl<T> Atom<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct Form<T>(::std::marker::PhantomData<T>);
impl<T> Form<T> {{ 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<String>) -> Self {{ Self(html.into()) }} }}
pub struct Html(SafeHtml);
impl ::std::convert::From<Html> for SafeHtml {{ fn from(value: Html) -> Self {{ value.0 }} }}
impl ::std::convert::AsRef<str> for Html {{ fn as_ref(&self) -> &str {{ "" }} }}
pub mod __private {{ pub fn html_trusted(html: impl Into<String>) -> 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#"<form data-hemx-handle="create"><input name="title"></form><li data-hemx-slot="row" data-hemx-key="7"></li>"#);
assert!(html.contains(r#"data-hid="#));
assert!(html.contains(r#"name="__h""#));
assert!(html.contains(r#"data-key="7""#));
assert!(!html.contains("data-hemx-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()))
}
}