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