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
49type 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 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 let file_text = fs::read_to_string(test_file_path).expect("Expected to read the file.");
185 let result_range = spec.expected_range.as_ref().and_then(|range| {
187 let (prefix, suffix) = (&spec.expected_text[..range.start], &spec.expected_text[range.end..]);
188 let fits = result.len() >= prefix.len() + suffix.len() && result.starts_with(prefix) && result.ends_with(suffix);
189 fits.then(|| range.start..result.len() - suffix.len())
190 });
191 let file_text = file_text.replace(
192 &with_range_markers(&spec.expected_text, spec.expected_range.as_ref()),
193 &with_range_markers(&result, result_range.as_ref()),
194 );
195 fs::write(test_file_path, file_text).expect("Expected to write to file.");
196 None
197 } else {
198 Some(FailedTestResult {
199 expected: spec.expected_text.clone(),
200 actual: result,
201 actual_second: None,
202 message: spec.message.clone(),
203 })
204 }
205 } else if run_spec_options.format_twice && !spec.skip_format_twice && (spec.range.is_none() || spec.expected_range.is_some()) {
206 let twice_result = match format(&result, spec.expected_range.clone()) {
209 Ok(formatted) => formatted.unwrap_or_else(|| result.to_string()),
210 Err(err_msg) => {
211 return Some(FailedTestResult {
212 expected: spec.expected_text.clone(),
213 actual: result,
214 actual_second: Some(format!("ERROR on second format: {}", err_msg)),
215 message: spec.message.clone(),
216 });
217 }
218 };
219 if twice_result != spec.expected_text {
220 Some(FailedTestResult {
221 expected: spec.expected_text.clone(),
222 actual: result,
223 actual_second: Some(twice_result),
224 message: spec.message.clone(),
225 })
226 } else {
227 None
228 }
229 } else {
230 None
231 }
232 }
233 }
234
235 fn handle_trace(spec: &Spec, trace_json: &str) {
236 let app_js_text = include_str!("../trace_analyzer/app.js");
237 let app_css_text = include_str!("../trace_analyzer/app.css");
238 let html_file = r#"<!DOCTYPE html>
239<html lang="en">
240<head>
241 <meta charset="utf-8">
242 <meta name="viewport" content="width=device-width">
243 <title><!-- title --></title>
244 <script src="https://d3js.org/d3.v5.min.js"></script>
245 <script src="https://d3js.org/d3-quadtree.v1.min.js"></script>
246 <script src="https://d3js.org/d3-timer.v1.min.js"></script>
247 <script src="https://d3js.org/d3-force.v2.min.js"></script>
248 <script src="https://d3js.org/d3-color.v2.min.js"></script>
249 <script src="https://d3js.org/d3-dispatch.v2.min.js"></script>
250 <script src="https://d3js.org/d3-ease.v2.min.js"></script>
251 <script src="https://d3js.org/d3-interpolate.v2.min.js"></script>
252 <script src="https://d3js.org/d3-selection.v2.min.js"></script>
253 <script src="https://d3js.org/d3-timer.v2.min.js"></script>
254 <script src="https://d3js.org/d3-transition.v2.min.js"></script>
255 <script src="https://d3js.org/d3-drag.v2.min.js"></script>
256 <script src="https://d3js.org/d3-zoom.v2.min.js"></script>
257 <script type="text/javascript">
258 <!-- script -->
259 </script>
260 <style>
261 <!-- style -->
262 </style>
263</head>
264<body onload="onLoad()">
265</body>
266</html>"#;
267 let mut script = format!("const rawTraceResult = {};\n", trace_json);
268 script.push_str(&format!("const specMessage = \"{}\";\n", spec.message.replace('"', "\\\"")));
269 script.push_str(app_js_text);
270 let html_file = html_file
271 .replace("<!-- script -->", &script)
272 .replace("<!-- title -->", &format!("Trace - {}", spec.message))
273 .replace("<!-- style -->", app_css_text);
274 let temp_file_path = std::env::temp_dir().join("dprint-core-trace.html");
275 fs::write(&temp_file_path, html_file).unwrap();
276 let url = format!("file://{}", temp_file_path.to_string_lossy().replace('\\', "/"));
277 panic!("\n==============\nTrace output ready! Please open your browser to: {}\n==============\n", url);
278 }
279
280 #[cfg(not(debug_assertions))]
281 fn assert_spec_not_only_or_trace(spec: &Spec) {
282 if spec.is_trace {
283 panic!("Cannot run 'trace' spec in release mode: {}", spec.message);
284 }
285
286 if spec.is_only {
287 panic!("Cannot run 'only' spec in release mode: {}", spec.message);
288 }
289 }
290
291 #[cfg(not(debug_assertions))]
292 fn assert_not_fix_failures(run_spec_options: &RunSpecsOptions) {
293 if run_spec_options.fix_failures {
294 panic!("Cannot have 'fix_failures' as `true` in release mode.");
295 }
296 }
297}
298
299fn with_range_markers(text: &str, range: Option<&Range<usize>>) -> String {
300 match range {
301 Some(range) => format!(
302 "{}{}{}{}{}",
303 &text[..range.start],
304 RANGE_START,
305 &text[range.clone()],
306 RANGE_END,
307 &text[range.end..]
308 ),
309 None => text.to_string(),
310 }
311}
312
313fn error_to_string(err: &(dyn std::error::Error + 'static)) -> String {
316 const MAX_DEPTH: usize = 100;
319 let mut result = err.to_string();
320 let mut source = err.source();
321 for _ in 0..MAX_DEPTH {
322 let Some(err) = source else { break };
323 result.push_str(": ");
324 result.push_str(&err.to_string());
325 source = err.source();
326 }
327 result
328}