SPICE-0028: Add support for multi-line string line continuations (#1507)

SPICE: https://github.com/apple/pkl-evolution/pull/31
This commit is contained in:
Jen Basch
2026-04-21 10:29:52 -07:00
committed by GitHub
parent d85f06be27
commit e07abb7311
24 changed files with 185 additions and 10 deletions

View File

@@ -255,8 +255,7 @@ String literals are enclosed in double quotes:
"Hello, World!"
----
TIP: Except for a few minor differences footnote:[Pkl's string literals have fewer character escape sequences,
have stricter rules for line indentation in multiline strings, and do not have a line continuation character.],
TIP: Except for a few minor differences footnote:[Pkl's string literals have fewer character escape sequences and stricter rules for line indentation in multiline strings.],
String literals have the same syntax and semantics as in Swift 5. Learn one of them, know both of them!
Inside a string literal, the following character escape sequences have special meaning:
@@ -362,6 +361,23 @@ str = """
"""
----
To prevent line breaks from becoming part of the string's value, use a backslash (`\`) to end those lines.
[source%tested,{pkl}]
----
str = """
Although the Dodo is extinct, \
the species will be remembered.
"""
----
This multiline string is equivalent to the following single-line string:
[source%parsed,{pkl-expr}]
----
"Although the Dodo is extinct, the species will be remembered."
----
[[custom-string-delimiters]]
=== Custom String Delimiters

View File

@@ -18,6 +18,7 @@ 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_CONTINUATION;
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;
@@ -41,7 +42,8 @@ public final class SyntaxHighlighter {
STRING_ESCAPE_RETURN,
STRING_ESCAPE_QUOTE,
STRING_ESCAPE_BACKSLASH,
STRING_ESCAPE_UNICODE);
STRING_ESCAPE_UNICODE,
STRING_ESCAPE_CONTINUATION);
private static final EnumSet<Token> constant = EnumSet.of(TRUE, FALSE, NULL);

View File

@@ -0,0 +1,21 @@
amends "../snippetTest.pkl"
examples {
["string continuation"] {
"""
hello \
world
"""
#"""
hello \#
world
"""#
"""
hello \
\
world
"""
}
}

View File

@@ -0,0 +1 @@
res1 = "xxx\ xxx"

View File

@@ -0,0 +1,5 @@
foo = """
hello \
\
world
"""

View File

@@ -0,0 +1,2 @@
foo = "hello \
world"

View File

@@ -0,0 +1,5 @@
foo =
"""
hello \
world
"""

View File

@@ -0,0 +1,7 @@
examples {
["string continuation"] {
"hello world"
"hello world"
"hello world"
}
}

View File

@@ -1,7 +1,7 @@
Pkl Error
Invalid character escape sequence `\a`.
Valid character escape sequences are: \n \r \t \" \\
Valid character escape sequences are: \n \r \t \" \\ \<newline>
x | res1 = "xxx\axxx"
^^

View File

@@ -0,0 +1,8 @@
Pkl Error
Invalid character escape sequence `\ `.
Valid character escape sequences are: \n \r \t \" \\ \<newline>
x | res1 = "xxx\ xxx"
^^
at invalidCharacterEscape2 (file:///$snippetsDir/input/errors/invalidCharacterEscape2.pkl)

View File

@@ -0,0 +1,6 @@
Pkl Error
Line must match or exceed indentation of the String's last line.
x | \
^
at parser19 (file:///$snippetsDir/input/errors/parser19.pkl)

View File

@@ -0,0 +1,8 @@
Pkl Error
Invalid line continuation escape sequence.
Line continuations are only allowed in multi-line strings.
x | foo = "hello \
^^
at parser20 (file:///$snippetsDir/input/errors/parser20.pkl)

View File

@@ -0,0 +1,8 @@
Pkl Error
Invalid line continuation escape sequence.
Whitespace between the continuation escape and following newline is not allowed.
x | hello \
^^^^^
at parser21 (file:///$snippetsDir/input/errors/parser21.pkl)

View File

@@ -75,6 +75,11 @@ final class Builder {
OPERATOR ->
new Text(node.text(source));
case STRING_NEWLINE -> mustForceLine();
case STRING_CONTINUATION -> {
var escape = node.text(source);
yield new Nodes(
List.of(new Text(escape.substring(0, escape.length() - 1)), mustForceLine()));
}
case MODULE_DECLARATION -> formatModuleDeclaration(node);
case MODULE_DEFINITION -> formatModuleDefinition(node);
case SINGLE_LINE_STRING_LITERAL_EXPR -> formatSingleLineString(node);
@@ -922,7 +927,9 @@ final class Builder {
if (elem.type == NodeType.TERMINAL && text(elem).endsWith("(")) {
isInStringInterpolation = true;
}
result.add(format(elem));
var formatted = format(elem);
if (formatted instanceof Nodes formattedNodes) result.addAll(formattedNodes.nodes());
else result.add(formatted);
prev = elem;
}
return result;

View File

@@ -48,6 +48,26 @@ quux {
"""
}
// line continuations
corge {
"""
hello \
world
"""
#"""
hello \#
world
"""#
"""
hello \
\
world
"""
}
obj {
data {
["bar"] = """

View File

@@ -52,6 +52,25 @@ quux {
"""
}
// line continuations
corge {
"""
hello \
world
"""
#"""
hello \#
world
"""#
"""
hello \
\
world
"""
}
obj {
data {
["bar"] =

View File

@@ -978,6 +978,8 @@ class GenericParserImpl {
STRING_ESCAPE_RETURN,
STRING_ESCAPE_UNICODE ->
children.add(make(NodeType.STRING_ESCAPE, next().span));
case STRING_ESCAPE_CONTINUATION ->
throw parserError("invalidLineContinuationEscapeSequence");
case INTERPOLATION_START -> {
children.add(makeTerminal(next()));
ff(children);
@@ -1011,6 +1013,8 @@ class GenericParserImpl {
}
}
case STRING_NEWLINE -> children.add(make(NodeType.STRING_NEWLINE, next().span));
case STRING_ESCAPE_CONTINUATION ->
children.add(make(NodeType.STRING_CONTINUATION, next().span));
case STRING_ESCAPE_NEWLINE,
STRING_ESCAPE_TAB,
STRING_ESCAPE_QUOTE,
@@ -1060,7 +1064,8 @@ class GenericParserImpl {
throw parserError(ErrorMessages.create("stringIndentationMustMatchLastLine"), child.span);
}
}
previousNewline = child.type == NodeType.STRING_NEWLINE;
previousNewline =
child.type == NodeType.STRING_NEWLINE || child.type == NodeType.STRING_CONTINUATION;
}
}

View File

@@ -460,6 +460,22 @@ public final class Lexer {
yield Token.INTERPOLATION_START;
}
case 'u' -> lexUnicodeEscape();
case '\n' -> Token.STRING_ESCAPE_CONTINUATION;
case ' ', '\t' -> {
var c = cursor;
var next = nextChar();
while (next == ' ' || next == '\t') next = nextChar();
if (next == '\n')
throw lexError(
ErrorMessages.create("invalidLineContinuationEscapeSequenceWhitespace"),
c - 2,
cursor - c + 2);
throw lexError(
ErrorMessages.create("invalidCharacterEscapeSequence", "\\" + (char) ch, "\\"),
c - 2,
2);
}
default ->
throw lexError(
ErrorMessages.create("invalidCharacterEscapeSequence", "\\" + (char) ch, "\\"),

View File

@@ -1131,6 +1131,8 @@ final class ParserImpl {
end = tk.span;
builder.append(parseUnicodeEscape(tk));
}
case STRING_ESCAPE_CONTINUATION ->
throw parserError("invalidLineContinuationEscapeSequence");
case INTERPOLATION_START -> {
var istart = next().span;
if (!builder.isEmpty()) {
@@ -1167,7 +1169,8 @@ final class ParserImpl {
STRING_ESCAPE_QUOTE,
STRING_ESCAPE_BACKSLASH,
STRING_ESCAPE_RETURN,
STRING_ESCAPE_UNICODE ->
STRING_ESCAPE_UNICODE,
STRING_ESCAPE_CONTINUATION ->
stringTokens.add(new TempNode(next(), null));
case INTERPOLATION_START -> {
var istart = next();
@@ -1236,6 +1239,7 @@ final class ParserImpl {
builder.append('\n');
isNewLine = true;
}
case STRING_ESCAPE_CONTINUATION -> isNewLine = true;
case STRING_PART -> {
var text = token.text(lexer);
if (isNewLine) {
@@ -1642,6 +1646,7 @@ final class ParserImpl {
case STRING_ESCAPE_BACKSLASH -> "\\";
case STRING_ESCAPE_TAB -> "\t";
case STRING_ESCAPE_RETURN -> "\r";
case STRING_ESCAPE_CONTINUATION -> "";
case STRING_ESCAPE_UNICODE -> parseUnicodeEscape(tk);
default -> throw new RuntimeException("Unreacheable code");
};

View File

@@ -129,6 +129,7 @@ public enum Token {
STRING_ESCAPE_QUOTE,
STRING_ESCAPE_BACKSLASH,
STRING_ESCAPE_UNICODE,
STRING_ESCAPE_CONTINUATION,
STRING_END,
STRING_PART;

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2025 Apple Inc. and the Pkl project authors. All rights reserved.
* 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.
@@ -70,6 +70,7 @@ public enum NodeType {
STRING_CHARS,
OPERATOR,
STRING_NEWLINE,
STRING_CONTINUATION,
STRING_ESCAPE,
// members

View File

@@ -44,13 +44,23 @@ The separator character (`_`) cannot follow `0x`, `0b`, `.`, `e`, or 'E' in a nu
invalidCharacterEscapeSequence=\
Invalid character escape sequence `{0}`.\n\
\n\
Valid character escape sequences are: {1}n {1}r {1}t {1}" {1}\\
Valid character escape sequences are: {1}n {1}r {1}t {1}" {1}\\ {1}<newline>
invalidUnicodeEscapeSequence=\
Invalid Unicode escape sequence `{0}`.\n\
\n\
Valid Unicode escape sequences are {1}'{'0'}' to {1}'{'10FFFF'}' (1-6 hexadecimal characters).
invalidLineContinuationEscapeSequence=\
Invalid line continuation escape sequence.\n\
\n\
Line continuations are only allowed in multi-line strings.
invalidLineContinuationEscapeSequenceWhitespace=\
Invalid line continuation escape sequence.\n\
\n\
Whitespace between the continuation escape and following newline is not allowed.
missingDelimiter=\
Missing `{0}` delimiter.

View File

@@ -197,6 +197,7 @@ class GenericSexpRenderer(code: String) {
NodeType.TERMINAL,
NodeType.OPERATOR,
NodeType.STRING_NEWLINE,
NodeType.STRING_CONTINUATION,
)
private val UNPACK_CHILDREN =

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.
@@ -97,6 +97,7 @@ class ParserComparisonTest {
"errors/parser18.pkl",
"errors/nested1.pkl",
"errors/invalidCharacterEscape.pkl",
"errors/invalidCharacterEscape2.pkl",
"errors/invalidUnicodeEscape.pkl",
"errors/unterminatedUnicodeEscape.pkl",
"errors/keywordNotAllowedHere1.pkl",