2 releases
0.1.1 | Jul 27, 2023 |
---|---|
0.1.0 | Jul 27, 2023 |
#2512 in Rust patterns
38KB
700 lines
This crate allows you to derive the Bake
trait with the bake()
method. bake()
returns a TokenStream
that can be used in a proc macro to create an equivalent struct.
Example
#[derive(Bake)]
struct MyStruct {
field_a: u64
}
fn main() {
println!("{}", MyStruct { field_a: 10}.bake().to_string())
// prints: MyStruct { field_a: 10}
}
For a more elaborate example see examples/simple_setup
Quick Feature and Usecase List
- Generate a parsing macro from your existing parsing function
- Use DSLs (Domain Specific Languages) inside hot loops by off-loading parsing to compile time
- Only one implementaion for runtime and compile time parsing
- Interpolate arbitrary Rust into your DSL
- Create injection-safe-by-construction parsers using interpolation
Everything said below about structs is true about named structs, tuple structs, unit structs and all variations of enums (but not unions).
Motivation
The main use case of this crate is to enable the efficient creation of compile time parsing macros.
Assuming you already have a parsing function
parse_str(input: &str) -> Result<MyType, MyError> {...}
and MyType
derives Bake
. A simple compile time parsing macro can be written like this.
#[proc_macro]
fn parse_macro(input: TokenStream) -> TokenStream {
parse_str(&input.to_string()).unwrap().bake().into()
}
(Note that bake()
returns a proc_macro2::Tokenstream
and thus requires conversion into a proc_macro::TokenStream
)
fn some_func() {
...
let my_struct = parse_macro!(Your syntax here);
my_struct.my_method();
...
}
When called the macro will either expand to an instance of MyType
or will panic which propagates MyError
to the compiler and stops the compilation. This way the parsed input:
- Is guaranteed to be valid during compile time.
- Does not need to be parsed during runtime
- Can be used directly and does not need to be unwrapped from a
Result
- Can benefit from compiler optimizations
Basic Baking
To add basic baking functionality to your struct simply derive Bake
. For this to work, all memebers of the struct must already implement Bake
.
Note that unlike similar derives like serde
it is not possible to ignore fields of the struct because all fields need to be baked in order to provide a valid struct. For the same reason all members of your struct must be public, because otherwise they could not be set at the location of the macro call.
Baking private fields
The code produced by a macro is scoped to the place of the macro invocation, for this reason, while some TokenStreams
for private may be produced by the bake()
, they will never actually compile.
To bake a struct with private fields a constructor function is needed. Note that the bake()
function can read private fields because it is implemented on the struct itself, it just can not produce them in the resulting TokenStream.
impl Bake for MyPartialPrivateStruct {
fn bake(&self) {
let MyPartialPrivateStruct {
pub_field,
priv_field
} = self;
// Note that you should fully qualify the module path to the struct
bake::util::quote!(mycrate::internal::MyPartialPrivateStruct::new(#pub_field, #priv_field))
}
}
It is not possible to bake a struct with private fields and a private constructor. If you want to work around this you can create a "private-public" constructor like mycrate::__private::constructor
to make it clear to users of your library that they should not directly call this function.
Smart Pointers
Baking smart pointers is disabled by default, not because it is not possible, but because it is most likely not what you want.
For example assume we have two Rc<MyStruct>
pointing at the same instance of MyStruct
. Calling bake()
on both of these Rc
s however will create two separate instances of MyStruct
at runtime since bake()
has no way to know about or even refference the other instance.
If you do need smart pointers in your struct you may have to implement bespoke baking logic.
Box<T>
is excempt from this since it can not be shared.
Baking Remote Types
Similar to serde you can create a dummy type in order to derive baking logic for a remote type.
#[derive(Bake)]
#[bake(bake_as(other::crate::Duration))]
pub struct DurationDummy {
secs: i64,
nanos: i32,
}
#[derive(Bake)]
pub struct StructWithDuration {
#[bake_via(DurationDummy)]
duration: Duration
}
This way you can even add interpolation to remote types that do not support it.
You can use any type inside bake_via
, but using a type that is not marked with bake_as(<other type>)
will most likely error. One big exception to this is unit-types.
Because no information about the internals of unit types is needed (there is none) remote unit types can be annotated with themselves and will bake just fine (you should still derive Bake
for your own unit types so people do not have to annotate all uses of them).
Note that this will not work if the unit type is generic.
#[derive(Bake)]
pub struct StructWithRemoteUnit {
#[bake_via(RemoteUnit)]
remote: RemoteUnit
}
Interpolation
Motivation
Interpolation allows you to replace structs with equivalent rust expressions.
For example let's imagine we have a json parser that parses the following static json
{
"name": "A String",
"value": 10
}
but instead of 10
we want a value that is given as a function parameter. Without interpolation the code would look something like this
fn wrap_my_number(number: i32) -> Json {
let mut json = parse_json!{
{
"name": "A String",
"value": 10
}
};
if let Json::Dict(mut map) = json {
map.insert("value", Json::Number(number));
}
json
}
which requires us to:
- set
value
to10
just so we have a valid json - make json mutable
- awkwardly unwrap the HashMap from the json even though the pattern will always match (also ugly
mut
in the pattern)
Now imagine what the code would look like if the structur was nested further.
With interpolation turned on it looks like this
fn wrap_my_number(number: i32) -> Json {
parse_json!{
{
"name": "A String",
"value": ${number}
}
}
}
Please note that the ${...}
syntax is something you have to implement in your parser, this crate only gives you the framework to bake the interpolation, not to parse them. The macro above will expand to something like this
See the examples directory for an example on how to do that.
Json::Map(
std::collections::HashMap::from(
[
("name", Json::String("A String".to_owned())),
("value", {number}.into())
]
)
)
As you can see this requires a From<i32>
impl for Json
which you would most likely implement anyway. Because of the blanket implementation impl<T> From<T> for T
it is always valid to put a value of the type that would be expected in that place by the normal parser.
fn wrap_my_node(node: Json) -> Json {
parse_json!{
{
"name": "Look! I wrapped a node!",
"value": ${node}
}
}
}
Adding Interpolation
Adding interpolation to a struct is as simple as annotatig it with #[bake(interpolation)]
#[derive(Bake, Debug, PartialEq)]
#[bake(interpolate)]
pub enum Json {
Number(i64),
Boolean(bool),
String(String),
List(Vec<Json>),
Dict(HashMap<String, Box<Json>>)
}
You can also only interpolate certain fields, in the case of a JSON it doesn't really make sense to interpolate more than List and Dict
#[derive(Bake, Debug, PartialEq)]
#[bake]
pub enum Json {
Number(i64),
Boolean(bool),
String(String),
#[interpolate]
List(Vec<Json>),
#[interpolate]
Dict(HashMap<String, Json>),
}
Interacting with the struct becomes a bit trickier though: For the user of your crate not much changes apart from being able to interpolate, but you now have to make sure that all your code works whether you are interpolating or not.
The 'macro' feature
Marking any type as interpolated implicitly adds the 'macro' feature to your crate. Interpolation is only available when the crate is imported with this feature enabled, otherwise it is assumed that all fields are just plain types.
When the macro feature is turned on all interpolated fields field: T
become field: Interpolatable<T>
instead. Interpolatable<T>
is an enum with two variants:
Actual(T)
represents an actual value of typeT
and gets baked the same wayT
wouldInterpolation(TokenTree)
represents a rust block that should evaluate to a type the implementsInto<T>
and gets baked as{/*TokenTree here*/}.into()
Creating a Interpolatable::<T>::Interpolation
that can not be converted into a T
with into()
will produce a compiler error when calling the macro.
Adjusting code
You will have the following changes to your code:
- parsing functions that may return an interpolated value need their return type changed from
T
toInterpolatable<T>
- Struct constructors from parsing functions need to call
.fit()?
on all fields. This will convert betweeenT
andInterpolatable<T>
as needed. - Your parsing errors need to implement
From<bake::RuntimeInterpolationError>
in order for.fit()?
to work (this is already done for nom errors if you enable thenom
feature) - Guard all functions that need to work on a raw
T
by-
using
fit()?
orforce_fit()
... _ => match tokens { "true" => Ok(Json::Boolean(true.force_fit()).fit()?), "false" => Ok(Json::Boolean(false.force_fit()).fit()?), _ => Err(NodeError::Parsing) }, ...
-
guarding them behind a
#[cfg(not(feature = "macro"))]
so they can not be called during macro-parsing#[cfg(not(feature = "macro"))] impl Json { pub fn truthyness(&self) -> bool{ match self { Json::Number(x) => *x != 0, Json::Boolean(x) => *x, Json::String(x) => x.len() > 0, Json::List(x) => !x.is_empty(), Json::Dict(x) => !x.is_empty() } } }
Note that the code your macro produces may still call these methods, you just can not call them inside of your proc_macro
-
fit()?
will be replaced with just ?
as soon as Try
is stabilized.
Runtime Interpolation
Trying to interpolate during runtime is always an error, for this reason fit()
returns a Result
that is always Ok
unless you try to convert from Interpolatable::<T>::Interpolation
to a T
. force_fit()
is just short for fit().expect("Interpolated during runtime")
and can be used if you know for sure that you have an Actual(T)
or T
like in Json::Boolean(false.force_fit())
.
Dependencies
~1.5–2MB
~43K SLoC