Way to hide auto generated boilerplate for project template

I need a way to hide boilerplate included in my customized project template. I want to hide code from src/main.rs and build.rs. For example, for now I want:

fn main() {
    // managed code...
}

rather than:

fn main() {
    initialize_framework();

    // managed code...
}

/// do not modify this function
fn initialize_framework() {
    include!(concat!(env!("OUT_DIR"), "/rialight_entry.rs"));
}

Someone suggested of using a macro like:

initialize_framework!(program);

where program is the entry point function. But this doesn't still hide the boilerplate at all and would require extra code at main.rs (like mod framework_entry;) and extra source (framework_entry.rs), because the macro has to be available at the src/main.rs context.

I suppose there's now way to achieve this currently. So it'd be interesting if from Cargo package settings (Cargo.toml) we could specify a little field that should be useful for frameworks, which tells where to locate extra code for build.rs and src/main.rs. Like:

[package]
framework-path = ".framework/entry"

File structure

  • .framework
    • entry
      • build.rs
      • src
        • main.rs

Then there should be a way for this main.rs from .framework to invoke main from the project template. Not sure what way is ideal for this.

What's the big deal with boilerplate?

1 Like

The user may mess with main.rs (or also build.rs) and end up breaking their new project generated by the framework.

Then that's where you use the macro as you stated. If it's a framework, then presumably it is an external crate. I don't see why you can't have a single line — proc_macro_crate_name::boilerplate!() or similar. You can't prevent a user from deleting that line, but that's really the least of your concerns. If it's not an external crate, then please state your problem more clearly.

Unfortunately no, that framework's boilerplate is not simply an external crate. A Cargo project generated by the framework has a build.rs script that dynamically generates Rust artifact code for initializing stuff. So a single line won't work.

A procedural macro is more than capable of running arbitrary code (including accessing the file system) to generate other Rust code. Why is that insufficient?

  1. A macro can't replace the functionality in build.rs, because build.rs reads project settings that mustn't be shipped together with a release of the user's application.
  2. Using a macro to invoke the auto-generated Rust artifact requires more than one line and an extra file (framework_entry.rs) to hide the macro definition:
mod framework_entry;
framework_entry::initialize!(program);

build.rs is packaged in the same manner as proc macros. Setting aside the guarantee that build.rs is executed before any other macros are compiled, any build.rs could actually be fully replaced by a proc macro if the author were so inclined. It's simply more idiomatic to use build.rs.

Proc macros have to be in external crates. Even if they didn't, requesting a new feature in cargo is a big ask to replace a mere two lines of code.

Ultimately you are trying to prevent a user from messing with generated code. Macros prevent that. If users can't be trusted not to delete two lines of code, I'm not sure what they can be trusted with. And if they do, so what, their build fails.

4 Likes

Right... looks like macros don't look bad. And I think I won't need an extra file:

src/main.rs

rialight::initialize!(|app| {
	// procedure
});

rialight_project will be an external crate, but... will this work? Like, will that OUT_DIR always work? It should look like this:

rialight_project/src/...

#[macro_export]
macro_rules! initialize {
    ($lambda_exp:expr) => {
        fn main() {
        	include!(concat!(env!("OUT_DIR"), "/rialight_entry.rs"));
            $lambda_exp();
        }
    };
}
pub use initialize;

The simple way to do it is an attribute procedural macro (not macro_rules, e.g.

#[rialight::main]
fn main() -> R {
    // user code
}

which could expand to

fn main() -> R {
    include!(concat!(env!("OUT_DIR"), "/rialight_initialize.rs"));
    railight_initialize();

    fn main() -> R {
        // user code
    }

    main()
}

If you want some extra parameters (e.g. that app), you can easily add them here. This is essentially the approach taken by #[tokio::main].

Or, depending on how much configuration is done in build.rs, you could even have the main procedural macro do all of the work to create whatever code would be generated to $OUT_DIR/railight_initialize.rs and include it in the expansion. This is essentially the approach taken by pest.

5 Likes

I actually prefer the macro rule using a lambda expression because I don't need to type app: Application:

#[rialight::main]
fn main(app: Application) {
    // procedure
}

vs.:

rialight::initialize!(|app| {
    // procedure
});

This just requires this line in the macro:

let user_ie: fn(Application) -> () = $lambda_exp;

I'm sure you know this, but for the record, build.rs provides functionality which cannot be emulated with proc macros. It can interact with Cargo, allowing it to change compiler invocation options (including cfg options, linking external libraries and setting environment variables). Also note that macros, including proc macros, are expanded in unspecified order, which means that you can't rely on the order of macro expansion for independent macro calls for any side effects. In particular, you can't use one proc macro to populate some compiler-internal data structure (e.g. list of registered structs) so that another proc macro would later access it to do something. I doubt that a proc macro can set an environment variable for compiler invocations (since proc macro is run as a separate child process), but even if it could, the unspecified expansion would mean that it's impossible to soundly rely on such variables.

4 Likes