Advent of Code 2025

It’s back! (Advent of Code)!

It’s been ten years (of advent of code, I haven’t done them all (yet)) and oh what a ten years it’s been. This time around, there will be only 12 days instead of 25, but honestly, that means I’m not working on these right up until Christmas. So I’m okay with this.

Once again, Rust! But this time, I won’t be using cargo-aoc, instead I wrote my own proc macros. Mostly to see if I could. 😄 See this section for more information.

Full solutions will once again be posted to GitHub (including previous years and possibly some I haven’t written up yet): jpverkamp/advent-of-code

Here are (or will be) all of my solutions:

Here’s how long each solution takes to run:

DayPartSolutionBenchmark
11part1215.243µs ± 11.937µs
12part22.237187ms ± 52.313µs
12part2_inline1.870518ms ± 50.308µs

And here are my previous year’s solutions:

My very own AOC macros

I like cargo aoc well enough, but I wanted to see what I could write for myself. And also, I can tune this one as I go (if that comes up). So we have a macro in three parts:

Registering solutions

First, the macro you use to tag functions as solutions:

// Attribute macro: #[aoc::register(day, name)]
#[proc_macro_attribute]
pub fn register(attr: TokenStream, item: TokenStream) -> TokenStream {
    let parser = syn::punctuated::Punctuated::<Expr, Token![,]>::parse_terminated;
    let args = parser
        .parse(attr.into())
        .expect("expected #[aoc::register(day0, name)]");
    assert!(
        args.len() == 2,
        "expected two arguments to #[aoc::register(day0, name)]"
    );

    let day_str = expr_to_string(&args[0]);
    let name_str = expr_to_string(&args[1]);

    let func = parse_macro_input!(item as ItemFn);
    let fn_name = func.sig.ident.clone();

    let shim_ident: Ident = format_ident!("__aoc_shim_{}", fn_name);
    let entry_ident: Ident = format_ident!("__AOC_ENTRY_{}", fn_name.to_string().to_uppercase());
    let reg_ident: Ident = format_ident!("__aoc_register_{}", fn_name);

    let day_lit = day_str;
    let name_lit = name_str;

    let expanded = quote! {
        #func

        #[doc(hidden)]
        fn #shim_ident(input: &str) -> String { #fn_name(input).into() }

        #[doc(hidden)]
        static #entry_ident: crate::__aoc::Entry = crate::__aoc::Entry { day: #day_lit, name: #name_lit, func: #shim_ident };

        #[doc(hidden)]
        #[::ctor::ctor]
        fn #reg_ident() { crate::__aoc::register(&#entry_ident); }
    };

    TokenStream::from(expanded)
}

This uses the ‘registry’ from aoc_main! below, to do basically all the work. But all you have to do is register it:

#[aoc::register(day1, part1)]
fn part1(input: &str) -> impl Into<String> {
    ...
}

And voila!

Write a main function (with CLI params)

This is the real meat of the functionality. It takes the registered functions above and builds out a CLI for each day, supporting:

$ ./target/release/day1 --help

Usage: day1 <COMMAND>

Commands:
  list
  run
  bench
  help   Print this message or the help of the given subcommand(s)

Options:
  -h, --help  Print help

$ ./target/release/day1 run --help

Usage: day1 run [OPTIONS] [NAME] [INPUT]

Arguments:
  [NAME]   Name of the solution to run (if not using --all)
  [INPUT]  Input path (use '-' for stdin)

Options:
      --all   Run all registered solutions for the day
  -h, --help  Print help

So you can list possible solutions, run one (or --all of them), or benchmark them. Pretty nice for basically free (after the first writing of course).

It is a bit inefficient in that this code will be generated for each day’s binary, but 🤷. Space is cheap.

Here it is!

// Function-like macro to define the machine function/CLI parsing: aoc::main!(day)
#[proc_macro]
pub fn main(input: TokenStream) -> TokenStream {
    let day_expr = parse_macro_input!(input as Expr);
    let day_str = expr_to_string(&day_expr);


    let expanded = quote! {
        #[doc(hidden)]
        mod __aoc {
            use std::sync::{Mutex, OnceLock};
            pub struct Entry { pub day: &'static str, pub name: &'static str, pub func: fn(&str) -> String }

            static REGISTRY: OnceLock<Mutex<Vec<&'static Entry>>> = OnceLock::new();

            pub fn register(e: &'static Entry) { let reg = REGISTRY.get_or_init(|| Mutex::new(Vec::new())); reg.lock().unwrap().push(e); }

            pub fn entries_for_day(day: &str) -> Vec<&'static Entry> {
                let reg = REGISTRY.get_or_init(|| Mutex::new(Vec::new()));
                let mut v: Vec<&'static Entry> = reg.lock().unwrap().iter().copied().filter(|e| e.day == day).collect();
                v.sort_by(|a,b| a.name.cmp(b.name)); v
            }

            pub fn get(day: &str, name: &str) -> Option<&'static Entry> {
                let reg = REGISTRY.get_or_init(|| Mutex::new(Vec::new()));
                reg.lock().unwrap().iter().copied().find(|e| e.day == day && e.name == name)
            }
        }

        fn main() {
            let day: &str = #day_str;

            use clap::{Parser, Subcommand, Args};

            #[derive(Parser)]
            #[command(name = env!("CARGO_PKG_NAME"))]
            struct Cli { #[command(subcommand)] command: Commands }

            #[derive(Subcommand)]
            enum Commands {
                List,
                Run(RunArgs),
                Bench(BenchArgs),
            }

            #[derive(Args)]
            struct RunArgs {
                /// Run all registered solutions for the day
                #[arg(long)]
                all: bool,
                /// Name of the solution to run (if not using --all)
                name: Option<String>,
                /// Input path (use '-' for stdin)
                input: Option<String>,
            }

            #[derive(Args)]
            struct BenchArgs {
                /// Run all registered solutions for the day
                #[arg(long)]
                all: bool,
                /// Name of the solution to bench (if not using --all)
                name: Option<String>,
                /// Input path (use '-' for stdin)
                input: Option<String>,
                /// Number of warmup runs (default: 3)
                #[arg(long, default_value_t = 3)]
                warmup: usize,
                /// Number of benchmark iterations (default: 100)
                #[arg(long, default_value_t = 100)]
                iters: usize,
            }

            let cli = Cli::parse();

            let read_input = |input_path: String| -> String {
                if input_path == "-" { use std::io::Read; let mut s = String::new(); std::io::stdin().read_to_string(&mut s).expect("failed to read stdin"); s } else { std::fs::read_to_string(&input_path).unwrap_or_else(|e| panic!("failed to read {}: {}", input_path, e)) }
            };

            let benchmark = |name: &str, func: fn(&str) -> String, input: &str, warmup: usize, iters: usize| {
                // Warmup
                for _ in 0..warmup { let _ = func(input); }

                // Benchmark
                let mut times = Vec::with_capacity(iters);
                for _ in 0..iters {
                    let start = std::time::Instant::now();
                    let _ = func(input);
                    times.push(start.elapsed());
                }

                times.sort();
                let min = times[0];
                let max = times[iters - 1];
                let median = times[iters / 2];

                // Compute average and standard deviation (in nanoseconds)
                let sum_ns: u128 = times.iter().map(|d| d.as_nanos()).sum();
                let avg_ns = sum_ns as f64 / (iters as f64);
                let avg = std::time::Duration::from_nanos(avg_ns as u64);

                let var_ns = times
                    .iter()
                    .map(|d| {
                        let x = d.as_nanos() as f64;
                        let diff = x - avg_ns;
                        diff * diff
                    })
                    .sum::<f64>()
                    / (iters as f64);
                let stddev_ns = var_ns.sqrt();
                let stddev = std::time::Duration::from_nanos(stddev_ns as u64);

                println!("{name}: {avg:?} ± {stddev:?} [min: {min:?}, max: {max:?}, median: {median:?}]");
            };

            match cli.command {
                Commands::List => {
                    let entries = crate::__aoc::entries_for_day(day);
                    if entries.is_empty() {
                        println!("No solutions registered for {}", day);
                    } else {
                        for e in entries { println!("{}", e.name); }
                    }
                }
                Commands::Run(args) => {
                    if args.all {
                        // If --all is provided, require an input path. For backward compatibility we
                        // accept that callers may have supplied the input as the positional `name`.
                        let input_path = match args.input {
                            Some(ip) => ip,
                            None => match args.name {
                                Some(pos) => pos,
                                None => {
                                    eprintln!("Missing input path for --all. Provide an input path (use '-' for stdin) or --input <FILE>");
                                    std::process::exit(2);
                                }
                            },
                        };
                        let input = read_input(input_path);
                        let entries = crate::__aoc::entries_for_day(day);
                        if entries.is_empty() { eprintln!("No solutions registered for {}", day); std::process::exit(3); }
                        for e in entries { let out = (e.func)(&input); println!("{}: {}", e.name, out); }
                    } else {
                        let name = match args.name {
                            Some(n) => n,
                            None => { eprintln!("Missing solution name. Try 'list' to see registered names."); std::process::exit(2); }
                        };
                        let input_path = match args.input {
                            Some(i) => i,
                            None => { eprintln!("Missing input path. Provide an input path (use '-' for stdin) or --input <FILE>"); std::process::exit(2); }
                        };
                        let input = read_input(input_path);
                        match crate::__aoc::get(day, &name) { Some(entry) => { let out = (entry.func)(&input); println!("{}", out); } None => { eprintln!("No such solution: {}. Try 'list'.", name); std::process::exit(3); } }
                    }
                }
                Commands::Bench(args) => {
                    // Validate iteration count
                    if args.iters == 0 { eprintln!("--iters must be >= 1"); std::process::exit(2); }

                    if args.all {
                        // If --all is used, require an input path (positional or via --input)
                        let input_path = match args.input {
                            Some(ip) => ip,
                            None => match args.name {
                                Some(pos) => pos,
                                None => { eprintln!("Missing input path for --all. Provide an input path (use '-' for stdin) or --input <FILE>"); std::process::exit(2); }
                            },
                        };
                        let input = read_input(input_path);
                        let entries = crate::__aoc::entries_for_day(day);
                        if entries.is_empty() { eprintln!("No solutions registered for {}", day); std::process::exit(3); }
                        for e in entries { benchmark(e.name, e.func, &input, args.warmup, args.iters); }
                    } else {
                        let name = match args.name {
                            Some(n) => n,
                            None => { eprintln!("Missing solution name. Try 'list' to see registered names."); std::process::exit(2); }
                        };
                        let input_path = match args.input {
                            Some(i) => i,
                            None => { eprintln!("Missing input path. Provide an input path (use '-' for stdin) or --input <FILE>"); std::process::exit(2); }
                        };
                        let input = read_input(input_path);
                        match crate::__aoc::get(day, &name) { Some(entry) => { benchmark(entry.name, entry.func, &input, args.warmup, args.iters); } None => { eprintln!("No such solution: {}. Try 'list'.", name); std::process::exit(3); } }
                    }
                }
            }
        }
    };
    TokenStream::from(expanded)
}

Yeah. It’s a lot.

Tests

And finally, our test macro!

I thought about using my testit framework, but figured I might as well build it in.

The idea is that you specify the day, an input file, and then you can map as many sets of input functions to expected output as you want. Heck, you could even have multiple input files for smaller test cases if you wanted!

aoc::test!(day1, file = "input/2025/day1.txt", [part1] => "1055", [part2, part2_inline] => "6386");

Or:

aoc::test!(day1, text = "\
L68
L30
R48
L5
R60
L55
L1
L99
R14
L82", [part1] => "3", [part2, part2_inline] => "6");

That’s pretty fun.

// Function-like macro: aoc::test!(day, file = "input_path", [solution1, solution2, etc] => "expected", [solution] => "expected", ...)
#[proc_macro]
pub fn test(input: TokenStream) -> TokenStream {
    // Custom parser for new test macro syntax
    struct TestCase {
        solutions: Vec<Expr>,
        expected: LitStr,
    }
    
    enum InputSpec {
        File(LitStr),
        Text(LitStr),
    }

    struct TestInput {
        day: Expr,
        input_spec: InputSpec,
        cases: Vec<TestCase>,
    }
    
    impl Parse for TestInput {
        fn parse(input: ParseStream) -> syn::Result<Self> {
            let day = input.parse()?;
            input.parse::<Token![,]>()?;

            // Accept either `file = "path"` or `text = "..."` here, or the legacy bare string literal (file path).
            let input_spec = if input.peek(syn::Ident) {
                let ident: syn::Ident = input.parse()?;
                input.parse::<Token![=]>()?;
                let val: LitStr = input.parse()?;
                match ident.to_string().as_str() {
                    "file" => InputSpec::File(val),
                    "text" => InputSpec::Text(val),
                    other => return Err(syn::Error::new_spanned(ident, format!("expected `file` or `text`, got `{}`", other))),
                }
            } else {
                // Legacy form: just a string literal which is treated as a file path
                let val: LitStr = input.parse()?;
                InputSpec::File(val)
            };
            
            let mut cases = Vec::new();
            
            while !input.is_empty() {
                input.parse::<Token![,]>()?;
                
                // Parse [solution1, solution2, ...]
                let content;
                syn::bracketed!(content in input);
                let solutions: Vec<Expr> = content
                    .parse_terminated(Expr::parse, Token![,])?
                    .into_iter()
                    .collect();
                
                input.parse::<Token![=>]>()?;
                let expected: LitStr = input.parse()?;
                
                cases.push(TestCase { solutions, expected });
            }
            
            Ok(TestInput { day, input_spec, cases })
        }
    }
    
    let test_input = parse_macro_input!(input as TestInput);
    let day_str = expr_to_string(&test_input.day);
    let input_spec = test_input.input_spec;

    // Build a unique source tag based on input type and content/path to avoid name collisions.
    fn short_hash(s: &str) -> String {
        use std::hash::{Hash, Hasher};
        let mut h = std::collections::hash_map::DefaultHasher::new();
        s.hash(&mut h);
        let v = h.finish();
        format!("{:x}", v)[..8].to_string()
    }
    let source_tag_str = match &input_spec {
        InputSpec::File(p) => {
            let path = p.value();
            let hash = short_hash(&path);
            // Take last component for readability
            let last = path.rsplit('/').next().unwrap_or(&path);
            let sanitized: String = last.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect();
            format!("file_{}_{}", sanitized, hash)
        }
        InputSpec::Text(t) => {
            let text = t.value();
            let hash = short_hash(&text);
            format!("text_{}", hash)
        }
    };

    // Precompute the input binding tokens (same for all cases in this macro invocation)
    let input_binding = match &input_spec {
        InputSpec::File(p) => { let p_lit = p.clone(); quote! { let input = std::fs::read_to_string(#p_lit).unwrap_or_else(|e| panic!("failed to read {}: {}", #p_lit, e)); } }
        InputSpec::Text(t) => { let t_lit = t.clone(); quote! { let input = #t_lit.to_string(); } }
    };

    // Generate test functions; include case and solution indices for full uniqueness.
    let test_functions: Vec<_> = test_input.cases.iter().flat_map(|test_case| {
        let expected = test_case.expected.value();
        let day_str = day_str.clone();
        let source_tag_str = source_tag_str.clone();
        let input_binding_outer = input_binding.clone();

        test_case.solutions.iter().map(move |solution_expr| {
            let name_str = expr_to_string(solution_expr);
            let test_name = format_ident!("test_{}_{}_{}", day_str, source_tag_str, name_str);
            let name_lit = name_str.clone();
            let expected_lit = expected.clone();
            let day_lit = day_str.clone();
            let input_binding_clone = input_binding_outer.clone();

            quote! {
                #[test]
                fn #test_name() {
                    #input_binding_clone
                    let entry = crate::__aoc::get(#day_lit, #name_lit)
                        .unwrap_or_else(|| panic!("solution {} not found", #name_lit));
                    let result = (entry.func)(&input);
                    assert_eq!(result, #expected_lit, "test failed for {}", #name_lit);
                }
            }
        }).collect::<Vec<_>>()
    }).collect();
    
    let expanded = quote! {
        #(#test_functions)*
    };
    
    TokenStream::from(expanded)
}

Originally, I didn’t support both files and text, but in a lot of Advent of Code problems, you are given a small test case which it’s useful to solve first. I did have to add the hashes though, since otherwise we’ll generate the same name twice. Now you have:

running 6 tests
test test_day1_text_f9cee729_part1 ... ok
test test_day1_text_f9cee729_part2_inline ... ok
test test_day1_text_f9cee729_part2 ... ok
test test_day1_file_day1_txt_71e8dd21_part1 ... ok
test test_day1_file_day1_txt_71e8dd21_part2_inline ... ok
test test_day1_file_day1_txt_71e8dd21_part2 ... ok

Which is pretty nice, IMO.

On to day 1!