diff --git a/gradle.properties b/gradle.properties index 5d0c627d65..e328419de3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -32,3 +32,4 @@ VER_JGIT=6.7.0.202309050840-r VER_JUNIT=5.10.2 VER_ASSERTJ=3.26.0 VER_MOCKITO=5.12.0 +VER_SELFIE=2.2.0 diff --git a/lib/src/main/java/com/diffplug/spotless/DirtyState.java b/lib/src/main/java/com/diffplug/spotless/DirtyState.java index 39d5da47d6..a4f737a701 100644 --- a/lib/src/main/java/com/diffplug/spotless/DirtyState.java +++ b/lib/src/main/java/com/diffplug/spotless/DirtyState.java @@ -46,7 +46,7 @@ public boolean didNotConverge() { return this == didNotConverge; } - private byte[] canonicalBytes() { + byte[] canonicalBytes() { if (canonicalBytes == null) { throw new IllegalStateException("First make sure that {@code !isClean()} and {@code !didNotConverge()}"); } @@ -74,7 +74,14 @@ public static DirtyState of(Formatter formatter, File file) throws IOException { } public static DirtyState of(Formatter formatter, File file, byte[] rawBytes) { - String raw = new String(rawBytes, formatter.getEncoding()); + return of(formatter, file, rawBytes, new String(rawBytes, formatter.getEncoding())); + } + + public static DirtyState of(Formatter formatter, File file, byte[] rawBytes, String raw) { + return of(formatter, file, rawBytes, raw, new ValuePerStep<>(formatter)); + } + + public static DirtyState of(Formatter formatter, File file, byte[] rawBytes, String raw, ValuePerStep exceptionPerStep) { // check that all characters were encodable String encodingError = EncodingErrorMsg.msg(raw, rawBytes, formatter.getEncoding()); if (encodingError != null) { @@ -84,7 +91,7 @@ public static DirtyState of(Formatter formatter, File file, byte[] rawBytes) { String rawUnix = LineEnding.toUnix(raw); // enforce the format - String formattedUnix = formatter.compute(rawUnix, file); + String formattedUnix = formatter.computeWithLint(rawUnix, file, exceptionPerStep); // convert the line endings if necessary String formatted = formatter.computeLineEndings(formattedUnix, file); @@ -95,13 +102,13 @@ public static DirtyState of(Formatter formatter, File file, byte[] rawBytes) { } // F(input) != input, so we'll do a padded check - String doubleFormattedUnix = formatter.compute(formattedUnix, file); + String doubleFormattedUnix = formatter.computeWithLint(formattedUnix, file, exceptionPerStep); if (doubleFormattedUnix.equals(formattedUnix)) { // most dirty files are idempotent-dirty, so this is a quick-short circuit for that common case return new DirtyState(formattedBytes); } - PaddedCell cell = PaddedCell.check(formatter, file, rawUnix); + PaddedCell cell = PaddedCell.check(formatter, file, rawUnix, exceptionPerStep); if (!cell.isResolvable()) { return didNotConverge; } diff --git a/lib/src/main/java/com/diffplug/spotless/FilterByContentPatternFormatterStep.java b/lib/src/main/java/com/diffplug/spotless/FilterByContentPatternFormatterStep.java index 4cc336e101..bcc444ad57 100644 --- a/lib/src/main/java/com/diffplug/spotless/FilterByContentPatternFormatterStep.java +++ b/lib/src/main/java/com/diffplug/spotless/FilterByContentPatternFormatterStep.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2023 DiffPlug + * Copyright 2016-2024 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,8 +16,8 @@ package com.diffplug.spotless; import java.io.File; +import java.util.List; import java.util.Objects; -import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.annotation.Nullable; @@ -36,14 +36,24 @@ final class FilterByContentPatternFormatterStep extends DelegateFormatterStep { public @Nullable String format(String raw, File file) throws Exception { Objects.requireNonNull(raw, "raw"); Objects.requireNonNull(file, "file"); - Matcher matcher = contentPattern.matcher(raw); - if (matcher.find() == (onMatch == OnMatch.INCLUDE)) { + if (contentPattern.matcher(raw).find() == (onMatch == OnMatch.INCLUDE)) { return delegateStep.format(raw, file); } else { return raw; } } + @Override + public List lint(String raw, File file) throws Exception { + Objects.requireNonNull(raw, "raw"); + Objects.requireNonNull(file, "file"); + if (contentPattern.matcher(raw).find() == (onMatch == OnMatch.INCLUDE)) { + return delegateStep.lint(raw, file); + } else { + return List.of(); + } + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/lib/src/main/java/com/diffplug/spotless/FilterByFileFormatterStep.java b/lib/src/main/java/com/diffplug/spotless/FilterByFileFormatterStep.java index 04a06a4673..bc5ddf6053 100644 --- a/lib/src/main/java/com/diffplug/spotless/FilterByFileFormatterStep.java +++ b/lib/src/main/java/com/diffplug/spotless/FilterByFileFormatterStep.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2022 DiffPlug + * Copyright 2016-2024 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ package com.diffplug.spotless; import java.io.File; +import java.util.List; import java.util.Objects; import javax.annotation.Nullable; @@ -39,6 +40,17 @@ final class FilterByFileFormatterStep extends DelegateFormatterStep { } } + @Override + public List lint(String content, File file) throws Exception { + Objects.requireNonNull(content, "content"); + Objects.requireNonNull(file, "file"); + if (filter.accept(file)) { + return delegateStep.lint(content, file); + } else { + return List.of(); + } + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/lib/src/main/java/com/diffplug/spotless/FormatExceptionPolicy.java b/lib/src/main/java/com/diffplug/spotless/FormatExceptionPolicy.java deleted file mode 100644 index 50f49e41db..0000000000 --- a/lib/src/main/java/com/diffplug/spotless/FormatExceptionPolicy.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2016-2023 DiffPlug - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.diffplug.spotless; - -import java.io.Serializable; - -/** A policy for handling exceptions in the format. */ -public interface FormatExceptionPolicy extends Serializable, NoLambda { - /** Called for every error in the formatter. */ - public void handleError(Throwable e, FormatterStep step, String relativePath); - - /** - * Returns a byte array representation of everything inside this {@code FormatExceptionPolicy}. - *

- * The main purpose of this method is to ensure one can't instantiate this class with lambda - * expressions, which are notoriously difficult to serialize and deserialize properly. - */ - public byte[] toBytes(); - - /** - * A policy which rethrows subclasses of {@code Error} and logs other kinds of Exception. - */ - public static FormatExceptionPolicy failOnlyOnError() { - return new FormatExceptionPolicyLegacy(); - } -} diff --git a/lib/src/main/java/com/diffplug/spotless/FormatExceptionPolicyStrict.java b/lib/src/main/java/com/diffplug/spotless/FormatExceptionPolicyStrict.java index 6fd8371928..9bafc946ef 100644 --- a/lib/src/main/java/com/diffplug/spotless/FormatExceptionPolicyStrict.java +++ b/lib/src/main/java/com/diffplug/spotless/FormatExceptionPolicyStrict.java @@ -1,5 +1,5 @@ /* - * Copyright 2016 DiffPlug + * Copyright 2016-2024 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,7 +23,7 @@ * A policy for handling exceptions in the format. Any exceptions will * halt the build except for a specifically excluded path or step. */ -public class FormatExceptionPolicyStrict extends NoLambda.EqualityBasedOnSerialization implements FormatExceptionPolicy { +public class FormatExceptionPolicyStrict extends NoLambda.EqualityBasedOnSerialization { private static final long serialVersionUID = 1L; private final Set excludeSteps = new TreeSet<>(); @@ -39,18 +39,17 @@ public void excludePath(String relativePath) { excludePaths.add(Objects.requireNonNull(relativePath)); } - @Override public void handleError(Throwable e, FormatterStep step, String relativePath) { Objects.requireNonNull(e, "e"); Objects.requireNonNull(step, "step"); Objects.requireNonNull(relativePath, "relativePath"); if (excludeSteps.contains(step.getName())) { - FormatExceptionPolicyLegacy.warning(e, step, relativePath); + LintPolicy.warning(e, step, relativePath); } else { if (excludePaths.contains(relativePath)) { - FormatExceptionPolicyLegacy.warning(e, step, relativePath); + LintPolicy.warning(e, step, relativePath); } else { - FormatExceptionPolicyLegacy.error(e, step, relativePath); + LintPolicy.error(e, step, relativePath); throw ThrowingEx.asRuntimeRethrowError(e); } } diff --git a/lib/src/main/java/com/diffplug/spotless/Formatter.java b/lib/src/main/java/com/diffplug/spotless/Formatter.java index 1e2e44fe3a..5160f674c2 100644 --- a/lib/src/main/java/com/diffplug/spotless/Formatter.java +++ b/lib/src/main/java/com/diffplug/spotless/Formatter.java @@ -127,28 +127,51 @@ public String computeLineEndings(String unix, File file) { * is guaranteed to also have unix line endings. */ public String compute(String unix, File file) { + ValuePerStep exceptionPerStep = new ValuePerStep<>(this); + String result = computeWithLint(unix, file, exceptionPerStep); + LintPolicy.legacyBehavior(this, file, exceptionPerStep); + return result; + } + + /** + * Returns the result of calling all of the FormatterSteps, while also + * tracking any exceptions which are thrown. + *

+ * The input must have unix line endings, and the output + * is guaranteed to also have unix line endings. + *

+ * It doesn't matter what is inside `ValuePerStep`, the value at every index will be overwritten + * when the method returns. + */ + String computeWithLint(String unix, File file, ValuePerStep exceptionPerStep) { Objects.requireNonNull(unix, "unix"); Objects.requireNonNull(file, "file"); - for (FormatterStep step : steps) { + for (int i = 0; i < steps.size(); ++i) { + FormatterStep step = steps.get(i); + Throwable storeForStep; try { String formatted = step.format(unix, file); if (formatted == null) { // This probably means it was a step that only checks // for errors and doesn't actually have any fixes. // No exception was thrown so we can just continue. + storeForStep = LintState.formatStepCausedNoChange(); } else { // Should already be unix-only, but some steps might misbehave. - unix = LineEnding.toUnix(formatted); + String clean = LineEnding.toUnix(formatted); + if (clean.equals(unix)) { + storeForStep = LintState.formatStepCausedNoChange(); + } else { + storeForStep = null; + unix = LineEnding.toUnix(formatted); + } } } catch (Throwable e) { - // TODO: this is bad, but it won't matter when add support for linting - if (e instanceof RuntimeException) { - throw (RuntimeException) e; - } else { - throw new RuntimeException(e); - } + // store the exception which was thrown and keep going + storeForStep = e; } + exceptionPerStep.set(i, storeForStep); } return unix; } diff --git a/lib/src/main/java/com/diffplug/spotless/FormatterFunc.java b/lib/src/main/java/com/diffplug/spotless/FormatterFunc.java index 800a553225..5e6c44b335 100644 --- a/lib/src/main/java/com/diffplug/spotless/FormatterFunc.java +++ b/lib/src/main/java/com/diffplug/spotless/FormatterFunc.java @@ -16,6 +16,7 @@ package com.diffplug.spotless; import java.io.File; +import java.util.List; import java.util.Objects; /** @@ -32,6 +33,14 @@ default String apply(String unix, File file) throws Exception { return apply(unix); } + /** + * Calculates a list of lints against the given content. + * By default, that's just an throwables thrown by the lint. + */ + default List lint(String content, File file) throws Exception { + return List.of(); + } + /** * {@code Function} and {@code BiFunction} whose implementation * requires a resource which should be released when the function is no longer needed. @@ -74,6 +83,14 @@ public String apply(String unix) throws Exception { @FunctionalInterface interface ResourceFunc { String apply(T resource, String unix) throws Exception; + + /** + * Calculates a list of lints against the given content. + * By default, that's just an throwables thrown by the lint. + */ + default List lint(T resource, String unix) throws Exception { + return List.of(); + } } /** Creates a {@link FormatterFunc.Closeable} which uses the given resource to execute the format function. */ @@ -101,6 +118,10 @@ public String apply(String unix) throws Exception { @FunctionalInterface interface ResourceFuncNeedsFile { String apply(T resource, String unix, File file) throws Exception; + + default List lint(T resource, String content, File file) throws Exception { + return List.of(); + } } /** Creates a {@link FormatterFunc.Closeable} which uses the given resource to execute the file-dependent format function. */ @@ -123,6 +144,11 @@ public String apply(String unix, File file) throws Exception { public String apply(String unix) throws Exception { return apply(unix, Formatter.NO_FILE_SENTINEL); } + + @Override + public List lint(String content, File file) throws Exception { + return function.lint(resource, content, file); + } }; } } diff --git a/lib/src/main/java/com/diffplug/spotless/FormatterStep.java b/lib/src/main/java/com/diffplug/spotless/FormatterStep.java index 2a5a7d2b2f..870ef0ee18 100644 --- a/lib/src/main/java/com/diffplug/spotless/FormatterStep.java +++ b/lib/src/main/java/com/diffplug/spotless/FormatterStep.java @@ -17,6 +17,7 @@ import java.io.File; import java.io.Serializable; +import java.util.List; import java.util.Objects; import javax.annotation.Nullable; @@ -46,6 +47,22 @@ public interface FormatterStep extends Serializable, AutoCloseable { @Nullable String format(String rawUnix, File file) throws Exception; + /** + * Returns a list of lints against the given file content + * + * @param content + * the content to check + * @param file + * the file which {@code content} was obtained from; never null. Pass an empty file using + * {@code new File("")} if and only if no file is actually associated with {@code content} + * @return a list of lints + * @throws Exception if the formatter step experiences a problem + */ + @Nullable + default List lint(String content, File file) throws Exception { + return List.of(); + } + /** * Returns a new {@code FormatterStep} which, observing the value of {@code formatIfMatches}, * will only apply, or not, its changes to files which pass the given filter. diff --git a/lib/src/main/java/com/diffplug/spotless/FormatterStepEqualityOnStateSerialization.java b/lib/src/main/java/com/diffplug/spotless/FormatterStepEqualityOnStateSerialization.java index 52bf9fc760..e42e0cb4f9 100644 --- a/lib/src/main/java/com/diffplug/spotless/FormatterStepEqualityOnStateSerialization.java +++ b/lib/src/main/java/com/diffplug/spotless/FormatterStepEqualityOnStateSerialization.java @@ -18,6 +18,7 @@ import java.io.File; import java.io.Serializable; import java.util.Arrays; +import java.util.List; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; @@ -48,6 +49,14 @@ public String format(String rawUnix, File file) throws Exception { return formatter.apply(rawUnix, file); } + @Override + public List lint(String content, File file) throws Exception { + if (formatter == null) { + formatter = stateToFormatter(state()); + } + return formatter.lint(content, file); + } + @Override public boolean equals(Object o) { if (o == null) { diff --git a/lib/src/main/java/com/diffplug/spotless/Lint.java b/lib/src/main/java/com/diffplug/spotless/Lint.java new file mode 100644 index 0000000000..81fcefc964 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/Lint.java @@ -0,0 +1,156 @@ +/* + * Copyright 2022-2024 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +/** + * Models a linted line or line range. Note that there is no concept of severity level - responsibility + * for severity and confidence are pushed down to the configuration of the lint tool. If a lint makes it + * to Spotless, then it is by definition. + */ +public final class Lint implements Serializable { + /** Any exception which implements this interface will have its lints extracted and reported cleanly to the user. */ + public interface Has { + List getLints(); + } + + /** An exception for shortcutting execution to report a lint to the user. */ + public static class ShortcutException extends RuntimeException implements Has { + public ShortcutException(Lint... lints) { + this(Arrays.asList(lints)); + } + + private final List lints; + + public ShortcutException(Collection lints) { + this.lints = List.copyOf(lints); + } + + @Override + public List getLints() { + return lints; + } + } + + private static final long serialVersionUID = 1L; + + private int lineStart, lineEnd; // 1-indexed, inclusive + private String code; // e.g. CN_IDIOM https://spotbugs.readthedocs.io/en/stable/bugDescriptions.html#cn-class-implements-cloneable-but-does-not-define-or-use-clone-method-cn-idiom + private String msg; + + private Lint(int lineStart, int lineEnd, String lintCode, String lintMsg) { + this.lineStart = lineStart; + this.lineEnd = lineEnd; + this.code = LineEnding.toUnix(lintCode); + this.msg = LineEnding.toUnix(lintMsg); + } + + public static Lint create(String code, String msg, int lineStart, int lineEnd) { + if (lineEnd < lineStart) { + throw new IllegalArgumentException("lineEnd must be >= lineStart: lineStart=" + lineStart + " lineEnd=" + lineEnd); + } + return new Lint(lineStart, lineEnd, code, msg); + } + + public static Lint create(String code, String msg, int line) { + return new Lint(line, line, code, msg); + } + + public int getLineStart() { + return lineStart; + } + + public int getLineEnd() { + return lineEnd; + } + + public String getCode() { + return code; + } + + public String getMsg() { + return msg; + } + + @Override + public String toString() { + if (lineStart == lineEnd) { + return lineStart + ": (" + code + ") " + msg; + } else { + return lineStart + "-" + lineEnd + ": (" + code + ") " + msg; + } + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + Lint lint = (Lint) o; + return lineStart == lint.lineStart && lineEnd == lint.lineEnd && Objects.equals(code, lint.code) && Objects.equals(msg, lint.msg); + } + + @Override + public int hashCode() { + return Objects.hash(lineStart, lineEnd, code, msg); + } + + /** Attempts to parse a line number from the given exception. */ + static Lint createFromThrowable(FormatterStep step, String content, Throwable e) { + Throwable current = e; + while (current != null) { + String message = current.getMessage(); + int lineNumber = lineNumberFor(message); + if (lineNumber != -1) { + return Lint.create(step.getName(), msgFrom(message), lineNumber); + } + current = current.getCause(); + } + int numNewlines = (int) content.codePoints().filter(c -> c == '\n').count(); + return Lint.create(step.getName(), ThrowingEx.stacktrace(e), 1, 1 + numNewlines); + } + + private static int lineNumberFor(String message) { + if (message == null) { + return -1; + } + int firstColon = message.indexOf(':'); + if (firstColon == -1) { + return -1; + } + String candidateNum = message.substring(0, firstColon); + try { + return Integer.parseInt(candidateNum); + } catch (NumberFormatException e) { + return -1; + } + } + + private static String msgFrom(String message) { + for (int i = 0; i < message.length(); ++i) { + if (Character.isLetter(message.charAt(i))) { + return message.substring(i); + } + } + return ""; + } +} diff --git a/lib/src/main/java/com/diffplug/spotless/FormatExceptionPolicyLegacy.java b/lib/src/main/java/com/diffplug/spotless/LintPolicy.java similarity index 68% rename from lib/src/main/java/com/diffplug/spotless/FormatExceptionPolicyLegacy.java rename to lib/src/main/java/com/diffplug/spotless/LintPolicy.java index 93ca5d05b6..0b28c36884 100644 --- a/lib/src/main/java/com/diffplug/spotless/FormatExceptionPolicyLegacy.java +++ b/lib/src/main/java/com/diffplug/spotless/LintPolicy.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2022 DiffPlug + * Copyright 2024 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,24 +15,14 @@ */ package com.diffplug.spotless; +import java.io.File; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; -class FormatExceptionPolicyLegacy extends NoLambda.EqualityBasedOnSerialization implements FormatExceptionPolicy { - private static final long serialVersionUID = 1L; - +class LintPolicy { private static final Logger logger = LoggerFactory.getLogger(Formatter.class); - @Override - public void handleError(Throwable e, FormatterStep step, String relativePath) { - if (e instanceof Error) { - error(e, step, relativePath); - throw ((Error) e); - } else { - warning(e, step, relativePath); - } - } - static void error(Throwable e, FormatterStep step, String relativePath) { logger.error("Step '{}' found problem in '{}':\n{}", step.getName(), relativePath, e.getMessage(), e); } @@ -40,4 +30,14 @@ static void error(Throwable e, FormatterStep step, String relativePath) { static void warning(Throwable e, FormatterStep step, String relativePath) { logger.warn("Unable to apply step '{}' to '{}'", step.getName(), relativePath, e); } + + static void legacyBehavior(Formatter formatter, File file, ValuePerStep exceptionPerStep) { + for (int i = 0; i < formatter.getSteps().size(); ++i) { + Throwable exception = exceptionPerStep.get(i); + if (exception != null && exception != LintState.formatStepCausedNoChange()) { + LintPolicy.error(exception, formatter.getSteps().get(i), file.getName()); + throw ThrowingEx.asRuntimeRethrowError(exception); + } + } + } } diff --git a/lib/src/main/java/com/diffplug/spotless/LintState.java b/lib/src/main/java/com/diffplug/spotless/LintState.java new file mode 100644 index 0000000000..95254a923e --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/LintState.java @@ -0,0 +1,161 @@ +/* + * Copyright 2024 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nullable; + +public class LintState { + private final DirtyState dirtyState; + private final @Nullable List> lintsPerStep; + + private LintState(DirtyState dirtyState, @Nullable List> lintsPerStep) { + this.dirtyState = dirtyState; + this.lintsPerStep = lintsPerStep; + } + + public DirtyState getDirtyState() { + return dirtyState; + } + + public boolean isHasLints() { + return lintsPerStep != null; + } + + public Map> getLints(Formatter formatter) { + if (lintsPerStep == null) { + throw new IllegalStateException("Check `isHasLints` first!"); + } + if (lintsPerStep.size() != formatter.getSteps().size()) { + throw new IllegalStateException("LintState was created with a different formatter!"); + } + Map> result = new LinkedHashMap<>(); + for (int i = 0; i < lintsPerStep.size(); i++) { + List lints = lintsPerStep.get(i); + if (lints != null) { + result.put(formatter.getSteps().get(i), lints); + } + } + return result; + } + + public String asString(File file, Formatter formatter) { + if (!isHasLints()) { + return "(none)"; + } else { + StringBuilder result = new StringBuilder(); + for (int i = 0; i < lintsPerStep.size(); i++) { + List lints = lintsPerStep.get(i); + if (lints != null) { + FormatterStep step = formatter.getSteps().get(i); + for (Lint lint : lints) { + result.append(file.getName()).append(":").append(lint.getLineStart()); + if (lint.getLineEnd() != lint.getLineStart()) { + result.append("-").append(lint.getLineEnd()); + } + result.append(" "); + result.append(step.getName()).append("(").append(lint.getCode()).append(") "); + result.append(lint.getMsg()); + result.append("\n"); + } + } + } + result.setLength(result.length() - 1); + return result.toString(); + } + } + + public static LintState of(Formatter formatter, File file) throws IOException { + return of(formatter, file, Files.readAllBytes(file.toPath())); + } + + public static LintState of(Formatter formatter, File file, byte[] rawBytes) { + var exceptions = new ValuePerStep(formatter); + var raw = new String(rawBytes, formatter.getEncoding()); + var dirty = DirtyState.of(formatter, file, rawBytes, raw, exceptions); + + String toLint = LineEnding.toUnix(dirty.isClean() || dirty.didNotConverge() ? raw : new String(dirty.canonicalBytes(), formatter.getEncoding())); + + var lints = new ValuePerStep>(formatter); + // if a step did not throw an exception, then it gets to check for lints if it wants + for (int i = 0; i < formatter.getSteps().size(); i++) { + FormatterStep step = formatter.getSteps().get(i); + Throwable exception = exceptions.get(i); + if (exception == null || exception == formatStepCausedNoChange()) { + try { + var lintsForStep = step.lint(toLint, file); + if (lintsForStep != null && !lintsForStep.isEmpty()) { + lints.set(i, lintsForStep); + } + } catch (Exception e) { + lints.set(i, List.of(Lint.createFromThrowable(step, toLint, e))); + } + } + } + // for steps that did throw an exception, we will turn those into lints + // we try to reuse the exception if possible, but that is only possible if other steps + // didn't change the formatted value. so we start at the end, and note when the string + // gets changed by a step. if it does, we rerun the steps to get an exception with accurate line numbers. + boolean nothingHasChangedSinceLast = true; + for (int i = formatter.getSteps().size() - 1; i >= 0; i--) { + FormatterStep step = formatter.getSteps().get(i); + Throwable exception = exceptions.get(i); + if (exception != null && exception != formatStepCausedNoChange()) { + nothingHasChangedSinceLast = false; + } + Throwable exceptionForLint; + if (nothingHasChangedSinceLast) { + exceptionForLint = exceptions.get(i); + } else { + // steps changed the content, so we need to rerun to get an exception with accurate line numbers + try { + step.format(toLint, file); + exceptionForLint = null; // the exception "went away" because it got fixed by a later step + } catch (Throwable e) { + exceptionForLint = e; + } + } + List lintsForStep; + if (exceptionForLint instanceof Lint.Has) { + lintsForStep = ((Lint.Has) exceptionForLint).getLints(); + } else if (exceptionForLint != null) { + lintsForStep = List.of(Lint.createFromThrowable(step, toLint, exceptionForLint)); + } else { + lintsForStep = List.of(); + } + if (!lintsForStep.isEmpty()) { + lints.set(i, lintsForStep); + } + } + return new LintState(dirty, lints.indexOfFirstValue() == -1 ? null : lints); + } + + static Throwable formatStepCausedNoChange() { + return FormatterCausedNoChange.INSTANCE; + } + + private static class FormatterCausedNoChange extends Exception { + private static final long serialVersionUID = 1L; + + static final FormatterCausedNoChange INSTANCE = new FormatterCausedNoChange(); + } +} diff --git a/lib/src/main/java/com/diffplug/spotless/PaddedCell.java b/lib/src/main/java/com/diffplug/spotless/PaddedCell.java index b0cb64ae90..5ec7ef1cad 100644 --- a/lib/src/main/java/com/diffplug/spotless/PaddedCell.java +++ b/lib/src/main/java/com/diffplug/spotless/PaddedCell.java @@ -86,29 +86,29 @@ public static PaddedCell check(Formatter formatter, File file) { byte[] rawBytes = ThrowingEx.get(() -> Files.readAllBytes(file.toPath())); String raw = new String(rawBytes, formatter.getEncoding()); String original = LineEnding.toUnix(raw); - return check(formatter, file, original, MAX_CYCLE); + return check(formatter, file, original, MAX_CYCLE, new ValuePerStep<>(formatter)); } public static PaddedCell check(Formatter formatter, File file, String originalUnix) { - return check( - Objects.requireNonNull(formatter, "formatter"), - Objects.requireNonNull(file, "file"), - Objects.requireNonNull(originalUnix, "originalUnix"), - MAX_CYCLE); + return check(formatter, file, originalUnix, new ValuePerStep<>(formatter)); + } + + public static PaddedCell check(Formatter formatter, File file, String originalUnix, ValuePerStep exceptionPerStep) { + return check(formatter, file, originalUnix, MAX_CYCLE, exceptionPerStep); } private static final int MAX_CYCLE = 10; - private static PaddedCell check(Formatter formatter, File file, String original, int maxLength) { + private static PaddedCell check(Formatter formatter, File file, String original, int maxLength, ValuePerStep exceptionPerStep) { if (maxLength < 2) { throw new IllegalArgumentException("maxLength must be at least 2"); } - String appliedOnce = formatter.compute(original, file); + String appliedOnce = formatter.computeWithLint(original, file, exceptionPerStep); if (appliedOnce.equals(original)) { return Type.CONVERGE.create(file, Collections.singletonList(appliedOnce)); } - String appliedTwice = formatter.compute(appliedOnce, file); + String appliedTwice = formatter.computeWithLint(appliedOnce, file, exceptionPerStep); if (appliedOnce.equals(appliedTwice)) { return Type.CONVERGE.create(file, Collections.singletonList(appliedOnce)); } @@ -118,7 +118,7 @@ private static PaddedCell check(Formatter formatter, File file, String original, appliedN.add(appliedTwice); String input = appliedTwice; while (appliedN.size() < maxLength) { - String output = formatter.compute(input, file); + String output = formatter.computeWithLint(input, file, exceptionPerStep); if (output.equals(input)) { return Type.CONVERGE.create(file, appliedN); } else { diff --git a/lib/src/main/java/com/diffplug/spotless/ThrowingEx.java b/lib/src/main/java/com/diffplug/spotless/ThrowingEx.java index 6eb573b5d8..7e017e0989 100644 --- a/lib/src/main/java/com/diffplug/spotless/ThrowingEx.java +++ b/lib/src/main/java/com/diffplug/spotless/ThrowingEx.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2023 DiffPlug + * Copyright 2016-2024 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,6 +15,9 @@ */ package com.diffplug.spotless; +import java.io.PrintWriter; +import java.io.StringWriter; + /** * Basic functional interfaces which throw exception, along with * static helper methods for calling them. @@ -142,4 +145,12 @@ public WrappedAsRuntimeException(Throwable e) { super(e); } } + + public static String stacktrace(Throwable e) { + StringWriter out = new StringWriter(); + PrintWriter writer = new PrintWriter(out); + e.printStackTrace(writer); + writer.flush(); + return out.toString(); + } } diff --git a/lib/src/main/java/com/diffplug/spotless/ValuePerStep.java b/lib/src/main/java/com/diffplug/spotless/ValuePerStep.java new file mode 100644 index 0000000000..314bdc7e60 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/ValuePerStep.java @@ -0,0 +1,93 @@ +/* + * Copyright 2024 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless; + +import java.util.AbstractList; + +import javax.annotation.Nullable; + +/** + * Fixed-size list which maintains a list of exceptions, one per step of the formatter. + * Usually this list will be empty or have only a single value, so it is optimized for stack allocation in those cases. + */ +class ValuePerStep extends AbstractList { + private final int size; + private @Nullable T value; + private int valueIdx; + private @Nullable Object[] multipleValues = null; + + ValuePerStep(Formatter formatter) { + this.size = formatter.getSteps().size(); + } + + @Override + public @Nullable T set(int index, T newValue) { + if (index < 0 || index >= size) { + throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size); + } + if (this.value == null) { + this.valueIdx = index; + this.value = newValue; + return null; + } else if (this.multipleValues != null) { + T previousValue = (T) multipleValues[index]; + multipleValues[index] = newValue; + return previousValue; + } else { + if (index == valueIdx) { + T previousValue = this.value; + this.value = newValue; + return previousValue; + } else { + multipleValues = new Object[size]; + multipleValues[valueIdx] = this.value; + multipleValues[index] = newValue; + return null; + } + } + } + + @Override + public T get(int index) { + if (multipleValues != null) { + return (T) multipleValues[index]; + } else if (valueIdx == index) { + return value; + } else { + return null; + } + } + + public int indexOfFirstValue() { + if (multipleValues != null) { + for (int i = 0; i < multipleValues.length; i++) { + if (multipleValues[i] != null) { + return i; + } + } + return -1; + } else if (value != null) { + return valueIdx; + } else { + return -1; + } + } + + @Override + public int size() { + return size; + } +} diff --git a/lib/src/main/java/com/diffplug/spotless/generic/FenceStep.java b/lib/src/main/java/com/diffplug/spotless/generic/FenceStep.java index 9ce34675f8..3cb0e3f047 100644 --- a/lib/src/main/java/com/diffplug/spotless/generic/FenceStep.java +++ b/lib/src/main/java/com/diffplug/spotless/generic/FenceStep.java @@ -27,9 +27,9 @@ import javax.annotation.Nullable; import com.diffplug.spotless.Formatter; -import com.diffplug.spotless.FormatterFunc; import com.diffplug.spotless.FormatterStep; import com.diffplug.spotless.LineEnding; +import com.diffplug.spotless.Lint; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; @@ -101,7 +101,7 @@ static class ApplyWithin extends BaseStep { } @Override - public String apply(Formatter formatter, String unix, File file) throws Exception { + protected String applySubclass(Formatter formatter, String unix, File file) { List groups = groupsZeroed(); Matcher matcher = regex.matcher(unix); while (matcher.find()) { @@ -130,7 +130,7 @@ private void storeGroups(String unix) { } @Override - public String apply(Formatter formatter, String unix, File file) throws Exception { + protected String applySubclass(Formatter formatter, String unix, File file) { storeGroups(unix); String formatted = formatter.compute(unix, file); return assembleGroups(formatted); @@ -138,7 +138,7 @@ public String apply(Formatter formatter, String unix, File file) throws Exceptio } @SuppressFBWarnings("SE_TRANSIENT_FIELD_NOT_RESTORED") - public static abstract class BaseStep implements Serializable, FormatterStep, FormatterFunc.Closeable.ResourceFuncNeedsFile { + private static abstract class BaseStep implements Serializable, FormatterStep { final String name; private static final long serialVersionUID = -2301848328356559915L; final Pattern regex; @@ -198,8 +198,8 @@ protected String assembleGroups(String unix) { return builder.toString(); } else { // these will be needed to generate Lints later on - // int startLine = 1 + (int) builder.toString().codePoints().filter(c -> c == '\n').count(); - // int endLine = 1 + (int) unix.codePoints().filter(c -> c == '\n').count(); + int startLine = 1 + (int) builder.toString().codePoints().filter(c -> c == '\n').count(); + int endLine = 1 + (int) unix.codePoints().filter(c -> c == '\n').count(); // throw an error with either the full regex, or the nicer open/close pair Matcher openClose = Pattern.compile("\\\\Q([\\s\\S]*?)\\\\E" + "\\Q([\\s\\S]*?)\\E" + "\\\\Q([\\s\\S]*?)\\\\E") @@ -210,7 +210,9 @@ protected String assembleGroups(String unix) { } else { pattern = regex.pattern(); } - throw new Error("An intermediate step removed a match of " + pattern); + throw new Lint.ShortcutException(Lint.create("fenceRemoved", + "An intermediate step removed a match of " + pattern, + startLine, endLine)); } } @@ -221,15 +223,21 @@ public String getName() { private transient Formatter formatter; - @Nullable - @Override - public String format(String rawUnix, File file) throws Exception { + private String apply(String rawUnix, File file) throws Exception { if (formatter == null) { formatter = buildFormatter(); } - return this.apply(formatter, rawUnix, file); + return applySubclass(formatter, rawUnix, file); } + @Nullable + @Override + public String format(String rawUnix, File file) throws Exception { + return apply(rawUnix, file); + } + + protected abstract String applySubclass(Formatter formatter, String unix, File file) throws Exception; + @Override public boolean equals(Object o) { if (this == o) diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTask.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTask.java index 19d81eb947..881e266eed 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTask.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTask.java @@ -37,7 +37,6 @@ import org.gradle.api.tasks.PathSensitivity; import org.gradle.work.Incremental; -import com.diffplug.spotless.FormatExceptionPolicy; import com.diffplug.spotless.FormatExceptionPolicyStrict; import com.diffplug.spotless.Formatter; import com.diffplug.spotless.FormatterStep; @@ -115,14 +114,14 @@ public ObjectId getRatchetSha() { return subtreeSha; } - protected FormatExceptionPolicy exceptionPolicy = new FormatExceptionPolicyStrict(); + protected FormatExceptionPolicyStrict exceptionPolicy = new FormatExceptionPolicyStrict(); - public void setExceptionPolicy(FormatExceptionPolicy exceptionPolicy) { + public void setExceptionPolicy(FormatExceptionPolicyStrict exceptionPolicy) { this.exceptionPolicy = Objects.requireNonNull(exceptionPolicy); } @Input - public FormatExceptionPolicy getExceptionPolicy() { + public FormatExceptionPolicyStrict getExceptionPolicy() { return exceptionPolicy; } diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/BiomeIntegrationTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/BiomeIntegrationTest.java index aff28664c5..ba39af1705 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/BiomeIntegrationTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/BiomeIntegrationTest.java @@ -21,12 +21,9 @@ import java.io.IOException; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.owasp.encoder.Encode; -import com.diffplug.spotless.tag.ForLintRefactor; - class BiomeIntegrationTest extends GradleIntegrationHarness { /** * Tests that biome can be used as a generic formatting step. @@ -323,8 +320,6 @@ void failureWhenExeNotFound() throws Exception { * @throws Exception When a test failure occurs. */ @Test - @Disabled - @ForLintRefactor void failureWhenNotParseable() throws Exception { setFile("build.gradle").toLines( "plugins {", diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/ErrorShouldRethrowTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/ErrorShouldRethrowTest.java index d8e9cbd2fa..5dd435fd1a 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/ErrorShouldRethrowTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/ErrorShouldRethrowTest.java @@ -32,8 +32,6 @@ import com.diffplug.spotless.tag.ForLintRefactor; /** Tests the desired behavior from https://github.com/diffplug/spotless/issues/46. */ -@Disabled -@ForLintRefactor class ErrorShouldRethrowTest extends GradleIntegrationHarness { private void writeBuild(String... toInsert) throws IOException { List lines = new ArrayList<>(); @@ -86,6 +84,8 @@ void unlessEnforceCheckIsFalse() throws Exception { runWithSuccess("> Task :processResources NO-SOURCE"); } + @Disabled + @ForLintRefactor @Test void unlessExemptedByStep() throws Exception { writeBuild( @@ -97,6 +97,8 @@ void unlessExemptedByStep() throws Exception { "Unable to apply step 'no swearing' to 'README.md'"); } + @Disabled + @ForLintRefactor @Test void unlessExemptedByPath() throws Exception { writeBuild( diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java index aa82c371f3..cf8f9cc739 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java @@ -20,11 +20,8 @@ import java.io.File; import java.io.IOException; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import com.diffplug.spotless.tag.ForLintRefactor; - class KotlinExtensionTest extends GradleIntegrationHarness { private static final String HEADER = "// License Header"; private static final String HEADER_WITH_YEAR = "// License Header $YEAR"; @@ -150,8 +147,6 @@ void testSetEditorConfigCanOverrideEditorConfigFile() throws IOException { } @Test - @Disabled - @ForLintRefactor void withCustomRuleSetApply() throws IOException { setFile("build.gradle.kts").toLines( "plugins {", diff --git a/plugin-maven/src/test/java/com/diffplug/spotless/maven/biome/BiomeMavenTest.java b/plugin-maven/src/test/java/com/diffplug/spotless/maven/biome/BiomeMavenTest.java index 24ce1d09b7..ff763c3cf8 100644 --- a/plugin-maven/src/test/java/com/diffplug/spotless/maven/biome/BiomeMavenTest.java +++ b/plugin-maven/src/test/java/com/diffplug/spotless/maven/biome/BiomeMavenTest.java @@ -20,11 +20,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.owasp.encoder.Encode.forXml; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import com.diffplug.spotless.maven.MavenIntegrationHarness; -import com.diffplug.spotless.tag.ForLintRefactor; class BiomeMavenTest extends MavenIntegrationHarness { /** @@ -192,8 +190,6 @@ void failureWhenExeNotFound() throws Exception { * @throws Exception When a test failure occurs. */ @Test - @Disabled - @ForLintRefactor void failureWhenNotParseable() throws Exception { writePomWithBiomeSteps("**/*.js", "1.2.0json"); setFile("biome_test.js").toResource("biome/js/fileBefore.js"); diff --git a/testlib/build.gradle b/testlib/build.gradle index 38e598369e..2364ea770d 100644 --- a/testlib/build.gradle +++ b/testlib/build.gradle @@ -13,6 +13,8 @@ dependencies { api "org.junit.jupiter:junit-jupiter:${VER_JUNIT}" api "org.assertj:assertj-core:${VER_ASSERTJ}" api "org.mockito:mockito-core:$VER_MOCKITO" + api "com.diffplug.selfie:selfie-lib:${VER_SELFIE}" + api "com.diffplug.selfie:selfie-runner-junit5:${VER_SELFIE}" runtimeOnly "org.junit.platform:junit-platform-launcher" implementation "com.diffplug.durian:durian-io:${VER_DURIAN}" diff --git a/testlib/src/main/java/com/diffplug/spotless/StepHarness.java b/testlib/src/main/java/com/diffplug/spotless/StepHarness.java index ae59a8d176..4f7f6b5c27 100644 --- a/testlib/src/main/java/com/diffplug/spotless/StepHarness.java +++ b/testlib/src/main/java/com/diffplug/spotless/StepHarness.java @@ -24,6 +24,9 @@ import org.assertj.core.api.AbstractStringAssert; import org.assertj.core.api.Assertions; +import com.diffplug.selfie.Selfie; +import com.diffplug.selfie.StringSelfie; + /** An api for testing a {@code FormatterStep} that doesn't depend on the File path. DO NOT ADD FILE SUPPORT TO THIS, use {@link StepHarnessWithFile} if you need that. */ public class StepHarness extends StepHarnessBase { private StepHarness(Formatter formatter, RoundTrip roundTrip) { @@ -111,4 +114,11 @@ public AbstractStringAssert testExceptionMsg(String before) { } } } + + public StringSelfie expectLintsOf(String before) { + LintState state = LintState.of(formatter(), Formatter.NO_FILE_SENTINEL, before.getBytes(formatter().getEncoding())); + String assertAgainst = state.asString(Formatter.NO_FILE_SENTINEL, formatter()); + String cleaned = assertAgainst.replace("NO_FILE_SENTINEL:", ""); + return Selfie.expectSelfie(cleaned); + } } diff --git a/testlib/src/main/java/selfie/SelfieSettings.java b/testlib/src/main/java/selfie/SelfieSettings.java new file mode 100644 index 0000000000..4a60c15dd0 --- /dev/null +++ b/testlib/src/main/java/selfie/SelfieSettings.java @@ -0,0 +1,26 @@ +/* + * Copyright 2024 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package selfie; + +import com.diffplug.selfie.junit5.SelfieSettingsAPI; + +/** https://selfie.dev/jvm/get-started#quickstart */ +public class SelfieSettings extends SelfieSettingsAPI { + @Override + public boolean getJavaDontUseTripleQuoteLiterals() { + return true; + } +} diff --git a/testlib/src/test/java/com/diffplug/spotless/generic/FenceStepTest.java b/testlib/src/test/java/com/diffplug/spotless/generic/FenceStepTest.java index 697a199fd2..3d9d2ed0fc 100644 --- a/testlib/src/test/java/com/diffplug/spotless/generic/FenceStepTest.java +++ b/testlib/src/test/java/com/diffplug/spotless/generic/FenceStepTest.java @@ -83,13 +83,13 @@ void multiple() { @Test void broken() { FormatterStep fence = FenceStep.named("fence").openClose("spotless:off", "spotless:on") - .preserveWithin(Arrays.asList(ToCaseStep.upper())); + .preserveWithin(Arrays.asList(ReplaceStep.create("replace", "spotless:on", "REMOVED"))); // this fails because uppercase turns spotless:off into SPOTLESS:OFF, etc - StepHarness.forStepNoRoundtrip(fence).testExceptionMsg(StringPrinter.buildStringFromLines("A B C", + StepHarness.forStep(fence).expectLintsOf(StringPrinter.buildStringFromLines("A B C", "spotless:off", "D E F", "spotless:on", - "G H I")).isEqualTo("An intermediate step removed a match of spotless:off spotless:on"); + "G H I")).toBe("1-6 fence(fenceRemoved) An intermediate step removed a match of spotless:off spotless:on"); } @Test