1use 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#[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#[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#[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}