diff --git a/PLAN.md b/PLAN.md index 1b9d736..005ebb7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -20,17 +20,11 @@ Blocked by: none ## HMX-M03 — Isolate artifact emission and diagnostics - -Outcome: generated Rust, metadata, lowering tables, and contract diagnostics are emitted through one private boundary behind the public builder. +Outcome: generated artifacts and contract diagnostics are privately owned without byte, ordering, fingerprint, or diagnostic drift. Delta: architecture/003 assurance/001 -Path: validated template and Rust facts -> stable IDs/metadata -> generated files -> downstream compilation diagnostics. -Build: move artifact assembly and diagnostic formatting as one responsibility; keep public entry points and no-op write behavior in place. -Risk: byte, ordering, fingerprint, or diagnostic drift breaks downstream builds despite compiling locally. -Checks: `cargo test -p hemx-build`; exact generated API/diagnostic/fingerprint fixtures; no-op rewrite check; package archive checks; `redgate check`. -Non-goals: a new IR, new serialization, public API changes, or formatting-only rewrites. -Residual risk: this is the broadest movement slice and requires fresh-context review after integrated proof. -State: Draft -Blocked by: HMX-M02 +Checks: exact generated API/diagnostic/fingerprint/no-op fixtures; package checks; Redgate; diff check; fresh review. +State: Done +Blocked by: none ## HMX-M04 — Localize Axum interaction forms diff --git a/hemx-build/src/artifact.rs b/hemx-build/src/artifact.rs new file mode 100644 index 0000000..335daac --- /dev/null +++ b/hemx-build/src/artifact.rs @@ -0,0 +1,1479 @@ +use super::*; + +pub(super) 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) +} + +#[derive(Default)] +pub(super) struct Resources { + pub(super) slots: BTreeMap, + pub(super) handles: BTreeMap, + pub(super) handle_forms: BTreeMap, + pub(super) handle_params: BTreeMap>, + pub(super) forms: BTreeMap, + pub(super) atoms: BTreeMap, + pub(super) classes: BTreeMap, + pub(super) events: BTreeMap, +} + +#[derive(Clone, Debug)] +pub(super) struct Resource { + pub(super) symbol: String, + pub(super) ident: String, + pub(super) component: String, + pub(super) keyed: bool, + pub(super) id: u32, +} + +#[derive(Clone, Debug)] +pub(super) struct FormResource { + pub(super) resource: Resource, + pub(super) controls: Vec, +} + +#[derive(Clone, Debug)] +pub(super) struct GeneratedControl { + pub(super) name: String, + pub(super) kind: ControlKind, + pub(super) required: bool, +} + +#[derive(Clone, Debug)] +pub(super) struct ClassToken { + pub(super) symbol: String, + pub(super) ident: String, + pub(super) component: String, + pub(super) token: String, +} + +#[derive(Clone, Debug)] +pub(super) struct EventToken { + pub(super) symbol: String, + pub(super) ident: String, + pub(super) component: String, + pub(super) name: String, +} + +impl Resources { + pub(super) 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(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(()) + } + + pub(super) 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(()) + } + + pub(super) fn insert_handle( + &mut self, + symbol: String, + name: String, + component: String, + ) -> io::Result<()> { + insert_resource(&mut self.handles, "handle", symbol, name, component).map(|_| ()) + } + + pub(super) fn insert_atom( + &mut self, + symbol: String, + name: String, + component: String, + ) -> io::Result<()> { + insert_resource(&mut self.atoms, "atom", symbol, name, component).map(|_| ()) + } + + pub(super) 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(()) + } + + pub(super) 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(()) + } + + pub(super) 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, + }); + } + + pub(super) fn insert_form( + &mut self, + symbol: String, + name: String, + component: String, + controls: Vec, + ) -> io::Result<()> { + let controls = controls + .into_iter() + .filter(|control| control.name != "__h") + .collect(); + let resource = make_resource("form", symbol, name, component)?; + match self.forms.get(&resource.ident) { + Some(existing) if existing.resource.symbol != resource.symbol => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "duplicate generated identifier `{}` for `{}` and `{}`", + resource.ident, existing.resource.symbol, resource.symbol + ), + )), + Some(_) => Ok(()), + None => { + self.forms + .insert(resource.ident.clone(), FormResource { resource, controls }); + Ok(()) + } + } + } + + pub(super) 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(()) + } + + pub(super) fn generated_rs(&self, global_exports: bool) -> String { + let mut out = String::new(); + out.push_str("// @generated by hemx-build. Do not edit.\n"); + out.push_str(&format!( + "pub const BUILD_FINGERPRINT: ::hemx::advanced::BuildFingerprint = ::hemx::advanced::BuildFingerprint::from_parts(&[{}]);\n\n", + self.fingerprint_parts() + .into_iter() + .map(|part| part.to_string()) + .collect::>() + .join(", ") + )); + + let component_names: BTreeSet = self.component_names().into_iter().collect(); + + let root_indent = usize::default(); + if global_exports { + self.push_component_refs(&mut out); + self.push_resource_modules(&mut out, &component_names, None, root_indent); + } + self.push_lowering_api(&mut out, None, root_indent); + + for component in self.component_names() { + out.push_str("\n#[allow(non_upper_case_globals)]\n"); + out.push_str(&format!("pub mod {component} {{\n")); + out.push_str(" #[derive(Clone, Copy)]\n"); + out.push_str(" pub struct Component;\n\n"); + self.push_resource_modules(&mut out, &component_names, Some(&component), 1); + self.push_lowering_api(&mut out, Some(&component), 1); + out.push_str("}\n"); + } + + self.push_lowering_helpers(&mut out); + out + } + + pub(super) 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"); + } + + pub(super) fn push_resource_modules( + &self, + out: &mut String, + components: &BTreeSet, + component: Option<&str>, + indent: usize, + ) { + let pad = " ".repeat(indent); + let inner = " ".repeat(indent + 1); + let mut handle_ids = Vec::new(); + let mut root_exports = BTreeSet::new(); + + if let Some(component) = component { + out.push_str(&format!( + "{pad}pub const COMPONENT: ::hemx::ComponentRef = ::hemx::ComponentRef::new({});\n", + rust_str(component) + )); + } + + out.push_str(&format!("{pad}#[doc(hidden)]\n{pad}pub mod advanced {{\n")); + out.push_str(&format!( + "{inner}#[allow(non_upper_case_globals)]\n{inner}pub mod slots {{\n" + )); + let slot_inner = " ".repeat(indent + 2); + for res in self + .slots + .values() + .filter(|res| component_matches(res, component)) + { + if res.keyed { + out.push_str(&format!( + "{slot_inner}pub const {}: ::hemx::advanced::KeyedSlot<::std::string::String, ::std::string::String> = ::hemx::advanced::KeyedSlot::new({});\n", + res.ident, res.id + )); + } else { + out.push_str(&format!( + "{slot_inner}pub const {}: ::hemx::advanced::Slot<::std::string::String> = ::hemx::advanced::Slot::new({});\n", + res.ident, res.id + )); + } + } + out.push_str(&format!("{inner}}}\n")); + out.push_str(&format!("{pad}}}\n\n")); + + out.push_str(&format!( + "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod targets {{\n" + )); + let child_prefix = if component.is_some() { + "super::super::" + } else { + "super::" + }; + out.push_str(&format!("{inner}#[derive(Clone, Copy)]\n")); + out.push_str(&format!( + "{inner}pub struct SlotTarget {{ slot: ::hemx::advanced::Slot, _marker: ::std::marker::PhantomData }}\n" + )); + out.push_str(&format!("{inner}#[allow(dead_code)]\n")); + out.push_str(&format!("{inner}impl SlotTarget {{\n")); + out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::Slot) -> Self {{ Self {{ slot, _marker: ::std::marker::PhantomData }} }}\n")); + out.push_str(&format!("{inner} pub fn text(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.text(value) }}\n")); + out.push_str(&format!("{inner} pub fn set(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.text(value) }}\n")); + out.push_str(&format!("{inner}}}\n")); + out.push_str(&format!("{inner}impl SlotTarget {{\n")); + out.push_str(&format!("{inner} pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n")); + out.push_str(&format!("{inner} pub fn replace(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n")); + out.push_str(&format!("{inner}}}\n")); + for res in self + .slots + .values() + .filter(|res| component_matches(res, component)) + .filter(|res| components.contains(&res.ident) && Some(res.ident.as_str()) != component) + { + let child = &res.ident; + out.push_str(&format!( + "{inner}impl SlotTarget {{\n" + )); + out.push_str(&format!("{inner} pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ {child_prefix}{child}::put(self.slot, view) }}\n")); + out.push_str(&format!("{inner} pub fn replace(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ {child_prefix}{child}::put(self.slot, view) }}\n")); + out.push_str(&format!("{inner}}}\n")); + } + out.push_str(&format!( + "{inner}impl ::hemx::GeneratedTarget for SlotTarget {{\n" + )); + out.push_str(&format!("{inner} fn __hemx_resource_id(self) -> ::hemx::advanced::ResourceId {{ self.slot.id() }}\n")); + out.push_str(&format!("{inner}}}\n")); + out.push_str(&format!("{inner}#[derive(Clone, Copy)]\n")); + out.push_str(&format!("{inner}pub struct KeyedSlotTarget {{ slot: ::hemx::advanced::KeyedSlot, _marker: ::std::marker::PhantomData }}\n")); + out.push_str(&format!("{inner}#[allow(dead_code)]\n")); + out.push_str(&format!("{inner}impl KeyedSlotTarget\n")); + out.push_str(&format!("{inner}where\n")); + out.push_str(&format!("{inner} K: ::std::string::ToString,\n")); + out.push_str(&format!("{inner}{{\n")); + out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::KeyedSlot) -> Self {{ Self {{ slot, _marker: ::std::marker::PhantomData }} }}\n")); + out.push_str(&format!("{inner}}}\n")); + out.push_str(&format!( + "{inner}impl KeyedSlotTarget {{\n" + )); + out.push_str(&format!("{inner} pub fn move_before(self, key: K, before: K) -> ::hemx::advanced::Effect {{ self.slot.move_before(key, before) }}\n")); + out.push_str(&format!("{inner} pub fn move_to_end(self, key: K) -> ::hemx::advanced::Effect {{ self.slot.move_to_end(key) }}\n")); + out.push_str(&format!("{inner} pub fn remove_key(self, key: K) -> ::hemx::advanced::Effect {{ self.slot.remove(key) }}\n")); + out.push_str(&format!("{inner}}}\n")); + out.push_str(&format!( + "{inner}impl KeyedSlotTarget<::std::string::String, T, ()> {{\n" + )); + out.push_str(&format!("{inner} pub fn append(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.append_html(view.hemx_key(), super::render(&view)) }}\n")); + out.push_str(&format!("{inner} pub fn prepend(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(view.hemx_key(), super::render(&view)) }}\n")); + out.push_str(&format!("{inner} pub fn replace(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.replace_html(view.hemx_key(), super::render(&view)) }}\n")); + out.push_str(&format!("{inner} pub fn append_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.append_html(key.to_string(), super::render(view)) }}\n")); + out.push_str(&format!("{inner} pub fn prepend_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(key.to_string(), super::render(view)) }}\n")); + out.push_str(&format!("{inner} pub fn replace_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.replace_html(key.to_string(), super::render(view)) }}\n")); + out.push_str(&format!("{inner} pub fn remove(self, key: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.remove(key.to_string()) }}\n")); + out.push_str(&format!("{inner}}}\n")); + for res in self + .slots + .values() + .filter(|res| component_matches(res, component)) + .filter(|res| res.keyed) + .filter(|res| components.contains(&res.ident) && Some(res.ident.as_str()) != component) + { + let child = &res.ident; + out.push_str(&format!( + "{inner}impl KeyedSlotTarget<::std::string::String, T, {child_prefix}{child}::Component> {{\n" + )); + out.push_str(&format!("{inner} pub fn append(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.append_html(view.hemx_key(), {child_prefix}{child}::render(&view)) }}\n")); + out.push_str(&format!("{inner} pub fn prepend(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(view.hemx_key(), {child_prefix}{child}::render(&view)) }}\n")); + out.push_str(&format!("{inner} pub fn replace(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.replace_html(view.hemx_key(), {child_prefix}{child}::render(&view)) }}\n")); + out.push_str(&format!("{inner} pub fn append_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.append_html(key.to_string(), {child_prefix}{child}::render(view)) }}\n")); + out.push_str(&format!("{inner} pub fn prepend_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(key.to_string(), {child_prefix}{child}::render(view)) }}\n")); + out.push_str(&format!("{inner} pub fn replace_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.replace_html(key.to_string(), {child_prefix}{child}::render(view)) }}\n")); + out.push_str(&format!("{inner} pub fn remove(self, key: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.remove(key.to_string()) }}\n")); + out.push_str(&format!("{inner}}}\n")); + } + out.push_str(&format!( + "{inner}impl ::hemx::GeneratedTarget for KeyedSlotTarget {{\n" + )); + out.push_str(&format!("{inner} fn __hemx_resource_id(self) -> ::hemx::advanced::ResourceId {{ self.slot.id() }}\n")); + out.push_str(&format!("{inner}}}\n")); + for res in self + .slots + .values() + .filter(|res| component_matches(res, component)) + { + let marker = if components.contains(&res.ident) && Some(res.ident.as_str()) != component + { + format!("{child_prefix}{}::Component", res.ident) + } else { + "()".to_string() + }; + if res.keyed { + out.push_str(&format!( + "{inner}pub const {}: KeyedSlotTarget<::std::string::String, ::std::string::String, {marker}> = KeyedSlotTarget::new(super::advanced::slots::{});\n", + res.ident, res.ident + )); + } else { + out.push_str(&format!( + "{inner}pub const {}: SlotTarget<::std::string::String, {marker}> = SlotTarget::new(super::advanced::slots::{});\n", + res.ident, res.ident + )); + } + } + out.push_str(&format!("{pad}}}\n")); + for res in self + .slots + .values() + .filter(|res| component_matches(res, component)) + { + root_exports.insert(res.ident.clone()); + out.push_str(&format!("{pad}pub use self::targets::{};\n", res.ident)); + } + out.push('\n'); + + out.push_str(&format!( + "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod handles {{\n" + )); + for res in self + .handles + .values() + .filter(|res| component_matches(res, component)) + { + handle_ids.push(res.id); + let input = if self.handle_forms.contains_key(&res.ident) { + "::hemx::Form<::std::string::String>" + } else { + "()" + }; + out.push_str(&format!( + "{inner}pub const {}: ::hemx::Handle<{input}> = ::hemx::Handle::new({});\n", + res.ident, res.id + )); + } + out.push_str(&format!( + "{inner}pub const ALL_IDS: &[u32] = &[{}];\n", + handle_ids + .into_iter() + .map(|id| id.to_string()) + .collect::>() + .join(", ") + )); + out.push_str(&format!("{pad}}}\n")); + for res in self + .handles + .values() + .filter(|res| component_matches(res, component)) + { + push_root_export( + out, + &pad, + "handles", + &mut root_exports, + &res.ident, + "handle", + ); + } + out.push('\n'); + + out.push_str(&format!( + "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod params {{\n" + )); + let mut emitted_params = BTreeSet::new(); + for res in self + .handles + .values() + .filter(|res| component_matches(res, component)) + { + if let Some(params) = self.handle_params.get(&res.ident) { + for param in params { + if emitted_params.insert(param.as_str()) { + out.push_str(&format!( + "{inner}pub const {param}: ::hemx::ParamName = ::hemx::ParamName::new({});\n", + rust_str(param) + )); + } + } + } + } + out.push_str(&format!("{pad}}}\n\n")); + + out.push_str(&format!( + "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod atoms {{\n" + )); + for res in self + .atoms + .values() + .filter(|res| component_matches(res, component)) + { + out.push_str(&format!( + "{inner}pub const {}: ::hemx::Atom<::std::string::String> = ::hemx::Atom::new({});\n", + res.ident, res.id + )); + } + out.push_str(&format!("{pad}}}\n\n")); + + out.push_str(&format!( + "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod classes {{\n" + )); + let mut emitted_classes = BTreeSet::new(); + for class in self + .classes + .values() + .filter(|class| class_matches(class, component)) + { + if !emitted_classes.insert(class.ident.as_str()) { + continue; + } + out.push_str(&format!( + "{inner}pub const {}: ::hemx::CssClass = ::hemx::CssClass::new({});\n", + class.ident, + rust_str(&class.token) + )); + } + out.push_str(&format!("{pad}}}\n\n")); + + out.push_str(&format!( + "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod events {{\n" + )); + let mut emitted_events = BTreeSet::new(); + for event in self + .events + .values() + .filter(|event| event_matches(event, component)) + { + if !emitted_events.insert(event.ident.as_str()) { + continue; + } + out.push_str(&format!( + "{inner}pub const {}: ::hemx::EventName = ::hemx::EventName::new({});\n", + event.ident, + rust_str(&event.name) + )); + } + out.push_str(&format!("{pad}}}\n\n")); + + out.push_str(&format!( + "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod forms {{\n" + )); + for form in self + .forms + .values() + .filter(|form| component_matches(&form.resource, component)) + { + let res = &form.resource; + out.push_str(&format!( + "{inner}pub const {}: ::hemx::Form<::std::string::String> = ::hemx::Form::new({});\n", + res.ident, res.id + )); + out.push_str(&format!( + "{inner}pub const {}_CONTRACT: ::hemx::FormContract = ::hemx::FormContract {{ fields: {}_FIELDS }};\n", + res.ident.to_ascii_uppercase(), res.ident.to_ascii_uppercase() + )); + out.push_str(&format!( + "{inner}pub const {}_FIELDS: &[::hemx::FormField] = &[\n", + res.ident.to_ascii_uppercase() + )); + for control in &form.controls { + out.push_str(&format!( + "{inner} ::hemx::FormField {{ name: {}, kind: {}, required: {} }},\n", + rust_str(&control.name), + form_control_kind_expr(&control.kind), + control.required + )); + } + out.push_str(&format!("{inner}];\n")); + } + out.push_str(&format!("{pad}}}\n")); + for form in self + .forms + .values() + .filter(|form| component_matches(&form.resource, component)) + { + push_root_export( + out, + &pad, + "forms", + &mut root_exports, + &form.resource.ident, + "form", + ); + } + } + + pub(super) fn push_lowering_api( + &self, + out: &mut String, + component: Option<&str>, + indent: usize, + ) { + let pad = " ".repeat(indent); + let table_name = match component { + Some(component) => format!("__HEMX_LOWERING_TABLE_{}", component.to_ascii_uppercase()), + None => "__HEMX_LOWERING_TABLE".to_string(), + }; + out.push_str(&format!("\n{pad}pub fn lower(html: impl ::std::convert::AsRef) -> ::std::string::String {{\n")); + out.push_str(&format!("{pad} ")); + if component.is_some() { + out.push_str("super::"); + } + out.push_str(&format!("__hemx_lower_html(html.as_ref(), {table_name})\n")); + out.push_str(&format!("{pad}}}\n\n")); + out.push_str(&format!("{pad}#[doc(hidden)]\n")); + out.push_str(&format!("{pad}pub fn lower_html(html: impl ::std::convert::AsRef) -> ::std::string::String {{ lower(html) }}\n\n")); + out.push_str(&format!( + "{pad}pub fn static_fragment(html: &'static str) -> ::hemx::Html {{\n" + )); + out.push_str(&format!( + "{pad} ::hemx::__private::html_trusted(lower(html))\n" + )); + out.push_str(&format!("{pad}}}\n\n")); + out.push_str(&format!("{pad}#[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}pub fn page(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{ render(view) }}\n\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}#[doc(hidden)]\n")); + out.push_str(&format!("{pad}pub fn put(slot: ::hemx::advanced::Slot, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{\n")); + out.push_str(&format!("{pad} slot.html(render(view))\n")); + out.push_str(&format!("{pad}}}\n\n")); + out.push_str(&format!("{pad}#[doc(hidden)]\n")); + out.push_str(&format!("{pad}pub fn append(slot: ::hemx::advanced::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\n")); + out.push_str(&format!("{pad}where\n")); + out.push_str(&format!("{pad} K: ::std::string::ToString,\n")); + out.push_str(&format!("{pad}{{\n")); + out.push_str(&format!("{pad} slot.append_html(key, render(view))\n")); + out.push_str(&format!("{pad}}}\n\n")); + out.push_str(&format!("{pad}#[doc(hidden)]\n")); + out.push_str(&format!("{pad}pub fn prepend(slot: ::hemx::advanced::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\n")); + out.push_str(&format!("{pad}where\n")); + out.push_str(&format!("{pad} K: ::std::string::ToString,\n")); + out.push_str(&format!("{pad}{{\n")); + out.push_str(&format!("{pad} slot.prepend_html(key, render(view))\n")); + out.push_str(&format!("{pad}}}\n\n")); + out.push_str(&format!("{pad}#[doc(hidden)]\n")); + out.push_str(&format!("{pad}pub fn replace(slot: ::hemx::advanced::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\n")); + out.push_str(&format!("{pad}where\n")); + out.push_str(&format!("{pad} K: ::std::string::ToString,\n")); + out.push_str(&format!("{pad}{{\n")); + out.push_str(&format!("{pad} slot.replace_html(key, render(view))\n")); + out.push_str(&format!("{pad}}}\n\n")); + self.push_lowering_table(out, component, indent, &table_name); + } + + pub(super) 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")); + } + + pub(super) 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-hemx-resource", + "data-hemx-handle" => "data-hemx-action", + "data-hemx-form" => "data-hemx-resource", + "data-hemx-revealed" => "data-hemx-revealed", + "data-hemx-atom" => "data-hemx-resource", + _ => continue, + }; + out = __hemx_replace_attr(out, attr, name, runtime_attr, *id); + } + out = __hemx_lower_static_attr(out, "data-hemx-key", "data-hemx-key"); + out = __hemx_lower_static_attr(out, "h-key", "data-hemx-key"); + __hemx_inject_handle_inputs(out) +} + +fn __hemx_replace_attr(mut html: ::std::string::String, attr: &str, name: &str, runtime_attr: &str, id: u32) -> ::std::string::String { + let replacement = if runtime_attr == "value" { + ::std::format!("{attr}=\"{name}\" {runtime_attr}=\"{id}\"") + } else { + ::std::format!("{runtime_attr}=\"{id}\"") + }; + let double = ::std::format!("{attr}=\"{name}\""); + html = html.replace(&double, &replacement); + let single_replacement = replacement.replace('"', "'"); + let single = ::std::format!("{attr}='{name}'"); + html.replace(&single, &single_replacement) +} + +fn __hemx_lower_static_attr(mut html: ::std::string::String, attr: &str, runtime_attr: &str) -> ::std::string::String { + html = html.replace(&::std::format!("{attr}=\""), &::std::format!("{runtime_attr}=\"")); + html.replace(&::std::format!("{attr}='"), &::std::format!("{runtime_attr}='")) +} + +fn __hemx_inject_handle_inputs(html: ::std::string::String) -> ::std::string::String { + let mut out = ::std::string::String::with_capacity(html.len()); + let mut rest = html.as_str(); + while let Some(start) = rest.find("') else { + out.push_str(rest); + return out; + }; + let opening = &rest[..=open_end]; + out.push_str(opening); + rest = &rest[open_end + 1..]; + + let Some(handle_id) = __hemx_attr(opening, "data-hemx-action") else { + continue; + }; + let form_body_end = rest.find("").unwrap_or(rest.len()); + let form_body = &rest[..form_body_end]; + if !__hemx_has_handle_input(form_body) { + out.push_str(&::std::format!("", handle_id)); + } + } + out.push_str(rest); + out +} + +fn __hemx_has_handle_input(html: &str) -> bool { + html.contains("name=\"__h\"") || html.contains("name='__h'") +} + +fn __hemx_attr(tag: &str, attr: &str) -> Option<::std::string::String> { + let attr_at = tag.find(attr)?; + let after_attr = &tag[attr_at + attr.len()..]; + let after_equals = after_attr.trim_start().strip_prefix('=')?.trim_start(); + let quote = after_equals.chars().next()?; + if quote != '\"' && quote != '\'' { + return None; + } + let value = &after_equals[quote.len_utf8()..]; + let end = value.find(quote)?; + Some(value[..end].to_owned()) +} +"#); + } + + pub(super) fn component_names(&self) -> Vec { + let mut components = Vec::new(); + for component in self + .slots + .values() + .map(|res| &res.component) + .chain(self.handles.values().map(|res| &res.component)) + .chain(self.atoms.values().map(|res| &res.component)) + .chain(self.forms.values().map(|form| &form.resource.component)) + .chain(self.classes.values().map(|class| &class.component)) + .chain(self.events.values().map(|event| &event.component)) + { + if !components.contains(component) { + components.push(component.clone()); + } + } + components.sort(); + components + } + + pub(super) 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 + } + + pub(super) fn fingerprint_parts(&self) -> Vec { + let mut parts = vec![ + SURFACE_SCHEMA_VERSION, + EFFECT_BATCH_ABI_VERSION, + RUNTIME_ABI_VERSION, + ]; + + for res in self.slots.values() { + parts.push(0); + parts.push(res.id); + } + for res in self.handles.values() { + parts.push(1); + parts.push(res.id); + } + for res in self.atoms.values() { + parts.push(3); + parts.push(res.id); + } + for form in self.forms.values() { + parts.push(2); + parts.push(form.resource.id); + parts.push(form.controls.len() as u32); + for control in &form.controls { + parts.push(stable_id("form-field", &control.name)); + parts.push(control.required as u32); + } + } + + parts + } +} +pub(super) fn insert_resource<'a>( + map: &'a mut BTreeMap, + kind: &str, + symbol: String, + name: String, + component: String, +) -> io::Result<&'a mut Resource> { + let resource = make_resource(kind, symbol, name, component)?; + match map.entry(resource.ident.clone()) { + std::collections::btree_map::Entry::Occupied(entry) + if entry.get().symbol != resource.symbol => + { + let existing = entry.get(); + Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "duplicate generated identifier `{}` for `{}` and `{}`", + resource.ident, existing.symbol, resource.symbol + ), + )) + } + std::collections::btree_map::Entry::Occupied(entry) => Ok(entry.into_mut()), + std::collections::btree_map::Entry::Vacant(entry) => Ok(entry.insert(resource)), + } +} + +pub(super) fn make_resource( + kind: &str, + symbol: String, + name: String, + component: String, +) -> io::Result { + let ident = rust_ident(&name).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid hemx {kind} name `{name}`; expected a Rust identifier"), + ) + })?; + let id = stable_id(kind, &symbol); + Ok(Resource { + symbol, + ident, + component, + keyed: false, + id, + }) +} + +pub(super) fn collect_input_files(root: &Path) -> io::Result> { + let mut paths = Vec::new(); + if !root.exists() { + return Ok(paths); + } + collect_input_files_into(root, &mut paths)?; + paths.sort(); + Ok(paths) +} + +pub(super) fn collect_input_files_into(dir: &Path, paths: &mut Vec) -> io::Result<()> { + for entry in std::fs::read_dir(dir)? { + let path = entry?.path(); + if path.is_dir() { + collect_input_files_into(&path, paths)?; + } else { + paths.push(path); + } + } + Ok(()) +} + +pub(super) fn static_attr(attrs: &[SurfaceAttribute], name: &str) -> Option { + attrs + .iter() + .find(|attr| attr.origin == AttributeOrigin::Static && attr.name == name) + .and_then(|attr| attr.value.clone()) +} + +pub(super) fn push_root_export( + out: &mut String, + pad: &str, + module: &str, + used: &mut BTreeSet, + ident: &str, + suffix: &str, +) { + if used.insert(ident.to_owned()) { + out.push_str(&format!("{pad}pub use self::{module}::{ident};\n")); + return; + } + let alias = format!("{ident}_{suffix}"); + if used.insert(alias.clone()) { + out.push_str(&format!( + "{pad}pub use self::{module}::{ident} as {alias};\n" + )); + } +} + +pub(super) fn component_matches(res: &Resource, component: Option<&str>) -> bool { + match component { + Some(component) => res.component == component, + None => true, + } +} + +pub(super) fn class_matches(class: &ClassToken, component: Option<&str>) -> bool { + match component { + Some(component) => class.component == component, + None => true, + } +} + +pub(super) fn event_matches(event: &EventToken, component: Option<&str>) -> bool { + match component { + Some(component) => event.component == component, + None => true, + } +} + +pub(super) fn class_tokens(value: &str) -> impl Iterator { + value + .split_ascii_whitespace() + .filter(|token| !token.is_empty()) +} + +pub(super) fn event_tokens(value: &str) -> impl Iterator { + value + .split_ascii_whitespace() + .filter(|token| !token.is_empty()) +} + +pub(super) 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 +} + +pub(super) 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 +} + +pub(super) fn is_class_start(byte: u8) -> bool { + byte == b'_' || byte == b'-' || byte.is_ascii_alphabetic() +} + +pub(super) fn is_class_continue(byte: u8) -> bool { + is_class_start(byte) || byte.is_ascii_digit() +} + +pub(super) fn class_ident(token: &str) -> Option { + rust_ident(&token.replace('-', "_")) +} + +pub(super) 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-") +} + +pub(super) fn data_param_ident(name: &str) -> Option { + let data_name = name.strip_prefix("data-")?; + rust_ident(&data_name.replace('-', "_")) +} + +pub(super) fn can_host_keyed_collection(tag: &str) -> bool { + matches!( + tag, + "ul" | "ol" | "tbody" | "thead" | "tfoot" | "table" | "select" | "datalist" + ) +} + +pub(super) 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) + }) +} + +pub(super) 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; + } +} + +pub(super) 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; + } +} + +pub(super) fn unkeyed_generated_target_diagnostics( + path: &Path, + surface: &SurfaceDocument, +) -> Vec { + let mut diagnostics = Vec::new(); + for (node_scope, target) in generated_targets_with_scope(surface) { + if let Some(diagnostic) = unkeyed_generated_target_diagnostic_for_scope( + surface, + node_scope, + path, + &target.kind, + &target.name, + ) { + diagnostics.push(diagnostic); + } + } + diagnostics +} + +pub(super) fn generated_targets(surface: &SurfaceDocument) -> Vec { + let mut targets = Vec::new(); + for (_, target) in generated_targets_with_scope(surface) { + if !targets.iter().any(|existing: &GeneratedTarget| { + existing.kind == target.kind && existing.name == target.name + }) { + targets.push(target); + } + } + targets +} + +pub(super) 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 +} + +pub(super) fn unkeyed_generated_target_diagnostic_for_scope( + surface: &SurfaceDocument, + mut scope: ScopeId, + path: &Path, + kind: &str, + name: &str, +) -> Option { + loop { + let current = surface.scopes.get(scope.0 as usize)?; + if let ScopeKind::For { + pattern, + expr, + key_expr: None, + } = ¤t.kind + { + return Some(unkeyed_generated_target_diagnostic( + path, kind, name, pattern, expr, + )); + } + let parent = current.parent?; + scope = parent; + } +} + +pub(super) 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-hemx-key on the child is rendered HTML, not the template fact hemx uses for generated targets" + ), + } +} + +pub(super) 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('\\', "/")) +} + +pub(super) fn component_ident(root: &Path, path: &Path) -> io::Result { + let rel = path.strip_prefix(root).unwrap_or(path); + let stem = rel + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid template path `{}`", path.display()), + ) + })?; + rust_ident(stem).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid template name `{stem}`; expected a Rust module identifier"), + ) + }) +} + +pub(super) fn rust_ident(name: &str) -> Option { + let mut chars = name.chars(); + let first = chars.next()?; + if !(first == '_' || first.is_ascii_alphabetic()) { + return None; + } + if chars + .clone() + .any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric())) + { + return None; + } + Some(name.to_string()) +} + +pub(super) 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 +} + +pub(super) fn form_control_is_multiple(kind: &ControlKind) -> bool { + matches!(kind, ControlKind::Select { multiple: true }) +} + +pub(super) 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()) + ), + } +} + +pub(super) fn rust_str(value: &str) -> String { + format!("{value:?}") +} + +pub(super) fn rust_str_opt(value: Option<&str>) -> String { + match value { + Some(value) => format!("Some({})", rust_str(value)), + None => "None".to_string(), + } +} + +pub(super) 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()), + ) +} diff --git a/hemx-build/src/authoring.rs b/hemx-build/src/authoring.rs index 9896526..a929b87 100644 --- a/hemx-build/src/authoring.rs +++ b/hemx-build/src/authoring.rs @@ -1,4 +1,5 @@ use super::*; +use crate::artifact::{event_tokens, static_attr, unkeyed_generated_target_diagnostic_for_scope}; pub(super) fn reject_selector_target_attrs( path: &Path, diff --git a/hemx-build/src/lib.rs b/hemx-build/src/lib.rs index 0d924c0..0530407 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -10,9 +10,14 @@ use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; +mod artifact; mod authoring; mod rust_source; +use artifact::{ + collect_input_files, generated_targets, parse_error, unkeyed_generated_target_diagnostics, + write_if_changed, Resources, +}; use authoring::{ reject_invalid_hemx_attr_placement, reject_invalid_hemx_attr_values, reject_selector_target_attrs, reject_unkeyed_loop, reject_unknown_hemx_attrs, @@ -245,16 +250,6 @@ impl AppBuilder { } } -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, @@ -263,1441 +258,10 @@ pub fn app() -> AppBuilder { surfaces: Vec::new(), } } - -#[derive(Default)] -struct Resources { - slots: BTreeMap, - handles: BTreeMap, - handle_forms: BTreeMap, - handle_params: BTreeMap>, - forms: BTreeMap, - atoms: BTreeMap, - classes: BTreeMap, - events: BTreeMap, -} - -#[derive(Clone, Debug)] -struct Resource { - symbol: String, - ident: String, - component: String, - keyed: bool, - id: u32, -} - -#[derive(Clone, Debug)] -struct FormResource { - resource: Resource, - controls: Vec, -} - -#[derive(Clone, Debug)] -struct GeneratedControl { - name: String, - kind: ControlKind, - required: bool, -} - -#[derive(Clone, Debug)] -struct ClassToken { - symbol: String, - ident: String, - component: String, - token: String, -} - -#[derive(Clone, Debug)] -struct EventToken { - symbol: String, - ident: String, - component: String, - name: String, -} - -impl Resources { - fn add_surface( - &mut self, - root: &Path, - path: &Path, - surface: &SurfaceDocument, - ) -> io::Result<()> { - let component = component_ident(root, path)?; - for node in &surface.nodes { - let SurfaceNodeKind::Element { tag } = &node.kind else { - continue; - }; - - reject_selector_target_attrs(path, &node.attrs)?; - reject_unknown_hemx_attrs(path, &node.attrs)?; - reject_invalid_hemx_attr_values(path, &node.attrs)?; - reject_invalid_hemx_attr_placement(path, tag, &node.attrs)?; - - if let Some(class_attr) = static_attr(&node.attrs, "class") { - for token in class_tokens(&class_attr) { - let canonical = canonical_symbol(root, path, token); - self.insert_class(canonical, token.to_owned(), component.clone())?; - } - } - - if let Some(on_attr) = static_attr(&node.attrs, "data-hemx-on") { - for event in event_tokens(&on_attr) { - let canonical = canonical_symbol(root, path, event); - self.insert_event(canonical, event.to_owned(), component.clone()); - } - } - - if let Some(name) = static_attr(&node.attrs, "data-hemx-slot") { - reject_unkeyed_loop(surface, node.scope, path, "slot", &name)?; - let keyed = is_inside_keyed_for(surface, node.scope) - || (can_host_keyed_collection(tag) - && has_descendant_keyed_for_scope(surface, node.scope)); - let canonical = canonical_symbol(root, path, &name); - self.insert_slot(canonical, name, component.clone(), keyed)?; - } - - if let Some(name) = static_attr(&node.attrs, "data-hemx-atom") { - reject_unkeyed_loop(surface, node.scope, path, "atom", &name)?; - let canonical = canonical_symbol(root, path, &name); - self.insert_atom(canonical, name, component.clone())?; - } - - if let Some(name) = static_attr(&node.attrs, "data-hemx-handle") { - reject_unkeyed_loop(surface, node.scope, path, "handle", &name)?; - let canonical = canonical_symbol(root, path, &name); - self.insert_handle(canonical, name.clone(), component.clone())?; - self.insert_handle_params(&name, &node.attrs)?; - - if tag == "form" { - let form_name = - static_attr(&node.attrs, "data-hemx-form").unwrap_or_else(|| name.clone()); - let controls = surface - .forms - .iter() - .find(|form| form.node == node.id) - .map(|form| { - form.controls - .iter() - .map(|control| GeneratedControl { - name: control.name.clone(), - kind: control.kind.clone(), - required: control.required, - }) - .collect() - }) - .unwrap_or_default(); - self.insert_form( - canonical_symbol(root, path, &form_name), - form_name.clone(), - component.clone(), - controls, - )?; - if let (Some(handle_ident), Some(form_ident)) = - (rust_ident(&name), rust_ident(&form_name)) - { - self.handle_forms.insert(handle_ident, form_ident); - } - } - } - } - Ok(()) - } - - fn insert_slot( - &mut self, - symbol: String, - name: String, - component: String, - keyed: bool, - ) -> io::Result<()> { - let slot = insert_resource(&mut self.slots, "slot", symbol, name, component)?; - slot.keyed |= keyed; - Ok(()) - } - - fn insert_handle(&mut self, symbol: String, name: String, component: String) -> io::Result<()> { - insert_resource(&mut self.handles, "handle", symbol, name, component).map(|_| ()) - } - - fn insert_atom(&mut self, symbol: String, name: String, component: String) -> io::Result<()> { - insert_resource(&mut self.atoms, "atom", symbol, name, component).map(|_| ()) - } - - fn insert_class(&mut self, symbol: String, token: String, component: String) -> io::Result<()> { - let ident = class_ident(&token).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - format!( - "invalid CSS class `{token}`; expected an ASCII class token usable from Rust" - ), - ) - })?; - let class = ClassToken { - symbol, - ident, - component, - token, - }; - if let Some(existing) = self - .classes - .values() - .find(|existing| existing.ident == class.ident && existing.token != class.token) - { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "duplicate generated class identifier `{}` for CSS classes `{}` and `{}`", - class.ident, existing.token, class.token - ), - )); - } - self.classes.entry(class.symbol.clone()).or_insert(class); - Ok(()) - } - - fn insert_handle_params(&mut self, handle: &str, attrs: &[SurfaceAttribute]) -> io::Result<()> { - let Some(handle_ident) = rust_ident(handle) else { - return Ok(()); - }; - for attr in attrs { - if !is_handle_param_attr(attr) { - continue; - } - let Some(param) = data_param_ident(&attr.name) else { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("invalid handler param attribute `{}`; expected data-* name usable from Rust", attr.name), - )); - }; - self.handle_params - .entry(handle_ident.clone()) - .or_default() - .insert(param); - } - Ok(()) - } - - fn insert_event(&mut self, symbol: String, name: String, component: String) { - self.events.entry(symbol.clone()).or_insert(EventToken { - symbol, - ident: name.clone(), - component, - name, - }); - } - - fn insert_form( - &mut self, - symbol: String, - name: String, - component: String, - controls: Vec, - ) -> io::Result<()> { - let controls = controls - .into_iter() - .filter(|control| control.name != "__h") - .collect(); - let resource = make_resource("form", symbol, name, component)?; - match self.forms.get(&resource.ident) { - Some(existing) if existing.resource.symbol != resource.symbol => Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "duplicate generated identifier `{}` for `{}` and `{}`", - resource.ident, existing.resource.symbol, resource.symbol - ), - )), - Some(_) => Ok(()), - None => { - self.forms - .insert(resource.ident.clone(), FormResource { resource, controls }); - Ok(()) - } - } - } - - fn add_stylesheet(&mut self, root: &Path, path: &Path, source: &str) -> io::Result<()> { - let component = component_ident(root, path)?; - for token in stylesheet_class_tokens(source) { - let canonical = canonical_symbol(root, path, token); - self.insert_class(canonical, token.to_owned(), component.clone())?; - } - Ok(()) - } - - fn generated_rs(&self, global_exports: bool) -> String { - let mut out = String::new(); - out.push_str("// @generated by hemx-build. Do not edit.\n"); - out.push_str(&format!( - "pub const BUILD_FINGERPRINT: ::hemx::advanced::BuildFingerprint = ::hemx::advanced::BuildFingerprint::from_parts(&[{}]);\n\n", - self.fingerprint_parts() - .into_iter() - .map(|part| part.to_string()) - .collect::>() - .join(", ") - )); - - let component_names: BTreeSet = self.component_names().into_iter().collect(); - - let root_indent = usize::default(); - if global_exports { - self.push_component_refs(&mut out); - self.push_resource_modules(&mut out, &component_names, None, root_indent); - } - self.push_lowering_api(&mut out, None, root_indent); - - for component in self.component_names() { - out.push_str("\n#[allow(non_upper_case_globals)]\n"); - out.push_str(&format!("pub mod {component} {{\n")); - out.push_str(" #[derive(Clone, Copy)]\n"); - out.push_str(" pub struct Component;\n\n"); - self.push_resource_modules(&mut out, &component_names, Some(&component), 1); - self.push_lowering_api(&mut out, Some(&component), 1); - out.push_str("}\n"); - } - - self.push_lowering_helpers(&mut out); - out - } - - fn push_component_refs(&self, out: &mut String) { - out.push_str("#[allow(non_upper_case_globals)]\npub mod components {\n"); - for component in self.component_names() { - out.push_str(&format!( - " pub const {component}: ::hemx::ComponentRef = ::hemx::ComponentRef::new({});\n", - rust_str(&component) - )); - } - out.push_str("}\n"); - } - - fn push_resource_modules( - &self, - out: &mut String, - components: &BTreeSet, - component: Option<&str>, - indent: usize, - ) { - let pad = " ".repeat(indent); - let inner = " ".repeat(indent + 1); - let mut handle_ids = Vec::new(); - let mut root_exports = BTreeSet::new(); - - if let Some(component) = component { - out.push_str(&format!( - "{pad}pub const COMPONENT: ::hemx::ComponentRef = ::hemx::ComponentRef::new({});\n", - rust_str(component) - )); - } - - out.push_str(&format!("{pad}#[doc(hidden)]\n{pad}pub mod advanced {{\n")); - out.push_str(&format!( - "{inner}#[allow(non_upper_case_globals)]\n{inner}pub mod slots {{\n" - )); - let slot_inner = " ".repeat(indent + 2); - for res in self - .slots - .values() - .filter(|res| component_matches(res, component)) - { - if res.keyed { - out.push_str(&format!( - "{slot_inner}pub const {}: ::hemx::advanced::KeyedSlot<::std::string::String, ::std::string::String> = ::hemx::advanced::KeyedSlot::new({});\n", - res.ident, res.id - )); - } else { - out.push_str(&format!( - "{slot_inner}pub const {}: ::hemx::advanced::Slot<::std::string::String> = ::hemx::advanced::Slot::new({});\n", - res.ident, res.id - )); - } - } - out.push_str(&format!("{inner}}}\n")); - out.push_str(&format!("{pad}}}\n\n")); - - out.push_str(&format!( - "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod targets {{\n" - )); - let child_prefix = if component.is_some() { - "super::super::" - } else { - "super::" - }; - out.push_str(&format!("{inner}#[derive(Clone, Copy)]\n")); - out.push_str(&format!( - "{inner}pub struct SlotTarget {{ slot: ::hemx::advanced::Slot, _marker: ::std::marker::PhantomData }}\n" - )); - out.push_str(&format!("{inner}#[allow(dead_code)]\n")); - out.push_str(&format!("{inner}impl SlotTarget {{\n")); - out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::Slot) -> Self {{ Self {{ slot, _marker: ::std::marker::PhantomData }} }}\n")); - out.push_str(&format!("{inner} pub fn text(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.text(value) }}\n")); - out.push_str(&format!("{inner} pub fn set(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.text(value) }}\n")); - out.push_str(&format!("{inner}}}\n")); - out.push_str(&format!("{inner}impl SlotTarget {{\n")); - out.push_str(&format!("{inner} pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n")); - out.push_str(&format!("{inner} pub fn replace(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n")); - out.push_str(&format!("{inner}}}\n")); - for res in self - .slots - .values() - .filter(|res| component_matches(res, component)) - .filter(|res| components.contains(&res.ident) && Some(res.ident.as_str()) != component) - { - let child = &res.ident; - out.push_str(&format!( - "{inner}impl SlotTarget {{\n" - )); - out.push_str(&format!("{inner} pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ {child_prefix}{child}::put(self.slot, view) }}\n")); - out.push_str(&format!("{inner} pub fn replace(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ {child_prefix}{child}::put(self.slot, view) }}\n")); - out.push_str(&format!("{inner}}}\n")); - } - out.push_str(&format!( - "{inner}impl ::hemx::GeneratedTarget for SlotTarget {{\n" - )); - out.push_str(&format!("{inner} fn __hemx_resource_id(self) -> ::hemx::advanced::ResourceId {{ self.slot.id() }}\n")); - out.push_str(&format!("{inner}}}\n")); - out.push_str(&format!("{inner}#[derive(Clone, Copy)]\n")); - out.push_str(&format!("{inner}pub struct KeyedSlotTarget {{ slot: ::hemx::advanced::KeyedSlot, _marker: ::std::marker::PhantomData }}\n")); - out.push_str(&format!("{inner}#[allow(dead_code)]\n")); - out.push_str(&format!("{inner}impl KeyedSlotTarget\n")); - out.push_str(&format!("{inner}where\n")); - out.push_str(&format!("{inner} K: ::std::string::ToString,\n")); - out.push_str(&format!("{inner}{{\n")); - out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::KeyedSlot) -> Self {{ Self {{ slot, _marker: ::std::marker::PhantomData }} }}\n")); - out.push_str(&format!("{inner}}}\n")); - out.push_str(&format!( - "{inner}impl KeyedSlotTarget {{\n" - )); - out.push_str(&format!("{inner} pub fn move_before(self, key: K, before: K) -> ::hemx::advanced::Effect {{ self.slot.move_before(key, before) }}\n")); - out.push_str(&format!("{inner} pub fn move_to_end(self, key: K) -> ::hemx::advanced::Effect {{ self.slot.move_to_end(key) }}\n")); - out.push_str(&format!("{inner} pub fn remove_key(self, key: K) -> ::hemx::advanced::Effect {{ self.slot.remove(key) }}\n")); - out.push_str(&format!("{inner}}}\n")); - out.push_str(&format!( - "{inner}impl KeyedSlotTarget<::std::string::String, T, ()> {{\n" - )); - out.push_str(&format!("{inner} pub fn append(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.append_html(view.hemx_key(), super::render(&view)) }}\n")); - out.push_str(&format!("{inner} pub fn prepend(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(view.hemx_key(), super::render(&view)) }}\n")); - out.push_str(&format!("{inner} pub fn replace(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.replace_html(view.hemx_key(), super::render(&view)) }}\n")); - out.push_str(&format!("{inner} pub fn append_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.append_html(key.to_string(), super::render(view)) }}\n")); - out.push_str(&format!("{inner} pub fn prepend_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(key.to_string(), super::render(view)) }}\n")); - out.push_str(&format!("{inner} pub fn replace_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.replace_html(key.to_string(), super::render(view)) }}\n")); - out.push_str(&format!("{inner} pub fn remove(self, key: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.remove(key.to_string()) }}\n")); - out.push_str(&format!("{inner}}}\n")); - for res in self - .slots - .values() - .filter(|res| component_matches(res, component)) - .filter(|res| res.keyed) - .filter(|res| components.contains(&res.ident) && Some(res.ident.as_str()) != component) - { - let child = &res.ident; - out.push_str(&format!( - "{inner}impl KeyedSlotTarget<::std::string::String, T, {child_prefix}{child}::Component> {{\n" - )); - out.push_str(&format!("{inner} pub fn append(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.append_html(view.hemx_key(), {child_prefix}{child}::render(&view)) }}\n")); - out.push_str(&format!("{inner} pub fn prepend(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(view.hemx_key(), {child_prefix}{child}::render(&view)) }}\n")); - out.push_str(&format!("{inner} pub fn replace(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.replace_html(view.hemx_key(), {child_prefix}{child}::render(&view)) }}\n")); - out.push_str(&format!("{inner} pub fn append_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.append_html(key.to_string(), {child_prefix}{child}::render(view)) }}\n")); - out.push_str(&format!("{inner} pub fn prepend_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(key.to_string(), {child_prefix}{child}::render(view)) }}\n")); - out.push_str(&format!("{inner} pub fn replace_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.replace_html(key.to_string(), {child_prefix}{child}::render(view)) }}\n")); - out.push_str(&format!("{inner} pub fn remove(self, key: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.remove(key.to_string()) }}\n")); - out.push_str(&format!("{inner}}}\n")); - } - out.push_str(&format!( - "{inner}impl ::hemx::GeneratedTarget for KeyedSlotTarget {{\n" - )); - out.push_str(&format!("{inner} fn __hemx_resource_id(self) -> ::hemx::advanced::ResourceId {{ self.slot.id() }}\n")); - out.push_str(&format!("{inner}}}\n")); - for res in self - .slots - .values() - .filter(|res| component_matches(res, component)) - { - let marker = if components.contains(&res.ident) && Some(res.ident.as_str()) != component - { - format!("{child_prefix}{}::Component", res.ident) - } else { - "()".to_string() - }; - if res.keyed { - out.push_str(&format!( - "{inner}pub const {}: KeyedSlotTarget<::std::string::String, ::std::string::String, {marker}> = KeyedSlotTarget::new(super::advanced::slots::{});\n", - res.ident, res.ident - )); - } else { - out.push_str(&format!( - "{inner}pub const {}: SlotTarget<::std::string::String, {marker}> = SlotTarget::new(super::advanced::slots::{});\n", - res.ident, res.ident - )); - } - } - out.push_str(&format!("{pad}}}\n")); - for res in self - .slots - .values() - .filter(|res| component_matches(res, component)) - { - root_exports.insert(res.ident.clone()); - out.push_str(&format!("{pad}pub use self::targets::{};\n", res.ident)); - } - out.push('\n'); - - out.push_str(&format!( - "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod handles {{\n" - )); - for res in self - .handles - .values() - .filter(|res| component_matches(res, component)) - { - handle_ids.push(res.id); - let input = if self.handle_forms.contains_key(&res.ident) { - "::hemx::Form<::std::string::String>" - } else { - "()" - }; - out.push_str(&format!( - "{inner}pub const {}: ::hemx::Handle<{input}> = ::hemx::Handle::new({});\n", - res.ident, res.id - )); - } - out.push_str(&format!( - "{inner}pub const ALL_IDS: &[u32] = &[{}];\n", - handle_ids - .into_iter() - .map(|id| id.to_string()) - .collect::>() - .join(", ") - )); - out.push_str(&format!("{pad}}}\n")); - for res in self - .handles - .values() - .filter(|res| component_matches(res, component)) - { - push_root_export( - out, - &pad, - "handles", - &mut root_exports, - &res.ident, - "handle", - ); - } - out.push('\n'); - - out.push_str(&format!( - "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod params {{\n" - )); - let mut emitted_params = BTreeSet::new(); - for res in self - .handles - .values() - .filter(|res| component_matches(res, component)) - { - if let Some(params) = self.handle_params.get(&res.ident) { - for param in params { - if emitted_params.insert(param.as_str()) { - out.push_str(&format!( - "{inner}pub const {param}: ::hemx::ParamName = ::hemx::ParamName::new({});\n", - rust_str(param) - )); - } - } - } - } - out.push_str(&format!("{pad}}}\n\n")); - - out.push_str(&format!( - "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod atoms {{\n" - )); - for res in self - .atoms - .values() - .filter(|res| component_matches(res, component)) - { - out.push_str(&format!( - "{inner}pub const {}: ::hemx::Atom<::std::string::String> = ::hemx::Atom::new({});\n", - res.ident, res.id - )); - } - out.push_str(&format!("{pad}}}\n\n")); - - out.push_str(&format!( - "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod classes {{\n" - )); - let mut emitted_classes = BTreeSet::new(); - for class in self - .classes - .values() - .filter(|class| class_matches(class, component)) - { - if !emitted_classes.insert(class.ident.as_str()) { - continue; - } - out.push_str(&format!( - "{inner}pub const {}: ::hemx::CssClass = ::hemx::CssClass::new({});\n", - class.ident, - rust_str(&class.token) - )); - } - out.push_str(&format!("{pad}}}\n\n")); - - out.push_str(&format!( - "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod events {{\n" - )); - let mut emitted_events = BTreeSet::new(); - for event in self - .events - .values() - .filter(|event| event_matches(event, component)) - { - if !emitted_events.insert(event.ident.as_str()) { - continue; - } - out.push_str(&format!( - "{inner}pub const {}: ::hemx::EventName = ::hemx::EventName::new({});\n", - event.ident, - rust_str(&event.name) - )); - } - out.push_str(&format!("{pad}}}\n\n")); - - out.push_str(&format!( - "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod forms {{\n" - )); - for form in self - .forms - .values() - .filter(|form| component_matches(&form.resource, component)) - { - let res = &form.resource; - out.push_str(&format!( - "{inner}pub const {}: ::hemx::Form<::std::string::String> = ::hemx::Form::new({});\n", - res.ident, res.id - )); - out.push_str(&format!( - "{inner}pub const {}_CONTRACT: ::hemx::FormContract = ::hemx::FormContract {{ fields: {}_FIELDS }};\n", - res.ident.to_ascii_uppercase(), res.ident.to_ascii_uppercase() - )); - out.push_str(&format!( - "{inner}pub const {}_FIELDS: &[::hemx::FormField] = &[\n", - res.ident.to_ascii_uppercase() - )); - for control in &form.controls { - out.push_str(&format!( - "{inner} ::hemx::FormField {{ name: {}, kind: {}, required: {} }},\n", - rust_str(&control.name), - form_control_kind_expr(&control.kind), - control.required - )); - } - out.push_str(&format!("{inner}];\n")); - } - out.push_str(&format!("{pad}}}\n")); - for form in self - .forms - .values() - .filter(|form| component_matches(&form.resource, component)) - { - push_root_export( - out, - &pad, - "forms", - &mut root_exports, - &form.resource.ident, - "form", - ); - } - } - - fn push_lowering_api(&self, out: &mut String, component: Option<&str>, indent: usize) { - let pad = " ".repeat(indent); - let table_name = match component { - Some(component) => format!("__HEMX_LOWERING_TABLE_{}", component.to_ascii_uppercase()), - None => "__HEMX_LOWERING_TABLE".to_string(), - }; - out.push_str(&format!("\n{pad}pub fn lower(html: impl ::std::convert::AsRef) -> ::std::string::String {{\n")); - out.push_str(&format!("{pad} ")); - if component.is_some() { - out.push_str("super::"); - } - out.push_str(&format!("__hemx_lower_html(html.as_ref(), {table_name})\n")); - out.push_str(&format!("{pad}}}\n\n")); - out.push_str(&format!("{pad}#[doc(hidden)]\n")); - out.push_str(&format!("{pad}pub fn lower_html(html: impl ::std::convert::AsRef) -> ::std::string::String {{ lower(html) }}\n\n")); - out.push_str(&format!( - "{pad}pub fn static_fragment(html: &'static str) -> ::hemx::Html {{\n" - )); - out.push_str(&format!( - "{pad} ::hemx::__private::html_trusted(lower(html))\n" - )); - out.push_str(&format!("{pad}}}\n\n")); - out.push_str(&format!("{pad}#[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}pub fn page(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{ render(view) }}\n\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}#[doc(hidden)]\n")); - out.push_str(&format!("{pad}pub fn put(slot: ::hemx::advanced::Slot, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{\n")); - out.push_str(&format!("{pad} slot.html(render(view))\n")); - out.push_str(&format!("{pad}}}\n\n")); - out.push_str(&format!("{pad}#[doc(hidden)]\n")); - out.push_str(&format!("{pad}pub fn append(slot: ::hemx::advanced::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\n")); - out.push_str(&format!("{pad}where\n")); - out.push_str(&format!("{pad} K: ::std::string::ToString,\n")); - out.push_str(&format!("{pad}{{\n")); - out.push_str(&format!("{pad} slot.append_html(key, render(view))\n")); - out.push_str(&format!("{pad}}}\n\n")); - out.push_str(&format!("{pad}#[doc(hidden)]\n")); - out.push_str(&format!("{pad}pub fn prepend(slot: ::hemx::advanced::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\n")); - out.push_str(&format!("{pad}where\n")); - out.push_str(&format!("{pad} K: ::std::string::ToString,\n")); - out.push_str(&format!("{pad}{{\n")); - out.push_str(&format!("{pad} slot.prepend_html(key, render(view))\n")); - out.push_str(&format!("{pad}}}\n\n")); - out.push_str(&format!("{pad}#[doc(hidden)]\n")); - out.push_str(&format!("{pad}pub fn replace(slot: ::hemx::advanced::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\n")); - out.push_str(&format!("{pad}where\n")); - out.push_str(&format!("{pad} K: ::std::string::ToString,\n")); - out.push_str(&format!("{pad}{{\n")); - out.push_str(&format!("{pad} slot.replace_html(key, render(view))\n")); - out.push_str(&format!("{pad}}}\n\n")); - self.push_lowering_table(out, component, indent, &table_name); - } - - fn push_lowering_table( - &self, - out: &mut String, - component: Option<&str>, - indent: usize, - name: &str, - ) { - let pad = " ".repeat(indent); - out.push_str(&format!("{pad}const {name}: &[(&str, &str, u32)] = &[\n")); - for res in self - .slots - .values() - .filter(|res| component_matches(res, component)) - { - out.push_str(&format!( - "{pad} (\"data-hemx-slot\", {}, {}),\n", - rust_str(&res.ident), - res.id - )); - } - for res in self - .handles - .values() - .filter(|res| component_matches(res, component)) - { - out.push_str(&format!( - "{pad} (\"data-hemx-handle\", {}, {}),\n", - rust_str(&res.ident), - res.id - )); - } - for form in self - .forms - .values() - .filter(|form| component_matches(&form.resource, component)) - { - let res = &form.resource; - out.push_str(&format!( - "{pad} (\"data-hemx-form\", {}, {}),\n", - rust_str(&res.ident), - res.id - )); - } - for res in self - .atoms - .values() - .filter(|res| component_matches(res, component)) - { - out.push_str(&format!( - "{pad} (\"data-hemx-atom\", {}, {}),\n", - rust_str(&res.ident), - res.id - )); - } - out.push_str(&format!("{pad}];\n")); - } - - fn push_lowering_helpers(&self, out: &mut String) { - out.push_str(r#" -fn __hemx_lower_html(html: &str, table: &[(&str, &str, u32)]) -> ::std::string::String { - let mut out = html.to_owned(); - for (attr, name, id) in table { - let runtime_attr = match *attr { - "data-hemx-slot" => "data-hemx-resource", - "data-hemx-handle" => "data-hemx-action", - "data-hemx-form" => "data-hemx-resource", - "data-hemx-revealed" => "data-hemx-revealed", - "data-hemx-atom" => "data-hemx-resource", - _ => continue, - }; - out = __hemx_replace_attr(out, attr, name, runtime_attr, *id); - } - out = __hemx_lower_static_attr(out, "data-hemx-key", "data-hemx-key"); - out = __hemx_lower_static_attr(out, "h-key", "data-hemx-key"); - __hemx_inject_handle_inputs(out) -} - -fn __hemx_replace_attr(mut html: ::std::string::String, attr: &str, name: &str, runtime_attr: &str, id: u32) -> ::std::string::String { - let replacement = if runtime_attr == "value" { - ::std::format!("{attr}=\"{name}\" {runtime_attr}=\"{id}\"") - } else { - ::std::format!("{runtime_attr}=\"{id}\"") - }; - let double = ::std::format!("{attr}=\"{name}\""); - html = html.replace(&double, &replacement); - let single_replacement = replacement.replace('"', "'"); - let single = ::std::format!("{attr}='{name}'"); - html.replace(&single, &single_replacement) -} - -fn __hemx_lower_static_attr(mut html: ::std::string::String, attr: &str, runtime_attr: &str) -> ::std::string::String { - html = html.replace(&::std::format!("{attr}=\""), &::std::format!("{runtime_attr}=\"")); - html.replace(&::std::format!("{attr}='"), &::std::format!("{runtime_attr}='")) -} - -fn __hemx_inject_handle_inputs(html: ::std::string::String) -> ::std::string::String { - let mut out = ::std::string::String::with_capacity(html.len()); - let mut rest = html.as_str(); - while let Some(start) = rest.find("') else { - out.push_str(rest); - return out; - }; - let opening = &rest[..=open_end]; - out.push_str(opening); - rest = &rest[open_end + 1..]; - - let Some(handle_id) = __hemx_attr(opening, "data-hemx-action") else { - continue; - }; - let form_body_end = rest.find("").unwrap_or(rest.len()); - let form_body = &rest[..form_body_end]; - if !__hemx_has_handle_input(form_body) { - out.push_str(&::std::format!("", handle_id)); - } - } - out.push_str(rest); - out -} - -fn __hemx_has_handle_input(html: &str) -> bool { - html.contains("name=\"__h\"") || html.contains("name='__h'") -} - -fn __hemx_attr(tag: &str, attr: &str) -> Option<::std::string::String> { - let attr_at = tag.find(attr)?; - let after_attr = &tag[attr_at + attr.len()..]; - let after_equals = after_attr.trim_start().strip_prefix('=')?.trim_start(); - let quote = after_equals.chars().next()?; - if quote != '\"' && quote != '\'' { - return None; - } - let value = &after_equals[quote.len_utf8()..]; - let end = value.find(quote)?; - Some(value[..end].to_owned()) -} -"#); - } - - fn component_names(&self) -> Vec { - let mut components = Vec::new(); - for component in self - .slots - .values() - .map(|res| &res.component) - .chain(self.handles.values().map(|res| &res.component)) - .chain(self.atoms.values().map(|res| &res.component)) - .chain(self.forms.values().map(|form| &form.resource.component)) - .chain(self.classes.values().map(|class| &class.component)) - .chain(self.events.values().map(|event| &event.component)) - { - if !components.contains(component) { - components.push(component.clone()); - } - } - components.sort(); - components - } - - fn syms(&self) -> String { - let mut out = String::from("hemx-syms-v1\n"); - for res in self.slots.values() { - out.push_str(&format!( - "slot\t{}\t{}\t{}\n", - res.symbol, res.ident, res.id - )); - } - for res in self.handles.values() { - out.push_str(&format!( - "handle\t{}\t{}\t{}\n", - res.symbol, res.ident, res.id - )); - } - for form in self.forms.values() { - let res = &form.resource; - out.push_str(&format!( - "form\t{}\t{}\t{}\n", - res.symbol, res.ident, res.id - )); - } - for (handle_ident, form_ident) in &self.handle_forms { - out.push_str(&format!("handle_form\t{handle_ident}\t{form_ident}\n")); - } - for form in self.forms.values() { - let mut fields = BTreeMap::<&str, (bool, bool)>::new(); - for control in &form.controls { - let field = fields.entry(&control.name).or_default(); - field.0 |= control.required; - field.1 |= form_control_is_multiple(&control.kind); - } - for (name, (required, multiple)) in fields { - out.push_str(&format!( - "form_field\t{}\t{name}\t{required}\t{multiple}\n", - form.resource.ident - )); - } - } - for (handle_ident, params) in &self.handle_params { - for param in params { - out.push_str(&format!("handle_param\t{handle_ident}\t{param}\n")); - } - } - for res in self.atoms.values() { - out.push_str(&format!( - "atom\t{}\t{}\t{}\n", - res.symbol, res.ident, res.id - )); - } - for class in self.classes.values() { - out.push_str(&format!( - "class\t{}\t{}\t{}\n", - class.symbol, class.ident, class.token - )); - } - for event in self.events.values() { - out.push_str(&format!( - "event\t{}\t{}\t{}\n", - event.symbol, event.ident, event.name - )); - } - out - } - - fn fingerprint_parts(&self) -> Vec { - let mut parts = vec![ - SURFACE_SCHEMA_VERSION, - EFFECT_BATCH_ABI_VERSION, - RUNTIME_ABI_VERSION, - ]; - - for res in self.slots.values() { - parts.push(0); - parts.push(res.id); - } - for res in self.handles.values() { - parts.push(1); - parts.push(res.id); - } - for res in self.atoms.values() { - parts.push(3); - parts.push(res.id); - } - for form in self.forms.values() { - parts.push(2); - parts.push(form.resource.id); - parts.push(form.controls.len() as u32); - for control in &form.controls { - parts.push(stable_id("form-field", &control.name)); - parts.push(control.required as u32); - } - } - - parts - } -} - -fn insert_resource<'a>( - map: &'a mut BTreeMap, - kind: &str, - symbol: String, - name: String, - component: String, -) -> io::Result<&'a mut Resource> { - let resource = make_resource(kind, symbol, name, component)?; - match map.entry(resource.ident.clone()) { - std::collections::btree_map::Entry::Occupied(entry) - if entry.get().symbol != resource.symbol => - { - let existing = entry.get(); - Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "duplicate generated identifier `{}` for `{}` and `{}`", - resource.ident, existing.symbol, resource.symbol - ), - )) - } - std::collections::btree_map::Entry::Occupied(entry) => Ok(entry.into_mut()), - std::collections::btree_map::Entry::Vacant(entry) => Ok(entry.insert(resource)), - } -} - -fn make_resource( - kind: &str, - symbol: String, - name: String, - component: String, -) -> io::Result { - let ident = rust_ident(&name).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("invalid hemx {kind} name `{name}`; expected a Rust identifier"), - ) - })?; - let id = stable_id(kind, &symbol); - Ok(Resource { - symbol, - ident, - component, - keyed: false, - id, - }) -} - -fn collect_input_files(root: &Path) -> io::Result> { - let mut paths = Vec::new(); - if !root.exists() { - return Ok(paths); - } - collect_input_files_into(root, &mut paths)?; - paths.sort(); - Ok(paths) -} - -fn collect_input_files_into(dir: &Path, paths: &mut Vec) -> io::Result<()> { - for entry in std::fs::read_dir(dir)? { - let path = entry?.path(); - if path.is_dir() { - collect_input_files_into(&path, paths)?; - } else { - paths.push(path); - } - } - Ok(()) -} - -fn static_attr(attrs: &[SurfaceAttribute], name: &str) -> Option { - attrs - .iter() - .find(|attr| attr.origin == AttributeOrigin::Static && attr.name == name) - .and_then(|attr| attr.value.clone()) -} - -fn push_root_export( - out: &mut String, - pad: &str, - module: &str, - used: &mut BTreeSet, - ident: &str, - suffix: &str, -) { - if used.insert(ident.to_owned()) { - out.push_str(&format!("{pad}pub use self::{module}::{ident};\n")); - return; - } - let alias = format!("{ident}_{suffix}"); - if used.insert(alias.clone()) { - out.push_str(&format!( - "{pad}pub use self::{module}::{ident} as {alias};\n" - )); - } -} - -fn component_matches(res: &Resource, component: Option<&str>) -> bool { - match component { - Some(component) => res.component == component, - None => true, - } -} - -fn class_matches(class: &ClassToken, component: Option<&str>) -> bool { - match component { - Some(component) => class.component == component, - None => true, - } -} - -fn event_matches(event: &EventToken, component: Option<&str>) -> bool { - match component { - Some(component) => event.component == component, - None => true, - } -} - -fn class_tokens(value: &str) -> impl Iterator { - value - .split_ascii_whitespace() - .filter(|token| !token.is_empty()) -} - -fn event_tokens(value: &str) -> impl Iterator { - value - .split_ascii_whitespace() - .filter(|token| !token.is_empty()) -} - -fn stylesheet_class_tokens(source: &str) -> Vec<&str> { - let bytes = source.as_bytes(); - let mut tokens = Vec::new(); - let mut i = 0; - while i < bytes.len() { - if bytes[i] != b'.' { - i += 1; - continue; - } - let prev = i.checked_sub(1).map(|idx| bytes[idx]); - if prev.is_some_and(|ch| ch == b'-' || ch == b'_' || ch.is_ascii_alphanumeric()) - && !preceded_by_class_in_compound(bytes, i) - { - i += 1; - continue; - } - let start = i + 1; - if start >= bytes.len() || !is_class_start(bytes[start]) { - i += 1; - continue; - } - let mut end = start; - while end < bytes.len() && is_class_continue(bytes[end]) { - end += 1; - } - if let Ok(token) = std::str::from_utf8(&bytes[start..end]) { - tokens.push(token); - } - i = end; - } - tokens.sort_unstable(); - tokens.dedup(); - tokens -} - -fn preceded_by_class_in_compound(bytes: &[u8], dot: usize) -> bool { - let mut i = dot; - while let Some(prev) = i.checked_sub(1) { - let byte = bytes[prev]; - if byte == b'.' { - return true; - } - if matches!( - byte, - b' ' | b'\n' | b'\r' | b'\t' | b',' | b'{' | b'}' | b'>' | b'+' | b'~' | b'(' | b')' - ) { - return false; - } - i = prev; - } - false -} - -fn is_class_start(byte: u8) -> bool { - byte == b'_' || byte == b'-' || byte.is_ascii_alphabetic() -} - -fn is_class_continue(byte: u8) -> bool { - is_class_start(byte) || byte.is_ascii_digit() -} - -fn class_ident(token: &str) -> Option { - rust_ident(&token.replace('-', "_")) -} - -fn is_handle_param_attr(attr: &SurfaceAttribute) -> bool { - matches!( - attr.origin, - AttributeOrigin::Static | AttributeOrigin::Dynamic - ) && attr.name.starts_with("data-") - && !attr.name.starts_with("data-hemx-") -} - -fn data_param_ident(name: &str) -> Option { - let data_name = name.strip_prefix("data-")?; - rust_ident(&data_name.replace('-', "_")) -} - -fn can_host_keyed_collection(tag: &str) -> bool { - matches!( - tag, - "ul" | "ol" | "tbody" | "thead" | "tfoot" | "table" | "select" | "datalist" - ) -} - -fn has_descendant_keyed_for_scope(surface: &SurfaceDocument, scope: ScopeId) -> bool { - surface.scopes.iter().enumerate().any(|(index, current)| { - matches!( - current.kind, - ScopeKind::For { - key_expr: Some(_), - .. - } - ) && is_descendant_scope(surface, ScopeId(index as u32), scope) - }) -} - -fn is_descendant_scope(surface: &SurfaceDocument, mut scope: ScopeId, ancestor: ScopeId) -> bool { - loop { - let Some(current) = surface.scopes.get(scope.0 as usize) else { - return false; - }; - let Some(parent) = current.parent else { - return false; - }; - if parent == ancestor { - return true; - } - scope = parent; - } -} - -fn is_inside_keyed_for(surface: &SurfaceDocument, mut scope: ScopeId) -> bool { - loop { - let Some(current) = surface.scopes.get(scope.0 as usize) else { - return false; - }; - if matches!( - current.kind, - ScopeKind::For { - key_expr: Some(_), - .. - } - ) { - return true; - } - let Some(parent) = current.parent else { - return false; - }; - scope = parent; - } -} - -fn unkeyed_generated_target_diagnostics(path: &Path, surface: &SurfaceDocument) -> Vec { - let mut diagnostics = Vec::new(); - for (node_scope, target) in generated_targets_with_scope(surface) { - if let Some(diagnostic) = unkeyed_generated_target_diagnostic_for_scope( - surface, - node_scope, - path, - &target.kind, - &target.name, - ) { - diagnostics.push(diagnostic); - } - } - diagnostics -} - -fn generated_targets(surface: &SurfaceDocument) -> Vec { - let mut targets = Vec::new(); - for (_, target) in generated_targets_with_scope(surface) { - if !targets.iter().any(|existing: &GeneratedTarget| { - existing.kind == target.kind && existing.name == target.name - }) { - targets.push(target); - } - } - targets -} - -fn generated_targets_with_scope(surface: &SurfaceDocument) -> Vec<(ScopeId, GeneratedTarget)> { - let mut targets = Vec::new(); - for node in &surface.nodes { - for attr in &node.attrs { - let Some(kind) = attr.name.strip_prefix("data-hemx-") else { - continue; - }; - if !matches!(kind, "slot" | "form" | "handle") { - continue; - } - let Some(name) = &attr.value else { - continue; - }; - targets.push(( - node.scope, - GeneratedTarget { - kind: kind.to_owned(), - name: name.to_owned(), - }, - )); - } - } - targets -} - -fn unkeyed_generated_target_diagnostic_for_scope( - surface: &SurfaceDocument, - mut scope: ScopeId, - path: &Path, - kind: &str, - name: &str, -) -> Option { - loop { - let current = surface.scopes.get(scope.0 as usize)?; - if let ScopeKind::For { - pattern, - expr, - key_expr: None, - } = ¤t.kind - { - return Some(unkeyed_generated_target_diagnostic( - path, kind, name, pattern, expr, - )); - } - let parent = current.parent?; - scope = parent; - } -} - -fn unkeyed_generated_target_diagnostic( - path: &Path, - kind: &str, - name: &str, - pattern: &str, - expr: &str, -) -> Diagnostic { - Diagnostic { - code: DiagnosticCode::UnkeyedGeneratedTarget, - severity: DiagnosticSeverity::Error, - file: path.to_path_buf(), - directive: format!("data-hemx-{kind}"), - target: name.to_owned(), - message: format!( - "data-hemx-{kind}=\"{name}\" is inside h-for=\"{pattern} in {expr}\" without h-key" - ), - expected: format!( - "a stable template h-key on h-for=\"{pattern} in {expr}\" so generated keyed helpers such as ui::{name}.replace(row) can target this partial" - ), - repair: format!( - "add h-key=\"{pattern}.id\" to that h-for; dynamic +data-hemx-key on the child is rendered HTML, not the template fact hemx uses for generated targets" - ), - } -} - -fn canonical_symbol(root: &Path, path: &Path, name: &str) -> String { - let rel = path.strip_prefix(root).unwrap_or(path); - format!("{}::{name}", rel.to_string_lossy().replace('\\', "/")) -} - -fn component_ident(root: &Path, path: &Path) -> io::Result { - let rel = path.strip_prefix(root).unwrap_or(path); - let stem = rel - .file_stem() - .and_then(|stem| stem.to_str()) - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("invalid template path `{}`", path.display()), - ) - })?; - rust_ident(stem).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("invalid template name `{stem}`; expected a Rust module identifier"), - ) - }) -} - -fn rust_ident(name: &str) -> Option { - let mut chars = name.chars(); - let first = chars.next()?; - if !(first == '_' || first.is_ascii_alphabetic()) { - return None; - } - if chars - .clone() - .any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric())) - { - return None; - } - Some(name.to_string()) -} - -fn stable_id(kind: &str, symbol: &str) -> u32 { - let mut hash = 0x811c9dc5u32; - for byte in kind.bytes().chain(*b":").chain(symbol.bytes()) { - hash ^= byte as u32; - hash = hash.wrapping_mul(0x01000193); - } - hash -} - -fn form_control_is_multiple(kind: &ControlKind) -> bool { - matches!(kind, ControlKind::Select { multiple: true }) -} - -fn form_control_kind_expr(kind: &ControlKind) -> String { - match kind { - ControlKind::Text => "::hemx::FormControlKind::Text".to_string(), - ControlKind::Number { min, max, step } => format!( - "::hemx::FormControlKind::Number {{ min: {}, max: {}, step: {} }}", - rust_str_opt(min.as_deref()), - rust_str_opt(max.as_deref()), - rust_str_opt(step.as_deref()) - ), - ControlKind::Checkbox => "::hemx::FormControlKind::Checkbox".to_string(), - ControlKind::Radio => "::hemx::FormControlKind::Radio".to_string(), - ControlKind::Select { multiple } => { - format!("::hemx::FormControlKind::Select {{ multiple: {multiple} }}") - } - ControlKind::TextArea => "::hemx::FormControlKind::TextArea".to_string(), - ControlKind::File => "::hemx::FormControlKind::File".to_string(), - ControlKind::Hidden => "::hemx::FormControlKind::Hidden".to_string(), - ControlKind::Submit => "::hemx::FormControlKind::Submit".to_string(), - ControlKind::Other { tag, input_type } => format!( - "::hemx::FormControlKind::Other {{ tag: {}, input_type: {} }}", - rust_str(tag), - rust_str_opt(input_type.as_deref()) - ), - } -} - -fn rust_str(value: &str) -> String { - format!("{value:?}") -} - -fn rust_str_opt(value: Option<&str>) -> String { - match value { - Some(value) => format!("Some({})", rust_str(value)), - None => "None".to_string(), - } -} - -fn parse_error(path: &Path, err: impl std::fmt::Display) -> io::Error { - io::Error::new( - io::ErrorKind::InvalidData, - format!("failed to parse {}: {err}", path.display()), - ) -} - #[cfg(test)] mod tests { use super::*; + use crate::artifact::*; static OUT_DIR_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); diff --git a/hemx-build/src/rust_source.rs b/hemx-build/src/rust_source.rs index 5186561..db8eb91 100644 --- a/hemx-build/src/rust_source.rs +++ b/hemx-build/src/rust_source.rs @@ -1,4 +1,5 @@ use super::*; +use crate::artifact::rust_ident; #[derive(Debug, Clone)] pub(super) struct RustStructFact { diff --git a/tests/redgate_test.sh b/tests/redgate_test.sh index 8099224..c6591d0 100755 --- a/tests/redgate_test.sh +++ b/tests/redgate_test.sh @@ -16,6 +16,7 @@ cargo test -p hemx-build emits_generated_resources_from_heml --lib # spec: assur cargo test -p hemx-build unkeyed_generated_target_diagnostic_is_structured --lib # spec: assurance/001 check test -f hemx-build/src/authoring.rs && grep -q '^mod authoring;$' hemx-build/src/lib.rs && ! grep -Eq '^fn (reject_selector_target_attrs|reject_unknown_hemx_attrs|known_hemx_attr|reject_invalid_hemx_attr_values|reject_invalid_hemx_attr_placement|reject_unkeyed_loop)\(' hemx-build/src/lib.rs # spec: architecture/001 check test -f hemx-build/src/rust_source.rs && grep -q '^mod rust_source;$' hemx-build/src/lib.rs && ! grep -Eq '^fn (context_type_for_heml_path|nearest_dir_with|rust_struct_facts_in|collect_rust_struct_facts|compact_tokens|loop_locals_for_surface|h_for_local_and_self_field|vec_element_type)\(' hemx-build/src/lib.rs # spec: architecture/002 check +test -f hemx-build/src/artifact.rs && grep -q '^mod artifact;$' hemx-build/src/lib.rs && ! grep -Eq '^fn (insert_resource|make_resource|generated_targets|unkeyed_generated_target_diagnostic|stable_id|rust_ident|write_if_changed|render_generated|render_symbols)\(' hemx-build/src/lib.rs # spec: architecture/003 check cargo test -p hemx-build no_op_build_preserves_generated_artifact_timestamps --lib # spec: build/003 check # spec: resource/005 check cargo test -p hemx-build changed_template_refreshes_generated_artifacts --lib # spec: resource/005 check cargo test -p hemx-derive --test surface surface_macro_preserves_inline_module_with_generated_file -- --exact # spec: derive/001 check