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