Add syntax highlighting of Pkl code (#1385)

This adds syntax highlighting of Pkl code!

It adds highlighting for:

* Stack frames within error messages
* CLI REPL (highlights as you type, highlights error output)
* Power assertions (coming in https://github.com/apple/pkl/pull/1384)

This uses the lexer for highlighting. It will highlight strings,
numbers, keywords, but doesn't understand how to highlight nodes like
types, function params, etc.
The reason for this is because a single line of code by itself may not
be grammatically valid.
This commit is contained in:
Daniel Chao
2026-01-06 10:33:11 -08:00
committed by GitHub
parent 4f4f03dbca
commit 6b9c670cfd
9 changed files with 315 additions and 27 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 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.
@@ -45,10 +45,12 @@ import org.pkl.core.repl.ReplResponse.EvalSuccess;
import org.pkl.core.repl.ReplResponse.InvalidRequest;
import org.pkl.core.resource.ResourceReader;
import org.pkl.core.runtime.*;
import org.pkl.core.util.AnsiStringBuilder;
import org.pkl.core.util.EconomicMaps;
import org.pkl.core.util.IoUtils;
import org.pkl.core.util.MutableReference;
import org.pkl.core.util.Nullable;
import org.pkl.core.util.SyntaxHighlighter;
import org.pkl.parser.Parser;
import org.pkl.parser.ParserError;
import org.pkl.parser.syntax.Class;
@@ -69,6 +71,7 @@ public class ReplServer implements AutoCloseable {
private final VmExceptionRenderer errorRenderer;
private final PackageResolver packageResolver;
private final @Nullable ProjectDependenciesManager projectDependenciesManager;
private final boolean color;
public ReplServer(
SecurityManager securityManager,
@@ -90,6 +93,7 @@ public class ReplServer implements AutoCloseable {
this.securityManager = securityManager;
this.moduleResolver = new ModuleResolver(moduleKeyFactories);
this.errorRenderer = new VmExceptionRenderer(new StackTraceRenderer(frameTransformer), color);
this.color = color;
replState = new ReplState(createEmptyReplModule(BaseModule.getModuleClass().getPrototype()));
var languageRef = new MutableReference<VmLanguage>(null);
@@ -172,7 +176,7 @@ public class ReplServer implements AutoCloseable {
.collect(Collectors.toList());
}
@SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"})
@SuppressWarnings({"StatementWithEmptyBody"})
private List<Object> evaluate(
ReplState replState,
String requestId,
@@ -448,7 +452,10 @@ public class ReplServer implements AutoCloseable {
}
private String render(Object value) {
return VmValueRenderer.multiLine(Integer.MAX_VALUE).render(value);
var sb = new AnsiStringBuilder(color);
var src = VmValueRenderer.multiLine(Integer.MAX_VALUE).render(value);
SyntaxHighlighter.writeTo(sb, src);
return sb.toString();
}
private static class ReplState {

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 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.
@@ -22,6 +22,7 @@ import org.pkl.core.StackFrame;
import org.pkl.core.util.AnsiStringBuilder;
import org.pkl.core.util.AnsiTheme;
import org.pkl.core.util.Nullable;
import org.pkl.core.util.SyntaxHighlighter;
public final class StackTraceRenderer {
private final Function<StackFrame, StackFrame> frameTransformer;
@@ -104,9 +105,11 @@ public final class StackTraceRenderer {
var prefix = frame.getStartLine() + " | ";
out.append(AnsiTheme.STACK_TRACE_MARGIN, leftMargin)
.append(AnsiTheme.STACK_TRACE_LINE_NUMBER, prefix)
.append(sourceLine)
.append('\n')
.append(AnsiTheme.STACK_TRACE_LINE_NUMBER, prefix);
SyntaxHighlighter.writeTo(out, sourceLine);
out.append('\n')
.append(AnsiTheme.STACK_TRACE_MARGIN, leftMargin)
.append(" ".repeat(prefix.length() + startColumn - 1))
.append(AnsiTheme.STACK_TRACE_CARET, "^".repeat(endColumn - startColumn + 1))

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 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.
@@ -19,7 +19,7 @@ import java.io.PrintWriter;
import java.util.EnumSet;
import java.util.Set;
@SuppressWarnings("DuplicatedCode")
@SuppressWarnings({"DuplicatedCode", "UnusedReturnValue"})
public final class AnsiStringBuilder {
private final StringBuilder builder = new StringBuilder();
private final boolean usingColor;
@@ -108,6 +108,25 @@ public final class AnsiStringBuilder {
return this;
}
/** Provides a runnable where anything appended is not affected by the existing context. */
public AnsiStringBuilder appendSandboxed(Runnable runnable) {
if (!usingColor) {
runnable.run();
return this;
}
var myCodes = currentCodes;
var myDeclaredCodes = declaredCodes;
currentCodes = EnumSet.noneOf(AnsiCode.class);
declaredCodes = EnumSet.noneOf(AnsiCode.class);
doReset();
runnable.run();
doReset();
currentCodes = myCodes;
declaredCodes = myDeclaredCodes;
doAppendCodes(currentCodes);
return this;
}
/**
* Append a string whose contents are unknown, and might contain ANSI color codes.
*
@@ -180,6 +199,14 @@ public final class AnsiStringBuilder {
return new PrintWriter(new StringBuilderWriter(builder));
}
public int length() {
return builder.length();
}
public void setLength(int length) {
builder.setLength(length);
}
/** 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.

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 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.
@@ -37,5 +37,15 @@ public final class AnsiTheme {
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);
public static final EnumSet<AnsiCode> TEST_EXAMPLE_OUTPUT =
EnumSet.of(AnsiCode.RED, AnsiCode.BOLD);
public static final AnsiCode SYNTAX_KEYWORD = AnsiCode.BLUE;
public static final AnsiCode SYNTAX_NUMBER = AnsiCode.GREEN;
public static final AnsiCode SYNTAX_STRING = AnsiCode.YELLOW;
public static final AnsiCode SYNTAX_STRING_ESCAPE = AnsiCode.BRIGHT_YELLOW;
public static final AnsiCode SYNTAX_COMMENT = AnsiCode.FAINT;
public static final AnsiCode SYNTAX_OPERATOR = AnsiCode.RESET;
public static final AnsiCode SYNTAX_CONTROL = AnsiCode.BLUE;
public static final AnsiCode SYNTAX_CONSTANT = AnsiCode.CYAN;
}

View File

@@ -0,0 +1,172 @@
/*
* Copyright © 2025-2026 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 static org.pkl.parser.Token.FALSE;
import static org.pkl.parser.Token.NULL;
import static org.pkl.parser.Token.STRING_ESCAPE_BACKSLASH;
import static org.pkl.parser.Token.STRING_ESCAPE_NEWLINE;
import static org.pkl.parser.Token.STRING_ESCAPE_QUOTE;
import static org.pkl.parser.Token.STRING_ESCAPE_RETURN;
import static org.pkl.parser.Token.STRING_ESCAPE_TAB;
import static org.pkl.parser.Token.STRING_ESCAPE_UNICODE;
import static org.pkl.parser.Token.TRUE;
import java.util.EnumSet;
import org.pkl.parser.Lexer;
import org.pkl.parser.ParserError;
import org.pkl.parser.Token;
/** Syntax highlighter that emits ansi color codes. */
public final class SyntaxHighlighter {
private SyntaxHighlighter() {}
private static final EnumSet<Token> stringEscape =
EnumSet.of(
STRING_ESCAPE_NEWLINE,
STRING_ESCAPE_TAB,
STRING_ESCAPE_RETURN,
STRING_ESCAPE_QUOTE,
STRING_ESCAPE_BACKSLASH,
STRING_ESCAPE_UNICODE);
private static final EnumSet<Token> constant = EnumSet.of(TRUE, FALSE, NULL);
private static final EnumSet<Token> operator =
EnumSet.of(
Token.COALESCE,
Token.LT,
Token.GT,
Token.NOT,
Token.EQUAL,
Token.NOT_EQUAL,
Token.LTE,
Token.GTE,
Token.AND,
Token.OR,
Token.PLUS,
Token.MINUS,
Token.POW,
Token.STAR,
Token.DIV,
Token.INT_DIV,
Token.MOD,
Token.PIPE);
private static final EnumSet<Token> keyword =
EnumSet.of(
Token.AMENDS,
Token.AS,
Token.EXTENDS,
Token.CLASS,
Token.TYPE_ALIAS,
Token.FUNCTION,
Token.MODULE,
Token.IMPORT,
Token.IMPORT_STAR,
Token.READ,
Token.READ_STAR,
Token.READ_QUESTION,
Token.TRACE,
Token.THROW,
Token.UNKNOWN,
Token.NOTHING,
Token.OUTER,
Token.SUPER,
Token.THIS,
Token.HIDDEN,
Token.ABSTRACT,
Token.CONST,
Token.FIXED,
Token.LOCAL,
Token.OPEN);
private static final EnumSet<Token> control =
EnumSet.of(Token.NEW, Token.IF, Token.ELSE, Token.WHEN, Token.FOR, Token.IN, Token.OUT);
private static final EnumSet<Token> number =
EnumSet.of(Token.INT, Token.FLOAT, Token.BIN, Token.OCT, Token.HEX);
public static void writeTo(AnsiStringBuilder out, String src) {
var prevLength = out.length();
try {
var lexer = new Lexer(src);
doHighlightNormal(out, lexer.next(), lexer, Token.EOF);
} catch (ParserError err) {
// bail out and emit everything un-highlighted
out.setLength(prevLength);
out.append(src);
}
}
private static void highlightString(AnsiStringBuilder out, Lexer lexer, Token token) {
out.append(
AnsiTheme.SYNTAX_STRING,
() -> {
var next = token;
while (next != Token.STRING_END && next != Token.EOF) {
if (stringEscape.contains(next)) {
out.append(AnsiTheme.SYNTAX_STRING_ESCAPE, lexer.text());
next = advance(out, lexer);
continue;
} else if (next == Token.INTERPOLATION_START) {
out.append(AnsiTheme.SYNTAX_STRING_ESCAPE, lexer.text());
out.appendSandboxed(() -> doHighlightNormal(out, lexer.next(), lexer, Token.RPAREN));
out.append(AnsiTheme.SYNTAX_STRING_ESCAPE, lexer.text());
lexer.next();
}
out.append(lexer.text());
next = advance(out, lexer);
}
out.append(lexer.text());
});
}
private static void doHighlightNormal(AnsiStringBuilder out, Token next, Lexer lexer, Token end) {
{
while (next != end && next != Token.EOF) {
if (constant.contains(next)) {
out.append(AnsiTheme.SYNTAX_CONSTANT, lexer.text());
} else if (operator.contains(next)) {
out.append(AnsiTheme.SYNTAX_OPERATOR, lexer.text());
} else if (control.contains(next)) {
out.append(AnsiTheme.SYNTAX_CONTROL, lexer.text());
} else if (keyword.contains(next)) {
out.append(AnsiTheme.SYNTAX_KEYWORD, lexer.text());
} else if (next.isAffix()) {
out.append(AnsiTheme.SYNTAX_COMMENT, lexer.text());
} else if (number.contains(next)) {
out.append(AnsiTheme.SYNTAX_NUMBER, lexer.text());
} else if (next == Token.STRING_MULTI_START || next == Token.STRING_START) {
highlightString(out, lexer, next);
} else {
out.append(lexer.text());
}
next = advance(out, lexer);
}
}
}
private static Token advance(AnsiStringBuilder out, Lexer lexer) {
var prevCursor = lexer.getCursor();
var next = lexer.next();
// fill in any whitespace (includes semicolons)
if (lexer.getStartCursor() > prevCursor) {
out.append(lexer.textFor(prevCursor, lexer.getStartCursor() - prevCursor));
}
return next;
}
}