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