include_wasm_rs/
lib.rs

1//! Provides a macro for including a Rust project as Wasm bytecode,
2//! by compiling it at build time of the invoking module.
3
4#![cfg_attr(feature = "proc_macro_span", feature(proc_macro_span))]
5
6use std::{fmt::Display, path::PathBuf, process::Command, sync::Mutex};
7
8use proc_macro::TokenStream;
9use quote::{quote, ToTokens};
10use syn::{parse::ParseStream, parse_macro_input, spanned::Spanned};
11
12// Hacky polyfill for `proc_macro::Span::source_file`
13#[cfg(not(feature = "proc_macro_span"))]
14fn find_me(root: &str, pattern: &str) -> PathBuf {
15    let mut options = Vec::new();
16
17    for path in glob::glob(&std::path::Path::new(root).join("**/*.rs").to_string_lossy())
18        .unwrap()
19        .flatten()
20    {
21        if let Ok(mut f) = std::fs::File::open(&path) {
22            let mut contents = String::new();
23            std::io::Read::read_to_string(&mut f, &mut contents).ok();
24            if contents.contains(pattern) {
25                options.push(path.to_owned());
26            }
27        }
28    }
29
30    match options.as_slice() {
31        [] => panic!(
32            "could not find invocation point - maybe it was in a macro? \
33            If you are on nightly (or in the future), enable the `proc_macro_span` \
34            feature on `include-wasm-rs` to use advanced call site resolution, \
35            but until then each instance of the `build_wasm` must be present \
36            in the source text, and each must have a unique argument."
37        ),
38        [v] => v.clone(),
39        _ => panic!(
40            "found more than one contender for macro invocation location. \
41            If you are on nightly (or in the future), enable the `proc_macro_span` \
42            feature on `include-wasm-rs` to use advanced call site resolution, \
43            but until then each instance of the `build_wasm` must be present \
44            in the source text, and each must have a unique argument. \
45            Found potential invocation locations: {:?}",
46            options
47                .into_iter()
48                .map(|path| format!("`{}`", path.display()))
49                .collect::<Vec<String>>()
50        ),
51    }
52}
53
54#[derive(Default)]
55struct TargetFeatures {
56    atomics: bool,
57    bulk_memory: bool,
58    mutable_globals: bool,
59}
60
61impl TargetFeatures {
62    fn from_list_of_exprs(
63        elems: syn::punctuated::Punctuated<syn::Expr, syn::Token![,]>,
64    ) -> syn::parse::Result<Self> {
65        let mut res = Self::default();
66
67        for elem in elems {
68            let span = elem.span();
69            let name = match elem {
70                syn::Expr::Path(ident)
71                    if ident.attrs.is_empty()
72                        && ident.qself.is_none()
73                        && ident.path.leading_colon.is_none()
74                        && ident.path.segments.len() == 1
75                        && ident.path.segments[0].arguments.is_empty() =>
76                {
77                    ident.path.segments[0].ident.to_string()
78                }
79                _ => {
80                    return Err(syn::Error::new(
81                        span,
82                        "expected a single token giving a feature",
83                    ))
84                }
85            };
86
87            match name.as_str() {
88                "atomics" => res.atomics = true,
89                "bulk_memory" => res.bulk_memory = true,
90                "mutable_globals" => res.mutable_globals = true,
91                _ => return Err(syn::Error::new(span, "unknown feature")),
92            }
93        }
94
95        Ok(res)
96    }
97}
98
99fn degroup_expr(expr: syn::Expr) -> syn::Expr {
100    match expr {
101        syn::Expr::Group(syn::ExprGroup {
102            attrs,
103            group_token: _,
104            expr,
105        }) if attrs.is_empty() => degroup_expr(*expr),
106        expr => expr,
107    }
108}
109
110#[derive(Default)]
111struct Args {
112    module_dir: PathBuf,
113    features: TargetFeatures,
114    env_vars: Vec<(String, String)>,
115    release: bool,
116}
117
118impl syn::parse::Parse for Args {
119    fn parse(input: ParseStream) -> syn::parse::Result<Self> {
120        // Just a string gives a path, with default options
121        if input.peek(syn::LitStr) {
122            let path = input.parse::<syn::LitStr>()?;
123            return Ok(Self {
124                module_dir: PathBuf::from(path.value()),
125                ..Self::default()
126            });
127        }
128
129        // Else we expect a json-like dict of options
130        let mut res = Self::default();
131
132        let dict =
133            syn::punctuated::Punctuated::<syn::FieldValue, syn::Token![,]>::parse_terminated(
134                input,
135            )?;
136        for mut value in dict {
137            if !value.attrs.is_empty() {
138                return Err(syn::Error::new(value.attrs[0].span(), "unexpected element"));
139            }
140            let name = match &value.member {
141                syn::Member::Named(name) => name.to_string(),
142                syn::Member::Unnamed(unnamed) => unnamed.index.to_string(),
143            };
144
145            value.expr = degroup_expr(value.expr);
146
147            // Parse value depending on key
148            match name.as_str() {
149                "path" => {
150                    // String as PathBuf
151                    res.module_dir = match value.expr {
152                        syn::Expr::Lit(syn::ExprLit {
153                            attrs,
154                            lit: syn::Lit::Str(path),
155                        }) if attrs.is_empty() => PathBuf::from(path.value()),
156                        _ => {
157                            return Err(syn::Error::new(
158                                value.expr.span(),
159                                format!("expected literal string, got {:?}", value.expr),
160                            ))
161                        }
162                    };
163                }
164                "release" => {
165                    // Boolean
166                    res.release = match value.expr {
167                        syn::Expr::Lit(syn::ExprLit {
168                            attrs,
169                            lit: syn::Lit::Bool(release),
170                        }) if attrs.is_empty() => release.value,
171                        _ => return Err(syn::Error::new(value.expr.span(), "expected boolean")),
172                    };
173                }
174                "features" => {
175                    // Array of identifiers
176                    match value.expr {
177                        syn::Expr::Array(syn::ExprArray {
178                            attrs,
179                            bracket_token: _,
180                            elems,
181                        }) if attrs.is_empty() => {
182                            res.features = TargetFeatures::from_list_of_exprs(elems)?
183                        }
184                        _ => return Err(syn::Error::new(value.expr.span(), "expected boolean")),
185                    };
186                }
187                "env" => {
188                    // Dictionary of key value pairs
189                    match value.expr {
190                        syn::Expr::Struct(syn::ExprStruct {
191                            attrs,
192                            qself: None,
193                            path:
194                                syn::Path {
195                                    leading_colon: None,
196                                    segments,
197                                },
198                            brace_token: _,
199                            fields,
200                            dot2_token: None,
201                            rest: None,
202                        }) if attrs.is_empty()
203                            && segments.len() == 1
204                            && segments[0].arguments.is_empty()
205                            && segments[0].ident == "Env" =>
206                        {
207                            for field in fields {
208                                let span = field.span();
209                                if !field.attrs.is_empty() || field.colon_token.is_none() {
210                                    return Err(syn::Error::new(span, "expected key value pair"));
211                                }
212
213                                let env_name = match &field.member {
214                                    syn::Member::Named(name) => name.to_string(),
215                                    _ => {
216                                        return Err(syn::Error::new(
217                                            span,
218                                            "expected env variable name",
219                                        ))
220                                    }
221                                };
222
223                                let mut expr = &field.expr;
224                                while let syn::Expr::Group(syn::ExprGroup {
225                                    attrs,
226                                    group_token: _,
227                                    expr: inner_expr,
228                                }) = expr
229                                {
230                                    if !attrs.is_empty() {
231                                        return Err(syn::Error::new(
232                                            attrs[0].span(),
233                                            "expected a string, int, float or bool",
234                                        ));
235                                    }
236
237                                    expr = inner_expr;
238                                }
239
240                                let env_val = match expr {
241                                    syn::Expr::Lit(syn::ExprLit { attrs, lit })
242                                        if attrs.is_empty() =>
243                                    {
244                                        match lit {
245                                            syn::Lit::Str(v) => v.value(),
246                                            syn::Lit::Int(i) => i.to_string(),
247                                            syn::Lit::Float(f) => f.to_string(),
248                                            syn::Lit::Bool(b) => b.value.to_string(),
249                                            _ => {
250                                                return Err(syn::Error::new(
251                                                    lit.span(),
252                                                    format!("expected a string, int, float or bool, found literal `{}`", lit.into_token_stream()),
253                                                ))
254                                            }
255                                        }
256                                    }
257                                    _ => {
258                                        return Err(syn::Error::new(
259                                            field.expr.span(),
260                                            format!("expected a string, int, float or bool, found `{}`", field.expr.into_token_stream()),
261                                        ))
262                                    }
263                                };
264
265                                res.env_vars.push((env_name, env_val));
266                            }
267                        }
268                        _ => {
269                            return Err(syn::Error::new(
270                                value.expr.span(),
271                                "expected key value pairs",
272                            ))
273                        }
274                    }
275                }
276                option => {
277                    return Err(syn::Error::new(
278                        value.member.span(),
279                        format!("unknown option `{}`", option),
280                    ))
281                }
282            }
283        }
284
285        Ok(res)
286    }
287}
288
289impl Display for TargetFeatures {
290    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291        if self.atomics {
292            write!(f, "+atomics,")?
293        }
294        if self.bulk_memory {
295            write!(f, "+bulk-memory,")?
296        }
297        if self.mutable_globals {
298            write!(f, "+mutable-globals,")?
299        }
300
301        Ok(())
302    }
303}
304
305/// Only allow one build job at a time, in case we are building one module many times.
306static GLOBAL_LOCK: Mutex<()> = Mutex::new(());
307
308/// Builds a cargo project as a webassembly module, returning the bytes of the module produced.
309fn do_build_wasm(args: &Args) -> Result<PathBuf, String> {
310    let Args {
311        module_dir,
312        features,
313        env_vars,
314        release,
315    } = args;
316
317    // Acquire global lock
318    let mut lock = GLOBAL_LOCK.lock();
319    while lock.is_err() {
320        GLOBAL_LOCK.clear_poison();
321        lock = GLOBAL_LOCK.lock();
322    }
323
324    // Check target path points to a module
325    let cargo_config = module_dir.join("Cargo.toml");
326    if !cargo_config.is_file() {
327        return Err(format!(
328            "target directory `{}` does not contain a `Cargo.toml` file",
329            module_dir.display()
330        ));
331    }
332    match std::fs::read_to_string(cargo_config) {
333        Ok(cfg) => {
334            if cfg.contains("[workspace]\n") {
335                return Err("provided directory points to a workspace, not a module".to_owned());
336            }
337        }
338        Err(e) => return Err(format!("failed to read target `Cargo.toml`: {e}")),
339    }
340
341    // Build output path, taking env vars into account
342    let mut target_dir = "target/".to_owned();
343    for (key, val) in env_vars.iter() {
344        target_dir += &format!("{}_{}", key, val);
345    }
346
347    // Run `cargo update` before building
348    let mut command = Command::new("cargo");
349
350    let out = command
351        .arg("update")
352        .current_dir(module_dir.clone())
353        .output();
354    match out {
355        Ok(out) => {
356            if !out.status.success() {
357                return Err(format!(
358                    "failed to update module `{}`: \n{}",
359                    module_dir.display(),
360                    String::from_utf8_lossy(&out.stderr).replace('\n', "\n\t")
361                ));
362            }
363        }
364        Err(e) => {
365            return Err(format!(
366                "failed to update module `{}`: {e}",
367                module_dir.display()
368            ))
369        }
370    }
371
372    // Construct build command
373    let mut command = Command::new("cargo");
374
375    // Treat `RUSTFLAGS` as special in env vars
376    const RUSTFLAGS: &str = "RUSTFLAGS";
377    let mut rustflags_value = format!("--cfg=web_sys_unstable_apis -C target-feature={features}");
378    command.env(RUSTFLAGS, &rustflags_value);
379
380    for (key, val) in env_vars.iter() {
381        if key == RUSTFLAGS {
382            rustflags_value += " ";
383            rustflags_value += val;
384            command.env(RUSTFLAGS, &rustflags_value);
385        } else {
386            command.env(key, val);
387        }
388    }
389
390    // Set args
391    let mut args = vec![
392        "+nightly",
393        "build",
394        "--target",
395        "wasm32-unknown-unknown",
396        "-Z",
397        "build-std=panic_abort,std",
398        "--target-dir",
399        &target_dir,
400    ];
401    if *release {
402        args.push("--release");
403    }
404
405    let command = command.args(args).current_dir(module_dir.clone());
406    let command_debug = format!("{command:?}");
407    let out = command.output();
408    match out {
409        Ok(out) => {
410            if !out.status.success() {
411                return Err(format!(
412                    "failed to build module `{}`: \nrunning `{}`\n{}",
413                    module_dir.display(),
414                    command_debug,
415                    String::from_utf8_lossy(&out.stderr).replace('\n', "\n\t")
416                ));
417            }
418        }
419        Err(e) => {
420            return Err(format!(
421                "failed to build module `{}`: \nrunning `{}`\n{e}",
422                command_debug,
423                module_dir.display()
424            ))
425        }
426    }
427
428    // Find output with glob
429    let root_output = module_dir.join(target_dir).join("wasm32-unknown-unknown/");
430    let glob = if *release {
431        root_output.join("release/")
432    } else {
433        root_output.join("debug/")
434    }
435    .join("*.wasm");
436    let mut glob_paths = glob::glob(
437        glob.as_os_str()
438            .to_str()
439            .expect("output path should be unicode compliant"),
440    )
441    .expect("glob should be valid");
442
443    let output = match glob_paths.next() {
444        Some(Ok(output)) => output,
445        Some(Err(err)) => {
446            return Err(format!(
447                "failed to find output file matching `{glob:?}`: {err} - this is probably a bug",
448            ))
449        }
450        None => {
451            return Err(format!(
452                "failed to find output file matching `{}` - this is probably a bug",
453                glob.display()
454            ))
455        }
456    };
457
458    // Check only one output to avoid hidden bugs
459    if let Some(Ok(_)) = glob_paths.next() {
460        return Err(format!("multiple output files matching `{}` were found - this may be because you recently changed the name of your module; try deleting the folder `{}` and rebuilding", glob.display(), root_output.display()));
461    }
462
463    drop(lock);
464
465    Ok(output)
466}
467
468fn all_module_files(path: PathBuf) -> Vec<String> {
469    let glob_paths = glob::glob(
470        path.as_os_str()
471            .to_str()
472            .expect("output path should be unicode compliant"),
473    )
474    .expect("glob should be valid");
475
476    glob_paths
477        .into_iter()
478        .filter_map(|path| {
479            let path = path.ok()?;
480            if !path.is_file() {
481                None
482            } else {
483                Some(path.to_string_lossy().to_string())
484            }
485        })
486        .collect()
487}
488
489/// Invokes `cargo build` at compile time on another module, replacing this macro invocation
490/// with the bytes contained in the output `.wasm` file.
491///
492/// # Usage
493///
494/// ```ignore
495/// let module = build_wasm!("relative/path/to/module");
496/// ```
497///
498/// # Arguments
499///
500/// This macro can take a number of additional arguments to control how the WebAssembly should be generated.
501/// These options are passed to `cargo build`:
502///
503/// ```ignore
504/// let module = build_wasm!{
505///     path: "relative/path/to/module",
506///     features: [
507///         atomics, // Controls if the `atomics` proposal is enabled
508///         bulk_memory, // Controls if the `bulk-memory` proposal is enabled
509///         mutable_globals, // Controls if the `mutable-globals` proposal is enabled
510///     ],
511///     // Allows additional environment variables to be set while compiling the module.
512///     env: Env {
513///         FOO: "bar",
514///         BAX: 7,
515///     },
516///     // Controls if the module should be built in debug or release mode.
517///     release: true
518/// };
519/// ```
520#[proc_macro]
521pub fn build_wasm(args: TokenStream) -> TokenStream {
522    // Parse args
523    let mut args = parse_macro_input!(args as Args);
524
525    #[cfg(not(feature = "proc_macro_span"))]
526    let invocation_file = {
527        let root =
528            std::env::var("CARGO_MANIFEST_DIR").expect("proc macros should be run using cargo");
529        find_me(&root, &format!("\"{}\"", args.module_dir.to_string_lossy()))
530    };
531    #[cfg(feature = "proc_macro_span")]
532    let invocation_file = proc_macro::Span::call_site().source_file().path();
533    let invocation_file = invocation_file
534        .parent()
535        .unwrap()
536        .to_path_buf()
537        .canonicalize()
538        .unwrap();
539    args.module_dir = invocation_file.join(args.module_dir);
540
541    // Build
542    let result = do_build_wasm(&args);
543
544    // Output
545    match result {
546        Ok(bytes_path) => {
547            let bytes_path = bytes_path.to_string_lossy().to_string();
548            // Register rebuild on files changed
549            let module_paths = all_module_files(args.module_dir);
550
551            quote! {
552                {
553                    #(
554                        let _ = include_str!(#module_paths);
555                    )*
556                    include_bytes!(#bytes_path) as &'static [u8]
557                }
558            }
559        }
560        Err(err) => quote! {
561            {
562                compile_error!(#err);
563                const BS: &'static [u8] = &[0u8];
564                BS
565            }
566        },
567    }
568    .into()
569}