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