use proc_macro::TokenStream;
use proc_macro2::Span;
use quote::{format_ident, quote};
use scoped_sass_core::ScopedModule;
use std::collections::{BTreeMap, HashSet};
use std::env;
use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};
use syn::parse::{Parse, ParseStream};
use syn::{Ident, LitStr, Result, Token, Visibility, parse_macro_input};
struct ScopedScssInput {
vis: Visibility,
_mod_token: Token![mod],
module_name: Ident,
_comma: Token![,],
scss_path: LitStr,
}
impl Parse for ScopedScssInput {
fn parse(input: ParseStream) -> Result<Self> {
Ok(Self {
vis: input.parse()?,
_mod_token: input.parse()?,
module_name: input.parse()?,
_comma: input.parse()?,
scss_path: input.parse()?,
})
}
}
struct ScopedScssAutoInput {
vis: Visibility,
_comma: Token![,],
source_dir: LitStr,
inject: bool,
output_file: Option<LitStr>,
href: Option<LitStr>,
}
#[derive(Default)]
struct ModuleTreeNode {
children: BTreeMap<String, ModuleTreeNode>,
module: Option<ScopedModuleEntry>,
}
struct ScopedModuleEntry {
absolute_path: PathBuf,
compiled: ScopedModule,
}
impl Parse for ScopedScssAutoInput {
fn parse(input: ParseStream) -> Result<Self> {
let vis: Visibility = input.parse()?;
let comma: Token![,] = input.parse()?;
let source_dir: LitStr = input.parse()?;
let mut inject = true;
let mut output_file: Option<LitStr> = None;
let mut href: Option<LitStr> = None;
while !input.is_empty() {
let _: Token![,] = input.parse()?;
let key: Ident = input.parse()?;
let _: Token![=] = input.parse()?;
if key == "inject" {
let value: syn::LitBool = input.parse()?;
inject = value.value();
} else if key == "output_file" {
let value: LitStr = input.parse()?;
output_file = Some(value);
} else if key == "href" {
let value: LitStr = input.parse()?;
href = Some(value);
} else {
return Err(syn::Error::new(
key.span(),
"Unknown option. Supported: inject = <bool>, output_file = \"<path>\", href = \"<url>\"",
));
}
}
Ok(Self {
vis,
_comma: comma,
source_dir,
inject,
output_file,
href,
})
}
}
#[proc_macro]
pub fn scoped_scss(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as ScopedScssInput);
let manifest_dir = match std::env::var("CARGO_MANIFEST_DIR") {
Ok(value) => value,
Err(err) => {
return syn::Error::new(
Span::call_site(),
format!("CARGO_MANIFEST_DIR is not available: {err}"),
)
.to_compile_error()
.into();
}
};
let relative = input.scss_path.value();
let absolute_path = PathBuf::from(manifest_dir).join(&relative);
if !absolute_path.exists() {
return syn::Error::new(
input.scss_path.span(),
format!("SCSS file not found: {}", absolute_path.display()),
)
.to_compile_error()
.into();
}
let compiled = match scoped_sass_core::compile_module_file(&absolute_path, Default::default())
{
Ok(module) => module,
Err(err) => {
return syn::Error::new(
input.scss_path.span(),
format!(
"Failed to compile scoped SCSS '{}': {err}",
absolute_path.display()
),
)
.to_compile_error()
.into();
}
};
module_tokens(&input.vis, &input.module_name, &absolute_path, &compiled).into()
}
#[proc_macro]
pub fn scoped_scss_auto(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as ScopedScssAutoInput);
let manifest_dir = match std::env::var("CARGO_MANIFEST_DIR") {
Ok(value) => PathBuf::from(value),
Err(err) => {
return syn::Error::new(
Span::call_site(),
format!("CARGO_MANIFEST_DIR is not available: {err}"),
)
.to_compile_error()
.into();
}
};
let source_dir = manifest_dir.join(input.source_dir.value());
if !source_dir.exists() {
return syn::Error::new(
input.source_dir.span(),
format!("Source directory not found: {}", source_dir.display()),
)
.to_compile_error()
.into();
}
let running_in_rust_analyzer = is_rust_analyzer();
let mut scss_files = Vec::new();
if let Err(err) = collect_scss_files(&source_dir, &mut scss_files) {
return syn::Error::new(
input.source_dir.span(),
format!(
"Failed to scan source directory '{}': {err}",
source_dir.display()
),
)
.to_compile_error()
.into();
}
scss_files.sort();
let mut module_tree = ModuleTreeNode::default();
let mut style_items = Vec::new();
let mut merged_css = String::new();
for scss_path in &scss_files {
let relative_path = scss_path.strip_prefix(&source_dir).unwrap_or(scss_path);
let mut module_path_segments = Vec::<String>::new();
if let Some(parent) = relative_path.parent() {
for component in parent.components() {
let Component::Normal(segment) = component else {
continue;
};
module_path_segments.push(sanitize_ident(&segment.to_string_lossy()));
}
}
let Some(stem) = scss_path.file_stem().and_then(|s| s.to_str()) else {
return syn::Error::new(
Span::call_site(),
format!("Invalid SCSS file name: {}", scss_path.display()),
)
.to_compile_error()
.into();
};
let module_name = sanitize_ident(stem);
module_path_segments.push(module_name);
let compiled = match scoped_sass_core::compile_module_file(scss_path, Default::default())
{
Ok(module) => module,
Err(err) => {
return syn::Error::new(
Span::call_site(),
format!(
"Failed to compile scoped SCSS '{}': {err}",
scss_path.display()
),
)
.to_compile_error()
.into();
}
};
let css_for_merge = compiled.css.clone();
if let Err(err) = insert_module_into_tree(
&mut module_tree,
&module_path_segments,
scss_path.to_path_buf(),
compiled,
) {
return syn::Error::new(Span::call_site(), err)
.to_compile_error()
.into();
}
let css_path = module_path_segments
.iter()
.map(|segment| format_ident!("{}", segment))
.collect::<Vec<_>>();
style_items.push(quote! { leptos::html::style().child(scoped::#(#css_path::)*CSS) });
if !merged_css.is_empty() {
merged_css.push('\n');
}
merged_css.push_str(&css_for_merge);
}
if let Some(output_file) = &input.output_file
&& !running_in_rust_analyzer
{
let output_path = manifest_dir.join(output_file.value());
if let Err(err) = write_if_changed(&output_path, &merged_css) {
return syn::Error::new(
output_file.span(),
format!(
"Failed to write generated stylesheet '{}': {err}",
output_path.display()
),
)
.to_compile_error()
.into();
}
}
let global_styles = if !input.inject || style_items.is_empty() {
quote! {
pub fn global_styles() -> impl leptos::prelude::IntoView {
()
}
}
} else {
quote! {
pub fn global_styles() -> impl leptos::prelude::IntoView {
(#(#style_items),*)
}
}
};
let app_styles = if input.inject && !style_items.is_empty() {
quote! {
pub fn app_styles() -> impl leptos::prelude::IntoView {
global_styles()
}
}
} else if let Some(output_file) = &input.output_file {
let href = input
.href
.as_ref()
.map(|v| v.value())
.unwrap_or_else(|| default_href_from_output_path(&output_file.value()));
let import_css = LitStr::new(&format!("@import url('{href}');"), Span::call_site());
quote! {
pub fn app_styles() -> impl leptos::prelude::IntoView {
leptos::html::style().child(#import_css)
}
}
} else {
quote! {
pub fn app_styles() -> impl leptos::prelude::IntoView {
()
}
}
};
let vis = &input.vis;
let scoped_tree = scoped_tree_tokens(&module_tree);
let expanded = quote! {
#vis mod scoped {
pub trait ClsArg {
fn push_to(self, out: &mut ::std::vec::Vec<::std::string::String>);
}
impl ClsArg for &str {
fn push_to(self, out: &mut ::std::vec::Vec<::std::string::String>) {
if !self.is_empty() {
out.push(self.to_string());
}
}
}
impl ClsArg for String {
fn push_to(self, out: &mut ::std::vec::Vec<::std::string::String>) {
if !self.is_empty() {
out.push(self);
}
}
}
impl ClsArg for &String {
fn push_to(self, out: &mut ::std::vec::Vec<::std::string::String>) {
if !self.is_empty() {
out.push(self.clone());
}
}
}
impl<T> ClsArg for Option<T>
where
T: ClsArg,
{
fn push_to(self, out: &mut ::std::vec::Vec<::std::string::String>) {
if let Some(value) = self {
value.push_to(out);
}
}
}
pub fn push_cls_arg<T>(out: &mut ::std::vec::Vec<::std::string::String>, value: T)
where
T: ClsArg,
{
value.push_to(out);
}
#(#scoped_tree)*
}
#[allow(unused_macros)]
macro_rules! cls {
($($arg:expr),* $(,)?) => {{
let mut __parts: ::std::vec::Vec<::std::string::String> = ::std::vec::Vec::new();
$(
$crate::scoped::push_cls_arg(&mut __parts, $arg);
)*
__parts.join(" ")
}};
}
#[allow(unused_imports)]
pub(crate) use cls;
#global_styles
#app_styles
};
let _ = write_generated_rust_snapshot(&manifest_dir, &expanded);
expanded.into()
}
#[proc_macro]
pub fn scoped_sass_auto(input: TokenStream) -> TokenStream {
scoped_scss_auto(input)
}
fn collect_scss_files(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_scss_files(&path, out)?;
continue;
}
let ext = path.extension().and_then(|e| e.to_str());
if matches!(ext, Some("scss") | Some("sass")) {
out.push(path);
}
}
Ok(())
}
fn write_if_changed(path: &Path, content: &str) -> std::io::Result<()> {
if let Ok(current) = fs::read_to_string(path)
&& current == content
{
return Ok(());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, content)
}
fn module_tokens(
vis: &Visibility,
module_name: &Ident,
absolute_path: &Path,
compiled: &ScopedModule,
) -> proc_macro2::TokenStream {
let module_body = module_body_tokens(absolute_path, compiled);
quote! {
#vis mod #module_name {
#module_body
}
}
}
fn module_body_tokens(absolute_path: &Path, compiled: &ScopedModule) -> proc_macro2::TokenStream {
let mut used_field_names = HashSet::new();
let mut field_idents = Vec::new();
let mut field_values = Vec::new();
for (class_name, transformed) in &compiled.classes {
let base = sanitize_ident(class_name);
let mut candidate = base.clone();
let mut idx = 1usize;
while !used_field_names.insert(candidate.clone()) {
idx += 1;
candidate = format!("{base}_{idx}");
}
field_idents.push(format_ident!("{}", candidate));
field_values.push(transformed.clone());
}
let classes_module = if field_idents.is_empty() {
quote! {}
} else {
quote! {
pub mod classes {
#(#[allow(non_upper_case_globals)] pub const #field_idents: &'static str = #field_values;)*
}
}
};
let abs_lit = LitStr::new(&absolute_path.to_string_lossy(), Span::call_site());
let css_lit = LitStr::new(&compiled.css, Span::call_site());
let suffix_lit = LitStr::new(&compiled.suffix, Span::call_site());
let dependency_literals = compiled
.dependencies
.iter()
.map(|dependency| LitStr::new(dependency, Span::call_site()))
.collect::<Vec<_>>();
let dependency_tracker_idents = dependency_literals
.iter()
.enumerate()
.map(|(idx, _)| format_ident!("_SCSS_TRACKER_{idx}"))
.collect::<Vec<_>>();
quote! {
#[allow(dead_code)]
const _SCSS_TRACKER: &str = include_str!(#abs_lit);
#(#[allow(dead_code)] const #dependency_tracker_idents: &str = include_str!(#dependency_literals);)*
pub const CSS: &str = #css_lit;
pub const SUFFIX: &str = #suffix_lit;
#classes_module
}
}
fn insert_module_into_tree(
root: &mut ModuleTreeNode,
segments: &[String],
absolute_path: PathBuf,
compiled: ScopedModule,
) -> std::result::Result<(), String> {
if segments.is_empty() {
return Err("Cannot insert scoped module with empty path".to_string());
}
let mut node = root;
for segment in segments {
node = node.children.entry(segment.clone()).or_default();
}
if node.module.is_some() {
return Err(format!(
"Duplicate SCSS module path '{}'",
segments.join("::")
));
}
node.module = Some(ScopedModuleEntry {
absolute_path,
compiled,
});
Ok(())
}
fn scoped_tree_tokens(root: &ModuleTreeNode) -> Vec<proc_macro2::TokenStream> {
root.children
.iter()
.map(|(segment, node)| {
let segment_ident = format_ident!("{}", segment);
let inner_items = scoped_tree_node_items(node);
quote! {
pub mod #segment_ident {
#(#inner_items)*
}
}
})
.collect::<Vec<_>>()
}
fn scoped_tree_node_items(node: &ModuleTreeNode) -> Vec<proc_macro2::TokenStream> {
let mut items = Vec::new();
if let Some(module) = &node.module {
items.push(module_body_tokens(&module.absolute_path, &module.compiled));
}
for (segment, child) in &node.children {
let segment_ident = format_ident!("{}", segment);
let child_items = scoped_tree_node_items(child);
items.push(quote! {
pub mod #segment_ident {
#(#child_items)*
}
});
}
items
}
fn sanitize_ident(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() {
out.push_str("class_name");
}
if out
.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false)
{
out.insert(0, '_');
}
out
}
fn default_href_from_output_path(output_path: &str) -> String {
if output_path.starts_with('/') {
output_path.to_string()
} else {
format!("/{output_path}")
}
}
fn is_rust_analyzer() -> bool {
std::env::var_os("RUST_ANALYZER_INTERNALS_DO_NOT_USE").is_some()
}
fn write_generated_rust_snapshot(
manifest_dir: &Path,
expanded: &proc_macro2::TokenStream,
) -> io::Result<()> {
let crate_name = manifest_dir
.file_name()
.and_then(|v| v.to_str())
.unwrap_or("crate");
let content = format!(
"// @generated by scoped_sass\n// crate: {}\n\n{}\n",
crate_name, expanded
);
write_if_changed(&default_snapshot_path(manifest_dir), &content)?;
write_if_changed(&rust_analyzer_snapshot_path(manifest_dir), &content)
}
fn target_root_for_macro(manifest_dir: &Path) -> PathBuf {
if let Ok(target_dir) = env::var("CARGO_TARGET_DIR") {
return PathBuf::from(target_dir);
}
if let Ok(out_dir) = env::var("OUT_DIR")
&& let Some(root) = target_root_from_out_dir(Path::new(&out_dir))
{
return root;
}
if let Some(workspace_root) = find_workspace_root(manifest_dir) {
return workspace_root.join("target");
}
manifest_dir.join("target")
}
fn target_root_from_out_dir(out_dir: &Path) -> Option<PathBuf> {
for ancestor in out_dir.ancestors() {
if ancestor.file_name().is_some_and(|n| n == "target") {
return Some(ancestor.to_path_buf());
}
}
None
}
fn find_workspace_root(start_dir: &Path) -> Option<PathBuf> {
for dir in start_dir.ancestors() {
let manifest = dir.join("Cargo.toml");
let Ok(contents) = fs::read_to_string(&manifest) else {
continue;
};
if contents.contains("[workspace]") {
return Some(dir.to_path_buf());
}
}
None
}
fn sanitize_file_name(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() {
"crate".to_string()
} else {
out
}
}
fn snapshot_file_name_for(manifest_dir: &Path) -> String {
let crate_name = manifest_dir
.file_name()
.and_then(|v| v.to_str())
.unwrap_or("crate");
format!(
"{}.scoped_styles.generated.rs",
sanitize_file_name(crate_name)
)
}
fn default_snapshot_path(manifest_dir: &Path) -> PathBuf {
let target_root = target_root_for_macro(manifest_dir);
target_root
.join("scoped_sass_cache/generated_rust")
.join(snapshot_file_name_for(manifest_dir))
}
fn rust_analyzer_snapshot_path(manifest_dir: &Path) -> PathBuf {
let base_target = if let Some(workspace_root) = find_workspace_root(manifest_dir) {
workspace_root.join("target")
} else {
target_root_for_macro(manifest_dir)
};
base_target
.join("rust-analyzer/scoped_sass_cache/generated_rust")
.join(snapshot_file_name_for(manifest_dir))
}