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