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