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