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;
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
171   * {@link #minSupportedVersion}, whichever is greater. This means annotations can pass
172   * {@link Integer#MIN_VALUE} as a default value safely.
173   *
174   * <p>The maximum compiler version will be set to the {@code max} parameter, or
175   * {@link #maxSupportedVersion}, whichever is smaller. This means annotations can pass
176   * {@link Integer#MAX_VALUE} as a default value safely.
177   *
178   * <p>If implementations do not support specifying custom compiler configurers, then an empty
179   * array must be passed for the {@code configurerClasses} parameter.
180   *
181   * <p>If implementations do not support changing the version strategy, then it is suggested to
182   * pass {@link VersionStrategy#RELEASE} as the value for the {@code versionStrategy} parameter.
183   *
184   * @param min               the inclusive minimum compiler version to use.
185   * @param max               the inclusive maximum compiler version to use.
186   * @param configurerClasses the configurer classes to apply to each compiler.
187   * @param versionStrategy   the version strategy to use.
188   */
189  protected final void configure(
190      int min,
191      int max,
192      Class<? extends JctCompilerConfigurer<?>>[] configurerClasses,
193      VersionStrategy versionStrategy
194  ) {
195    min = Math.max(min, minSupportedVersion());
196    max = Math.min(max, maxSupportedVersion());
197
198    if (max < 8 || min < 8) {
199      throw new JctIllegalInputException("Cannot use a Java version less than Java 8");
200    }
201
202    if (min > max) {
203      throw new JctIllegalInputException(
204          "Cannot set min version to a version higher than the max version"
205      );
206    }
207
208    minVersion = min;
209    maxVersion = max;
210
211    this.configurerClasses = requireNonNullValues(configurerClasses, "configurerClasses");
212    this.versionStrategy = requireNonNull(versionStrategy, "versionStrategy");
213  }
214
215  /**
216   * Initialise a new compiler.
217   *
218   * @return the compiler object.
219   */
220  protected abstract JctCompiler initializeNewCompiler();
221
222  /**
223   * Get the minimum supported compiler version.
224   *
225   * @return the minimum supported compiler version.
226   * @since 1.0.0
227   */
228  @API(since = "1.0.0", status = Status.STABLE)
229  protected abstract int minSupportedVersion();
230
231  /**
232   * Get the maximum supported compiler version.
233   *
234   * @return the minimum supported compiler version.
235   * @since 1.0.0
236   */
237  @API(since = "1.0.0", status = Status.STABLE)
238  protected abstract int maxSupportedVersion();
239
240  private JctCompiler createCompilerForVersion(int version) {
241    var compiler = initializeNewCompiler();
242    versionStrategy.configureCompiler(compiler, version);
243    return compiler;
244  }
245
246  private void applyConfigurers(JctCompiler compiler) {
247    var classes = requireNonNull(configurerClasses);
248
249    for (var configurerClass : classes) {
250      var configurer = initializeConfigurer(configurerClass);
251
252      try {
253        configurer.configure(compiler);
254  
255      } catch (Exception ex) {
256        if (isTestAbortedException(ex)) {
257          throw (RuntimeException) 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
276    } catch (NoSuchMethodException ex) {
277      throw new JctJunitConfigurerException(
278          "No no-args constructor was found for configurer class " + configurerClass.getName(),
279          ex
280      );
281    }
282
283    try {
284      // Force-enable reflective access. If the user is using a SecurityManager for any reason then
285      // tough luck. JVM go bang.
286      // If the module is not open to JCT, then we will get an InaccessibleObjectException that
287      // we should wrap and rethrow.
288      constructor.setAccessible(true);
289
290    } catch (InaccessibleObjectException ex) {
291
292      throw new JctJunitConfigurerException(
293          "The constructor in " + configurerClass.getSimpleName() + " cannot be called from JCT."
294              + "\n"
295              + "This is likely because JPMS modules are in use and you have not granted "
296              + "permission for JCT to access your classes reflectively."
297              + "\n"
298              + "To fix this, add the following line into your module-info.java within the "
299              + "'module' block:"
300              + "\n\n"
301              + "    opens " + constructor.getDeclaringClass().getPackageName() + " to "
302              + getClass().getModule().getName() + ";",
303          ex
304      );
305    }
306
307    try {
308      return constructor.newInstance();
309
310    } catch (ReflectiveOperationException ex) {
311      if (ex instanceof InvocationTargetException) {
312        var target = ((InvocationTargetException) ex).getTargetException();
313        if (isTestAbortedException(target)) {
314          target.addSuppressed(ex);
315          throw (RuntimeException) target;
316        }
317      }
318
319      throw new JctJunitConfigurerException(
320          "Failed to initialise a new instance of configurer class " + configurerClass.getName(),
321          ex
322      );
323    }
324  }
325
326  @SuppressWarnings("unchecked")
327  private static <T> Class<T>[] emptyArray() {
328    return (Class<T>[]) new Class[0];
329  }
330
331  private static boolean isTestAbortedException(Throwable ex) {
332    // Use string-based reflective lookup to prevent needing the OpenTest4J modules loaded at
333    // runtime. We don't need to cover JUnit4 or TestNG here since this package specifically
334    // deals with JUnit5 only.
335    return ex.getClass().getName().equals("org.opentest4j.TestAbortedException");
336  }
337}