Skip to main content

manganis_macro/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3
4use std::path::{Component, PathBuf};
5
6use proc_macro::TokenStream;
7use proc_macro2::Span;
8use quote::{ToTokens, quote};
9use syn::{
10    ItemStruct,
11    parse::{Parse, ParseStream},
12    parse_macro_input,
13};
14
15pub(crate) mod asset;
16pub(crate) mod css_module;
17pub(crate) mod ffi;
18pub(crate) mod linker;
19
20use crate::css_module::{CssModuleAttribute, expand_css_module_struct};
21
22/// The asset macro collects assets that will be included in the final binary
23///
24/// # Files
25///
26/// The file builder collects an arbitrary file.
27/// ```rust
28/// # use manganis::{asset, Asset};
29/// const _: Asset = asset!("/assets/asset.txt");
30/// ```
31///
32/// # Images
33///
34/// You can collect images which will be automatically optimized with the image builder:
35/// ```rust
36/// # use manganis::{asset, Asset};
37/// const _: Asset = asset!("/assets/image.png");
38/// ```
39/// Resize the image at compile time to make the assets file size smaller:
40/// ```rust
41/// # use manganis::{asset, Asset, AssetOptions, ImageSize};
42/// const _: Asset = asset!("/assets/image.png", AssetOptions::image().with_size(ImageSize::Manual { width: 52, height: 52 }));
43/// ```
44/// Or convert the image at compile time to a web friendly format:
45/// ```rust
46/// # use manganis::{asset, Asset, AssetOptions, ImageSize, ImageFormat};
47/// const _: Asset = asset!("/assets/image.png", AssetOptions::image().with_format(ImageFormat::Avif));
48/// ```
49/// You can mark images as preloaded to make them load faster in your app
50/// ```rust
51/// # use manganis::{asset, Asset, AssetOptions};
52/// const _: Asset = asset!("/assets/image.png", AssetOptions::image().with_preload(true));
53/// ```
54///
55/// # Path resolution
56///
57/// Paths are resolved relative to the current file if they begin with either `.` or `..`.
58/// ```rust
59/// # use manganis::{asset, Asset};
60/// const _: Asset = asset!("./asset.txt");
61/// const _: Asset = asset!("../assets/asset.txt");
62/// ```
63/// If a path points to within the output directory, the full path is used.
64/// The output directory is the value of the `OUT_DIR` environment variable.
65/// ```rust, ignore
66/// const _: Asset = asset!(concat!(env!("OUT_DIR"), "/generated-asset.txt"));
67/// ```
68/// Otherwise, paths are resolved relative to the crate root.
69/// Leading `/` characters are ignored.
70/// ```rust
71/// # use manganis::{asset, Asset};
72/// const _: Asset = asset!("assets/asset.txt");
73/// const _: Asset = asset!("/assets/asset.txt");
74/// ```
75#[proc_macro]
76pub fn asset(input: TokenStream) -> TokenStream {
77    let asset = parse_macro_input!(input as asset::AssetParser);
78
79    quote! { #asset }.into_token_stream().into()
80}
81
82/// Resolve an asset at compile time, returning `None` if the asset does not exist.
83///
84/// This behaves like the `asset!` macro when the asset can be resolved, but mirrors
85/// [`option_env!`](core::option_env) by returning an `Option` instead of emitting a compile error
86/// when the asset is missing.
87///
88/// ```rust
89/// # use manganis::{asset, option_asset, Asset};
90/// const REQUIRED: Asset = asset!("/assets/style.css");
91/// const OPTIONAL: Option<Asset> = option_asset!("/assets/maybe.css");
92/// ```
93#[proc_macro]
94pub fn option_asset(input: TokenStream) -> TokenStream {
95    let asset = parse_macro_input!(input as asset::AssetParser);
96
97    asset.expand_option_tokens().into()
98}
99
100/// Generate type-safe styles with scoped CSS class names.
101///
102/// The `css_module` attribute macro creates scoped CSS modules that prevent class name collisions
103/// by making each class globally unique. It expands the annotated struct to provide type-safe
104/// identifiers for your CSS classes, allowing you to reference styles in your Rust code with
105/// compile-time guarantees.
106///
107/// # Syntax
108///
109/// The `css_module` attribute takes:
110/// - The asset path. Uses the same rules for resolution as `asset!`.
111/// - Optional `AssetOptions` to configure the processing of your CSS module.
112///
113/// It must be applied to a unit struct:
114/// ```rust, ignore
115/// #[css_module("/assets/my-styles.css")]
116/// struct Styles;
117///
118/// #[css_module("/assets/my-styles.css", AssetOptions::css_module().with_minify(true))]
119/// struct Styles;
120/// ```
121///
122/// # Generation
123///
124/// The `css_module` attribute macro does two things:
125/// - It generates an asset and automatically inserts it as a stylesheet link in the document.
126/// - It expands the annotated struct with snake-case associated constants for your CSS class names.
127///
128/// ```rust, ignore
129/// // This macro usage:
130/// #[css_module("/assets/mycss.css")]
131/// struct Styles;
132///
133/// // Will expand the struct to (simplified):
134/// struct Styles {}
135///
136/// impl Styles {
137///     // Snake-cased class names can be accessed like this:
138///     pub const your_class: &str = "your_class-a1b2c3";
139/// }
140/// ```
141///
142/// # CSS Class Name Scoping
143///
144/// **The macro only processes CSS class selectors (`.class-name`).** Other selectors like IDs (`#id`),
145/// element selectors (`div`, `p`), attribute selectors, etc. are left unchanged and not exposed as
146/// Rust constants.
147///
148/// The macro collects all class selectors in your CSS file and transforms them to be globally unique
149/// by appending a hash. For example, `.myClass` becomes `.myClass-a1b2c3` where `a1b2c3` is a hash
150/// of the file path.
151///
152/// Class names are converted to snake_case for the Rust constants. For example:
153/// - `.fooBar` becomes `Styles::foo_bar`
154/// - `.my-class` becomes `Styles::my_class`
155///
156/// To prevent a class from being scoped, wrap it in `:global()`:
157/// ```css
158/// /* This class will be scoped */
159/// .my-class { color: blue; }
160///
161/// /* This class will NOT be scoped (no hash added) */
162/// :global(.global-class) { color: red; }
163///
164/// /* Element selectors and other CSS remain unchanged */
165/// div { margin: 0; }
166/// #my-id { padding: 10px; }
167/// ```
168///
169/// # Using Multiple CSS Modules
170///
171/// Multiple `css_module` attributes can be used in the same scope by applying them to different structs:
172/// ```rust, ignore
173/// // First CSS module
174/// #[css_module("/assets/styles1.css")]
175/// struct Styles;
176///
177/// // Second CSS module with a different struct name
178/// #[css_module("/assets/styles2.css")]
179/// struct OtherStyles;
180///
181/// // Access classes from both:
182/// rsx! {
183///     div { class: Styles::container }
184///     div { class: OtherStyles::button }
185/// }
186/// ```
187///
188/// # Asset Options
189///
190/// Similar to the `asset!()` macro, you can pass optional `AssetOptions` to configure processing:
191/// ```rust, ignore
192/// #[css_module(
193///     "/assets/mycss.css",
194///     AssetOptions::css_module()
195///         .with_minify(true)
196///         .with_preload(false)
197/// )]
198/// struct Styles;
199/// ```
200///
201/// # Example
202///
203/// First create a CSS file:
204/// ```css
205/// /* assets/styles.css */
206///
207/// .container {
208///     padding: 20px;
209/// }
210///
211/// .button {
212///     background-color: #373737;
213/// }
214///
215/// :global(.global-text) {
216///     font-weight: bold;
217/// }
218/// ```
219///
220/// Then use the `css_module` attribute:
221/// ```rust, ignore
222/// use dioxus::prelude::*;
223///
224/// fn app() -> Element {
225///     #[css_module("/assets/styles.css")]
226///     struct Styles;
227///
228///     rsx! {
229///         div { class: Styles::container,
230///             button { class: Styles::button, "Click me" }
231///             span { class: Styles::global_text, "This uses global class" }
232///         }
233///     }
234/// }
235/// ```
236#[proc_macro_attribute]
237pub fn css_module(input: TokenStream, item: TokenStream) -> TokenStream {
238    let attribute = parse_macro_input!(input as CssModuleAttribute);
239    let item_struct = parse_macro_input!(item as ItemStruct);
240    let mut tokens = proc_macro2::TokenStream::new();
241    expand_css_module_struct(&mut tokens, &attribute, &item_struct);
242    tokens.into()
243}
244
245/// Generate FFI bindings between Rust and native platforms (Swift/Kotlin)
246///
247/// This attribute macro parses an `extern "Swift"` or `extern "Kotlin"` block and generates:
248/// 1. Opaque type wrappers for foreign types
249/// 2. Function implementations with direct JNI/ObjC bindings
250/// 3. Linker metadata for the CLI to compile the native source
251///
252/// # Syntax
253///
254/// ```rust,ignore
255/// #[manganis::ffi("/src/ios")]
256/// extern "Swift" {
257///     pub type GeolocationPlugin;
258///     pub fn get_position(this: &GeolocationPlugin, high_accuracy: bool) -> Option<String>;
259/// }
260///
261/// #[manganis::ffi("/src/android")]
262/// extern "Kotlin" {
263///     pub type GeolocationPlugin;
264///     pub fn get_position(this: &GeolocationPlugin, high_accuracy: bool) -> Option<String>;
265/// }
266/// ```
267///
268/// # Path Parameter
269///
270/// The path in the attribute specifies the native source folder relative to `CARGO_MANIFEST_DIR`:
271/// - For Swift: A SwiftPM package folder containing `Package.swift`
272/// - For Kotlin: A Gradle project folder containing `build.gradle.kts`
273///
274/// # Type Declarations
275///
276/// Use `type Name;` to declare opaque foreign types. These become Rust structs wrapping
277/// the native object handle (GlobalRef for JNI, raw pointer for ObjC).
278///
279/// # Function Declarations
280///
281/// Functions can be:
282/// - **Instance methods**: First argument is `this: &TypeName`
283/// - **Static methods**: No `this` argument
284///
285/// # Supported Types
286///
287/// - Primitives: `bool`, `i8`-`i64`, `u8`-`u64`, `f32`, `f64`
288/// - Strings: `String`, `&str`
289/// - Options: `Option<T>` where T is supported
290/// - Opaque refs: `&TypeName` for foreign type references
291#[proc_macro_attribute]
292pub fn ffi(attr: TokenStream, item: TokenStream) -> TokenStream {
293    use ffi::{FfiAttribute, FfiBridgeParser};
294
295    let attr = parse_macro_input!(attr as FfiAttribute);
296    let item = parse_macro_input!(item as syn::ItemForeignMod);
297
298    match FfiBridgeParser::parse_with_attr(attr, item) {
299        Ok(parser) => parser.generate().into(),
300        Err(err) => err.to_compile_error().into(),
301    }
302}
303
304struct PathResolver {
305    src: PathBuf,
306    manifest_dir: PathBuf,
307    out_dir: Option<PathBuf>,
308    file_path: Option<PathBuf>,
309    looks_like_rust_analyzer: bool,
310}
311
312impl PathResolver {
313    fn new(src: impl Into<PathBuf>, span: &Span) -> Self {
314        let manifest_dir = Self::get_manifest_dir();
315
316        // The output directory is only available when a build script is present
317        let out_dir = std::env::var("OUT_DIR")
318            .ok()
319            .map(dunce::canonicalize)
320            .transpose()
321            .unwrap();
322
323        Self {
324            src: src.into(),
325            manifest_dir,
326            out_dir,
327            file_path: span.local_file(),
328            looks_like_rust_analyzer: looks_like_rust_analyzer(span),
329        }
330    }
331
332    fn get_manifest_dir() -> PathBuf {
333        dunce::canonicalize(std::env::var("CARGO_MANIFEST_DIR").unwrap()).unwrap()
334    }
335
336    fn resolve(self) -> Result<PathBuf, AssetParseError> {
337        // If this is an absolute path, try to resolve it directly.
338        // We only treat absolute paths as absolute if they are canonicalized
339        // to a location within the manifest directory or the output directory.
340        //
341        // Eg. `/path/to/build/dir` is treated as an absolute path if it is within
342        // the output directory, but `/src/assets/foo.css` is treated as a relative path
343        // because it does not canonicalize to a location within the manifest or output directory.
344        if self.src.is_absolute() {
345            if let Some(path) = dunce::canonicalize(&self.src)
346                .ok()
347                .and_then(|path| self.canonicalize_path_within_crate(path).ok())
348            {
349                return Ok(path);
350            }
351        }
352
353        // Otherwise, resolve the path relative to the current file or the manifest directory
354        let path = if self
355            .src
356            .as_path()
357            .components()
358            .next()
359            .is_some_and(|component| {
360                component == Component::CurDir || component == Component::ParentDir
361            }) {
362            self.resolve_relative_path()?
363        } else {
364            self.resolve_manifest_relative_path()
365        };
366
367        self.canonicalize_path_within_crate(path)
368    }
369
370    /// Resolve paths like `/path/to/file` that are relative to the manifest directory.
371    fn resolve_manifest_relative_path(&self) -> PathBuf {
372        // Strip the leading slash to make the path relative
373        let relative_path = self.src.strip_prefix("/").unwrap_or(self.src.as_path());
374        self.manifest_dir.join(relative_path)
375    }
376
377    /// Resolve paths like `./path/to/file` or `../path/to/file` that are relative to the current file.
378    fn resolve_relative_path(&self) -> Result<PathBuf, AssetParseError> {
379        if let Some(parent) = self.file_path.as_ref().and_then(|path| path.parent()) {
380            return Ok(parent.join(&self.src));
381        }
382
383        // If we are running in rust analyzer, just assume the path is valid and return an error when
384        // we compile if it doesn't exist
385        if self.looks_like_rust_analyzer {
386            let message = concat!(
387                "The asset macro was expanded under Rust Analyzer ",
388                "which doesn't support paths or local assets yet."
389            );
390
391            return Ok(message.into());
392        }
393
394        // Otherwise, return an error about the version of rust required for relative assets
395        Err(AssetParseError::FileBaseUnavailable)
396    }
397
398    /// Make sure a path is within the allowed scope (manifest directory or output directory).
399    fn canonicalize_path_within_crate(&self, path: PathBuf) -> Result<PathBuf, AssetParseError> {
400        // 1. Convert to absolute path
401        let Ok(path) = std::path::absolute(&path) else {
402            return Err(AssetParseError::DoesNotExist { path });
403        };
404
405        // 2. Ensure the path exists
406        let Ok(path) = dunce::canonicalize(&path) else {
407            return Err(AssetParseError::DoesNotExist { path });
408        };
409        let in_manifest_dir = path != self.manifest_dir && path.starts_with(&self.manifest_dir);
410
411        let in_out_dir = self
412            .out_dir
413            .as_ref()
414            .is_some_and(|dir| path != *dir && path.starts_with(dir));
415
416        // 3. Ensure the path doesn't escape the crate or output directories
417        //
418        // On windows, we can only compare the prefix if both paths are canonicalized (not just absolute)
419        //
420        // See: https://github.com/rust-lang/rust/issues/42869
421        if !in_manifest_dir && !in_out_dir {
422            return Err(AssetParseError::Outside { path });
423        }
424
425        Ok(path)
426    }
427}
428
429/// Parse `T`, while also collecting the tokens it was parsed from.
430fn parse_with_tokens<T: Parse>(input: ParseStream) -> syn::Result<(T, proc_macro2::TokenStream)> {
431    let begin = input.cursor();
432    let t: T = input.parse()?;
433    let end = input.cursor();
434
435    let mut cursor = begin;
436    let mut tokens = proc_macro2::TokenStream::new();
437    while cursor != end {
438        let (tt, next) = cursor.token_tree().unwrap();
439        tokens.extend(std::iter::once(tt));
440        cursor = next;
441    }
442
443    Ok((t, tokens))
444}
445
446#[derive(Debug, PartialEq, Eq)]
447enum AssetParseError {
448    FileBaseUnavailable,
449    DoesNotExist { path: PathBuf },
450    Outside { path: PathBuf },
451}
452
453impl std::fmt::Display for AssetParseError {
454    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455        match self {
456            AssetParseError::FileBaseUnavailable => {
457                write!(f, "Relative paths are only supported in Rust 1.88+.")
458            }
459            AssetParseError::DoesNotExist { path } => {
460                write!(f, "Path {} not found.", path.display())
461            }
462            AssetParseError::Outside { path } => {
463                write!(f, "Path {} is outside of allowed folders.", path.display())
464            }
465        }
466    }
467}
468
469/// Rust analyzer doesn't provide a stable way to detect if macros are running under it.
470/// This function uses heuristics to determine if we are running under rust analyzer for better error
471/// messages.
472fn looks_like_rust_analyzer(span: &Span) -> bool {
473    // Rust analyzer spans have a struct debug impl compared to rustcs custom debug impl
474    // RA Example: SpanData { range: 45..58, anchor: SpanAnchor(EditionedFileId(0, Edition2024), ErasedFileAstId { kind: Fn, index: 0, hash: 9CD8 }), ctx: SyntaxContext(4294967036) }
475    // Rustc Example: #0 bytes(70..83)
476    let looks_like_rust_analyzer_span = format!("{:?}", span).contains("ctx:");
477    // The rust analyzer macro expander runs under RUST_ANALYZER_INTERNALS_DO_NOT_USE
478    let looks_like_rust_analyzer_env = std::env::var("RUST_ANALYZER_INTERNALS_DO_NOT_USE").is_ok();
479    // The rust analyzer executable is named rust-analyzer-proc-macro-srv
480    let looks_like_rust_analyzer_exe = std::env::current_exe().ok().is_some_and(|p| {
481        p.file_stem()
482            .and_then(|s| s.to_str())
483            .is_some_and(|s| s.contains("rust-analyzer"))
484    });
485    looks_like_rust_analyzer_span || looks_like_rust_analyzer_env || looks_like_rust_analyzer_exe
486}
487
488#[cfg(test)]
489mod tests {
490    use std::path::PathBuf;
491
492    use super::{AssetParseError, PathResolver};
493    use tempfile::TempDir;
494
495    struct Ctx {
496        crate_root: PathBuf,
497        #[expect(unused)]
498        out_dir: TempDir,
499        out_path: PathBuf,
500    }
501
502    impl Ctx {
503        fn init() -> Self {
504            let out_dir = tempfile::tempdir().unwrap();
505            let out_path = dunce::canonicalize(out_dir.path()).unwrap();
506
507            std::fs::write(out_path.join("generated-asset.txt"), b"").unwrap();
508
509            Self {
510                crate_root: PathResolver::get_manifest_dir(),
511                out_dir,
512                out_path,
513            }
514        }
515
516        fn create_resolver(&self, src: impl Into<PathBuf>) -> PathResolver {
517            PathResolver {
518                src: src.into(),
519                manifest_dir: self.crate_root.clone(),
520                out_dir: Some(self.out_path.clone()),
521                file_path: Some(self.crate_root.join("src/lib.rs")),
522                looks_like_rust_analyzer: false,
523            }
524        }
525    }
526
527    #[test]
528    fn resolve_crate_path() {
529        let ctx = Ctx::init();
530
531        assert_eq!(
532            ctx.create_resolver("assets/asset.txt").resolve(),
533            Ok(ctx.crate_root.join("assets/asset.txt")),
534        );
535
536        assert_eq!(
537            ctx.create_resolver("/assets/asset.txt").resolve(),
538            Ok(ctx.crate_root.join("assets/asset.txt")),
539        );
540    }
541
542    #[test]
543    fn resolve_missing_crate_path() {
544        let ctx = Ctx::init();
545
546        assert_eq!(
547            ctx.create_resolver("assets/does-not-exist.txt").resolve(),
548            Err(AssetParseError::DoesNotExist {
549                path: ctx.crate_root.join("assets/does-not-exist.txt"),
550            }),
551        );
552
553        assert_eq!(
554            ctx.create_resolver("/assets/does-not-exist.txt").resolve(),
555            Err(AssetParseError::DoesNotExist {
556                path: ctx.crate_root.join("assets/does-not-exist.txt"),
557            }),
558        );
559    }
560
561    #[test]
562    fn resolve_outside_crate_path() {
563        let ctx = Ctx::init();
564
565        assert_eq!(
566            ctx.create_resolver("/").resolve(),
567            Err(AssetParseError::Outside {
568                path: ctx.crate_root,
569            }),
570        );
571    }
572
573    #[test]
574    fn resolve_file_path() {
575        let ctx = Ctx::init();
576
577        assert_eq!(
578            ctx.create_resolver("./asset.txt").resolve(),
579            Ok(ctx.crate_root.join("src/asset.txt")),
580        );
581
582        assert_eq!(
583            ctx.create_resolver("../assets/asset.txt").resolve(),
584            Ok(ctx.crate_root.join("assets/asset.txt")),
585        );
586    }
587
588    #[test]
589    fn resolve_missing_file_path() {
590        let ctx = Ctx::init();
591
592        assert_eq!(
593            ctx.create_resolver("./does-not-exist.txt").resolve(),
594            Err(AssetParseError::DoesNotExist {
595                path: ctx.crate_root.join("src/does-not-exist.txt"),
596            }),
597        );
598
599        assert_eq!(
600            ctx.create_resolver("../assets/does-not-exist.txt")
601                .resolve(),
602            Err(AssetParseError::DoesNotExist {
603                path: ctx.crate_root.join("src/../assets/does-not-exist.txt"),
604            }),
605        );
606    }
607
608    #[test]
609    fn resolve_outside_file_path() {
610        let ctx = Ctx::init();
611
612        assert_eq!(
613            ctx.create_resolver("./..").resolve(),
614            Err(AssetParseError::Outside {
615                path: ctx.crate_root.clone(),
616            }),
617        );
618
619        assert_eq!(
620            ctx.create_resolver("..").resolve(),
621            Err(AssetParseError::Outside {
622                path: ctx.crate_root.clone(),
623            }),
624        );
625    }
626
627    #[test]
628    fn resolve_out_path() {
629        let ctx = Ctx::init();
630
631        let path = ctx.out_path.join("generated-asset.txt");
632
633        assert_eq!(ctx.create_resolver(&path).resolve(), Ok(path));
634    }
635
636    #[test]
637    #[cfg(unix)]
638    fn resolve_symlinked_out_path() {
639        let ctx = Ctx::init();
640        let link_root = tempfile::tempdir().unwrap();
641        let symlinked_out_path = link_root.path().join("out");
642        std::os::unix::fs::symlink(&ctx.out_path, &symlinked_out_path).unwrap();
643
644        let path = symlinked_out_path.join("generated-asset.txt");
645
646        assert_eq!(
647            PathResolver {
648                src: path,
649                manifest_dir: ctx.crate_root.clone(),
650                out_dir: Some(ctx.out_path.clone()),
651                file_path: Some(ctx.crate_root.join("src/lib.rs")),
652                looks_like_rust_analyzer: false,
653            }
654            .resolve(),
655            Ok(ctx.out_path.join("generated-asset.txt")),
656        );
657    }
658
659    #[test]
660    fn resolve_missing_out_path() {
661        let ctx = Ctx::init();
662
663        let path = ctx.out_path.join("does-not-exist.txt");
664
665        assert!(matches!(
666            ctx.create_resolver(&path).resolve(),
667            Err(AssetParseError::DoesNotExist { .. }),
668        ));
669    }
670
671    #[test]
672    fn resolve_outside_out_path() {
673        let ctx = Ctx::init();
674
675        assert!(ctx.create_resolver(&ctx.out_path).resolve().is_err());
676    }
677}