diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/DecoratedAssertionError.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/DecoratedAssertionError.java new file mode 100644 index 0000000000000..85848a9949aba --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/DecoratedAssertionError.java @@ -0,0 +1,61 @@ +package io.quarkus.vertx.http.runtime; + +import java.io.PrintStream; +import java.io.PrintWriter; + +/** + * Wraps an exception and prints stack trace with code snippets. + * + *

+ * To obtain the original exception, use {@link #getOriginal()}. + * + *

+ * NOTE: Copied from laech/java-stacksrc + */ +final class DecoratedAssertionError extends AssertionError { + + private final Throwable original; + private final String decoratedStackTrace; + + DecoratedAssertionError(Throwable original) { + this(original, null); + } + + /** + * @param pruneStackTraceKeepFromClass if not null, will prune the stack traces, keeping only + * elements that are called directly or indirectly by this class + */ + DecoratedAssertionError( + Throwable original, Class pruneStackTraceKeepFromClass) { + this.original = original; + this.decoratedStackTrace = StackTraceDecorator.get().decorate(original, pruneStackTraceKeepFromClass); + setStackTrace(new StackTraceElement[0]); + } + + @Override + public String getMessage() { + // Override this instead of calling the super(message) constructor, as super(null) will create + // the "null" string instead of actually being null + return getOriginal().getMessage(); + } + + /** Gets the original throwable being wrapped. */ + public Throwable getOriginal() { + return original; + } + + @Override + public void printStackTrace(PrintWriter out) { + out.println(this); + } + + @Override + public void printStackTrace(PrintStream out) { + out.println(this); + } + + @Override + public String toString() { + return decoratedStackTrace; + } +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/FileCollector.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/FileCollector.java new file mode 100644 index 0000000000000..8fb5e5bd6dbce --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/FileCollector.java @@ -0,0 +1,49 @@ +package io.quarkus.vertx.http.runtime; + +import java.io.IOException; +import java.nio.file.FileVisitOption; +import java.nio.file.FileVisitResult; +import java.nio.file.FileVisitor; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * NOTE: Copied from laech/java-stacksrc + */ +final class FileCollector implements FileVisitor { + + private final Map> result = new HashMap<>(); + + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + result.computeIfAbsent(file.getFileName().toString(), __ -> new ArrayList<>()).add(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException exc) { + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) { + return FileVisitResult.CONTINUE; + } + + static Map> collect(Path dir) throws IOException { + var collector = new FileCollector(); + Files.walkFileTree(dir, EnumSet.of(FileVisitOption.FOLLOW_LINKS), Integer.MAX_VALUE, collector); + return collector.result; + } +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java index 6d2a61a2851a4..939578d216584 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java @@ -129,6 +129,7 @@ public void accept(Throwable throwable) { exception.addSuppressed(e); } if (showStack && exception != null) { + exception = new DecoratedAssertionError(exception); details = generateHeaderMessage(exception, uuid); stack = generateStackTrace(exception); } else { diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/StackTraceDecorator.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/StackTraceDecorator.java new file mode 100644 index 0000000000000..2f0a1a4b32c37 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/StackTraceDecorator.java @@ -0,0 +1,235 @@ +package io.quarkus.vertx.http.runtime; + +import static java.lang.String.format; +import static java.lang.System.lineSeparator; +import static java.util.Objects.requireNonNull; +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toList; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.IntStream; + +/** + *

+ * NOTE: Copied from laech/java-stacksrc + */ +final class StackTraceDecorator { + private StackTraceDecorator() { + } + + private static final StackTraceDecorator instance = new StackTraceDecorator(); + + public static StackTraceDecorator get() { + return instance; + } + + private static final int CONTEXT_LINE_COUNT = 2; + + private volatile Map> cachedFiles; + + private Map> cachedFiles() throws IOException { + if (cachedFiles == null) { + cachedFiles = FileCollector.collect(Paths.get("")); + } + return cachedFiles; + } + + public String decorate(Throwable e) { + return decorate(e, null); + } + + public String decorate(Throwable e, Class keepFromClass) { + if (keepFromClass != null) { + pruneStackTrace(e, keepFromClass, new HashSet<>()); + } + + var stackTrace = getStackTraceAsString(e); + try { + + var alreadySeenElements = new HashSet(); + var alreadySeenSnippets = new HashSet>(); + stackTrace = decorate(e, stackTrace, 1, alreadySeenElements, alreadySeenSnippets); + + var cause = e.getCause(); + if (cause != null) { + stackTrace = decorate(cause, stackTrace, 1, alreadySeenElements, alreadySeenSnippets); + } + + for (var suppressed : e.getSuppressed()) { + stackTrace = decorate(suppressed, stackTrace, 2, alreadySeenElements, alreadySeenSnippets); + } + + } catch (Exception sup) { + e.addSuppressed(sup); + } + return stackTrace; + } + + private String decorate( + Throwable e, + String stackTrace, + int indentLevel, + Set alreadySeenElements, + Set> alreadySeenSnippets) + throws IOException { + + for (var element : e.getStackTrace()) { + if (!alreadySeenElements.add(element)) { + continue; + } + + var snippet = decorate(element); + if (snippet.isEmpty() || !alreadySeenSnippets.add(snippet.get())) { + // Don't print the same snippet multiple times, + // multiple lambda on one line can create this situation + continue; + } + + var line = element.toString(); + var indent = "\t".repeat(indentLevel); + var replacement = String.format( + "%s%n%n%s%n%n", + line, snippet.get().stream().collect(joining(lineSeparator() + indent, indent, ""))); + stackTrace = stackTrace.replace(line, replacement); + } + return stackTrace; + } + + private Optional> decorate(StackTraceElement element) throws IOException { + var file = findFile(element); + if (file.isEmpty()) { + return Optional.empty(); + } + + var lines = readContextLines(element, file.get()); + if (lines.isEmpty()) { + return Optional.empty(); + } + + removeBlankLinesFromStart(lines); + removeBlankLinesFromEnd(lines); + return Optional.of(buildSnippet(lines, element)); + } + + private Optional findFile(StackTraceElement element) throws IOException { + if (element.getLineNumber() < 1 + || element.getFileName() == null + || element.getMethodName().startsWith("access$")) { // Ignore class entry lines + return Optional.empty(); + } + + var tail = withPackagePath(element); + var paths = cachedFiles().getOrDefault(element.getFileName(), List.of()); + var exact = paths.stream().filter(it -> it.endsWith(tail)).findAny(); + if (exact.isPresent() || element.getFileName().endsWith(".java")) { + return exact; + } + return Optional.ofNullable(paths.size() == 1 ? paths.get(0) : null); + } + + private Path withPackagePath(StackTraceElement element) { + var fileName = requireNonNull(element.getFileName()); + var className = element.getClassName(); + var i = className.lastIndexOf("."); + var parent = i < 0 ? "" : className.substring(0, i).replace('.', '/'); + return Paths.get(parent).resolve(fileName); + } + + private NavigableMap readContextLines(StackTraceElement elem, Path path) + throws IOException { + + var startLineNum = Math.max(1, elem.getLineNumber() - CONTEXT_LINE_COUNT); + try (var stream = Files.lines(path)) { + + var lines = stream + .limit(elem.getLineNumber() + CONTEXT_LINE_COUNT) + .skip(startLineNum - 1) + .collect(toList()); + + return IntStream.range(0, lines.size()) + .boxed() + .reduce( + new TreeMap<>(), + (acc, i) -> { + acc.put(i + startLineNum, lines.get(i)); + return acc; + }, + (a, b) -> b); + } + } + + @SuppressWarnings("NullAway") + private void removeBlankLinesFromStart(NavigableMap lines) { + IntStream.rangeClosed(lines.firstKey(), lines.lastKey()) + .takeWhile(i -> lines.get(i).isBlank()) + .forEach(lines::remove); + } + + @SuppressWarnings("NullAway") + private void removeBlankLinesFromEnd(NavigableMap lines) { + IntStream.iterate(lines.lastKey(), i -> i >= lines.firstKey(), i -> i - 1) + .takeWhile(i -> lines.get(i).isBlank()) + .forEach(lines::remove); + } + + private static List buildSnippet( + NavigableMap lines, StackTraceElement elem) { + var maxLineNumWidth = String.valueOf(lines.lastKey()).length(); + return lines.entrySet().stream() + .map( + entry -> { + var lineNum = entry.getKey(); + var isTarget = lineNum == elem.getLineNumber(); + var line = entry.getValue(); + var lineNumStr = format("%" + maxLineNumWidth + "d", lineNum); + return format( + "%s %s%s", isTarget ? "->" : " ", lineNumStr, line.isEmpty() ? "" : " " + line); + }) + .collect(toList()); + } + + private static String getStackTraceAsString(Throwable e) { + var stringWriter = new StringWriter(); + var printWriter = new PrintWriter(stringWriter); + e.printStackTrace(printWriter); + printWriter.flush(); + return stringWriter.toString(); + } + + private static void pruneStackTrace( + Throwable throwable, Class keepFromClass, Set alreadySeen) { + if (!alreadySeen.add(throwable)) { + return; + } + + var stackTrace = throwable.getStackTrace(); + for (var i = stackTrace.length - 1; i >= 0; i--) { + if (stackTrace[i].getClassName().equals(keepFromClass.getName())) { + throwable.setStackTrace(Arrays.copyOfRange(stackTrace, 0, i + 1)); + break; + } + } + + var cause = throwable.getCause(); + if (cause != null) { + pruneStackTrace(cause, keepFromClass, alreadySeen); + } + + for (var suppressed : throwable.getSuppressed()) { + pruneStackTrace(suppressed, keepFromClass, alreadySeen); + } + } +}