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   * @deprecated this method is rarely needed, so is being removed in v1.0.0. Use
268   * {@link #outputGroup(Location)}, passing {@link StandardLocation#NATIVE_HEADER_OUTPUT} as the
269   * first parameter instead.
270   */
271  @Deprecated(since = "0.6.0", forRemoval = true)
272  public OutputContainerGroupAssert generatedHeaders() {
273    return outputGroup(StandardLocation.NATIVE_HEADER_OUTPUT);
274  }
275
276  /**
277   * Get assertions on the path containing the class path, if it exists.
278   *
279   * <p>If not configured, the value being asserted on will be {@code null} in value.
280   *
281   * @return the assertions to perform on the class path.
282   * @throws AssertionError if the compilation is null.
283   */
284  public PackageContainerGroupAssert classPath() {
285    return packageGroup(StandardLocation.CLASS_PATH);
286  }
287
288  /**
289   * Get assertions on the path containing the source path, if it exists.
290   *
291   * <p>If not configured, the value being asserted on will be {@code null} in value.
292   *
293   * @return the assertions to perform on the source path.
294   * @throws AssertionError if the compilation is null.
295   */
296  public PackageContainerGroupAssert sourcePath() {
297    return packageGroup(StandardLocation.SOURCE_PATH);
298  }
299
300  /**
301   * Get assertions on the path containing the source path, if it exists.
302   *
303   * <p>If not configured, the value being asserted on will be {@code null} in value.
304   *
305   * @return the assertions to perform on the source path.
306   * @throws AssertionError if the compilation is null.
307   */
308  public ModuleContainerGroupAssert moduleSourcePath() {
309    return moduleGroup(StandardLocation.MODULE_SOURCE_PATH);
310  }
311
312  /**
313   * Get assertions on the path containing the module path, if it exists.
314   *
315   * <p>If not configured, the value being asserted on will be {@code null} in value.
316   *
317   * @return the assertions to perform on the module path.
318   * @throws AssertionError if the compilation is null.
319   */
320  public ModuleContainerGroupAssert modulePath() {
321    return moduleGroup(StandardLocation.MODULE_PATH);
322  }
323
324  private void failWithDiagnostics(
325      Collection<? extends Kind> kindsToDisplay,
326      String message,
327      Object... args
328  ) {
329    var diagnostics = actual
330        .getDiagnostics()
331        .stream()
332        .filter(diagnostic -> kindsToDisplay.contains(diagnostic.getKind()))
333        .collect(toUnmodifiableList());
334
335    if (diagnostics.isEmpty()) {
336      failWithMessage(message, args);
337    } else {
338      var fullMessage = String.join(
339          "\n",
340          args.length > 0
341              ? String.format(message, args)
342              : message,
343          "",
344          "Diagnostics:",
345          TraceDiagnosticListRepresentation.getInstance().toStringOf(diagnostics)
346      );
347
348      failWithMessage(fullMessage);
349    }
350  }
351}