001/* 002 * Copyright (C) 2022 - 2024, the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package io.github.ascopes.jct.junit; 017 018import static io.github.ascopes.jct.utils.IterableUtils.requireNonNullValues; 019import static java.util.Objects.requireNonNull; 020 021import io.github.ascopes.jct.compilers.JctCompiler; 022import io.github.ascopes.jct.compilers.JctCompilerConfigurer; 023import io.github.ascopes.jct.ex.JctIllegalInputException; 024import io.github.ascopes.jct.ex.JctJunitConfigurerException; 025import java.lang.reflect.Constructor; 026import java.lang.reflect.InaccessibleObjectException; 027import java.lang.reflect.InvocationTargetException; 028import java.util.stream.IntStream; 029import java.util.stream.Stream; 030import org.junit.jupiter.api.extension.ExtensionContext; 031import org.junit.jupiter.params.provider.Arguments; 032import org.junit.jupiter.params.provider.ArgumentsProvider; 033import org.junit.jupiter.params.support.AnnotationConsumer; 034 035/** 036 * Base for defining a compiler-supplying arguments-provider for JUnit Jupiter parameterised test 037 * support. 038 * 039 * <p>Each implementation is expected to provide: 040 * 041 * <ul> 042 * <li>A method {@link #initializeNewCompiler} that returns new instances of a 043 * {@link JctCompiler};</li> 044 * <li>A minimum acceptable language level for the compiler, as an integer;</li> 045 * <li>A maximum acceptable language level for the compiler, as an integer;</li> 046 * <li>An implementation of {@link AnnotationConsumer} that consumes the desired 047 * annotation. The details of the annotation should be extracted and a call 048 * to {@link #configure} should be made.</li> 049 * </ul> 050 * 051 * <p>An example annotation would look like the following: 052 * 053 * <pre><code> 054 * {@literal @ArgumentsSource(MyCompilersProvider.class)} 055 * {@literal @ParameterizedTest(name = "for {0}")} 056 * {@literal @Retention(RetentionPolicy.RUNTIME)} 057 * {@literal @Tag("java-compiler-testing-test")} 058 * {@literal @Target}({ 059 * ElementType.ANNOTATION_TYPE, 060 * ElementType.METHOD, 061 * }) 062 * {@literal @TestTemplate} 063 * public {@literal @interface} MyCompilerTest { 064 * int minVersion() default Integer.MIN_VALUE; 065 * int maxVersion() default Integer.MAX_VALUE; 066 * Class<? extends JctSimpleCompilerConfigurer>[] configurers() default {}; 067 * VersionStrategy versionStrategy() default VersionStrategy.RELEASE; 068 * } 069 * </code></pre> 070 * 071 * <p>...with the JUnit5 annotation provider being implemented as: 072 * 073 * <pre><code> 074 * public final class MyCompilersProvider 075 * extends AbstractCompilersProvider 076 * implements AnnotationConsumer<MyCompilerTest> { 077 * 078 * {@literal @Override} 079 * protected JctCompiler initializeNewCompiler() { 080 * return new MyCompilerImpl(); 081 * } 082 * 083 * {@literal @Override} 084 * protected int minSupportedVersion() { 085 * return 11; // Support Java 11 as the minimum. 086 * } 087 * 088 * {@literal @Override} 089 * protected int maxSupportedVersion() { 090 * return 19; // Support Java 19 as the maximum. 091 * } 092 * 093 * {@literal @Override} 094 * public void accept(MyCompilerTest annotation) { 095 * super.configure( 096 * annotation.minVersion(), 097 * annotation.maxVersion(), 098 * annotation.configurers(), 099 * annotation.versionStrategy(), 100 * ); 101 * } 102 * } 103 * </code></pre> 104 * 105 * <p>This would enable you to define your test cases like so: 106 * 107 * <pre><code> 108 * {@literal @MyCompilerTest(minVersion=13, maxVersion=17)} 109 * void testSomething(JctCompiler compiler) { 110 * ... 111 * } 112 * 113 * {@literal @MyCompilerTest(configurers=WerrorConfigurer.class)} 114 * void testSomethingElse(JctCompiler compiler) { 115 * ... 116 * } 117 * 118 * static class WerrorConfigurer implements JctCompilerConfigurer { 119 * {@literal @Override} 120 * public void configure(JctCompiler compiler) { 121 * compiler.failOnErrors(true); 122 * } 123 * } 124 * </code></pre> 125 * 126 * <p>Note that if you are running your tests within a JPMS module, you will need 127 * to ensure that you declare your module to be {@code open} to {@code io.github.ascopes.jct}, 128 * otherwise this component will be unable to discover the constructor to initialise your configurer 129 * correctly, and may raise an exception as a result. 130 * 131 * @author Ashley Scopes 132 * @since 0.0.1 133 */ 134public abstract class AbstractCompilersProvider implements ArgumentsProvider { 135 136 // Values that are late-bound when configure() is called from the 137 // AnnotationConsumer. 138 private int minVersion; 139 private int maxVersion; 140 private Class<? extends JctCompilerConfigurer<?>>[] configurerClasses; 141 private VersionStrategy versionStrategy; 142 143 /** 144 * Initialise this provider. 145 */ 146 protected AbstractCompilersProvider() { 147 minVersion = 0; 148 maxVersion = Integer.MAX_VALUE; 149 configurerClasses = emptyArray(); 150 versionStrategy = VersionStrategy.RELEASE; 151 } 152 153 @Override 154 public Stream<? extends Arguments> provideArguments(ExtensionContext context) { 155 return IntStream 156 .rangeClosed(minVersion, maxVersion) 157 .mapToObj(this::createCompilerForVersion) 158 .peek(this::applyConfigurers) 159 .map(Arguments::of); 160 } 161 162 /** 163 * Configure this provider with parameters from annotations. 164 * 165 * <p>This is expected to be called from an implementation of {@link AnnotationConsumer}. 166 * 167 * <p>The minimum compiler version will be set to the {@code min} parameter, or 168 * {@link #minSupportedVersion}, whichever is greater. This means annotations can pass 169 * {@link Integer#MIN_VALUE} as a default value safely. 170 * 171 * <p>The maximum compiler version will be set to the {@code max} parameter, or 172 * {@link #maxSupportedVersion}, whichever is smaller. This means annotations can pass 173 * {@link Integer#MAX_VALUE} as a default value safely. 174 * 175 * <p>If implementations do not support specifying custom compiler configurers, then an empty 176 * array must be passed for the {@code configurerClasses} parameter. 177 * 178 * <p>If implementations do not support changing the version strategy, then it is suggested to 179 * pass {@link VersionStrategy#RELEASE} as the value for the {@code versionStrategy} parameter. 180 * 181 * @param min the inclusive minimum compiler version to use. 182 * @param max the inclusive maximum compiler version to use. 183 * @param configurerClasses the configurer classes to apply to each compiler. 184 * @param versionStrategy the version strategy to use. 185 */ 186 protected final void configure( 187 int min, 188 int max, 189 Class<? extends JctCompilerConfigurer<?>>[] configurerClasses, 190 VersionStrategy versionStrategy 191 ) { 192 min = Math.max(min, minSupportedVersion()); 193 max = Math.min(max, maxSupportedVersion()); 194 195 if (max < 8 || min < 8) { 196 throw new JctIllegalInputException("Cannot use a Java version less than Java 8"); 197 } 198 199 if (min > max) { 200 throw new JctIllegalInputException( 201 "Cannot set min version to a version higher than the max version" 202 ); 203 } 204 205 minVersion = min; 206 maxVersion = max; 207 208 this.configurerClasses = requireNonNullValues(configurerClasses, "configurerClasses"); 209 this.versionStrategy = requireNonNull(versionStrategy, "versionStrategy"); 210 } 211 212 /** 213 * Initialise a new compiler. 214 * 215 * @return the compiler object. 216 */ 217 protected abstract JctCompiler initializeNewCompiler(); 218 219 /** 220 * Get the minimum supported compiler version. 221 * 222 * @return the minimum supported compiler version. 223 * @since 1.0.0 224 */ 225 protected abstract int minSupportedVersion(); 226 227 /** 228 * Get the maximum supported compiler version. 229 * 230 * @return the minimum supported compiler version. 231 * @since 1.0.0 232 */ 233 protected abstract int maxSupportedVersion(); 234 235 private JctCompiler createCompilerForVersion(int version) { 236 var compiler = initializeNewCompiler(); 237 versionStrategy.configureCompiler(compiler, version); 238 return compiler; 239 } 240 241 private void applyConfigurers(JctCompiler compiler) { 242 var classes = requireNonNull(configurerClasses); 243 244 for (var configurerClass : classes) { 245 var configurer = initializeConfigurer(configurerClass); 246 247 try { 248 configurer.configure(compiler); 249 250 } catch (Exception ex) { 251 if (isTestAbortedException(ex)) { 252 throw (RuntimeException) ex; 253 } 254 255 throw new JctJunitConfigurerException( 256 "Failed to configure compiler with configurer class " + configurerClass.getName(), 257 ex 258 ); 259 } 260 } 261 } 262 263 private JctCompilerConfigurer<?> initializeConfigurer( 264 Class<? extends JctCompilerConfigurer<?>> configurerClass 265 ) { 266 Constructor<? extends JctCompilerConfigurer<?>> constructor; 267 268 try { 269 constructor = configurerClass.getDeclaredConstructor(); 270 271 } catch (NoSuchMethodException ex) { 272 throw new JctJunitConfigurerException( 273 "No no-args constructor was found for configurer class " + configurerClass.getName(), 274 ex 275 ); 276 } 277 278 try { 279 // Force-enable reflective access. If the user is using a SecurityManager for any reason then 280 // tough luck. JVM go bang. 281 // If the module is not open to JCT, then we will get an InaccessibleObjectException that 282 // we should wrap and rethrow. 283 constructor.setAccessible(true); 284 285 } catch (InaccessibleObjectException ex) { 286 287 throw new JctJunitConfigurerException( 288 "The constructor in " + configurerClass.getSimpleName() + " cannot be called from JCT." 289 + "\n" 290 + "This is likely because JPMS modules are in use and you have not granted " 291 + "permission for JCT to access your classes reflectively." 292 + "\n" 293 + "To fix this, add the following line into your module-info.java within the " 294 + "'module' block:" 295 + "\n\n" 296 + " opens " + constructor.getDeclaringClass().getPackageName() + " to " 297 + getClass().getModule().getName() + ";", 298 ex 299 ); 300 } 301 302 try { 303 return constructor.newInstance(); 304 305 } catch (ReflectiveOperationException ex) { 306 if (ex instanceof InvocationTargetException) { 307 var target = ((InvocationTargetException) ex).getTargetException(); 308 if (isTestAbortedException(target)) { 309 target.addSuppressed(ex); 310 throw (RuntimeException) target; 311 } 312 } 313 314 throw new JctJunitConfigurerException( 315 "Failed to initialise a new instance of configurer class " + configurerClass.getName(), 316 ex 317 ); 318 } 319 } 320 321 @SuppressWarnings("unchecked") 322 private static <T> Class<T>[] emptyArray() { 323 return (Class<T>[]) new Class[0]; 324 } 325 326 private static boolean isTestAbortedException(Throwable ex) { 327 // Use string-based reflective lookup to prevent needing the OpenTest4J modules loaded at 328 // runtime. We don't need to cover JUnit4 or TestNG here since this package specifically 329 // deals with JUnit5 only. 330 return ex.getClass().getName().equals("org.opentest4j.TestAbortedException"); 331 } 332}