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