Skip to main content

dprint_development/
spec_helpers.rs

1use console::Style;
2use file_test_runner::RunOptions;
3use file_test_runner::SubTestResult;
4use file_test_runner::TestResult;
5use file_test_runner::collection::CollectOptions;
6use similar::ChangeTag;
7use similar::TextDiff;
8use std::fmt::Display;
9use std::fs;
10use std::panic::AssertUnwindSafe;
11use std::panic::catch_unwind;
12use std::path::Path;
13use std::path::PathBuf;
14use std::sync::Arc;
15
16use super::*;
17
18struct FailedTestResult {
19  expected: String,
20  actual: String,
21  actual_second: Option<String>,
22  message: String,
23}
24
25struct DiffFailedMessage<'a> {
26  expected: &'a str,
27  actual: &'a str,
28}
29
30impl Display for DiffFailedMessage<'_> {
31  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32    let diff = TextDiff::from_lines(self.expected, self.actual);
33
34    for op in diff.ops() {
35      for change in diff.iter_changes(op) {
36        let (sign, style) = match change.tag() {
37          ChangeTag::Delete => ("-", Style::new().green()),
38          ChangeTag::Insert => ("+", Style::new().red()),
39          ChangeTag::Equal => (" ", Style::new()),
40        };
41        write!(f, "{}{}", style.apply_to(sign).bold(), style.apply_to(change),)?;
42      }
43    }
44    Ok(())
45  }
46}
47
48type FormatTextFunc = dyn (Fn(&Path, &str, &SpecConfigMap) -> Result<Option<String>, Box<dyn std::error::Error + Send + Sync>>) + Send + Sync;
49type GetTraceJsonFunc = dyn (Fn(&Path, &str, &SpecConfigMap) -> String) + Send + Sync;
50
51#[derive(Debug, Clone)]
52pub struct RunSpecsOptions {
53  /// Set to true to overwrite the failing tests with the actual result.
54  pub fix_failures: bool,
55  pub format_twice: bool,
56}
57
58pub fn run_specs(
59  directory_path: &Path,
60  parse_spec_options: &ParseSpecOptions,
61  run_spec_options: &RunSpecsOptions,
62  format_text: Arc<FormatTextFunc>,
63  get_trace_json: Arc<GetTraceJsonFunc>,
64) {
65  #[cfg(not(debug_assertions))]
66  assert_not_fix_failures(run_spec_options);
67
68  let parse_spec_options = parse_spec_options.clone();
69  let run_spec_options = run_spec_options.clone();
70  file_test_runner::collect_and_run_tests(
71    CollectOptions {
72      base: directory_path.to_path_buf(),
73      filter_override: None,
74      strategy: Box::new(file_test_runner::collection::strategies::TestPerFileCollectionStrategy { file_pattern: None }),
75    },
76    RunOptions::default(),
77    move |test| {
78      let file_text = test.read_to_string().unwrap();
79      let specs = parse_specs(file_text, &parse_spec_options);
80      let specs = if specs.iter().any(|s| s.is_only) {
81        specs.into_iter().filter(|s| s.is_only).collect()
82      } else {
83        specs
84      };
85      let mut sub_tests = Vec::new();
86      for spec in specs {
87        #[cfg(not(debug_assertions))]
88        assert_spec_not_only_or_trace(&spec);
89
90        if spec.skip {
91          sub_tests.push(SubTestResult {
92            name: spec.message.clone(),
93            result: TestResult::Ignored,
94          });
95          continue;
96        }
97
98        let test_file_path = &test.path;
99        let maybe_failed_result = run_spec(&spec, test_file_path, &run_spec_options, &format_text, &get_trace_json);
100
101        sub_tests.push(SubTestResult {
102          name: spec.message.clone(),
103          result: if let Some(failed_test) = maybe_failed_result {
104            let mut output = Vec::<u8>::new();
105            let mut failed_message = format!(
106              "Failed:   {} ({})\nExpected: `{:?}`,\nActual:   `{:?}`,`,\nDiff:\n{}",
107              failed_test.message,
108              test_file_path.display(),
109              failed_test.expected,
110              failed_test.actual,
111              DiffFailedMessage {
112                actual: &failed_test.actual,
113                expected: &failed_test.expected
114              }
115            );
116            if let Some(actual_second) = &failed_test.actual_second {
117              failed_message.push_str(&format!(
118                "\nTwice:    `{:?}`,\nTwice diff:\n{}",
119                actual_second,
120                DiffFailedMessage {
121                  actual: actual_second,
122                  expected: &failed_test.actual,
123                }
124              ));
125            }
126            output.extend(failed_message.as_bytes());
127            TestResult::Failed { duration: None, output }
128          } else {
129            TestResult::Passed { duration: None }
130          },
131        });
132      }
133
134      TestResult::SubTests { duration: None, sub_tests }
135    },
136  );
137
138  fn run_spec(
139    spec: &Spec,
140    test_file_path: &Path,
141    run_spec_options: &RunSpecsOptions,
142    format_text: &Arc<FormatTextFunc>,
143    get_trace_json: &Arc<GetTraceJsonFunc>,
144  ) -> Option<FailedTestResult> {
145    let spec_file_path_buf = PathBuf::from(&spec.file_name);
146    let format = |file_text: &str| -> Result<Option<String>, String> {
147      match catch_unwind(AssertUnwindSafe(|| format_text(&spec_file_path_buf, file_text, &spec.config))) {
148        Ok(Ok(formatted)) => Ok(formatted),
149        Ok(Err(err)) => Err(format!("Formatter error: {}", error_to_string(err.as_ref()))),
150        Err(panic_info) => {
151          let panic_msg = panic_info
152            .downcast_ref::<String>()
153            .map(|s| s.as_str())
154            .or_else(|| panic_info.downcast_ref::<&str>().copied())
155            .unwrap_or("unknown panic");
156          Err(format!("Formatter panicked: {}", panic_msg))
157        }
158      }
159    };
160
161    if spec.is_trace {
162      let trace_json = get_trace_json(&spec_file_path_buf, &spec.file_text, &spec.config);
163      handle_trace(spec, &trace_json);
164      None
165    } else {
166      let result = match format(&spec.file_text) {
167        Ok(formatted) => formatted.unwrap_or_else(|| spec.file_text.to_string()),
168        Err(err_msg) => {
169          return Some(FailedTestResult {
170            expected: spec.expected_text.clone(),
171            actual: format!("{}\n\nInput:\n{}", err_msg, spec.file_text),
172            actual_second: None,
173            message: spec.message.clone(),
174          });
175        }
176      };
177
178      if result != spec.expected_text {
179        if run_spec_options.fix_failures {
180          // very rough, but good enough
181          let file_text = fs::read_to_string(test_file_path).expect("Expected to read the file.");
182          let file_text = file_text.replace(&spec.expected_text, &result);
183          fs::write(test_file_path, file_text).expect("Expected to write to file.");
184          None
185        } else {
186          Some(FailedTestResult {
187            expected: spec.expected_text.clone(),
188            actual: result,
189            actual_second: None,
190            message: spec.message.clone(),
191          })
192        }
193      } else if run_spec_options.format_twice && !spec.skip_format_twice {
194        // ensure no changes when formatting twice
195        let twice_result = match format(&result) {
196          Ok(formatted) => formatted.unwrap_or_else(|| result.to_string()),
197          Err(err_msg) => {
198            return Some(FailedTestResult {
199              expected: spec.expected_text.clone(),
200              actual: result,
201              actual_second: Some(format!("ERROR on second format: {}", err_msg)),
202              message: spec.message.clone(),
203            });
204          }
205        };
206        if twice_result != spec.expected_text {
207          Some(FailedTestResult {
208            expected: spec.expected_text.clone(),
209            actual: result,
210            actual_second: Some(twice_result),
211            message: spec.message.clone(),
212          })
213        } else {
214          None
215        }
216      } else {
217        None
218      }
219    }
220  }
221
222  fn handle_trace(spec: &Spec, trace_json: &str) {
223    let app_js_text = include_str!("../trace_analyzer/app.js");
224    let app_css_text = include_str!("../trace_analyzer/app.css");
225    let html_file = r#"<!DOCTYPE html>
226<html lang="en">
227<head>
228    <meta charset="utf-8">
229    <meta name="viewport" content="width=device-width">
230    <title><!-- title --></title>
231    <script src="https://d3js.org/d3.v5.min.js"></script>
232    <script src="https://d3js.org/d3-quadtree.v1.min.js"></script>
233    <script src="https://d3js.org/d3-timer.v1.min.js"></script>
234    <script src="https://d3js.org/d3-force.v2.min.js"></script>
235    <script src="https://d3js.org/d3-color.v2.min.js"></script>
236    <script src="https://d3js.org/d3-dispatch.v2.min.js"></script>
237    <script src="https://d3js.org/d3-ease.v2.min.js"></script>
238    <script src="https://d3js.org/d3-interpolate.v2.min.js"></script>
239    <script src="https://d3js.org/d3-selection.v2.min.js"></script>
240    <script src="https://d3js.org/d3-timer.v2.min.js"></script>
241    <script src="https://d3js.org/d3-transition.v2.min.js"></script>
242    <script src="https://d3js.org/d3-drag.v2.min.js"></script>
243    <script src="https://d3js.org/d3-zoom.v2.min.js"></script>
244    <script type="text/javascript">
245    <!-- script -->
246    </script>
247    <style>
248    <!-- style -->
249    </style>
250</head>
251<body onload="onLoad()">
252</body>
253</html>"#;
254    let mut script = format!("const rawTraceResult = {};\n", trace_json);
255    script.push_str(&format!("const specMessage = \"{}\";\n", spec.message.replace('"', "\\\"")));
256    script.push_str(app_js_text);
257    let html_file = html_file
258      .replace("<!-- script -->", &script)
259      .replace("<!-- title -->", &format!("Trace - {}", spec.message))
260      .replace("<!-- style -->", app_css_text);
261    let temp_file_path = std::env::temp_dir().join("dprint-core-trace.html");
262    fs::write(&temp_file_path, html_file).unwrap();
263    let url = format!("file://{}", temp_file_path.to_string_lossy().replace('\\', "/"));
264    panic!("\n==============\nTrace output ready! Please open your browser to: {}\n==============\n", url);
265  }
266
267  #[cfg(not(debug_assertions))]
268  fn assert_spec_not_only_or_trace(spec: &Spec) {
269    if spec.is_trace {
270      panic!("Cannot run 'trace' spec in release mode: {}", spec.message);
271    }
272
273    if spec.is_only {
274      panic!("Cannot run 'only' spec in release mode: {}", spec.message);
275    }
276  }
277
278  #[cfg(not(debug_assertions))]
279  fn assert_not_fix_failures(run_spec_options: &RunSpecsOptions) {
280    if run_spec_options.fix_failures {
281      panic!("Cannot have 'fix_failures' as `true` in release mode.");
282    }
283  }
284}
285
286/// Formats an error and its source chain into a single string,
287/// joining each level with `: ` (similar to the alternate `{:#}` specifier).
288fn error_to_string(err: &(dyn std::error::Error + 'static)) -> String {
289  // cap the depth so a pathological error with a cyclic `source()` chain
290  // can't make this loop forever
291  const MAX_DEPTH: usize = 100;
292  let mut result = err.to_string();
293  let mut source = err.source();
294  for _ in 0..MAX_DEPTH {
295    let Some(err) = source else { break };
296    result.push_str(": ");
297    result.push_str(&err.to_string());
298    source = err.source();
299  }
300  result
301}