The (very niche) Problem

Some time ago I came across this blogpost which describes the agony the author went through to provide both a synchronous and an asynchronous version of their library.

What the author opted for in the end is a library that either generated async or normal code depending on the presence (or absence) of a feature flag. Importantly however, this would not work to enable both synchronous and asynchronous code generation at the same time (since synchronous code is emitted at the absence of the “asynchronous” flag).

This usually is not an issue - you either use synchronous or asynchronous code, not both. However, this can become an issue if you are writing a library - if it’s suddenly a transitive dependency you rely on, and multiple of your direct dependencies use that library, in async and sync mode, code generation can no longer work, since the library can only provide synchronous or asynchronous interfaces, but not both at the same time.

Rust specifically states that you should not use feature flags in that way - they are only supposed to add features, never subtract. This requirement simplifies compilation, as you only ever need to compile every crate once, with the union of all needed feature flags enabled.

My unhinged solution

The straightforward solution would be to simply put the synchronous code and the asynchronous code in two different modules and add feature flags to those. Then, you can have two feature flags, “blocking” and “async”, to enable one, the other, or both. However, copy-pasting code and adjusting it is not very exciting - But that’s what macros are here for!

What I first decided on was an interface on how a user of the library is supposed to interact with it. I kept it quite similar to my inspiration, the maybe_async crate.

  • the #[bisync] attribute: will adjust code to either keep the async and await keywords or strip them away.
  • the #[only_async] attribute: will only emit the following code in an asynchronous context
  • the #[only_sync] attribute: will only emit the following code in a blocking context

The novel aspect to this solution now comes from where these macros are defined - by themselves they are not very interesting.

pub mod synchronous {
    pub use ::bisync_macros::delete      as only_async;
    pub use ::bisync_macros::noop        as only_sync;
    pub use ::bisync_macros::strip_async as bisync;
}
pub mod asynchronous {
    pub use ::bisync_macros::noop    as only_async;
    pub use ::bisync_macros::delete  as only_sync;
    pub use ::bisync_macros::noop    as bisync;
}

Wait what - they’re defined twice?

Yes! What you now can do is copy-paste the same code into two different modules which makes use of #[bisync] and friends, and only in the two roots of the modules import bisync::synchronous::* or bisync::asynchronous::*.

This works, because depending on which context you use the macros, they’re defined differently. In the synchronous module all functions marked as bisync are stripped of all async/await, and in an asynchronous module they are kept.

One last trick

Of course, copy-pasting code is still very annoying. Luckily, there is a feature in the Rust language that isn’t very well known and can come to our rescue: the #[path] attribute. The path attribute defines where a submodule resides in the file system. Concretely this also means that you can use the same source code within multiple modules!

All together this would look something like this:

//! lib.rs

#[path = "."]
pub mod asynchronous {
    use bisync::asynchronous::*;
    mod inner;
    pub use inner::*;
}

// here you could also add `#[cfg]` attributes to enable or disable this module
#[path = "."]
pub mod blocking {
    use bisync::synchronous::*;
    mod inner;
    pub use inner::*;
}
/// inner.rs

// import macros as "parameters" to this module
use super::{bisync, only_sync, only_async};

#[bisync]
pub async fn foo() -> String {
    bar().await
}

#[bisync]
async fn bar() -> String {
    baz().await
}

#[only_sync]
fn baz() -> String {
    ureq::get("https://example.com")
        .call()
        .unwrap()
        .into_string()
        .unwrap()
}

#[only_async]
async fn baz() -> String {
    reqwest::get("https://example.com")
        .await
        .unwrap()
        .text()
        .await
        .unwrap()
}

Thanks to the macros we now only need to implement all generic functionality once, instead of duplicating it across synchronous and asynchronous variants. In this small example, we prevented duplication of foo and bar, where in a real library it might be much more. :)


Feel free to check out the library at https://crates.io/crates/bisync.