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