Generating Rust bindings for Windows APIs with a pure-Rust toolchain

Published

There's now a 100% Rust-native toolchain for generating Rust bindings from C/C++ headers. No MSBuild, no NuGet, no .NET. The old Microsoft.Windows.WinmdGenerator SDK still works if you need it, but the recommended path is now a three-step pipeline built entirely in Rust:

  1. windows-clang parses C/C++ headers and emits Rust Definition Language (.rdl) files
  2. windows-rdl compiles .rdl into ECMA-335-compliant metadata (.winmd)
  3. windows-bindgen consumes .winmd assemblies and generates Rust source

This walkthrough recreates the DIA SDK crate from scratch. If you've been through the original walkthrough, the final output is generally the same. What changes is how you produce the .winmd metadata.

Let's get started.

Scaffolding

First, create a new library crate.

mkdir dia-rs
cd dia-rs
cargo init --lib

Now, edit Cargo.toml and add a workspace.

[package]
name = "dia-rs"
version = "0.1.0"
edition = "2024"

[dependencies]

+[workspace]
+resolver = "3"

Last, add a placeholder child crate that will be responsible for parsing headers and generating Rust bindings.

cargo new tools/bindings

Cargo automatically adds tools/bindings to the workspace's members list in the root Cargo.toml, so no manual edit is needed there.

And that's it for the scaffolding work. Let's work on the bindings crate.

Bindings Crate: Headers to RDL

Start by adding windows-clang as a dependency to the new tools/bindings crate. (Because windows-clang has not yet been published, point to the Rust for Windows git repository.)

cargo add -p bindings windows-clang --git https://github.com/microsoft/windows-rs

Now flesh out the tool's main function by invoking windows_clang::clang with clang-specific args:

  • -x c++, to treat the input as C++ source code
  • --target=x86_64-pc-windows-msvc, to target the x86-64 architecture and to use Microsoft Visual C++ ABI/calling conventions
fn main() {
-    println!("Hello, world!");
+    windows_clang::clang()
+        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
}

Continue the build by specifying which headers clang needs to parse via input_text. We use input_text rather than input because each call to input is parsed in its own translation unit, preventing us from supplying headers to satisfy DIA header dependencies. Using input_text with #include directives keeps everything in one translation unit.

Also, when you specify --target=x86_64-pc-windows-msvc, libclang automatically discovers and adds the VC, ATLMFC, UCRT, and Windows SDK include directories to its search path. You can leverage this to construct relative paths to other MSVC directories. For example, to reach the DIA SDK at C:\Program Files\Microsoft Visual Studio\18\Insiders\DIA SDK, start from the discovered VC include path C:\Program Files\Microsoft Visual Studio\18\Insiders\VC\Tools\MSVC\[version]\include and traverse up five directory levels to the Visual Studio root.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
+        .input_text(
+            r#"
+            #include <windows.h>
+            #include "../../../../../DIA SDK/include/cvconst.h"
+            #include "../../../../../DIA SDK/include/dia2.h"
+            #include "../../../../../DIA SDK/include/diacreate.h"
+        "#,
+        )
}

Now, point to the default (built-in) Windows API metadata so the parser can resolve any shared types the DIA headers depend on.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
        .input_text(
            r#"
            #include <windows.h>
            #include "../../../../../DIA SDK/include/cvconst.h"
            #include "../../../../../DIA SDK/include/dia2.h"
            #include "../../../../../DIA SDK/include/diacreate.h"
        "#,
        )
+        .reference_default()
}

And narrow the output by adding a filter for each header. Only matched files are included in the generated metadata.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
        .input_text(
            r#"
            #include <windows.h>
            #include "../../../../../DIA SDK/include/cvconst.h"
            #include "../../../../../DIA SDK/include/dia2.h"
            #include "../../../../../DIA SDK/include/diacreate.h"
        "#,
        )
        .reference_default()
+        .filter("cvconst.h")
+        .filter("dia2.h")
+        .filter("diacreate.h")
}

Then set the output path so the parser knows where to write the .rdl file it produces.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
        .input_text(
            r#"
            #include <windows.h>
            #include "../../../../../DIA SDK/include/cvconst.h"
            #include "../../../../../DIA SDK/include/dia2.h"
            #include "../../../../../DIA SDK/include/diacreate.h"
        "#,
        )
        .reference_default()
        .filter("cvconst.h")
        .filter("dia2.h")
        .filter("diacreate.h")
+        .output("rdl/dia.rdl")
}

And assign a namespace to organize the generated metadata under Microsoft.Dia.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
        .input_text(
            r#"
            #include <windows.h>
            #include "../../../../../DIA SDK/include/cvconst.h"
            #include "../../../../../DIA SDK/include/dia2.h"
            #include "../../../../../DIA SDK/include/diacreate.h"
        "#,
        )
        .reference_default()
        .filter("cvconst.h")
        .filter("dia2.h")
        .filter("diacreate.h")
        .output("rdl/dia.rdl")
+        .namespace("Microsoft.Dia")
}

Then tell the tool which DLL implements any free functions it encounters during parsing so the metadata can store the right library association.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
        .input_text(
            r#"
            #include <windows.h>
            #include "../../../../../DIA SDK/include/cvconst.h"
            #include "../../../../../DIA SDK/include/dia2.h"
            #include "../../../../../DIA SDK/include/diacreate.h"
        "#,
        )
        .reference_default()
        .filter("cvconst.h")
        .filter("dia2.h")
        .filter("diacreate.h")
        .output("rdl/dia.rdl")
        .namespace("Microsoft.Dia")
+        .library("msdia140.dll")
}

Finally, call write to run the parser and produce the .rdl output.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
        .input_text(
            r#"
            #include <windows.h>
            #include "../../../../../DIA SDK/include/cvconst.h"
            #include "../../../../../DIA SDK/include/dia2.h"
            #include "../../../../../DIA SDK/include/diacreate.h"
        "#,
        )
        .reference_default()
        .filter("cvconst.h")
        .filter("dia2.h")
        .filter("diacreate.h")
        .output("rdl/dia.rdl")
        .namespace("Microsoft.Dia")
        .library("msdia140.dll")
+        .write()
+        .unwrap();
}

Now, run the tool and verify dia.rdl exists in the rdl/ directory.

cargo run -p bindings

With the C++ declarations now translated into RDL, the next step is to compile that RDL into Windows Metadata.

Bindings Crate: RDL to Metadata

Add windows-rdl as a dependency to the tools/bindings crate.

cargo add -p bindings windows-rdl --git https://github.com/microsoft/windows-rs

Now extend the main function to read the intermediate RDL and generate ECMA-335-compliant metadata.

Start by telling the windows_rdl reader to read our RDL and use the default Windows API metadata as a reference for any types that DIA depends on.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
        .input_text(
            r#"
            #include <windows.h>
            #include "../../../../../DIA SDK/include/cvconst.h"
            #include "../../../../../DIA SDK/include/dia2.h"
            #include "../../../../../DIA SDK/include/diacreate.h"
        "#,
        )
        .reference_default()
        .filter("cvconst.h")
        .filter("dia2.h")
        .filter("diacreate.h")
        .output("rdl/dia.rdl")
        .namespace("Microsoft.Dia")
        .library("msdia140.dll")
        .write()
        .unwrap();
+
+    windows_rdl::reader()
+        .input("rdl/dia.rdl")
+        .reference_default()
}

And where to put the output.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
        .input_text(
            r#"
            #include <windows.h>
            #include "../../../../../DIA SDK/include/cvconst.h"
            #include "../../../../../DIA SDK/include/dia2.h"
            #include "../../../../../DIA SDK/include/diacreate.h"
        "#,
        )
        .reference_default()
        .filter("cvconst.h")
        .filter("dia2.h")
        .filter("diacreate.h")
        .output("rdl/dia.rdl")
        .namespace("Microsoft.Dia")
        .library("msdia140.dll")
        .write()
        .unwrap();

    windows_rdl::reader()
        .input("rdl/dia.rdl")
        .reference_default()
+        .output("winmd/Microsoft.Dia.winmd")
}

Finally, call write to run the reader and write out metadata.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
        .input_text(
            r#"
            #include <windows.h>
            #include "../../../../../DIA SDK/include/cvconst.h"
            #include "../../../../../DIA SDK/include/dia2.h"
            #include "../../../../../DIA SDK/include/diacreate.h"
        "#,
        )
        .reference_default()
        .filter("cvconst.h")
        .filter("dia2.h")
        .filter("diacreate.h")
        .output("rdl/dia.rdl")
        .namespace("Microsoft.Dia")
        .library("msdia140.dll")
        .write()
        .unwrap();

    windows_rdl::reader()
        .input("rdl/dia.rdl")
        .reference_default()
        .output("winmd/Microsoft.Dia.winmd")
+        .write()
+        .unwrap();
}

Now, create that winmd directory.

mkdir winmd

And run the tool. Look for a Microsoft.Dia.winmd file in the winmd/ directory.

cargo run -p bindings

With metadata in place, the pipeline can now move to code generation, where windows-bindgen projects that metadata into Rust bindings.

Bindings Crate: Metadata to Rust Bindings

Add windows-bindgen as a dependency to the tools/bindings crate.

cargo add -p bindings windows-bindgen --git https://github.com/microsoft/windows-rs

Now extend the main function to consume the .winmd assembly and generate the Rust bindings.

Start by telling windows_bindgen to read our Microsoft.Dia.winmd assembly via the --in argument.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
        .input_text(
            r#"
            #include <windows.h>
            #include "../../../../../DIA SDK/include/cvconst.h"
            #include "../../../../../DIA SDK/include/dia2.h"
            #include "../../../../../DIA SDK/include/diacreate.h"
        "#,
        )
        .reference_default()
        .filter("cvconst.h")
        .filter("dia2.h")
        .filter("diacreate.h")
        .output("rdl/dia.rdl")
        .namespace("Microsoft.Dia")
        .library("msdia140.dll")
        .write()
        .unwrap();

    windows_rdl::reader()
        .input("rdl/dia.rdl")
        .reference_default()
        .output("winmd/Microsoft.Dia.winmd")
        .write()
        .unwrap();
+
+    windows_bindgen::bindgen([
+        "--in",
+        "winmd/Microsoft.Dia.winmd",
+    ]);
}

The DIA types depend on shared Windows API types, so add a second --in pointing at the default (built-in) Windows API metadata for reference.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
        .input_text(
            r#"
            #include <windows.h>
            #include "../../../../../DIA SDK/include/cvconst.h"
            #include "../../../../../DIA SDK/include/dia2.h"
            #include "../../../../../DIA SDK/include/diacreate.h"
        "#,
        )
        .reference_default()
        .filter("cvconst.h")
        .filter("dia2.h")
        .filter("diacreate.h")
        .output("rdl/dia.rdl")
        .namespace("Microsoft.Dia")
        .library("msdia140.dll")
        .write()
        .unwrap();

    windows_rdl::reader()
        .input("rdl/dia.rdl")
        .reference_default()
        .output("winmd/Microsoft.Dia.winmd")
        .write()
        .unwrap();

    windows_bindgen::bindgen([
        "--in",
        "winmd/Microsoft.Dia.winmd",
+        "--in",
+        "default",
+    ]);
}

Then specify where generated Rust code should be written with --out.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
        .input_text(
            r#"
            #include <windows.h>
            #include "../../../../../DIA SDK/include/cvconst.h"
            #include "../../../../../DIA SDK/include/dia2.h"
            #include "../../../../../DIA SDK/include/diacreate.h"
        "#,
        )
        .reference_default()
        .filter("cvconst.h")
        .filter("dia2.h")
        .filter("diacreate.h")
        .output("rdl/dia.rdl")
        .namespace("Microsoft.Dia")
        .library("msdia140.dll")
        .write()
        .unwrap();

    windows_rdl::reader()
        .input("rdl/dia.rdl")
        .reference_default()
        .output("winmd/Microsoft.Dia.winmd")
        .write()
        .unwrap();

    windows_bindgen::bindgen([
        "--in",
        "winmd/Microsoft.Dia.winmd",
        "--in",
        "default",
+        "--out",
+        "src/public_bindings.rs",
    ]);
}

And add --flat to merge all namespaces together and emit their types as a single flat list of items.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
        .input_text(
            r#"
            #include <windows.h>
            #include "../../../../../DIA SDK/include/cvconst.h"
            #include "../../../../../DIA SDK/include/dia2.h"
            #include "../../../../../DIA SDK/include/diacreate.h"
        "#,
        )
        .reference_default()
        .filter("cvconst.h")
        .filter("dia2.h")
        .filter("diacreate.h")
        .output("rdl/dia.rdl")
        .namespace("Microsoft.Dia")
        .library("msdia140.dll")
        .write()
        .unwrap();

    windows_rdl::reader()
        .input("rdl/dia.rdl")
        .reference_default()
        .output("winmd/Microsoft.Dia.winmd")
        .write()
        .unwrap();

    windows_bindgen::bindgen([
        "--in",
        "winmd/Microsoft.Dia.winmd",
        "--in",
        "default",
        "--out",
        "src/public_bindings.rs",
+        "--flat",
    ]);
}

Finally, select our namespace for output with a --filter.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
        .input_text(
            r#"
            #include <windows.h>
            #include "../../../../../DIA SDK/include/cvconst.h"
            #include "../../../../../DIA SDK/include/dia2.h"
            #include "../../../../../DIA SDK/include/diacreate.h"
        "#,
        )
        .reference_default()
        .filter("cvconst.h")
        .filter("dia2.h")
        .filter("diacreate.h")
        .output("rdl/dia.rdl")
        .namespace("Microsoft.Dia")
        .library("msdia140.dll")
        .write()
        .unwrap();

    windows_rdl::reader()
        .input("rdl/dia.rdl")
        .reference_default()
        .output("winmd/Microsoft.Dia.winmd")
        .write()
        .unwrap();

    windows_bindgen::bindgen([
        "--in",
        "winmd/Microsoft.Dia.winmd",
        "--in",
        "default",
        "--out",
        "src/public_bindings.rs",
        "--flat",
+        "--filter",
+        "Microsoft.Dia",
    ]);
}

Now, run the tool and look for a public_bindings.rs file in the src/ directory.

cargo run -p bindings

With the DIA bindings generated, the remaining work is runtime activation.

Bindings Crate: Helpers

DIA doesn't ship with Windows; it's distributed separately as part of the Visual Studio installer. That means the DiaSource CLSID isn't guaranteed to be registered with the system, so a plain CoCreateInstance may fail. The library either has to be registered ahead of time, or discovered and loaded at runtime. We're going to take the runtime route with a NoRegCoCreate helper that locates and loads msdia140.dll by hand, bypassing the need for registration completely.

To write that helper we need a handful of Windows API types and functions (IClassFactory, LoadLibraryExA, GetProcAddress), and the LOAD_WITH_ALTERED_SEARCH_PATH flag. To keep things tidy, generate a second set of bindings from default metadata.

Start by telling windows_bindgen to read the default Windows API metadata via --in.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
        .input_text(
            r#"
            #include <windows.h>
            #include "../../../../../DIA SDK/include/cvconst.h"
            #include "../../../../../DIA SDK/include/dia2.h"
            #include "../../../../../DIA SDK/include/diacreate.h"
        "#,
        )
        .reference_default()
        .filter("cvconst.h")
        .filter("dia2.h")
        .filter("diacreate.h")
        .output("rdl/dia.rdl")
        .namespace("Microsoft.Dia")
        .library("msdia140.dll")
        .write()
        .unwrap();

    windows_rdl::reader()
        .input("rdl/dia.rdl")
        .reference_default()
        .output("winmd/Microsoft.Dia.winmd")
        .write()
        .unwrap();

    windows_bindgen::bindgen([
        "--in",
        "winmd/Microsoft.Dia.winmd",
        "--in",
        "default",
        "--out",
        "src/public_bindings.rs",
        "--flat",
        "--filter",
        "Microsoft.Dia",
    ]);
+
+    windows_bindgen::bindgen([
+        "--in",
+        "default",
+    ]);
}

Then name the output file with --out.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
        .input_text(
            r#"
            #include <windows.h>
            #include "../../../../../DIA SDK/include/cvconst.h"
            #include "../../../../../DIA SDK/include/dia2.h"
            #include "../../../../../DIA SDK/include/diacreate.h"
        "#,
        )
        .reference_default()
        .filter("cvconst.h")
        .filter("dia2.h")
        .filter("diacreate.h")
        .output("rdl/dia.rdl")
        .namespace("Microsoft.Dia")
        .library("msdia140.dll")
        .write()
        .unwrap();

    windows_rdl::reader()
        .input("rdl/dia.rdl")
        .reference_default()
        .output("winmd/Microsoft.Dia.winmd")
        .write()
        .unwrap();

    windows_bindgen::bindgen([
        "--in",
        "winmd/Microsoft.Dia.winmd",
        "--in",
        "default",
        "--out",
        "src/public_bindings.rs",
        "--flat",
        "--filter",
        "Microsoft.Dia",
    ]);

    windows_bindgen::bindgen([
        "--in",
        "default",
+        "--out",
+        "src/helper_bindings.rs",
    ]);
}

Then add --flat to keep the types as a flat list.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
        .input_text(
            r#"
            #include <windows.h>
            #include "../../../../../DIA SDK/include/cvconst.h"
            #include "../../../../../DIA SDK/include/dia2.h"
            #include "../../../../../DIA SDK/include/diacreate.h"
        "#,
        )
        .reference_default()
        .filter("cvconst.h")
        .filter("dia2.h")
        .filter("diacreate.h")
        .output("rdl/dia.rdl")
        .namespace("Microsoft.Dia")
        .library("msdia140.dll")
        .write()
        .unwrap();

    windows_rdl::reader()
        .input("rdl/dia.rdl")
        .reference_default()
        .output("winmd/Microsoft.Dia.winmd")
        .write()
        .unwrap();

    windows_bindgen::bindgen([
        "--in",
        "winmd/Microsoft.Dia.winmd",
        "--in",
        "default",
        "--out",
        "src/public_bindings.rs",
        "--flat",
        "--filter",
        "Microsoft.Dia",
    ]);

    windows_bindgen::bindgen([
        "--in",
        "default",
        "--out",
        "src/helper_bindings.rs",
+        "--flat",
    ]);
}

Then use --filter to narrow the output down to just the handful of types and functions the helper needs.

fn main() {
    windows_clang::clang()
        .args(["-x", "c++", "--target=x86_64-pc-windows-msvc"])
        .input_text(
            r#"
            #include <windows.h>
            #include "../../../../../DIA SDK/include/cvconst.h"
            #include "../../../../../DIA SDK/include/dia2.h"
            #include "../../../../../DIA SDK/include/diacreate.h"
        "#,
        )
        .reference_default()
        .filter("cvconst.h")
        .filter("dia2.h")
        .filter("diacreate.h")
        .output("rdl/dia.rdl")
        .namespace("Microsoft.Dia")
        .library("msdia140.dll")
        .write()
        .unwrap();

    windows_rdl::reader()
        .input("rdl/dia.rdl")
        .reference_default()
        .output("winmd/Microsoft.Dia.winmd")
        .write()
        .unwrap();

    windows_bindgen::bindgen([
        "--in",
        "winmd/Microsoft.Dia.winmd",
        "--in",
        "default",
        "--out",
        "src/public_bindings.rs",
        "--flat",
        "--filter",
        "Microsoft.Dia",
    ]);

    windows_bindgen::bindgen([
        "--in",
        "default",
        "--out",
        "src/helper_bindings.rs",
        "--flat",
+        "--filter",
+        "IClassFactory",
+        "LoadLibraryExA",
+        "GetProcAddress",
+        "LOAD_WITH_ALTERED_SEARCH_PATH",
    ]);
}

Now, run the tool again and look for src/public_bindings.rs and src/helper_bindings.rs in the workspace.

cargo run -p bindings

With both binding sets in place, we can now move up to the root SDK crate, where those generated types are consumed to implement and expose the DIA SDK API.

SDK Crate

The generated bindings rely on the windows-core crate, so before we consume them, add the windows-core crate as a dependency to the root crate.

cargo add windows-core --git https://github.com/microsoft/windows-rs

Now clear out lib.rs and bring in the previously generated bindings.

-pub fn add(left: u64, right: u64) -> u64 {
-    left + right
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn it_works() {
-        let result = add(2, 2);
-        assert_eq!(result, 4);
-    }
-}
+mod helper_bindings;
+mod public_bindings;
+
+pub use helper_bindings::*;
+pub use public_bindings::*;

Now, compile the crate to check our work thus far.

cargo build

You'll notice many warnings about identifier casing. Since this is generated code that follows Windows naming conventions rather than idiomatic Rust, it's safe to suppress those warnings in the imported modules.

Add an inner attribute #![allow(...)] to squelch those warnings across all imported modules.

+#![allow(non_snake_case, non_upper_case_globals, non_camel_case_types)]
+
mod helper_bindings;
mod public_bindings;

pub use helper_bindings::*;
pub use public_bindings::*;

At this point, you have built nearly everything in the published Microsoft DIA SDK Rust crate.

Additional Tasks

That covers the full pure-Rust pipeline: headers to RDL, RDL to metadata, metadata to bindings. What's left is wiring up the NoRegCoCreate helper and writing a few sample crates, which I'll pick up in a follow-up revision.