diff --git a/slhx-build/src/lib.rs b/slhx-build/src/lib.rs index 6747371..c72537f 100644 --- a/slhx-build/src/lib.rs +++ b/slhx-build/src/lib.rs @@ -151,6 +151,7 @@ impl Resources { reject_selector_target_attrs(path, &node.attrs)?; reject_unknown_slhx_attrs(path, &node.attrs)?; + reject_invalid_slhx_attr_values(path, &node.attrs)?; if let Some(class_attr) = static_attr(&node.attrs, "class") { for token in class_tokens(&class_attr) { @@ -1012,6 +1013,59 @@ fn known_slhx_attr(name: &str) -> bool { ) } +fn reject_invalid_slhx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> io::Result<()> { + for attr in attrs.iter().filter(|attr| attr.origin == AttributeOrigin::Static) { + let Some(value) = attr.value.as_deref() else { + continue; + }; + match attr.name.as_str() { + "data-slhx-policy" if !valid_policy(value) => { + return Err(invalid_slhx_value( + path, + &attr.name, + value, + "expected one of `latest`, `queue`, `drop`, or `parallel`", + )); + } + "data-slhx-debounce" | "data-slhx-throttle" | "data-slhx-every" + if !valid_duration(value) => + { + return Err(invalid_slhx_value( + path, + &attr.name, + value, + "expected milliseconds like `250`/`250ms` or seconds like `1s`", + )); + } + _ => {} + } + } + Ok(()) +} + +fn valid_policy(value: &str) -> bool { + matches!(value.trim(), "latest" | "queue" | "drop" | "parallel") +} + +fn valid_duration(value: &str) -> bool { + let value = value.trim(); + let digits = value + .strip_suffix("ms") + .or_else(|| value.strip_suffix('s')) + .unwrap_or(value); + !digits.is_empty() && digits.as_bytes().iter().all(u8::is_ascii_digit) +} + +fn invalid_slhx_value(path: &Path, attr: &str, value: &str, expectation: &str) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "{}: invalid {attr} value `{value}`; {expectation}", + path.display() + ), + ) +} + fn reject_unkeyed_loop( surface: &SurfaceDocument, mut scope: ScopeId, @@ -1461,6 +1515,45 @@ fn main() {{ let _ = std::fs::remove_dir_all(&base); } + #[test] + fn rejects_invalid_static_slhx_convention_values() { + // req: convention/003 req: convention/005 req: convention/006 req: diagnostics/002 + for (case, template, attr, guidance) in [ + ( + "policy", + r#""#, + "data-slhx-policy", + "latest", + ), + ( + "debounce", + r#""#, + "data-slhx-debounce", + "250ms", + ), + ( + "every", + r#""#, + "data-slhx-every", + "1s", + ), + ] { + let base = test_dir(&format!("slhx-build-invalid-convention-{case}-test")); + let templates = base.join("templates"); + let out = base.join("out"); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&templates).unwrap(); + std::fs::write(templates.join("invalid.heml"), template).unwrap(); + + let err = app().template_dir(&templates).out_dir(&out).run().unwrap_err(); + assert!(err.to_string().contains(attr), "missing attr in diagnostic: {err}"); + assert!(err.to_string().contains("invalid"), "missing invalid-value diagnostic: {err}"); + assert!(err.to_string().contains(guidance), "missing fix guidance {guidance:?}: {err}"); + + let _ = std::fs::remove_dir_all(&base); + } + } + #[test] fn generated_lowering_injects_progressive_form_handle_and_key() { // req: form/002, req: wire/001