001/* 002 * Copyright (C) 2022 - 2023, 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.JctJunitConfigurerException; 024import java.lang.reflect.Constructor; 025import java.lang.reflect.InaccessibleObjectException; 026import java.lang.reflect.InvocationTargetException; 027import java.util.stream.IntStream; 028import java.util.stream.Stream; 029import org.apiguardian.api.API; 030import org.apiguardian.api.API.Status; 031import org.junit.jupiter.api.extension.ExtensionContext; 032import org.junit.jupiter.params.provider.Arguments; 033import org.junit.jupiter.params.provider.ArgumentsProvider; 034import org.junit.jupiter.params.support.AnnotationConsumer; 035import org.opentest4j.TestAbortedException; 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 {@link #minSupportedVersion}, 171 * whichever is greater. This means annotations can pass {@link Integer#MIN_VALUE} as a default value safely. 172 * 173 * <p>The maximum compiler version will be set to the {@code max} parameter, or {@link #maxSupportedVersion}, 174 * whichever is smaller. This means annotations can pass {@link Integer#MAX_VALUE} as a default value safely. 175 * 176 * <p>If implementations do not support specifying custom compiler configurers, then an empty array must be 177 * passed for the {@code configurerClasses} parameter. 178 * 179 * <p>If implementations do not support changing the version strategy, then it is suggested to pass 180 * {@link VersionStrategy#RELEASE} as the value for the {@code versionStrategy} parameter. 181 * 182 * @param min the inclusive minimum compiler version to use. 183 * @param max the inclusive maximum compiler version to use. 184 * @param configurerClasses the configurer classes to apply to each compiler. 185 * @param versionStrategy the version strategy to use. 186 */ 187 protected final void configure( 188 int min, 189 int max, 190 Class<? extends JctCompilerConfigurer<?>>[] configurerClasses, 191 VersionStrategy versionStrategy 192 ) { 193 min = Math.max(min, minSupportedVersion()); 194 max = Math.min(max, maxSupportedVersion()); 195 196 if (max < 8 || min < 8) { 197 throw new IllegalArgumentException("Cannot use a Java version less than Java 8"); 198 } 199 200 if (min > max) { 201 throw new IllegalArgumentException( 202 "Cannot set min version to a version higher than the max version" 203 ); 204 } 205 206 minVersion = min; 207 maxVersion = max; 208 209 this.configurerClasses = requireNonNullValues(configurerClasses, "configurerClasses"); 210 this.versionStrategy = requireNonNull(versionStrategy, "versionStrategy"); 211 } 212 213 /** 214 * Initialise a new compiler. 215 * 216 * @return the compiler object. 217 */ 218 protected abstract JctCompiler initializeNewCompiler(); 219 220 /** 221 * Get the minimum supported compiler version. 222 * 223 * @return the minimum supported compiler version. 224 * @since 1.0.0 225 */ 226 @API(since = "1.0.0", status = Status.STABLE) 227 protected abstract int minSupportedVersion(); 228 229 /** 230 * Get the maximum supported compiler version. 231 * 232 * @return the minimum supported compiler version. 233 * @since 1.0.0 234 */ 235 @API(since = "1.0.0", status = Status.STABLE) 236 protected abstract int maxSupportedVersion(); 237 238 private JctCompiler createCompilerForVersion(int version) { 239 var compiler = initializeNewCompiler(); 240 versionStrategy.configureCompiler(compiler, version); 241 return compiler; 242 } 243 244 private void applyConfigurers(JctCompiler compiler) { 245 var classes = requireNonNull(configurerClasses); 246 247 for (var configurerClass : classes) { 248 var configurer = initializeConfigurer(configurerClass); 249 250 try { 251 configurer.configure(compiler); 252 } catch (Exception ex) { 253 if (isTestAbortedException(ex)) { 254 throw (TestAbortedException) ex; 255 } 256 257 throw new JctJunitConfigurerException( 258 "Failed to configure compiler with configurer class " + configurerClass.getName(), 259 ex 260 ); 261 } 262 } 263 } 264 265 private JctCompilerConfigurer<?> initializeConfigurer( 266 Class<? extends JctCompilerConfigurer<?>> configurerClass 267 ) { 268 Constructor<? extends JctCompilerConfigurer<?>> constructor; 269 270 try { 271 constructor = configurerClass.getDeclaredConstructor(); 272 } catch (NoSuchMethodException ex) { 273 throw new JctJunitConfigurerException( 274 "No no-args constructor was found for configurer class " + configurerClass.getName(), 275 ex 276 ); 277 } 278 279 try { 280 // Force-enable reflective access. If the user is using a SecurityManager for any reason then 281 // tough luck. JVM go bang. 282 // If the module is not open to JCT, then we will get an InaccessibleObjectException that 283 // we should wrap and rethrow. 284 constructor.setAccessible(true); 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 } catch (ReflectiveOperationException ex) { 305 if (ex instanceof InvocationTargetException) { 306 var target = ((InvocationTargetException) ex).getTargetException(); 307 if (isTestAbortedException(target)) { 308 // XXX: Creates a circular reference, do we care? JVM should handle this for us. 309 target.addSuppressed(ex); 310 throw (TestAbortedException) 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 modules loaded at runtime. 328 // We don't actually need to cover junit4 or testng here since this package specifically deals 329 // with JUnit5 only. 330 return ex.getClass().getName().equals("org.opentest4j.TestAbortedException"); 331 } 332}