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.assertions;
017
018import static java.util.Objects.requireNonNull;
019import static java.util.stream.Collectors.toUnmodifiableList;
020
021import io.github.ascopes.jct.compilers.JctCompilation;
022import io.github.ascopes.jct.repr.TraceDiagnosticListRepresentation;
023import io.github.ascopes.jct.utils.StringUtils;
024import java.util.Collection;
025import java.util.List;
026import javax.tools.Diagnostic.Kind;
027import javax.tools.JavaFileManager.Location;
028import javax.tools.StandardLocation;
029import org.apiguardian.api.API;
030import org.apiguardian.api.API.Status;
031import org.assertj.core.api.AbstractAssert;
032import org.assertj.core.api.AbstractListAssert;
033import org.assertj.core.api.AbstractStringAssert;
034import org.assertj.core.api.AssertFactory;
035import org.assertj.core.api.Assertions;
036import org.assertj.core.api.FactoryBasedNavigableListAssert;
037import org.assertj.core.api.StringAssert;
038import org.jspecify.annotations.Nullable;
039
040/**
041 * Assertions that apply to a {@link JctCompilation}.
042 *
043 * @author Ashley Scopes
044 * @since 0.0.1
045 */
046@API(since = "0.0.1", status = Status.STABLE)
047public final class JctCompilationAssert extends
048    AbstractAssert<JctCompilationAssert, JctCompilation> {
049
050  /**
051   * Initialize this compilation assertion.
052   *
053   * @param value the value to assert on.
054   */
055  public JctCompilationAssert(@Nullable JctCompilation value) {
056    super(value, JctCompilationAssert.class);
057  }
058
059  /**
060   * Assert that the arguments passed to the compiler were the expected values.
061   *
062   * @return a list assertion object to perform assertions on the arguments with.
063   * @throws AssertionError if the compilation was null.
064   */
065  public AbstractListAssert<?, List<? extends String>, String, ? extends AbstractStringAssert<?>> arguments() {
066    isNotNull();
067
068    var arguments = actual.getArguments();
069
070    // TODO(ascopes): find a way to use Assertions::assertThat here instead of passing the
071    //   StringAssert constructor around.
072    return FactoryBasedNavigableListAssert.assertThat(arguments, StringAssert::new)
073        .as("Compiler arguments %s", StringUtils.quotedIterable(arguments));
074  }
075
076  /**
077   * Assert that the compilation was successful.
078   *
079   * @return this assertion object.
080   * @throws AssertionError if the compilation was null, or if the compilation was not successful.
081   */
082  public JctCompilationAssert isSuccessful() {
083    isNotNull();
084
085    if (actual.isFailure()) {
086      // If we have error diagnostics, add them to the error message to provide helpful debugging
087      // information. If we are treating warnings as errors, then we want to include those in this
088      // as well.
089      var diagnosticKinds = actual.isFailOnWarnings()
090          ? DiagnosticKindAssert.WARNING_AND_ERROR_DIAGNOSTIC_KINDS
091          : DiagnosticKindAssert.ERROR_DIAGNOSTIC_KINDS;
092
093      failWithDiagnostics(diagnosticKinds, "Expected a successful compilation, but it failed.");
094    }
095
096    return myself;
097  }
098
099  /**
100   * Assert that the compilation was successful and had no warnings.
101   *
102   * <p>If warnings were treated as errors by the compiler, then this is identical to calling
103   * {@link #isSuccessful()}.
104   *
105   * @return this assertion object.
106   * @throws AssertionError if the compilation was null, if the compilation was not successful, or
107   *                        if the compilation was successful but had one or more warning
108   *                        diagnostics.
109   */
110  public JctCompilationAssert isSuccessfulWithoutWarnings() {
111    isSuccessful();
112    diagnostics().hasNoErrorsOrWarnings();
113    return myself;
114  }
115
116  /**
117   * Assert that the compilation was a failure.
118   *
119   * @return this assertion object.
120   * @throws AssertionError if the compilation was null, or if the compilation succeeded.
121   */
122  public JctCompilationAssert isFailure() {
123    isNotNull();
124
125    // If we fail due to failOnWarnings, we expect the compiler itself to have failed the
126    // build because of this. If the compiler ignores this flag and succeeds, then this method will
127    // follow that behaviour and treat the compilation as a success.
128
129    if (actual.isSuccessful()) {
130      // If we have any warnings, we should show them in the error message as it might be useful
131      // to the user.
132      failWithDiagnostics(
133          DiagnosticKindAssert.WARNING_AND_ERROR_DIAGNOSTIC_KINDS,
134          "Expected compilation to fail, but it succeeded."
135      );
136    }
137
138    return myself;
139  }
140
141  /**
142   * Get assertions for diagnostics.
143   *
144   * @return assertions for the diagnostics.
145   * @throws AssertionError if the compilation was null.
146   */
147  public TraceDiagnosticListAssert diagnostics() {
148    isNotNull();
149    return new TraceDiagnosticListAssert(actual.getDiagnostics());
150  }
151
152  /**
153   * Perform assertions on the given package group, if it has been configured.
154   *
155   * <p>If not configured, this will return assertions on a {@code null} value instead.
156   *
157   * @param location the location to configure.
158   * @return the assertions to perform.
159   * @throws AssertionError           if the compilation was null.
160   * @throws IllegalArgumentException if the location was
161   *                                  {@link Location#isModuleOrientedLocation() module-oriented}.
162   * @throws NullPointerException     if the provided location object is null.
163   */
164  public PackageContainerGroupAssert packageGroup(Location location) {
165    requireNonNull(location, "location must not be null");
166
167    if (location.isModuleOrientedLocation()) {
168      throw new IllegalArgumentException(
169          "Expected location " + location + " to not be module-oriented"
170      );
171    }
172
173    isNotNull();
174
175    return new PackageContainerGroupAssert(
176        actual.getFileManager().getPackageContainerGroup(location)
177    );
178  }
179
180  /**
181   * Perform assertions on the given module group, if it has been configured.
182   *
183   * <p>If not configured, the value being asserted on will be {@code null} in value.
184   *
185   * @param location the location to configure.
186   * @return the assertions to perform.
187   * @throws AssertionError           if the compilation was null.
188   * @throws IllegalArgumentException if the location is not
189   *                                  {@link Location#isModuleOrientedLocation() module-oriented}.
190   * @throws NullPointerException     if the provided location object is null.
191   */
192  public ModuleContainerGroupAssert moduleGroup(Location location) {
193    requireNonNull(location, "location must not be null");
194
195    if (!location.isModuleOrientedLocation()) {
196      throw new IllegalArgumentException(
197          "Expected location " + location + " to be module-oriented"
198      );
199    }
200
201    isNotNull();
202
203    return new ModuleContainerGroupAssert(
204        actual.getFileManager().getModuleContainerGroup(location)
205    );
206  }
207
208  /**
209   * Perform assertions on the given output group, if it has been configured.
210   *
211   * <p>If not configured, the value being asserted on will be {@code null} in value.
212   *
213   * @param location the location to configure.
214   * @return the assertions to perform.
215   * @throws AssertionError           if the compilation was null.
216   * @throws IllegalArgumentException if the location is not
217   *                                  {@link Location#isOutputLocation() an output location}.
218   * @throws NullPointerException     if the provided location object is null.
219   */
220  public OutputContainerGroupAssert outputGroup(Location location) {
221    requireNonNull(location, "location must not be null");
222
223    if (!location.isOutputLocation()) {
224      throw new IllegalArgumentException(
225          "Expected location " + location + " to be an output location"
226      );
227    }
228
229    isNotNull();
230
231    return new OutputContainerGroupAssert(
232        actual.getFileManager().getOutputContainerGroup(location)
233    );
234  }
235
236  /**
237   * Get assertions on the path containing class outputs, if it exists.
238   *
239   * <p>If not configured, the value being asserted on will be {@code null} in value.
240   *
241   * @return the assertions to perform on the class outputs.
242   * @throws AssertionError if the compilation is null.
243   */
244  public OutputContainerGroupAssert classOutput() {
245    return outputGroup(StandardLocation.CLASS_OUTPUT);
246  }
247
248  /**
249   * Get assertions on the path containing generated source outputs, if it exists.
250   *
251   * <p>If not configured, the value being asserted on will be {@code null} in value.
252   *
253   * @return the assertions to perform on the source outputs.
254   * @throws AssertionError if the compilation is null.
255   */
256  public OutputContainerGroupAssert sourceOutput() {
257    return outputGroup(StandardLocation.SOURCE_OUTPUT);
258  }
259
260  /**
261   * Get assertions on the path containing header outputs, if it exists.
262   *
263   * <p>If not configured, the value being asserted on will be {@code null} in value.
264   *
265   * @return the assertions to perform on the header outputs.
266   * @throws AssertionError if the compilation is null.
267   */
268  public OutputContainerGroupAssert generatedHeaders() {
269    return outputGroup(StandardLocation.NATIVE_HEADER_OUTPUT);
270  }
271
272  /**
273   * Get assertions on the path containing the class path, if it exists.
274   *
275   * <p>If not configured, the value being asserted on will be {@code null} in value.
276   *
277   * @return the assertions to perform on the class path.
278   * @throws AssertionError if the compilation is null.
279   */
280  public PackageContainerGroupAssert classPath() {
281    return packageGroup(StandardLocation.CLASS_PATH);
282  }
283
284  /**
285   * Get assertions on the path containing the source path, if it exists.
286   *
287   * <p>If not configured, the value being asserted on will be {@code null} in value.
288   *
289   * @return the assertions to perform on the source path.
290   * @throws AssertionError if the compilation is null.
291   */
292  public PackageContainerGroupAssert sourcePath() {
293    return packageGroup(StandardLocation.SOURCE_PATH);
294  }
295
296  /**
297   * Get assertions on the path containing the source path, if it exists.
298   *
299   * <p>If not configured, the value being asserted on will be {@code null} in value.
300   *
301   * @return the assertions to perform on the source path.
302   * @throws AssertionError if the compilation is null.
303   */
304  public ModuleContainerGroupAssert moduleSourcePath() {
305    return moduleGroup(StandardLocation.MODULE_SOURCE_PATH);
306  }
307
308  /**
309   * Get assertions on the path containing the module path, if it exists.
310   *
311   * <p>If not configured, the value being asserted on will be {@code null} in value.
312   *
313   * @return the assertions to perform on the module path.
314   * @throws AssertionError if the compilation is null.
315   */
316  public ModuleContainerGroupAssert modulePath() {
317    return moduleGroup(StandardLocation.MODULE_PATH);
318  }
319
320  private void failWithDiagnostics(
321      Collection<? extends Kind> kindsToDisplay,
322      String message,
323      Object... args
324  ) {
325    var diagnostics = actual
326        .getDiagnostics()
327        .stream()
328        .filter(diagnostic -> kindsToDisplay.contains(diagnostic.getKind()))
329        .collect(toUnmodifiableList());
330
331    if (diagnostics.isEmpty()) {
332      failWithMessage(message, args);
333    } else {
334      var fullMessage = String.join(
335          "\n",
336          args.length > 0
337              ? String.format(message, args)
338              : message,
339          "",
340          "Diagnostics:",
341          TraceDiagnosticListRepresentation.getInstance().toStringOf(diagnostics)
342      );
343
344      failWithMessage(fullMessage);
345    }
346  }
347}