diff --git a/bench/src/jmh/java/org/pkl/core/ListSort.java b/bench/src/jmh/java/org/pkl/core/ListSort.java index 2a4fc022..1324b53b 100644 --- a/bench/src/jmh/java/org/pkl/core/ListSort.java +++ b/bench/src/jmh/java/org/pkl/core/ListSort.java @@ -50,7 +50,8 @@ public class ListSort { null, null, IoUtils.getCurrentWorkingDir(), - StackFrameTransformers.defaultTransformer); + StackFrameTransformers.defaultTransformer, + false); private static final List list = new ArrayList<>(100000); static { diff --git a/docs/modules/pkl-cli/partials/cli-common-options.adoc b/docs/modules/pkl-cli/partials/cli-common-options.adoc index a8509711..26b5e3fd 100644 --- a/docs/modules/pkl-cli/partials/cli-common-options.adoc +++ b/docs/modules/pkl-cli/partials/cli-common-options.adoc @@ -19,6 +19,19 @@ Patterns are matched against the beginning of resource URIs. At least one pattern needs to match for a resource to be readable. ==== +[[color]] +.--color +[%collapsible] +==== +Default: `auto` + +When to format messages with ANSI color codes. +Possible values: + +- `"never"`: Never format +- `"auto"`: Format if `stdin`, `stdout`, or `stderr` are connected to a console. +- `"always"`: Always format +==== + [[cache-dir]] .--cache-dir [%collapsible] diff --git a/docs/modules/pkl-gradle/partials/gradle-common-properties.adoc b/docs/modules/pkl-gradle/partials/gradle-common-properties.adoc index deaf5117..899b4ccd 100644 --- a/docs/modules/pkl-gradle/partials/gradle-common-properties.adoc +++ b/docs/modules/pkl-gradle/partials/gradle-common-properties.adoc @@ -67,6 +67,13 @@ The cache directory for storing packages. If `null`, defaults to `~/.pkl/cache`. ==== +.color: Property +[%collapsible] +==== +Default: `false` + +Format messages using ANSI color. +==== + .noCache: Property [%collapsible] ==== diff --git a/docs/src/test/kotlin/DocSnippetTests.kt b/docs/src/test/kotlin/DocSnippetTests.kt index 48873584..5a5bb56b 100644 --- a/docs/src/test/kotlin/DocSnippetTests.kt +++ b/docs/src/test/kotlin/DocSnippetTests.kt @@ -96,7 +96,8 @@ class DocSnippetTestsEngine : HierarchicalTestEngine are 'never', 'auto', and 'always'." + ) + .enum { it.name.lowercase() } + .single() + .default(Color.AUTO) + val noCache: Boolean by option(names = arrayOf("--no-cache"), help = "Disable caching of packages") .single() @@ -265,6 +278,7 @@ class BaseOptions : OptionGroup() { projectDir = projectOptions?.projectDir, timeout = timeout, moduleCacheDir = cacheDir ?: defaults.normalizedModuleCacheDir, + color = color, noCache = noCache, testMode = testMode, testPort = testPort, diff --git a/pkl-core/src/main/java/org/pkl/core/Analyzer.java b/pkl-core/src/main/java/org/pkl/core/Analyzer.java index e1122b2b..9a27e6e8 100644 --- a/pkl-core/src/main/java/org/pkl/core/Analyzer.java +++ b/pkl-core/src/main/java/org/pkl/core/Analyzer.java @@ -41,6 +41,7 @@ import org.pkl.core.util.Nullable; /** Utility library for static analysis of Pkl programs. */ public class Analyzer { private final StackFrameTransformer transformer; + private final boolean color; private final SecurityManager securityManager; private final @Nullable Path moduleCacheDir; private final @Nullable DeclaredDependencies projectDependencies; @@ -49,12 +50,14 @@ public class Analyzer { public Analyzer( StackFrameTransformer transformer, + boolean color, SecurityManager securityManager, Collection moduleKeyFactories, @Nullable Path moduleCacheDir, @Nullable DeclaredDependencies projectDependencies, HttpClient httpClient) { this.transformer = transformer; + this.color = color; this.securityManager = securityManager; this.moduleCacheDir = moduleCacheDir; this.projectDependencies = projectDependencies; @@ -82,7 +85,7 @@ public class Analyzer { } catch (PklException err) { throw err; } catch (VmException err) { - throw err.toPklException(transformer); + throw err.toPklException(transformer, color); } catch (Exception e) { throw new PklBugException(e); } finally { diff --git a/pkl-core/src/main/java/org/pkl/core/EvaluatorBuilder.java b/pkl-core/src/main/java/org/pkl/core/EvaluatorBuilder.java index e11838fc..8b5ad5d4 100644 --- a/pkl-core/src/main/java/org/pkl/core/EvaluatorBuilder.java +++ b/pkl-core/src/main/java/org/pkl/core/EvaluatorBuilder.java @@ -61,6 +61,8 @@ public final class EvaluatorBuilder { private @Nullable String outputFormat; + private boolean color = false; + private @Nullable StackFrameTransformer stackFrameTransformer; private @Nullable DeclaredDependencies dependencies; @@ -144,6 +146,17 @@ public final class EvaluatorBuilder { return new EvaluatorBuilder(); } + /** Sets the option to render errors in ANSI color. */ + public EvaluatorBuilder setColor(boolean color) { + this.color = color; + return this; + } + + /** Returns the current setting of the option to render errors in ANSI color. */ + public boolean getColor() { + return color; + } + /** Sets the given stack frame transformer, replacing any previously set transformer. */ public EvaluatorBuilder setStackFrameTransformer(StackFrameTransformer stackFrameTransformer) { this.stackFrameTransformer = stackFrameTransformer; @@ -475,6 +488,9 @@ public final class EvaluatorBuilder { if (settings.rootDir() != null) { setRootDir(settings.rootDir()); } + if (settings.color() != null) { + setColor(settings.color().hasColor()); + } if (Boolean.TRUE.equals(settings.noCache())) { setModuleCacheDir(null); } else if (settings.moduleCacheDir() != null) { @@ -513,6 +529,7 @@ public final class EvaluatorBuilder { return new EvaluatorImpl( stackFrameTransformer, + color, securityManager, httpClient, new LoggerImpl(logger, stackFrameTransformer), diff --git a/pkl-core/src/main/java/org/pkl/core/EvaluatorImpl.java b/pkl-core/src/main/java/org/pkl/core/EvaluatorImpl.java index 4910e6b9..52f2ae26 100644 --- a/pkl-core/src/main/java/org/pkl/core/EvaluatorImpl.java +++ b/pkl-core/src/main/java/org/pkl/core/EvaluatorImpl.java @@ -57,6 +57,7 @@ import org.pkl.core.util.Nullable; public class EvaluatorImpl implements Evaluator { protected final StackFrameTransformer frameTransformer; + protected final boolean color; protected final ModuleResolver moduleResolver; protected final Context polyglotContext; protected final @Nullable Duration timeout; @@ -68,6 +69,7 @@ public class EvaluatorImpl implements Evaluator { public EvaluatorImpl( StackFrameTransformer transformer, + boolean color, SecurityManager manager, HttpClient httpClient, Logger logger, @@ -82,6 +84,7 @@ public class EvaluatorImpl implements Evaluator { securityManager = manager; frameTransformer = transformer; + this.color = color; moduleResolver = new ModuleResolver(factories); this.logger = new BufferedLogger(logger); packageResolver = PackageResolver.getInstance(securityManager, httpClient, moduleCacheDir); @@ -304,20 +307,20 @@ public class EvaluatorImpl implements Evaluator { .bug("Stack overflow") .withCause(e.getCause()) .build() - .toPklException(frameTransformer); + .toPklException(frameTransformer, color); } handleTimeout(timeoutTask); - throw e.toPklException(frameTransformer); + throw e.toPklException(frameTransformer, color); } catch (VmException e) { handleTimeout(timeoutTask); - throw e.toPklException(frameTransformer); + throw e.toPklException(frameTransformer, color); } catch (Exception e) { throw new PklBugException(e); } catch (ExceptionInInitializerError e) { if (!(e.getCause() instanceof VmException vmException)) { throw new PklBugException(e); } - var pklException = vmException.toPklException(frameTransformer); + var pklException = vmException.toPklException(frameTransformer, color); var error = new ExceptionInInitializerError(pklException); error.setStackTrace(e.getStackTrace()); throw new PklBugException(error); diff --git a/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/Color.java b/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/Color.java new file mode 100644 index 00000000..f9b1d05d --- /dev/null +++ b/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/Color.java @@ -0,0 +1,30 @@ +/* + * 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.evaluatorSettings; + +public enum Color { + NEVER, + AUTO, + ALWAYS; + + public boolean hasColor() { + return switch (this) { + case AUTO -> System.console() != null; + case NEVER -> false; + case ALWAYS -> true; + }; + } +} diff --git a/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java b/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java index 946c5a99..cb470566 100644 --- a/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java +++ b/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java @@ -40,6 +40,7 @@ public record PklEvaluatorSettings( @Nullable Map env, @Nullable List allowedModules, @Nullable List allowedResources, + @Nullable Color color, @Nullable Boolean noCache, @Nullable Path moduleCacheDir, @Nullable List modulePath, @@ -102,11 +103,14 @@ public record PklEvaluatorSettings( Collectors.toMap( Entry::getKey, entry -> ExternalReader.parse(entry.getValue()))); + var color = (String) pSettings.get("color"); + return new PklEvaluatorSettings( (Map) pSettings.get("externalProperties"), (Map) pSettings.get("env"), allowedModules, allowedResources, + color == null ? null : Color.valueOf(color.toUpperCase()), (Boolean) pSettings.get("noCache"), moduleCacheDir, modulePath, @@ -198,6 +202,7 @@ public record PklEvaluatorSettings( && Objects.equals(env, that.env) && arePatternsEqual(allowedModules, that.allowedModules) && arePatternsEqual(allowedResources, that.allowedResources) + && Objects.equals(color, that.color) && Objects.equals(noCache, that.noCache) && Objects.equals(moduleCacheDir, that.moduleCacheDir) && Objects.equals(timeout, that.timeout) @@ -219,7 +224,8 @@ public record PklEvaluatorSettings( @Override public int hashCode() { var result = - Objects.hash(externalProperties, env, noCache, moduleCacheDir, timeout, rootDir, http); + Objects.hash( + externalProperties, env, color, noCache, moduleCacheDir, timeout, rootDir, http); result = 31 * result + hashPatterns(allowedModules); result = 31 * result + hashPatterns(allowedResources); return result; diff --git a/pkl-core/src/main/java/org/pkl/core/project/Project.java b/pkl-core/src/main/java/org/pkl/core/project/Project.java index 59d122cf..ab3ff31a 100644 --- a/pkl-core/src/main/java/org/pkl/core/project/Project.java +++ b/pkl-core/src/main/java/org/pkl/core/project/Project.java @@ -143,7 +143,7 @@ public final class Project { .build(); } // stack frame transformer never used; this exception has no stack frames. - throw vmException.toPklException(StackFrameTransformers.defaultTransformer); + throw vmException.toPklException(StackFrameTransformers.defaultTransformer, false); } throw e; } catch (URISyntaxException e) { @@ -192,6 +192,7 @@ public final class Project { var analyzer = new Analyzer( StackFrameTransformers.defaultTransformer, + builder.getColor(), SecurityManagers.defaultManager, builder.getModuleKeyFactories(), builder.getModuleCacheDir(), @@ -517,6 +518,7 @@ public final class Project { env, allowedModules, allowedResources, + null, noCache, moduleCacheDir, modulePath, diff --git a/pkl-core/src/main/java/org/pkl/core/project/ProjectPackager.java b/pkl-core/src/main/java/org/pkl/core/project/ProjectPackager.java index f5fd79f1..2cf38d3b 100644 --- a/pkl-core/src/main/java/org/pkl/core/project/ProjectPackager.java +++ b/pkl-core/src/main/java/org/pkl/core/project/ProjectPackager.java @@ -98,6 +98,7 @@ public final class ProjectPackager { private final Path workingDir; private final String outputPathPattern; private final StackFrameTransformer stackFrameTransformer; + private final boolean color; private final SecurityManager securityManager; private final PackageResolver packageResolver; private final boolean skipPublishCheck; @@ -108,6 +109,7 @@ public final class ProjectPackager { Path workingDir, String outputPathPattern, StackFrameTransformer stackFrameTransformer, + boolean color, SecurityManager securityManager, HttpClient httpClient, boolean skipPublishCheck, @@ -116,6 +118,7 @@ public final class ProjectPackager { this.workingDir = workingDir; this.outputPathPattern = outputPathPattern; this.stackFrameTransformer = stackFrameTransformer; + this.color = color; this.securityManager = securityManager; // intentionally use InMemoryPackageResolver this.packageResolver = PackageResolver.getInstance(securityManager, httpClient, null); @@ -409,14 +412,14 @@ public final class ProjectPackager { .evalError("invalidModuleUri", importStr) .withSourceSection(sourceSection) .build() - .toPklException(stackFrameTransformer); + .toPklException(stackFrameTransformer, color); } if (importStr.startsWith("/") && !project.getProjectDir().toString().equals("/")) { throw new VmExceptionBuilder() .evalError("invalidRelativeProjectImport", importStr) .withSourceSection(sourceSection) .build() - .toPklException(stackFrameTransformer); + .toPklException(stackFrameTransformer, color); } var currentPath = pklModulePath.getParent(); var importPath = Path.of(importUri.getPath()); @@ -433,7 +436,7 @@ public final class ProjectPackager { .evalError("invalidRelativeProjectImport", importStr) .withSourceSection(sourceSection) .build() - .toPklException(stackFrameTransformer); + .toPklException(stackFrameTransformer, color); } } } diff --git a/pkl-core/src/main/java/org/pkl/core/repl/ReplServer.java b/pkl-core/src/main/java/org/pkl/core/repl/ReplServer.java index 96162fe3..3b437a29 100644 --- a/pkl-core/src/main/java/org/pkl/core/repl/ReplServer.java +++ b/pkl-core/src/main/java/org/pkl/core/repl/ReplServer.java @@ -78,12 +78,13 @@ public class ReplServer implements AutoCloseable { @Nullable DeclaredDependencies projectDependencies, @Nullable String outputFormat, Path workingDir, - StackFrameTransformer frameTransformer) { + StackFrameTransformer frameTransformer, + boolean color) { this.workingDir = workingDir; this.securityManager = securityManager; this.moduleResolver = new ModuleResolver(moduleKeyFactories); - this.errorRenderer = new VmExceptionRenderer(new StackTraceRenderer(frameTransformer)); + this.errorRenderer = new VmExceptionRenderer(new StackTraceRenderer(frameTransformer), color); replState = new ReplState(createEmptyReplModule(BaseModule.getModuleClass().getPrototype())); var languageRef = new MutableReference(null); diff --git a/pkl-core/src/main/java/org/pkl/core/runtime/StackTraceRenderer.java b/pkl-core/src/main/java/org/pkl/core/runtime/StackTraceRenderer.java index 202bcfcc..56aa8486 100644 --- a/pkl-core/src/main/java/org/pkl/core/runtime/StackTraceRenderer.java +++ b/pkl-core/src/main/java/org/pkl/core/runtime/StackTraceRenderer.java @@ -19,6 +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.Nullable; public final class StackTraceRenderer { @@ -28,66 +29,68 @@ public final class StackTraceRenderer { this.frameTransformer = frameTransformer; } - public void render(List frames, @Nullable String hint, StringBuilder builder) { + public void render(List frames, @Nullable String hint, TextFormatter out) { var compressed = compressFrames(frames); - doRender(compressed, hint, builder, "", true); + doRender(compressed, hint, out, "", true); } // non-private for testing void doRender( List frames, @Nullable String hint, - StringBuilder builder, + TextFormatter out, String leftMargin, boolean isFirstElement) { for (var frame : frames) { if (frame instanceof StackFrameLoop loop) { // ensure a cycle of length 1 doesn't get rendered as a loop if (loop.count == 1) { - doRender(loop.frames, null, builder, leftMargin, isFirstElement); + doRender(loop.frames, null, out, leftMargin, isFirstElement); } else { if (!isFirstElement) { - builder.append(leftMargin).append("\n"); + out.margin(leftMargin).newline(); } - builder.append(leftMargin).append("┌─ ").append(loop.count).append(" repetitions of:\n"); + out.margin(leftMargin) + .margin("┌─ ") + .style(Element.STACK_OVERFLOW_LOOP_COUNT) + .append(loop.count) + .style(Element.TEXT) + .append(" repetitions of:\n"); var newLeftMargin = leftMargin + "│ "; - doRender(loop.frames, null, builder, newLeftMargin, isFirstElement); + doRender(loop.frames, null, out, newLeftMargin, isFirstElement); if (isFirstElement) { - renderHint(hint, builder, newLeftMargin); + renderHint(hint, out, newLeftMargin); isFirstElement = false; } - builder.append(leftMargin).append("└─\n"); + out.margin(leftMargin).margin("└─").newline(); } } else { if (!isFirstElement) { - builder.append(leftMargin).append('\n'); + out.margin(leftMargin).newline(); } - renderFrame((StackFrame) frame, builder, leftMargin); + renderFrame((StackFrame) frame, out, leftMargin); } if (isFirstElement) { - renderHint(hint, builder, leftMargin); + renderHint(hint, out, leftMargin); isFirstElement = false; } } } - private void renderFrame(StackFrame frame, StringBuilder builder, String leftMargin) { + private void renderFrame(StackFrame frame, TextFormatter out, String leftMargin) { var transformed = frameTransformer.apply(frame); - renderSourceLine(transformed, builder, leftMargin); - renderSourceLocation(transformed, builder, leftMargin); + renderSourceLine(transformed, out, leftMargin); + renderSourceLocation(transformed, out, leftMargin); } - private void renderHint(@Nullable String hint, StringBuilder builder, String leftMargin) { + private void renderHint(@Nullable String hint, TextFormatter out, String leftMargin) { if (hint == null || hint.isEmpty()) return; - builder.append('\n'); - builder.append(leftMargin); - builder.append(hint); - builder.append('\n'); + out.newline().margin(leftMargin).style(Element.HINT).append(hint).newline(); } - private void renderSourceLine(StackFrame frame, StringBuilder builder, String leftMargin) { + private void renderSourceLine(StackFrame frame, TextFormatter out, String leftMargin) { var originalSourceLine = frame.getSourceLines().get(0); var leadingWhitespace = VmUtils.countLeadingWhitespace(originalSourceLine); var sourceLine = originalSourceLine.strip(); @@ -98,27 +101,28 @@ public final class StackTraceRenderer { : sourceLine.length(); var prefix = frame.getStartLine() + " | "; - builder.append(leftMargin).append(prefix).append(sourceLine).append('\n'); - builder.append(leftMargin); - //noinspection StringRepeatCanBeUsed - for (int i = 1; i < prefix.length() + startColumn; i++) { - builder.append(' '); - } - //noinspection StringRepeatCanBeUsed - for (int i = startColumn; i <= endColumn; i++) { - builder.append('^'); - } - builder.append('\n'); + out.margin(leftMargin) + .style(Element.LINE_NUMBER) + .append(prefix) + .style(Element.TEXT) + .append(sourceLine) + .newline() + .margin(leftMargin) + .repeat(prefix.length() + startColumn - 1, ' ') + .style(Element.ERROR) + .repeat(endColumn - startColumn + 1, '^') + .newline(); } - private void renderSourceLocation(StackFrame frame, StringBuilder builder, String leftMargin) { - builder.append(leftMargin).append("at "); - if (frame.getMemberName() != null) { - builder.append(frame.getMemberName()); - } else { - builder.append(""); - } - builder.append(" (").append(frame.getModuleUri()).append(')').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() : "") + .append(" (") + .append(frame.getModuleUri()) + .append(")") + .newline(); } /** diff --git a/pkl-core/src/main/java/org/pkl/core/runtime/TestRunner.java b/pkl-core/src/main/java/org/pkl/core/runtime/TestRunner.java index 606dc6a7..2660c99a 100644 --- a/pkl-core/src/main/java/org/pkl/core/runtime/TestRunner.java +++ b/pkl-core/src/main/java/org/pkl/core/runtime/TestRunner.java @@ -59,7 +59,8 @@ public final class TestRunner { try { checkAmendsPklTest(testModule); } catch (VmException v) { - var error = new TestResults.Error(v.getMessage(), v.toPklException(stackFrameTransformer)); + var error = + new TestResults.Error(v.getMessage(), v.toPklException(stackFrameTransformer, false)); return resultsBuilder.setError(error).build(); } @@ -108,7 +109,7 @@ public final class TestRunner { } catch (VmException err) { var error = new TestResults.Error( - err.getMessage(), err.toPklException(stackFrameTransformer)); + err.getMessage(), err.toPklException(stackFrameTransformer, false)); resultBuilder.addError(error); } return true; @@ -208,7 +209,7 @@ public final class TestRunner { errored.set(true); testResultBuilder.addError( new TestResults.Error( - err.getMessage(), err.toPklException(stackFrameTransformer))); + err.getMessage(), err.toPklException(stackFrameTransformer, false))); return true; } var expectedValue = VmUtils.readMember(expectedGroup, exampleIndex); @@ -305,7 +306,7 @@ public final class TestRunner { } catch (VmException err) { testResultBuilder.addError( new TestResults.Error( - err.getMessage(), err.toPklException(stackFrameTransformer))); + err.getMessage(), err.toPklException(stackFrameTransformer, false))); allSucceeded.set(false); success.set(false); return true; diff --git a/pkl-core/src/main/java/org/pkl/core/runtime/TextFormatter.java b/pkl-core/src/main/java/org/pkl/core/runtime/TextFormatter.java new file mode 100644 index 00000000..7e414128 --- /dev/null +++ b/pkl-core/src/main/java/org/pkl/core/runtime/TextFormatter.java @@ -0,0 +1,185 @@ +/* + * 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 THEME_PLAIN = new HashMap<>(); + public static final Map 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 theme; + private final StringBuilder builder = new StringBuilder(); + + private @Nullable Styling currentStyle; + + private TextFormatter(Map 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; + } + } +} diff --git a/pkl-core/src/main/java/org/pkl/core/runtime/VmBugException.java b/pkl-core/src/main/java/org/pkl/core/runtime/VmBugException.java index 01c35884..4c4f12d6 100644 --- a/pkl-core/src/main/java/org/pkl/core/runtime/VmBugException.java +++ b/pkl-core/src/main/java/org/pkl/core/runtime/VmBugException.java @@ -58,8 +58,8 @@ public final class VmBugException extends VmException { @Override @TruffleBoundary - public PklException toPklException(StackFrameTransformer transformer) { - var renderer = new VmExceptionRenderer(new StackTraceRenderer(transformer)); + public PklException toPklException(StackFrameTransformer transformer, boolean color) { + var renderer = new VmExceptionRenderer(new StackTraceRenderer(transformer), color); var rendered = renderer.render(this); return new PklBugException(rendered, this); } diff --git a/pkl-core/src/main/java/org/pkl/core/runtime/VmException.java b/pkl-core/src/main/java/org/pkl/core/runtime/VmException.java index f6ed7619..fef997b3 100644 --- a/pkl-core/src/main/java/org/pkl/core/runtime/VmException.java +++ b/pkl-core/src/main/java/org/pkl/core/runtime/VmException.java @@ -117,8 +117,8 @@ public abstract class VmException extends AbstractTruffleException { } @TruffleBoundary - public PklException toPklException(StackFrameTransformer transformer) { - var renderer = new VmExceptionRenderer(new StackTraceRenderer(transformer)); + public PklException toPklException(StackFrameTransformer transformer, boolean color) { + var renderer = new VmExceptionRenderer(new StackTraceRenderer(transformer), color); var rendered = renderer.render(this); return new PklException(rendered); } diff --git a/pkl-core/src/main/java/org/pkl/core/runtime/VmExceptionRenderer.java b/pkl-core/src/main/java/org/pkl/core/runtime/VmExceptionRenderer.java index 9f72bb0d..e6aade01 100644 --- a/pkl-core/src/main/java/org/pkl/core/runtime/VmExceptionRenderer.java +++ b/pkl-core/src/main/java/org/pkl/core/runtime/VmExceptionRenderer.java @@ -16,66 +16,67 @@ package org.pkl.core.runtime; import com.oracle.truffle.api.CompilerDirectives.TruffleBoundary; -import java.io.PrintWriter; 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.ErrorMessages; import org.pkl.core.util.Nullable; -import org.pkl.core.util.StringBuilderWriter; public final class VmExceptionRenderer { private final @Nullable StackTraceRenderer stackTraceRenderer; + private final boolean color; /** * Constructs an error renderer with the given stack trace renderer. If stack trace renderer is * {@code null}, stack traces will not be included in error output. */ - public VmExceptionRenderer(@Nullable StackTraceRenderer stackTraceRenderer) { + public VmExceptionRenderer(@Nullable StackTraceRenderer stackTraceRenderer, boolean color) { this.stackTraceRenderer = stackTraceRenderer; + this.color = color; } @TruffleBoundary public String render(VmException exception) { - var builder = new StringBuilder(); - render(exception, builder); - return builder.toString(); + var formatter = TextFormatter.create(color); + render(exception, formatter); + return formatter.toString(); } - private void render(VmException exception, StringBuilder builder) { + private void render(VmException exception, TextFormatter out) { if (exception instanceof VmBugException bugException) { - renderBugException(bugException, builder); + renderBugException(bugException, out); } else { - renderException(exception, builder, true); + renderException(exception, out, true); } } - private void renderBugException(VmBugException exception, StringBuilder builder) { + private void renderBugException(VmBugException exception, TextFormatter 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); - builder - .append("An unexpected error has occurred. Would you mind filing a bug report?\n") - .append("Cmd+Double-click the link below to open an issue.\n") - .append( - "Please copy and paste the entire error output into the issue's description, provided you can share it.\n\n") - .append("https://github.com/apple/pkl/issues/new\n\n"); + out.style(Element.TEXT) + .append("An unexpected error has occurred. Would you mind filing a bug report?") + .newline() + .append("Cmd+Double-click the link below to open an issue.") + .newline() + .append("Please copy and paste the entire error output into the issue's description.") + .newlines(2) + .append("https://github.com/apple/pkl/issues/new") + .newlines(2) + .append(exceptionUrl.replaceAll("\\+", "%20")) + .newlines(2); - builder.append( - URLEncoder.encode(exceptionToReport.toString(), StandardCharsets.UTF_8) - .replaceAll("\\+", "%20")); + renderException(exception, out, true); - builder.append("\n\n"); - renderException(exception, builder, true); - builder.append('\n').append(Release.current().versionInfo()).append("\n\n"); + out.newline().style(Element.TEXT).append(Release.current().versionInfo()).newlines(2); - exceptionToReport.printStackTrace(new PrintWriter(new StringBuilderWriter(builder))); + exceptionToReport.printStackTrace(out.toPrintWriter()); } - private void renderException(VmException exception, StringBuilder builder, boolean withHeader) { - var header = "–– Pkl Error ––"; - + private void renderException(VmException exception, TextFormatter out, boolean withHeader) { String message; var hint = exception.getHint(); if (exception.isExternalMessage()) { @@ -96,15 +97,9 @@ public final class VmExceptionRenderer { } if (withHeader) { - builder.append(header).append('\n'); - } - builder.append(message).append('\n'); - - if (exception instanceof VmWrappedEvalException vmWrappedEvalException) { - var sb = new StringBuilder(); - renderException(vmWrappedEvalException.getWrappedException(), sb, false); - hint = sb.toString().lines().map((it) -> ">\t" + it).collect(Collectors.joining("\n")); + out.style(Element.ERROR_HEADER).append("–– Pkl Error ––").newline(); } + out.style(Element.ERROR).append(message).newline(); // include cause's message unless it's the same as this exception's message if (exception.getCause() != null) { @@ -112,11 +107,11 @@ public final class VmExceptionRenderer { var causeMessage = cause.getMessage(); // null for Truffle's LazyStackTrace if (causeMessage != null && !causeMessage.equals(message)) { - builder + out.style(Element.TEXT) .append(cause.getClass().getSimpleName()) .append(": ") .append(causeMessage) - .append('\n'); + .newline(); } } @@ -124,22 +119,28 @@ public final class VmExceptionRenderer { exception.getProgramValues().stream().mapToInt(v -> v.name.length()).max().orElse(0); for (var value : exception.getProgramValues()) { - builder.append(value.name); - builder.append(" ".repeat(Math.max(0, maxNameLength - value.name.length()))); - builder.append(": "); - builder.append(value); - builder.append('\n'); + out.style(Element.TEXT) + .append(value.name) + .repeat(Math.max(0, maxNameLength - value.name.length()), ' ') + .append(": ") + .append(value) + .newline(); } if (stackTraceRenderer != null) { var frames = StackTraceGenerator.capture(exception); + + if (exception instanceof VmWrappedEvalException vmWrappedEvalException) { + var sb = out.newInstance(); + renderException(vmWrappedEvalException.getWrappedException(), sb, false); + hint = sb.toString().lines().map((it) -> ">\t" + it).collect(Collectors.joining("\n")); + } + if (!frames.isEmpty()) { - builder.append('\n'); - stackTraceRenderer.render(frames, hint, builder); + stackTraceRenderer.render(frames, hint, out.newline()); } else if (hint != null) { // render hint if there are no stack frames - builder.append('\n'); - builder.append(hint); + out.newline().style(Element.HINT).append(hint); } } } diff --git a/pkl-core/src/main/java/org/pkl/core/stdlib/test/TestNodes.java b/pkl-core/src/main/java/org/pkl/core/stdlib/test/TestNodes.java index 52efe9e5..b40643d6 100644 --- a/pkl-core/src/main/java/org/pkl/core/stdlib/test/TestNodes.java +++ b/pkl-core/src/main/java/org/pkl/core/stdlib/test/TestNodes.java @@ -28,7 +28,7 @@ import org.pkl.core.stdlib.PklName; public final class TestNodes { private static final VmExceptionRenderer noStackTraceExceptionRenderer = - new VmExceptionRenderer(null); + new VmExceptionRenderer(null, false); private TestNodes() {} diff --git a/pkl-core/src/test/kotlin/org/pkl/core/AnalyzerTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/AnalyzerTest.kt index 539cb759..4774ecf6 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/AnalyzerTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/AnalyzerTest.kt @@ -31,6 +31,7 @@ class AnalyzerTest { private val simpleAnalyzer = Analyzer( StackFrameTransformers.defaultTransformer, + false, SecurityManagers.defaultManager, listOf(ModuleKeyFactories.file, ModuleKeyFactories.standardLibrary, ModuleKeyFactories.pkg), null, @@ -108,6 +109,7 @@ class AnalyzerTest { val analyzer = Analyzer( StackFrameTransformers.defaultTransformer, + false, SecurityManagers.defaultManager, listOf(ModuleKeyFactories.file, ModuleKeyFactories.standardLibrary, ModuleKeyFactories.pkg), tempDir.resolve("packages"), @@ -177,6 +179,7 @@ class AnalyzerTest { val analyzer = Analyzer( StackFrameTransformers.defaultTransformer, + false, SecurityManagers.defaultManager, listOf( ModuleKeyFactories.file, @@ -288,6 +291,7 @@ class AnalyzerTest { val analyzer = Analyzer( StackFrameTransformers.defaultTransformer, + false, SecurityManagers.defaultManager, listOf( ModuleKeyFactories.file, diff --git a/pkl-core/src/test/kotlin/org/pkl/core/ReplServerTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/ReplServerTest.kt index 9da6a9c4..11b0c9c1 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/ReplServerTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/ReplServerTest.kt @@ -43,7 +43,8 @@ class ReplServerTest { null, null, "/".toPath(), - StackFrameTransformers.defaultTransformer + StackFrameTransformers.defaultTransformer, + false, ) @Test diff --git a/pkl-core/src/test/kotlin/org/pkl/core/ast/builder/ImportsAndReadsParserTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/ast/builder/ImportsAndReadsParserTest.kt index 040f6f14..2a23443d 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/ast/builder/ImportsAndReadsParserTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/ast/builder/ImportsAndReadsParserTest.kt @@ -78,7 +78,7 @@ class ImportsAndReadsParserTest { assertThrows { ImportsAndReadsParser.parse(moduleKey, moduleKey.resolve(SecurityManagers.defaultManager)) } - assertThat(err.toPklException(StackFrameTransformers.defaultTransformer)) + assertThat(err.toPklException(StackFrameTransformers.defaultTransformer, false)) .hasMessage( """ –– Pkl Error –– diff --git a/pkl-core/src/test/kotlin/org/pkl/core/project/ProjectTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/project/ProjectTest.kt index 6865c409..51b0c235 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/project/ProjectTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/project/ProjectTest.kt @@ -65,6 +65,7 @@ class ProjectTest { mapOf("one" to "1"), listOf("foo:", "bar:").map(Pattern::compile), listOf("baz:", "biz:").map(Pattern::compile), + null, false, path.resolve("cache/"), listOf(path.resolve("modulepath1/"), path.resolve("modulepath2/")), diff --git a/pkl-core/src/test/kotlin/org/pkl/core/runtime/StackTraceRendererTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/runtime/StackTraceRendererTest.kt index bb757a18..75506089 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/runtime/StackTraceRendererTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/runtime/StackTraceRendererTest.kt @@ -190,7 +190,9 @@ class StackTraceRendererTest { } val loop = StackTraceRenderer.StackFrameLoop(loopFrames, 1) val frames = listOf(createFrame("bar", 1), createFrame("baz", 2), loop) - val renderedFrames = buildString { renderer.doRender(frames, null, this, "", true) } + val formatter = TextFormatter.create(false) + renderer.doRender(frames, null, formatter, "", true) + val renderedFrames = formatter.toString() assertThat(renderedFrames) .isEqualTo( """ diff --git a/pkl-core/src/test/resources/org/pkl/core/project/project1/PklProject b/pkl-core/src/test/resources/org/pkl/core/project/project1/PklProject index 35c76e48..af9b32df 100644 --- a/pkl-core/src/test/resources/org/pkl/core/project/project1/PklProject +++ b/pkl-core/src/test/resources/org/pkl/core/project/project1/PklProject @@ -19,6 +19,7 @@ evaluatorSettings { ["two"] = "2" } moduleCacheDir = "my-cache-dir/" + color = "always" noCache = false rootDir = "my-root-dir/" timeout = 5.min diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java b/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java index f0208778..86a0c234 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java @@ -445,6 +445,7 @@ public class PklPlugin implements Plugin { task.getModulePath().from(spec.getModulePath()); task.getSettingsModule().set(spec.getSettingsModule()); task.getEvalRootDir().set(spec.getEvalRootDir()); + task.getColor().set(spec.getColor()); task.getNoCache().set(spec.getNoCache()); task.getModuleCacheDir().set(spec.getModuleCacheDir()); task.getEvalTimeout().set(spec.getEvalTimeout()); diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/spec/BasePklSpec.java b/pkl-gradle/src/main/java/org/pkl/gradle/spec/BasePklSpec.java index 0fe0513c..5702c952 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/spec/BasePklSpec.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/spec/BasePklSpec.java @@ -45,6 +45,8 @@ public interface BasePklSpec { DirectoryProperty getModuleCacheDir(); + Property getColor(); + Property getNoCache(); // use same type (Duration) as Gradle's `Task.timeout` diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java index fdd7c2c2..eea79ccf 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java @@ -48,6 +48,7 @@ import org.gradle.api.tasks.Internal; import org.gradle.api.tasks.Optional; import org.gradle.api.tasks.TaskAction; import org.pkl.commons.cli.CliBaseOptions; +import org.pkl.core.evaluatorSettings.Color; import org.pkl.core.util.IoUtils; import org.pkl.core.util.LateInit; import org.pkl.core.util.Nullable; @@ -117,6 +118,10 @@ public abstract class BasePklTask extends DefaultTask { @Internal public abstract DirectoryProperty getModuleCacheDir(); + @Input + @Optional + public abstract Property getColor(); + @Input @Optional public abstract Property getNoCache(); @@ -164,6 +169,7 @@ public abstract class BasePklTask extends DefaultTask { null, getEvalTimeout().getOrNull(), mapAndGetOrNull(getModuleCacheDir(), it1 -> it1.getAsFile().toPath()), + getColor().getOrElse(false) ? Color.ALWAYS : Color.NEVER, getNoCache().getOrElse(false), false, false, diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/ModulesTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/ModulesTask.java index 59a5ea97..46a38caf 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/ModulesTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/ModulesTask.java @@ -36,6 +36,7 @@ import org.gradle.api.tasks.Internal; import org.gradle.api.tasks.Optional; import org.gradle.api.tasks.TaskAction; import org.pkl.commons.cli.CliBaseOptions; +import org.pkl.core.evaluatorSettings.Color; import org.pkl.core.util.IoUtils; import org.pkl.core.util.Pair; @@ -175,6 +176,7 @@ public abstract class ModulesTask extends BasePklTask { getProjectDir().isPresent() ? getProjectDir().get().getAsFile().toPath() : null, getEvalTimeout().getOrNull(), mapAndGetOrNull(getModuleCacheDir(), it1 -> it1.getAsFile().toPath()), + getColor().getOrElse(false) ? Color.ALWAYS : Color.NEVER, getNoCache().getOrElse(false), getOmitProjectSettings().getOrElse(false), getNoProject().getOrElse(false), diff --git a/pkl-server/src/main/kotlin/org/pkl/server/BinaryEvaluator.kt b/pkl-server/src/main/kotlin/org/pkl/server/BinaryEvaluator.kt index af9161ac..bb2db155 100644 --- a/pkl-server/src/main/kotlin/org/pkl/server/BinaryEvaluator.kt +++ b/pkl-server/src/main/kotlin/org/pkl/server/BinaryEvaluator.kt @@ -42,6 +42,7 @@ internal class BinaryEvaluator( ) : EvaluatorImpl( transformer, + false, manager, httpClient, logger, diff --git a/stdlib/EvaluatorSettings.pkl b/stdlib/EvaluatorSettings.pkl index 7b50c562..ac37515b 100644 --- a/stdlib/EvaluatorSettings.pkl +++ b/stdlib/EvaluatorSettings.pkl @@ -67,6 +67,16 @@ allowedModules: Listing? /// ``` allowedResources: Listing? +/// When to format messages with ANSI color codes. +/// +/// Possible values: +/// +/// - `"never"`: Never format +/// - `"auto"`: Format if the process' stdin, stdout, or stderr are connected to a console. +/// - `"always"`: Always format +@Since { version = "0.27.0" } +color: ("never"|"auto"|"always")? + /// Disables the file system cache for `package:` modules. /// /// When caching is disabled, packages are loaded over the network and stored in memory.