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&lt;? extends JctSimpleCompilerConfigurer&gt;[] 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&lt;MyCompilerTest&gt; {
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}