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