Skip to main content

scoped_sass/
lib.rs

1//! Procedural macros for compiling scoped Sass files into Rust modules.
2//!
3//! This crate exposes two main macros:
4//!
5//! - [`scoped_scss!`] compiles a single `.scss` or `.sass` file into one Rust module.
6//! - [`scoped_scss_auto!`] scans a directory recursively and generates a nested module tree.
7//!
8//! [`scoped_sass_auto!`] is a shorter alias for [`scoped_scss_auto!`].
9//!
10//! # Example
11//!
12//! ```ignore
13//! use scoped_sass::scoped_scss;
14//!
15//! scoped_scss!(pub mod button, "src/components/button.scss");
16//!
17//! fn render() {
18//!     let css = button::CSS;
19//!     let root_class = button::classes::root;
20//! }
21//! ```
22//!
23//! # Leptos integration
24//!
25//! The auto macros generate `global_styles()` and `app_styles()` helpers
26//! that return `impl leptos::prelude::IntoView`, so consumers should depend on `leptos`
27//! when using [`scoped_scss_auto!`] or [`scoped_sass_auto!`].
28
29use proc_macro::TokenStream;
30use proc_macro2::Span;
31use quote::{format_ident, quote};
32use scoped_sass_core::ScopedModule;
33use std::collections::{BTreeMap, HashSet};
34use std::env;
35use std::fs;
36use std::io;
37use std::path::{Component, Path, PathBuf};
38use syn::parse::{Parse, ParseStream};
39use syn::{Ident, LitStr, Result, Token, Visibility, parse_macro_input};
40
41struct ScopedScssInput {
42    vis: Visibility,
43    _mod_token: Token![mod],
44    module_name: Ident,
45    _comma: Token![,],
46    scss_path: LitStr,
47}
48
49impl Parse for ScopedScssInput {
50    fn parse(input: ParseStream) -> Result<Self> {
51        Ok(Self {
52            vis: input.parse()?,
53            _mod_token: input.parse()?,
54            module_name: input.parse()?,
55            _comma: input.parse()?,
56            scss_path: input.parse()?,
57        })
58    }
59}
60
61struct ScopedScssAutoInput {
62    vis: Visibility,
63    _comma: Token![,],
64    source_dir: LitStr,
65    inject: bool,
66    output_file: Option<LitStr>,
67    href: Option<LitStr>,
68}
69
70#[derive(Default)]
71struct ModuleTreeNode {
72    children: BTreeMap<String, ModuleTreeNode>,
73    module: Option<ScopedModuleEntry>,
74}
75
76struct ScopedModuleEntry {
77    absolute_path: PathBuf,
78    compiled: ScopedModule,
79}
80
81impl Parse for ScopedScssAutoInput {
82    fn parse(input: ParseStream) -> Result<Self> {
83        let vis: Visibility = input.parse()?;
84        let comma: Token![,] = input.parse()?;
85        let source_dir: LitStr = input.parse()?;
86
87        let mut inject = true;
88        let mut output_file: Option<LitStr> = None;
89        let mut href: Option<LitStr> = None;
90
91        while !input.is_empty() {
92            let _: Token![,] = input.parse()?;
93            let key: Ident = input.parse()?;
94            let _: Token![=] = input.parse()?;
95
96            if key == "inject" {
97                let value: syn::LitBool = input.parse()?;
98                inject = value.value();
99            } else if key == "output_file" {
100                let value: LitStr = input.parse()?;
101                output_file = Some(value);
102            } else if key == "href" {
103                let value: LitStr = input.parse()?;
104                href = Some(value);
105            } else {
106                return Err(syn::Error::new(
107                    key.span(),
108                    "Unknown option. Supported: inject = <bool>, output_file = \"<path>\", href = \"<url>\"",
109                ));
110            }
111        }
112
113        Ok(Self {
114            vis,
115            _comma: comma,
116            source_dir,
117            inject,
118            output_file,
119            href,
120        })
121    }
122}
123
124/// Compiles a single Sass file into a Rust module with scoped class names.
125///
126/// The macro accepts the form:
127///
128/// ```text
129/// scoped_scss!(<visibility> mod <module_name>, "<path/to/file.scss>");
130/// ```
131///
132/// The path is resolved relative to `CARGO_MANIFEST_DIR`.
133///
134/// The generated module contains:
135///
136/// - `CSS`: the transformed CSS with deterministic scoped suffixes
137/// - `SUFFIX`: the suffix applied to local class selectors
138/// - `classes::<name>` constants for every discovered source class
139///
140/// # Example
141///
142/// ```ignore
143/// use scoped_sass::scoped_scss;
144///
145/// scoped_scss!(pub mod button, "src/components/button.scss");
146///
147/// fn render() {
148///     let css = button::CSS;
149///     let root_class = button::classes::root;
150/// }
151/// ```
152#[proc_macro]
153pub fn scoped_scss(input: TokenStream) -> TokenStream {
154    let input = parse_macro_input!(input as ScopedScssInput);
155
156    let manifest_dir = match std::env::var("CARGO_MANIFEST_DIR") {
157        Ok(value) => value,
158        Err(err) => {
159            return syn::Error::new(
160                Span::call_site(),
161                format!("CARGO_MANIFEST_DIR is not available: {err}"),
162            )
163            .to_compile_error()
164            .into();
165        }
166    };
167
168    let relative = input.scss_path.value();
169    let absolute_path = PathBuf::from(manifest_dir).join(&relative);
170    if !absolute_path.exists() {
171        return syn::Error::new(
172            input.scss_path.span(),
173            format!("SCSS file not found: {}", absolute_path.display()),
174        )
175        .to_compile_error()
176        .into();
177    }
178
179    let compiled = match scoped_sass_core::compile_module_file(&absolute_path, Default::default())
180    {
181        Ok(module) => module,
182        Err(err) => {
183            return syn::Error::new(
184                input.scss_path.span(),
185                format!(
186                    "Failed to compile scoped SCSS '{}': {err}",
187                    absolute_path.display()
188                ),
189            )
190            .to_compile_error()
191            .into();
192        }
193    };
194
195    module_tokens(&input.vis, &input.module_name, &absolute_path, &compiled).into()
196}
197
198/// Compiles every Sass file under a directory and generates a nested module tree.
199///
200/// The macro accepts the form:
201///
202/// ```text
203/// scoped_scss_auto!(
204///     <visibility>,
205///     "<source_dir>"
206///     [, inject = <bool>]
207///     [, output_file = "<path>"]
208///     [, href = "<url>"]
209/// );
210/// ```
211///
212/// The source directory is resolved relative to `CARGO_MANIFEST_DIR`.
213///
214/// Generated items include:
215///
216/// - `pub mod scoped { ... }` with one module per discovered Sass file
217/// - `global_styles()` for inline style injection
218/// - `app_styles()` as the high-level application stylesheet entry point
219/// - `cls!(...)` for joining optional and required class fragments
220///
221/// When `inject = true`, the generated style helpers render inline `<style>` elements
222/// using `leptos`. When `inject = false` and `output_file` is set, `app_styles()`
223/// renders an `@import` reference instead.
224///
225/// # Example
226///
227/// ```ignore
228/// use scoped_sass::scoped_scss_auto;
229///
230/// scoped_scss_auto!(
231///     pub,
232///     "src",
233///     inject = true,
234///     output_file = "public/styles/scoped.generated.css"
235/// );
236///
237/// fn render() {
238///     let root_class = scoped::components::button::classes::root;
239///     let styles = app_styles();
240/// }
241/// ```
242#[proc_macro]
243pub fn scoped_scss_auto(input: TokenStream) -> TokenStream {
244    let input = parse_macro_input!(input as ScopedScssAutoInput);
245
246    let manifest_dir = match std::env::var("CARGO_MANIFEST_DIR") {
247        Ok(value) => PathBuf::from(value),
248        Err(err) => {
249            return syn::Error::new(
250                Span::call_site(),
251                format!("CARGO_MANIFEST_DIR is not available: {err}"),
252            )
253            .to_compile_error()
254            .into();
255        }
256    };
257
258    let source_dir = manifest_dir.join(input.source_dir.value());
259    if !source_dir.exists() {
260        return syn::Error::new(
261            input.source_dir.span(),
262            format!("Source directory not found: {}", source_dir.display()),
263        )
264        .to_compile_error()
265        .into();
266    }
267
268    let running_in_rust_analyzer = is_rust_analyzer();
269
270    let mut scss_files = Vec::new();
271    if let Err(err) = collect_scss_files(&source_dir, &mut scss_files) {
272        return syn::Error::new(
273            input.source_dir.span(),
274            format!(
275                "Failed to scan source directory '{}': {err}",
276                source_dir.display()
277            ),
278        )
279        .to_compile_error()
280        .into();
281    }
282    scss_files.sort();
283
284    let mut module_tree = ModuleTreeNode::default();
285    let mut style_items = Vec::new();
286    let mut merged_css = String::new();
287
288    for scss_path in &scss_files {
289        let relative_path = scss_path.strip_prefix(&source_dir).unwrap_or(scss_path);
290        let mut module_path_segments = Vec::<String>::new();
291        if let Some(parent) = relative_path.parent() {
292            for component in parent.components() {
293                let Component::Normal(segment) = component else {
294                    continue;
295                };
296                module_path_segments.push(sanitize_ident(&segment.to_string_lossy()));
297            }
298        }
299
300        let Some(stem) = scss_path.file_stem().and_then(|s| s.to_str()) else {
301            return syn::Error::new(
302                Span::call_site(),
303                format!("Invalid SCSS file name: {}", scss_path.display()),
304            )
305            .to_compile_error()
306            .into();
307        };
308
309        let module_name = sanitize_ident(stem);
310        module_path_segments.push(module_name);
311
312        let compiled = match scoped_sass_core::compile_module_file(scss_path, Default::default())
313        {
314            Ok(module) => module,
315            Err(err) => {
316                return syn::Error::new(
317                    Span::call_site(),
318                    format!(
319                        "Failed to compile scoped SCSS '{}': {err}",
320                        scss_path.display()
321                    ),
322                )
323                .to_compile_error()
324                .into();
325            }
326        };
327        let css_for_merge = compiled.css.clone();
328
329        if let Err(err) = insert_module_into_tree(
330            &mut module_tree,
331            &module_path_segments,
332            scss_path.to_path_buf(),
333            compiled,
334        ) {
335            return syn::Error::new(Span::call_site(), err)
336                .to_compile_error()
337                .into();
338        }
339
340        let css_path = module_path_segments
341            .iter()
342            .map(|segment| format_ident!("{}", segment))
343            .collect::<Vec<_>>();
344        style_items.push(quote! { leptos::html::style().child(scoped::#(#css_path::)*CSS) });
345
346        if !merged_css.is_empty() {
347            merged_css.push('\n');
348        }
349        merged_css.push_str(&css_for_merge);
350    }
351
352    if let Some(output_file) = &input.output_file
353        && !running_in_rust_analyzer
354    {
355        let output_path = manifest_dir.join(output_file.value());
356        if let Err(err) = write_if_changed(&output_path, &merged_css) {
357            return syn::Error::new(
358                output_file.span(),
359                format!(
360                    "Failed to write generated stylesheet '{}': {err}",
361                    output_path.display()
362                ),
363            )
364            .to_compile_error()
365            .into();
366        }
367    }
368
369    let global_styles = if !input.inject || style_items.is_empty() {
370        quote! {
371            pub fn global_styles() -> impl leptos::prelude::IntoView {
372                ()
373            }
374        }
375    } else {
376        quote! {
377            pub fn global_styles() -> impl leptos::prelude::IntoView {
378                (#(#style_items),*)
379            }
380        }
381    };
382
383    let app_styles = if input.inject && !style_items.is_empty() {
384        quote! {
385            pub fn app_styles() -> impl leptos::prelude::IntoView {
386                global_styles()
387            }
388        }
389    } else if let Some(output_file) = &input.output_file {
390        let href = input
391            .href
392            .as_ref()
393            .map(|v| v.value())
394            .unwrap_or_else(|| default_href_from_output_path(&output_file.value()));
395        let import_css = LitStr::new(&format!("@import url('{href}');"), Span::call_site());
396
397        quote! {
398            pub fn app_styles() -> impl leptos::prelude::IntoView {
399                leptos::html::style().child(#import_css)
400            }
401        }
402    } else {
403        quote! {
404            pub fn app_styles() -> impl leptos::prelude::IntoView {
405                ()
406            }
407        }
408    };
409
410    let vis = &input.vis;
411    let scoped_tree = scoped_tree_tokens(&module_tree);
412    let expanded = quote! {
413        #vis mod scoped {
414            pub trait ClsArg {
415                fn push_to(self, out: &mut ::std::vec::Vec<::std::string::String>);
416            }
417
418            impl ClsArg for &str {
419                fn push_to(self, out: &mut ::std::vec::Vec<::std::string::String>) {
420                    if !self.is_empty() {
421                        out.push(self.to_string());
422                    }
423                }
424            }
425
426            impl ClsArg for String {
427                fn push_to(self, out: &mut ::std::vec::Vec<::std::string::String>) {
428                    if !self.is_empty() {
429                        out.push(self);
430                    }
431                }
432            }
433
434            impl ClsArg for &String {
435                fn push_to(self, out: &mut ::std::vec::Vec<::std::string::String>) {
436                    if !self.is_empty() {
437                        out.push(self.clone());
438                    }
439                }
440            }
441
442            impl<T> ClsArg for Option<T>
443            where
444                T: ClsArg,
445            {
446                fn push_to(self, out: &mut ::std::vec::Vec<::std::string::String>) {
447                    if let Some(value) = self {
448                        value.push_to(out);
449                    }
450                }
451            }
452
453            pub fn push_cls_arg<T>(out: &mut ::std::vec::Vec<::std::string::String>, value: T)
454            where
455                T: ClsArg,
456            {
457                value.push_to(out);
458            }
459
460            #(#scoped_tree)*
461        }
462
463        #[allow(unused_macros)]
464        macro_rules! cls {
465            ($($arg:expr),* $(,)?) => {{
466                let mut __parts: ::std::vec::Vec<::std::string::String> = ::std::vec::Vec::new();
467                $(
468                    $crate::scoped::push_cls_arg(&mut __parts, $arg);
469                )*
470                __parts.join(" ")
471            }};
472        }
473
474        #[allow(unused_imports)]
475        pub(crate) use cls;
476
477        #global_styles
478        #app_styles
479    };
480
481    let _ = write_generated_rust_snapshot(&manifest_dir, &expanded);
482
483    expanded.into()
484}
485
486/// Alias for [`scoped_scss_auto!`].
487///
488/// This exists for users who prefer a shorter macro name.
489#[proc_macro]
490pub fn scoped_sass_auto(input: TokenStream) -> TokenStream {
491    scoped_scss_auto(input)
492}
493
494fn collect_scss_files(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
495    for entry in fs::read_dir(dir)? {
496        let entry = entry?;
497        let path = entry.path();
498        if path.is_dir() {
499            collect_scss_files(&path, out)?;
500            continue;
501        }
502
503        let ext = path.extension().and_then(|e| e.to_str());
504        if matches!(ext, Some("scss") | Some("sass")) {
505            out.push(path);
506        }
507    }
508    Ok(())
509}
510
511fn write_if_changed(path: &Path, content: &str) -> std::io::Result<()> {
512    if let Ok(current) = fs::read_to_string(path)
513        && current == content
514    {
515        return Ok(());
516    }
517    if let Some(parent) = path.parent() {
518        fs::create_dir_all(parent)?;
519    }
520    fs::write(path, content)
521}
522
523fn module_tokens(
524    vis: &Visibility,
525    module_name: &Ident,
526    absolute_path: &Path,
527    compiled: &ScopedModule,
528) -> proc_macro2::TokenStream {
529    let module_body = module_body_tokens(absolute_path, compiled);
530    quote! {
531        #vis mod #module_name {
532            #module_body
533        }
534    }
535}
536
537fn module_body_tokens(absolute_path: &Path, compiled: &ScopedModule) -> proc_macro2::TokenStream {
538    let mut used_field_names = HashSet::new();
539    let mut field_idents = Vec::new();
540    let mut field_values = Vec::new();
541    for (class_name, transformed) in &compiled.classes {
542        let base = sanitize_ident(class_name);
543        let mut candidate = base.clone();
544        let mut idx = 1usize;
545        while !used_field_names.insert(candidate.clone()) {
546            idx += 1;
547            candidate = format!("{base}_{idx}");
548        }
549
550        field_idents.push(format_ident!("{}", candidate));
551        field_values.push(transformed.clone());
552    }
553
554    let classes_module = if field_idents.is_empty() {
555        quote! {}
556    } else {
557        quote! {
558            pub mod classes {
559                #(#[allow(non_upper_case_globals)] pub const #field_idents: &'static str = #field_values;)*
560            }
561        }
562    };
563
564    let abs_lit = LitStr::new(&absolute_path.to_string_lossy(), Span::call_site());
565    let css_lit = LitStr::new(&compiled.css, Span::call_site());
566    let suffix_lit = LitStr::new(&compiled.suffix, Span::call_site());
567    let dependency_literals = compiled
568        .dependencies
569        .iter()
570        .map(|dependency| LitStr::new(dependency, Span::call_site()))
571        .collect::<Vec<_>>();
572    let dependency_tracker_idents = dependency_literals
573        .iter()
574        .enumerate()
575        .map(|(idx, _)| format_ident!("_SCSS_TRACKER_{idx}"))
576        .collect::<Vec<_>>();
577
578    quote! {
579        #[allow(dead_code)]
580        const _SCSS_TRACKER: &str = include_str!(#abs_lit);
581        #(#[allow(dead_code)] const #dependency_tracker_idents: &str = include_str!(#dependency_literals);)*
582
583        pub const CSS: &str = #css_lit;
584        pub const SUFFIX: &str = #suffix_lit;
585
586        #classes_module
587    }
588}
589
590fn insert_module_into_tree(
591    root: &mut ModuleTreeNode,
592    segments: &[String],
593    absolute_path: PathBuf,
594    compiled: ScopedModule,
595) -> std::result::Result<(), String> {
596    if segments.is_empty() {
597        return Err("Cannot insert scoped module with empty path".to_string());
598    }
599
600    let mut node = root;
601    for segment in segments {
602        node = node.children.entry(segment.clone()).or_default();
603    }
604
605    if node.module.is_some() {
606        return Err(format!(
607            "Duplicate SCSS module path '{}'",
608            segments.join("::")
609        ));
610    }
611
612    node.module = Some(ScopedModuleEntry {
613        absolute_path,
614        compiled,
615    });
616    Ok(())
617}
618
619fn scoped_tree_tokens(root: &ModuleTreeNode) -> Vec<proc_macro2::TokenStream> {
620    root.children
621        .iter()
622        .map(|(segment, node)| {
623            let segment_ident = format_ident!("{}", segment);
624            let inner_items = scoped_tree_node_items(node);
625            quote! {
626                pub mod #segment_ident {
627                    #(#inner_items)*
628                }
629            }
630        })
631        .collect::<Vec<_>>()
632}
633
634fn scoped_tree_node_items(node: &ModuleTreeNode) -> Vec<proc_macro2::TokenStream> {
635    let mut items = Vec::new();
636
637    if let Some(module) = &node.module {
638        items.push(module_body_tokens(&module.absolute_path, &module.compiled));
639    }
640
641    for (segment, child) in &node.children {
642        let segment_ident = format_ident!("{}", segment);
643        let child_items = scoped_tree_node_items(child);
644        items.push(quote! {
645            pub mod #segment_ident {
646                #(#child_items)*
647            }
648        });
649    }
650
651    items
652}
653
654fn sanitize_ident(input: &str) -> String {
655    let mut out = String::with_capacity(input.len());
656    for ch in input.chars() {
657        if ch.is_ascii_alphanumeric() || ch == '_' {
658            out.push(ch);
659        } else {
660            out.push('_');
661        }
662    }
663
664    if out.is_empty() {
665        out.push_str("class_name");
666    }
667    if out
668        .chars()
669        .next()
670        .map(|c| c.is_ascii_digit())
671        .unwrap_or(false)
672    {
673        out.insert(0, '_');
674    }
675    out
676}
677
678fn default_href_from_output_path(output_path: &str) -> String {
679    if output_path.starts_with('/') {
680        output_path.to_string()
681    } else {
682        format!("/{output_path}")
683    }
684}
685
686fn is_rust_analyzer() -> bool {
687    std::env::var_os("RUST_ANALYZER_INTERNALS_DO_NOT_USE").is_some()
688}
689
690fn write_generated_rust_snapshot(
691    manifest_dir: &Path,
692    expanded: &proc_macro2::TokenStream,
693) -> io::Result<()> {
694    let crate_name = manifest_dir
695        .file_name()
696        .and_then(|v| v.to_str())
697        .unwrap_or("crate");
698
699    let content = format!(
700        "// @generated by scoped_sass\n// crate: {}\n\n{}\n",
701        crate_name, expanded
702    );
703    write_if_changed(&default_snapshot_path(manifest_dir), &content)?;
704    write_if_changed(&rust_analyzer_snapshot_path(manifest_dir), &content)
705}
706
707fn target_root_for_macro(manifest_dir: &Path) -> PathBuf {
708    if let Ok(target_dir) = env::var("CARGO_TARGET_DIR") {
709        return PathBuf::from(target_dir);
710    }
711    if let Ok(out_dir) = env::var("OUT_DIR")
712        && let Some(root) = target_root_from_out_dir(Path::new(&out_dir))
713    {
714        return root;
715    }
716    if let Some(workspace_root) = find_workspace_root(manifest_dir) {
717        return workspace_root.join("target");
718    }
719    manifest_dir.join("target")
720}
721
722fn target_root_from_out_dir(out_dir: &Path) -> Option<PathBuf> {
723    for ancestor in out_dir.ancestors() {
724        if ancestor.file_name().is_some_and(|n| n == "target") {
725            return Some(ancestor.to_path_buf());
726        }
727    }
728    None
729}
730
731fn find_workspace_root(start_dir: &Path) -> Option<PathBuf> {
732    for dir in start_dir.ancestors() {
733        let manifest = dir.join("Cargo.toml");
734        let Ok(contents) = fs::read_to_string(&manifest) else {
735            continue;
736        };
737        if contents.contains("[workspace]") {
738            return Some(dir.to_path_buf());
739        }
740    }
741    None
742}
743
744fn sanitize_file_name(input: &str) -> String {
745    let mut out = String::with_capacity(input.len());
746    for ch in input.chars() {
747        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
748            out.push(ch);
749        } else {
750            out.push('_');
751        }
752    }
753    if out.is_empty() {
754        "crate".to_string()
755    } else {
756        out
757    }
758}
759
760fn snapshot_file_name_for(manifest_dir: &Path) -> String {
761    let crate_name = manifest_dir
762        .file_name()
763        .and_then(|v| v.to_str())
764        .unwrap_or("crate");
765    format!(
766        "{}.scoped_styles.generated.rs",
767        sanitize_file_name(crate_name)
768    )
769}
770
771fn default_snapshot_path(manifest_dir: &Path) -> PathBuf {
772    let target_root = target_root_for_macro(manifest_dir);
773    target_root
774        .join("scoped_sass_cache/generated_rust")
775        .join(snapshot_file_name_for(manifest_dir))
776}
777
778fn rust_analyzer_snapshot_path(manifest_dir: &Path) -> PathBuf {
779    let base_target = if let Some(workspace_root) = find_workspace_root(manifest_dir) {
780        workspace_root.join("target")
781    } else {
782        target_root_for_macro(manifest_dir)
783    };
784    base_target
785        .join("rust-analyzer/scoped_sass_cache/generated_rust")
786        .join(snapshot_file_name_for(manifest_dir))
787}