From 9afa6dd722669820df7d5aca7a403279f2c55359 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 30 May 2024 14:52:44 -0700 Subject: [PATCH] Handy tool for making encoding formats that don't have any forbidden characters. --- .../diffplug/spotless/ExceptionPerStep.java | 101 ++++++++ .../FilterByContentPatternFormatterStep.java | 18 +- .../spotless/FilterByFileFormatterStep.java | 14 +- .../java/com/diffplug/spotless/Formatter.java | 30 ++- .../com/diffplug/spotless/FormatterFunc.java | 26 ++ .../com/diffplug/spotless/FormatterStep.java | 17 ++ ...atterStepEqualityOnStateSerialization.java | 9 + .../main/java/com/diffplug/spotless/Lint.java | 242 ++++++++++++++++++ .../spotless/PerCharacterEscaper.java | 152 +++++++++++ .../com/diffplug/spotless/ThrowingEx.java | 13 +- .../java/com/diffplug/spotless/LintTest.java | 47 ++++ .../spotless/PerCharacterEscaperTest.java | 65 +++++ 12 files changed, 720 insertions(+), 14 deletions(-) create mode 100644 lib/src/main/java/com/diffplug/spotless/ExceptionPerStep.java create mode 100644 lib/src/main/java/com/diffplug/spotless/Lint.java create mode 100644 lib/src/main/java/com/diffplug/spotless/PerCharacterEscaper.java create mode 100644 testlib/src/test/java/com/diffplug/spotless/LintTest.java create mode 100644 testlib/src/test/java/com/diffplug/spotless/PerCharacterEscaperTest.java diff --git a/lib/src/main/java/com/diffplug/spotless/ExceptionPerStep.java b/lib/src/main/java/com/diffplug/spotless/ExceptionPerStep.java new file mode 100644 index 0000000000..7bf7fe2011 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/ExceptionPerStep.java @@ -0,0 +1,101 @@ +/* + * 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 ExceptionPerStep extends AbstractList { + private final int size; + private @Nullable Throwable exception; + private int exceptionIdx; + private @Nullable Throwable[] multipleExceptions = null; + + ExceptionPerStep(Formatter formatter) { + this.size = formatter.getSteps().size(); + } + + @Override + public @Nullable Throwable set(int index, Throwable exception) { + if (index < 0 || index >= size) { + throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size); + } + if (this.exception == null) { + this.exceptionIdx = index; + this.exception = exception; + return null; + } else if (this.multipleExceptions != null) { + Throwable previousValue = multipleExceptions[index]; + multipleExceptions[index] = exception; + return previousValue; + } else { + if (index == exceptionIdx) { + Throwable previousValue = this.exception; + this.exception = exception; + return previousValue; + } else { + multipleExceptions = new Throwable[size]; + multipleExceptions[exceptionIdx] = this.exception; + multipleExceptions[index] = exception; + return null; + } + } + } + + @Override + public Throwable get(int index) { + if (multipleExceptions != null) { + return multipleExceptions[index]; + } else if (exceptionIdx == index) { + return exception; + } else { + return null; + } + } + + private int indexOfFirstException() { + if (multipleExceptions != null) { + for (int i = 0; i < multipleExceptions.length; i++) { + if (multipleExceptions[i] != null) { + return i; + } + } + return -1; + } else if (exception != null) { + return exceptionIdx; + } else { + return -1; + } + } + + @Override + public int size() { + return size; + } + + /** Rethrows the first exception in the list. */ + public void rethrowFirstIfPresent() { + int firstException = indexOfFirstException(); + if (firstException != -1) { + throw ThrowingEx.asRuntimeRethrowError(get(firstException)); + } + } +} 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/Formatter.java b/lib/src/main/java/com/diffplug/spotless/Formatter.java index 1e2e44fe3a..5db20942f5 100644 --- a/lib/src/main/java/com/diffplug/spotless/Formatter.java +++ b/lib/src/main/java/com/diffplug/spotless/Formatter.java @@ -26,6 +26,7 @@ import java.nio.charset.Charset; import java.util.ArrayList; import java.util.List; +import java.util.ListIterator; import java.util.Objects; /** Formatter which performs the full formatting. */ @@ -127,12 +128,28 @@ public String computeLineEndings(String unix, File file) { * is guaranteed to also have unix line endings. */ public String compute(String unix, File file) { + ExceptionPerStep exceptionPerStep = new ExceptionPerStep(this); + String result = compute(unix, file, exceptionPerStep); + exceptionPerStep.rethrowFirstIfPresent(); + 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. + *

+ */ + String compute(String unix, File file, ExceptionPerStep exceptionPerStep) { Objects.requireNonNull(unix, "unix"); Objects.requireNonNull(file, "file"); - for (FormatterStep step : steps) { + ListIterator iter = steps.listIterator(); + while (iter.hasNext()) { try { - String formatted = step.format(unix, file); + String formatted = iter.next().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. @@ -142,12 +159,9 @@ public String compute(String unix, File file) { 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 stop execution so we don't alter line numbers + exceptionPerStep.set(iter.previousIndex(), e); + return unix; } } 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..bc6d026330 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/Lint.java @@ -0,0 +1,242 @@ +/* + * 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.File; +import java.io.IOException; +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +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); + } + + /** Guaranteed to have no newlines, but also guarantees to preserve all newlines and parenthesis in code and msg. */ + String asOneLine() { + StringBuilder buffer = new StringBuilder(); + buffer.append(Integer.toString(lineStart)); + if (lineStart != lineEnd) { + buffer.append('-'); + buffer.append(Integer.toString(lineEnd)); + } + buffer.append(OPEN); + buffer.append(safeParensAndNewlines.escape(code)); + buffer.append(CLOSE); + buffer.append(safeParensAndNewlines.escape(msg)); + return buffer.toString(); + } + + private static final String OPEN = ": ("; + private static final String CLOSE = ") "; + + static Lint fromOneLine(String content) { + int codeOpen = content.indexOf(OPEN); + int codeClose = content.indexOf(CLOSE, codeOpen); + + int lineStart, lineEnd; + String lineNumber = content.substring(0, codeOpen); + int idxDash = lineNumber.indexOf('-'); + if (idxDash == -1) { + lineStart = Integer.parseInt(lineNumber); + lineEnd = lineStart; + } else { + lineStart = Integer.parseInt(lineNumber.substring(0, idxDash)); + lineEnd = Integer.parseInt(lineNumber.substring(idxDash + 1)); + } + + String code = safeParensAndNewlines.unescape(content.substring(codeOpen + OPEN.length(), codeClose)); + String msg = safeParensAndNewlines.unescape(content.substring(codeClose + CLOSE.length())); + return Lint.create(code, msg, lineStart, lineEnd); + } + + /** Call .escape to get a string which is guaranteed to have no parenthesis or newlines, and you can call unescape to get the original back. */ + static final PerCharacterEscaper safeParensAndNewlines = PerCharacterEscaper.specifiedEscape("\\\\\nn(₍)₎"); + + /** Converts a list of lints to a String, format is not guaranteed to be consistent from version to version of Spotless. */ + public static String toString(List lints) { + StringBuilder builder = new StringBuilder(); + for (Lint lint : lints) { + builder.append(lint.asOneLine()); + builder.append('\n'); + } + return builder.toString(); + } + + /** Converts a list of lints to a String, format is not guaranteed to be consistent from version to version of Spotless. */ + public static List fromString(String content) { + List lints = new ArrayList<>(); + String[] lines = content.split("\n"); + for (String line : lines) { + line = line.trim(); + if (!line.isEmpty()) { + lints.add(fromOneLine(line)); + } + } + return lints; + } + + public static List fromFile(File file) throws IOException { + byte[] content = Files.readAllBytes(file.toPath()); + return fromString(new String(content, StandardCharsets.UTF_8)); + } + + public static void toFile(List lints, File file) throws IOException { + Path path = file.toPath(); + Path parent = path.getParent(); + if (parent == null) { + throw new IllegalArgumentException("file has no parent dir"); + } + Files.createDirectories(parent); + byte[] content = toString(lints).getBytes(StandardCharsets.UTF_8); + Files.write(path, content); + } + + /** 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/PerCharacterEscaper.java b/lib/src/main/java/com/diffplug/spotless/PerCharacterEscaper.java new file mode 100644 index 0000000000..3138e3e781 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/PerCharacterEscaper.java @@ -0,0 +1,152 @@ +/* + * 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. + * 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; + +class PerCharacterEscaper { + /** + * If your escape policy is "'a1b2c3d", it means this: + * + * ``` + * abc->abc + * 123->'b'c'd + * I won't->I won'at + * ``` + */ + public static PerCharacterEscaper specifiedEscape(String escapePolicy) { + int[] codePoints = escapePolicy.codePoints().toArray(); + if (codePoints.length % 2 != 0) { + throw new IllegalArgumentException(); + } + int escapeCodePoint = codePoints[0]; + int[] escapedCodePoints = new int[codePoints.length / 2]; + int[] escapedByCodePoints = new int[codePoints.length / 2]; + for (int i = 0; i < escapedCodePoints.length; ++i) { + escapedCodePoints[i] = codePoints[2 * i]; + escapedByCodePoints[i] = codePoints[2 * i + 1]; + } + return new PerCharacterEscaper(escapeCodePoint, escapedCodePoints, escapedByCodePoints); + } + + private final int escapeCodePoint; + private final int[] escapedCodePoints; + private final int[] escapedByCodePoints; + + /** The first character in the string will be uses as the escape character, and all characters will be escaped. */ + private PerCharacterEscaper(int escapeCodePoint, int[] escapedCodePoints, int[] escapedByCodePoints) { + this.escapeCodePoint = escapeCodePoint; + this.escapedCodePoints = escapedCodePoints; + this.escapedByCodePoints = escapedByCodePoints; + } + + public boolean needsEscaping(String input) { + return firstOffsetNeedingEscape(input) != -1; + } + + private int firstOffsetNeedingEscape(String input) { + final int length = input.length(); + int firstOffsetNeedingEscape = -1; + outer: for (int offset = 0; offset < length;) { + int codepoint = input.codePointAt(offset); + for (int escaped : escapedCodePoints) { + if (codepoint == escaped) { + firstOffsetNeedingEscape = offset; + break outer; + } + } + offset += Character.charCount(codepoint); + } + return firstOffsetNeedingEscape; + } + + public String escape(String input) { + final int noEscapes = firstOffsetNeedingEscape(input); + if (noEscapes == -1) { + return input; + } else { + final int length = input.length(); + final int needsEscapes = length - noEscapes; + StringBuilder builder = new StringBuilder(noEscapes + 4 + (needsEscapes * 5 / 4)); + builder.append(input, 0, noEscapes); + for (int offset = noEscapes; offset < length;) { + final int codepoint = input.codePointAt(offset); + offset += Character.charCount(codepoint); + int idx = indexOf(escapedCodePoints, codepoint); + if (idx == -1) { + builder.appendCodePoint(codepoint); + } else { + builder.appendCodePoint(escapeCodePoint); + builder.appendCodePoint(escapedByCodePoints[idx]); + } + } + return builder.toString(); + } + } + + private int firstOffsetNeedingUnescape(String input) { + final int length = input.length(); + int firstOffsetNeedingEscape = -1; + for (int offset = 0; offset < length;) { + int codepoint = input.codePointAt(offset); + if (codepoint == escapeCodePoint) { + firstOffsetNeedingEscape = offset; + break; + } + offset += Character.charCount(codepoint); + } + return firstOffsetNeedingEscape; + } + + public String unescape(String input) { + final int noEscapes = firstOffsetNeedingUnescape(input); + if (noEscapes == -1) { + return input; + } else { + final int length = input.length(); + final int needsEscapes = length - noEscapes; + StringBuilder builder = new StringBuilder(noEscapes + 4 + (needsEscapes * 5 / 4)); + builder.append(input, 0, noEscapes); + for (int offset = noEscapes; offset < length;) { + int codepoint = input.codePointAt(offset); + offset += Character.charCount(codepoint); + // if we need to escape something, escape it + if (codepoint == escapeCodePoint) { + if (offset < length) { + codepoint = input.codePointAt(offset); + int idx = indexOf(escapedByCodePoints, codepoint); + if (idx != -1) { + codepoint = escapedCodePoints[idx]; + } + offset += Character.charCount(codepoint); + } else { + throw new IllegalArgumentException("Escape character '" + new String(new int[]{escapeCodePoint}, 0, 1) + "' can't be the last character in a string."); + } + } + // we didn't escape it, append it raw + builder.appendCodePoint(codepoint); + } + return builder.toString(); + } + } + + private static int indexOf(int[] array, int value) { + for (int i = 0; i < array.length; ++i) { + if (array[i] == value) { + return i; + } + } + return -1; + } +} 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/testlib/src/test/java/com/diffplug/spotless/LintTest.java b/testlib/src/test/java/com/diffplug/spotless/LintTest.java new file mode 100644 index 0000000000..d8fcfba873 --- /dev/null +++ b/testlib/src/test/java/com/diffplug/spotless/LintTest.java @@ -0,0 +1,47 @@ +/* + * 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 org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class LintTest { + @Test + public void examples() { + roundtrip(Lint.create("code", "msg", 5)); + roundtrip(Lint.create("code", "msg", 5, 7)); + roundtrip(Lint.create("(code)", "msg\nwith\nnewlines", 5, 7)); + } + + private void roundtrip(Lint lint) { + Lint roundTripped = Lint.fromOneLine(lint.asOneLine()); + Assertions.assertEquals(lint.asOneLine(), roundTripped.asOneLine()); + } + + @Test + public void perCharacterEscaper() { + roundtrip("abcn123", "abcn123"); + roundtrip("abc\\123", "abc\\\\123"); + roundtrip("abc(123)", "abc\\₍123\\₎"); + roundtrip("abc\n123", "abc\\n123"); + roundtrip("abc\nn123", "abc\\nn123"); + } + + private void roundtrip(String unescaped, String escaped) { + Assertions.assertEquals(escaped, Lint.safeParensAndNewlines.escape(unescaped)); + Assertions.assertEquals(unescaped, Lint.safeParensAndNewlines.unescape(escaped)); + } +} diff --git a/testlib/src/test/java/com/diffplug/spotless/PerCharacterEscaperTest.java b/testlib/src/test/java/com/diffplug/spotless/PerCharacterEscaperTest.java new file mode 100644 index 0000000000..aa450ee43a --- /dev/null +++ b/testlib/src/test/java/com/diffplug/spotless/PerCharacterEscaperTest.java @@ -0,0 +1,65 @@ +/* + * 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. + * 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 org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class PerCharacterEscaperTest { + @Test + public void examples() { + roundtrip("abcn123", "abcn123"); + roundtrip("abc/123", "abc//123"); + roundtrip("abc(123)", "abc/₍123/₎"); + roundtrip("abc\n123", "abc/n123"); + roundtrip("abc\nn123", "abc/nn123"); + } + + private void roundtrip(String unescaped, String escaped) { + Assertions.assertEquals(escaped, Lint.safeParensAndNewlines.escape(unescaped)); + Assertions.assertEquals(unescaped, Lint.safeParensAndNewlines.unescape(escaped)); + } + + @Test + public void performanceOptimizationSpecific() { + PerCharacterEscaper escaper = PerCharacterEscaper.specifiedEscape("`a1b2c3d"); + // if nothing gets changed, it should return the exact same value + String abc = "abc"; + Assertions.assertSame(abc, escaper.escape(abc)); + Assertions.assertSame(abc, escaper.unescape(abc)); + + // otherwise it should have the normal behavior + Assertions.assertEquals("`b", escaper.escape("1")); + Assertions.assertEquals("`a", escaper.escape("`")); + Assertions.assertEquals("abc`b`c`d`adef", escaper.escape("abc123`def")); + + // in both directions + Assertions.assertEquals("1", escaper.unescape("`b")); + Assertions.assertEquals("`", escaper.unescape("`a")); + Assertions.assertEquals("abc123`def", escaper.unescape("abc`1`2`3``def")); + } + + @Test + public void cornerCasesSpecific() { + PerCharacterEscaper escaper = PerCharacterEscaper.specifiedEscape("`a1b2c3d"); + // cornercase - escape character without follow-on will throw an error + org.assertj.core.api.Assertions.assertThatThrownBy(() -> escaper.unescape("`")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Escape character '`' can't be the last character in a string."); + // escape character followed by non-escape character is fine + Assertions.assertEquals("e", escaper.unescape("`e")); + } +}