validate_parameter_type(&syn::Type): Verifies the type is a reference (syn::Type::Reference). Ensures the reference is mutable (type_ref.mutability.is_some()), reporting: #[turtle_main] parameter must be a mutable reference: '&mut TurtlePlan'. Checks that the target type (type_ref.elem) is a path whose trailing identifier is TurtlePlan, allowing both &mut TurtlePlan and qualified paths like &mut turtle_lib::TurtlePlan. Rejects other types (like value: i32 or owned t: TurtlePlan), reporting: Integration into validate_input: Called validate_parameter_type(&pat_type.ty)?; directly within syn::FnArg::Typed(pat_type). Unit Tests Added: test_valid_qualified_type: verifies &mut turtle_lib::TurtlePlan is accepted. test_rejects_wrong_type: verifies value: i32 is rejected with an informative error. test_rejects_immutable_reference: verifies t: &TurtlePlan is rejected. test_rejects_owned_type: verifies t: TurtlePlan is rejected. test_rejects_wrong_reference_type: verifies t: &mut i32 is rejected.
580 lines
18 KiB
Rust
580 lines
18 KiB
Rust
//! Procedural macros for turtle-lib
|
|
//!
|
|
//! This crate provides the `turtle_main` procedural macro that simplifies
|
|
//! creating turtle graphics programs by automatically setting up the
|
|
//! macroquad window, turtle initialization, and the main rendering loop.
|
|
|
|
use proc_macro::TokenStream;
|
|
use quote::quote;
|
|
use syn::ItemFn;
|
|
|
|
/// A convenience macro that wraps your turtle drawing code with the necessary
|
|
/// boilerplate for running a turtle graphics program.
|
|
///
|
|
/// This macro:
|
|
/// - Wraps your code with `#[macroquad::main]`
|
|
/// - Creates a turtle instance (`turtle`)
|
|
/// - Sets up the `TurtleApp` with your drawing commands
|
|
/// - Provides a main loop with rendering and quit handling (ESC or Q)
|
|
/// - Adds command-line parameter support for SVG export (when `svg` feature is enabled)
|
|
///
|
|
/// # Command-Line Parameters
|
|
///
|
|
/// When the `svg` feature is enabled, the following command-line parameter is available:
|
|
///
|
|
/// * `--export-svg <filename>` - Exports the drawing to an SVG file and exits immediately
|
|
/// without opening the window. Example: `cargo run --features svg -- --export-svg output.svg`
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```ignore
|
|
/// use turtle_lib::*;
|
|
///
|
|
/// #[turtle_main("My Turtle Drawing")]
|
|
/// fn my_drawing(turtle: &mut TurtlePlan) {
|
|
/// // Use colors from turtle_lib (re-exported from macroquad)
|
|
/// turtle.set_pen_color(RED);
|
|
/// turtle.forward(100.0);
|
|
/// turtle.right(90.0);
|
|
/// turtle.forward(100.0);
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// If you need macroquad types not re-exported by `turtle_lib`:
|
|
///
|
|
/// ```ignore
|
|
/// use macroquad::prelude::SKYBLUE; // Import specific items
|
|
/// use turtle_lib::*;
|
|
///
|
|
/// #[turtle_main("My Drawing")]
|
|
/// fn my_drawing(turtle: &mut TurtlePlan) {
|
|
/// turtle.set_pen_color(SKYBLUE);
|
|
/// turtle.forward(100.0);
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// # SVG Export Example
|
|
///
|
|
/// ```bash
|
|
/// # Run with SVG export (requires svg feature)
|
|
/// cargo run --package turtle-lib --example macro_demo --features svg -- --export-svg output.svg
|
|
/// ```
|
|
///
|
|
/// This expands to approximately:
|
|
///
|
|
/// ```ignore
|
|
/// use turtle_lib::*;
|
|
///
|
|
/// fn main() {
|
|
/// // Handle optional SVG export headlessly without opening a window
|
|
/// if let Some(filename) = turtle_lib::export::parse_svg_export_arg() {
|
|
/// let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| {
|
|
/// my_drawing(turtle);
|
|
/// };
|
|
/// if let Err(e) = turtle_lib::export::run_headless_svg_export(&mut build_commands, &filename) {
|
|
/// eprintln!("Error exporting SVG: {:?}", e);
|
|
/// std::process::exit(1);
|
|
/// }
|
|
/// return;
|
|
/// }
|
|
///
|
|
/// // Normal interactive GUI mode with window
|
|
/// turtle_lib::macroquad::Window::new("My Turtle Drawing", async {
|
|
/// let mut turtle = create_turtle_plan();
|
|
/// my_drawing(&mut turtle);
|
|
///
|
|
/// let mut app = TurtleApp::new().with_commands(turtle.build());
|
|
///
|
|
/// loop {
|
|
/// turtle_lib::macroquad::prelude::clear_background(turtle_lib::macroquad::prelude::WHITE);
|
|
/// app.update();
|
|
/// app.render();
|
|
/// turtle_lib::macroquad::prelude::draw_text("Press ESC or Q to quit", 10.0, 40.0, 16.0, turtle_lib::macroquad::prelude::DARKGRAY);
|
|
///
|
|
/// if turtle_lib::macroquad::prelude::is_key_pressed(turtle_lib::macroquad::prelude::KeyCode::Escape)
|
|
/// || turtle_lib::macroquad::prelude::is_key_pressed(turtle_lib::macroquad::prelude::KeyCode::Q)
|
|
/// {
|
|
/// break;
|
|
/// }
|
|
///
|
|
/// turtle_lib::macroquad::prelude::next_frame().await;
|
|
/// }
|
|
/// });
|
|
/// }
|
|
/// ```
|
|
fn validate_parameter_type(ty: &syn::Type) -> Result<(), syn::Error> {
|
|
match ty {
|
|
syn::Type::Reference(type_ref) => {
|
|
if type_ref.mutability.is_none() {
|
|
return Err(syn::Error::new_spanned(
|
|
type_ref,
|
|
"#[turtle_main] parameter must be a mutable reference: `&mut TurtlePlan`",
|
|
));
|
|
}
|
|
|
|
if let syn::Type::Path(type_path) = &*type_ref.elem {
|
|
let is_turtle_plan = type_path
|
|
.path
|
|
.segments
|
|
.last()
|
|
.is_some_and(|seg| seg.ident == "TurtlePlan");
|
|
|
|
if is_turtle_plan {
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
Err(syn::Error::new_spanned(
|
|
&type_ref.elem,
|
|
"#[turtle_main] expected reference to `TurtlePlan`, e.g. `&mut TurtlePlan`",
|
|
))
|
|
}
|
|
_ => Err(syn::Error::new_spanned(
|
|
ty,
|
|
"#[turtle_main] parameter must be of type `&mut TurtlePlan`",
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn validate_input(input_fn: &ItemFn) -> Result<(), syn::Error> {
|
|
if input_fn.sig.asyncness.is_some() {
|
|
return Err(syn::Error::new_spanned(
|
|
input_fn.sig.fn_token,
|
|
"#[turtle_main] functions cannot be async",
|
|
));
|
|
}
|
|
|
|
if input_fn.sig.inputs.len() > 1 {
|
|
return Err(syn::Error::new_spanned(
|
|
&input_fn.sig.inputs,
|
|
"#[turtle_main] functions must take either 0 arguments or a single `&mut TurtlePlan`",
|
|
));
|
|
}
|
|
|
|
if let Some(arg) = input_fn.sig.inputs.first() {
|
|
match arg {
|
|
syn::FnArg::Receiver(receiver) => {
|
|
return Err(syn::Error::new_spanned(
|
|
receiver,
|
|
"#[turtle_main] functions cannot take a `self` parameter",
|
|
));
|
|
}
|
|
syn::FnArg::Typed(pat_type) => {
|
|
match &*pat_type.pat {
|
|
syn::Pat::Ident(pat_ident)
|
|
if pat_ident.by_ref.is_none() && pat_ident.subpat.is_none() => {}
|
|
syn::Pat::Wild(_) => {}
|
|
_ => {
|
|
return Err(syn::Error::new_spanned(
|
|
&pat_type.pat,
|
|
"#[turtle_main] unsupported parameter pattern; expected an identifier like `turtle` or `t`",
|
|
));
|
|
}
|
|
}
|
|
|
|
validate_parameter_type(&pat_type.ty)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
if matches!(input_fn.sig.output, syn::ReturnType::Type(..)) {
|
|
return Err(syn::Error::new_spanned(
|
|
&input_fn.sig.output,
|
|
"#[turtle_main] functions cannot have a return type",
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[proc_macro_attribute]
|
|
pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream {
|
|
turtle_main_impl(&args.into(), input.into())
|
|
.unwrap_or_else(|err| err.to_compile_error())
|
|
.into()
|
|
}
|
|
|
|
fn turtle_main_impl(
|
|
args: &proc_macro2::TokenStream,
|
|
input: proc_macro2::TokenStream,
|
|
) -> Result<proc_macro2::TokenStream, syn::Error> {
|
|
let input_fn: ItemFn = syn::parse2(input)?;
|
|
|
|
// Validate function signature
|
|
validate_input(&input_fn)?;
|
|
|
|
// Parse the window title from args (default to "Turtle Graphics")
|
|
let window_title = if args.is_empty() {
|
|
quote! { "Turtle Graphics" }
|
|
} else {
|
|
let args_str = args.to_string();
|
|
// Remove quotes if present
|
|
let title = args_str.trim().trim_matches('"');
|
|
quote! { #title }
|
|
};
|
|
|
|
let fn_name = &input_fn.sig.ident;
|
|
let fn_block = &input_fn.block;
|
|
let fn_attrs = &input_fn.attrs;
|
|
let fn_vis = if fn_name == "main" {
|
|
None
|
|
} else {
|
|
Some(&input_fn.vis)
|
|
};
|
|
let has_turtle_param = input_fn.sig.inputs.len() == 1;
|
|
|
|
let helper_name = if fn_name == "main" {
|
|
quote::format_ident!("__turtle_main_draw")
|
|
} else {
|
|
fn_name.clone()
|
|
};
|
|
|
|
let helper_fn = if has_turtle_param {
|
|
let param = &input_fn.sig.inputs[0];
|
|
quote! {
|
|
#(#fn_attrs)*
|
|
#fn_vis fn #helper_name(#param) #fn_block
|
|
}
|
|
} else {
|
|
quote! {
|
|
#(#fn_attrs)*
|
|
#fn_vis fn #helper_name(turtle: &mut turtle_lib::TurtlePlan) {
|
|
let turtle = turtle;
|
|
#fn_block
|
|
}
|
|
}
|
|
};
|
|
|
|
let expanded = quote! {
|
|
fn main() {
|
|
let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| {
|
|
#helper_name(turtle);
|
|
};
|
|
|
|
// If --export-svg flag is present, export headlessly without opening a window
|
|
if let Some(filename) = turtle_lib::export::parse_svg_export_arg() {
|
|
match turtle_lib::export::run_headless_svg_export(&mut build_commands, &filename) {
|
|
Ok(()) => {
|
|
println!("SVG exported successfully to: {}", filename);
|
|
return;
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error exporting SVG: {:?}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Normal rendering mode (interactive window)
|
|
turtle_lib::macroquad::Window::new(#window_title, async {
|
|
let mut turtle = turtle_lib::create_turtle_plan();
|
|
#helper_name(&mut turtle);
|
|
|
|
let mut app = turtle_lib::TurtleApp::new()
|
|
.with_commands(turtle.build());
|
|
|
|
loop {
|
|
turtle_lib::macroquad::prelude::clear_background(turtle_lib::macroquad::prelude::WHITE);
|
|
app.update();
|
|
app.render();
|
|
turtle_lib::macroquad::prelude::draw_text(
|
|
"Press ESC or Q to quit",
|
|
10.0,
|
|
40.0,
|
|
16.0,
|
|
turtle_lib::macroquad::prelude::DARKGRAY
|
|
);
|
|
|
|
if turtle_lib::macroquad::prelude::is_key_pressed(turtle_lib::macroquad::prelude::KeyCode::Escape)
|
|
|| turtle_lib::macroquad::prelude::is_key_pressed(turtle_lib::macroquad::prelude::KeyCode::Q)
|
|
{
|
|
break;
|
|
}
|
|
|
|
turtle_lib::macroquad::prelude::next_frame().await;
|
|
}
|
|
});
|
|
}
|
|
|
|
#helper_fn
|
|
};
|
|
|
|
Ok(expanded)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use syn::parse_quote;
|
|
|
|
#[test]
|
|
fn test_valid_zero_args() {
|
|
let input: ItemFn = parse_quote! {
|
|
fn my_draw() {
|
|
turtle.forward(100.0);
|
|
}
|
|
};
|
|
assert!(validate_input(&input).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_valid_one_arg() {
|
|
let input: ItemFn = parse_quote! {
|
|
fn my_draw(t: &mut TurtlePlan) {
|
|
t.forward(100.0);
|
|
}
|
|
};
|
|
assert!(validate_input(&input).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_valid_mut_arg() {
|
|
let input: ItemFn = parse_quote! {
|
|
fn my_draw(mut t: &mut TurtlePlan) {
|
|
t.forward(100.0);
|
|
}
|
|
};
|
|
assert!(validate_input(&input).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_valid_wildcard_arg() {
|
|
let input: ItemFn = parse_quote! {
|
|
fn my_draw(_: &mut TurtlePlan) {}
|
|
};
|
|
assert!(validate_input(&input).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_rejects_async() {
|
|
let input: ItemFn = parse_quote! {
|
|
async fn my_draw() {}
|
|
};
|
|
let err = validate_input(&input).unwrap_err();
|
|
assert!(err.to_string().contains("cannot be async"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rejects_multiple_args() {
|
|
let input: ItemFn = parse_quote! {
|
|
fn my_draw(t: &mut TurtlePlan, extra: i32) {}
|
|
};
|
|
let err = validate_input(&input).unwrap_err();
|
|
assert!(err.to_string().contains("must take either 0 arguments or a single"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rejects_self() {
|
|
let input: ItemFn = parse_quote! {
|
|
fn my_draw(&mut self) {}
|
|
};
|
|
let err = validate_input(&input).unwrap_err();
|
|
assert!(err.to_string().contains("cannot take a `self` parameter"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rejects_unsupported_pattern() {
|
|
let input: ItemFn = parse_quote! {
|
|
fn my_draw((a, b): &mut TurtlePlan) {}
|
|
};
|
|
let err = validate_input(&input).unwrap_err();
|
|
assert!(err.to_string().contains("unsupported parameter pattern"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rejects_return_type() {
|
|
let input: ItemFn = parse_quote! {
|
|
fn my_draw() -> i32 {
|
|
42
|
|
}
|
|
};
|
|
let err = validate_input(&input).unwrap_err();
|
|
assert!(err.to_string().contains("cannot have a return type"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_valid_qualified_type() {
|
|
let input: ItemFn = parse_quote! {
|
|
fn my_draw(t: &mut turtle_lib::TurtlePlan) {}
|
|
};
|
|
assert!(validate_input(&input).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_rejects_wrong_type() {
|
|
let input: ItemFn = parse_quote! {
|
|
fn my_draw(value: i32) {}
|
|
};
|
|
let err = validate_input(&input).unwrap_err();
|
|
assert!(err.to_string().contains("parameter must be of type `&mut TurtlePlan`"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rejects_immutable_reference() {
|
|
let input: ItemFn = parse_quote! {
|
|
fn my_draw(t: &TurtlePlan) {}
|
|
};
|
|
let err = validate_input(&input).unwrap_err();
|
|
assert!(err.to_string().contains("parameter must be a mutable reference: `&mut TurtlePlan`"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rejects_owned_type() {
|
|
let input: ItemFn = parse_quote! {
|
|
fn my_draw(t: TurtlePlan) {}
|
|
};
|
|
let err = validate_input(&input).unwrap_err();
|
|
assert!(err.to_string().contains("parameter must be of type `&mut TurtlePlan`"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rejects_wrong_reference_type() {
|
|
let input: ItemFn = parse_quote! {
|
|
fn my_draw(t: &mut i32) {}
|
|
};
|
|
let err = validate_input(&input).unwrap_err();
|
|
assert!(err.to_string().contains("expected reference to `TurtlePlan`"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expansion_preserves_custom_param_name() {
|
|
let input = quote! {
|
|
fn my_draw(t: &mut TurtlePlan) {
|
|
t.forward(100.0);
|
|
}
|
|
};
|
|
let output = turtle_main_impl("e!(), input).unwrap();
|
|
let file: syn::File = syn::parse2(output).unwrap();
|
|
|
|
let helper_fn = file
|
|
.items
|
|
.iter()
|
|
.find_map(|item| {
|
|
if let syn::Item::Fn(f) = item {
|
|
if f.sig.ident == "my_draw" {
|
|
return Some(f);
|
|
}
|
|
}
|
|
None
|
|
})
|
|
.expect("helper fn `my_draw` should exist");
|
|
|
|
let first_arg = helper_fn.sig.inputs.first().expect("should have 1 arg");
|
|
if let syn::FnArg::Typed(pat_type) = first_arg {
|
|
if let syn::Pat::Ident(pat_ident) = &*pat_type.pat {
|
|
assert_eq!(pat_ident.ident, "t");
|
|
} else {
|
|
panic!("expected ident pattern");
|
|
}
|
|
} else {
|
|
panic!("expected typed arg");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_expansion_preserves_mut_param() {
|
|
let input = quote! {
|
|
fn my_draw(mut t: &mut TurtlePlan) {
|
|
t.forward(100.0);
|
|
}
|
|
};
|
|
let output = turtle_main_impl("e!(), input).unwrap();
|
|
let file: syn::File = syn::parse2(output).unwrap();
|
|
|
|
let helper_fn = file
|
|
.items
|
|
.iter()
|
|
.find_map(|item| {
|
|
if let syn::Item::Fn(f) = item {
|
|
if f.sig.ident == "my_draw" {
|
|
return Some(f);
|
|
}
|
|
}
|
|
None
|
|
})
|
|
.expect("helper fn `my_draw` should exist");
|
|
|
|
let first_arg = helper_fn.sig.inputs.first().expect("should have 1 arg");
|
|
if let syn::FnArg::Typed(pat_type) = first_arg {
|
|
if let syn::Pat::Ident(pat_ident) = &*pat_type.pat {
|
|
assert_eq!(pat_ident.ident, "t");
|
|
assert!(pat_ident.mutability.is_some());
|
|
} else {
|
|
panic!("expected ident pattern");
|
|
}
|
|
} else {
|
|
panic!("expected typed arg");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_expansion_main_fn_renamed() {
|
|
let input = quote! {
|
|
fn main(t: &mut TurtlePlan) {
|
|
t.forward(100.0);
|
|
}
|
|
};
|
|
let output = turtle_main_impl("e!(), input).unwrap();
|
|
let file: syn::File = syn::parse2(output).unwrap();
|
|
|
|
let helper_fn = file
|
|
.items
|
|
.iter()
|
|
.find_map(|item| {
|
|
if let syn::Item::Fn(f) = item {
|
|
if f.sig.ident == "__turtle_main_draw" {
|
|
return Some(f);
|
|
}
|
|
}
|
|
None
|
|
})
|
|
.expect("helper fn `__turtle_main_draw` should exist");
|
|
|
|
let first_arg = helper_fn.sig.inputs.first().expect("should have 1 arg");
|
|
if let syn::FnArg::Typed(pat_type) = first_arg {
|
|
if let syn::Pat::Ident(pat_ident) = &*pat_type.pat {
|
|
assert_eq!(pat_ident.ident, "t");
|
|
} else {
|
|
panic!("expected ident pattern");
|
|
}
|
|
} else {
|
|
panic!("expected typed arg");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_expansion_zero_args() {
|
|
let input = quote! {
|
|
fn my_draw() {
|
|
turtle.forward(100.0);
|
|
}
|
|
};
|
|
let output = turtle_main_impl("e!(), input).unwrap();
|
|
let file: syn::File = syn::parse2(output).unwrap();
|
|
|
|
let helper_fn = file
|
|
.items
|
|
.iter()
|
|
.find_map(|item| {
|
|
if let syn::Item::Fn(f) = item {
|
|
if f.sig.ident == "my_draw" {
|
|
return Some(f);
|
|
}
|
|
}
|
|
None
|
|
})
|
|
.expect("helper fn `my_draw` should exist");
|
|
|
|
let first_arg = helper_fn.sig.inputs.first().expect("should have 1 arg");
|
|
if let syn::FnArg::Typed(pat_type) = first_arg {
|
|
if let syn::Pat::Ident(pat_ident) = &*pat_type.pat {
|
|
assert_eq!(pat_ident.ident, "turtle");
|
|
} else {
|
|
panic!("expected ident pattern");
|
|
}
|
|
} else {
|
|
panic!("expected typed arg");
|
|
}
|
|
}
|
|
}
|