/** * Copyright 2012-2017 Gunnar Morling (http://www.gunnarmorling.de/) * and/or other contributors as indicated by the @authors tag. See the * copyright.txt file in the distribution for a full listing of all * contributors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.mapstruct.ap.testutil.runner; import static org.assertj.core.api.Assertions.assertThat; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.Set; import org.junit.runners.model.FrameworkMethod; import org.junit.runners.model.Statement; import org.mapstruct.ap.testutil.WithClasses; import org.mapstruct.ap.testutil.WithServiceImplementation; import org.mapstruct.ap.testutil.WithServiceImplementations; import org.mapstruct.ap.testutil.compilation.annotation.CompilationResult; import org.mapstruct.ap.testutil.compilation.annotation.DisableCheckstyle; import org.mapstruct.ap.testutil.compilation.annotation.ExpectedCompilationOutcome; import org.mapstruct.ap.testutil.compilation.annotation.ProcessorOption; import org.mapstruct.ap.testutil.compilation.annotation.ProcessorOptions; import org.mapstruct.ap.testutil.compilation.model.CompilationOutcomeDescriptor; import org.mapstruct.ap.testutil.compilation.model.DiagnosticDescriptor; import org.xml.sax.InputSource; import com.google.common.collect.Lists; import com.google.common.io.ByteStreams; import com.puppycrawl.tools.checkstyle.Checker; import com.puppycrawl.tools.checkstyle.ConfigurationLoader; import com.puppycrawl.tools.checkstyle.DefaultLogger; import com.puppycrawl.tools.checkstyle.PropertiesExpander; /** * A JUnit4 statement that performs source generation using the annotation processor and compiles those sources. * * @author Andreas Gudian */ abstract class CompilingStatement extends Statement { private static final String TARGET_COMPILATION_TESTS = "/target/compilation-tests/"; private static final String LINE_SEPARATOR = System.getProperty( "line.separator" ); private static final DiagnosticDescriptorComparator COMPARATOR = new DiagnosticDescriptorComparator(); protected static final String SOURCE_DIR = getBasePath() + "/src/test/java"; protected static final List<String> TEST_COMPILATION_CLASSPATH = buildTestCompilationClasspath(); protected static final List<String> PROCESSOR_CLASSPATH = buildProcessorClasspath(); private final FrameworkMethod method; private final CompilationCache compilationCache; private final boolean runCheckstyle; private Statement next; private String classOutputDir; private String sourceOutputDir; private String additionalCompilerClasspath; private CompilationRequest compilationRequest; CompilingStatement(FrameworkMethod method, CompilationCache compilationCache) { this.method = method; this.compilationCache = compilationCache; this.runCheckstyle = !method.getMethod().getDeclaringClass().isAnnotationPresent( DisableCheckstyle.class ); this.compilationRequest = new CompilationRequest( getTestClasses(), getServices(), getProcessorOptions() ); } void setNextStatement(Statement next) { this.next = next; } @Override public void evaluate() throws Throwable { generateMapperImplementation(); GeneratedSource.setCompilingStatement( this ); next.evaluate(); GeneratedSource.clearCompilingStatement(); } String getSourceOutputDir() { return compilationCache.getLastSourceOutputDir(); } protected void setupDirectories() throws Exception { String compilationRoot = getBasePath() + TARGET_COMPILATION_TESTS + method.getDeclaringClass().getName() + "/" + method.getName() + getPathSuffix(); classOutputDir = compilationRoot + "/classes"; sourceOutputDir = compilationRoot + "/generated-sources"; additionalCompilerClasspath = compilationRoot + "/compiler"; createOutputDirs(); ( (ModifiableURLClassLoader) Thread.currentThread().getContextClassLoader() ).withPath( classOutputDir ); } protected abstract String getPathSuffix(); private static List<String> buildTestCompilationClasspath() { String[] whitelist = new String[] { // MapStruct annotations in multi-module reactor build or IDE "core" + File.separator + "target", // MapStruct annotations in single module build "org" + File.separator + "mapstruct" + File.separator + "mapstruct" + File.separator, "guava", "javax.inject", "spring-beans", "spring-context", "joda-time" }; return filterBootClassPath( whitelist ); } private static List<String> buildProcessorClasspath() { String[] whitelist = new String[] { "processor" + File.separator + "target", // the processor itself, "freemarker", "javax.inject", "spring-context", "joda-time" }; return filterBootClassPath( whitelist ); } protected static List<String> filterBootClassPath(String[] whitelist) { String[] bootClasspath = System.getProperty( "java.class.path" ).split( File.pathSeparator ); String testClasses = "target" + File.separator + "test-classes"; List<String> classpath = new ArrayList<String>(); for ( String path : bootClasspath ) { if ( !path.contains( testClasses ) && isWhitelisted( path, whitelist ) ) { classpath.add( path ); } } return classpath; } private static boolean isWhitelisted(String path, String[] whitelist) { for ( String whitelisted : whitelist ) { if ( path.contains( whitelisted ) ) { return true; } } return false; } protected void generateMapperImplementation() throws Exception { CompilationOutcomeDescriptor actualResult = compile(); CompilationOutcomeDescriptor expectedResult = CompilationOutcomeDescriptor.forExpectedCompilationResult( method.getAnnotation( ExpectedCompilationOutcome.class ) ); if ( expectedResult.getCompilationResult() == CompilationResult.SUCCEEDED ) { assertThat( actualResult.getCompilationResult() ).describedAs( "Compilation failed. Diagnostics: " + actualResult.getDiagnostics() ).isEqualTo( CompilationResult.SUCCEEDED ); } else { assertThat( actualResult.getCompilationResult() ).describedAs( "Compilation succeeded but should have failed." ).isEqualTo( CompilationResult.FAILED ); } assertDiagnostics( actualResult.getDiagnostics(), expectedResult.getDiagnostics() ); if ( runCheckstyle ) { assertCheckstyleRules(); } } private void assertCheckstyleRules() throws Exception { if ( sourceOutputDir != null ) { Properties properties = new Properties(); properties.put( "checkstyle.cache.file", classOutputDir + "/checkstyle.cache" ); final Checker checker = new Checker(); checker.setModuleClassLoader( Checker.class.getClassLoader() ); checker.configure( ConfigurationLoader.loadConfiguration( new InputSource( getClass().getClassLoader().getResourceAsStream( "checkstyle-for-generated-sources.xml" ) ), new PropertiesExpander( properties ), true ) ); ByteArrayOutputStream errorStream = new ByteArrayOutputStream(); checker.addListener( new DefaultLogger( ByteStreams.nullOutputStream(), true, errorStream, true ) ); int errors = checker.process( findGeneratedFiles( new File( sourceOutputDir ) ) ); if ( errors > 0 ) { String errorLog = errorStream.toString( "UTF-8" ); assertThat( true ).describedAs( "Expected checkstyle compliant output, but got errors:\n" + errorLog ) .isEqualTo( false ); } } } private static List<File> findGeneratedFiles(File file) { final List<File> files = Lists.newLinkedList(); if ( file.canRead() ) { if ( file.isDirectory() ) { for ( File element : file.listFiles() ) { files.addAll( findGeneratedFiles( element ) ); } } else if ( file.isFile() ) { files.add( file ); } } return files; } private void assertDiagnostics(List<DiagnosticDescriptor> actualDiagnostics, List<DiagnosticDescriptor> expectedDiagnostics) { Collections.sort( actualDiagnostics, COMPARATOR ); Collections.sort( expectedDiagnostics, COMPARATOR ); expectedDiagnostics = filterExpectedDiagnostics( expectedDiagnostics ); Iterator<DiagnosticDescriptor> actualIterator = actualDiagnostics.iterator(); Iterator<DiagnosticDescriptor> expectedIterator = expectedDiagnostics.iterator(); assertThat( actualDiagnostics ).describedAs( String.format( "Numbers of expected and actual diagnostics are diffent. Actual:%s%s%sExpected:%s%s.", LINE_SEPARATOR, actualDiagnostics.toString().replace( ", ", LINE_SEPARATOR ), LINE_SEPARATOR, LINE_SEPARATOR, expectedDiagnostics.toString().replace( ", ", LINE_SEPARATOR ) ) ).hasSize( expectedDiagnostics.size() ); while ( actualIterator.hasNext() ) { DiagnosticDescriptor actual = actualIterator.next(); DiagnosticDescriptor expected = expectedIterator.next(); if ( expected.getSourceFileName() != null ) { assertThat( actual.getSourceFileName() ).isEqualTo( expected.getSourceFileName() ); } if ( expected.getLine() != null ) { assertThat( actual.getLine() ).isEqualTo( expected.getLine() ); } assertThat( actual.getKind() ).isEqualTo( expected.getKind() ); assertThat( actual.getMessage() ).describedAs( String.format( "Unexpected message for diagnostic %s:%s %s", actual.getSourceFileName(), actual.getLine(), actual.getKind() ) ).matches( "(?ms).*" + expected.getMessage() + ".*" ); } } /** * @param expectedDiagnostics expected diagnostics * @return a possibly filtered list of expected diagnostics */ protected List<DiagnosticDescriptor> filterExpectedDiagnostics(List<DiagnosticDescriptor> expectedDiagnostics) { return expectedDiagnostics; } /** * Returns the classes to be compiled for this test. * * @return A set containing the classes to be compiled for this test */ private Set<Class<?>> getTestClasses() { Set<Class<?>> testClasses = new HashSet<Class<?>>(); WithClasses withClasses = method.getAnnotation( WithClasses.class ); if ( withClasses != null ) { testClasses.addAll( Arrays.asList( withClasses.value() ) ); } withClasses = method.getMethod().getDeclaringClass().getAnnotation( WithClasses.class ); if ( withClasses != null ) { testClasses.addAll( Arrays.asList( withClasses.value() ) ); } if ( testClasses.isEmpty() ) { throw new IllegalStateException( "The classes to be compiled during the test must be specified via @WithClasses." ); } return testClasses; } /** * Returns the resources to be compiled for this test. * * @return A map containing the package were to look for a resource (key) and the resource (value) to be compiled * for this test */ private Map<Class<?>, Class<?>> getServices() { Map<Class<?>, Class<?>> services = new HashMap<Class<?>, Class<?>>(); addServices( services, method.getAnnotation( WithServiceImplementations.class ) ); addService( services, method.getAnnotation( WithServiceImplementation.class ) ); Class<?> declaringClass = method.getMethod().getDeclaringClass(); addServices( services, declaringClass.getAnnotation( WithServiceImplementations.class ) ); addService( services, declaringClass.getAnnotation( WithServiceImplementation.class ) ); return services; } private void addServices(Map<Class<?>, Class<?>> services, WithServiceImplementations withImplementations) { if ( withImplementations != null ) { for ( WithServiceImplementation resource : withImplementations.value() ) { addService( services, resource ); } } } private void addService(Map<Class<?>, Class<?>> services, WithServiceImplementation annoation) { if ( annoation == null ) { return; } Class<?> provides = annoation.provides(); Class<?> implementor = annoation.value(); if ( provides == Object.class ) { Class<?>[] implemented = implementor.getInterfaces(); if ( implemented.length != 1 ) { throw new IllegalArgumentException( "The class " + implementor.getName() + " either needs to implement exactly one interface, or \"provides\" needs to be specified" + " as well in the annotation " + WithServiceImplementation.class.getSimpleName() + "." ); } provides = implemented[0]; } services.put( provides, implementor ); } /** * Returns the processor options to be used this test. * * @return A list containing the processor options to be used for this test */ private List<String> getProcessorOptions() { List<ProcessorOption> processorOptions = getProcessorOptions( method.getAnnotation( ProcessorOptions.class ), method.getAnnotation( ProcessorOption.class ) ); if ( processorOptions.isEmpty() ) { processorOptions = getProcessorOptions( method.getMethod().getDeclaringClass().getAnnotation( ProcessorOptions.class ), method.getMethod().getDeclaringClass().getAnnotation( ProcessorOption.class ) ); } List<String> result = new ArrayList<String>( processorOptions.size() ); for ( ProcessorOption option : processorOptions ) { result.add( asOptionString( option ) ); } // Add all debugging info to class files result.add( "-g:source,lines,vars" ); return result; } private List<ProcessorOption> getProcessorOptions(ProcessorOptions options, ProcessorOption option) { if ( options != null ) { return Arrays.asList( options.value() ); } else if ( option != null ) { return Arrays.asList( option ); } return Collections.emptyList(); } private String asOptionString(ProcessorOption processorOption) { return String.format( "-A%s=%s", processorOption.name(), processorOption.value() ); } protected static Set<File> getSourceFiles(Collection<Class<?>> classes) { Set<File> sourceFiles = new HashSet<File>( classes.size() ); for ( Class<?> clazz : classes ) { sourceFiles.add( new File( SOURCE_DIR + File.separator + clazz.getName().replace( ".", File.separator ) + ".java" ) ); } return sourceFiles; } private CompilationOutcomeDescriptor compile() throws Exception { if ( !needsRecompilation() ) { return compilationCache.getLastResult(); } setupDirectories(); compilationCache.setLastSourceOutputDir( sourceOutputDir ); boolean needsAdditionalCompilerClasspath = prepareServices(); CompilationOutcomeDescriptor resultHolder; resultHolder = compileWithSpecificCompiler( compilationRequest, sourceOutputDir, classOutputDir, needsAdditionalCompilerClasspath ? additionalCompilerClasspath : null ); compilationCache.update( compilationRequest, resultHolder ); return resultHolder; } protected Object loadAndInstantiate(ClassLoader processorClassloader, Class<?> clazz) { try { return processorClassloader.loadClass( clazz.getName() ).newInstance(); } catch ( Exception e ) { throw new RuntimeException( e ); } } protected abstract CompilationOutcomeDescriptor compileWithSpecificCompiler( CompilationRequest compilationRequest, String sourceOutputDir, String classOutputDir, String additionalCompilerClasspath); boolean needsRecompilation() { return !compilationRequest.equals( compilationCache.getLastRequest() ); } private static String getBasePath() { try { return new File( "." ).getCanonicalPath(); } catch ( IOException e ) { throw new RuntimeException( e ); } } private void createOutputDirs() { File directory = new File( classOutputDir ); deleteDirectory( directory ); directory.mkdirs(); directory = new File( sourceOutputDir ); deleteDirectory( directory ); directory.mkdirs(); directory = new File( additionalCompilerClasspath ); deleteDirectory( directory ); directory.mkdirs(); } private void deleteDirectory(File path) { if ( path.exists() ) { File[] files = path.listFiles(); for ( int i = 0; i < files.length; i++ ) { if ( files[i].isDirectory() ) { deleteDirectory( files[i] ); } else { files[i].delete(); } } } path.delete(); } private boolean prepareServices() { if ( !compilationRequest.getServices().isEmpty() ) { String servicesDir = additionalCompilerClasspath + File.separator + "META-INF" + File.separator + "services"; File directory = new File( servicesDir ); deleteDirectory( directory ); directory.mkdirs(); for ( Map.Entry<Class<?>, Class<?>> serviceEntry : compilationRequest.getServices().entrySet() ) { try { File file = new File( servicesDir + File.separator + serviceEntry.getKey().getName() ); FileWriter fileWriter = new FileWriter( file ); fileWriter.append( serviceEntry.getValue().getName() ).append( "\n" ); fileWriter.flush(); fileWriter.close(); } catch ( IOException e ) { throw new RuntimeException( e ); } } return true; } return false; } private static class DiagnosticDescriptorComparator implements Comparator<DiagnosticDescriptor> { @Override public int compare(DiagnosticDescriptor o1, DiagnosticDescriptor o2) { String sourceFileName1 = o1.getSourceFileName() != null ? o1.getSourceFileName() : ""; String sourceFileName2 = o2.getSourceFileName() != null ? o2.getSourceFileName() : ""; int result = sourceFileName1.compareTo( sourceFileName2 ); if ( result != 0 ) { return result; } result = Long.valueOf( o1.getLine() ).compareTo( o2.getLine() ); if ( result != 0 ) { return result; } return o1.getKind().compareTo( o2.getKind() ); } } }