Use ANSI colors for test results; more polish (#771)

Any thrown Pkl Errors are colored in the simple test report!

Also:
* Refactor `TextFormatter` to be more generic; rename to `TextFormattingStringBuilder`
* Adjust test report slightly (no emojis, add more spacing).
* Introduce `ColorTheme` class.
* Make stack frame descriptors colored as "faint"

Also: this changes the summary so it summarizes _all_ modules, rather than a summary per module.

---------

Co-authored-by: Islon Scherer <islonscherer@gmail.com>
Co-authored-by: Philip K.F. Hölzenspies <holzensp@gmail.com>
This commit is contained in:
Daniel Chao
2024-11-04 14:14:19 -08:00
committed by GitHub
parent 4b4d81ba93
commit 40a08affa6
18 changed files with 688 additions and 381 deletions

View File

@@ -235,7 +235,7 @@ public class EvaluatorImpl implements Evaluator {
return doEvaluate(
moduleSource,
(module) -> {
var testRunner = new TestRunner(logger, frameTransformer, overwrite);
var testRunner = new TestRunner(logger, frameTransformer, overwrite, color);
return testRunner.run(module);
});
}

View File

@@ -92,7 +92,7 @@ public record TestResults(
* being written.
*/
public boolean isExampleWrittenFailure() {
if (!failed() || !examples.failed()) return false;
if (!failed() || facts.failed() || !examples.failed()) return false;
for (var testResult : examples.results) {
if (!testResult.isExampleWritten) {
return false;
@@ -295,7 +295,19 @@ public record TestResults(
}
}
/**
* Indicates that an exception was thrown when evaluating the assertion.
*
* @param message The message of the underlying exception.
* @param exception The exception thrown by Pkl
*/
public record Error(String message, PklException exception) {}
/**
* Indicates that an assertion failed.
*
* @param kind The type of assertion failure.
* @param message The detailed message for the failure.
*/
public record Failure(String kind, String message) {}
}

View File

@@ -0,0 +1,277 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
*
* 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
*
* https://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 org.pkl.core.runtime;
import java.io.PrintWriter;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Set;
import org.pkl.core.util.StringBuilderWriter;
@SuppressWarnings("DuplicatedCode")
public final class AnsiCodingStringBuilder {
private final StringBuilder builder = new StringBuilder();
private final boolean usingColor;
/** The set of ansi codes currently applied. */
private Set<AnsiCode> currentCodes = Collections.emptySet();
/** The set of ansi codes intended to be applied the next time text is written. */
private Set<AnsiCode> declaredCodes = Collections.emptySet();
public AnsiCodingStringBuilder(boolean usingColor) {
this.usingColor = usingColor;
}
/** Append {@code value} to the string, ensuring it is formatted with {@code codes}. */
public AnsiCodingStringBuilder append(Set<AnsiCode> codes, String value) {
if (!usingColor) {
builder.append(value);
return this;
}
var prevDeclaredCodes = declaredCodes;
declaredCodes = EnumSet.copyOf(codes);
declaredCodes.addAll(prevDeclaredCodes);
append(value);
declaredCodes = prevDeclaredCodes;
return this;
}
/** Append {@code value} to the string, ensuring it is formatted with {@code codes}. */
public AnsiCodingStringBuilder append(AnsiCode code, int value) {
if (!usingColor) {
builder.append(value);
return this;
}
var prevDeclaredCodes = declaredCodes;
declaredCodes = EnumSet.of(code);
declaredCodes.addAll(prevDeclaredCodes);
append(value);
declaredCodes = prevDeclaredCodes;
return this;
}
/** Append {@code value} to the string, ensuring it is formatted with {@code codes}. */
public AnsiCodingStringBuilder append(AnsiCode code, String value) {
if (!usingColor) {
builder.append(value);
return this;
}
var prevDeclaredCodes = declaredCodes;
declaredCodes = EnumSet.of(code);
declaredCodes.addAll(prevDeclaredCodes);
append(value);
declaredCodes = prevDeclaredCodes;
return this;
}
/**
* Apply {@code code} to every appended element within {@code runnable}.
*
* <p>This is a helper method. With this:
*
* <ul>
* <li>There is no need to repeat the same style for multiple appends in a row.
* <li>The parent style is added to any styles added applied in the children.
* <p>For example, in the following snippet, {@code "hello"} is formatted in both bold and
* red:
* <pre>{@code
* var sb = new AnsiCodingStringBuilder(true);
* sb.append(AnsiCode.RED, () -> {
* sb.append(AnsiCode.BOLD, "hello");
* });
*
* }</pre>
* </ul>
*/
public AnsiCodingStringBuilder append(AnsiCode code, Runnable runnable) {
if (!usingColor) {
runnable.run();
return this;
}
var prevDeclaredCodes = declaredCodes;
declaredCodes = EnumSet.of(code);
declaredCodes.addAll(prevDeclaredCodes);
runnable.run();
declaredCodes = prevDeclaredCodes;
return this;
}
/**
* Append a string whose contents are unknown, and might contain ANSI color codes.
*
* <p>Always add a reset and re-apply all colors after appending the string.
*/
public AnsiCodingStringBuilder appendUntrusted(String value) {
appendCodes();
builder.append(value);
if (usingColor) {
doReset();
doAppendCodes(currentCodes);
}
return this;
}
/**
* Append {@code value} to the string.
*
* <p>If called within {@link #append(AnsiCode, Runnable)}, applies any styles in the current
* context.
*/
public AnsiCodingStringBuilder append(String value) {
appendCodes();
builder.append(value);
return this;
}
/**
* Append the string representation of {@code value} to the string.
*
* <p>If called within {@link #append(AnsiCode, Runnable)}, applies any styles in the current
* context.
*/
public AnsiCodingStringBuilder append(char value) {
appendCodes();
builder.append(value);
return this;
}
/**
* Append the string representation of {@code value} to the string.
*
* <p>If called within {@link #append(AnsiCode, Runnable)}, applies any styles in the current
* context.
*/
public AnsiCodingStringBuilder append(int value) {
appendCodes();
builder.append(value);
return this;
}
/**
* Append the string representation of {@code value} to the string.
*
* <p>If called within {@link #append(AnsiCode, Runnable)}, applies any styles in the current
* context.
*/
public AnsiCodingStringBuilder append(Object value) {
appendCodes();
builder.append(value);
return this;
}
/** Returns a fresh instance of this string builder. */
public AnsiCodingStringBuilder newInstance() {
return new AnsiCodingStringBuilder(usingColor);
}
public PrintWriter toPrintWriter() {
return new PrintWriter(new StringBuilderWriter(builder));
}
/** Builds the data represented by this builder into a {@link String}. */
public String toString() {
// be a good citizen and unset any ansi escape codes currently set.
reset();
return builder.toString();
}
private void doAppendCodes(Set<AnsiCode> codes) {
if (codes.isEmpty()) return;
builder.append("\033[");
var isFirst = true;
for (var code : codes) {
if (isFirst) {
isFirst = false;
} else {
builder.append(';');
}
builder.append(code.value);
}
builder.append('m');
}
private void appendCodes() {
if (!usingColor || currentCodes.equals(declaredCodes)) return;
if (declaredCodes.containsAll(currentCodes)) {
var newCodes = EnumSet.copyOf(declaredCodes);
newCodes.removeAll(currentCodes);
doAppendCodes(newCodes);
} else {
reset();
doAppendCodes(declaredCodes);
}
currentCodes = declaredCodes;
}
private void reset() {
if (!usingColor || currentCodes.isEmpty()) return;
doReset();
currentCodes = Collections.emptySet();
}
private void doReset() {
builder.append("\033[0m");
}
public enum AnsiCode {
RESET(0),
BOLD(1),
FAINT(2),
BLACK(30),
RED(31),
GREEN(32),
YELLOW(33),
BLUE(34),
MAGENTA(35),
CYAN(36),
WHITE(37),
BG_BLACK(40),
BG_RED(41),
BG_GREEN(42),
BG_YELLOW(43),
BG_BLUE(44),
BG_MAGENTA(45),
BG_CYAN(46),
BG_WHITE(47),
BRIGHT_BLACK(90),
BRIGHT_RED(91),
BRIGHT_GREEN(92),
BRIGHT_YELLOW(93),
BRIGHT_BLUE(94),
BRIGHT_MAGENTA(95),
BRIGHT_CYAN(96),
BRIGHT_WHITE(97),
BG_BRIGHT_BLACK(100),
BG_BRIGHT_RED(101),
BG_BRIGHT_GREEN(102),
BG_BRIGHT_YELLOW(103),
BG_BRIGHT_BLUE(104),
BG_BRIGHT_MAGENTA(105),
BG_BRIGHT_CYAN(106),
BG_BRIGHT_WHITE(107);
private final int value;
AnsiCode(int value) {
this.value = value;
}
}
}

View File

@@ -19,7 +19,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import org.pkl.core.StackFrame;
import org.pkl.core.runtime.TextFormatter.Element;
import org.pkl.core.util.AnsiTheme;
import org.pkl.core.util.Nullable;
public final class StackTraceRenderer {
@@ -29,7 +29,7 @@ public final class StackTraceRenderer {
this.frameTransformer = frameTransformer;
}
public void render(List<StackFrame> frames, @Nullable String hint, TextFormatter out) {
public void render(List<StackFrame> frames, @Nullable String hint, AnsiCodingStringBuilder out) {
var compressed = compressFrames(frames);
doRender(compressed, hint, out, "", true);
}
@@ -38,7 +38,7 @@ public final class StackTraceRenderer {
void doRender(
List<Object /*StackFrame|StackFrameLoop*/> frames,
@Nullable String hint,
TextFormatter out,
AnsiCodingStringBuilder out,
String leftMargin,
boolean isFirstElement) {
for (var frame : frames) {
@@ -48,13 +48,11 @@ public final class StackTraceRenderer {
doRender(loop.frames, null, out, leftMargin, isFirstElement);
} else {
if (!isFirstElement) {
out.margin(leftMargin).newline();
out.append(AnsiTheme.STACK_TRACE_MARGIN, leftMargin).append('\n');
}
out.margin(leftMargin)
.margin("┌─ ")
.style(Element.STACK_OVERFLOW_LOOP_COUNT)
.append(loop.count)
.style(Element.TEXT)
out.append(AnsiTheme.STACK_TRACE_MARGIN, leftMargin)
.append(AnsiTheme.STACK_TRACE_MARGIN, "┌─ ")
.append(AnsiTheme.STACK_TRACE_LOOP_COUNT, loop.count)
.append(" repetitions of:\n");
var newLeftMargin = leftMargin + "";
doRender(loop.frames, null, out, newLeftMargin, isFirstElement);
@@ -62,11 +60,11 @@ public final class StackTraceRenderer {
renderHint(hint, out, newLeftMargin);
isFirstElement = false;
}
out.margin(leftMargin).margin("└─").newline();
out.append(AnsiTheme.STACK_TRACE_MARGIN, leftMargin + "└─").append('\n');
}
} else {
if (!isFirstElement) {
out.margin(leftMargin).newline();
out.append(AnsiTheme.STACK_TRACE_MARGIN, leftMargin).append('\n');
}
renderFrame((StackFrame) frame, out, leftMargin);
}
@@ -78,19 +76,22 @@ public final class StackTraceRenderer {
}
}
private void renderFrame(StackFrame frame, TextFormatter out, String leftMargin) {
private void renderFrame(StackFrame frame, AnsiCodingStringBuilder out, String leftMargin) {
var transformed = frameTransformer.apply(frame);
renderSourceLine(transformed, out, leftMargin);
renderSourceLocation(transformed, out, leftMargin);
}
private void renderHint(@Nullable String hint, TextFormatter out, String leftMargin) {
private void renderHint(@Nullable String hint, AnsiCodingStringBuilder out, String leftMargin) {
if (hint == null || hint.isEmpty()) return;
out.newline().margin(leftMargin).style(Element.HINT).append(hint).newline();
out.append('\n')
.append(AnsiTheme.STACK_TRACE_MARGIN, leftMargin)
.append(AnsiTheme.ERROR_MESSAGE_HINT, hint)
.append('\n');
}
private void renderSourceLine(StackFrame frame, TextFormatter out, String leftMargin) {
private void renderSourceLine(StackFrame frame, AnsiCodingStringBuilder out, String leftMargin) {
var originalSourceLine = frame.getSourceLines().get(0);
var leadingWhitespace = VmUtils.countLeadingWhitespace(originalSourceLine);
var sourceLine = originalSourceLine.strip();
@@ -101,28 +102,28 @@ public final class StackTraceRenderer {
: sourceLine.length();
var prefix = frame.getStartLine() + " | ";
out.margin(leftMargin)
.style(Element.LINE_NUMBER)
.append(prefix)
.style(Element.TEXT)
out.append(AnsiTheme.STACK_TRACE_MARGIN, leftMargin)
.append(AnsiTheme.STACK_TRACE_LINE_NUMBER, prefix)
.append(sourceLine)
.newline()
.margin(leftMargin)
.repeat(prefix.length() + startColumn - 1, ' ')
.style(Element.ERROR)
.repeat(endColumn - startColumn + 1, '^')
.newline();
.append('\n')
.append(AnsiTheme.STACK_TRACE_MARGIN, leftMargin)
.append(" ".repeat(prefix.length() + startColumn - 1))
.append(AnsiTheme.STACK_TRACE_CARET, "^".repeat(endColumn - startColumn + 1))
.append('\n');
}
private void renderSourceLocation(StackFrame frame, TextFormatter out, String leftMargin) {
out.margin(leftMargin)
.style(Element.TEXT)
.append("at ")
.append(frame.getMemberName() != null ? frame.getMemberName() : "<unknown>")
.append(" (")
.append(frame.getModuleUri())
.append(")")
.newline();
private void renderSourceLocation(
StackFrame frame, AnsiCodingStringBuilder out, String leftMargin) {
out.append(AnsiTheme.STACK_TRACE_MARGIN, leftMargin)
.append(
AnsiTheme.STACK_FRAME,
() ->
out.append("at ")
.append(frame.getMemberName() != null ? frame.getMemberName() : "<unknown>")
.append(" (")
.appendUntrusted(frame.getModuleUri())
.append(")")
.append('\n'));
}
/**

View File

@@ -34,6 +34,7 @@ import org.pkl.core.ast.member.ObjectMember;
import org.pkl.core.module.ModuleKeys;
import org.pkl.core.stdlib.PklConverter;
import org.pkl.core.stdlib.base.PcfRenderer;
import org.pkl.core.util.AnsiTheme;
import org.pkl.core.util.EconomicMaps;
import org.pkl.core.util.MutableBoolean;
import org.pkl.core.util.MutableReference;
@@ -41,15 +42,20 @@ import org.pkl.core.util.MutableReference;
/** Runs test results examples and facts. */
public final class TestRunner {
private static final PklConverter converter = new PklConverter(VmMapping.empty());
private final boolean overwrite;
private final StackFrameTransformer stackFrameTransformer;
private final BufferedLogger logger;
private final StackFrameTransformer stackFrameTransformer;
private final boolean overwrite;
private final boolean useColor;
public TestRunner(
BufferedLogger logger, StackFrameTransformer stackFrameTransformer, boolean overwrite) {
BufferedLogger logger,
StackFrameTransformer stackFrameTransformer,
boolean overwrite,
boolean useColor) {
this.logger = logger;
this.stackFrameTransformer = stackFrameTransformer;
this.overwrite = overwrite;
this.useColor = useColor;
}
public TestResults run(VmTyped testModule) {
@@ -60,7 +66,7 @@ public final class TestRunner {
checkAmendsPklTest(testModule);
} catch (VmException v) {
var error =
new TestResults.Error(v.getMessage(), v.toPklException(stackFrameTransformer, false));
new TestResults.Error(v.getMessage(), v.toPklException(stackFrameTransformer, useColor));
return resultsBuilder.setError(error).build();
}
@@ -109,7 +115,7 @@ public final class TestRunner {
} catch (VmException err) {
var error =
new TestResults.Error(
err.getMessage(), err.toPklException(stackFrameTransformer, false));
err.getMessage(), err.toPklException(stackFrameTransformer, useColor));
resultBuilder.addError(error);
}
return true;
@@ -209,7 +215,7 @@ public final class TestRunner {
errored.set(true);
testResultBuilder.addError(
new TestResults.Error(
err.getMessage(), err.toPklException(stackFrameTransformer, false)));
err.getMessage(), err.toPklException(stackFrameTransformer, useColor)));
return true;
}
var expectedValue = VmUtils.readMember(expectedGroup, exampleIndex);
@@ -306,7 +312,7 @@ public final class TestRunner {
} catch (VmException err) {
testResultBuilder.addError(
new TestResults.Error(
err.getMessage(), err.toPklException(stackFrameTransformer, false)));
err.getMessage(), err.toPklException(stackFrameTransformer, useColor)));
allSucceeded.set(false);
success.set(false);
return true;
@@ -388,27 +394,32 @@ public final class TestRunner {
moduleInfo.getModuleKey().getUri(), VmContext.get(null).getFrameTransformer());
}
private static Failure factFailure(SourceSection sourceSection, String location) {
String message = sourceSection.getCharacters().toString() + " " + renderLocation(location);
return new Failure("Fact Failure", message);
private Failure factFailure(SourceSection sourceSection, String location) {
var sb = new AnsiCodingStringBuilder(useColor);
sb.append(AnsiTheme.TEST_FACT_SOURCE, sourceSection.getCharacters().toString()).append(" ");
appendLocation(sb, location);
return new Failure("Fact Failure", sb.toString());
}
private static Failure exampleLengthMismatchFailure(
private Failure exampleLengthMismatchFailure(
String location, String property, int expectedLength, int actualLength) {
String msg =
renderLocation(location)
+ "\n"
+ "Output mismatch: Expected \""
+ property
+ "\" to contain "
+ expectedLength
+ " examples, but found "
+ actualLength;
var sb = new AnsiCodingStringBuilder(useColor);
appendLocation(sb, location);
return new Failure("Output Mismatch (Length)", msg);
sb.append('\n')
.append(
AnsiTheme.TEST_FAILURE_MESSAGE,
() ->
sb.append("Output mismatch: Expected \"")
.append(property)
.append("\" to contain ")
.append(expectedLength)
.append(" examples, but found ")
.append(actualLength));
return new Failure("Output Mismatch (Length)", sb.toString());
}
private static Failure examplePropertyMismatchFailure(
private Failure examplePropertyMismatchFailure(
String location, String property, boolean isMissingInExpected) {
String existsIn;
@@ -422,52 +433,58 @@ public final class TestRunner {
missingIn = "actual";
}
String message =
renderLocation(location)
+ "\n"
+ "Output mismatch: \""
+ property
+ "\" exists in "
+ existsIn
+ " but not in "
+ missingIn
+ " output";
var sb = new AnsiCodingStringBuilder(useColor);
appendLocation(sb, location);
return new Failure("Output Mismatch", message);
sb.append('\n')
.append(
AnsiTheme.TEST_FAILURE_MESSAGE,
() ->
sb.append("Output mismatch: \"")
.append(property)
.append("\" exists in ")
.append(existsIn)
.append(" but not in ")
.append(missingIn)
.append(" output"));
return new Failure("Output Mismatch", sb.toString());
}
private static Failure exampleFailure(
private Failure exampleFailure(
String location,
String expectedLocation,
String expectedValue,
String actualLocation,
String actualValue,
int exampleNumber) {
String err =
"#"
+ exampleNumber
+ " "
+ renderLocation(location)
+ ":\n "
+ "Expected: "
+ renderLocation(expectedLocation)
+ "\n "
+ expectedValue.replaceAll("\n", "\n ")
+ "\n "
+ "Actual: "
+ renderLocation(actualLocation)
+ "\n "
+ actualValue.replaceAll("\n", "\n ");
return new Failure("Example Failure", err);
var sb = new AnsiCodingStringBuilder(useColor);
sb.append(AnsiTheme.TEST_NAME, "#" + exampleNumber + ": ");
sb.append(
AnsiTheme.TEST_FAILURE_MESSAGE,
() -> {
appendLocation(sb, location);
sb.append("\n Expected: ");
appendLocation(sb, expectedLocation);
sb.append("\n ");
sb.append(AnsiTheme.TEST_EXAMPLE_OUTPUT, expectedValue.replaceAll("\n", "\n "));
sb.append("\n Actual: ");
appendLocation(sb, actualLocation);
sb.append("\n ");
sb.append(AnsiTheme.TEST_EXAMPLE_OUTPUT, actualValue.replaceAll("\n", "\n "));
});
return new Failure("Example Failure", sb.toString());
}
private static String renderLocation(String location) {
return "(" + location + ")";
private void appendLocation(AnsiCodingStringBuilder stringBuilder, String location) {
stringBuilder.append(
AnsiTheme.STACK_FRAME,
() -> stringBuilder.append("(").appendUntrusted(location).append(")"));
}
private static Failure writtenExampleOutputFailure(String testName, String location) {
var message = renderLocation(location) + "\n" + "Wrote expected output for test " + testName;
return new Failure("Example Output Written", message);
private Failure writtenExampleOutputFailure(String testName, String location) {
var sb = new AnsiCodingStringBuilder(useColor);
appendLocation(sb, location);
sb.append(AnsiTheme.TEST_FAILURE_MESSAGE, "\nWrote expected output for test ").append(testName);
return new Failure("Example Output Written", sb.toString());
}
}

View File

@@ -1,185 +0,0 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
*
* 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
*
* https://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 org.pkl.core.runtime;
import java.io.PrintWriter;
import java.util.HashMap;
import java.util.Map;
import org.pkl.core.util.Nullable;
import org.pkl.core.util.StringBuilderWriter;
/*
TODO:
* Make "margin matter" a facility of the formatter, managing margins in e.g. `newline()`.
- `pushMargin(String matter)` / `popMargin()`
* Replace implementation methods `repeat()` with more semantic equivalents.
- `underline(int startColumn, int endColumn)`
* Replace `newInstance()` with an alternative that doesn't require instance management,
i.e. better composition (currently only used for pre-rendering `hint`s).
* Assert assumed invariants (e.g. `append(String text)` checking there are no newlines).
* Replace `THEME_ANSI` with one read from `pkl:settings`.
*/
public final class TextFormatter {
public static final Map<Element, @Nullable Styling> THEME_PLAIN = new HashMap<>();
public static final Map<Element, @Nullable Styling> THEME_ANSI;
static {
THEME_ANSI =
Map.of(
Element.MARGIN, new Styling(Color.YELLOW, true, false),
Element.HINT, new Styling(Color.YELLOW, true, true),
Element.STACK_OVERFLOW_LOOP_COUNT, new Styling(Color.MAGENTA, false, false),
Element.LINE_NUMBER, new Styling(Color.BLUE, false, false),
Element.ERROR_HEADER, new Styling(Color.RED, false, false),
Element.ERROR, new Styling(Color.RED, false, true));
}
private final Map<Element, @Nullable Styling> theme;
private final StringBuilder builder = new StringBuilder();
private @Nullable Styling currentStyle;
private TextFormatter(Map<Element, Styling> theme) {
this.theme = theme;
this.currentStyle = theme.getOrDefault(Element.PLAIN, null);
}
public static TextFormatter create(boolean usingColor) {
return new TextFormatter(usingColor ? THEME_ANSI : THEME_PLAIN);
}
public PrintWriter toPrintWriter() {
return new PrintWriter(new StringBuilderWriter(builder));
}
public String toString() {
return builder.toString();
}
public TextFormatter newline() {
return newlines(1);
}
public TextFormatter newInstance() {
return new TextFormatter(theme);
}
public TextFormatter newlines(int count) {
return repeat(count, '\n');
}
public TextFormatter margin(String marginMatter) {
return style(Element.MARGIN).append(marginMatter);
}
public TextFormatter style(Element element) {
var style = theme.getOrDefault(element, null);
if (currentStyle == style) {
return this;
}
if (style == null) {
append("\033[0m");
currentStyle = style;
return this;
}
var colorCode =
style.bright() ? style.foreground().fgBrightCode() : style.foreground().fgCode();
append('\033');
append('[');
append(colorCode);
if (style.bold() && (currentStyle == null || !currentStyle.bold())) {
append(";1");
} else if (!style.bold() && currentStyle != null && currentStyle.bold()) {
append(";22");
}
append('m');
currentStyle = style;
return this;
}
public TextFormatter repeat(int width, char ch) {
for (var i = 0; i < width; i++) {
append(ch);
}
return this;
}
public TextFormatter append(String s) {
builder.append(s);
return this;
}
public TextFormatter append(char ch) {
builder.append(ch);
return this;
}
public TextFormatter append(int i) {
builder.append(i);
return this;
}
public TextFormatter append(Object obj) {
builder.append(obj);
return this;
}
public enum Element {
PLAIN,
MARGIN,
HINT,
STACK_OVERFLOW_LOOP_COUNT,
LINE_NUMBER,
TEXT,
ERROR_HEADER,
ERROR
}
public record Styling(Color foreground, boolean bold, boolean bright) {}
public enum Color {
BLACK(30),
RED(31),
GREEN(32),
YELLOW(33),
BLUE(34),
MAGENTA(35),
CYAN(36),
WHITE(37);
private final int code;
Color(int code) {
this.code = code;
}
public int fgCode() {
return code;
}
public int bgCode() {
return code + 10;
}
public int fgBrightCode() {
return code + 60;
}
public int bgBrightCode() {
return code + 70;
}
}
}

View File

@@ -20,7 +20,7 @@ import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.stream.Collectors;
import org.pkl.core.Release;
import org.pkl.core.runtime.TextFormatter.Element;
import org.pkl.core.util.AnsiTheme;
import org.pkl.core.util.ErrorMessages;
import org.pkl.core.util.Nullable;
@@ -39,12 +39,12 @@ public final class VmExceptionRenderer {
@TruffleBoundary
public String render(VmException exception) {
var formatter = TextFormatter.create(color);
var formatter = new AnsiCodingStringBuilder(color);
render(exception, formatter);
return formatter.toString();
}
private void render(VmException exception, TextFormatter out) {
private void render(VmException exception, AnsiCodingStringBuilder out) {
if (exception instanceof VmBugException bugException) {
renderBugException(bugException, out);
} else {
@@ -52,31 +52,31 @@ public final class VmExceptionRenderer {
}
}
private void renderBugException(VmBugException exception, TextFormatter out) {
private void renderBugException(VmBugException exception, AnsiCodingStringBuilder out) {
// if a cause exists, it's more useful to report just that
var exceptionToReport = exception.getCause() != null ? exception.getCause() : exception;
var exceptionUrl = URLEncoder.encode(exceptionToReport.toString(), StandardCharsets.UTF_8);
out.style(Element.TEXT)
.append("An unexpected error has occurred. Would you mind filing a bug report?")
.newline()
out.append("An unexpected error has occurred. Would you mind filing a bug report?")
.append('\n')
.append("Cmd+Double-click the link below to open an issue.")
.newline()
.append('\n')
.append("Please copy and paste the entire error output into the issue's description.")
.newlines(2)
.append("\n".repeat(2))
.append("https://github.com/apple/pkl/issues/new")
.newlines(2)
.append("\n".repeat(2))
.append(exceptionUrl.replaceAll("\\+", "%20"))
.newlines(2);
.append("\n\n");
renderException(exception, out, true);
out.newline().style(Element.TEXT).append(Release.current().versionInfo()).newlines(2);
out.append('\n').append(Release.current().versionInfo()).append("\n".repeat(2));
exceptionToReport.printStackTrace(out.toPrintWriter());
}
private void renderException(VmException exception, TextFormatter out, boolean withHeader) {
private void renderException(
VmException exception, AnsiCodingStringBuilder out, boolean withHeader) {
String message;
var hint = exception.getHint();
if (exception.isExternalMessage()) {
@@ -97,9 +97,9 @@ public final class VmExceptionRenderer {
}
if (withHeader) {
out.style(Element.ERROR_HEADER).append(" Pkl Error ").newline();
out.append(AnsiTheme.ERROR_HEADER, " Pkl Error ").append('\n');
}
out.style(Element.ERROR).append(message).newline();
out.append(AnsiTheme.ERROR_MESSAGE, message).append('\n');
// include cause's message unless it's the same as this exception's message
if (exception.getCause() != null) {
@@ -107,11 +107,7 @@ public final class VmExceptionRenderer {
var causeMessage = cause.getMessage();
// null for Truffle's LazyStackTrace
if (causeMessage != null && !causeMessage.equals(message)) {
out.style(Element.TEXT)
.append(cause.getClass().getSimpleName())
.append(": ")
.append(causeMessage)
.newline();
out.append(cause.getClass().getSimpleName()).append(": ").append(causeMessage).append('\n');
}
}
@@ -119,12 +115,11 @@ public final class VmExceptionRenderer {
exception.getProgramValues().stream().mapToInt(v -> v.name.length()).max().orElse(0);
for (var value : exception.getProgramValues()) {
out.style(Element.TEXT)
.append(value.name)
.repeat(Math.max(0, maxNameLength - value.name.length()), ' ')
out.append(value.name)
.append(" ".repeat(Math.max(0, maxNameLength - value.name.length())))
.append(": ")
.append(value)
.newline();
.append('\n');
}
if (stackTraceRenderer != null) {
@@ -137,10 +132,10 @@ public final class VmExceptionRenderer {
}
if (!frames.isEmpty()) {
stackTraceRenderer.render(frames, hint, out.newline());
stackTraceRenderer.render(frames, hint, out.append('\n'));
} else if (hint != null) {
// render hint if there are no stack frames
out.newline().style(Element.HINT).append(hint);
out.append('\n').append(AnsiTheme.ERROR_MESSAGE_HINT, hint);
}
}
}

View File

@@ -105,7 +105,9 @@ public final class JUnitReport implements TestReport {
long element = i++;
list.add(
buildXmlElement(
"failure", attrs, members -> members.put(element, syntheticElement(fail.message()))));
"failure",
attrs,
members -> members.put(element, syntheticElement(stripColors(fail.message())))));
}
return list;
}
@@ -120,7 +122,9 @@ public final class JUnitReport implements TestReport {
buildXmlElement(
"error",
attrs,
members -> members.put(element, syntheticElement(error.exception().getMessage()))));
members ->
members.put(
element, syntheticElement(stripColors(error.exception().getMessage())))));
}
return list;
}
@@ -132,7 +136,9 @@ public final class JUnitReport implements TestReport {
buildXmlElement(
"error",
attrs,
members -> members.put(1, syntheticElement("\n" + error.exception().getMessage()))));
members ->
members.put(
1, syntheticElement(stripColors("\n" + error.exception().getMessage())))));
return list;
}
@@ -191,7 +197,11 @@ public final class JUnitReport implements TestReport {
return new VmTyped(VmUtils.createEmptyMaterializedFrame(), clazz.getPrototype(), clazz, attrs);
}
public static String renderXML(String indent, String version, VmDynamic value) {
private String stripColors(String str) {
return str.replaceAll("\033\\[[;\\d]*m", "");
}
private static String renderXML(String indent, String version, VmDynamic value) {
var builder = new StringBuilder();
var converter = new PklConverter(VmMapping.empty());
var renderer = new Renderer(builder, indent, version, "", VmMapping.empty(), converter);

View File

@@ -17,19 +17,32 @@ package org.pkl.core.stdlib.test.report;
import java.io.IOException;
import java.io.Writer;
import java.util.List;
import java.util.stream.Collectors;
import org.pkl.core.TestResults;
import org.pkl.core.TestResults.TestResult;
import org.pkl.core.TestResults.TestSectionResults;
import org.pkl.core.runtime.AnsiCodingStringBuilder;
import org.pkl.core.runtime.AnsiCodingStringBuilder.AnsiCode;
import org.pkl.core.util.AnsiTheme;
import org.pkl.core.util.StringUtils;
public final class SimpleReport implements TestReport {
private static final String passingMark = "";
private static final String failingMark = "";
private final boolean useColor;
public SimpleReport(boolean useColor) {
this.useColor = useColor;
}
@Override
public void report(TestResults results, Writer writer) throws IOException {
var builder = new StringBuilder();
var builder = new AnsiCodingStringBuilder(useColor);
builder.append("module ").append(results.moduleName()).append("\n");
builder.append("module ").append(results.moduleName()).append('\n');
if (results.error() != null) {
var rendered = results.error().exception().getMessage();
@@ -40,45 +53,61 @@ public final class SimpleReport implements TestReport {
reportResults(results.examples(), builder);
}
if (results.isExampleWrittenFailure()) {
builder.append(results.examples().totalFailures()).append(" examples written\n");
writer.append(builder);
return;
}
builder.append(results.failed() ? "" : "");
var totalStatsLine =
makeStatsLine("tests", results.totalTests(), results.totalFailures(), results.failed());
builder.append(totalStatsLine);
var totalAssertsStatsLine =
makeStatsLine(
"asserts", results.totalAsserts(), results.totalAssertsFailed(), results.failed());
builder.append(", ").append(totalAssertsStatsLine);
builder.append("\n");
writer.append(builder);
writer.append(builder.toString());
}
private void reportResults(TestSectionResults section, StringBuilder builder) {
if (!section.results().isEmpty()) {
builder.append(" ").append(section.name()).append("\n");
public void summarize(List<TestResults> allTestResults, Writer writer) throws IOException {
var totalTests = 0;
var totalFailedTests = 0;
var totalAsserts = 0;
var totalFailedAsserts = 0;
var isFailed = false;
var isExampleWrittenFailure = true;
for (var testResults : allTestResults) {
if (!isFailed) {
isFailed = testResults.failed();
}
if (testResults.failed()) {
isExampleWrittenFailure = testResults.isExampleWrittenFailure() & isExampleWrittenFailure;
}
totalTests += testResults.totalTests();
totalFailedTests += testResults.totalFailures();
totalAsserts += testResults.totalAsserts();
totalFailedAsserts += testResults.totalAssertsFailed();
}
var builder = new AnsiCodingStringBuilder(useColor);
if (isFailed && isExampleWrittenFailure) {
builder.append(totalFailedTests).append(" examples written");
} else {
makeStatsLine(builder, "tests", totalTests, totalFailedTests, isFailed);
builder.append(", ");
makeStatsLine(builder, "asserts", totalAsserts, totalFailedAsserts, isFailed);
}
builder.append('\n');
writer.append(builder.toString());
}
private void reportResults(TestSectionResults section, AnsiCodingStringBuilder builder) {
if (!section.results().isEmpty()) {
builder.append(" ").append(section.name()).append('\n');
StringUtils.joinToStringBuilder(
builder, section.results(), "\n", res -> reportResult(res, builder));
builder.append("\n");
builder.append('\n');
}
}
private void reportResult(TestResult result, StringBuilder builder) {
private void reportResult(TestResult result, AnsiCodingStringBuilder builder) {
builder.append(" ");
if (result.isExampleWritten()) {
builder.append("✍️ ").append(result.name());
} else {
builder.append(result.isFailure() ? "" : "").append(result.name());
if (result.isFailure()) {
builder.append(AnsiTheme.FAILING_TEST_MARK, failingMark);
} else {
builder.append(AnsiTheme.PASSING_TEST_MARK, passingMark);
}
builder.append(AnsiTheme.TEST_NAME, result.name());
if (result.isFailure()) {
var failurePadding = " ";
builder.append("\n");
@@ -96,7 +125,7 @@ public final class SimpleReport implements TestReport {
}
}
private static void appendPadded(StringBuilder builder, String lines, String padding) {
private static void appendPadded(AnsiCodingStringBuilder builder, String lines, String padding) {
StringUtils.joinToStringBuilder(
builder,
lines.lines().collect(Collectors.toList()),
@@ -106,18 +135,21 @@ public final class SimpleReport implements TestReport {
});
}
private String makeStatsLine(String kind, int total, int failed, boolean isFailed) {
private void makeStatsLine(
AnsiCodingStringBuilder sb, String kind, int total, int failed, boolean isFailed) {
var passed = total - failed;
var passRate = total > 0 ? 100.0 * passed / total : 0.0;
String line = String.format("%.1f%% %s pass", passRate, kind);
var color = isFailed ? AnsiCode.RED : AnsiCode.GREEN;
sb.append(
color,
() ->
sb.append(String.format("%.1f%%", passRate)).append(" ").append(kind).append(" pass"));
if (isFailed) {
line += String.format(" [%d/%d failed]", failed, total);
sb.append(" [").append(failed).append('/').append(total).append(" failed]");
} else {
line += String.format(" [%d passed]", passed);
sb.append(" [").append(passed).append(" passed]");
}
return line;
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
*
* 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
*
* https://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 org.pkl.core.util;
import java.util.EnumSet;
import java.util.Set;
import org.pkl.core.runtime.AnsiCodingStringBuilder.AnsiCode;
public final class AnsiTheme {
private AnsiTheme() {}
public static final AnsiCode ERROR_MESSAGE_HINT = AnsiCode.YELLOW;
public static final AnsiCode ERROR_HEADER = AnsiCode.RED;
public static final Set<AnsiCode> ERROR_MESSAGE = EnumSet.of(AnsiCode.RED, AnsiCode.BOLD);
public static final AnsiCode STACK_FRAME = AnsiCode.FAINT;
public static final AnsiCode STACK_TRACE_MARGIN = AnsiCode.YELLOW;
public static final AnsiCode STACK_TRACE_LINE_NUMBER = AnsiCode.BLUE;
public static final AnsiCode STACK_TRACE_LOOP_COUNT = AnsiCode.MAGENTA;
public static final AnsiCode STACK_TRACE_CARET = AnsiCode.RED;
public static final AnsiCode FAILING_TEST_MARK = AnsiCode.RED;
public static final AnsiCode PASSING_TEST_MARK = AnsiCode.GREEN;
public static final AnsiCode TEST_NAME = AnsiCode.FAINT;
public static final AnsiCode TEST_FACT_SOURCE = AnsiCode.RED;
public static final AnsiCode TEST_FAILURE_MESSAGE = AnsiCode.RED;
public static final Set<AnsiCode> TEST_EXAMPLE_OUTPUT = EnumSet.of(AnsiCode.RED, AnsiCode.BOLD);
}

View File

@@ -16,6 +16,7 @@
package org.pkl.core.util;
import java.util.function.Consumer;
import org.pkl.core.runtime.AnsiCodingStringBuilder;
// Some code in this class was taken from the following Google Guava classes:
// * com.google.common.base.CharMatcher
@@ -76,7 +77,7 @@ public final class StringUtils {
public static <T> void joinToStringBuilder(
StringBuilder builder, Iterable<T> coll, String delimiter, Consumer<T> eachFn) {
int i = 0;
var i = 0;
for (var v : coll) {
if (i++ != 0) {
builder.append(delimiter);
@@ -85,6 +86,18 @@ public final class StringUtils {
}
}
public static <T> void joinToStringBuilder(
AnsiCodingStringBuilder builder, Iterable<T> coll, String delimiter, Consumer<T> eachFn) {
var i = 0;
for (var v : coll) {
if (i != 0) {
builder.append(delimiter);
}
eachFn.accept(v);
i++;
}
}
public static <T> void joinToStringBuilder(
StringBuilder builder, Iterable<T> coll, String delimiter) {
joinToStringBuilder(builder, coll, delimiter, builder::append);

View File

@@ -125,7 +125,7 @@ class EvaluateTestsTest {
val error = res.errors[0]
assertThat(error.message).isEqualTo("got an error")
assertThat(error.exception.message)
assertThat(error.exception().message)
.isEqualTo(
"""
Pkl Error
@@ -347,7 +347,7 @@ class EvaluateTestsTest {
assertThat(fail1.message.stripFileAndLines(tempDir))
.isEqualTo(
"""
#0 (/tempDir/example.pkl):
#0: (/tempDir/example.pkl)
Expected: (/tempDir/example.pkl-expected.pcf)
new {
name = "Alice"

View File

@@ -0,0 +1,76 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
*
* 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
*
* https://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 org.pkl.core.runtime
import java.util.*
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.pkl.core.runtime.AnsiCodingStringBuilder.AnsiCode
class AnsiCodingStringBuilderTest {
@Test
fun `no formatting`() {
val result = AnsiCodingStringBuilder(false).append(AnsiCode.RED, "hello").toString()
assertThat(result).isEqualTo("hello")
}
private val red = "\u001b[31m"
private val redBold = "\u001b[1;31m"
private val reset = "\u001b[0m"
private val bold = "\u001b[1m"
// make test failures easier to debug
private val String.escaped
get() = replace("\u001b", "[ESC]")
@Test
fun `don't emit same color code`() {
val result =
AnsiCodingStringBuilder(true).append(AnsiCode.RED, "hi").append(AnsiCode.RED, "hi").toString()
assertThat(result.escaped).isEqualTo("${red}hihi${reset}".escaped)
}
@Test
fun `only add needed codes`() {
val result =
AnsiCodingStringBuilder(true)
.append(AnsiCode.RED, "hi")
.append(EnumSet.of(AnsiCode.RED, AnsiCode.BOLD), "hi")
.toString()
assertThat(result.escaped).isEqualTo("${red}hi${bold}hi${reset}".escaped)
}
@Test
fun `reset if need to subtract`() {
val result =
AnsiCodingStringBuilder(true)
.append(EnumSet.of(AnsiCode.RED, AnsiCode.BOLD), "hi")
.append(AnsiCode.RED, "hi")
.toString()
assertThat(result.escaped).isEqualTo("${redBold}hi${reset}${red}hi${reset}".escaped)
}
@Test
fun `plain text in between`() {
val result =
AnsiCodingStringBuilder(true)
.append(AnsiCode.RED, "hi")
.append("hi")
.append(AnsiCode.RED, "hi")
.toString()
assertThat(result.escaped).isEqualTo("${red}hi${reset}hi${red}hi${reset}".escaped)
}
}

View File

@@ -190,7 +190,7 @@ class StackTraceRendererTest {
}
val loop = StackTraceRenderer.StackFrameLoop(loopFrames, 1)
val frames = listOf(createFrame("bar", 1), createFrame("baz", 2), loop)
val formatter = TextFormatter.create(false)
val formatter = AnsiCodingStringBuilder(false)
renderer.doRender(frames, null, formatter, "", true)
val renderedFrames = formatter.toString()
assertThat(renderedFrames)