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