Skip to content

Commit e4cb1bd

Browse files
authored
Merge pull request #1551 from AppAppWorks/convert-string-concatenation-to-string-interpolation
Convert String Concatenation to String Interpolation
2 parents f0e8645 + 17e33a6 commit e4cb1bd

File tree

4 files changed

+325
-0
lines changed

4 files changed

+325
-0
lines changed

Sources/SourceKitLSP/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ target_sources(SourceKitLSP PRIVATE
3131
Swift/CodeActions/AddDocumentation.swift
3232
Swift/CodeActions/ConvertIntegerLiteral.swift
3333
Swift/CodeActions/ConvertJSONToCodableStruct.swift
34+
Swift/CodeActions/ConvertStringConcatenationToStringInterpolation.swift
3435
Swift/CodeActions/PackageManifestEdits.swift
3536
Swift/CodeActions/SyntaxCodeActionProvider.swift
3637
Swift/CodeActions/SyntaxCodeActions.swift
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import LanguageServerProtocol
14+
import SwiftRefactor
15+
import SwiftSyntax
16+
17+
/// ConvertStringConcatenationToStringInterpolation is a code action that converts a valid string concatenation into a
18+
/// string interpolation.
19+
struct ConvertStringConcatenationToStringInterpolation: SyntaxRefactoringProvider {
20+
static func refactor(syntax: SequenceExprSyntax, in context: Void) -> SequenceExprSyntax? {
21+
guard let (componentsOnly, commonPounds) = preflight(exprList: syntax.elements) else {
22+
return nil
23+
}
24+
25+
var segments: StringLiteralSegmentListSyntax = []
26+
for component in componentsOnly {
27+
guard let stringLiteral = component.as(StringLiteralExprSyntax.self) else {
28+
segments.append(
29+
.expressionSegment(
30+
ExpressionSegmentSyntax(
31+
pounds: commonPounds,
32+
expressions: [
33+
LabeledExprSyntax(expression: component.singleLineTrivia)
34+
]
35+
)
36+
)
37+
)
38+
continue
39+
}
40+
41+
if let commonPounds, stringLiteral.openingPounds?.tokenKind != commonPounds.tokenKind {
42+
segments += stringLiteral.segments.map { segment in
43+
if case let .expressionSegment(exprSegment) = segment {
44+
.expressionSegment(exprSegment.with(\.pounds, commonPounds))
45+
} else {
46+
segment
47+
}
48+
}
49+
} else {
50+
segments += stringLiteral.segments
51+
}
52+
}
53+
54+
return syntax.with(
55+
\.elements,
56+
[
57+
ExprSyntax(
58+
StringLiteralExprSyntax(
59+
openingPounds: commonPounds,
60+
openingQuote: .stringQuoteToken(),
61+
segments: segments,
62+
closingQuote: .stringQuoteToken(),
63+
closingPounds: commonPounds
64+
)
65+
)
66+
]
67+
)
68+
}
69+
70+
/// If `exprList` is a valid string concatenation, returns 1) all elements in `exprList` with concat operators
71+
/// stripped and 2) the longest pounds amongst all string literals, otherwise returns nil.
72+
///
73+
/// `exprList` as a valid string concatenation must contain n >= 3 children where n is an odd number with a concat
74+
/// operator `+` separating every other child, which must either be a single-line string literal or a valid
75+
/// expression for string interpolation. `exprList` must also contain at least one string literal child.
76+
///
77+
/// The following is a valid string concatenation.
78+
/// ``` swift
79+
/// "Hello " + aString + "\(1)World"
80+
/// ```
81+
/// The following are invalid string concatenations.
82+
/// ``` swift
83+
/// aString + bString // no string literals
84+
///
85+
/// "Hello " * aString - "World" // non `+` operators
86+
///
87+
/// """
88+
/// Hello
89+
/// """
90+
/// + """
91+
/// World
92+
/// """ // multi-line string literals
93+
/// ```
94+
private static func preflight(
95+
exprList: ExprListSyntax
96+
) -> (componentsOnly: [ExprListSyntax.Element], longestPounds: TokenSyntax?)? {
97+
var iter = exprList.makeIterator()
98+
guard let first = iter.next() else {
99+
return nil
100+
}
101+
102+
var hasStringComponents = false
103+
var longestPounds: TokenSyntax?
104+
var componentsOnly = [ExprListSyntax.Element]()
105+
componentsOnly.reserveCapacity(exprList.count / 2 + 1)
106+
107+
if let stringLiteral = first.as(StringLiteralExprSyntax.self) {
108+
guard stringLiteral.isSingleLine else {
109+
return nil
110+
}
111+
hasStringComponents = true
112+
longestPounds = stringLiteral.openingPounds
113+
}
114+
componentsOnly.append(first)
115+
116+
while let concat = iter.next(), let stringComponent = iter.next() {
117+
guard let concat = concat.as(BinaryOperatorExprSyntax.self),
118+
concat.operator.tokenKind == .binaryOperator("+") && !stringComponent.is(MissingExprSyntax.self)
119+
else {
120+
return nil
121+
}
122+
123+
if let stringLiteral = stringComponent.as(StringLiteralExprSyntax.self) {
124+
guard stringLiteral.isSingleLine else {
125+
return nil
126+
}
127+
hasStringComponents = true
128+
if let pounds = stringLiteral.openingPounds,
129+
pounds.trimmedLength > (longestPounds?.trimmedLength ?? SourceLength(utf8Length: 0))
130+
{
131+
longestPounds = pounds
132+
}
133+
}
134+
135+
componentsOnly[componentsOnly.count - 1].trailingTrivia += concat.leadingTrivia
136+
componentsOnly.append(
137+
stringComponent.with(\.leadingTrivia, stringComponent.leadingTrivia + concat.trailingTrivia)
138+
)
139+
}
140+
141+
guard hasStringComponents && componentsOnly.count > 1 else {
142+
return nil
143+
}
144+
145+
return (componentsOnly, longestPounds)
146+
}
147+
}
148+
149+
extension ConvertStringConcatenationToStringInterpolation: SyntaxRefactoringCodeActionProvider {
150+
static let title: String = "Convert String Concatenation to String Interpolation"
151+
152+
static func nodeToRefactor(in scope: SyntaxCodeActionScope) -> SequenceExprSyntax? {
153+
guard let expr = scope.innermostNodeContainingRange,
154+
let seqExpr = expr.findParentOfSelf(
155+
ofType: SequenceExprSyntax.self,
156+
stoppingIf: {
157+
$0.kind == .codeBlockItem || $0.kind == .memberBlockItem
158+
}
159+
)
160+
else {
161+
return nil
162+
}
163+
164+
return seqExpr
165+
}
166+
}
167+
168+
private extension String {
169+
var uncommented: Substring {
170+
trimmingPrefix { $0 == "/" }
171+
}
172+
}
173+
174+
private extension StringLiteralExprSyntax {
175+
var isSingleLine: Bool {
176+
openingQuote.tokenKind == .stringQuote
177+
}
178+
}
179+
180+
private extension SyntaxProtocol {
181+
/// Modifies the trivia to not contain any newlines. This removes whitespace trivia, replaces newlines with
182+
/// whitespaces in block comments and converts line comments to block comments.
183+
var singleLineTrivia: Self {
184+
with(\.leadingTrivia, leadingTrivia.withSingleLineComments.withCommentsOnly(isLeadingTrivia: true))
185+
.with(\.trailingTrivia, trailingTrivia.withSingleLineComments.withCommentsOnly(isLeadingTrivia: false))
186+
}
187+
}
188+
189+
private extension Trivia {
190+
/// Replaces newlines with whitespaces in block comments and converts line comments to block comments.
191+
var withSingleLineComments: Self {
192+
Trivia(
193+
pieces: map {
194+
switch $0 {
195+
case let .lineComment(lineComment):
196+
.blockComment("/*\(lineComment.uncommented)*/")
197+
case let .docLineComment(docLineComment):
198+
.docBlockComment("/**\(docLineComment.uncommented)*/")
199+
case let .blockComment(blockComment), let .docBlockComment(blockComment):
200+
.blockComment(blockComment.replacing("\r\n", with: " ").replacing("\n", with: " "))
201+
default:
202+
$0
203+
}
204+
}
205+
)
206+
}
207+
208+
/// Removes all non-comment trivia pieces and inserts a whitespace between each comment.
209+
func withCommentsOnly(isLeadingTrivia: Bool) -> Self {
210+
Trivia(
211+
pieces: flatMap { piece -> [TriviaPiece] in
212+
if piece.isComment {
213+
if isLeadingTrivia {
214+
[piece, .spaces(1)]
215+
} else {
216+
[.spaces(1), piece]
217+
}
218+
} else {
219+
[]
220+
}
221+
}
222+
)
223+
}
224+
}

Sources/SourceKitLSP/Swift/CodeActions/SyntaxCodeActions.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ let allSyntaxCodeActions: [SyntaxCodeActionProvider.Type] = [
1919
AddSeparatorsToIntegerLiteral.self,
2020
ConvertIntegerLiteral.self,
2121
ConvertJSONToCodableStruct.self,
22+
ConvertStringConcatenationToStringInterpolation.self,
2223
FormatRawStringLiteral.self,
2324
MigrateToNewIfLetSyntax.self,
2425
OpaqueParameterToGeneric.self,

Tests/SourceKitLSPTests/CodeActionTests.swift

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,105 @@ final class CodeActionTests: XCTestCase {
10061006
}
10071007
}
10081008

1009+
func testConvertStringConcatenationToStringInterpolation() async throws {
1010+
try await assertCodeActions(
1011+
#"""
1012+
1️⃣#"["# + 2️⃣key + ": \(3️⃣d) " + 4️⃣value + ##"]"##5️⃣
1013+
"""#,
1014+
ranges: [("1️⃣", "2️⃣"), ("3️⃣", "4️⃣"), ("1️⃣", "5️⃣")],
1015+
exhaustive: false
1016+
) { uri, positions in
1017+
[
1018+
CodeAction(
1019+
title: "Convert String Concatenation to String Interpolation",
1020+
kind: .refactorInline,
1021+
edit: WorkspaceEdit(
1022+
changes: [
1023+
uri: [
1024+
TextEdit(
1025+
range: positions["1️⃣"]..<positions["5️⃣"],
1026+
newText: ###"""
1027+
##"[\##(key): \##(d) \##(value)]"##
1028+
"""###
1029+
)
1030+
]
1031+
]
1032+
)
1033+
)
1034+
]
1035+
}
1036+
}
1037+
1038+
func testConvertStringConcatenationToStringInterpolationWithInterspersingAndMultilineComments() async throws {
1039+
try await assertCodeActions(
1040+
"""
1041+
1️⃣"hello" + /*self.leading1*/ /**self.leading2*/ self //self.trailing1
1042+
///concat.leading1
1043+
2️⃣+/*concat.trailing1
1044+
line 1
1045+
line 2
1046+
1047+
1048+
line 3
1049+
*/ value3️⃣
1050+
""",
1051+
exhaustive: false
1052+
) { uri, positions in
1053+
[
1054+
CodeAction(
1055+
title: "Convert String Concatenation to String Interpolation",
1056+
kind: .refactorInline,
1057+
edit: WorkspaceEdit(
1058+
changes: [
1059+
uri: [
1060+
TextEdit(
1061+
range: positions["1️⃣"]..<positions["3️⃣"],
1062+
newText: #"""
1063+
"hello\(/*self.leading1*/ /**self.leading2*/ self /*self.trailing1*/ /**concat.leading1*/)\(/*concat.trailing1 line 1 line 2 line 3 */ value)"
1064+
"""#
1065+
)
1066+
]
1067+
]
1068+
)
1069+
)
1070+
]
1071+
}
1072+
}
1073+
1074+
func testConvertStringConcatenationToStringInterpolationNotShowUpMissingExpr() async throws {
1075+
try await assertCodeActions(
1076+
###"""
1077+
1️⃣"Hello" + 2️⃣
1078+
"""###,
1079+
ranges: [("1️⃣", "2️⃣")]
1080+
) { uri, positions in
1081+
[]
1082+
}
1083+
}
1084+
1085+
func testConvertStringConcatenationToStringInterpolationNotShowUpOnlyOneStringLiteral() async throws {
1086+
try await assertCodeActions(
1087+
###"""
1088+
1️⃣"[\(2️⃣key): \(3️⃣d) 4️⃣\(value)]"5️⃣
1089+
"""###,
1090+
ranges: [("1️⃣", "2️⃣"), ("3️⃣", "4️⃣"), ("1️⃣", "5️⃣")]
1091+
) { uri, positions in
1092+
[]
1093+
}
1094+
}
1095+
1096+
func testConvertStringConcatenationToStringInterpolationNotShowUpMultilineStringLiteral() async throws {
1097+
try await assertCodeActions(
1098+
###"""
1099+
"""
1100+
1️⃣Hello
1101+
""" + 2️⃣" World"
1102+
"""###
1103+
) { uri, positions in
1104+
[]
1105+
}
1106+
}
1107+
10091108
/// Retrieves the code action at a set of markers and asserts that it matches a list of expected code actions.
10101109
///
10111110
/// - Parameters:

0 commit comments

Comments
 (0)