macrotest/
expand.rs

1use std::env;
2use std::ffi::OsStr;
3use std::fs;
4use std::io::Write;
5use std::iter;
6use std::path::{Path, PathBuf};
7
8use crate::cargo;
9use crate::dependencies::{self, Dependency};
10use crate::features;
11use crate::manifest::{Bin, Build, Config, Manifest, Name, Package, Workspace};
12use crate::message::{message_different, message_expansion_error};
13use crate::rustflags;
14use crate::{error::Error, error::Result};
15use syn::punctuated::Punctuated;
16use syn::{Item, Meta, Token};
17
18/// An extension for files containing `cargo expand` result.
19const EXPANDED_RS_SUFFIX: &str = "expanded.rs";
20
21#[derive(Debug)]
22pub(crate) struct Project {
23    pub dir: PathBuf,
24    source_dir: PathBuf,
25    /// Used for the inner runs of cargo()
26    pub inner_target_dir: PathBuf,
27    pub name: String,
28    pub features: Option<Vec<String>>,
29    workspace: PathBuf,
30    overwrite: bool,
31}
32
33/// This `Drop` implementation will clean up the temporary crates when expansion is finished.
34/// This is to prevent pollution of the filesystem with dormant files.
35impl Drop for Project {
36    fn drop(&mut self) {
37        if let Err(e) = fs::remove_dir_all(&self.dir) {
38            eprintln!(
39                "Failed to cleanup the directory `{}`: {}",
40                self.dir.to_string_lossy(),
41                e
42            );
43        }
44    }
45}
46
47/// Attempts to expand macros in files that match glob pattern.
48///
49/// # Refresh behavior
50///
51/// If no matching `.expanded.rs` files present, they will be created and result of expansion
52/// will be written into them.
53///
54/// # Panics
55///
56/// Will panic if matching `.expanded.rs` file is present, but has different expanded code in it.
57pub fn expand(path: impl AsRef<Path>) {
58    run_tests(
59        path,
60        ExpansionBehavior::RegenerateFiles,
61        Option::<Vec<String>>::None,
62    );
63}
64
65/// Same as [`expand`] but allows to pass additional arguments to `cargo-expand`.
66///
67/// [`expand`]: expand/fn.expand.html
68pub fn expand_args<I, S>(path: impl AsRef<Path>, args: I)
69where
70    I: IntoIterator<Item = S> + Clone,
71    S: AsRef<OsStr>,
72{
73    run_tests(path, ExpansionBehavior::RegenerateFiles, Some(args));
74}
75
76/// Attempts to expand macros in files that match glob pattern.
77/// More strict version of [`expand`] function.
78///
79/// # Refresh behavior
80///
81/// If no matching `.expanded.rs` files present, it considered a failed test.
82///
83/// # Panics
84///
85/// Will panic if no matching `.expanded.rs` file is present. Otherwise it will exhibit the same
86/// behavior as in [`expand`].
87///
88/// [`expand`]: expand/fn.expand.html
89pub fn expand_without_refresh(path: impl AsRef<Path>) {
90    run_tests(
91        path,
92        ExpansionBehavior::ExpectFiles,
93        Option::<Vec<String>>::None,
94    );
95}
96
97/// Same as [`expand_without_refresh`] but allows to pass additional arguments to `cargo-expand`.
98///
99/// [`expand_without_refresh`]: expand/fn.expand_without_refresh.html
100pub fn expand_without_refresh_args<I, S>(path: impl AsRef<Path>, args: I)
101where
102    I: IntoIterator<Item = S> + Clone,
103    S: AsRef<OsStr>,
104{
105    run_tests(path, ExpansionBehavior::ExpectFiles, Some(args));
106}
107
108#[derive(Debug, Copy, Clone)]
109enum ExpansionBehavior {
110    RegenerateFiles,
111    ExpectFiles,
112}
113
114fn run_tests<I, S>(path: impl AsRef<Path>, expansion_behavior: ExpansionBehavior, args: Option<I>)
115where
116    I: IntoIterator<Item = S> + Clone,
117    S: AsRef<OsStr>,
118{
119    let tests = expand_globs(&path)
120        .into_iter()
121        .filter(|t| !t.test.to_string_lossy().ends_with(EXPANDED_RS_SUFFIX))
122        .collect::<Vec<_>>();
123
124    let len = tests.len();
125    println!("Running {} macro expansion tests", len);
126
127    let project = prepare(&tests).unwrap_or_else(|err| {
128        panic!("prepare failed: {:#?}", err);
129    });
130
131    let mut failures = 0;
132    for test in tests {
133        let path = test.test.display();
134        let expanded_path = test.test.with_extension(EXPANDED_RS_SUFFIX);
135
136        match test.run(&project, expansion_behavior, &args) {
137            Ok(outcome) => match outcome {
138                ExpansionOutcome::Same => {
139                    let _ = writeln!(std::io::stdout(), "{} - ok", path);
140                }
141
142                ExpansionOutcome::Different(a, b) => {
143                    message_different(&path.to_string(), &a, &b);
144                    failures += 1;
145                }
146
147                ExpansionOutcome::Update => {
148                    let _ = writeln!(std::io::stderr(), "{} - refreshed", expanded_path.display());
149                }
150
151                ExpansionOutcome::ExpandError(msg) => {
152                    message_expansion_error(msg);
153                    failures += 1;
154                }
155                ExpansionOutcome::NoExpandedFileFound => {
156                    let _ = writeln!(
157                        std::io::stderr(),
158                        "{} is expected but not found",
159                        expanded_path.display()
160                    );
161                    failures += 1;
162                }
163            },
164
165            Err(e) => {
166                eprintln!("Error: {:#?}", e);
167                failures += 1;
168            }
169        }
170    }
171
172    if failures > 0 {
173        eprintln!("\n\n");
174        panic!("{} of {} tests failed", failures, len);
175    }
176}
177
178fn prepare(tests: &[ExpandedTest]) -> Result<Project> {
179    let metadata = cargo::metadata()?;
180    let target_dir = metadata.target_directory;
181    let workspace = metadata.workspace_root;
182
183    let crate_name = env::var("CARGO_PKG_NAME").map_err(|_| Error::PkgName)?;
184
185    let source_dir = env::var_os("CARGO_MANIFEST_DIR")
186        .map(PathBuf::from)
187        .ok_or(Error::ManifestDir)?;
188
189    let features = features::find();
190
191    let overwrite = match env::var_os("MACROTEST") {
192        Some(ref v) if v == "overwrite" => true,
193        Some(v) => return Err(Error::UnrecognizedEnv(v)),
194        None => false,
195    };
196
197    // Use random string for the crate dir to
198    // prevent conflicts when running parallel tests.
199    let random_string: String = iter::repeat_with(fastrand::alphanumeric).take(42).collect();
200    let dir = path!(target_dir / "tests" / crate_name / random_string);
201    if dir.exists() {
202        // Remove remaining artifacts from previous runs if exist.
203        // For example, if the user stops the test with Ctrl-C during a previous
204        // run, the destructor of Project will not be called.
205        fs::remove_dir_all(&dir)?;
206    }
207
208    let inner_target_dir = path!(target_dir / "tests" / "macrotest");
209
210    let mut project = Project {
211        dir,
212        source_dir,
213        inner_target_dir,
214        name: format!("{}-tests", crate_name),
215        features,
216        workspace,
217        overwrite,
218    };
219
220    let manifest = make_manifest(crate_name, &project, tests)?;
221    let manifest_toml = toml::ser::to_string(&manifest)?;
222
223    let config = make_config();
224    let config_toml = toml::ser::to_string(&config)?;
225
226    if let Some(enabled_features) = &mut project.features {
227        enabled_features.retain(|feature| manifest.features.contains_key(feature));
228    }
229
230    fs::create_dir_all(path!(project.dir / ".cargo"))?;
231    fs::write(path!(project.dir / ".cargo" / "config.toml"), config_toml)?;
232    fs::write(path!(project.dir / "Cargo.toml"), manifest_toml)?;
233    fs::write(path!(project.dir / "main.rs"), b"fn main() {}\n")?;
234
235    let source_lockfile = path!(project.workspace / "Cargo.lock");
236    match fs::copy(source_lockfile, path!(project.dir / "Cargo.lock")) {
237        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
238        otherwise => otherwise,
239    }?;
240
241    fs::create_dir_all(&project.inner_target_dir)?;
242
243    cargo::build_dependencies(&project)?;
244
245    Ok(project)
246}
247
248fn make_manifest(
249    crate_name: String,
250    project: &Project,
251    tests: &[ExpandedTest],
252) -> Result<Manifest> {
253    let source_manifest = dependencies::get_manifest(&project.source_dir);
254    let workspace_manifest = dependencies::get_workspace_manifest(&project.workspace);
255
256    let features = source_manifest
257        .features
258        .iter()
259        .map(|(feature, source_deps)| {
260            let enable = format!("{}/{}", crate_name, feature);
261            let mut deps = vec![enable];
262            deps.extend(
263                source_deps
264                    .iter()
265                    .filter(|dep| dep.starts_with("dep:"))
266                    .cloned(),
267            );
268            (feature.clone(), deps)
269        })
270        .collect();
271
272    let mut manifest = Manifest {
273        cargo_features: source_manifest.cargo_features.clone(),
274        package: Package {
275            name: project.name.clone(),
276            version: "0.0.0".to_owned(),
277            edition: source_manifest.package.edition,
278            publish: false,
279        },
280        features,
281        dependencies: std::collections::BTreeMap::new(),
282        bins: Vec::new(),
283        workspace: Some(Workspace {
284            package: crate::manifest::WorkspacePackage {
285                edition: workspace_manifest.workspace.package.edition,
286            },
287            dependencies: workspace_manifest.workspace.dependencies,
288        }),
289        // Within a workspace, only the [patch] and [replace] sections in
290        // the workspace root's Cargo.toml are applied by Cargo.
291        patch: workspace_manifest.patch,
292        replace: workspace_manifest.replace,
293    };
294
295    manifest.dependencies.extend(source_manifest.dependencies);
296    manifest
297        .dependencies
298        .extend(source_manifest.dev_dependencies);
299    manifest.dependencies.insert(
300        crate_name,
301        Dependency {
302            version: None,
303            path: Some(project.source_dir.clone()),
304            default_features: false,
305            features: Vec::new(),
306            workspace: false,
307            rest: std::collections::BTreeMap::new(),
308        },
309    );
310
311    manifest.bins.push(Bin {
312        name: Name(project.name.to_owned()),
313        path: Path::new("main.rs").to_owned(),
314    });
315
316    for expanded in tests {
317        if expanded.error.is_none() {
318            manifest.bins.push(Bin {
319                name: expanded.name.clone(),
320                path: project.source_dir.join(&expanded.test),
321            });
322        }
323    }
324
325    Ok(manifest)
326}
327
328fn make_config() -> Config {
329    Config {
330        build: Build {
331            rustflags: rustflags::make_vec(),
332        },
333    }
334}
335
336#[derive(Debug)]
337enum ExpansionOutcome {
338    Same,
339    Different(Vec<u8>, Vec<u8>),
340    Update,
341    ExpandError(Vec<u8>),
342    NoExpandedFileFound,
343}
344
345struct ExpandedTest {
346    name: Name,
347    test: PathBuf,
348    error: Option<Error>,
349}
350
351impl ExpandedTest {
352    pub fn run<I, S>(
353        &self,
354        project: &Project,
355        expansion_behavior: ExpansionBehavior,
356        args: &Option<I>,
357    ) -> Result<ExpansionOutcome>
358    where
359        I: IntoIterator<Item = S> + Clone,
360        S: AsRef<OsStr>,
361    {
362        let (success, output_bytes) = cargo::expand(project, &self.name, args)?;
363
364        if !success {
365            return Ok(ExpansionOutcome::ExpandError(output_bytes));
366        }
367
368        let file_stem = self
369            .test
370            .file_stem()
371            .expect("no file stem")
372            .to_string_lossy()
373            .into_owned();
374        let mut expanded = self.test.clone();
375        expanded.pop();
376        let expanded = &expanded.join(format!("{}.{}", file_stem, EXPANDED_RS_SUFFIX));
377
378        let output = normalize_expansion(&output_bytes);
379
380        if !expanded.exists() {
381            if let ExpansionBehavior::ExpectFiles = expansion_behavior {
382                return Ok(ExpansionOutcome::NoExpandedFileFound);
383            }
384
385            // Write a .expanded.rs file contents
386            std::fs::write(expanded, output)?;
387
388            return Ok(ExpansionOutcome::Update);
389        }
390
391        let expected_expansion_bytes = std::fs::read(expanded)?;
392        let expected_expansion = String::from_utf8_lossy(&expected_expansion_bytes);
393
394        let same = output.lines().eq(expected_expansion.lines());
395
396        if !same && project.overwrite {
397            if let ExpansionBehavior::ExpectFiles = expansion_behavior {
398                return Ok(ExpansionOutcome::NoExpandedFileFound);
399            }
400
401            // Write a .expanded.rs file contents
402            std::fs::write(expanded, output)?;
403
404            return Ok(ExpansionOutcome::Update);
405        }
406
407        Ok(if same {
408            ExpansionOutcome::Same
409        } else {
410            let output_bytes = output.into_bytes(); // Use normalized text for a message
411            ExpansionOutcome::Different(expected_expansion_bytes, output_bytes)
412        })
413    }
414}
415
416fn normalize_expansion(input: &[u8]) -> String {
417    let code = String::from_utf8_lossy(input);
418    let mut syntax_tree = match syn::parse_file(&code) {
419        Ok(syntax_tree) => syntax_tree,
420        Err(_) => return code.into_owned(),
421    };
422
423    // Strip the following:
424    //
425    //     #![feature(prelude_import)]
426    //
427    syntax_tree.attrs.retain(|attr| {
428        if let Meta::List(meta) = &attr.meta {
429            if meta.path.is_ident("feature") {
430                if let Ok(list) =
431                    meta.parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated)
432                {
433                    if list.len() == 1 {
434                        if let Meta::Path(inner) = &list.first().unwrap() {
435                            if inner.is_ident("prelude_import") {
436                                return false;
437                            }
438                        }
439                    }
440                }
441            }
442        }
443        true
444    });
445
446    // Strip the following:
447    //
448    //     #[prelude_import]
449    //     use std::prelude::$edition::*;
450    //
451    //     #[macro_use]
452    //     extern crate std;
453    //
454    syntax_tree.items.retain(|item| {
455        if let Item::Use(item) = item {
456            if let Some(attr) = item.attrs.first() {
457                if attr.path().is_ident("prelude_import") && attr.meta.require_path_only().is_ok() {
458                    return false;
459                }
460            }
461        }
462        if let Item::ExternCrate(item) = item {
463            if item.ident == "std" {
464                return false;
465            }
466        }
467        true
468    });
469
470    prettyplease::unparse(&syntax_tree)
471}
472
473fn expand_globs(path: impl AsRef<Path>) -> Vec<ExpandedTest> {
474    fn glob(pattern: &str) -> Result<Vec<PathBuf>> {
475        let mut paths = glob::glob(pattern)?
476            .map(|entry| entry.map_err(Error::from))
477            .collect::<Result<Vec<PathBuf>>>()?;
478        paths.sort();
479        Ok(paths)
480    }
481
482    fn bin_name(i: usize) -> Name {
483        Name(format!("macrotest{:03}", i))
484    }
485
486    let mut vec = Vec::new();
487
488    let name = path
489        .as_ref()
490        .file_stem()
491        .expect("no file stem")
492        .to_string_lossy()
493        .to_string();
494    let mut expanded = ExpandedTest {
495        name: Name(name),
496        test: path.as_ref().to_path_buf(),
497        error: None,
498    };
499
500    if let Some(utf8) = path.as_ref().to_str() {
501        if utf8.contains('*') {
502            match glob(utf8) {
503                Ok(paths) => {
504                    for path in paths {
505                        vec.push(ExpandedTest {
506                            name: bin_name(vec.len()),
507                            test: path,
508                            error: None,
509                        });
510                    }
511                }
512                Err(error) => expanded.error = Some(error),
513            }
514        } else {
515            vec.push(expanded);
516        }
517    }
518
519    vec
520}