Skip to content

Commit 9b96da4

Browse files
committed
Improve builder string escape insertion
The `StringLiteralExpr.init(… content: …)` initializer was attempting to escape newlines, but it did not insert pound delimiters after the backslashes as Swift requires for raw literals. It also was not escaping control characters, which (except for tab) can only be written as escapes in Swift source code. Modify the initializer’s logic to correct these issues.
1 parent 813a63a commit 9b96da4

File tree

2 files changed

+65
-19
lines changed

2 files changed

+65
-19
lines changed

Sources/SwiftSyntaxBuilder/ConvenienceInitializers.swift

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -199,24 +199,39 @@ extension MemberAccessExpr {
199199
// MARK: - StringLiteralExpr
200200

201201
extension String {
202-
/// Replace literal newlines with "\r", "\n".
203-
fileprivate func replacingNewlines() -> String {
204-
var result = ""
205-
var input = self[...]
206-
while let firstNewline = input.firstIndex(where: { $0.isNewline }) {
202+
/// Replace literal newlines with "\r", "\n", "\u{2028}", and ASCII control characters with "\0", "\u{7}"
203+
fileprivate func escapingForStringLiteral(usingDelimiter delimiter: String) -> String {
204+
// String literals cannot contain "unprintable" ASCII characters (control
205+
// characters, etc.) besides tab. As a matter of style, we also choose to
206+
// escape Unicode newlines like "\u{2028}" even though swiftc will allow
207+
// them in string literals.
208+
func needsEscaping(_ scalar: UnicodeScalar) -> Bool {
209+
return (scalar.isASCII && scalar != "\t" && !scalar.isPrintableASCII)
210+
|| Character(scalar).isNewline
211+
}
212+
213+
// Work at the Unicode scalar level so that "\r\n" isn't combined.
214+
var result = String.UnicodeScalarView()
215+
var input = self.unicodeScalars[...]
216+
while let firstNewline = input.firstIndex(where: needsEscaping(_:)) {
207217
result += input[..<firstNewline]
208-
if input[firstNewline] == "\r" {
209-
result += "\\r"
210-
} else if input[firstNewline] == "\r\n" {
211-
result += "\\r\\n"
212-
} else {
213-
result += "\\n"
218+
219+
result += "\\\(delimiter)".unicodeScalars
220+
switch input[firstNewline] {
221+
case "\r":
222+
result += "r".unicodeScalars
223+
case "\n":
224+
result += "n".unicodeScalars
225+
case "\0":
226+
result += "0".unicodeScalars
227+
case let other:
228+
result += "u{\(String(other.value, radix: 16))}".unicodeScalars
214229
}
215230
input = input[input.index(after: firstNewline)...]
216-
continue
217231
}
232+
result += input
218233

219-
return result + input
234+
return String(result)
220235
}
221236
}
222237

@@ -269,10 +284,6 @@ extension StringLiteralExpr {
269284
closeQuote: Token = .stringQuote,
270285
closeDelimiter: Token? = nil
271286
) {
272-
let contentToken = Token.stringSegment(content.replacingNewlines())
273-
let segment = StringSegment(content: contentToken)
274-
let segments = StringLiteralSegments([.stringSegment(segment)])
275-
276287
var openDelimiter = openDelimiter
277288
var closeDelimiter = closeDelimiter
278289
if openDelimiter == nil, closeDelimiter == nil {
@@ -285,6 +296,11 @@ extension StringLiteralExpr {
285296
}
286297
}
287298

299+
let escapedContent = content.escapingForStringLiteral(usingDelimiter: closeDelimiter?.text ?? "")
300+
let contentToken = Token.stringSegment(escapedContent)
301+
let segment = StringSegment(content: contentToken)
302+
let segments = StringLiteralSegments([.stringSegment(segment)])
303+
288304
self.init(
289305
openDelimiter: openDelimiter,
290306
openQuote: openQuote,

Tests/SwiftSyntaxBuilderTest/StringLiteralTests.swift

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,38 @@ final class StringLiteralTests: XCTestCase {
8585

8686
func testNewlines() {
8787
AssertBuildResult(
88-
StringLiteralExpr(content: "linux\nwindows\r\na"),
89-
#""linux\nwindows\r\na""#
88+
StringLiteralExpr(content: "linux\nwindows\r\nunicode\u{2028}a"),
89+
#""linux\nwindows\r\nunicode\u{2028}a""#
90+
)
91+
92+
AssertBuildResult(
93+
StringLiteralExpr(content: "\\linux\nwindows\r\nunicode\u{2028}a"),
94+
##"#"\linux\#nwindows\#r\#nunicode\#u{2028}a"#"##
95+
)
96+
}
97+
98+
func testNul() {
99+
AssertBuildResult(
100+
StringLiteralExpr(content: "before\0after"),
101+
#""before\0after""#
102+
)
103+
104+
AssertBuildResult(
105+
StringLiteralExpr(content: "\\before\0after"),
106+
##"#"\before\#0after"#"##
107+
)
108+
}
109+
110+
func testControlChars() {
111+
// Note that tabs do *not* get escaped.
112+
AssertBuildResult(
113+
StringLiteralExpr(content: "before\u{07}\t\u{7f}after"),
114+
#""before\u{7}\#t\u{7f}after""#
115+
)
116+
117+
AssertBuildResult(
118+
StringLiteralExpr(content: "\\before\u{07}\t\u{7f}after"),
119+
##"#"\before\#u{7}\##t\#u{7f}after"#"##
90120
)
91121
}
92122
}

0 commit comments

Comments
 (0)