Files
hemx/slhx-build/src/lib.rs
T
slhx agent d25361d34d feat(derive): check generated handler params
Emit handle_param facts for handled data-* attributes and reject handlers that omit generated param arguments. Cover the generated param boundary with unit and compile-fail tests.

req: derive_handler/001

req: test/003
2026-05-25 22:17:08 +02:00

1139 lines
43 KiB
Rust

use hemplate_core::ast::build_ast;
use hemplate_core::surface::{
extract_surface, AttributeOrigin, ControlKind, ScopeId, ScopeKind, SurfaceAttribute,
SurfaceDocument, SurfaceNodeKind,
};
use slhx_core::{EFFECT_BATCH_ABI_VERSION, RUNTIME_ABI_VERSION, SURFACE_SCHEMA_VERSION};
use std::collections::{BTreeMap, BTreeSet};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Clone, Debug)]
pub struct AppBuilder {
out_dir: Option<PathBuf>,
template_dir: PathBuf,
}
impl AppBuilder {
pub fn out_dir(mut self, out_dir: impl Into<PathBuf>) -> Self {
self.out_dir = Some(out_dir.into());
self
}
pub fn template_dir(mut self, template_dir: impl Into<PathBuf>) -> Self {
self.template_dir = template_dir.into();
self
}
pub fn run(self) -> io::Result<()> {
println!("cargo:rerun-if-changed={}", self.template_dir.display());
let out_dir = self
.out_dir
.or_else(|| std::env::var_os("OUT_DIR").map(PathBuf::from));
let Some(out_dir) = out_dir else {
return Ok(());
};
std::fs::create_dir_all(&out_dir)?;
let mut resources = Resources::default();
for path in collect_heml(&self.template_dir)? {
let source = std::fs::read_to_string(&path)?;
let doc = build_ast(Arc::new(source)).map_err(|err| parse_error(&path, err))?;
let Some(doc) = doc else {
continue;
};
let surface = extract_surface(&doc);
resources.add_surface(&self.template_dir, &path, &surface)?;
}
for path in collect_stylesheets(&self.template_dir)? {
let source = std::fs::read_to_string(&path)?;
resources.add_stylesheet(&self.template_dir, &path, &source)?;
}
std::fs::write(out_dir.join("slhx.generated.rs"), resources.generated_rs())?;
std::fs::write(out_dir.join("slhx.syms"), resources.syms())?;
Ok(())
}
}
pub fn app() -> AppBuilder {
AppBuilder {
out_dir: None,
template_dir: PathBuf::from("templates"),
}
}
#[derive(Default)]
struct Resources {
slots: BTreeMap<String, Resource>,
handles: BTreeMap<String, Resource>,
handle_forms: BTreeMap<String, String>,
handle_params: BTreeMap<String, BTreeSet<String>>,
forms: BTreeMap<String, FormResource>,
atoms: BTreeMap<String, Resource>,
classes: BTreeMap<String, ClassToken>,
}
#[derive(Clone, Debug)]
struct Resource {
symbol: String,
ident: String,
component: String,
keyed: bool,
id: u32,
}
#[derive(Clone, Debug)]
struct FormResource {
resource: Resource,
controls: Vec<GeneratedControl>,
}
#[derive(Clone, Debug)]
struct GeneratedControl {
name: String,
kind: ControlKind,
required: bool,
}
#[derive(Clone, Debug)]
struct ClassToken {
symbol: String,
ident: String,
component: String,
token: String,
}
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;
};
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(name) = static_attr(&node.attrs, "data-slhx-slot") {
reject_unkeyed_loop(surface, node.scope, path, "slot", &name)?;
let keyed = is_inside_keyed_for(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-slhx-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-slhx-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-slhx-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<()> {
insert_resource(&mut self.slots, "slot", symbol, name, component, keyed)
}
fn insert_handle(&mut self, symbol: String, name: String, component: String) -> io::Result<()> {
insert_resource(&mut self.handles, "handle", symbol, name, component, false)
}
fn insert_atom(&mut self, symbol: String, name: String, component: String) -> io::Result<()> {
insert_resource(&mut self.atoms, "atom", symbol, name, component, false)
}
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
),
));
}
match self.classes.get(&class.symbol) {
Some(existing) if existing.token != class.token => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("conflicting CSS class token for `{}`", existing.symbol),
)),
Some(_) => Ok(()),
None => {
self.classes.insert(class.symbol.clone(), 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_form(
&mut self,
symbol: String,
name: String,
component: String,
controls: Vec<GeneratedControl>,
) -> io::Result<()> {
let resource = make_resource("form", symbol, name, component, false)?;
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) -> String {
let mut out = String::new();
out.push_str("// @generated by slhx-build. Do not edit.\n");
out.push_str(&format!(
"pub const BUILD_FINGERPRINT: ::slhx::BuildFingerprint = ::slhx::BuildFingerprint::from_parts(&[{}]);\n\n",
self.fingerprint_parts()
.into_iter()
.map(|part| part.to_string())
.collect::<Vec<_>>()
.join(", ")
));
// req: build/001
self.push_resource_modules(&mut out, None, 0);
self.push_lowering_api(&mut out, None, 0);
for component in self.component_names() {
out.push_str("\n#[allow(non_upper_case_globals)]\n");
out.push_str(&format!("pub mod {component} {{\n"));
self.push_resource_modules(&mut out, 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_resource_modules(&self, out: &mut String, component: Option<&str>, indent: usize) {
let pad = " ".repeat(indent);
let inner = " ".repeat(indent + 1);
let mut handle_ids = Vec::new();
out.push_str(&format!("{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod slots {{\n"));
for res in self.slots.values().filter(|res| component_matches(res, component)) {
if res.keyed {
out.push_str(&format!(
"{inner}pub const {}: ::slhx::KeyedSlot<::std::string::String, ::std::string::String> = ::slhx::KeyedSlot::new({});\n",
res.ident, res.id
));
} else {
out.push_str(&format!(
"{inner}pub const {}: ::slhx::Slot<::std::string::String> = ::slhx::Slot::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 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) {
"::slhx::Form<::std::string::String>"
} else {
"()"
};
out.push_str(&format!(
"{inner}pub const {}: ::slhx::Handle<{input}> = ::slhx::Handle::new({});\n",
res.ident, res.id
));
}
out.push_str(&format!(
"{inner}pub const ALL_IDS: &[u32] = &[{}];\n",
handle_ids
.into_iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(", ")
));
out.push_str(&format!("{pad}}}\n\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 {}: ::slhx::Atom<::std::string::String> = ::slhx::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 {}: ::slhx::CssClass = ::slhx::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 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 {}: ::slhx::Form<::std::string::String> = ::slhx::Form::new({});\n",
res.ident, res.id
));
out.push_str(&format!(
"{inner}pub const {}_CONTRACT: ::slhx::FormContract = ::slhx::FormContract {{ fields: &{}_FIELDS }};\n",
res.ident.to_ascii_uppercase(), res.ident.to_ascii_uppercase()
));
out.push_str(&format!(
"{inner}pub const {}_FIELDS: &[::slhx::FormField] = &[\n",
res.ident.to_ascii_uppercase()
));
for control in &form.controls {
out.push_str(&format!(
"{inner} ::slhx::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"));
}
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!("__SLHX_LOWERING_TABLE_{}", component.to_ascii_uppercase()),
None => "__SLHX_LOWERING_TABLE".to_string(),
};
out.push_str(&format!("\n{pad}pub fn lower_html(html: impl ::std::convert::AsRef<str>) -> ::std::string::String {{\n"));
out.push_str(&format!("{pad} "));
if component.is_some() {
out.push_str("super::");
}
out.push_str(&format!("__slhx_lower_html(html.as_ref(), &{table_name})\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-slhx-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-slhx-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-slhx-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-slhx-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 __slhx_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-slhx-slot" => "data-sid",
"data-slhx-handle" => "data-hid",
"data-slhx-form" => "data-fid",
"data-slhx-atom" => "data-aid",
_ => continue,
};
out = __slhx_replace_attr(out, attr, name, runtime_attr, *id);
}
out = __slhx_lower_static_attr(out, "data-slhx-key", "data-key");
__slhx_inject_handle_inputs(out)
}
fn __slhx_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 __slhx_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 __slhx_inject_handle_inputs(html: ::std::string::String) -> ::std::string::String {
let mut out = ::std::string::String::with_capacity(html.len());
let mut rest = html.as_str();
while let Some(start) = rest.find("<form") {
out.push_str(&rest[..start]);
rest = &rest[start..];
let Some(open_end) = rest.find('>') else {
out.push_str(rest);
return out;
};
let opening = &rest[..=open_end];
out.push_str(opening);
rest = &rest[open_end + 1..];
let Some(handle_id) = __slhx_attr(opening, "data-hid") else {
continue;
};
let form_body_end = rest.find("</form>").unwrap_or(rest.len());
let form_body = &rest[..form_body_end];
if !__slhx_has_handle_input(form_body) {
out.push_str(&::std::format!("<input type=\"hidden\" name=\"__h\" value=\"{}\">", handle_id));
}
}
out.push_str(rest);
out
}
fn __slhx_has_handle_input(html: &str) -> bool {
html.contains("name=\"__h\"") || html.contains("name='__h'")
}
fn __slhx_attr(tag: &str, attr: &str) -> Option<::std::string::String> {
let attr_at = tag.find(attr)?;
let after_attr = &tag[attr_at + attr.len()..];
let after_equals = after_attr.trim_start().strip_prefix('=')?.trim_start();
let quote = after_equals.chars().next()?;
if quote != '\"' && quote != '\'' {
return None;
}
let value = &after_equals[quote.len_utf8()..];
let end = value.find(quote)?;
Some(value[..end].to_owned())
}
"#);
}
fn component_names(&self) -> Vec<String> {
let mut components = Vec::new();
for component in self
.slots
.values()
.map(|res| &res.component)
.chain(self.handles.values().map(|res| &res.component))
.chain(self.atoms.values().map(|res| &res.component))
.chain(self.forms.values().map(|form| &form.resource.component))
.chain(self.classes.values().map(|class| &class.component))
{
if !components.contains(component) {
components.push(component.clone());
}
}
components.sort();
components
}
fn syms(&self) -> String {
let mut out = String::from("slhx-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 (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));
}
out
}
fn fingerprint_parts(&self) -> Vec<u32> {
let mut parts = vec![
SURFACE_SCHEMA_VERSION,
EFFECT_BATCH_ABI_VERSION,
RUNTIME_ABI_VERSION,
];
for res in self.slots.values() {
parts.push(0);
parts.push(res.id);
}
for res in self.handles.values() {
parts.push(1);
parts.push(res.id);
}
for res in self.atoms.values() {
parts.push(3);
parts.push(res.id);
}
for form in self.forms.values() {
parts.push(2);
parts.push(form.resource.id);
parts.push(form.controls.len() as u32);
for control in &form.controls {
parts.push(stable_id("form-field", &control.name));
parts.push(control.required as u32);
}
}
parts
}
}
fn insert_resource(
map: &mut BTreeMap<String, Resource>,
kind: &str,
symbol: String,
name: String,
component: String,
keyed: bool,
) -> io::Result<()> {
let resource = make_resource(kind, symbol, name, component, keyed)?;
match map.get(&resource.ident) {
Some(existing) if existing.symbol != resource.symbol => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"duplicate generated identifier `{}` for `{}` and `{}`",
resource.ident, existing.symbol, resource.symbol
),
)),
Some(_) => Ok(()),
None => {
map.insert(resource.ident.clone(), resource);
Ok(())
}
}
}
fn make_resource(
kind: &str,
symbol: String,
name: String,
component: String,
keyed: bool,
) -> io::Result<Resource> {
let ident = rust_ident(&name).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid slhx {kind} name `{name}`; expected a Rust identifier"),
)
})?;
let id = stable_id(kind, &symbol);
Ok(Resource {
symbol,
ident,
component,
keyed,
id,
})
}
fn collect_heml(root: &Path) -> io::Result<Vec<PathBuf>> {
let mut paths = Vec::new();
if !root.exists() {
return Ok(paths);
}
collect_heml_into(root, &mut paths)?;
paths.sort();
Ok(paths)
}
fn collect_heml_into(dir: &Path, paths: &mut Vec<PathBuf>) -> io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_heml_into(&path, paths)?;
} else if path.extension().and_then(|ext| ext.to_str()) == Some("heml") {
paths.push(path);
}
}
Ok(())
}
fn collect_stylesheets(root: &Path) -> io::Result<Vec<PathBuf>> {
let mut paths = Vec::new();
if !root.exists() {
return Ok(paths);
}
collect_stylesheets_into(root, &mut paths)?;
paths.sort();
Ok(paths)
}
fn collect_stylesheets_into(dir: &Path, paths: &mut Vec<PathBuf>) -> io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_stylesheets_into(&path, paths)?;
} else if matches!(path.extension().and_then(|ext| ext.to_str()), Some("css" | "scss")) {
paths.push(path);
}
}
Ok(())
}
fn static_attr(attrs: &[SurfaceAttribute], name: &str) -> Option<String> {
attrs
.iter()
.find(|attr| attr.origin == AttributeOrigin::Static && attr.name == name)
.and_then(|attr| attr.value.clone())
}
fn 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 class_tokens(value: &str) -> impl Iterator<Item = &str> {
value.split_ascii_whitespace().filter(|token| !token.is_empty())
}
fn stylesheet_class_tokens(source: &str) -> Vec<&str> {
let bytes = source.as_bytes();
let mut tokens = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'.' {
i += 1;
continue;
}
let prev = i.checked_sub(1).map(|idx| bytes[idx]);
if prev.is_some_and(|ch| ch == b'-' || ch == b'_' || ch.is_ascii_alphanumeric())
&& !preceded_by_class_in_compound(bytes, i)
{
i += 1;
continue;
}
let start = i + 1;
if start >= bytes.len() || !is_class_start(bytes[start]) {
i += 1;
continue;
}
let mut end = start + 1;
while end < bytes.len() && is_class_continue(bytes[end]) {
end += 1;
}
if let Ok(token) = std::str::from_utf8(&bytes[start..end]) {
tokens.push(token);
}
i = end;
}
tokens.sort_unstable();
tokens.dedup();
tokens
}
fn preceded_by_class_in_compound(bytes: &[u8], dot: usize) -> bool {
let mut i = dot;
while let Some(prev) = i.checked_sub(1) {
let byte = bytes[prev];
if byte == b'.' {
return true;
}
if matches!(byte, b' ' | b'\n' | b'\r' | b'\t' | b',' | b'{' | b'}' | b'>' | b'+' | b'~' | b'(' | b')') {
return false;
}
i = prev;
}
false
}
fn is_class_start(byte: u8) -> bool {
byte == b'_' || byte == b'-' || byte.is_ascii_alphabetic()
}
fn is_class_continue(byte: u8) -> bool {
is_class_start(byte) || byte.is_ascii_digit()
}
fn class_ident(token: &str) -> Option<String> {
let mut ident = String::with_capacity(token.len());
for ch in token.chars() {
match ch {
'-' => ident.push('_'),
'_' => ident.push('_'),
ch if ch.is_ascii_alphanumeric() => ident.push(ch),
_ => return None,
}
}
rust_ident(&ident)
}
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-slhx-")
}
fn data_param_ident(name: &str) -> Option<String> {
let data_name = name.strip_prefix("data-")?;
rust_ident(&data_name.replace('-', "_"))
}
fn is_inside_keyed_for(surface: &SurfaceDocument, mut scope: ScopeId) -> bool {
loop {
let Some(current) = surface.scopes.get(scope.0 as usize) else {
return false;
};
if matches!(current.kind, ScopeKind::For { key_expr: Some(_), .. }) {
return true;
}
let Some(parent) = current.parent else {
return false;
};
scope = parent;
}
}
fn reject_unkeyed_loop(
surface: &SurfaceDocument,
mut scope: ScopeId,
path: &Path,
kind: &str,
name: &str,
) -> io::Result<()> {
loop {
let Some(current) = surface.scopes.get(scope.0 as usize) else {
return Ok(());
};
if matches!(current.kind, ScopeKind::For { key_expr: None, .. }) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{}: data-slhx-{kind}=\"{name}\" is inside an h-for without h-key; add h-key=\"item.id\" to the loop",
path.display()
),
));
}
let Some(parent) = current.parent else {
return Ok(());
};
scope = parent;
}
}
fn canonical_symbol(root: &Path, path: &Path, name: &str) -> String {
let rel = path.strip_prefix(root).unwrap_or(path);
format!("{}::{name}", rel.to_string_lossy().replace('\\', "/"))
}
fn component_ident(root: &Path, path: &Path) -> io::Result<String> {
let rel = path.strip_prefix(root).unwrap_or(path);
let stem = rel
.file_stem()
.and_then(|stem| stem.to_str())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("invalid template path `{}`", path.display())))?;
rust_ident(stem).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid template name `{stem}`; expected a Rust module identifier"),
)
})
}
fn rust_ident(name: &str) -> Option<String> {
let mut chars = name.chars();
let first = chars.next()?;
if !(first == '_' || first.is_ascii_alphabetic()) {
return None;
}
if chars.clone().any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric())) {
return None;
}
Some(name.to_string())
}
fn stable_id(kind: &str, symbol: &str) -> u32 {
let mut hash = 0x811c9dc5u32;
for byte in kind.bytes().chain([b':']).chain(symbol.bytes()) {
hash ^= byte as u32;
hash = hash.wrapping_mul(0x01000193);
}
hash
}
fn form_control_kind_expr(kind: &ControlKind) -> String {
match kind {
ControlKind::Text => "::slhx::FormControlKind::Text".to_string(),
ControlKind::Number { min, max, step } => format!(
"::slhx::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 => "::slhx::FormControlKind::Checkbox".to_string(),
ControlKind::Radio => "::slhx::FormControlKind::Radio".to_string(),
ControlKind::Select { multiple } => {
format!("::slhx::FormControlKind::Select {{ multiple: {multiple} }}")
}
ControlKind::TextArea => "::slhx::FormControlKind::TextArea".to_string(),
ControlKind::File => "::slhx::FormControlKind::File".to_string(),
ControlKind::Hidden => "::slhx::FormControlKind::Hidden".to_string(),
ControlKind::Submit => "::slhx::FormControlKind::Submit".to_string(),
ControlKind::Other { tag, input_type } => format!(
"::slhx::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::*;
#[test]
fn emits_generated_resources_from_heml() {
// req: codegen/003
let base = test_dir("slhx-build-test");
let templates = base.join("templates");
let out = base.join("out");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&templates).unwrap();
std::fs::write(
templates.join("todo.heml"),
r#"<form data-slhx-handle="create" data-slhx-form="new_todo"><input name="title"></form><button data-slhx-handle="delete" data-todo-id="7">Delete</button><ul data-slhx-slot="todos"></ul><section data-slhx-atom="filter"></section>"#,
)
.unwrap();
app().template_dir(&templates).out_dir(&out).run().unwrap();
let generated = std::fs::read_to_string(out.join("slhx.generated.rs")).unwrap();
assert!(generated.contains("pub mod slots"));
assert!(generated.contains("pub const todos"));
assert!(generated.contains("pub mod handles"));
assert!(generated.contains("pub const create: ::slhx::Handle<::slhx::Form<::std::string::String>>"));
assert!(generated.contains("pub mod forms"));
assert!(generated.contains("pub mod atoms"));
assert!(generated.contains("pub const filter"));
assert!(generated.contains("pub const new_todo"));
assert!(generated.contains("pub mod todo"));
assert!(generated.contains("pub const ALL_IDS"));
assert!(generated.contains("pub fn lower_html"));
assert!(generated.contains("data-slhx-slot"));
assert!(generated.contains("data-slhx-form"));
assert!(generated.contains("data-sid"));
assert!(generated.contains("data-fid"));
assert!(generated.contains("__slhx_inject_handle_inputs"));
let syms = std::fs::read_to_string(out.join("slhx.syms")).unwrap();
assert!(syms.contains("atom\t"));
assert!(syms.contains("\tfilter\t"));
assert!(syms.contains("handle_form\tcreate\tnew_todo\n"));
assert!(syms.contains("handle_param\tdelete\ttodo_id\n"));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn emits_checked_css_class_tokens_from_templates_and_stylesheets() {
// req: style/001, req: codegen/001
let base = test_dir("slhx-build-classes-test");
let templates = base.join("templates");
let out = base.join("out");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&templates).unwrap();
std::fs::write(
templates.join("card.heml"),
r#"<article class="card is-active" data-slhx-slot="card_body"></article>"#,
)
.unwrap();
std::fs::write(
templates.join("card.css"),
r#".card { padding: 1rem; }
.is-active:hover, .drag-handle { cursor: grab; }
.work-card.is-selected { outline: 1px solid currentColor; }
"#,
)
.unwrap();
std::fs::write(
templates.join("panel.scss"),
r#".panel-shell { &.is-open { display: block; } }"#,
)
.unwrap();
app().template_dir(&templates).out_dir(&out).run().unwrap();
let generated = std::fs::read_to_string(out.join("slhx.generated.rs")).unwrap();
assert!(generated.contains("pub mod classes"));
assert!(generated.contains("pub const card: ::slhx::CssClass = ::slhx::CssClass::new(\"card\")"));
assert!(generated.contains("pub const is_active: ::slhx::CssClass = ::slhx::CssClass::new(\"is-active\")"));
assert!(generated.contains("pub const drag_handle: ::slhx::CssClass = ::slhx::CssClass::new(\"drag-handle\")"));
assert!(generated.contains("pub const work_card: ::slhx::CssClass = ::slhx::CssClass::new(\"work-card\")"));
assert!(generated.contains("pub const is_selected: ::slhx::CssClass = ::slhx::CssClass::new(\"is-selected\")"));
assert!(generated.contains("pub mod card"));
assert!(generated.contains("pub mod panel"));
let syms = std::fs::read_to_string(out.join("slhx.syms")).unwrap();
assert!(syms.contains("class\t"));
assert!(syms.contains("\tdrag_handle\tdrag-handle"));
let source = format!(
r###"
#![allow(dead_code)]
mod slhx {{
#[derive(Clone, Copy)] pub struct BuildFingerprint;
impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }}
#[derive(Clone, Copy)] pub struct Slot<T>(::std::marker::PhantomData<T>);
impl<T> Slot<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct KeyedSlot<K, T>(::std::marker::PhantomData<(K, T)>);
impl<K, T> KeyedSlot<K, T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct Handle<T>(::std::marker::PhantomData<T>);
impl<T> Handle<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct Atom<T>(::std::marker::PhantomData<T>);
impl<T> Atom<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct Form<T>(::std::marker::PhantomData<T>);
impl<T> Form<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct CssClass(&'static str);
impl CssClass {{ pub const fn new(name: &'static str) -> Self {{ Self(name) }} pub const fn as_str(self) -> &'static str {{ self.0 }} }}
pub struct FormContract {{ pub fields: &'static [FormField] }}
pub struct FormField {{ pub name: &'static str, pub kind: FormControlKind, pub required: bool }}
pub enum FormControlKind {{ Text, Number {{ min: Option<&'static str>, max: Option<&'static str>, step: Option<&'static str> }}, Checkbox, Radio, Select {{ multiple: bool }}, TextArea, File, Hidden, Submit, Other {{ tag: &'static str, input_type: Option<&'static str> }} }}
}}
{generated}
fn main() {{
assert_eq!(classes::drag_handle.as_str(), "drag-handle");
assert_eq!(card::classes::is_active.as_str(), "is-active");
assert_eq!(panel::classes::panel_shell.as_str(), "panel-shell");
}}
"###,
);
let source_path = base.join("classes.rs");
let bin_path = base.join("classes-bin");
std::fs::write(&source_path, source).unwrap();
let status = std::process::Command::new("rustc")
.arg(&source_path)
.arg("-o")
.arg(&bin_path)
.status()
.unwrap();
assert!(status.success());
let status = std::process::Command::new(&bin_path).status().unwrap();
assert!(status.success());
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn rejects_slhx_resources_inside_unkeyed_for() {
let base = test_dir("slhx-build-unkeyed-for-test");
let templates = base.join("templates");
let out = base.join("out");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&templates).unwrap();
std::fs::write(
templates.join("todo.heml"),
r#"<template h-for="todo in &self.todos"><li data-slhx-slot="todo_row">{+ todo.title +}</li></template>"#,
)
.unwrap();
let err = app().template_dir(&templates).out_dir(&out).run().unwrap_err();
assert!(err.to_string().contains("inside an h-for without h-key"));
assert!(err.to_string().contains("data-slhx-slot=\"todo_row\""));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn generated_lowering_injects_progressive_form_handle_and_key() {
// req: form/002, req: wire/001
let base = test_dir("slhx-build-lowering-test");
let templates = base.join("templates");
let out = base.join("out");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&templates).unwrap();
std::fs::write(
templates.join("todo.heml"),
r#"<form data-slhx-handle="create"><input name="title"></form><li data-slhx-slot="row" data-slhx-key="7"></li>"#,
)
.unwrap();
app().template_dir(&templates).out_dir(&out).run().unwrap();
let generated = std::fs::read_to_string(out.join("slhx.generated.rs")).unwrap();
assert!(generated.contains("fn __slhx_lower_html"));
let source = format!(
r###"
#![allow(dead_code)]
mod slhx {{
#[derive(Clone, Copy)] pub struct BuildFingerprint;
impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }}
#[derive(Clone, Copy)] pub struct Slot<T>(::std::marker::PhantomData<T>);
impl<T> Slot<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct KeyedSlot<K, T>(::std::marker::PhantomData<(K, T)>);
impl<K, T> KeyedSlot<K, T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct Handle<T>(::std::marker::PhantomData<T>);
impl<T> Handle<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct Atom<T>(::std::marker::PhantomData<T>);
impl<T> Atom<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct Form<T>(::std::marker::PhantomData<T>);
impl<T> Form<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
pub struct FormContract {{ pub fields: &'static [FormField] }}
pub struct FormField {{ pub name: &'static str, pub kind: FormControlKind, pub required: bool }}
pub enum FormControlKind {{ Text, Number {{ min: Option<&'static str>, max: Option<&'static str>, step: Option<&'static str> }}, Checkbox, Radio, Select {{ multiple: bool }}, TextArea, File, Hidden, Submit, Other {{ tag: &'static str, input_type: Option<&'static str> }} }}
}}
{generated}
fn main() {{
let html = lower_html(r#"<form data-slhx-handle="create"><input name="title"></form><li data-slhx-slot="row" data-slhx-key="7"></li>"#);
assert!(html.contains(r#"data-hid="#));
assert!(html.contains(r#"name="__h""#));
assert!(html.contains(r#"data-key="7""#));
assert!(!html.contains("data-slhx-key"));
}}
"###,
);
let source_path = base.join("lowering.rs");
let bin_path = base.join("lowering-bin");
std::fs::write(&source_path, source).unwrap();
let status = std::process::Command::new("rustc")
.arg(&source_path)
.arg("-o")
.arg(&bin_path)
.status()
.unwrap();
assert!(status.success());
let status = std::process::Command::new(&bin_path).status().unwrap();
assert!(status.success());
let _ = std::fs::remove_dir_all(&base);
}
fn test_dir(prefix: &str) -> PathBuf {
std::env::temp_dir().join(format!("{prefix}-{}", std::process::id()))
}
}