From e82b6fe75d07e8bc544007900d14fcbe1372c30d Mon Sep 17 00:00:00 2001 From: szagi3891 Date: Fri, 13 Mar 2026 12:40:03 +0100 Subject: [PATCH] feat(macro): implement CSS variable validation in twdom! and css! macros --- Cargo.lock | 1 + crates/vertigo-macro/Cargo.toml | 1 + crates/vertigo-macro/src/css_parser.rs | 4 + .../src/trace_tailwind/macro_impl.rs | 41 ++++-- .../vertigo-macro/src/trace_tailwind/mod.rs | 2 +- .../src/trace_tailwind/validate.rs | 136 ++++++++++++++++-- demo/app/src/app/render.rs | 2 +- demo/app/src/app/styling/tailwind.rs | 6 +- demo/app/src/tailwind.css | 11 ++ 9 files changed, 175 insertions(+), 29 deletions(-) create mode 100644 demo/app/src/tailwind.css diff --git a/Cargo.lock b/Cargo.lock index a544bb5c..040614d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3651,6 +3651,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", + "regex", "rstml", "syn 2.0.117", ] diff --git a/crates/vertigo-macro/Cargo.toml b/crates/vertigo-macro/Cargo.toml index 08f3fcde..31cfa969 100644 --- a/crates/vertigo-macro/Cargo.toml +++ b/crates/vertigo-macro/Cargo.toml @@ -24,6 +24,7 @@ pkg-version = "1" proc-macro-error = "1.0" proc-macro2 = "1.0" quote = "1.0" +regex = "1.11" rstml = "0.12" syn = { version = "2.0", features = ["full"] } diff --git a/crates/vertigo-macro/src/css_parser.rs b/crates/vertigo-macro/src/css_parser.rs index 0a560359..66a923a0 100644 --- a/crates/vertigo-macro/src/css_parser.rs +++ b/crates/vertigo-macro/src/css_parser.rs @@ -40,6 +40,10 @@ impl CssParser { let css_output = parser.children.join("\n"); + if let Err(err) = crate::trace_tailwind::collect_tailwind_classes(&css_output, true) { + emit_error!(call_site, "Failed to collect Tailwind classes: {}", err); + } + let params = parser.params.into_hashmap(); if params.is_empty() { diff --git a/crates/vertigo-macro/src/trace_tailwind/macro_impl.rs b/crates/vertigo-macro/src/trace_tailwind/macro_impl.rs index b783d133..18393d87 100644 --- a/crates/vertigo-macro/src/trace_tailwind/macro_impl.rs +++ b/crates/vertigo-macro/src/trace_tailwind/macro_impl.rs @@ -1,7 +1,7 @@ use proc_macro::TokenStream; use proc_macro_error::emit_error; use proc_macro2::TokenStream as TokenStream2; -use quote::{ToTokens, quote}; +use quote::quote; use std::error::Error; use std::io::Write; use syn::spanned::Spanned; @@ -24,23 +24,36 @@ pub(crate) fn add_to_tailwind(classes: TokenStream2) -> Result Result<(), Box> { + // Only collect tailwind classes during build + if std::env::var("VERTIGO_BUNDLE").is_ok() { + let file_path = get_tailwind_classes_file_path()?; + // Open the file in append mode + let mut file = std::fs::OpenOptions::new() + .append(true) + .create(true) // Create the file if it doesn't exist + .open(&file_path)?; + + if is_raw_css { + // For raw CSS, we only care about variables + // Extract var usage and write it in a way that validate.rs can identify as non-class + let re = regex::Regex::new(r"var\((--[a-zA-Z0-9_-]+)\)").unwrap(); + for cap in re.captures_iter(input_str) { + writeln!(file, "var({})", &cap[1])?; + } + } else { // Write the input string to the file writeln!(file, "{input_str}")?; } - // Use output in source code - return Ok(quote! { #input_lit }); } - Ok(quote! { #input }) + Ok(()) } diff --git a/crates/vertigo-macro/src/trace_tailwind/mod.rs b/crates/vertigo-macro/src/trace_tailwind/mod.rs index 4e4b06f0..ddc4fa3d 100644 --- a/crates/vertigo-macro/src/trace_tailwind/mod.rs +++ b/crates/vertigo-macro/src/trace_tailwind/mod.rs @@ -4,4 +4,4 @@ pub(crate) mod paths; pub(crate) mod validate; pub(crate) use bundle::bundle_tailwind; -pub(crate) use macro_impl::{add_to_tailwind, trace_tailwind}; +pub(crate) use macro_impl::{add_to_tailwind, collect_tailwind_classes, trace_tailwind}; diff --git a/crates/vertigo-macro/src/trace_tailwind/validate.rs b/crates/vertigo-macro/src/trace_tailwind/validate.rs index fe9294bc..3a03d71c 100644 --- a/crates/vertigo-macro/src/trace_tailwind/validate.rs +++ b/crates/vertigo-macro/src/trace_tailwind/validate.rs @@ -1,5 +1,7 @@ use crate::trace_tailwind::paths::get_tailwind_classes_file_path; use proc_macro_error::emit_error; +use regex::Regex; +use std::collections::BTreeSet; use std::error::Error; pub(crate) fn validate_tailwind_classes(bundle: &str) -> Result<(), Box> { @@ -9,9 +11,22 @@ pub(crate) fn validate_tailwind_classes(bundle: &str) -> Result<(), Box Result, Box> { + let classes_content = std::fs::read_to_string(classes_path)?; + let mut used_classes = BTreeSet::new(); for line in classes_content.lines() { let line = line.trim(); @@ -20,18 +35,43 @@ pub(crate) fn validate_tailwind_classes(bundle: &str) -> Result<(), Box, + used_classes: BTreeSet, +) -> (BTreeSet, BTreeSet) { + let mut missing_classes = BTreeSet::new(); + let mut missing_vars = BTreeSet::new(); + + for class in used_classes { + // Validate class existence - only if it's not a raw var usage + if !class.starts_with("var(") && !contains_class(bundle, &class) { + missing_classes.insert(class.to_string()); + } + + // Validate CSS variables + for var in extract_used_variables(&class) { + if !defined_vars.contains(&var) { + missing_vars.insert(var); } } } - if !missing.is_empty() { - let missing_list: Vec<&str> = missing.iter().map(|s| s.as_str()).collect(); - // Warn the developer that these classes were used but not resolved by Tailwind + (missing_classes, missing_vars) +} + +fn emit_validation_errors(missing_classes: BTreeSet, missing_vars: BTreeSet) { + if !missing_classes.is_empty() { + let missing_list: Vec<&str> = missing_classes.iter().map(|s| s.as_str()).collect(); emit_error!( proc_macro::Span::call_site(), "The following Tailwind classes were used but not found in the generated CSS: {}", @@ -39,7 +79,34 @@ pub(crate) fn validate_tailwind_classes(bundle: &str) -> Result<(), Box = missing_vars.iter().map(|s| s.as_str()).collect(); + emit_error!( + proc_macro::Span::call_site(), + "The following CSS variables were used but are not defined in the CSS bundle: {}", + missing_list.join(", ") + ); + } +} + +fn extract_defined_variables(bundle: &str) -> BTreeSet { + let mut vars = BTreeSet::new(); + // Matches --var-name: + let re = Regex::new(r"(?m)^\s+(--[a-zA-Z0-9_-]+):").unwrap(); + for cap in re.captures_iter(bundle) { + vars.insert(cap[1].to_string()); + } + vars +} + +pub(crate) fn extract_used_variables(class: &str) -> Vec { + let mut vars = Vec::new(); + // Matches var(--var-name) + let re = Regex::new(r"var\((--[a-zA-Z0-9_-]+)\)").unwrap(); + for cap in re.captures_iter(class) { + vars.push(cap[1].to_string()); + } + vars } fn escape_css_name(name: &str) -> String { @@ -79,3 +146,52 @@ fn contains_class(bundle: &str, class: &str) -> bool { // Also check for attribute selectors logic or complex variants if needed, but above covers 99% false } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_defined_variables() { + let bundle = r#" + :root { + --token-text-text_dark: #333; + --bg-primary: #fff; + } + .some-class { + color: var(--token-text-text_dark); + } + "#; + let vars = extract_defined_variables(bundle); + assert!(vars.contains("--token-text-text_dark")); + assert!(vars.contains("--bg-primary")); + assert_eq!(vars.len(), 2); + } + + #[test] + fn test_extract_used_variables() { + let class = "text-[var(--token-text-text_dark)]"; + let vars = extract_used_variables(class); + assert_eq!(vars, vec!["--token-text-text_dark"]); + + let class_multiple = "bg-[var(--bg)] shadow-[0_0_10px_var(--shadow)]"; + let vars_multiple = extract_used_variables(class_multiple); + assert_eq!(vars_multiple, vec!["--bg", "--shadow"]); + } + + #[test] + fn test_validation_logic() { + let bundle = r#" + :root { + --menu-active: #000; + --menu-inactivef: #fff; + } + "#; + let defined_vars = extract_defined_variables(bundle); + let mut used_classes = BTreeSet::new(); + used_classes.insert("bg-[var(--menu-inactive)]".to_string()); + + let (_, missing_vars) = find_missing_elements(bundle, &defined_vars, used_classes); + assert!(missing_vars.contains("--menu-inactive")); + } +} diff --git a/demo/app/src/app/render.rs b/demo/app/src/app/render.rs index 3b2bf959..2b1e9893 100644 --- a/demo/app/src/app/render.rs +++ b/demo/app/src/app/render.rs @@ -9,7 +9,7 @@ use super::{ }; fn css_menu_item(active: bool) -> Css { - let bg_color = if active { "lightblue" } else { "lightgreen" }; + let bg_color = if active { "var(--menu-active)" } else { "var(--menu-inactive)" }; css! {" display: inline-block; padding: 5px 10px; diff --git a/demo/app/src/app/styling/tailwind.rs b/demo/app/src/app/styling/tailwind.rs index e3b14f29..babd58be 100644 --- a/demo/app/src/app/styling/tailwind.rs +++ b/demo/app/src/app/styling/tailwind.rs @@ -10,7 +10,7 @@ pub fn Tailwind() { my_class += tw!("flex"); if toogle_bg.get(context) { - my_class + tw!("bg-green-500 text-red-800") + my_class + tw!("bg-[var(--theme-secondary)] text-[var(--theme-accent)]") } else { my_class + tw!("bg-green-900 text-[white]") } @@ -20,7 +20,7 @@ pub fn Tailwind() {
-
"Tailwind CSS 4 test"
+
"Tailwind CSS 4 test"
} } diff --git a/demo/app/src/tailwind.css b/demo/app/src/tailwind.css new file mode 100644 index 00000000..2c06dd6b --- /dev/null +++ b/demo/app/src/tailwind.css @@ -0,0 +1,11 @@ +@import "tailwindcss"; +@source "./**/*.rs"; + +:root { + --theme-primary: #3b82f6; + --theme-secondary: #22c55e; + --theme-accent: #f97316; + --theme-background: #111827; + --menu-active: lightblue; + --menu-inactive: lightgreen; +}