Skip to content

Commit 651cff4

Browse files
committed
Diagnose incorrect indentation of expression segments in multi-line string literals
1 parent 5c193eb commit 651cff4

File tree

7 files changed

+259
-22
lines changed

7 files changed

+259
-22
lines changed

Sources/SwiftParser/StringLiterals.swift

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,81 @@
1212

1313
@_spi(RawSyntax) import SwiftSyntax
1414

15+
fileprivate class StringLiteralExpressionIndentationChecker {
16+
// MARK: Entry
17+
18+
init(expectedIndentation: SyntaxText, arena: SyntaxArena) {
19+
self.expectedIndentation = expectedIndentation
20+
self.arena = arena
21+
}
22+
23+
func checkIndentation(of expressionSegment: RawExpressionSegmentSyntax) -> RawExpressionSegmentSyntax? {
24+
if let rewrittenSegment = self.visit(node: RawSyntax(expressionSegment)) {
25+
return rewrittenSegment.as(RawExpressionSegmentSyntax.self)
26+
} else {
27+
return nil
28+
}
29+
}
30+
31+
// MARK: Implementation
32+
33+
private let expectedIndentation: SyntaxText
34+
private let arena: SyntaxArena
35+
36+
private func visit(node: RawSyntax) -> RawSyntax? {
37+
if node.isToken {
38+
return visitTokenNode(token: node.as(RawTokenSyntax.self)!)
39+
} else {
40+
return visitLayoutNode(node: node)
41+
}
42+
}
43+
44+
private func visitTokenNode(token: RawTokenSyntax) -> RawSyntax? {
45+
if !token.leadingTriviaPieces.contains(where: { $0.isNewline }) {
46+
// Only checking tokens on a newline
47+
return nil
48+
}
49+
let hasSufficientIndentation = token.tokenView.leadingTrivia { leadingTrivia in
50+
let indentationStartIndex = leadingTrivia.lastIndex(where: { $0 == UInt8(ascii: "\n") || $0 == UInt8(ascii: "\n") })?.advanced(by: 1) ?? leadingTrivia.startIndex
51+
return SyntaxText(rebasing: leadingTrivia[indentationStartIndex...]).hasPrefix(expectedIndentation)
52+
}
53+
if hasSufficientIndentation {
54+
return nil
55+
}
56+
if token.tokenView.lexerError != nil {
57+
// Token already has a lexer error, ignore the indentation error until that
58+
// error is fixed
59+
return nil
60+
}
61+
return token.tokenView.withLexerError(
62+
lexerError: LexerError(.insufficientIndentationInMultilineStringLiteral, byteOffset: 0),
63+
arena: arena
64+
)
65+
}
66+
67+
private func visitLayoutNode(node: RawSyntax) -> RawSyntax? {
68+
let layoutView = node.layoutView!
69+
70+
var hasRewrittenChild = false
71+
var rewrittenChildren: [RawSyntax?] = []
72+
for child in layoutView.children {
73+
if let child = child, let rewrittenChild = visit(node: child) {
74+
hasRewrittenChild = true
75+
rewrittenChildren.append(rewrittenChild)
76+
} else {
77+
rewrittenChildren.append(child)
78+
}
79+
}
80+
assert(rewrittenChildren.count == layoutView.children.count)
81+
if hasRewrittenChild {
82+
return layoutView.replacingLayout(with: rewrittenChildren, arena: arena)
83+
} else {
84+
return nil
85+
}
86+
}
87+
}
88+
89+
1590
extension Parser {
1691
/// Consumes a raw string delimiter that has the same number of `#` as `openDelimiter`.
1792
private mutating func parseStringDelimiter(openDelimiter: RawTokenSyntax?) -> (unexpectedBeforeCheckedDelimiter: RawUnexpectedNodesSyntax?, checkedDelimiter: RawTokenSyntax?) {
@@ -181,6 +256,8 @@ extension Parser {
181256
// -------------------------------------------------------------------------
182257
// Check indentation of segments and escaped newlines at end of segment
183258

259+
let expressionIndentationChecker = StringLiteralExpressionIndentationChecker(expectedIndentation: indentation, arena: self.arena)
260+
184261
for (index, segment) in middleSegments.enumerated() {
185262
switch segment {
186263
case .stringSegment(var segment):
@@ -236,8 +313,10 @@ extension Parser {
236313
middleSegments[index] = .stringSegment(segment)
237314
case .expressionSegment(let segment):
238315
isSegmentOnNewLine = segment.rightParen.trailingTriviaPieces.contains(where: { $0.isNewline })
239-
// TODO: Check indentation
240-
break
316+
317+
if let rewrittenSegment = expressionIndentationChecker.checkIndentation(of: segment) {
318+
middleSegments[index] = .expressionSegment(rewrittenSegment)
319+
}
241320
}
242321
}
243322

Sources/SwiftParserDiagnostics/MultiLineStringLiteralDiagnoticsGenerator.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,15 @@ final class MultiLineStringLiteralIndentatinDiagnosticsGenerator: SyntaxVisitor
5555
// Determine kind and position of the diagnonstic
5656
var kind: InvalidIndentationInMultiLineStringLiteralError.Kind = .insufficientIdentation
5757
var position = token.positionAfterSkippingLeadingTrivia
58-
var positionOffset = 0
59-
for (invalidTriviaPiece, missingTriviaPiece) in zip(token.leadingTrivia.decomposed, closeQuote.leadingTrivia.decomposed) {
58+
59+
let tokenLeadingTrivia = token.leadingTrivia
60+
61+
let indentationStartIndex = tokenLeadingTrivia.pieces.lastIndex(where: { $0.isNewline })?.advanced(by: 1) ?? tokenLeadingTrivia.startIndex
62+
let preIndentationTrivia = Trivia(pieces: tokenLeadingTrivia[0..<indentationStartIndex])
63+
let indentationTrivia = Trivia(pieces: tokenLeadingTrivia[indentationStartIndex...])
64+
var positionOffset = preIndentationTrivia.reduce(0, { $0 + $1.sourceLength.utf8Length })
65+
66+
for (invalidTriviaPiece, missingTriviaPiece) in zip(indentationTrivia.decomposed, closeQuote.leadingTrivia.decomposed) {
6067
if invalidTriviaPiece == missingTriviaPiece {
6168
positionOffset += invalidTriviaPiece.sourceLength.utf8Length
6269
continue
@@ -77,7 +84,7 @@ final class MultiLineStringLiteralIndentatinDiagnosticsGenerator: SyntaxVisitor
7784
}
7885

7986
// Append hte inProgress diagnostic or create a new one.
80-
let changes = [FixIt.Change.replaceLeadingTrivia(token: token, newTrivia: closeQuote.leadingTrivia)]
87+
let changes = [FixIt.Change.replaceLeadingTrivia(token: token, newTrivia: preIndentationTrivia + closeQuote.leadingTrivia)]
8188
let handledNodes = [token.id]
8289
if self.inProgressDiagnostic != nil {
8390
self.inProgressDiagnostic!.lines += 1

Sources/SwiftSyntax/Raw/RawSyntax.swift

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,21 @@ internal struct RawSyntaxData {
6262
private var lexerErrorByteOffset: UInt16
6363

6464
var lexerError: LexerError? {
65-
if let kind = lexerErrorKind {
66-
return LexerError(kind, byteOffset: lexerErrorByteOffset)
67-
} else {
68-
return nil
65+
get {
66+
if let kind = lexerErrorKind {
67+
return LexerError(kind, byteOffset: lexerErrorByteOffset)
68+
} else {
69+
return nil
70+
}
71+
}
72+
set {
73+
if let newValue = newValue {
74+
self.lexerErrorKind = newValue.kind
75+
self.lexerErrorByteOffset = newValue.byteOffset
76+
} else {
77+
self.lexerErrorKind = nil
78+
self.lexerErrorByteOffset = 0
79+
}
6980
}
7081
}
7182

@@ -828,12 +839,14 @@ extension RawSyntax: CustomReflectable {
828839
}
829840
}
830841

831-
enum RawSyntaxView {
842+
@_spi(RawSyntax)
843+
public enum RawSyntaxView {
832844
case token(RawSyntaxTokenView)
833845
case layout(RawSyntaxLayoutView)
834846
}
835847

836-
extension RawSyntax {
848+
@_spi(RawSyntax)
849+
public extension RawSyntax {
837850
var view: RawSyntaxView {
838851
switch raw.payload {
839852
case .parsedToken, .materializedToken:

Sources/SwiftSyntax/Raw/RawSyntaxTokenView.swift

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,34 @@ public struct RawSyntaxTokenView {
129129
return SourceLength(utf8Length: trailingTriviaByteLength)
130130
}
131131

132+
/// Perform `body` with text of th leading trivia.
133+
@_spi(RawSyntax)
134+
public func leadingTrivia<T>(_ body: (SyntaxText) -> T) -> T {
135+
switch raw.rawData.payload {
136+
case .parsedToken(let dat):
137+
return body(dat.leadingTriviaText)
138+
case .materializedToken(let dat):
139+
var leadingTriviaStr = Trivia(pieces: dat.leadingTrivia.map(TriviaPiece.init)).description
140+
return leadingTriviaStr.withSyntaxText(body)
141+
case .layout(_):
142+
preconditionFailure("'tokenTrailingRawTriviaPieces' is called on non-token raw syntax")
143+
}
144+
}
145+
146+
/// Perform `body` with text of th trailing trivia.
147+
@_spi(RawSyntax)
148+
public func trailingTrivia<T>(_ body: (SyntaxText) -> T) -> T {
149+
switch raw.rawData.payload {
150+
case .parsedToken(let dat):
151+
return body(dat.trailingTriviaText)
152+
case .materializedToken(let dat):
153+
var trailingTriviaStr = Trivia(pieces: dat.trailingTrivia.map(TriviaPiece.init)).description
154+
return trailingTriviaStr.withSyntaxText(body)
155+
case .layout(_):
156+
preconditionFailure("'tokenTrailingRawTriviaPieces' is called on non-token raw syntax")
157+
}
158+
}
159+
132160
/// Returns the leading `Trivia`.
133161
@_spi(RawSyntax)
134162
public func formLeadingTrivia() -> Trivia {
@@ -212,7 +240,8 @@ public struct RawSyntaxTokenView {
212240
}
213241
}
214242

215-
var lexerError: LexerError? {
243+
@_spi(RawSyntax)
244+
public var lexerError: LexerError? {
216245
switch raw.rawData.payload {
217246
case .parsedToken(let dat):
218247
return dat.lexerError
@@ -222,4 +251,18 @@ public struct RawSyntaxTokenView {
222251
preconditionFailure("'lexerError' is a token-only property")
223252
}
224253
}
254+
255+
@_spi(RawSyntax)
256+
public func withLexerError(lexerError: LexerError?, arena: SyntaxArena) -> RawSyntax {
257+
switch raw.rawData.payload {
258+
case .parsedToken(var dat):
259+
dat.lexerError = lexerError
260+
return RawSyntax(arena: arena, payload: .parsedToken(dat))
261+
case .materializedToken(var dat):
262+
dat.lexerError = lexerError
263+
return RawSyntax(arena: arena, payload: .materializedToken(dat))
264+
default:
265+
preconditionFailure("'withLexerError()' is called on non-token raw syntax")
266+
}
267+
}
225268
}

Sources/SwiftSyntax/Trivia.swift.gyb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ public struct Trivia {
102102
public let pieces: [TriviaPiece]
103103

104104
/// Creates Trivia with the provided underlying pieces.
105-
public init(pieces: [TriviaPiece]) {
106-
self.pieces = pieces
105+
public init<S: Sequence>(pieces: S) where S.Element == TriviaPiece {
106+
self.pieces = Array(pieces)
107107
}
108108

109109
/// Creates Trivia with no pieces.

Sources/SwiftSyntax/gyb_generated/Trivia.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,8 @@ public struct Trivia {
148148
public let pieces: [TriviaPiece]
149149

150150
/// Creates Trivia with the provided underlying pieces.
151-
public init(pieces: [TriviaPiece]) {
152-
self.pieces = pieces
151+
public init<S: Sequence>(pieces: S) where S.Element == TriviaPiece {
152+
self.pieces = Array(pieces)
153153
}
154154

155155
/// Creates Trivia with no pieces.

Tests/SwiftParserTest/translated/MultilineErrorsTests.swift

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,19 +100,43 @@ final class MultilineErrorsTests: XCTestCase {
100100
)
101101
}
102102

103-
func testMultilineErrors6() {
103+
func testMultilineErrors6a() {
104104
AssertParse(
105105
#"""
106106
_ = """
107107
\(42
108-
)
108+
1️⃣)
109109
"""
110110
"""#,
111111
diagnostics: [
112-
// TODO: Old parser expected error on line 3: insufficient indentation of line in multi-line string literal
113-
// TODO: Old parser expected note on line 3: change indentation of this line to match closing delimiter, Fix-It replacements: 1 - 1 = ' '
114-
// TODO: Old parser expected note on line 4: should match space here
115-
]
112+
DiagnosticSpec(message: "insufficient indentation of line in multi-line string literal")
113+
],
114+
fixedSource: #"""
115+
_ = """
116+
\(42
117+
)
118+
"""
119+
"""#
120+
)
121+
}
122+
123+
func testMultilineErrors6b() {
124+
AssertParse(
125+
#"""
126+
_ = """
127+
\(42
128+
1️⃣)
129+
"""
130+
"""#,
131+
diagnostics: [
132+
DiagnosticSpec(message: "insufficient indentation of line in multi-line string literal")
133+
],
134+
fixedSource: #"""
135+
_ = """
136+
\(42
137+
)
138+
"""
139+
"""#
116140
)
117141
}
118142

@@ -612,4 +636,75 @@ final class MultilineErrorsTests: XCTestCase {
612636
"""#
613637
)
614638
}
639+
640+
func testInsufficientIndentationInInterpolation() {
641+
AssertParse(
642+
#"""
643+
"""
644+
\(
645+
1️⃣1
646+
)
647+
"""
648+
"""#,
649+
diagnostics: [
650+
DiagnosticSpec(message: "insufficient indentation of line in multi-line string literal")
651+
],
652+
fixedSource: #"""
653+
"""
654+
\(
655+
1
656+
)
657+
"""
658+
"""#
659+
)
660+
661+
AssertParse(
662+
#"""
663+
"""
664+
\(
665+
1️⃣1
666+
+
667+
2️⃣2
668+
)
669+
"""
670+
"""#,
671+
diagnostics: [
672+
DiagnosticSpec(locationMarker: "1️⃣", message: "insufficient indentation of line in multi-line string literal"),
673+
DiagnosticSpec(locationMarker: "2️⃣", message: "insufficient indentation of line in multi-line string literal"),
674+
],
675+
fixedSource: #"""
676+
"""
677+
\(
678+
1
679+
+
680+
2
681+
)
682+
"""
683+
"""#
684+
)
685+
686+
AssertParse(
687+
#"""
688+
"""
689+
\(
690+
1️⃣1
691+
+
692+
2
693+
)
694+
"""
695+
"""#,
696+
diagnostics: [
697+
DiagnosticSpec(message: "insufficient indentation of next 3 lines in multi-line string literal")
698+
],
699+
fixedSource: #"""
700+
"""
701+
\(
702+
1
703+
+
704+
2
705+
)
706+
"""
707+
"""#
708+
)
709+
}
615710
}

0 commit comments

Comments
 (0)