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.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  public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
155    return IntStream
156        .rangeClosed(minVersion, maxVersion)
157        .mapToObj(this::createCompilerForVersion)
158        .peek(this::applyConfigurers)
159        .map(Arguments::of);
160  }
161
162  /**
163   * Configure this provider with parameters from annotations.
164   *
165   * <p>This is expected to be called from an implementation of {@link AnnotationConsumer}.
166   *
167   * <p>The minimum compiler version will be set to the {@code min} parameter, or
168   * {@link #minSupportedVersion}, whichever is greater. This means annotations can pass
169   * {@link Integer#MIN_VALUE} as a default value safely.
170   *
171   * <p>The maximum compiler version will be set to the {@code max} parameter, or
172   * {@link #maxSupportedVersion}, whichever is smaller. This means annotations can pass
173   * {@link Integer#MAX_VALUE} as a default value safely.
174   *
175   * <p>If implementations do not support specifying custom compiler configurers, then an empty
176   * array must be passed for the {@code configurerClasses} parameter.
177   *
178   * <p>If implementations do not support changing the version strategy, then it is suggested to
179   * pass {@link VersionStrategy#RELEASE} as the value for the {@code versionStrategy} parameter.
180   *
181   * @param min               the inclusive minimum compiler version to use.
182   * @param max               the inclusive maximum compiler version to use.
183   * @param configurerClasses the configurer classes to apply to each compiler.
184   * @param versionStrategy   the version strategy to use.
185   */
186  protected final void configure(
187      int min,
188      int max,
189      Class<? extends JctCompilerConfigurer<?>>[] configurerClasses,
190      VersionStrategy versionStrategy
191  ) {
192    min = Math.max(min, minSupportedVersion());
193    max = Math.min(max, maxSupportedVersion());
194
195    if (max < 8 || min < 8) {
196      throw new JctIllegalInputException("Cannot use a Java version less than Java 8");
197    }
198
199    if (min > max) {
200      throw new JctIllegalInputException(
201          "Cannot set min version to a version higher than the max version"
202      );
203    }
204
205    minVersion = min;
206    maxVersion = max;
207
208    this.configurerClasses = requireNonNullValues(configurerClasses, "configurerClasses");
209    this.versionStrategy = requireNonNull(versionStrategy, "versionStrategy");
210  }
211
212  /**
213   * Initialise a new compiler.
214   *
215   * @return the compiler object.
216   */
217  protected abstract JctCompiler initializeNewCompiler();
218
219  /**
220   * Get the minimum supported compiler version.
221   *
222   * @return the minimum supported compiler version.
223   * @since 1.0.0
224   */
225  protected abstract int minSupportedVersion();
226
227  /**
228   * Get the maximum supported compiler version.
229   *
230   * @return the minimum supported compiler version.
231   * @since 1.0.0
232   */
233  protected abstract int maxSupportedVersion();
234
235  private JctCompiler createCompilerForVersion(int version) {
236    var compiler = initializeNewCompiler();
237    versionStrategy.configureCompiler(compiler, version);
238    return compiler;
239  }
240
241  private void applyConfigurers(JctCompiler compiler) {
242    var classes = requireNonNull(configurerClasses);
243
244    for (var configurerClass : classes) {
245      var configurer = initializeConfigurer(configurerClass);
246
247      try {
248        configurer.configure(compiler);
249  
250      } catch (Exception ex) {
251        if (isTestAbortedException(ex)) {
252          throw (RuntimeException) ex;
253        }
254
255        throw new JctJunitConfigurerException(
256            "Failed to configure compiler with configurer class " + configurerClass.getName(),
257            ex
258        );
259      }
260    }
261  }
262
263  private JctCompilerConfigurer<?> initializeConfigurer(
264      Class<? extends JctCompilerConfigurer<?>> configurerClass
265  ) {
266    Constructor<? extends JctCompilerConfigurer<?>> constructor;
267
268    try {
269      constructor = configurerClass.getDeclaredConstructor();
270
271    } catch (NoSuchMethodException ex) {
272      throw new JctJunitConfigurerException(
273          "No no-args constructor was found for configurer class " + configurerClass.getName(),
274          ex
275      );
276    }
277
278    try {
279      // Force-enable reflective access. If the user is using a SecurityManager for any reason then
280      // tough luck. JVM go bang.
281      // If the module is not open to JCT, then we will get an InaccessibleObjectException that
282      // we should wrap and rethrow.
283      constructor.setAccessible(true);
284
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
305    } catch (ReflectiveOperationException ex) {
306      if (ex instanceof InvocationTargetException) {
307        var target = ((InvocationTargetException) ex).getTargetException();
308        if (isTestAbortedException(target)) {
309          target.addSuppressed(ex);
310          throw (RuntimeException) 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 OpenTest4J modules loaded at
328    // runtime. We don't need to cover JUnit4 or TestNG here since this package specifically
329    // deals with JUnit5 only.
330    return ex.getClass().getName().equals("org.opentest4j.TestAbortedException");
331  }
332}