chore(checkpoint): save current v0 build state
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "slhx-build"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
hemplate-core = { path = "../../hemplate/hemplate-core", features = ["surface"] }
|
||||
slhx-core = { path = "../slhx-core" }
|
||||
@@ -0,0 +1,544 @@
|
||||
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;
|
||||
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)?;
|
||||
}
|
||||
|
||||
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>,
|
||||
forms: BTreeMap<String, FormResource>,
|
||||
atoms: BTreeMap<String, Resource>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Resource {
|
||||
symbol: String,
|
||||
ident: 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,
|
||||
}
|
||||
|
||||
impl Resources {
|
||||
fn add_surface(&mut self, root: &Path, path: &Path, surface: &SurfaceDocument) -> io::Result<()> {
|
||||
for node in &surface.nodes {
|
||||
let SurfaceNodeKind::Element { tag } = &node.kind else {
|
||||
continue;
|
||||
};
|
||||
|
||||
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, 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)?;
|
||||
}
|
||||
|
||||
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())?;
|
||||
|
||||
if tag == "form" {
|
||||
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, &name), name, controls)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_slot(&mut self, symbol: String, name: String, keyed: bool) -> io::Result<()> {
|
||||
insert_resource(&mut self.slots, "slot", symbol, name, keyed)
|
||||
}
|
||||
|
||||
fn insert_handle(&mut self, symbol: String, name: String) -> io::Result<()> {
|
||||
insert_resource(&mut self.handles, "handle", symbol, name, false)
|
||||
}
|
||||
|
||||
fn insert_atom(&mut self, symbol: String, name: String) -> io::Result<()> {
|
||||
insert_resource(&mut self.atoms, "atom", symbol, name, false)
|
||||
}
|
||||
|
||||
fn insert_form(
|
||||
&mut self,
|
||||
symbol: String,
|
||||
name: String,
|
||||
controls: Vec<GeneratedControl>,
|
||||
) -> io::Result<()> {
|
||||
let resource = make_resource("form", symbol, name, 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 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(", ")
|
||||
));
|
||||
|
||||
out.push_str("pub mod slots {\n");
|
||||
for res in self.slots.values() {
|
||||
if res.keyed {
|
||||
out.push_str(&format!(
|
||||
" pub const {}: ::slhx::KeyedSlot<::std::string::String, ::std::string::String> = ::slhx::KeyedSlot::new({});\n",
|
||||
res.ident, res.id
|
||||
));
|
||||
} else {
|
||||
out.push_str(&format!(
|
||||
" pub const {}: ::slhx::Slot<::std::string::String> = ::slhx::Slot::new({});\n",
|
||||
res.ident, res.id
|
||||
));
|
||||
}
|
||||
}
|
||||
out.push_str("}\n\n");
|
||||
|
||||
out.push_str("pub mod handles {\n");
|
||||
for res in self.handles.values() {
|
||||
out.push_str(&format!(
|
||||
" pub const {}: ::slhx::Handle<()> = ::slhx::Handle::new({});\n",
|
||||
res.ident, res.id
|
||||
));
|
||||
}
|
||||
out.push_str("}\n\n");
|
||||
|
||||
out.push_str("pub mod atoms {\n");
|
||||
for res in self.atoms.values() {
|
||||
out.push_str(&format!(
|
||||
" pub const {}: ::slhx::Atom<::std::string::String> = ::slhx::Atom::new({});\n",
|
||||
res.ident, res.id
|
||||
));
|
||||
}
|
||||
out.push_str("}\n\n");
|
||||
|
||||
out.push_str("pub mod forms {\n");
|
||||
for form in self.forms.values() {
|
||||
let res = &form.resource;
|
||||
out.push_str(&format!(
|
||||
" pub const {}: ::slhx::Form<::std::string::String> = ::slhx::Form::new({});\n",
|
||||
res.ident, res.id
|
||||
));
|
||||
out.push_str(&format!(
|
||||
" pub const {}_CONTRACT: ::slhx::FormContract = ::slhx::FormContract {{ fields: &{}_FIELDS }};\n",
|
||||
res.ident.to_ascii_uppercase(), res.ident.to_ascii_uppercase()
|
||||
));
|
||||
out.push_str(&format!(
|
||||
" pub const {}_FIELDS: &[::slhx::FormField] = &[\n",
|
||||
res.ident.to_ascii_uppercase()
|
||||
));
|
||||
for control in &form.controls {
|
||||
out.push_str(&format!(
|
||||
" ::slhx::FormField {{ name: {}, kind: {}, required: {} }},\n",
|
||||
rust_str(&control.name),
|
||||
form_control_kind_expr(&control.kind),
|
||||
control.required
|
||||
));
|
||||
}
|
||||
out.push_str(" ];\n");
|
||||
}
|
||||
out.push_str("}\n");
|
||||
out
|
||||
}
|
||||
|
||||
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 res in self.atoms.values() {
|
||||
out.push_str(&format!("atom\t{}\t{}\t{}\n", res.symbol, res.ident, res.id));
|
||||
}
|
||||
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,
|
||||
keyed: bool,
|
||||
) -> io::Result<()> {
|
||||
let resource = make_resource(kind, symbol, name, 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, 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,
|
||||
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 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 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 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() {
|
||||
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"><input name="title"></form><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"));
|
||||
assert!(generated.contains("pub mod forms"));
|
||||
assert!(generated.contains("pub mod atoms"));
|
||||
assert!(generated.contains("pub const filter"));
|
||||
|
||||
let syms = std::fs::read_to_string(out.join("slhx.syms")).unwrap();
|
||||
assert!(syms.contains("atom\t"));
|
||||
assert!(syms.contains("\tfilter\t"));
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
fn test_dir(prefix: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("{prefix}-{}", std::process::id()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user