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.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
072        .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      throw failWithDiagnostics(
087          // If we have error diagnostics, add them to the error message to provide helpful
088          // debugging information. If we are treating warnings as errors, then we want to include
089          // those in this as well.
090          actual.isFailOnWarnings()
091              ? DiagnosticKindAssert.WARNING_AND_ERROR_DIAGNOSTIC_KINDS
092              : DiagnosticKindAssert.ERROR_DIAGNOSTIC_KINDS,
093          "Expected a successful compilation, but it failed."
094      );
095    }
096
097    return myself;
098  }
099
100  /**
101   * Assert that the compilation was successful and had no warnings.
102   *
103   * <p>If warnings were treated as errors by the compiler, then this is identical to calling
104   * {@link #isSuccessful()}.
105   *
106   * @return this assertion object.
107   * @throws AssertionError if the compilation was null, if the compilation was not successful, or
108   *                        if the compilation was successful but had one or more warning
109   *                        diagnostics.
110   */
111  public JctCompilationAssert isSuccessfulWithoutWarnings() {
112    isSuccessful();
113    diagnostics().hasNoErrorsOrWarnings();
114    return myself;
115  }
116
117  /**
118   * Assert that the compilation was a failure.
119   *
120   * @return this assertion object.
121   * @throws AssertionError if the compilation was null, or if the compilation succeeded.
122   */
123  public JctCompilationAssert isFailure() {
124    isNotNull();
125
126    // If we fail due to failOnWarnings, we expect the compiler itself to have failed the
127    // build because of this. If the compiler ignores this flag and succeeds, then this method will
128    // follow that behaviour and treat the compilation as a success.
129
130    if (actual.isSuccessful()) {
131      // If we have any warnings, we should show them in the error message as it might be useful
132      // to the user.
133      throw failWithDiagnostics(
134          DiagnosticKindAssert.WARNING_AND_ERROR_DIAGNOSTIC_KINDS,
135          "Expected compilation to fail, but it succeeded."
136      );
137    }
138
139    return myself;
140  }
141
142  /**
143   * Get assertions for diagnostics.
144   *
145   * @return assertions for the diagnostics.
146   * @throws AssertionError if the compilation was null.
147   */
148  public TraceDiagnosticListAssert diagnostics() {
149    isNotNull();
150    return new TraceDiagnosticListAssert(actual.getDiagnostics());
151  }
152
153  /**
154   * Perform assertions on the given package group, if it has been configured.
155   *
156   * <p>If not configured, this will return assertions on a {@code null} value instead.
157   *
158   * @param location the location to configure.
159   * @return the assertions to perform.
160   * @throws AssertionError           if the compilation was null, or no group for the location was
161   *                                  found.
162   * @throws IllegalArgumentException if the location was
163   *                                  {@link Location#isModuleOrientedLocation() module-oriented} or
164   *                                  {@link Location#isOutputLocation() an output location}.
165   * @throws NullPointerException     if the provided location object is null.
166   */
167  public PackageContainerGroupAssert packageGroup(Location location) {
168    requireNonNull(location, "location must not be null");
169
170    if (location.isModuleOrientedLocation()) {
171      throw new IllegalArgumentException(
172          "Expected location " + location + " to not be module-oriented"
173      );
174    }
175
176    if (location.isOutputLocation()) {
177      throw new IllegalArgumentException(
178          "Expected location " + location + " to not be an output location"
179      );
180    }
181
182    isNotNull();
183
184    var group = actual.getFileManager().getPackageContainerGroup(location);
185    assertLocationExists(location, group);
186    return new PackageContainerGroupAssert(group);
187  }
188
189  /**
190   * Perform assertions on the given module group, if it has been configured.
191   *
192   * <p>If not configured, the value being asserted on will be {@code null} in value.
193   *
194   * @param location the location to configure.
195   * @return the assertions to perform.
196   * @throws AssertionError           if the compilation was null, or no group for the location was
197   *                                  found.
198   * @throws IllegalArgumentException if the location is not
199   *                                  {@link Location#isModuleOrientedLocation() module-oriented}.
200   * @throws NullPointerException     if the provided location object is null.
201   */
202  public ModuleContainerGroupAssert moduleGroup(Location location) {
203    requireNonNull(location, "location must not be null");
204
205    if (!location.isModuleOrientedLocation()) {
206      throw new IllegalArgumentException(
207          "Expected location " + location.getName() + " to be module-oriented"
208      );
209    }
210
211    if (location.isOutputLocation()) {
212      throw new IllegalArgumentException(
213          "Expected location " + location.getName() + " to not be an output location"
214      );
215    }
216
217    isNotNull();
218
219    var group = actual.getFileManager().getModuleContainerGroup(location);
220    assertLocationExists(location, group);
221    return new ModuleContainerGroupAssert(group);
222  }
223
224  /**
225   * Perform assertions on the given output group, if it has been configured.
226   *
227   * <p>If not configured, the value being asserted on will be {@code null} in value.
228   *
229   * @param location the location to configure.
230   * @return the assertions to perform.
231   * @throws AssertionError           if the compilation was null, or no group for the location was
232   *                                  found.
233   * @throws IllegalArgumentException if the location is not
234   *                                  {@link Location#isOutputLocation() an output location}.
235   * @throws NullPointerException     if the provided location object is null.
236   */
237  public OutputContainerGroupAssert outputGroup(Location location) {
238    requireNonNull(location, "location must not be null");
239
240    if (!location.isOutputLocation()) {
241      throw new IllegalArgumentException(
242          "Expected location " + location.getName() + " to be an output location"
243      );
244    }
245
246    isNotNull();
247
248    var group = actual.getFileManager().getOutputContainerGroup(location);
249    assertLocationExists(location, group);
250    return new OutputContainerGroupAssert(group);
251  }
252
253  /**
254   * Get assertions on the path containing class package outputs, if it exists.
255   *
256   * <p>If not configured, the value being asserted on will be {@code null} in value.
257   *
258   * @return the assertions to perform on the class package outputs.
259   * @throws AssertionError if the compilation was null, or no group for the location was found.
260   */
261  @API(since = "0.6.4", status = Status.STABLE)
262  public PackageContainerGroupAssert classOutputPackages() {
263    return outputGroup(StandardLocation.CLASS_OUTPUT).packages();
264  }
265
266  /**
267   * Get assertions on the path containing class module outputs, if it exists.
268   *
269   * <p>If not configured, the value being asserted on will be {@code null} in value.
270   *
271   * @return the assertions to perform on the class module outputs.
272   * @throws AssertionError if the compilation was null, or no group for the location was found.
273   */
274  @API(since = "0.6.4", status = Status.STABLE)
275  public ModuleContainerGroupAssert classOutputModules() {
276    return outputGroup(StandardLocation.CLASS_OUTPUT).modules();
277  }
278
279  /**
280   * Get assertions on the path containing generated source package outputs, if it exists.
281   *
282   * <p>If not configured, the value being asserted on will be {@code null} in value.
283   *
284   * @return the assertions to perform on the source package outputs.
285   * @throws AssertionError if the compilation was null, or no group for the location was found.
286   */
287  @API(since = "0.6.4", status = Status.STABLE)
288  public PackageContainerGroupAssert sourceOutputPackages() {
289    return outputGroup(StandardLocation.SOURCE_OUTPUT).packages();
290  }
291
292  /**
293   * Get assertions on the path containing generated source module outputs, if it exists.
294   *
295   * <p>If not configured, the value being asserted on will be {@code null} in value.
296   *
297   * @return the assertions to perform on the source module outputs.
298   * @throws AssertionError if the compilation was null, or no group for the location was found.
299   */
300  @API(since = "0.6.4", status = Status.STABLE)
301  public ModuleContainerGroupAssert sourceOutputModules() {
302    return outputGroup(StandardLocation.SOURCE_OUTPUT).modules();
303  }
304
305  /**
306   * Get assertions on the path containing the class path, if it exists.
307   *
308   * <p>If not configured, the value being asserted on will be {@code null} in value.
309   *
310   * @return the assertions to perform on the class path.
311   * @throws AssertionError if the compilation was null, or no group for the location was found.
312   */
313  @API(since = "0.6.4", status = Status.STABLE)
314  public PackageContainerGroupAssert classPathPackages() {
315    return packageGroup(StandardLocation.CLASS_PATH);
316  }
317
318  /**
319   * Get assertions on the path containing the source path, if it exists.
320   *
321   * <p>If not configured, the value being asserted on will be {@code null} in value.
322   *
323   * @return the assertions to perform on the source path.
324   * @throws AssertionError if the compilation was null, or no group for the location was found.
325   */
326  @API(since = "0.6.4", status = Status.STABLE)
327  public PackageContainerGroupAssert sourcePathPackages() {
328    return packageGroup(StandardLocation.SOURCE_PATH);
329  }
330
331  /**
332   * Get assertions on the path containing the source path, if it exists.
333   *
334   * <p>If not configured, the value being asserted on will be {@code null} in value.
335   *
336   * @return the assertions to perform on the source path.
337   * @throws AssertionError if the compilation was null, or no group for the location was found.
338   */
339  @API(since = "0.6.4", status = Status.STABLE)
340  public ModuleContainerGroupAssert moduleSourcePathModules() {
341    return moduleGroup(StandardLocation.MODULE_SOURCE_PATH);
342  }
343
344  /**
345   * Get assertions on the path containing the module path, if it exists.
346   *
347   * <p>If not configured, the value being asserted on will be {@code null} in value.
348   *
349   * @return the assertions to perform on the module path.
350   * @throws AssertionError if the compilation was null, or no group for the location was found.
351   */
352  @API(since = "0.6.4", status = Status.STABLE)
353  public ModuleContainerGroupAssert modulePathModules() {
354    return moduleGroup(StandardLocation.MODULE_PATH);
355  }
356
357
358  private AssertionError failWithDiagnostics(
359      Collection<? extends Kind> kindsToDisplay,
360      String message
361  ) {
362    var diagnostics = actual
363        .getDiagnostics()
364        .stream()
365        .filter(diagnostic -> kindsToDisplay.contains(diagnostic.getKind()))
366        .collect(toUnmodifiableList());
367
368    return failure(
369        "%s\n\nDiagnostics:\n%s",
370        message,
371        TraceDiagnosticListRepresentation.getInstance().toStringOf(diagnostics)
372    );
373  }
374
375  private void assertLocationExists(Location location, @Nullable ContainerGroup group) {
376    if (group == null) {
377      throw failure("No location named %s exists", location.getName());
378    }
379  }
380}