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