Skip to content

Commit 3006f3b

Browse files
authored
Merge pull request #1090 from beccadax/string-theory
Improve safety of SwiftSyntaxBuilder interpolation
2 parents 297b398 + be398f6 commit 3006f3b

File tree

10 files changed

+636
-52
lines changed

10 files changed

+636
-52
lines changed

Sources/SwiftSyntaxBuilder/ConvenienceInitializers.swift

Lines changed: 100 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,42 @@ extension DictionaryExpr {
9090
}
9191
}
9292

93+
// MARK: - Expr
94+
95+
extension Expr {
96+
/// Returns a syntax tree for an expression that represents the value of the
97+
/// provided instance. For example, passing an `Array<String>` will result in
98+
/// an array literal containing string literals:
99+
///
100+
/// let arrayExpr = Expr(literal: ["a", "b", "c"])
101+
/// // `arrayExpr` is a syntax tree like `["a", "b", "c"]`
102+
///
103+
/// This initializer is compatible with types that conform to
104+
/// ``ExpressibleByLiteralSyntax``. These include:
105+
///
106+
/// * `String` and `Substring`
107+
/// * `Int` and other integer types
108+
/// * `Double` and other floating-point types
109+
/// * `Bool`
110+
/// * `Array` and `Set` of conforming elements
111+
/// * `Dictionary` and `KeyValuePairs` of conforming keys and values
112+
/// * `Optional` of conforming wrapped value
113+
///
114+
/// Conformances will generally handle edge cases sensibly: `String` will
115+
/// use raw literals and escapes as needed, `Optional` will wrap a nested
116+
/// `nil` in `.some`, `Double` wil represent special values like infinities
117+
/// as code sequences like `.infinity`, etc. `Set` and `Dictionary` sort
118+
/// thier elements to improve stability.
119+
///
120+
/// Because of that intelligent behavior, this initializer is not guaranteed
121+
/// to produce a literal as the outermost syntax node, or even to have a
122+
/// literal anywhere in its syntax tree. Use a convenience initializer on a
123+
/// specific type if you need that exact type in the syntax tree.
124+
public init<Literal: ExpressibleByLiteralSyntax>(literal: Literal) {
125+
self.init(fromProtocol: literal.makeLiteralSyntax())
126+
}
127+
}
128+
93129
// MARK: - FloatLiteralExprSyntax
94130

95131
extension FloatLiteralExprSyntax: ExpressibleByFloatLiteral {
@@ -105,6 +141,7 @@ extension FloatLiteralExprSyntax: ExpressibleByFloatLiteral {
105141
// MARK: - FunctionCallExpr
106142

107143
extension FunctionCallExpr {
144+
// Need an overload that's explicitly `ExprSyntax` for code literals to work.
108145
/// A convenience initializer that allows passing in arguments using a result builder
109146
/// instead of having to wrap them in a `TupleExprElementList`.
110147
/// The presence of the parenthesis will be inferred based on the presence of arguments and the trailing closure.
@@ -125,6 +162,23 @@ extension FunctionCallExpr {
125162
additionalTrailingClosures: additionalTrailingClosures
126163
)
127164
}
165+
166+
/// A convenience initializer that allows passing in arguments using a result builder
167+
/// instead of having to wrap them in a `TupleExprElementList`.
168+
/// The presence of the parenthesis will be inferred based on the presence of arguments and the trailing closure.
169+
public init(
170+
callee: ExprSyntaxProtocol,
171+
trailingClosure: ClosureExprSyntax? = nil,
172+
additionalTrailingClosures: MultipleTrailingClosureElementList? = nil,
173+
@TupleExprElementListBuilder argumentList: () -> TupleExprElementList = { [] }
174+
) {
175+
self.init(
176+
callee: ExprSyntax(fromProtocol: callee),
177+
trailingClosure: trailingClosure,
178+
additionalTrailingClosures: additionalTrailingClosures,
179+
argumentList: argumentList
180+
)
181+
}
128182
}
129183

130184
// MARK: - FunctionParameter
@@ -199,24 +253,39 @@ extension MemberAccessExpr {
199253
// MARK: - StringLiteralExpr
200254

201255
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 }) {
256+
/// Replace literal newlines with "\r", "\n", "\u{2028}", and ASCII control characters with "\0", "\u{7}"
257+
fileprivate func escapingForStringLiteral(usingDelimiter delimiter: String) -> String {
258+
// String literals cannot contain "unprintable" ASCII characters (control
259+
// characters, etc.) besides tab. As a matter of style, we also choose to
260+
// escape Unicode newlines like "\u{2028}" even though swiftc will allow
261+
// them in string literals.
262+
func needsEscaping(_ scalar: UnicodeScalar) -> Bool {
263+
return (scalar.isASCII && scalar != "\t" && !scalar.isPrintableASCII)
264+
|| Character(scalar).isNewline
265+
}
266+
267+
// Work at the Unicode scalar level so that "\r\n" isn't combined.
268+
var result = String.UnicodeScalarView()
269+
var input = self.unicodeScalars[...]
270+
while let firstNewline = input.firstIndex(where: needsEscaping(_:)) {
207271
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"
272+
273+
result += "\\\(delimiter)".unicodeScalars
274+
switch input[firstNewline] {
275+
case "\r":
276+
result += "r".unicodeScalars
277+
case "\n":
278+
result += "n".unicodeScalars
279+
case "\0":
280+
result += "0".unicodeScalars
281+
case let other:
282+
result += "u{\(String(other.value, radix: 16))}".unicodeScalars
214283
}
215284
input = input[input.index(after: firstNewline)...]
216-
continue
217285
}
286+
result += input
218287

219-
return result + input
288+
return String(result)
220289
}
221290
}
222291

@@ -226,34 +295,28 @@ extension StringLiteralExpr {
226295
}
227296

228297
private static func requiresEscaping(_ content: String) -> (Bool, poundCount: Int) {
229-
var state: PoundState = .none
298+
var countingPounds = false
230299
var consecutivePounds = 0
231300
var maxPounds = 0
232301
var requiresEscaping = false
233302

234303
for c in content {
235-
switch c {
236-
case "#":
304+
switch (countingPounds, c) {
305+
// Normal mode: scanning for characters that can be followed by pounds.
306+
case (false, "\""), (false, "\\"):
307+
countingPounds = true
308+
requiresEscaping = true
309+
case (false, _):
310+
continue
311+
312+
// Special mode: counting a sequence of pounds until we reach its end.
313+
case (true, "#"):
237314
consecutivePounds += 1
238-
case "\"":
239-
state = .afterQuote
240-
consecutivePounds = 0
241-
case "\\":
242-
state = .afterBackslash
243-
consecutivePounds = 0
244-
case "(" where state == .afterBackslash:
245315
maxPounds = max(maxPounds, consecutivePounds)
246-
fallthrough
247-
default:
316+
case (true, _):
317+
countingPounds = false
248318
consecutivePounds = 0
249-
state = .none
250-
}
251-
252-
if state == .afterQuote {
253-
maxPounds = max(maxPounds, consecutivePounds)
254319
}
255-
256-
requiresEscaping = requiresEscaping || state != .none
257320
}
258321

259322
return (requiresEscaping, poundCount: maxPounds)
@@ -269,10 +332,6 @@ extension StringLiteralExpr {
269332
closeQuote: Token = .stringQuote,
270333
closeDelimiter: Token? = nil
271334
) {
272-
let contentToken = Token.stringSegment(content.replacingNewlines())
273-
let segment = StringSegment(content: contentToken)
274-
let segments = StringLiteralSegments([.stringSegment(segment)])
275-
276335
var openDelimiter = openDelimiter
277336
var closeDelimiter = closeDelimiter
278337
if openDelimiter == nil, closeDelimiter == nil {
@@ -285,6 +344,11 @@ extension StringLiteralExpr {
285344
}
286345
}
287346

347+
let escapedContent = content.escapingForStringLiteral(usingDelimiter: closeDelimiter?.text ?? "")
348+
let contentToken = Token.stringSegment(escapedContent)
349+
let segment = StringSegment(content: contentToken)
350+
let segments = StringLiteralSegments([.stringSegment(segment)])
351+
288352
self.init(
289353
openDelimiter: openDelimiter,
290354
openQuote: openQuote,

0 commit comments

Comments
 (0)