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
@@ -255,8 +255,7 @@ String literals are enclosed in double quotes:
"Hello, World!" "Hello, World!"
---- ----
TIP: Except for a few minor differences footnote:[Pkl's string literals have fewer character escape sequences, 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.],
have stricter rules for line indentation in multiline strings, and do not have a line continuation character.],
String literals have the same syntax and semantics as in Swift 5. Learn one of them, know both of them! 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: 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]]
=== Custom String Delimiters === Custom String Delimiters
@@ -18,6 +18,7 @@ package org.pkl.core.util;
import static org.pkl.parser.Token.FALSE; import static org.pkl.parser.Token.FALSE;
import static org.pkl.parser.Token.NULL; import static org.pkl.parser.Token.NULL;
import static org.pkl.parser.Token.STRING_ESCAPE_BACKSLASH; 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_NEWLINE;
import static org.pkl.parser.Token.STRING_ESCAPE_QUOTE; 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_RETURN;
@@ -41,7 +42,8 @@ public final class SyntaxHighlighter {
STRING_ESCAPE_RETURN, STRING_ESCAPE_RETURN,
STRING_ESCAPE_QUOTE, STRING_ESCAPE_QUOTE,
STRING_ESCAPE_BACKSLASH, STRING_ESCAPE_BACKSLASH,
STRING_ESCAPE_UNICODE); STRING_ESCAPE_UNICODE,
STRING_ESCAPE_CONTINUATION);
private static final EnumSet<Token> constant = EnumSet.of(TRUE, FALSE, NULL); private static final EnumSet<Token> constant = EnumSet.of(TRUE, FALSE, NULL);
@@ -0,0 +1,21 @@
amends "../snippetTest.pkl"
examples {
["string continuation"] {
"""
hello \
world
"""
#"""
hello \#
world
"""#
"""
hello \
\
world
"""
}
}
@@ -0,0 +1 @@
res1 = "xxx\ xxx"
@@ -0,0 +1,5 @@
foo = """
hello \
\
world
"""
@@ -0,0 +1,2 @@
foo = "hello \
world"
@@ -0,0 +1,5 @@
foo =
"""
hello \
world
"""
@@ -0,0 +1,7 @@
examples {
["string continuation"] {
"hello world"
"hello world"
"hello world"
}
}
@@ -1,7 +1,7 @@
–– Pkl Error –– –– Pkl Error ––
Invalid character escape sequence `\a`. 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" x | res1 = "xxx\axxx"
^^ ^^
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -75,6 +75,11 @@ final class Builder {
OPERATOR -> OPERATOR ->
new Text(node.text(source)); new Text(node.text(source));
case STRING_NEWLINE -> mustForceLine(); 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_DECLARATION -> formatModuleDeclaration(node);
case MODULE_DEFINITION -> formatModuleDefinition(node); case MODULE_DEFINITION -> formatModuleDefinition(node);
case SINGLE_LINE_STRING_LITERAL_EXPR -> formatSingleLineString(node); case SINGLE_LINE_STRING_LITERAL_EXPR -> formatSingleLineString(node);
@@ -922,7 +927,9 @@ final class Builder {
if (elem.type == NodeType.TERMINAL && text(elem).endsWith("(")) { if (elem.type == NodeType.TERMINAL && text(elem).endsWith("(")) {
isInStringInterpolation = true; 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; prev = elem;
} }
return result; return result;
@@ -48,6 +48,26 @@ quux {
""" """
} }
// line continuations
corge {
"""
hello \
world
"""
#"""
hello \#
world
"""#
"""
hello \
\
world
"""
}
obj { obj {
data { data {
["bar"] = """ ["bar"] = """
@@ -52,6 +52,25 @@ quux {
""" """
} }
// line continuations
corge {
"""
hello \
world
"""
#"""
hello \#
world
"""#
"""
hello \
\
world
"""
}
obj { obj {
data { data {
["bar"] = ["bar"] =
@@ -978,6 +978,8 @@ class GenericParserImpl {
STRING_ESCAPE_RETURN, STRING_ESCAPE_RETURN,
STRING_ESCAPE_UNICODE -> STRING_ESCAPE_UNICODE ->
children.add(make(NodeType.STRING_ESCAPE, next().span)); children.add(make(NodeType.STRING_ESCAPE, next().span));
case STRING_ESCAPE_CONTINUATION ->
throw parserError("invalidLineContinuationEscapeSequence");
case INTERPOLATION_START -> { case INTERPOLATION_START -> {
children.add(makeTerminal(next())); children.add(makeTerminal(next()));
ff(children); ff(children);
@@ -1011,6 +1013,8 @@ class GenericParserImpl {
} }
} }
case STRING_NEWLINE -> children.add(make(NodeType.STRING_NEWLINE, next().span)); 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, case STRING_ESCAPE_NEWLINE,
STRING_ESCAPE_TAB, STRING_ESCAPE_TAB,
STRING_ESCAPE_QUOTE, STRING_ESCAPE_QUOTE,
@@ -1060,7 +1064,8 @@ class GenericParserImpl {
throw parserError(ErrorMessages.create("stringIndentationMustMatchLastLine"), child.span); throw parserError(ErrorMessages.create("stringIndentationMustMatchLastLine"), child.span);
} }
} }
previousNewline = child.type == NodeType.STRING_NEWLINE; previousNewline =
child.type == NodeType.STRING_NEWLINE || child.type == NodeType.STRING_CONTINUATION;
} }
} }
@@ -460,6 +460,22 @@ public final class Lexer {
yield Token.INTERPOLATION_START; yield Token.INTERPOLATION_START;
} }
case 'u' -> lexUnicodeEscape(); 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 -> default ->
throw lexError( throw lexError(
ErrorMessages.create("invalidCharacterEscapeSequence", "\\" + (char) ch, "\\"), ErrorMessages.create("invalidCharacterEscapeSequence", "\\" + (char) ch, "\\"),
@@ -1131,6 +1131,8 @@ final class ParserImpl {
end = tk.span; end = tk.span;
builder.append(parseUnicodeEscape(tk)); builder.append(parseUnicodeEscape(tk));
} }
case STRING_ESCAPE_CONTINUATION ->
throw parserError("invalidLineContinuationEscapeSequence");
case INTERPOLATION_START -> { case INTERPOLATION_START -> {
var istart = next().span; var istart = next().span;
if (!builder.isEmpty()) { if (!builder.isEmpty()) {
@@ -1167,7 +1169,8 @@ final class ParserImpl {
STRING_ESCAPE_QUOTE, STRING_ESCAPE_QUOTE,
STRING_ESCAPE_BACKSLASH, STRING_ESCAPE_BACKSLASH,
STRING_ESCAPE_RETURN, STRING_ESCAPE_RETURN,
STRING_ESCAPE_UNICODE -> STRING_ESCAPE_UNICODE,
STRING_ESCAPE_CONTINUATION ->
stringTokens.add(new TempNode(next(), null)); stringTokens.add(new TempNode(next(), null));
case INTERPOLATION_START -> { case INTERPOLATION_START -> {
var istart = next(); var istart = next();
@@ -1236,6 +1239,7 @@ final class ParserImpl {
builder.append('\n'); builder.append('\n');
isNewLine = true; isNewLine = true;
} }
case STRING_ESCAPE_CONTINUATION -> isNewLine = true;
case STRING_PART -> { case STRING_PART -> {
var text = token.text(lexer); var text = token.text(lexer);
if (isNewLine) { if (isNewLine) {
@@ -1642,6 +1646,7 @@ final class ParserImpl {
case STRING_ESCAPE_BACKSLASH -> "\\"; case STRING_ESCAPE_BACKSLASH -> "\\";
case STRING_ESCAPE_TAB -> "\t"; case STRING_ESCAPE_TAB -> "\t";
case STRING_ESCAPE_RETURN -> "\r"; case STRING_ESCAPE_RETURN -> "\r";
case STRING_ESCAPE_CONTINUATION -> "";
case STRING_ESCAPE_UNICODE -> parseUnicodeEscape(tk); case STRING_ESCAPE_UNICODE -> parseUnicodeEscape(tk);
default -> throw new RuntimeException("Unreacheable code"); default -> throw new RuntimeException("Unreacheable code");
}; };
@@ -129,6 +129,7 @@ public enum Token {
STRING_ESCAPE_QUOTE, STRING_ESCAPE_QUOTE,
STRING_ESCAPE_BACKSLASH, STRING_ESCAPE_BACKSLASH,
STRING_ESCAPE_UNICODE, STRING_ESCAPE_UNICODE,
STRING_ESCAPE_CONTINUATION,
STRING_END, STRING_END,
STRING_PART; STRING_PART;
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -70,6 +70,7 @@ public enum NodeType {
STRING_CHARS, STRING_CHARS,
OPERATOR, OPERATOR,
STRING_NEWLINE, STRING_NEWLINE,
STRING_CONTINUATION,
STRING_ESCAPE, STRING_ESCAPE,
// members // members
@@ -44,13 +44,23 @@ The separator character (`_`) cannot follow `0x`, `0b`, `.`, `e`, or 'E' in a nu
invalidCharacterEscapeSequence=\ invalidCharacterEscapeSequence=\
Invalid character escape sequence `{0}`.\n\ Invalid character escape sequence `{0}`.\n\
\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=\ invalidUnicodeEscapeSequence=\
Invalid Unicode escape sequence `{0}`.\n\ Invalid Unicode escape sequence `{0}`.\n\
\n\ \n\
Valid Unicode escape sequences are {1}'{'0'}' to {1}'{'10FFFF'}' (1-6 hexadecimal characters). 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=\ missingDelimiter=\
Missing `{0}` delimiter. Missing `{0}` delimiter.
@@ -197,6 +197,7 @@ class GenericSexpRenderer(code: String) {
NodeType.TERMINAL, NodeType.TERMINAL,
NodeType.OPERATOR, NodeType.OPERATOR,
NodeType.STRING_NEWLINE, NodeType.STRING_NEWLINE,
NodeType.STRING_CONTINUATION,
) )
private val UNPACK_CHILDREN = private val UNPACK_CHILDREN =
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -97,6 +97,7 @@ class ParserComparisonTest {
"errors/parser18.pkl", "errors/parser18.pkl",
"errors/nested1.pkl", "errors/nested1.pkl",
"errors/invalidCharacterEscape.pkl", "errors/invalidCharacterEscape.pkl",
"errors/invalidCharacterEscape2.pkl",
"errors/invalidUnicodeEscape.pkl", "errors/invalidUnicodeEscape.pkl",
"errors/unterminatedUnicodeEscape.pkl", "errors/unterminatedUnicodeEscape.pkl",
"errors/keywordNotAllowedHere1.pkl", "errors/keywordNotAllowedHere1.pkl",