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