From 8e3eed75623f43231e1f312babc2b3317d615adf Mon Sep 17 00:00:00 2001 From: Yuji Sugiura <6259812+leaysgur@users.noreply.github.com> Date: Wed, 8 Jan 2025 18:10:30 +0900 Subject: [PATCH] refactor(prettier): Update tasks/prettier to correctly handle snapshots (#8337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I refactored the code in `tasks/prettier_conformance` primarily to make the output more readable when using `--filter`. But I also discovered that our previous implementation did not correctly handle Prettier's behavior of adding a blank line at the EOF. In addition, I resolved a problem where test specs that used patterns like `runFormatTest(_, parsers)` were unable to locate the correct snapshot output. As a result, compatibility has also improved slightly. 😉 --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- Cargo.lock | 1 + crates/oxc_prettier/src/format/js.rs | 3 +- crates/oxc_prettier/src/format/print/block.rs | 1 + tasks/prettier_conformance/Cargo.toml | 1 + .../snapshots/prettier.js.snap.md | 9 +- .../snapshots/prettier.ts.snap.md | 9 +- tasks/prettier_conformance/src/lib.rs | 700 ++++++++---------- tasks/prettier_conformance/src/main.rs | 21 +- tasks/prettier_conformance/src/options.rs | 33 + tasks/prettier_conformance/src/spec.rs | 84 ++- 10 files changed, 425 insertions(+), 437 deletions(-) create mode 100644 tasks/prettier_conformance/src/options.rs diff --git a/Cargo.lock b/Cargo.lock index a32903f61e60f..2a862f213e401 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1892,6 +1892,7 @@ dependencies = [ name = "oxc_prettier_conformance" version = "0.0.0" dependencies = [ + "cow-utils", "oxc_allocator", "oxc_ast", "oxc_parser", diff --git a/crates/oxc_prettier/src/format/js.rs b/crates/oxc_prettier/src/format/js.rs index bff618036f412..3dc462a112b9a 100644 --- a/crates/oxc_prettier/src/format/js.rs +++ b/crates/oxc_prettier/src/format/js.rs @@ -33,8 +33,7 @@ impl<'a> Format<'a> for Program<'a> { if let Some(body_doc) = block::print_block_body(p, &self.body, Some(&self.directives)) { parts.push(body_doc); - // XXX: Prettier seems to add this, but test results don't match - // parts.extend(hardline!()); + parts.extend(hardline!()); } array!(p, parts) diff --git a/crates/oxc_prettier/src/format/print/block.rs b/crates/oxc_prettier/src/format/print/block.rs index 622e9c18f71dc..454f982435e2b 100644 --- a/crates/oxc_prettier/src/format/print/block.rs +++ b/crates/oxc_prettier/src/format/print/block.rs @@ -48,6 +48,7 @@ pub fn print_block<'a>( array!(p, parts) } +/// For `Program` only pub fn print_block_body<'a>( p: &mut Prettier<'a>, stmts: &[Statement<'a>], diff --git a/tasks/prettier_conformance/Cargo.toml b/tasks/prettier_conformance/Cargo.toml index e4909c66383ac..474b42fa76e2d 100644 --- a/tasks/prettier_conformance/Cargo.toml +++ b/tasks/prettier_conformance/Cargo.toml @@ -30,6 +30,7 @@ oxc_prettier = { workspace = true } oxc_span = { workspace = true } oxc_tasks_common = { workspace = true } +cow-utils = { workspace = true } pico-args = { workspace = true } rustc-hash = { workspace = true } walkdir = { workspace = true } diff --git a/tasks/prettier_conformance/snapshots/prettier.js.snap.md b/tasks/prettier_conformance/snapshots/prettier.js.snap.md index 1940017cfe0a7..b04fbee10a699 100644 --- a/tasks/prettier_conformance/snapshots/prettier.js.snap.md +++ b/tasks/prettier_conformance/snapshots/prettier.js.snap.md @@ -1,4 +1,4 @@ -js compatibility: 246/641 (38.38%) +js compatibility: 249/641 (38.85%) # Failed @@ -220,9 +220,6 @@ js compatibility: 246/641 (38.38%) * js/empty-paren-comment/class.js * js/empty-paren-comment/empty_paren_comment.js -### js/end-of-line -* js/end-of-line/example.js - ### js/export * js/export/blank-line-between-specifiers.js * js/export/same-local-and-exported.js @@ -566,10 +563,8 @@ js compatibility: 246/641 (38.38%) * jsx/jsx/quotes.js * jsx/jsx/regex.js * jsx/jsx/return-statement.js -* jsx/jsx/self-closing.js * jsx/jsx/spacing.js * jsx/jsx/template-literal-in-attr.js -* jsx/jsx/ternary.js ### jsx/last-line * jsx/last-line/last_line.js @@ -603,4 +598,4 @@ js compatibility: 246/641 (38.38%) * jsx/stateless-arrow-fn/test.js ### jsx/text-wrap -* jsx/text-wrap/test.js \ No newline at end of file +* jsx/text-wrap/test.js diff --git a/tasks/prettier_conformance/snapshots/prettier.ts.snap.md b/tasks/prettier_conformance/snapshots/prettier.ts.snap.md index eb65c0f1b46d7..5681e18ebd198 100644 --- a/tasks/prettier_conformance/snapshots/prettier.ts.snap.md +++ b/tasks/prettier_conformance/snapshots/prettier.ts.snap.md @@ -1,4 +1,4 @@ -ts compatibility: 190/568 (33.45%) +ts compatibility: 193/568 (33.98%) # Failed @@ -54,10 +54,8 @@ ts compatibility: 190/568 (33.45%) * jsx/jsx/quotes.js * jsx/jsx/regex.js * jsx/jsx/return-statement.js -* jsx/jsx/self-closing.js * jsx/jsx/spacing.js * jsx/jsx/template-literal-in-attr.js -* jsx/jsx/ternary.js ### jsx/last-line * jsx/last-line/last_line.js @@ -230,9 +228,6 @@ ts compatibility: 190/568 (33.45%) * typescript/conformance/classes/mixinClassesMembers.ts * typescript/conformance/classes/nestedClassDeclaration.ts -### typescript/conformance/classes/classDeclarations/classAbstractKeyword -* typescript/conformance/classes/classDeclarations/classAbstractKeyword/classAbstractWithInterface.ts - ### typescript/conformance/classes/classDeclarations/classHeritageSpecification * typescript/conformance/classes/classDeclarations/classHeritageSpecification/classExtendsItselfIndirectly.ts @@ -654,4 +649,4 @@ ts compatibility: 190/568 (33.45%) * typescript/update-expression/update-expressions.ts ### typescript/webhost -* typescript/webhost/webtsc.ts \ No newline at end of file +* typescript/webhost/webtsc.ts diff --git a/tasks/prettier_conformance/src/lib.rs b/tasks/prettier_conformance/src/lib.rs index c87fc2d294ebd..fe4c61ef3699e 100644 --- a/tasks/prettier_conformance/src/lib.rs +++ b/tasks/prettier_conformance/src/lib.rs @@ -1,72 +1,24 @@ -#![allow(clippy::print_stdout, clippy::print_stderr, clippy::disallowed_methods)] +#![allow(clippy::print_stdout)] + mod ignore_list; +pub mod options; mod spec; -use std::{ - fs, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; + +use cow_utils::CowUtils; +use rustc_hash::FxHashSet; +use walkdir::WalkDir; use oxc_allocator::Allocator; use oxc_parser::{ParseOptions, Parser}; use oxc_prettier::{Prettier, PrettierOptions}; use oxc_span::SourceType; -use oxc_tasks_common::project_root; -use rustc_hash::FxHashSet; -use walkdir::WalkDir; - -use crate::{ignore_list::IGNORE_TESTS, spec::SpecParser}; -#[test] -#[cfg(any(coverage, coverage_nightly))] -fn test() { - TestRunner::new(TestLanguage::Js, TestRunnerOptions::default()).run(); - TestRunner::new(TestLanguage::Ts, TestRunnerOptions::default()).run(); -} - -pub enum TestLanguage { - Js, - Ts, -} - -impl TestLanguage { - fn as_str(&self) -> &'static str { - match self { - Self::Js => "js", - Self::Ts => "ts", - } - } - - /// Prettier's test fixtures roots for different languages. - fn fixtures_roots(&self) -> Vec { - match self { - Self::Js => ["js", "jsx"], - // There is no `tsx` directory, just check it works with TS - // `SourceType`.`variant` is handled by spec file extension - Self::Ts => ["typescript", "jsx"], - } - .iter() - .map(|dir| fixtures_root().join(dir)) - .collect::>() - } -} - -#[derive(Default, Clone)] -pub struct TestRunnerOptions { - pub filter: Option, -} - -/// The test runner which walks the prettier repository and searches for formatting tests. -pub struct TestRunner { - language: TestLanguage, - fixtures_roots: Vec, - ignore_tests: &'static [&'static str], - options: TestRunnerOptions, - spec: SpecParser, -} +use crate::{ignore_list::IGNORE_TESTS, options::TestRunnerOptions, spec::parse_spec}; fn root() -> PathBuf { - project_root().join("tasks").join("prettier_conformance") + oxc_tasks_common::project_root().join("tasks").join("prettier_conformance") } fn fixtures_root() -> PathBuf { @@ -77,182 +29,315 @@ fn snap_root() -> PathBuf { root().join("snapshots") } -const SNAP_NAME: &str = "format.test.js"; -const SNAP_RELATIVE_PATH: &str = "__snapshots__/format.test.js.snap"; -const LF: char = '\u{a}'; -const CR: char = '\u{d}'; +const FORMAT_TEST_SPEC_NAME: &str = "format.test.js"; +const SNAPSHOT_DIR_NAME: &str = "__snapshots__"; +const SNAPSHOT_FILE_NAME: &str = "format.test.js.snap"; + +pub struct TestRunner { + options: TestRunnerOptions, +} impl TestRunner { - pub fn new(language: TestLanguage, options: TestRunnerOptions) -> Self { - let fixtures_roots = language.fixtures_roots(); - Self { - language, - fixtures_roots, - ignore_tests: IGNORE_TESTS, - options, - spec: SpecParser::default(), - } + pub fn new(options: TestRunnerOptions) -> Self { + Self { options } } /// # Panics - #[expect(clippy::cast_precision_loss)] - pub fn run(mut self) { - let fixture_roots = &self.fixtures_roots; - // Read the first level of directories that contain `__snapshots__` - let mut dirs = vec![]; - for fixture_root in fixture_roots { - let dir = WalkDir::new(fixture_root) - .min_depth(1) - .into_iter() - .filter_map(Result::ok) - .filter(|e| { - self.options - .filter - .as_ref() - .map_or(true, |name| e.path().to_string_lossy().contains(name)) - }) - .filter(|e| { - !self.ignore_tests.iter().any(|s| e.path().to_string_lossy().contains(s)) - }) - .map(|e| { - let mut path = e.into_path(); - if path.is_file() { - if let Some(parent_path) = path.parent() { - path = parent_path.into(); - } - } - path - }) - .filter(|path| path.join("__snapshots__").exists()) - .collect::>(); - dirs.extend(dir); + pub fn run(&self) { + let test_lang = self.options.language.as_str(); + let test_dirs = collect_test_dirs(&self.options.language.fixtures_roots(&fixtures_root())); + + // If filter is set, only run the specified test for debug + if self.options.filter.is_some() { + for dir in &test_dirs { + let inputs = collect_test_files(dir, self.options.filter.as_ref()); + // If filter is set, many of the tests can be skipped + if !inputs.is_empty() { + // This will print the diff + let _failed_test_files = test_snapshots(dir, &inputs, true); + } + } + + return; } - let dir_set: FxHashSet<_> = dirs.iter().cloned().collect(); - dirs = dir_set.into_iter().collect(); + // Otherwise, run all tests and generate coverage reports + let mut total_tested_file_count = 0; + let mut total_failed_file_count = 0; + let mut failed_reports = String::new(); + failed_reports.push_str("# Failed\n"); - dirs.sort_unstable(); + for dir in &test_dirs { + let inputs = collect_test_files(dir, None); + let failed_test_files = test_snapshots(dir, &inputs, false); - let mut total = 0; - let mut failed = vec![]; + total_tested_file_count += inputs.len(); + total_failed_file_count += failed_test_files.len(); - for dir in &dirs { - // Get `format.test.js` - let mut spec_path = dir.join(SNAP_NAME); - while !spec_path.exists() { - spec_path = dir.parent().unwrap().join(SNAP_NAME); + if !failed_test_files.is_empty() { + // Use dir as header + failed_reports.push_str(&format!( + "\n### {}\n", + &dir.strip_prefix(fixtures_root()).unwrap().to_string_lossy() + )); + // Each failed test file + for path in failed_test_files { + failed_reports.push_str(&format!( + "* {}\n", + path.strip_prefix(fixtures_root()).unwrap().to_string_lossy() + )); + } } + } - if !spec_path.exists() { - continue; - } + let passed = total_tested_file_count - total_failed_file_count; + #[expect(clippy::cast_precision_loss)] + let percentage = (passed as f64 / total_tested_file_count as f64) * 100.0; + let summary = format!( + "{test_lang} compatibility: {passed}/{total_tested_file_count} ({percentage:.2}%)" + ); - // Get all the other input files - let mut inputs: Vec = WalkDir::new(dir) - .min_depth(1) - .max_depth(1) - .into_iter() - .filter_map(Result::ok) - .filter(|e| !e.file_type().is_dir()) - .filter(|e| { - !self.ignore_tests.iter().any(|s| e.path().to_string_lossy().contains(s)) - }) - .filter(|e| { - self.options - .filter - .as_ref() - .map_or(true, |name| e.path().to_string_lossy().contains(name)) - && !e - .path() - .file_name() - .is_some_and(|name| name.to_string_lossy().contains(SNAP_NAME)) - }) - .map(|e| e.path().to_path_buf()) - .collect(); - inputs.sort_unstable(); - - self.spec.parse(&spec_path); - debug_assert!( - !self.spec.calls.is_empty(), - "There is no `runFormatTest()` in {}, please check if it is correct?", - spec_path.to_string_lossy() - ); + // Print summary + println!("{summary}"); + // And generate coverage reports + let snapshot = format!("{summary}\n\n{failed_reports}"); + std::fs::write(snap_root().join(format!("prettier.{test_lang}.snap.md")), snapshot) + .unwrap(); + } +} - total += inputs.len(); - self.test_snapshot(dir, &spec_path, &inputs, &mut failed); - } +/// Read the first level of directories that contain `__snapshots__` and `format.test.js` +/// ```text +/// js/arrows <------------------------------- THIS +/// ├── __snapshots__ +/// ├── arrow-chain-with-trailing-comments.js +/// ├── arrow_function_expression.js +/// ├── format.test.js +/// ├── semi <-------------------------------- AND THIS +/// │ ├── __snapshots__ +/// │ ├── format.test.js +/// │ └── semi.js +/// └── tuple-and-record.js +/// ``` +fn collect_test_dirs(fixture_roots: &Vec) -> Vec { + let mut test_dirs = FxHashSet::default(); + + for fixture_root in fixture_roots { + let dirs = WalkDir::new(fixture_root) + .min_depth(1) + .into_iter() + .filter_map(Result::ok) + .map(|e| { + let mut path = e.into_path(); + if path.is_file() { + if let Some(parent_path) = path.parent() { + path = parent_path.into(); + } + } + path + }) + .filter(|path| { + path.join(SNAPSHOT_DIR_NAME).exists() && path.join(FORMAT_TEST_SPEC_NAME).exists() + }) + .collect::>(); - let language = self.language.as_str(); - let passed = total - failed.len(); - let percentage = if passed == 0 { 0_f64 } else { (passed as f64 / total as f64) * 100.0 }; - let heading = format!("{language} compatibility: {passed}/{total} ({percentage:.2}%)"); + test_dirs.extend(dirs); + } - println!("{heading}"); + let mut test_dirs = test_dirs.into_iter().collect::>(); + test_dirs.sort_unstable(); - if self.options.filter.is_none() { - let failed = failed.join("\n"); - let snapshot = format!("{heading}\n\n# Failed\n{failed}"); - let filename = format!("prettier.{language}.snap.md"); - fs::write(snap_root().join(filename), snapshot).unwrap(); - } - } + test_dirs +} - fn test_snapshot( - &self, - dir: &Path, - spec_path: &Path, - inputs: &[PathBuf], - failed: &mut Vec, - ) { - let expected_file = spec_path.parent().unwrap().join(SNAP_RELATIVE_PATH); - let expected = fs::read_to_string(expected_file).unwrap(); - - let mut write_dir_info = true; - for path in inputs { - let input = fs::read_to_string(path).unwrap(); - - let result = self.spec.calls.iter().all(|spec| { - let snapshot = self.get_single_snapshot(path, &input, spec.0, &spec.1, &expected); - if snapshot.trim().is_empty() { - return false; - } - expected.contains(&snapshot) - }); - - if !result { - let mut dir_info = String::new(); - if write_dir_info { - dir_info = format!( - "\n### {}\n", - dir.strip_prefix(fixtures_root()).unwrap().to_string_lossy() - ); - write_dir_info = false; - } +/// Read all test files in the directory with applying ignore + filter +/// ```text +/// js/arrows +/// ├── __snapshots__ +/// ├── arrow-chain-with-trailing-comments.js <---- THIS +/// ├── arrow_function_expression.js <------------- AND THIS +/// ├── format.test.js +/// └── tuple-and-record.js <---------------------- AND THIS +/// ``` +fn collect_test_files(dir: &Path, filter: Option<&String>) -> Vec { + let mut test_files: Vec = WalkDir::new(dir) + .min_depth(1) + .max_depth(1) + .into_iter() + .filter_map(Result::ok) + .filter(|e| !e.file_type().is_dir()) + .filter(|e| e.path().file_name().is_none_or(|name| name != FORMAT_TEST_SPEC_NAME)) + .filter(|e| !IGNORE_TESTS.iter().any(|s| e.path().to_string_lossy().contains(s))) + .filter(|e| filter.map_or(true, |name| e.path().to_string_lossy().contains(name))) + .map(|e| e.path().to_path_buf()) + .collect(); + test_files.sort_unstable(); + + test_files +} - // NOTE: `failed.len()` is used as failed count directly - failed.push(format!( - "{dir_info}* {}", - path.strip_prefix(fixtures_root()).unwrap().to_string_lossy() - )); +/// Run `oxc_prettier` and compare the output with the Prettier's snapshot +fn test_snapshots(dir: &Path, test_files: &Vec, has_debug_filter: bool) -> Vec { + // Parse all `runFormatTest()` calls and collect format options + let spec_path = &dir.join(FORMAT_TEST_SPEC_NAME); + let spec_calls = parse_spec(spec_path); + debug_assert!( + !spec_calls.is_empty(), + "There is no `runFormatTest()` in {}, please check if it is correct?", + spec_path.to_string_lossy() + ); + + let snapshots = + std::fs::read_to_string(dir.join(SNAPSHOT_DIR_NAME).join(SNAPSHOT_FILE_NAME)).unwrap(); + + let mut failed_test_files = vec![]; + for path in test_files { + // Single source text is used for multiple options + let source_text = std::fs::read_to_string(path).unwrap(); + // Check every combination of options! + let result = spec_calls.iter().all(|(prettier_options, snapshot_options)| { + // Single snapshot file contains multiple test cases, so need to find the right one + let expected = find_output_from_snapshots( + &snapshots, + path.file_name().unwrap().to_string_lossy().as_ref(), + snapshot_options, + prettier_options.print_width, + ) + .unwrap(); + + let actual = replace_escape_and_eol( + &run_oxc_prettier( + &source_text, + SourceType::from_path(path).unwrap(), + *prettier_options, + ), + expected.contains("LF>") || expected.contains(">() + .join(", ") + ); + + if !result { + print_with_border("Input"); + println!("{source_text}"); + print_with_border(&format!("PrettierOutput: {}LoC", expected.lines().count())); + println!("{expected}"); + print_with_border(&format!("OxcOutput: {}LoC", actual.lines().count())); + println!("{actual}"); + print_with_border("Diff"); + oxc_tasks_common::print_diff_in_terminal(&expected, &actual); + } + println!(); } + + result + }); + + if !result { + failed_test_files.push(path.clone()); } } - fn visualize_end_of_line(content: &str) -> String { - let mut chars = content.chars(); - let mut result = String::new(); + failed_test_files +} - loop { - let current = chars.next(); - let Some(char) = current else { - break; - }; +/// Extract single output section from snapshot file which contains multiple test cases. +/// +/// Format is like below: +/// ``` +/// filename1 +/// ===optionsA=== +/// ====input1==== +/// ===output1A=== +/// ============== +/// filename1 +/// ===optionsB=== +/// ====input1==== +/// ===output1B=== +/// ============== +/// +/// filename2 +/// ===optionsA=== +/// ====input2==== +/// ===output2A=== +/// ============== +/// ``` +/// +/// There are also options-like strings after the filename, but it seems that format is not guaranteed... +/// Thus, we need to find the right section by filename and options for sure. +fn find_output_from_snapshots( + snap_content: &str, + file_name: &str, + snapshot_options: &[(String, String)], + print_width: usize, +) -> Option { + let filename_started = snap_content.find(&format!("exports[`{file_name} "))?; + let expected = &snap_content[filename_started..]; + + let options_started = expected.find(&format!( + "====================================options===================================== +{} +{}| printWidth +=====================================input====================================== +", + snapshot_options.iter().map(|(k, v)| format!("{k}: {v}")).collect::>().join("\n"), + " ".repeat(print_width) + ))?; + let expected = &expected[options_started..]; + + let output_start_line = + "=====================================output=====================================\n"; + let output_started = expected.find(output_start_line)?; + let output_end_line = + "\n================================================================================"; + let output_ended = expected.find(output_end_line)?; + + let output = expected[output_started..output_ended] + .trim_start_matches(output_start_line) + .trim_end_matches(output_end_line); + + Some(output.to_string()) +} +/// Apply the same escape rules as Prettier does. +/// If Prettier's snapshot contains ``, `` or ``, we also need to visualize. +fn replace_escape_and_eol(input: &str, need_eol_visualized: bool) -> String { + let input = input + .cow_replace("\\", "\\\\") + .cow_replace("`", "\\`") + .cow_replace("${", "\\${") + .into_owned(); + + if need_eol_visualized { + let mut chars = input.chars(); + let mut result = String::new(); + + while let Some(char) = chars.next() { match char { - LF => result.push_str("\n"), - CR => { + '\u{a}' => result.push_str("\n"), + '\u{d}' => { let next = chars.clone().next(); - if next == Some(LF) { + if next == Some('\u{a}') { result.push_str("\n"); chars.next(); } else { @@ -264,198 +349,21 @@ impl TestRunner { } } } - result - } - - fn get_single_snapshot( - &self, - path: &Path, - input: &str, - prettier_options: PrettierOptions, - snapshot_options: &[(String, String)], - snap_content: &str, - ) -> String { - let filename = path.file_name().unwrap().to_string_lossy(); - - let snapshot_line = snapshot_options - .iter() - .filter(|k| { - if k.0 == "parsers" { - false - } else if k.0 == "printWidth" { - return k.1 != "80"; - } else { - true - } - }) - .map(|(k, v)| format!("\"{k}\":{v}")) - .collect::>() - .join(","); - - let title_snapshot_options = format!("- {{{snapshot_line}}} ",); - - let title = format!( - "exports[`{filename} {}format 1`] = `", - if snapshot_line.is_empty() { String::new() } else { title_snapshot_options } - ); - - let need_eol_visualized = snap_content.contains(""); - let output = Self::prettier(path, input, prettier_options); - let output = Self::escape_and_convert_snap_string(&output, need_eol_visualized); - let input = Self::escape_and_convert_snap_string(input, need_eol_visualized); - let snapshot_options = snapshot_options - .iter() - .map(|(k, v)| format!("{k}: {v}")) - .collect::>() - .join("\n"); - - let space_line = " ".repeat(prettier_options.print_width); - let snapshot_without_output = format!( - r#" -{title} -====================================options===================================== -{snapshot_options} -{space_line}| printWidth -=====================================input====================================== -{input}"# - ); - - let snapshot_output = format!( - r#" -=====================================output===================================== -{output} - -================================================================================ -`;"# - ); - - // put it here but not in below if-statement to help detect no matched input cases. - let expected = Self::get_expect(snap_content, &snapshot_without_output).unwrap_or_default(); - if self.options.filter.is_some() { - println!("Input path: {}", path.to_string_lossy()); - if !snapshot_line.is_empty() { - println!("Options: \n{snapshot_line}\n"); - } - println!("Input:"); - println!("{input}"); - println!("Output:"); - println!("{output}"); - println!("Diff:"); - println!("{}", Self::get_diff(&output, &expected)); - } - - format!("{snapshot_without_output}{snapshot_output}") - } - - fn get_expect(expected: &str, input: &str) -> Option { - let input_started = expected.find(input)?; - let expected = &expected[input_started..]; - let output_start_line = - "=====================================output=====================================\n"; - let output_end_line = - "================================================================================"; - let output_started = expected.find(output_start_line)?; - let output_ended = expected.find(output_end_line)?; - let output = expected[output_started..output_ended] - .trim_start_matches(output_start_line) - .trim_end_matches(output_end_line); - Some(output.to_string()) - } - - fn get_diff(output: &str, expect: &str) -> String { - let output = output.trim().lines().collect::>(); - let expect = expect.trim().lines().collect::>(); - let length = output.len().max(expect.len()); - let mut result = String::new(); - - for i in 0..length { - let left = output.get(i).unwrap_or(&""); - let right = expect.get(i).unwrap_or(&""); - - let s = if left == right { - format!("{left: <80} | {right: <80}\n") - } else { - format!("{left: <80} X {right: <80}\n") - }; - - result.push_str(&s); - } - - result - } - - fn escape_and_convert_snap_string(input: &str, need_eol_visualized: bool) -> String { - let input = input.replace('\\', "\\\\").replace('`', "\\`").replace("${", "\\${"); - if need_eol_visualized { - Self::visualize_end_of_line(&input) - } else { - input - } + return result; } - fn prettier(path: &Path, source_text: &str, prettier_options: PrettierOptions) -> String { - let allocator = Allocator::default(); - let source_type = SourceType::from_path(path).unwrap(); - let ret = Parser::new(&allocator, source_text, source_type) - .with_options(ParseOptions { preserve_parens: false, ..ParseOptions::default() }) - .parse(); - Prettier::new(&allocator, prettier_options).build(&ret.program) - } + input } -#[cfg(test)] -mod tests { - use std::fs; - - use crate::{fixtures_root, TestRunner, SNAP_RELATIVE_PATH}; - - fn get_expect_in_arrays(input_name: &str) -> String { - let base = fixtures_root().join("js/arrays"); - let expect_file = fs::read_to_string(base.join(SNAP_RELATIVE_PATH)).unwrap(); - let input = fs::read_to_string(base.join(input_name)).unwrap(); - TestRunner::get_expect(&expect_file, &input).unwrap() - } - - #[ignore] - #[test] - fn test_get_expect() { - let expected = get_expect_in_arrays("empty.js"); - assert_eq!( - expected, - "const a = - someVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeLong.Expression || []; -const b = - someVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeLong.Expression || {}; - -" - ); - } - - #[ignore] - #[test] - fn test_get_diff() { - let expected = get_expect_in_arrays("empty.js"); - let output = " -const a = - someVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeLong.Expression || - [] -; -const b = - someVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeLong.Expression || - {} -;"; - let diff = TestRunner::get_diff(output, &expected); - let expected_diff = " -const a = | const a = - someVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeLong.Expression || X someVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeLong.Expression || []; - [] X const b = -; X someVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeLong.Expression || {}; -const b = X - someVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeLong.Expression || X - {} X -; X"; - - assert_eq!(diff.trim(), expected_diff.trim()); - } +fn run_oxc_prettier( + source_text: &str, + source_type: SourceType, + prettier_options: PrettierOptions, +) -> String { + let allocator = Allocator::default(); + let ret = Parser::new(&allocator, source_text, source_type) + .with_options(ParseOptions { preserve_parens: false, ..ParseOptions::default() }) + .parse(); + Prettier::new(&allocator, prettier_options).build(&ret.program) } diff --git a/tasks/prettier_conformance/src/main.rs b/tasks/prettier_conformance/src/main.rs index 644cb0ae85af5..8edb48cebab29 100644 --- a/tasks/prettier_conformance/src/main.rs +++ b/tasks/prettier_conformance/src/main.rs @@ -1,11 +1,24 @@ -use oxc_prettier_conformance::{TestLanguage, TestRunner, TestRunnerOptions}; use pico_args::Arguments; +use oxc_prettier_conformance::{ + options::{TestLanguage, TestRunnerOptions}, + TestRunner, +}; + +/// This CLI runs in 2 modes: +/// - `cargo run`: Run all tests and generate coverage reports +/// - `cargo run -- --filter `: Debug a specific test, not generating coverage reports fn main() { let mut args = Arguments::from_env(); + let filter = args.opt_value_from_str("--filter").unwrap(); - let options = TestRunnerOptions { filter: args.opt_value_from_str("--filter").unwrap() }; + TestRunner::new(TestRunnerOptions { filter: filter.clone(), language: TestLanguage::Js }).run(); + TestRunner::new(TestRunnerOptions { filter: filter.clone(), language: TestLanguage::Ts }).run(); +} - TestRunner::new(TestLanguage::Js, options.clone()).run(); - TestRunner::new(TestLanguage::Ts, options.clone()).run(); +#[test] +#[cfg(any(coverage, coverage_nightly))] +fn test() { + TestRunner::new(TestRunnerOptions { filter: None, language: TestLanguage::Js }).run(); + TestRunner::new(TestRunnerOptions { filter: None, language: TestLanguage::Ts }).run(); } diff --git a/tasks/prettier_conformance/src/options.rs b/tasks/prettier_conformance/src/options.rs new file mode 100644 index 0000000000000..61137304d02be --- /dev/null +++ b/tasks/prettier_conformance/src/options.rs @@ -0,0 +1,33 @@ +use std::path::{Path, PathBuf}; + +pub struct TestRunnerOptions { + pub language: TestLanguage, + pub filter: Option, +} + +pub enum TestLanguage { + Js, + Ts, +} + +impl TestLanguage { + pub fn as_str(&self) -> &'static str { + match self { + Self::Js => "js", + Self::Ts => "ts", + } + } + + /// Prettier's test fixtures roots for different languages. + pub fn fixtures_roots(&self, base: &Path) -> Vec { + match self { + Self::Js => ["js", "jsx"], + // There is no `tsx` directory, just check it works with TS + // `SourceType`.`variant` is handled by spec file extension + Self::Ts => ["typescript", "jsx"], + } + .iter() + .map(|dir| base.join(dir)) + .collect::>() + } +} diff --git a/tasks/prettier_conformance/src/spec.rs b/tasks/prettier_conformance/src/spec.rs index 6faaa818e8bb8..3f0b82c8a948a 100644 --- a/tasks/prettier_conformance/src/spec.rs +++ b/tasks/prettier_conformance/src/spec.rs @@ -1,28 +1,38 @@ -#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - use std::{fs, path::Path, str::FromStr}; use oxc_allocator::Allocator; use oxc_ast::{ - ast::{Argument, ArrayExpressionElement, CallExpression, Expression, ObjectPropertyKind}, + ast::{ + Argument, ArrayExpressionElement, CallExpression, Expression, ObjectPropertyKind, + VariableDeclarator, + }, VisitMut, }; use oxc_parser::Parser; use oxc_prettier::{ArrowParens, EndOfLine, PrettierOptions, QuoteProps, TrailingComma}; use oxc_span::{GetSpan, SourceType}; +/// Vec<(key, value)> +type SnapshotOptions = Vec<(String, String)>; + +pub fn parse_spec(spec: &Path) -> Vec<(PrettierOptions, SnapshotOptions)> { + let mut parser = SpecParser::default(); + parser.parse(spec); + parser.calls +} + #[derive(Default)] -pub struct SpecParser { - pub calls: Vec<(PrettierOptions, Vec<(String, String)>)>, +struct SpecParser { source_text: String, + parsers: Vec, + calls: Vec<(PrettierOptions, SnapshotOptions)>, } impl SpecParser { - pub fn parse(&mut self, spec: &Path) { + fn parse(&mut self, spec: &Path) { let spec_content = fs::read_to_string(spec).unwrap_or_default(); self.source_text.clone_from(&spec_content); - self.calls.clear(); let allocator = Allocator::default(); let source_type = SourceType::from_path(spec).unwrap_or_default(); @@ -33,39 +43,69 @@ impl SpecParser { } impl VisitMut<'_> for SpecParser { + // Some test cases use a variable to store the parsers. + // + // ```js + // const parser = ["babel"]; + // + // runFormatTest(import.meta, parser, {}); + // runFormatTest(import.meta, parser, { semi: false }); + // ```` + fn visit_variable_declarator(&mut self, decl: &mut VariableDeclarator<'_>) { + let Some(name) = decl.id.get_identifier() else { return }; + if !matches!(name.as_str(), "parser" | "parsers") { + return; + } + + debug_assert!(self.parsers.is_empty(), "`parsers` is already defined"); + if let Some(Expression::ArrayExpression(arr_expr)) = &decl.init { + for el in &arr_expr.elements { + if let ArrayExpressionElement::StringLiteral(literal) = el { + self.parsers.push(literal.value.to_string()); + } + } + } + } + + // The `runFormatTest()` function is used on prettier's test cases. + // We need to collect all calls and get the options and parsers. fn visit_call_expression(&mut self, expr: &mut CallExpression<'_>) { let Some(ident) = expr.callee.get_identifier_reference() else { return }; - // The `runFormatTest` function used on prettier's test cases. We need to collect all `run_spec` calls - // And then parse the arguments to get the options and parsers - // Finally we use this information to generate the snapshot tests if ident.name != "runFormatTest" { return; } + + let mut snapshot_options: SnapshotOptions = vec![]; let mut parsers = vec![]; - let mut snapshot_options: Vec<(String, String)> = vec![]; let mut options = PrettierOptions::default(); + // Get parsers if let Some(argument) = expr.arguments.get(1) { let Some(argument_expr) = argument.as_expression() else { return; }; + // If inlined array if let Expression::ArrayExpression(arr_expr) = argument_expr { - parsers = arr_expr - .elements - .iter() - .filter_map(|el| { - if let ArrayExpressionElement::StringLiteral(literal) = el { - return Some(literal.value.to_string()); - } - None - }) - .collect::>(); + for el in &arr_expr.elements { + if let ArrayExpressionElement::StringLiteral(literal) = el { + parsers.push(literal.value.to_string()); + } + } + } + // If variable + if let Expression::Identifier(_) = argument_expr { + debug_assert!( + !self.parsers.is_empty(), + "`parsers` is not collected, check variable name" + ); + parsers.clone_from(&self.parsers); } } else { return; } + // Get options if let Some(Argument::ObjectExpression(obj_expr)) = expr.arguments.get(2) { obj_expr.properties.iter().for_each(|item| { if let ObjectPropertyKind::ObjectProperty(obj_prop) = item { @@ -80,6 +120,7 @@ impl VisitMut<'_> for SpecParser { options.single_quote = literal.value; } } + #[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation)] Expression::NumericLiteral(literal) => match name.as_ref() { "printWidth" => options.print_width = literal.value as usize, "tabWidth" => options.tab_width = literal.value as usize, @@ -117,6 +158,7 @@ impl VisitMut<'_> for SpecParser { }); } + debug_assert!(!parsers.is_empty(), "`parsers` should not be empty"); snapshot_options.push(( "parsers".to_string(), format!(