diff --git a/PLAN.md b/PLAN.md index 3356b40..1b9d736 100644 --- a/PLAN.md +++ b/PLAN.md @@ -12,17 +12,11 @@ Blocked by: none ## HMX-M02 — Isolate Rust source fact extraction - -Outcome: `hemx-build` Rust/syn fact collection has one private owner independent of template validation and emission. +Outcome: Rust/syn context facts are privately owned without generated contract, ordering, fingerprint, or host-boundary drift. Delta: architecture/002 assurance/001 -Path: application Rust source -> syntax facts -> generated component/form/handler contract -> deterministic artifact. -Build: move the whole fact model, syn traversal, and related tests together; retain the existing host-only dependency boundary. -Risk: reordered facts or changed path handling alters generated contracts or fingerprints. -Checks: `cargo test -p hemx-build`; generated-contract semantic and fingerprint fixtures; Wasm target tree gate; `redgate check`. -Non-goals: replacing syn, changing generated vocabulary, or introducing a generic analysis framework. -Residual risk: overlaps `hemx-build/src/lib.rs`; implement after HMX-M01 to avoid conflicting movement. -State: Draft -Blocked by: HMX-M01 +Checks: focused build facts/contracts/fingerprint tests; Wasm graph; Redgate; diff check; fresh review. +State: Done +Blocked by: none ## HMX-M03 — Isolate artifact emission and diagnostics diff --git a/hemx-build/src/lib.rs b/hemx-build/src/lib.rs index 9792b52..0d924c0 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -11,11 +11,20 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; mod authoring; +mod rust_source; use authoring::{ reject_invalid_hemx_attr_placement, reject_invalid_hemx_attr_values, reject_selector_target_attrs, reject_unkeyed_loop, reject_unknown_hemx_attrs, }; +#[cfg(test)] +use rust_source::{ + collect_rust_struct_facts_from_file, compact_tokens, h_for_local_and_self_field, + vec_element_type, +}; +use rust_source::{ + context_type_for_heml_path, loop_locals_for_surface, nearest_dir_with, rust_struct_facts_in, +}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DiagnosticSeverity { @@ -92,12 +101,6 @@ pub struct TemplateLocalFact { pub fields: Vec, } -#[derive(Debug, Clone)] -struct RustStructFact { - fields: Vec, - derives_hemplate: bool, -} - pub fn diagnostics_for_heml_file(path: impl AsRef) -> io::Result> { let path = path.as_ref(); let source = std::fs::read_to_string(path)?; @@ -1494,190 +1497,6 @@ fn is_inside_keyed_for(surface: &SurfaceDocument, mut scope: ScopeId) -> bool { } } -fn context_type_for_heml_path(path: &Path) -> Option { - let stem = path.file_stem()?.to_str()?; - let mut out = String::new(); - for word in stem.split(['_', '-']).filter(|word| !word.is_empty()) { - let mut chars = word.chars(); - out.extend(chars.next()?.to_uppercase()); - out.extend(chars); - } - (!out.is_empty()).then_some(out) -} - -fn nearest_dir_with(path: &Path, file_name: &str) -> Option { - for dir in path.ancestors().filter(|path| path.is_dir()) { - if dir.join(file_name).is_file() { - return Some(dir.to_path_buf()); - } - } - None -} - -fn rust_struct_facts_in(root: &Path) -> io::Result> { - let mut facts = HashMap::new(); - collect_rust_struct_facts(&root.join("src"), &mut facts)?; - Ok(facts) -} - -fn collect_rust_struct_facts( - dir: &Path, - facts: &mut HashMap, -) -> io::Result<()> { - let Ok(entries) = std::fs::read_dir(dir) else { - return Ok(()); - }; - for entry in entries { - let path = entry?.path(); - if path.is_dir() { - collect_rust_struct_facts(&path, facts)?; - } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { - collect_rust_struct_facts_from_file(&path, facts)?; - } - } - Ok(()) -} - -fn collect_rust_struct_facts_from_file( - path: &Path, - facts: &mut HashMap, -) -> io::Result<()> { - let source = std::fs::read_to_string(path)?; - let file = match syn::parse_file(&source) { - Ok(file) => file, - Err(error) => return Err(io::Error::new(io::ErrorKind::InvalidData, error)), - }; - collect_rust_struct_facts_from_items(&file.items, facts); - Ok(()) -} - -fn collect_rust_struct_facts_from_items( - items: &[syn::Item], - facts: &mut HashMap, -) { - for item in items { - match item { - syn::Item::Struct(item) => { - if let syn::Fields::Named(fields) = &item.fields { - facts.insert( - item.ident.to_string(), - RustStructFact { - fields: fields - .named - .iter() - .map(|field| TemplateFieldFact { - name: field.ident.as_ref().unwrap().to_string(), - type_name: compact_tokens(&field.ty), - }) - .collect(), - derives_hemplate: derives_hemplate(&item.attrs), - }, - ); - } - } - syn::Item::Mod(item) => { - if let Some((_, items)) = &item.content { - collect_rust_struct_facts_from_items(items, facts); - } - } - _ => {} - } - } -} - -fn derives_hemplate(attrs: &[syn::Attribute]) -> bool { - attrs - .iter() - .filter(|attr| attr.path().is_ident("derive")) - .filter_map(|attr| { - attr.parse_args_with( - syn::punctuated::Punctuated::::parse_terminated, - ) - .ok() - }) - .flatten() - .any(|path| { - path.segments - .last() - .is_some_and(|segment| segment.ident == "Hemplate") - }) -} - -fn compact_tokens(tokens: &impl ToTokens) -> String { - tokens - .to_token_stream() - .to_string() - .replace(" :: ", "::") - .replace(" < ", "<") - .replace(" >", ">") - .replace(" ,", ",") - .replace(", ", ",") - .replace(" & ", "&") - .replace("& ", "&") -} - -fn loop_locals_for_surface( - surface: &SurfaceDocument, - self_fields: &[TemplateFieldFact], - structs: &HashMap, -) -> Vec { - let mut locals = Vec::new(); - for node in &surface.nodes { - let Some(value) = node - .attrs - .iter() - .find(|attr| attr.name == "h-for") - .and_then(|attr| attr.value.as_deref()) - else { - continue; - }; - let Some((local, field)) = h_for_local_and_self_field(value) else { - continue; - }; - let Some(self_field) = self_fields.iter().find(|candidate| candidate.name == field) else { - continue; - }; - let Some(type_name) = vec_element_type(&self_field.type_name) else { - continue; - }; - let fields = structs - .get(&type_name) - .map(|fact| fact.fields.clone()) - .unwrap_or_default(); - if !locals - .iter() - .any(|existing: &TemplateLocalFact| existing.name == local) - { - locals.push(TemplateLocalFact { - name: local, - type_name, - fields, - }); - } - } - locals -} - -fn h_for_local_and_self_field(value: &str) -> Option<(String, String)> { - let (local, expr) = value.split_once(" in ")?; - let local = rust_ident(local.trim())?; - let expr = expr.trim().strip_prefix('&').unwrap_or(expr.trim()).trim(); - let field = expr - .strip_prefix("self.")? - .split(['.', '(', '[']) - .next() - .filter(|field| !field.is_empty())?; - Some((local, field.to_owned())) -} - -fn vec_element_type(type_name: &str) -> Option { - let inner = type_name - .strip_prefix("Vec<") - .or_else(|| type_name.strip_prefix("std::vec::Vec<"))? - .strip_suffix('>')?; - Some(inner.trim().to_owned()) -} - fn unkeyed_generated_target_diagnostics(path: &Path, surface: &SurfaceDocument) -> Vec { let mut diagnostics = Vec::new(); for (node_scope, target) in generated_targets_with_scope(surface) { diff --git a/hemx-build/src/rust_source.rs b/hemx-build/src/rust_source.rs new file mode 100644 index 0000000..5186561 --- /dev/null +++ b/hemx-build/src/rust_source.rs @@ -0,0 +1,191 @@ +use super::*; + +#[derive(Debug, Clone)] +pub(super) struct RustStructFact { + pub(super) fields: Vec, + pub(super) derives_hemplate: bool, +} + +pub(super) fn context_type_for_heml_path(path: &Path) -> Option { + let stem = path.file_stem()?.to_str()?; + let mut out = String::new(); + for word in stem.split(['_', '-']).filter(|word| !word.is_empty()) { + let mut chars = word.chars(); + out.extend(chars.next()?.to_uppercase()); + out.extend(chars); + } + (!out.is_empty()).then_some(out) +} + +pub(super) fn nearest_dir_with(path: &Path, file_name: &str) -> Option { + for dir in path.ancestors().filter(|path| path.is_dir()) { + if dir.join(file_name).is_file() { + return Some(dir.to_path_buf()); + } + } + None +} + +pub(super) fn rust_struct_facts_in(root: &Path) -> io::Result> { + let mut facts = HashMap::new(); + collect_rust_struct_facts(&root.join("src"), &mut facts)?; + Ok(facts) +} + +fn collect_rust_struct_facts( + dir: &Path, + facts: &mut HashMap, +) -> io::Result<()> { + let Ok(entries) = std::fs::read_dir(dir) else { + return Ok(()); + }; + for entry in entries { + let path = entry?.path(); + if path.is_dir() { + collect_rust_struct_facts(&path, facts)?; + } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { + collect_rust_struct_facts_from_file(&path, facts)?; + } + } + Ok(()) +} + +pub(super) fn collect_rust_struct_facts_from_file( + path: &Path, + facts: &mut HashMap, +) -> io::Result<()> { + let source = std::fs::read_to_string(path)?; + let file = match syn::parse_file(&source) { + Ok(file) => file, + Err(error) => return Err(io::Error::new(io::ErrorKind::InvalidData, error)), + }; + collect_rust_struct_facts_from_items(&file.items, facts); + Ok(()) +} + +fn collect_rust_struct_facts_from_items( + items: &[syn::Item], + facts: &mut HashMap, +) { + for item in items { + match item { + syn::Item::Struct(item) => { + if let syn::Fields::Named(fields) = &item.fields { + facts.insert( + item.ident.to_string(), + RustStructFact { + fields: fields + .named + .iter() + .map(|field| TemplateFieldFact { + name: field.ident.as_ref().unwrap().to_string(), + type_name: compact_tokens(&field.ty), + }) + .collect(), + derives_hemplate: derives_hemplate(&item.attrs), + }, + ); + } + } + syn::Item::Mod(item) => { + if let Some((_, items)) = &item.content { + collect_rust_struct_facts_from_items(items, facts); + } + } + _ => {} + } + } +} + +fn derives_hemplate(attrs: &[syn::Attribute]) -> bool { + attrs + .iter() + .filter(|attr| attr.path().is_ident("derive")) + .filter_map(|attr| { + attr.parse_args_with( + syn::punctuated::Punctuated::::parse_terminated, + ) + .ok() + }) + .flatten() + .any(|path| { + path.segments + .last() + .is_some_and(|segment| segment.ident == "Hemplate") + }) +} + +pub(super) fn compact_tokens(tokens: &impl ToTokens) -> String { + tokens + .to_token_stream() + .to_string() + .replace(" :: ", "::") + .replace(" < ", "<") + .replace(" >", ">") + .replace(" ,", ",") + .replace(", ", ",") + .replace(" & ", "&") + .replace("& ", "&") +} + +pub(super) fn loop_locals_for_surface( + surface: &SurfaceDocument, + self_fields: &[TemplateFieldFact], + structs: &HashMap, +) -> Vec { + let mut locals = Vec::new(); + for node in &surface.nodes { + let Some(value) = node + .attrs + .iter() + .find(|attr| attr.name == "h-for") + .and_then(|attr| attr.value.as_deref()) + else { + continue; + }; + let Some((local, field)) = h_for_local_and_self_field(value) else { + continue; + }; + let Some(self_field) = self_fields.iter().find(|candidate| candidate.name == field) else { + continue; + }; + let Some(type_name) = vec_element_type(&self_field.type_name) else { + continue; + }; + let fields = structs + .get(&type_name) + .map(|fact| fact.fields.clone()) + .unwrap_or_default(); + if !locals + .iter() + .any(|existing: &TemplateLocalFact| existing.name == local) + { + locals.push(TemplateLocalFact { + name: local, + type_name, + fields, + }); + } + } + locals +} + +pub(super) fn h_for_local_and_self_field(value: &str) -> Option<(String, String)> { + let (local, expr) = value.split_once(" in ")?; + let local = rust_ident(local.trim())?; + let expr = expr.trim().strip_prefix('&').unwrap_or(expr.trim()).trim(); + let field = expr + .strip_prefix("self.")? + .split(['.', '(', '[']) + .next() + .filter(|field| !field.is_empty())?; + Some((local, field.to_owned())) +} + +pub(super) fn vec_element_type(type_name: &str) -> Option { + let inner = type_name + .strip_prefix("Vec<") + .or_else(|| type_name.strip_prefix("std::vec::Vec<"))? + .strip_suffix('>')?; + Some(inner.trim().to_owned()) +} diff --git a/tests/redgate_test.sh b/tests/redgate_test.sh index 8ca5417..8099224 100755 --- a/tests/redgate_test.sh +++ b/tests/redgate_test.sh @@ -15,6 +15,7 @@ cargo test -p hemx-build generated_contract_fingerprint_is_deterministic --lib # cargo test -p hemx-build emits_generated_resources_from_heml --lib # spec: assurance/001 check 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 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