mirror of
				https://github.com/astral-sh/ruff.git
				synced 2025-10-31 12:05:52 +00:00 
			
		
		
		
	test(ruff_python_formatter): Run all Black tests (#2993)
This PR changes the testing infrastructure to run all black tests and: * Pass if Ruff and Black generate the same formatting * Fail and write a markdown snapshot that shows the input code, the differences between Black and Ruff, Ruffs output, and Blacks output This is achieved by introducing a new `fixture` macro (open to better name suggestions) that "duplicates" the attributed test for every file that matches the specified glob pattern. Creating a new test for each file over having a test that iterates over all files has the advantage that you can run a single test, and that test failures indicate which case is failing. The `fixture` macro also makes it straightforward to e.g. setup our own spec tests that test very specific formatting by creating a new folder and use insta to assert the formatted output.
This commit is contained in:
		
							parent
							
								
									262e768fd3
								
							
						
					
					
						commit
						ed33b75bad
					
				
					 119 changed files with 12989 additions and 981 deletions
				
			
		
							
								
								
									
										397
									
								
								crates/ruff_testing_macros/src/lib.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										397
									
								
								crates/ruff_testing_macros/src/lib.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,397 @@ | |||
| use glob::{glob, Pattern}; | ||||
| use proc_macro::TokenStream; | ||||
| use proc_macro2::Span; | ||||
| use quote::{format_ident, quote}; | ||||
| use std::borrow::Cow; | ||||
| use std::collections::BTreeMap; | ||||
| use std::env; | ||||
| use std::path::{Component, PathBuf}; | ||||
| use syn::parse::{Parse, ParseStream}; | ||||
| use syn::punctuated::Punctuated; | ||||
| use syn::spanned::Spanned; | ||||
| use syn::{bracketed, parse_macro_input, parse_quote, Error, FnArg, ItemFn, LitStr, Pat, Token}; | ||||
| 
 | ||||
| #[derive(Debug)] | ||||
| struct FixtureConfiguration { | ||||
|     pattern: Pattern, | ||||
|     pattern_span: Span, | ||||
|     exclude: Vec<Pattern>, | ||||
| } | ||||
| 
 | ||||
| struct Arg { | ||||
|     name: syn::Ident, | ||||
|     value: ArgValue, | ||||
| } | ||||
| 
 | ||||
| impl Parse for Arg { | ||||
|     fn parse(input: ParseStream) -> syn::Result<Self> { | ||||
|         let name = input.parse()?; | ||||
|         let _equal_token: Token![=] = input.parse()?; | ||||
|         let value = input.parse()?; | ||||
| 
 | ||||
|         Ok(Self { name, value }) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| enum ArgValue { | ||||
|     LitStr(LitStr), | ||||
|     List(Punctuated<LitStr, Token![,]>), | ||||
| } | ||||
| 
 | ||||
| impl Parse for ArgValue { | ||||
|     fn parse(input: ParseStream) -> syn::Result<Self> { | ||||
|         let value = if input.peek(syn::token::Bracket) { | ||||
|             let inner; | ||||
|             let _ = bracketed!(inner in input); | ||||
| 
 | ||||
|             let values = inner.parse_terminated(|parser| { | ||||
|                 let value: LitStr = parser.parse()?; | ||||
|                 Ok(value) | ||||
|             })?; | ||||
|             ArgValue::List(values) | ||||
|         } else { | ||||
|             ArgValue::LitStr(input.parse()?) | ||||
|         }; | ||||
| 
 | ||||
|         Ok(value) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl Parse for FixtureConfiguration { | ||||
|     fn parse(input: ParseStream) -> syn::Result<Self> { | ||||
|         let args: Punctuated<_, Token![,]> = input.parse_terminated(Arg::parse)?; | ||||
| 
 | ||||
|         let mut pattern = None; | ||||
|         let mut exclude = None; | ||||
| 
 | ||||
|         for arg in args { | ||||
|             match arg.name.to_string().as_str() { | ||||
|                 "pattern" => match arg.value { | ||||
|                     ArgValue::LitStr(value) => { | ||||
|                         pattern = Some(try_parse_pattern(&value)?); | ||||
|                     } | ||||
|                     ArgValue::List(list) => { | ||||
|                         return Err(Error::new( | ||||
|                             list.span(), | ||||
|                             "The pattern must be a string literal", | ||||
|                         )) | ||||
|                     } | ||||
|                 }, | ||||
| 
 | ||||
|                 "exclude" => { | ||||
|                     match arg.value { | ||||
|                         ArgValue::LitStr(lit) => return Err(Error::new( | ||||
|                             lit.span(), | ||||
|                             "The exclude argument must be an array of globs: 'exclude=[\"a.py\"]", | ||||
|                         )), | ||||
|                         ArgValue::List(list) => { | ||||
|                             let mut exclude_patterns = Vec::with_capacity(list.len()); | ||||
| 
 | ||||
|                             for pattern in list { | ||||
|                                 let (pattern, _) = try_parse_pattern(&pattern)?; | ||||
|                                 exclude_patterns.push(pattern); | ||||
|                             } | ||||
| 
 | ||||
|                             exclude = Some(exclude_patterns); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 _ => { | ||||
|                     return Err(Error::new( | ||||
|                         arg.name.span(), | ||||
|                         format!("Unknown argument {}.", arg.name), | ||||
|                     )); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         let exclude = exclude.unwrap_or_default(); | ||||
| 
 | ||||
|         match pattern { | ||||
|             None => Err(Error::new( | ||||
|                 input.span(), | ||||
|                 "'fixture' macro must have a pattern attribute", | ||||
|             )), | ||||
|             Some((pattern, pattern_span)) => Ok(Self { | ||||
|                 pattern, | ||||
|                 pattern_span, | ||||
|                 exclude, | ||||
|             }), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| fn try_parse_pattern(pattern_lit: &LitStr) -> syn::Result<(Pattern, Span)> { | ||||
|     let raw_pattern = pattern_lit.value(); | ||||
|     match Pattern::new(&raw_pattern) { | ||||
|         Ok(pattern) => Ok((pattern, pattern_lit.span())), | ||||
|         Err(err) => Err(Error::new( | ||||
|             pattern_lit.span(), | ||||
|             format!("'{raw_pattern}' is not a valid glob pattern: '{}'", err.msg), | ||||
|         )), | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// Generates a test for each file that matches the specified pattern.
 | ||||
| ///
 | ||||
| /// The attributed function must have exactly one argument of the type `&Path`.
 | ||||
| /// The `#[test]` attribute must come after the `#[fixture]` argument or `test` will complain
 | ||||
| /// that your function can not have any arguments.
 | ||||
| ///
 | ||||
| /// ## Examples
 | ||||
| ///
 | ||||
| /// Creates a test for every python file file in the `fixtures` directory.
 | ||||
| ///
 | ||||
| /// ```ignore
 | ||||
| /// #[fixture(pattern="fixtures/*.py")]
 | ||||
| /// #[test]
 | ||||
| /// fn my_test(path: &Path) -> std::io::Result<()> {
 | ||||
| ///     // ... implement the test
 | ||||
| ///     Ok(())
 | ||||
| /// }
 | ||||
| /// ```
 | ||||
| ///
 | ||||
| /// ### Excluding Files
 | ||||
| ///
 | ||||
| /// You can exclude files by specifying optional `exclude` patterns.
 | ||||
| ///
 | ||||
| /// ```ignore
 | ||||
| /// #[fixture(pattern="fixtures/*.py", exclude=["a_*.py"])]
 | ||||
| /// #[test]
 | ||||
| /// fn my_test(path: &Path) -> std::io::Result<()> {
 | ||||
| ///     // ... implement the test
 | ||||
| ///     Ok(())
 | ||||
| /// }
 | ||||
| /// ```
 | ||||
| ///
 | ||||
| /// Creates tests for each python file in the `fixtures` directory except for files matching the `a_*.py` pattern.
 | ||||
| #[proc_macro_attribute] | ||||
| pub fn fixture(attribute: TokenStream, item: TokenStream) -> TokenStream { | ||||
|     let test_fn = parse_macro_input!(item as ItemFn); | ||||
|     let configuration = parse_macro_input!(attribute as FixtureConfiguration); | ||||
| 
 | ||||
|     let result = generate_fixtures(test_fn, &configuration); | ||||
| 
 | ||||
|     let stream = match result { | ||||
|         Ok(output) => output, | ||||
|         Err(err) => err.to_compile_error(), | ||||
|     }; | ||||
| 
 | ||||
|     TokenStream::from(stream) | ||||
| } | ||||
| 
 | ||||
| fn generate_fixtures( | ||||
|     mut test_fn: ItemFn, | ||||
|     configuration: &FixtureConfiguration, | ||||
| ) -> syn::Result<proc_macro2::TokenStream> { | ||||
|     // Remove the fixtures attribute
 | ||||
|     test_fn.attrs.retain(|attr| !attr.path.is_ident("fixtures")); | ||||
| 
 | ||||
|     // Extract the name of the only argument of the test function.
 | ||||
|     let last_arg = test_fn.sig.inputs.last(); | ||||
|     let path_ident = match (test_fn.sig.inputs.len(), last_arg) { | ||||
|         (1, Some(last_arg)) => match last_arg { | ||||
|             FnArg::Typed(typed) => match typed.pat.as_ref() { | ||||
|                 Pat::Ident(ident) => ident.ident.clone(), | ||||
|                 pat => { | ||||
|                     return Err(Error::new( | ||||
|                         pat.span(), | ||||
|                         "#[fixture] function argument name must be an identifier", | ||||
|                     )); | ||||
|                 } | ||||
|             }, | ||||
|             FnArg::Receiver(receiver) => { | ||||
|                 return Err(Error::new( | ||||
|                     receiver.span(), | ||||
|                     "#[fixture] function argument name must be an identifier", | ||||
|                 )); | ||||
|             } | ||||
|         }, | ||||
|         _ => { | ||||
|             return Err(Error::new( | ||||
|                 test_fn.sig.inputs.span(), | ||||
|                 "#[fixture] function must have exactly one argument with the type '&Path'", | ||||
|             )); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     // Remove all arguments
 | ||||
|     test_fn.sig.inputs.clear(); | ||||
| 
 | ||||
|     let crate_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect( | ||||
|         "#[fixture] requires CARGO_MANIFEST_DIR to be set during the build to resolve the relative paths to the test files.", | ||||
|     )); | ||||
| 
 | ||||
|     let pattern = if configuration.pattern.as_str().starts_with('/') { | ||||
|         Cow::from(configuration.pattern.as_str()) | ||||
|     } else { | ||||
|         Cow::from(format!( | ||||
|             "{}/{}", | ||||
|             crate_dir | ||||
|                 .to_str() | ||||
|                 .expect("CARGO_MANIFEST_DIR must point to a directory with a UTF8 path"), | ||||
|             configuration.pattern.as_str() | ||||
|         )) | ||||
|     }; | ||||
| 
 | ||||
|     let files = glob(&pattern).expect("Pattern to be valid").flatten(); | ||||
|     let mut modules = Modules::default(); | ||||
| 
 | ||||
|     for file in files { | ||||
|         if configuration | ||||
|             .exclude | ||||
|             .iter() | ||||
|             .any(|exclude| exclude.matches_path(&file)) | ||||
|         { | ||||
|             continue; | ||||
|         } | ||||
| 
 | ||||
|         let mut test_fn = test_fn.clone(); | ||||
| 
 | ||||
|         let test_name = file | ||||
|             .file_name() | ||||
|             // SAFETY: Glob only matches on file names.
 | ||||
|             .unwrap() | ||||
|             .to_str() | ||||
|             .expect("Expected path to be valid UTF8") | ||||
|             .replace('.', "_"); | ||||
| 
 | ||||
|         test_fn.sig.ident = format_ident!("{test_name}"); | ||||
| 
 | ||||
|         let path = file.as_os_str().to_str().unwrap(); | ||||
| 
 | ||||
|         test_fn.block.stmts.insert( | ||||
|             0, | ||||
|             parse_quote!(let #path_ident = std::path::Path::new(#path);), | ||||
|         ); | ||||
| 
 | ||||
|         modules.push_test(Test { | ||||
|             path: file, | ||||
|             test_fn, | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     if modules.is_empty() { | ||||
|         return Err(Error::new( | ||||
|             configuration.pattern_span, | ||||
|             "No file matches the specified glob pattern", | ||||
|         )); | ||||
|     } | ||||
| 
 | ||||
|     let root = find_highest_common_ancestor_module(&modules.root); | ||||
| 
 | ||||
|     root.generate(&test_fn.sig.ident.to_string()) | ||||
| } | ||||
| 
 | ||||
| fn find_highest_common_ancestor_module(module: &Module) -> &Module { | ||||
|     let children = &module.children; | ||||
| 
 | ||||
|     if children.len() == 1 { | ||||
|         let (_, child) = children.iter().next().unwrap(); | ||||
| 
 | ||||
|         match child { | ||||
|             Child::Module(common_child) => find_highest_common_ancestor_module(common_child), | ||||
|             Child::Test(_) => module, | ||||
|         } | ||||
|     } else { | ||||
|         module | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug)] | ||||
| struct Test { | ||||
|     path: PathBuf, | ||||
|     test_fn: ItemFn, | ||||
| } | ||||
| 
 | ||||
| impl Test { | ||||
|     fn generate(&self, _: &str) -> proc_macro2::TokenStream { | ||||
|         let test_fn = &self.test_fn; | ||||
|         quote!(#test_fn) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Default)] | ||||
| struct Module { | ||||
|     children: BTreeMap<String, Child>, | ||||
| } | ||||
| 
 | ||||
| impl Module { | ||||
|     fn generate(&self, name: &str) -> syn::Result<proc_macro2::TokenStream> { | ||||
|         let mut inner = Vec::with_capacity(self.children.len()); | ||||
| 
 | ||||
|         for (name, child) in &self.children { | ||||
|             inner.push(child.generate(name)?); | ||||
|         } | ||||
| 
 | ||||
|         let module_ident = format_ident!("{name}"); | ||||
| 
 | ||||
|         Ok(quote!( | ||||
|             mod #module_ident { | ||||
|                 use super::*; | ||||
| 
 | ||||
|                 #(#inner)* | ||||
|             } | ||||
|         )) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug)] | ||||
| enum Child { | ||||
|     Module(Module), | ||||
|     Test(Test), | ||||
| } | ||||
| 
 | ||||
| impl Child { | ||||
|     fn generate(&self, name: &str) -> syn::Result<proc_macro2::TokenStream> { | ||||
|         match self { | ||||
|             Child::Module(module) => module.generate(name), | ||||
|             Child::Test(test) => Ok(test.generate(name)), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Default)] | ||||
| struct Modules { | ||||
|     root: Module, | ||||
| } | ||||
| 
 | ||||
| impl Modules { | ||||
|     fn push_test(&mut self, test: Test) { | ||||
|         let mut components = test | ||||
|             .path | ||||
|             .as_path() | ||||
|             .components() | ||||
|             .skip_while(|c| matches!(c, Component::RootDir)) | ||||
|             .peekable(); | ||||
| 
 | ||||
|         let mut current = &mut self.root; | ||||
|         while let Some(component) = components.next() { | ||||
|             let name = component.as_os_str().to_str().unwrap(); | ||||
|             // A directory
 | ||||
|             if components.peek().is_some() { | ||||
|                 let name = component.as_os_str().to_str().unwrap(); | ||||
|                 let entry = current.children.entry(name.to_owned()); | ||||
| 
 | ||||
|                 match entry.or_insert_with(|| Child::Module(Module::default())) { | ||||
|                     Child::Module(module) => { | ||||
|                         current = module; | ||||
|                     } | ||||
|                     Child::Test(_) => { | ||||
|                         unreachable!() | ||||
|                     } | ||||
|                 } | ||||
|             } else { | ||||
|                 // We reached the final component, insert the test
 | ||||
|                 drop(components); | ||||
|                 current.children.insert(name.to_owned(), Child::Test(test)); | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fn is_empty(&self) -> bool { | ||||
|         self.root.children.is_empty() | ||||
|     } | ||||
| } | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Micha Reiser
						Micha Reiser