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}