Skip to content

Commit 5c193eb

Browse files
committed
Diagnose invalid string segment indentation in multi-line string literals
1 parent fac654a commit 5c193eb

12 files changed

+479
-93
lines changed

Sources/SwiftParser/StringLiterals.swift

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -162,41 +162,80 @@ extension Parser {
162162
// -------------------------------------------------------------------------
163163
// Check open quote followed by newline
164164

165+
// Condition for the loop below that indicates whether the segment we are
166+
// iterating over is on a new line.
167+
var isSegmentOnNewLine: Bool
168+
165169
if firstSegment?.content.tokenText == "\n" {
166170
openQuote = openQuote.extendingTrailingTrivia(by: [.newlines(1)], arena: self.arena)
171+
isSegmentOnNewLine = true
167172
} else {
168173
if let firstSegment = firstSegment {
169174
middleSegments.insert(.stringSegment(firstSegment), at: 0)
170175
}
176+
isSegmentOnNewLine = false
171177
unexpectedBeforeOpenQuote = [openQuote]
172178
openQuote = RawTokenSyntax(missing: openQuote.tokenKind, trailingTriviaPieces: [.newlines(1)] + indentationTrivia, arena: self.arena)
173179
}
174180

175181
// -------------------------------------------------------------------------
176-
// Check indentation of segments
182+
// Check indentation of segments and escaped newlines at end of segment
177183

178184
for (index, segment) in middleSegments.enumerated() {
179185
switch segment {
180186
case .stringSegment(var segment):
181-
assert(segment.unexpectedBeforeContent == nil, "Segment should not have unexpected before content")
182-
assert(segment.content.leadingTriviaByteLength == 0, "Segment should not have leading trivia")
183-
if segment.content.tokenText.hasPrefix(indentation) {
184-
segment = RawStringSegmentSyntax(
185-
content: segment.content.reclassifyAsLeadingTrivia(indentationTrivia, arena: self.arena),
186-
arena: self.arena
187-
)
188-
} else {
189-
// TODO: Diagnose
187+
if segment.content.isMissing {
188+
// Don't diagnose incorrect indentation for segments that we synthesized
189+
break
190+
}
191+
// We are not considering unexpected and leading trivia for indentation
192+
// computation. If these assertions are violated, we can probably lift
193+
// them but we would need to check the produce the expected results.
194+
assert(segment.unexpectedBeforeContent == nil && segment.content.leadingTriviaByteLength == 0)
195+
196+
// Re-classify indentation as leading trivia
197+
if isSegmentOnNewLine {
198+
if segment.content.tokenText.hasPrefix(indentation) {
199+
segment = RawStringSegmentSyntax(
200+
segment.unexpectedBeforeContent,
201+
content: segment.content.reclassifyAsLeadingTrivia(indentationTrivia, arena: self.arena),
202+
segment.unexpectedAfterContent,
203+
arena: self.arena
204+
)
205+
} else {
206+
let actualIndentation = segment.content.tokenText.prefix(while: { $0 == UInt8(ascii: " ") || $0 == UInt8(ascii: "\t") })
207+
let actualIndentationTriva = TriviaParser.parseTrivia(SyntaxText(rebasing: actualIndentation), position: .leading)
208+
let content = segment.content.reclassifyAsLeadingTrivia(
209+
actualIndentationTriva,
210+
lexerError: LexerError(.insufficientIndentationInMultilineStringLiteral, byteOffset: 0),
211+
arena: self.arena
212+
)
213+
segment = RawStringSegmentSyntax(
214+
segment.unexpectedBeforeContent,
215+
content: content,
216+
segment.unexpectedAfterContent,
217+
arena: self.arena
218+
)
219+
}
190220
}
221+
222+
isSegmentOnNewLine = segment.content.tokenText.hasSuffix("\n")
223+
224+
// If the segment has a `\` in front of its trailing newline, that newline
225+
// is not part of the reprsented string and should be trivia.
226+
191227
if segment.content.tokenText.hasSuffix("\\\n") {
192228
// TODO: Add a backslash trivia kind
193229
segment = RawStringSegmentSyntax(
230+
segment.unexpectedBeforeContent,
194231
content: segment.content.reclassifyAsTrailingTrivia([.unexpectedText("\\"), .newlines(1)], arena: self.arena),
232+
segment.unexpectedAfterContent,
195233
arena: self.arena
196234
)
197235
}
198236
middleSegments[index] = .stringSegment(segment)
199-
case .expressionSegment:
237+
case .expressionSegment(let segment):
238+
isSegmentOnNewLine = segment.rightParen.trailingTriviaPieces.contains(where: { $0.isNewline })
200239
// TODO: Check indentation
201240
break
202241
}

Sources/SwiftParserDiagnostics/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ add_swift_host_library(SwiftParserDiagnostics
1111
LexerDiagnosticMessages.swift
1212
MissingNodesError.swift
1313
MissingTokenError.swift
14+
MultiLineStringLiteralDiagnoticsGenerator.swift
1415
ParserDiagnosticMessages.swift
1516
ParseDiagnosticsGenerator.swift
1617
PresenceUtils.swift

Sources/SwiftParserDiagnostics/LexerDiagnosticMessages.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ public extension SwiftSyntax.LexerError {
108108
return StaticLexerError.expectedBinaryExponentInHexFloatLiteral
109109
case .expectedDigitInFloatLiteral:
110110
return StaticLexerError.expectedDigitInFloatLiteral
111+
case .insufficientIndentationInMultilineStringLiteral:
112+
// This should be diagnosed when visiting the `StringLiteralExprSyntax`
113+
// inside `ParseDiagnosticsGenerator` but fall back to an error message
114+
// here in case the error is not diagnosed.
115+
return InvalidIndentationInMultiLineStringLiteralError(kind: .insufficientIdentation, lines: 1)
111116
case .invalidBinaryDigitInIntegerLiteral:
112117
return InvalidDigitInIntegerLiteral(kind: .binary(scalarAtErrorOffset))
113118
case .invalidDecimalDigitInIntegerLiteral:
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2022 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 SwiftDiagnostics
14+
import SwiftSyntax
15+
16+
/// A diagnostic that `MultiLineStringLiteralIndentatinDiagnosticsGenerator` is building.
17+
/// As indentation errors are found on more lines, this diagnostic is modified
18+
/// to include more fixIts.
19+
private struct InProgressDiagnostic {
20+
let anchor: TokenSyntax
21+
let position: AbsolutePosition
22+
let kind: InvalidIndentationInMultiLineStringLiteralError.Kind
23+
var lines: Int
24+
var changes: [FixIt.Change]
25+
var handledNodes: [SyntaxIdentifier]
26+
}
27+
28+
final class MultiLineStringLiteralIndentatinDiagnosticsGenerator: SyntaxVisitor {
29+
30+
// MARK: Entry
31+
32+
public static func diagnose(_ node: StringLiteralExprSyntax) -> [(diagnostic: Diagnostic, handledNodes: [SyntaxIdentifier])] {
33+
let visitor = MultiLineStringLiteralIndentatinDiagnosticsGenerator(closeQuote: node.closeQuote)
34+
visitor.walk(node)
35+
visitor.finishInProgressDiagnostic()
36+
return visitor.finishedDiagnostics
37+
}
38+
39+
// MARK: Implemenatation
40+
41+
private let closeQuote: TokenSyntax
42+
43+
/// Diagnostics that we have finisehed because their incorrect indentation was followed by correct indentation
44+
private var finishedDiagnostics: [(diagnostic: Diagnostic, handledNodes: [SyntaxIdentifier])] = []
45+
46+
/// The diagnostic we are currently building up
47+
private var inProgressDiagnostic: InProgressDiagnostic?
48+
49+
private init(closeQuote: TokenSyntax) {
50+
self.closeQuote = closeQuote
51+
super.init(viewMode: .sourceAccurate)
52+
}
53+
54+
private func addIncorrectlyIndentedToken(token: TokenSyntax) {
55+
// Determine kind and position of the diagnonstic
56+
var kind: InvalidIndentationInMultiLineStringLiteralError.Kind = .insufficientIdentation
57+
var position = token.positionAfterSkippingLeadingTrivia
58+
var positionOffset = 0
59+
for (invalidTriviaPiece, missingTriviaPiece) in zip(token.leadingTrivia.decomposed, closeQuote.leadingTrivia.decomposed) {
60+
if invalidTriviaPiece == missingTriviaPiece {
61+
positionOffset += invalidTriviaPiece.sourceLength.utf8Length
62+
continue
63+
}
64+
switch invalidTriviaPiece {
65+
case .tabs: kind = .unexpectedTab
66+
case .spaces: kind = .unexpectedSpace
67+
default: break
68+
}
69+
position = token.position.advanced(by: positionOffset)
70+
break
71+
}
72+
73+
// If the diagnostic we are currently building has a different kind, we
74+
// cannot merge them. Commit the current diagnostic so we can create a new one.
75+
if inProgressDiagnostic?.kind != kind {
76+
finishInProgressDiagnostic()
77+
}
78+
79+
// Append hte inProgress diagnostic or create a new one.
80+
let changes = [FixIt.Change.replaceLeadingTrivia(token: token, newTrivia: closeQuote.leadingTrivia)]
81+
let handledNodes = [token.id]
82+
if self.inProgressDiagnostic != nil {
83+
self.inProgressDiagnostic!.lines += 1
84+
self.inProgressDiagnostic!.changes += changes
85+
self.inProgressDiagnostic!.handledNodes += handledNodes
86+
} else {
87+
self.inProgressDiagnostic = InProgressDiagnostic(
88+
anchor: token,
89+
position: position,
90+
kind: kind,
91+
lines: 1,
92+
changes: changes,
93+
handledNodes: handledNodes
94+
)
95+
}
96+
}
97+
98+
// Finish the diagnostic that's currently in progress so any new indentation
99+
// issue that's found will start a new diagnostic.
100+
private func finishInProgressDiagnostic() {
101+
guard let currentDiagnostic = self.inProgressDiagnostic else {
102+
return
103+
}
104+
105+
let diagnostic = Diagnostic(
106+
node: Syntax(currentDiagnostic.anchor),
107+
position: currentDiagnostic.position,
108+
message: InvalidIndentationInMultiLineStringLiteralError(kind: currentDiagnostic.kind, lines: currentDiagnostic.lines),
109+
highlights: [],
110+
notes: [Note(node: Syntax(closeQuote), message: .shouldMatchIndentationOfClosingQuote)],
111+
fixIts: [FixIt(message: .changeIndentationToMatchClosingDelimiter, changes: FixIt.Changes(changes: currentDiagnostic.changes))]
112+
)
113+
114+
finishedDiagnostics.append((diagnostic, currentDiagnostic.handledNodes))
115+
self.inProgressDiagnostic = nil
116+
}
117+
118+
private func isOnNewline(_ token: TokenSyntax) -> Bool {
119+
if token.leadingTrivia.contains(where: { $0.isNewline }) {
120+
return true
121+
}
122+
guard let previousToken = token.previousToken(viewMode: .sourceAccurate) else {
123+
return false
124+
}
125+
switch previousToken.tokenKind {
126+
case .stringSegment(let stringSegment):
127+
return stringSegment.hasSuffix("\r") || stringSegment.hasSuffix("\n")
128+
default:
129+
// FIXME: newlines should never be part of trailing trivia
130+
return previousToken.trailingTrivia.contains(where: { $0.isNewline })
131+
}
132+
}
133+
134+
override func visit(_ token: TokenSyntax) -> SyntaxVisitorContinueKind {
135+
guard isOnNewline(token) else {
136+
// We are only interested in tokens at the start of a line
137+
return .visitChildren
138+
}
139+
140+
if token.lexerError?.kind == .insufficientIndentationInMultilineStringLiteral {
141+
addIncorrectlyIndentedToken(token: token)
142+
} else {
143+
finishInProgressDiagnostic()
144+
}
145+
return .visitChildren
146+
}
147+
}

Sources/SwiftParserDiagnostics/ParseDiagnosticsGenerator.swift

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,21 @@ public class ParseDiagnosticsGenerator: SyntaxAnyVisitor {
8080
notes: [Note] = [],
8181
fixIts: [FixIt] = [],
8282
handledNodes: [SyntaxIdentifier] = []
83+
) {
84+
let diagnostic = Diagnostic(node: Syntax(node), position: position, message: message, highlights: highlights, notes: notes, fixIts: fixIts)
85+
addDiagnostic(diagnostic, handledNodes: handledNodes)
86+
}
87+
88+
/// Produce a diagnostic.
89+
func addDiagnostic(
90+
_ diagnostic: Diagnostic,
91+
handledNodes: [SyntaxIdentifier] = []
8392
) {
8493
if suppressRemainingDiagnostics {
8594
return
8695
}
8796
diagnostics.removeAll(where: { handledNodes.contains($0.node.id) })
88-
diagnostics.append(Diagnostic(node: Syntax(node), position: position, message: message, highlights: highlights, notes: notes, fixIts: fixIts))
97+
diagnostics.append(diagnostic)
8998
self.handledNodes.append(contentsOf: handledNodes)
9099
}
91100

@@ -696,6 +705,16 @@ public class ParseDiagnosticsGenerator: SyntaxAnyVisitor {
696705
return .visitChildren
697706
}
698707

708+
public override func visit(_ node: StringLiteralExprSyntax) -> SyntaxVisitorContinueKind {
709+
if shouldSkip(node) {
710+
return .skipChildren
711+
}
712+
for (diagnostic, handledNodes) in MultiLineStringLiteralIndentatinDiagnosticsGenerator.diagnose(node) {
713+
addDiagnostic(diagnostic, handledNodes: handledNodes)
714+
}
715+
return .visitChildren
716+
}
717+
699718
public override func visit(_ node: SubscriptDeclSyntax) -> SyntaxVisitorContinueKind {
700719
if shouldSkip(node) {
701720
return .skipChildren

Sources/SwiftParserDiagnostics/ParserDiagnosticMessages.swift

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,6 @@ extension DiagnosticMessage where Self == StaticParserError {
187187
}
188188
}
189189

190-
// MARK: - Diagnostics (please sort alphabetically)
191-
192190
public struct AvailabilityConditionInExpression: ParserError {
193191
public let avaialabilityCondition: AvailabilityConditionSyntax
194192

@@ -242,6 +240,33 @@ public struct InvalidIdentifierError: ParserError {
242240
}
243241
}
244242

243+
public struct InvalidIndentationInMultiLineStringLiteralError: ParserError {
244+
public enum Kind {
245+
case insufficientIdentation
246+
case unexpectedSpace
247+
case unexpectedTab
248+
249+
var message: String {
250+
switch self {
251+
case .insufficientIdentation: return "insufficient indentation"
252+
case .unexpectedSpace: return "unexpected space in indentation"
253+
case .unexpectedTab: return "unexpected tab in indentation"
254+
}
255+
}
256+
}
257+
258+
public let kind: Kind
259+
public let lines: Int
260+
261+
public var message: String {
262+
if lines == 1 {
263+
return "\(kind.message) of line in multi-line string literal"
264+
} else {
265+
return "\(kind.message) of next \(lines) lines in multi-line string literal"
266+
}
267+
}
268+
}
269+
245270
public struct MissingAttributeArgument: ParserError {
246271
/// The name of the attribute that's missing the argument, without `@`.
247272
public let attributeName: TypeSyntax
@@ -323,6 +348,33 @@ public struct UnknownDirectiveError: ParserError {
323348
}
324349
}
325350

351+
// MARK: - Notes (please sort alphabetically)
352+
353+
/// A parser fix-it with a static message.
354+
public struct StaticParserNote: NoteMessage {
355+
public let message: String
356+
private let messageID: String
357+
358+
/// This should only be called within a static var on FixItMessage, such
359+
/// as the examples below. This allows us to pick up the messageID from the
360+
/// var name.
361+
fileprivate init(_ message: String, messageID: String = #function) {
362+
self.message = message
363+
self.messageID = messageID
364+
}
365+
366+
public var fixItID: MessageID {
367+
MessageID(domain: diagnosticDomain, id: "\(type(of: self)).\(messageID)")
368+
}
369+
}
370+
371+
extension NoteMessage where Self == StaticParserNote {
372+
/// Please order alphabetically by property name.
373+
public static var shouldMatchIndentationOfClosingQuote: Self {
374+
.init("should match indentation here")
375+
}
376+
}
377+
326378
// MARK: - Fix-Its (please sort alphabetically)
327379

328380
/// A parser fix-it with a static message.
@@ -345,6 +397,9 @@ public struct StaticParserFixIt: FixItMessage {
345397

346398
extension FixItMessage where Self == StaticParserFixIt {
347399
/// Please order alphabetically by property name.
400+
public static var changeIndentationToMatchClosingDelimiter: Self {
401+
.init("change indentation of this line to match closing delimiter")
402+
}
348403
public static var insertSemicolon: Self {
349404
.init("insert ';'")
350405
}

Sources/SwiftSyntax/LexerError.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public struct LexerError: Hashable {
1919

2020
case expectedBinaryExponentInHexFloatLiteral
2121
case expectedDigitInFloatLiteral
22+
case insufficientIndentationInMultilineStringLiteral
2223
case invalidBinaryDigitInIntegerLiteral
2324
case invalidDecimalDigitInIntegerLiteral
2425
case invalidFloatingPointCharacter

0 commit comments

Comments
 (0)