├── .gitignore ├── tests ├── multiple.rs └── baz │ ├── Cargo.toml │ └── lib.rs ├── Cargo.toml ├── src ├── tests.rs └── lib.rs ├── README.md └── .concourse.yml /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | .vscode 4 | -------------------------------------------------------------------------------- /tests/multiple.rs: -------------------------------------------------------------------------------- 1 | #[baz::baz] 2 | struct A; 3 | 4 | #[baz::baz] 5 | struct B; 6 | 7 | #[test] 8 | fn main() { 9 | let _a = A; 10 | let _b = B; 11 | } 12 | -------------------------------------------------------------------------------- /tests/baz/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "baz" 3 | version = "0.0.1" 4 | authors = ["Bernhard Schuster "] 5 | edition = "2021" 6 | license = "MIT OR Apache-2.0" 7 | homepage = "https://ahoi.io" 8 | publish = false 9 | 10 | [lib] 11 | path = "lib.rs" 12 | proc-macro = true 13 | 14 | [dependencies] 15 | proc-macro2 = "1" 16 | quote = "1" 17 | expander = { path = "../..", features = [ 18 | "pretty", 19 | ], default-features = false } 20 | -------------------------------------------------------------------------------- /tests/baz/lib.rs: -------------------------------------------------------------------------------- 1 | use expander::{Channel, Edition, Expander}; 2 | 3 | #[proc_macro_attribute] 4 | pub fn baz(_attr: proc_macro::TokenStream, input: proc_macro::TokenStream) -> proc_macro::TokenStream { 5 | baz2(input.into()).into() 6 | } 7 | 8 | fn baz2(input: proc_macro2::TokenStream) -> proc_macro2::TokenStream { 9 | let modified = quote::quote!{ 10 | #[derive(Debug, Clone, Copy)] 11 | #input 12 | }; 13 | 14 | let expanded = Expander::new("baz") 15 | .verbose(true) 16 | .add_comment("This is generated code!".to_owned()) 17 | .fmt_full(Channel::Stable, Edition::_2021, true) 18 | .write_to_out_dir(modified).expect("No IO error happens. qed"); 19 | expanded 20 | } 21 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "expander" 3 | version = "2.2.1" 4 | authors = ["Bernhard Schuster "] 5 | edition = "2021" 6 | license = "MIT OR Apache-2.0" 7 | description = "Expands proc macro output to a file, to enable easier debugging" 8 | homepage = "https://ahoi.io" 9 | repository = "https://github.com/drahnr/expander.git" 10 | rust-version = "1.65" 11 | 12 | [dependencies] 13 | fs-err = "2" 14 | proc-macro2 = "1" 15 | quote = "1" 16 | blake2 = "0.10" 17 | syn = { version = "2", optional = true, default-features = false } 18 | prettyplease = { version = "0.2", optional = true, default-features = false } 19 | file-guard = "0.2.0" 20 | 21 | [dev-dependencies] 22 | baz = { path = "./tests/baz" } 23 | syn = { version = "2", features = ["extra-traits", "parsing", "full"] } 24 | 25 | [features] 26 | default = ["syndicate", "pretty"] 27 | syndicate = ["syn"] 28 | pretty = ["prettyplease", "syn/parsing", "syn/full"] 29 | -------------------------------------------------------------------------------- /src/tests.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use proc_macro2::Span; 3 | 4 | #[test] 5 | fn dry() -> Result<(), std::io::Error> { 6 | let ts = quote! { 7 | pub struct X { 8 | x: [u8;32], 9 | } 10 | }; 11 | let modified = Expander::new("foo") 12 | .add_comment("This is generated code!".to_owned()) 13 | .fmt(Edition::_2021) 14 | .dry(true) 15 | .write_to_out_dir(ts.clone())?; 16 | 17 | assert_eq!( 18 | ts.to_string(), 19 | modified.to_string(), 20 | "Dry does not alter the provided `TokenStream`. qed" 21 | ); 22 | Ok(()) 23 | } 24 | 25 | #[test] 26 | fn basic() -> Result<(), std::io::Error> { 27 | let ts = quote! { 28 | pub struct X { 29 | x: [u8;32], 30 | } 31 | }; 32 | let modified = Expander::new("bar") 33 | .add_comment("This is generated code!".to_owned()) 34 | .fmt(Edition::_2021) 35 | // .dry(false) 36 | .write_to_out_dir(ts.clone())?; 37 | 38 | let s = modified.to_string(); 39 | assert_ne!(s, ts.to_string()); 40 | assert!(s.contains("include ! (")); 41 | Ok(()) 42 | } 43 | 44 | #[test] 45 | fn syn_ok_is_written_to_external_file() -> Result<(), std::io::Error> { 46 | let ts = Ok(quote! { 47 | pub struct X { 48 | x: [u8;32], 49 | } 50 | }); 51 | let result = Expander::new("bar") 52 | .add_comment("This is generated code!".to_owned()) 53 | .fmt(Edition::_2021) 54 | // .dry(false) 55 | .maybe_write_to_out_dir(ts.clone())?; 56 | let modified = result.expect("Is not a syn error. qed"); 57 | 58 | let s = modified.to_string(); 59 | assert_ne!(s, ts.unwrap().to_string()); 60 | assert!(s.contains("include ! ")); 61 | Ok(()) 62 | } 63 | 64 | #[test] 65 | fn syn_error_is_not_written_to_external_file() -> Result<(), std::io::Error> { 66 | const MSG: &str = "Hajajajaiii!"; 67 | let ts = Err(syn::Error::new(Span::call_site(), MSG)); 68 | let result = Expander::new("") 69 | .add_comment("This is generated code!".to_owned()) 70 | .fmt(Edition::_2021) 71 | // .dry(false) 72 | .maybe_write_to_out_dir(ts.clone())?; 73 | let modified = result.expect_err("Is a syn error. qed"); 74 | 75 | let s = modified.to_compile_error().to_string(); 76 | assert!(dbg!(&s).contains("compile_error !")); 77 | assert!(s.contains(MSG)); 78 | 79 | Ok(()) 80 | } 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![crates.io](https://img.shields.io/crates/v/expander.svg)](https://crates.io/crates/expander) 3 | [![CI](https://ci.fff.rs/api/v1/teams/main/pipelines/expander/jobs/master-validate/badge)](https://ci.fff.rs/teams/main/pipelines/expander/jobs/master-validate) 4 | ![commits-since](https://img.shields.io/github/commits-since/drahnr/expander/latest.svg) 5 | [![rust 1.65.0+ badge](https://img.shields.io/badge/rust-1.65.0+-93450a.svg)](https://blog.rust-lang.org/2022/11/03/Rust-1.65.0.html) 6 | 7 | # expander 8 | 9 | Expands a proc-macro into a file, and uses a `include!` directive in place. 10 | 11 | 12 | ## Advantages 13 | 14 | * Only expands a particular proc-macro, not all of them. I.e. `tracing` is notorious for expanding into a significant amount of boilerplate with i.e. `cargo expand` 15 | * Get good errors when _your_ generated code is not perfect yet 16 | 17 | 18 | ## Usage 19 | 20 | In your `proc-macro`, use it like: 21 | 22 | ```rust 23 | 24 | #[proc_macro_attribute] 25 | pub fn baz(_attr: proc_macro::TokenStream, input: proc_macro::TokenStream) -> proc_macro::TokenStream { 26 | // wrap as per usual for `proc-macro2::TokenStream`, here dropping `attr` for simplicity 27 | baz2(input.into()).into() 28 | } 29 | 30 | 31 | // or any other macro type 32 | fn baz2(input: proc_macro2::TokenStream) -> proc_macro2::TokenStream { 33 | let modified = quote::quote!{ 34 | #[derive(Debug, Clone, Copy)] 35 | #input 36 | }; 37 | 38 | let expanded = Expander::new("baz") 39 | .add_comment("This is generated code!".to_owned()) 40 | .fmt(Edition::_2021) 41 | .verbose(true) 42 | // common way of gating this, by making it part of the default feature set 43 | .dry(cfg!(feature="no-file-expansion")) 44 | .write_to_out_dir(modified.clone()).unwrap_or_else(|e| { 45 | eprintln!("Failed to write to file: {:?}", e); 46 | modified 47 | }); 48 | expanded 49 | } 50 | ``` 51 | 52 | will expand into 53 | 54 | ```rust 55 | include!("/absolute/path/to/your/project/target/debug/build/expander-49db7ae3a501e9f4/out/baz-874698265c6c4afd1044a1ced12437c901a26034120b464626128281016424db.rs"); 56 | ``` 57 | 58 | where the file content will be 59 | 60 | ```rust 61 | #[derive(Debug, Clone, Copy)] 62 | struct X { 63 | y: [u8:32], 64 | } 65 | ``` 66 | 67 | 68 | ## Exemplary output 69 | 70 | An error in your proc-macro, i.e. an excess `;`, is shown as 71 | 72 | --- 73 | 74 |
   Compiling expander v0.0.4-alpha.0 (/somewhere/expander)
 75 | error: macro expansion ignores token `;` and any following
 76 |  --> tests/multiple.rs:1:1
 77 |   |
 78 | 1 | #[baz::baz]
 79 |   | ^^^^^^^^^^^ caused by the macro expansion here
 80 |   |
 81 |   = note: the usage of `baz::baz!` is likely invalid in item context
 82 | 
 83 | error: macro expansion ignores token `;` and any following
 84 |  --> tests/multiple.rs:4:1
 85 |   |
 86 | 4 | #[baz::baz]
 87 |   | ^^^^^^^^^^^ caused by the macro expansion here
 88 |   |
 89 |   = note: the usage of `baz::baz!` is likely invalid in item context
 90 | 
 91 | error: could not compile `expander` due to 2 previous errors
 92 | warning: build failed, waiting for other jobs to finish...
 93 | error: build failed
 94 | 
95 | 96 | --- 97 | 98 | becomes 99 | 100 | --- 101 | 102 |
103 |    Compiling expander v0.0.4-alpha.0 (/somewhere/expander)
104 | expander: writing /somewhere/expander/target/debug/build/expander-8cb9d7a52d4e83d1/out/baz-874698265c6c.rs
105 | error: expected item, found `;`
106 |  --> /somewhere/expander/target/debug/build/expander-8cb9d7a52d4e83d1/out/baz-874698265c6c.rs:2:42
107 |   |
108 | 2 | #[derive(Debug, Clone, Copy)] struct A ; ;
109 |   |                                          ^
110 | 
111 | expander: writing /somewhere/expander/target/debug/build/expander-8cb9d7a52d4e83d1/out/baz-73b3d5b9bc46.rs
112 | error: expected item, found `;`
113 |  --> /somewhere/expander/target/debug/build/expander-8cb9d7a52d4e83d1/out/baz-73b3d5b9bc46.rs:2:42
114 |   |
115 | 2 | #[derive(Debug, Clone, Copy)] struct B ; ;
116 |   |                                          ^
117 | 
118 | error: could not compile `expander` due to 2 previous errors
119 | warning: build failed, waiting for other jobs to finish...
120 | error: build failed
121 | 
122 | 123 | --- 124 | 125 | which shows exactly where in the generated code, the produce of your proc-macro, rustc found an invalid token sequence. 126 | 127 | Now this was a simple example, doing this with macros that would expand to multiple tens of thousand lines of 128 | code when expanded with `cargo-expand`, and still in a few thousand that your particular one generates, it's a 129 | life saver to know what caused the issue rather than having to use `eprintln!` to print a unformated 130 | string to the terminal. 131 | 132 | > Hint: You can quickly toggle this by using `.dry(true || false)` 133 | 134 | 135 | # Features 136 | 137 | ## Special handling: `syn` 138 | 139 | By default `expander` is built with feature `syndicate` which adds `fn maybe_write_*` 140 | to `struct Expander`, which aids handling of `Result` for the 141 | commonly used rust parsing library `syn`. 142 | 143 | ### Reasoning 144 | 145 | `syn::Error::new(Span::call_site(),"yikes!").into_token_stream(self)` becomes `compile_error!("yikes!")` 146 | which provides better info to the user (that's you!) than when serializing it to file, since the provided 147 | `span` for the `syn::Error` is printed differently - being pointed to the `compile_error!` invocation 148 | in the generated file is not helpful, and `rustc` can point to the `span` instead. 149 | 150 | ## `rustfmt`-free formatting: `pretty` 151 | 152 | When built with feature `pretty`, the output is formatted with `prettier-please`. Note that this adds 153 | additional compiletime overhead and weight to the crate as a trade off not needing any host side tooling. 154 | 155 | The formatting output will, for any significant amount of lines of code, differ from the output of `rustfmt`. 156 | -------------------------------------------------------------------------------- /.concourse.yml: -------------------------------------------------------------------------------- 1 | resource_types: 2 | - name: pull-request 3 | type: registry-image 4 | source: 5 | repository: teliaoss/github-pr-resource 6 | 7 | resources: 8 | - name: git-clone-resource 9 | type: git 10 | webhook_token: ((expander-webhook-token)) 11 | check_every: 12h 12 | source: 13 | branch: master 14 | uri: https://github.com/drahnr/expander.git 15 | 16 | - name: github-release 17 | type: github-release 18 | webhook_token: ((expander-webhook-token)) 19 | check_every: 12h 20 | source: 21 | owner: drahnr 22 | access_token: ((sirmergealot-github-token)) 23 | repository: expander 24 | 25 | - name: git-tag-resource 26 | type: git 27 | webhook_token: ((expander-webhook-token)) 28 | check_every: 12h 29 | source: 30 | tag_regex: "v[0-9]+\\.[0-9]+\\.[0-9]+.*" 31 | branch: master 32 | uri: https://github.com/drahnr/expander.git 33 | 34 | - name: git-pull-request-resource 35 | type: pull-request 36 | webhook_token: ((expander-webhook-token)) 37 | check_every: 12h 38 | source: 39 | repository: drahnr/expander 40 | access_token: ((sirmergealot-github-token)) 41 | 42 | - name: env-glibc 43 | type: registry-image 44 | source: 45 | repository: quay.io/drahnr/rust-glibc-builder 46 | 47 | jobs: 48 | #################################################################################### 49 | # P U L L - R E Q U E S T 50 | #################################################################################### 51 | - name: pr-validate 52 | build_logs_to_retain: 10 53 | public: true 54 | serial: true 55 | plan: 56 | - in_parallel: 57 | - get: git-pull-request-resource 58 | resource: git-pull-request-resource 59 | version: every 60 | trigger: true 61 | 62 | - get: env-glibc 63 | 64 | - in_parallel: 65 | - put: git-pull-request-resource 66 | params: 67 | path: git-pull-request-resource 68 | context: meta-check 69 | status: pending 70 | - put: git-pull-request-resource 71 | params: 72 | path: git-pull-request-resource 73 | context: compile 74 | status: pending 75 | - put: git-pull-request-resource 76 | params: 77 | path: git-pull-request-resource 78 | context: unit-tests 79 | status: pending 80 | 81 | - in_parallel: 82 | - task: compile-pr 83 | timeout: 40m 84 | image: env-glibc 85 | config: 86 | platform: linux 87 | inputs: 88 | - name: git-pull-request-resource 89 | outputs: 90 | - name: binary 91 | run: 92 | # user: root 93 | path: sh 94 | args: 95 | - -exc 96 | - | 97 | export CARGO_HOME="$(pwd)/../cargo" 98 | export CARGO_TARGET_DIR="$(pwd)/../target" 99 | 100 | sudo chown $(whoami): -Rf ${CARGO_HOME} 101 | sudo chown $(whoami): -Rf ${CARGO_TARGET_DIR} 102 | sudo chown $(whoami): -Rf . 103 | sudo chown $(whoami): -Rf ../binary 104 | 105 | cargo +stable build --release 106 | 107 | dir: git-pull-request-resource 108 | caches: 109 | - path: cargo 110 | - path: target 111 | 112 | on_success: 113 | put: git-pull-request-resource 114 | params: 115 | path: git-pull-request-resource 116 | context: compile 117 | status: success 118 | on_failure: 119 | put: git-pull-request-resource 120 | params: 121 | path: git-pull-request-resource 122 | context: compile 123 | status: failure 124 | 125 | - task: unit-tests-pr 126 | timeout: 40m 127 | image: env-glibc 128 | config: 129 | platform: linux 130 | inputs: 131 | - name: git-pull-request-resource 132 | run: 133 | # user: root 134 | path: sh 135 | args: 136 | - -exc 137 | - | 138 | export CARGO_HOME="$(pwd)/../cargo" 139 | sudo chown $(whoami): -Rf ${CARGO_HOME} . 140 | rustc +stable --version --verbose 141 | cargo +stable t 142 | dir: git-pull-request-resource 143 | caches: 144 | - path: cargo 145 | 146 | on_success: 147 | put: git-pull-request-resource 148 | params: 149 | path: git-pull-request-resource 150 | context: unit-tests 151 | status: success 152 | on_failure: 153 | put: git-pull-request-resource 154 | params: 155 | path: git-pull-request-resource 156 | context: unit-tests 157 | status: failure 158 | 159 | - task: format-check 160 | timeout: 10m 161 | image: env-glibc 162 | config: 163 | platform: linux 164 | inputs: 165 | - name: git-pull-request-resource 166 | run: 167 | # user: root 168 | path: sh 169 | args: 170 | - -exc 171 | - | 172 | rustc +stable --version --verbose 173 | 174 | cargo +stable fmt -- --check 175 | 176 | dir: git-pull-request-resource 177 | 178 | on_success: 179 | put: git-pull-request-resource 180 | params: 181 | path: git-pull-request-resource 182 | context: meta-check 183 | status: success 184 | 185 | on_failure: 186 | put: git-pull-request-resource 187 | params: 188 | path: git-pull-request-resource 189 | context: meta-check 190 | status: failure 191 | 192 | #################################################################################### 193 | # M A S T E R 194 | #################################################################################### 195 | - name: master-validate 196 | build_logs_to_retain: 10 197 | public: true 198 | serial: true 199 | plan: 200 | - in_parallel: 201 | - get: env-glibc 202 | - get: git-repo 203 | resource: git-clone-resource 204 | trigger: true 205 | 206 | - in_parallel: 207 | - task: compile-master 208 | timeout: 40m 209 | image: env-glibc 210 | config: 211 | platform: linux 212 | inputs: 213 | - name: git-repo 214 | outputs: 215 | - name: binary 216 | run: 217 | # user: root 218 | path: sh 219 | args: 220 | - -exc 221 | - | 222 | export RUST_BACKTRACE=full 223 | export CARGO_HOME="$(pwd)/../cargo" 224 | export CARGO_TARGET_DIR="$(pwd)/../target" 225 | 226 | sudo chown $(whoami): -Rf ${CARGO_HOME} 227 | sudo chown $(whoami): -Rf ${CARGO_TARGET_DIR} 228 | sudo chown $(whoami): -Rf . 229 | sudo chown $(whoami): -Rf ../binary 230 | 231 | cargo +stable build --release 232 | 233 | dir: git-repo 234 | caches: 235 | - path: cargo 236 | - path: target 237 | 238 | - task: unit-tests-master 239 | timeout: 40m 240 | image: env-glibc 241 | config: 242 | platform: linux 243 | inputs: 244 | - name: git-repo 245 | run: 246 | # user: root 247 | path: sh 248 | args: 249 | - -exc 250 | - | 251 | export RUST_BACKTRACE=1 252 | export CARGO_HOME="$(pwd)/../cargo" 253 | sudo chown $(whoami): -Rf ${CARGO_HOME} . 254 | rustc +stable --version --verbose 255 | 256 | cargo +stable test 257 | 258 | dir: git-repo 259 | caches: 260 | - path: cargo 261 | 262 | - task: validate-meta-master 263 | timeout: 15m 264 | image: env-glibc 265 | config: 266 | platform: linux 267 | inputs: 268 | - name: git-repo 269 | run: 270 | # user: root 271 | path: sh 272 | args: 273 | - -exc 274 | - | 275 | export CARGO_HOME="$(pwd)/../cargo" 276 | sudo chown $(whoami): -Rf ${CARGO_HOME} . 277 | rustc +stable --version --verbose 278 | 279 | cargo +stable fmt -- --check 280 | 281 | dir: git-repo 282 | caches: 283 | - path: cargo 284 | 285 | - name: publish-github-release 286 | build_logs_to_retain: 5 287 | public: false 288 | serial: true 289 | plan: 290 | - get: env-glibc 291 | - get: git-repo 292 | resource: git-tag-resource 293 | trigger: true 294 | 295 | - task: github-release 296 | timeout: 40m 297 | image: env-glibc 298 | config: 299 | platform: linux 300 | inputs: 301 | - name: git-repo 302 | outputs: 303 | - name: release-info 304 | caches: 305 | - path: cargo 306 | run: 307 | path: sh 308 | args: 309 | - -exc 310 | - | 311 | sudo chown -Rf sirmergealot: ../release-info 312 | sudo chmod -Rf o+rw ../release-info 313 | sudo chown -Rf sirmergealot: . 314 | sudo chmod -Rf o+r . 315 | git rev-parse HEAD | sd '\n' '' > ../release-info/COMMITISH 316 | git tag --contains HEAD | rg '^v[0-9]+\.[0-9]+\.[0-9]+(?:-.+(?:\.[0-9]+)?)?$' | head -n 1 | sd '\n' '' > ../release-info/TAG 317 | echo "expander $(cat < ../release-info/TAG)" > ../release-info/NAME 318 | dir: git-repo 319 | 320 | - in_parallel: 321 | - put: github-release 322 | params: 323 | name: release-info/NAME 324 | tag: release-info/TAG 325 | commitish: release-info/COMMITISH 326 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use fs_err as fs; 2 | use proc_macro2::TokenStream; 3 | use quote::quote; 4 | use std::env; 5 | use std::io::Write; 6 | use std::path::Path; 7 | use std::process::Stdio; 8 | 9 | /// Rust edition to format for. 10 | #[derive(Debug, Clone, Copy)] 11 | pub enum Edition { 12 | Unspecified, 13 | _2015, 14 | _2018, 15 | _2021, 16 | } 17 | 18 | impl std::default::Default for Edition { 19 | fn default() -> Self { 20 | Self::Unspecified 21 | } 22 | } 23 | 24 | impl std::fmt::Display for Edition { 25 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 26 | let s = match self { 27 | Self::_2015 => "2015", 28 | Self::_2018 => "2018", 29 | Self::_2021 => "2021", 30 | Self::Unspecified => "", 31 | }; 32 | write!(f, "{}", s) 33 | } 34 | } 35 | 36 | /// The channel to use for formatting. 37 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 38 | pub enum Channel { 39 | #[default] 40 | Default, 41 | Stable, 42 | Beta, 43 | Nightly, 44 | } 45 | 46 | impl std::fmt::Display for Channel { 47 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 48 | let s = match self { 49 | Self::Stable => "+stable", 50 | Self::Beta => "+beta", 51 | Self::Nightly => "+nightly", 52 | Self::Default => return Ok(()), 53 | }; 54 | write!(f, "{}", s) 55 | } 56 | } 57 | 58 | #[derive(Debug, Clone)] 59 | enum RustFmt { 60 | Yes { 61 | edition: Edition, 62 | channel: Channel, 63 | allow_failure: bool, 64 | }, 65 | No, 66 | } 67 | 68 | impl std::default::Default for RustFmt { 69 | fn default() -> Self { 70 | RustFmt::No 71 | } 72 | } 73 | 74 | impl From for RustFmt { 75 | fn from(edition: Edition) -> Self { 76 | RustFmt::Yes { 77 | edition, 78 | channel: Channel::Default, 79 | allow_failure: false, 80 | } 81 | } 82 | } 83 | 84 | /// Expander to replace a tokenstream by a include to a file 85 | #[derive(Default, Debug)] 86 | pub struct Expander { 87 | /// Determines if the whole file `include!` should be done (`false`) or not (`true`). 88 | dry: bool, 89 | /// If `true`, print the generated destination file to terminal. 90 | verbose: bool, 91 | /// Filename for the generated indirection file to be used. 92 | filename_base: String, 93 | /// Additional comment to be added. 94 | comment: Option, 95 | /// Format using `rustfmt` in your path. 96 | rustfmt: RustFmt, 97 | } 98 | 99 | impl Expander { 100 | /// Create a new expander. 101 | /// 102 | /// The `filename_base` will be expanded to `{filename_base}-{digest}.rs` in order to dismabiguate 103 | /// . 104 | pub fn new(filename_base: impl AsRef) -> Self { 105 | Self { 106 | dry: false, 107 | verbose: false, 108 | filename_base: filename_base.as_ref().to_owned(), 109 | comment: None, 110 | rustfmt: RustFmt::No, 111 | } 112 | } 113 | 114 | /// Add a header comment. 115 | pub fn add_comment(mut self, comment: impl Into>) -> Self { 116 | self.comment = comment.into().map(|comment| format!("/* {} */\n", comment)); 117 | self 118 | } 119 | 120 | /// Format the resulting file, for readability. 121 | pub fn fmt(mut self, edition: impl Into) -> Self { 122 | self.rustfmt = RustFmt::Yes { 123 | edition: edition.into(), 124 | channel: Channel::Default, 125 | allow_failure: false, 126 | }; 127 | self 128 | } 129 | 130 | /// Format the resulting file, for readability. 131 | /// 132 | /// Allows to specify `channel` and if a failure is fatal in addition. 133 | /// 134 | /// Note: Calling [`fn fmt(..)`] afterwards will override settings given. 135 | pub fn fmt_full( 136 | mut self, 137 | channel: impl Into, 138 | edition: impl Into, 139 | allow_failure: bool, 140 | ) -> Self { 141 | self.rustfmt = RustFmt::Yes { 142 | edition: edition.into(), 143 | channel: channel.into(), 144 | allow_failure, 145 | }; 146 | self 147 | } 148 | 149 | /// Do not modify the provided tokenstream. 150 | pub fn dry(mut self, dry: bool) -> Self { 151 | self.dry = dry; 152 | self 153 | } 154 | 155 | /// Print the path of the generated file to `stderr` during the proc-macro invocation. 156 | pub fn verbose(mut self, verbose: bool) -> Self { 157 | self.verbose = verbose; 158 | self 159 | } 160 | 161 | #[cfg(any(feature = "syndicate", test))] 162 | /// Create a file with `filename` under `env!("OUT_DIR")` if it's not an `Err(_)`. 163 | pub fn maybe_write_to_out_dir( 164 | self, 165 | tokens: impl Into>, 166 | ) -> Result, std::io::Error> { 167 | self.maybe_write_to(tokens, std::path::PathBuf::from(env!("OUT_DIR")).as_path()) 168 | } 169 | 170 | /// Create a file with `filename` under `env!("OUT_DIR")`. 171 | pub fn write_to_out_dir(self, tokens: TokenStream) -> Result { 172 | let out = std::path::PathBuf::from(env!("OUT_DIR")); 173 | self.write_to(tokens, out.as_path()) 174 | } 175 | 176 | #[cfg(any(feature = "syndicate", test))] 177 | /// Create a file with `filename` at `dest` if it's not an `Err(_)`. 178 | pub fn maybe_write_to( 179 | self, 180 | maybe_tokens: impl Into>, 181 | dest_dir: &Path, 182 | ) -> Result, std::io::Error> { 183 | match maybe_tokens.into() { 184 | Ok(tokens) => Ok(Ok(self.write_to(tokens, dest_dir)?)), 185 | err => Ok(err), 186 | } 187 | } 188 | 189 | /// Create a file with `self.filename` in `dest_dir`. 190 | pub fn write_to( 191 | self, 192 | tokens: TokenStream, 193 | dest_dir: &Path, 194 | ) -> Result { 195 | if self.dry { 196 | Ok(tokens) 197 | } else { 198 | expand_to_file( 199 | tokens, 200 | dest_dir.join(self.filename_base).as_path(), 201 | dest_dir, 202 | self.rustfmt, 203 | self.comment, 204 | self.verbose, 205 | ) 206 | } 207 | } 208 | } 209 | 210 | /// Take the leading 6 bytes and convert them to 12 hex ascii characters. 211 | fn make_suffix(digest: &[u8; 32]) -> String { 212 | let mut shortened_hex = String::with_capacity(12); 213 | const TABLE: &[u8] = b"0123456789abcdef"; 214 | for &byte in digest.iter().take(6) { 215 | shortened_hex.push(TABLE[((byte >> 4) & 0x0F) as usize] as char); 216 | shortened_hex.push(TABLE[((byte >> 0) & 0x0F) as usize] as char); 217 | } 218 | shortened_hex 219 | } 220 | 221 | /// Expand a proc-macro to file. 222 | /// 223 | /// The current working directory `cwd` is only used for the `rustfmt` invocation 224 | /// and hence influences where the config files would be pulled in from. 225 | fn expand_to_file( 226 | tokens: TokenStream, 227 | dest: &Path, 228 | _cwd: &Path, 229 | rustfmt: RustFmt, 230 | comment: impl Into>, 231 | verbose: bool, 232 | ) -> Result { 233 | let token_str = tokens.to_string(); 234 | 235 | // Determine the content to write 236 | let bytes = { 237 | #[cfg(feature = "pretty")] 238 | { 239 | // Try prettyplease first if the feature is enabled 240 | match syn::parse_file(&token_str) { 241 | Ok(sf) => { 242 | if verbose { 243 | eprintln!("expander: formatting with prettyplease"); 244 | } 245 | prettyplease::unparse(&sf).into_bytes() 246 | } 247 | Err(e) => { 248 | eprintln!( 249 | "expander: prettyplease failed for {}: {:?}", 250 | dest.display(), 251 | e 252 | ); 253 | // Fall back to rustfmt if available, regardless of rustfmt setting 254 | maybe_run_rustfmt_on_content( 255 | &rustfmt, 256 | verbose, 257 | "expander: falling back to rustfmt", 258 | token_str, 259 | )? 260 | } 261 | } 262 | } 263 | 264 | #[cfg(not(feature = "pretty"))] 265 | { 266 | // Without pretty feature, use rustfmt if requested 267 | maybe_run_rustfmt_on_content( 268 | &rustfmt, 269 | verbose, 270 | "expander: formatting with rustfmt", 271 | token_str, 272 | )? 273 | } 274 | }; 275 | 276 | // we need to disambiguate for transitive dependencies, that might create different output to not override one another 277 | let hash = ::digest(&bytes); 278 | let shortened_hex = make_suffix(hash.as_ref()); 279 | 280 | let dest = 281 | std::path::PathBuf::from(dest.display().to_string() + "-" + shortened_hex.as_str() + ".rs"); 282 | 283 | let mut f = fs::OpenOptions::new() 284 | .write(true) 285 | .create(true) 286 | .truncate(true) 287 | .open(dest.as_path())?; 288 | 289 | let Ok(mut f) = file_guard::try_lock(f.file_mut(), file_guard::Lock::Exclusive, 0, 64) else { 290 | // the digest of the file will not match if the content to be written differed, hence any existing lock 291 | // means we are already writing the same content to the file 292 | if verbose { 293 | eprintln!("expander: already in progress of writing identical content to {} by a different crate", dest.display()); 294 | } 295 | // now actually wait until the write is complete 296 | let _lock = file_guard::lock(f.file_mut(), file_guard::Lock::Exclusive, 0, 64) 297 | .expect("File Lock never fails us. qed"); 298 | 299 | if verbose { 300 | eprintln!("expander: lock was release, referencing"); 301 | } 302 | 303 | let dest = dest.display().to_string(); 304 | return Ok(quote! { 305 | include!( #dest ); 306 | }); 307 | }; 308 | 309 | if verbose { 310 | eprintln!("expander: writing {}", dest.display()); 311 | } 312 | 313 | if let Some(comment) = comment.into() { 314 | f.write_all(&mut comment.as_bytes())?; 315 | } 316 | 317 | // Write the already-formatted content while holding the guard 318 | f.write_all(&bytes)?; 319 | 320 | let dest = dest.display().to_string(); 321 | Ok(quote! { 322 | include!( #dest ); 323 | }) 324 | } 325 | 326 | fn maybe_run_rustfmt_on_content( 327 | rustfmt: &RustFmt, 328 | verbose: bool, 329 | message: &str, 330 | token_str: String, 331 | ) -> Result, std::io::Error> { 332 | Ok( 333 | if let RustFmt::Yes { 334 | channel, 335 | edition, 336 | allow_failure, 337 | } = *rustfmt 338 | { 339 | if verbose { 340 | eprintln!("{message}"); 341 | } 342 | run_rustfmt_on_content(token_str.as_bytes(), channel, edition, allow_failure)? 343 | } else { 344 | token_str.into_bytes() 345 | }, 346 | ) 347 | } 348 | 349 | fn run_rustfmt_on_content( 350 | content: &[u8], 351 | channel: Channel, 352 | edition: Edition, 353 | allow_failure: bool, 354 | ) -> Result, std::io::Error> { 355 | let mut process = std::process::Command::new("rustfmt"); 356 | if Channel::Default != channel { 357 | process.arg(channel.to_string()); 358 | } 359 | 360 | let mut child = process 361 | .arg(format!("--edition={}", edition)) 362 | .arg("--emit=stdout") 363 | .arg("--") // Signal to read from stdin 364 | .stdin(Stdio::piped()) 365 | .stdout(Stdio::piped()) 366 | .stderr(Stdio::piped()) 367 | .spawn()?; 368 | 369 | // Write content to rustfmt's stdin 370 | if let Some(ref mut stdin) = child.stdin { 371 | stdin.write_all(content)?; 372 | // Dropping stdin here signals EOF to rustfmt 373 | } 374 | 375 | let output = child.wait_with_output()?; 376 | if !output.status.success() { 377 | let error = std::io::Error::new( 378 | std::io::ErrorKind::Other, 379 | format!( 380 | "rustfmt failed with exit code {}\nstderr: {}", 381 | output.status.code().unwrap_or(-1), 382 | String::from_utf8_lossy(&output.stderr) 383 | ), 384 | ); 385 | if allow_failure { 386 | eprintln!("expander: {}", error); 387 | Ok(content.to_vec()) 388 | } else { 389 | Err(error) 390 | } 391 | } else { 392 | Ok(output.stdout) 393 | } 394 | } 395 | 396 | #[cfg(test)] 397 | mod tests; 398 | --------------------------------------------------------------------------------