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