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
21part146.189482ms ± 467.675µs
21part1_regex838.820205ms ± 10.505208ms
21part1_intmatch11.533401ms ± 1.266642ms
21part1_intmatch_divrem9.121032ms ± 85.661µs
21part1_chatgpt650.796µs ± 231.766µs
22part2110.797727ms ± 5.697853ms
22part2_intmatch100.421589ms ± 1.573153ms
31part11.31203ms ± 23.678µs
31part1_max_digits54.131µs ± 2.798µs
32part2147.712µs ± 5.738µs
41part1107.041µs ± 5.007µs
42part24.826662ms ± 86.921µs
42part2_no_map1.164075ms ± 33.765µs
42part2_floodfill670.268µs ± 19.006µs
51part169.969µs ± 2.217µs
52part2261.825µs ± 12.143µs
52part2_bruteforce~43 weeks 😄
61part138.877µs ± 3.178µs
61part1_grid39.811µs ± 3.344µs
62part234.372µs ± 1.887µs
71part150.033µs ± 3.791µs
72part245.279µs ± 3.015µs
81part116.634383ms ± 418.086µs
81part1_heap8.19521ms ± 314.775µs
82part216.977939ms ± 497.608µs
91part1113.22µs ± 5.711µs
92part2117.772829ms ± 5.835355ms
92part2_area_first75.460115ms ± 4.87159ms
92part2_compress30.911492ms ± 348.699µs
101part11.212226433s ± 13.832532ms
101part1_rayon331.882295ms ± 5.14392ms
102part2_z3_rayon129.899304ms ± 2.541906ms
102part2_eqn_rayon79.115645459s ± 0ns
111part1127.686µs ± 3.013µs
112part2_memo4.545146ms ± 78.356µs
121part196.430551417s ± 0ns
121part1_trivial153.807µs ± 8.791µ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:

pub fn register_impl(_attr: TokenStream, item: TokenStream) -> TokenStream {
    let func = parse_macro_input!(item as ItemFn);
    let fn_name = func.sig.ident.clone();
    let name_str = fn_name.to_string();

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

    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: crate::__aoc::DAY, 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]
fn part1(input: &str) -> impl Into<String> {
    ...
}

And voila!

It will automatically pick up the name of the function as the name used to run it in the CLI (coming up…)

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!

pub fn main_impl(input: TokenStream) -> TokenStream {
    let day_expr = parse_macro_input!(input as Expr);
    let day_str = crate::expr_to_string(&day_expr);

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

            static REGISTRY: OnceLock<Mutex<Vec<&'static Entry>>> = OnceLock::new();
            static RENDER_REGISTRY: OnceLock<Mutex<Vec<&'static RenderEntry>>> = 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 register_render(e: &'static RenderEntry) { let reg = RENDER_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 render_entries_for_day(day: &str) -> Vec<&'static RenderEntry> {
                let reg = RENDER_REGISTRY.get_or_init(|| Mutex::new(Vec::new()));
                let mut v: Vec<&'static RenderEntry> = 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)
            }

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

        fn main() {
            let tracing_enabled = std::env::var("RUST_TRACE").is_ok();
            if tracing_enabled {
                tracing_subscriber::fmt()
                    .pretty()
                    .without_time()
                    .with_max_level(tracing::Level::DEBUG)
                    .init();
                tracing::info!("Tracing enabled");
            } else {
                env_logger::init();
            }

            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,
                Render(RenderArgs),
                Run(RunArgs),
                Bench(BenchArgs),
            }

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

            #[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);
                    let render_entries = crate::__aoc::render_entries_for_day(day);
                    if entries.is_empty() && render_entries.is_empty() {
                        println!("No solutions registered for {}", day);
                    } else {
                        if !entries.is_empty() {
                            println!("Solutions:");
                            for e in entries { println!("  {}", e.name); }
                        }
                        if !render_entries.is_empty() {
                            println!("\nRender:");
                            for e in render_entries { println!("  {}", e.name); }
                        }
                    }
                }
                Commands::Render(args) => {
                    if args.all {
                        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::render_entries_for_day(day);
                        if entries.is_empty() { eprintln!("No render solutions registered for {}", day); std::process::exit(3); }
                        for e in entries { (e.func)(&input); }
                    } 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_render(day, &name) { Some(entry) => { (entry.func)(&input); } None => { eprintln!("No such render solution: {}. Try 'list'.", name); std::process::exit(3); } }
                    }
                }
                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!(file = "input/2025/day1.txt", [part1] => "1055", [part2, part2_inline] => "6386");

Or:

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

That’s pretty fun.

pub fn test_impl(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 {
        input_spec: InputSpec,
        cases: Vec<TestCase>,
    }

    impl Parse for TestInput {
        fn parse(input: ParseStream) -> syn::Result<Self> {
            // 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::<syn::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::<syn::Token![,]>()?;

                // Parse [solution1, solution2, ...]
                let content;
                syn::bracketed!(content in input);
                let solutions: Vec<Expr> = content
                    .parse_terminated(Expr::parse, syn::Token![,])?
                    .into_iter()
                    .collect();

                input.parse::<syn::Token![=>]>()?;
                let expected: LitStr = input.parse()?;

                cases.push(TestCase {
                    solutions,
                    expected,
                });
            }

            Ok(TestInput { input_spec, cases })
        }
    }

    let test_input = parse_macro_input!(input as TestInput);
    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 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 = crate::expr_to_string(solution_expr);
                    let test_name = quote::format_ident!("test_{}_{}", source_tag_str, name_str);
                    let name_lit = name_str.clone();
                    let expected_lit = expected.clone();
                    let input_binding_clone = input_binding_outer.clone();

                    quote! {
                        #[test]
                        fn #test_name() {
                            #input_binding_clone
                            let entry = crate::__aoc::get(crate::__aoc::DAY, #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!