Skip to content

Commit 8bf7809

Browse files
committed
Add diagnostic to move 'async' and 'throws' in 'ArrowExpr' in front of '->'
1 parent 1983838 commit 8bf7809

File tree

8 files changed

+381
-104
lines changed

8 files changed

+381
-104
lines changed

Sources/SwiftParser/Diagnostics/MissingNodesError.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ import SwiftBasicFormat
1717
// MARK: - Shared code
1818

1919
/// Returns a string that describes `missingNodes`.
20-
/// `missingNodes` are expected to all be children of `commonParent`.
21-
private func missingNodesDescription(missingNodes: [Syntax], commonParent: Syntax?) -> String {
22-
assert(missingNodes.allSatisfy({ $0.parent == commonParent }))
20+
/// If `commonParent` is not `nil`, `missingNodes` are expected to all be children of `commonParent`.
21+
func missingNodesDescription(missingNodes: [Syntax], commonParent: Syntax?) -> String {
22+
if commonParent != nil {
23+
assert(missingNodes.allSatisfy({ $0.parent == commonParent }))
24+
}
2325

2426
// If all tokens in the parent are missing, return the parent type name.
2527
if let commonParent = commonParent,

Sources/SwiftParser/Diagnostics/ParseDiagnosticsGenerator.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,51 @@ public class ParseDiagnosticsGenerator: SyntaxAnyVisitor {
9595
return handledNodes.contains(node.id)
9696
}
9797

98+
/// Utility function to emit a diagnostic that removes a misplaced token and instead inserts an equivalent token at the corrected location.
99+
///
100+
/// If `incorrectContainer` contains only tokens that satisfy `unexpectedTokenCondition`, emit a diagnostic with message `message` that marks this token as misplaced.
101+
/// If `correctTokens` contains missing tokens, also emit a Fix-It with message `fixIt` that marks the unexpected token as missing and instead inserts `correctTokens`.
102+
public func exchangeTokens(
103+
unexpected: UnexpectedNodesSyntax?,
104+
unexpectedTokenCondition: (TokenSyntax) -> Bool,
105+
correctTokens: [TokenSyntax?],
106+
message: (_ misplacedTokens: [TokenSyntax]) -> DiagnosticMessage,
107+
fixIt: (_ misplacedTokens: [TokenSyntax]) -> FixItMessage
108+
) {
109+
guard let incorrectContainer = unexpected,
110+
let misplacedTokens = incorrectContainer.onlyTokens(satisfying: unexpectedTokenCondition) else {
111+
// If there are no unexpected nodes or the unexpected contain multiple tokens, don't emit a diagnostic.
112+
return
113+
}
114+
115+
// Ignore `correctTokens` that are already present.
116+
let correctTokens = correctTokens.compactMap({ $0 }).filter({ $0.presence == .missing })
117+
var changes = misplacedTokens.map(FixIt.Changes.makeMissing)
118+
for correctToken in correctTokens {
119+
if misplacedTokens.count == 1, let misplacedToken = misplacedTokens.first,
120+
misplacedToken.nextToken(viewMode: .all) == correctToken || misplacedToken.previousToken(viewMode: .all) == correctToken {
121+
changes.append(FixIt.Changes.makePresent(
122+
node: correctToken,
123+
leadingTrivia: misplacedToken.leadingTrivia,
124+
trailingTrivia: misplacedToken.trailingTrivia
125+
))
126+
} else {
127+
changes.append(FixIt.Changes.makePresent(
128+
node: correctToken,
129+
leadingTrivia: nil,
130+
trailingTrivia: nil
131+
))
132+
}
133+
}
134+
var fixIts: [FixIt] = []
135+
if changes.count > 1 {
136+
// Only emit a Fix-It if we are moving a token, i.e. also making a token present.
137+
fixIts.append(FixIt(message: fixIt(misplacedTokens), changes: changes))
138+
}
139+
140+
addDiagnostic(incorrectContainer, message(misplacedTokens), fixIts: fixIts, handledNodes: [incorrectContainer.id] + correctTokens.map(\.id))
141+
}
142+
98143
// MARK: - Generic diagnostic generation
99144

100145
public override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind {
@@ -180,6 +225,20 @@ public class ParseDiagnosticsGenerator: SyntaxAnyVisitor {
180225
return .visitChildren
181226
}
182227

228+
public override func visit(_ node: ArrowExprSyntax) -> SyntaxVisitorContinueKind {
229+
if shouldSkip(node) {
230+
return .skipChildren
231+
}
232+
exchangeTokens(
233+
unexpected: node.unexpectedAfterArrowToken,
234+
unexpectedTokenCondition: { $0.tokenKind == .contextualKeyword("async") || $0.tokenKind == .throwsKeyword },
235+
correctTokens: [node.asyncKeyword, node.throwsToken],
236+
message: { EffectsSpecifierAfterArrow(effectsSpecifiersAfterArrow: $0) },
237+
fixIt: { MoveEffectsSpecifiersBeforeArrowFixIt(effectsSpecifiersAfterArrow: $0) }
238+
)
239+
return .visitChildren
240+
}
241+
183242
public override func visit(_ node: ForInStmtSyntax) -> SyntaxVisitorContinueKind {
184243
if shouldSkip(node) {
185244
return .skipChildren

Sources/SwiftParser/Diagnostics/ParserDiagnosticMessages.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ public enum StaticParserError: String, DiagnosticMessage {
8383

8484
// MARK: - Diagnostics (please sort alphabetically)
8585

86+
public struct EffectsSpecifierAfterArrow: ParserError {
87+
public let effectsSpecifiersAfterArrow: [TokenSyntax]
88+
89+
public var message: String {
90+
"\(missingNodesDescription(missingNodes: effectsSpecifiersAfterArrow.map(Syntax.init), commonParent: nil)) may only occur before '->'"
91+
}
92+
}
93+
8694
public struct ExtaneousCodeAtTopLevel: ParserError {
8795
public let extraneousCode: UnexpectedNodesSyntax
8896

@@ -149,3 +157,11 @@ public enum StaticParserFixIt: String, FixItMessage {
149157
MessageID(domain: diagnosticDomain, id: "\(type(of: self)).\(self)")
150158
}
151159
}
160+
161+
public struct MoveEffectsSpecifiersBeforeArrowFixIt: ParserFixIt {
162+
public let effectsSpecifiersAfterArrow: [TokenSyntax]
163+
164+
public var message: String {
165+
"move \(missingNodesDescription(missingNodes: effectsSpecifiersAfterArrow.map(Syntax.init), commonParent: nil)) in front of '->'"
166+
}
167+
}

Sources/SwiftParser/Diagnostics/SyntaxExtensions.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,30 @@ extension UnexpectedNodesSyntax {
2020
func tokens(withKind kind: TokenKind) -> [TokenSyntax] {
2121
return self.tokens(satisfying: { $0.tokenKind == kind })
2222
}
23+
24+
/// If this only contains a single item that is a token, return that token, otherwise return `nil`.
25+
var onlyToken: TokenSyntax? {
26+
return onlyToken(where: { _ in true })
27+
}
28+
29+
/// If this only contains a single item, which is a token satisfying `condition`, return that token, otherwise return `nil`.
30+
func onlyToken(where condition: (TokenSyntax) -> Bool) -> TokenSyntax? {
31+
if self.count == 1, let token = self.first?.as(TokenSyntax.self), condition(token) {
32+
return token
33+
} else {
34+
return nil
35+
}
36+
}
37+
38+
/// If this only contains tokens satisfying `condition`, return an array containing those tokens, otherwise return `nil`.
39+
func onlyTokens(satisfying condition: (TokenSyntax) -> Bool) -> [TokenSyntax]? {
40+
let tokens = tokens(satisfying: condition)
41+
if tokens.count == self.count {
42+
return tokens
43+
} else {
44+
return nil
45+
}
46+
}
2347
}
2448

2549
extension Syntax {

Sources/SwiftParser/Expressions.swift

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -319,17 +319,42 @@ extension Parser {
319319
return nil
320320
}
321321
case (.arrow, _)?, (.throwsKeyword, _)?:
322-
let asyncKeyword = self.consumeIfContextualKeyword("async")
322+
var asyncKeyword = self.consumeIfContextualKeyword("async")
323+
324+
var throwsKeyword = self.consume(if: .throwsKeyword)
323325

324-
let throwsKeyword = self.consume(if: .throwsKeyword)
325326
let (unexpectedBeforeArrow, arrow) = self.expect(.arrow)
326327

328+
// Recover if effect modifiers are specified after '->' by eating them into
329+
// unexpectedAfterArrow and inserting the corresponding effects modifier as
330+
// missing into the ArrowExprSyntax. This reflect the semantics the user
331+
// originally intended.
332+
var effectModifiersAfterArrow: [RawTokenSyntax] = []
333+
if let asyncAfterArrow = self.consumeIfContextualKeyword("async") {
334+
effectModifiersAfterArrow.append(asyncAfterArrow)
335+
if asyncKeyword == nil {
336+
asyncKeyword = missingToken(.contextualKeyword, text: "async")
337+
}
338+
}
339+
if let throwsAfterArrow = self.consume(if: .throwsKeyword) {
340+
effectModifiersAfterArrow.append(throwsAfterArrow)
341+
if throwsKeyword == nil {
342+
throwsKeyword = missingToken(.throwsKeyword, text: nil)
343+
}
344+
}
345+
let unexpectedAfterArrow = effectModifiersAfterArrow.isEmpty ? nil : RawUnexpectedNodesSyntax(
346+
elements: effectModifiersAfterArrow.map { RawSyntax($0) },
347+
arena: self.arena
348+
)
349+
327350
let op = RawArrowExprSyntax(
328351
asyncKeyword: asyncKeyword,
329352
throwsToken: throwsKeyword,
330353
unexpectedBeforeArrow,
331354
arrowToken: arrow,
332-
arena: self.arena)
355+
unexpectedAfterArrow,
356+
arena: self.arena
357+
)
333358

334359
return (RawExprSyntax(op), nil)
335360

Tests/SwiftParserTest/Expressions.swift

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -581,10 +581,17 @@ final class ExpressionTests: XCTestCase {
581581
AssertParse(
582582
"[(Int) -> #^DIAG^#throws Int]()",
583583
diagnostics: [
584-
// FIXME: We should suggest to move 'throws' in front of '->'
585-
DiagnosticSpec(message: "expected expression in array element"),
586-
DiagnosticSpec(message: "unexpected text 'throws Int' in array"),
587-
]
584+
DiagnosticSpec(message: "'throws' may only occur before '->'", fixIts: ["move 'throws' in front of '->'"]),
585+
],
586+
fixedSource: "[(Int) throws -> Int]()"
587+
)
588+
589+
AssertParse(
590+
"[(Int) -> #^DIAG^#async throws Int]()",
591+
diagnostics: [
592+
DiagnosticSpec(message: "'async throws' may only occur before '->'", fixIts: ["move 'async throws' in front of '->'"]),
593+
],
594+
fixedSource: "[(Int) async throws -> Int]()"
588595
)
589596

590597
AssertParse(

Tests/SwiftParserTest/translated/AsyncTests.swift

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,16 +91,20 @@ final class AsyncTests: XCTestCase {
9191
func testAsync8() {
9292
AssertParse(
9393
"""
94-
func asyncGlobal8() async throws#^DIAG_1^# async -> async#^DIAG_2^# Int#^DIAG_3^# async {}
94+
func asyncGlobal8() async throws#^DIAG_1^# async -> #^DIAG_2^#async Int#^DIAG_3^# async {}
9595
""",
9696
diagnostics: [
9797
DiagnosticSpec(locationMarker: "DIAG_1", message: "consecutive statements on a line must be separated by ';'"),
98-
DiagnosticSpec(locationMarker: "DIAG_2", message: "consecutive statements on a line must be separated by ';'"),
98+
DiagnosticSpec(locationMarker: "DIAG_2", message: "'async' may only occur before '->'"),
9999
DiagnosticSpec(locationMarker: "DIAG_3", message: "consecutive statements on a line must be separated by ';'"),
100100
// TODO: Old parser expected error on line 1: 'async' has already been specified, Fix-It replacements: 34 - 40 = ''
101101
// TODO: Old parser expected error on line 1: 'async' has already been specified, Fix-It replacements: 43 - 49 = ''
102102
// TODO: Old parser expected error on line 1: 'async' has already been specified, Fix-It replacements: 53 - 59 = ''
103-
]
103+
],
104+
// TODO: This should not insert another 'async'
105+
fixedSource: """
106+
func asyncGlobal8() async throws; async async -> Int; async {}
107+
"""
104108
)
105109
}
106110

@@ -135,16 +139,29 @@ final class AsyncTests: XCTestCase {
135139
)
136140
}
137141

138-
func testAsync10() {
142+
func testAsync10a() {
139143
AssertParse(
140144
"""
141-
// Parsing function types with 'async'.
142145
typealias AsyncFunc1 = () async -> ()
146+
"""
147+
)
148+
}
149+
150+
func testAsync10b() {
151+
AssertParse(
152+
"""
143153
typealias AsyncFunc2 = () async throws -> ()
154+
"""
155+
)
156+
}
157+
158+
func testAsync10c() {
159+
AssertParse(
160+
"""
144161
typealias AsyncFunc3 = () throws async -> ()
145162
""",
146163
diagnostics: [
147-
// TODO: Old parser expected error on line 4: 'async' must precede 'throws', Fix-It replacements: 34 - 40 = '', 27 - 27 = 'async '
164+
// TODO: Old parser expected error on line 1: 'async' must precede 'throws', Fix-It replacements: 34 - 40 = '', 27 - 27 = 'async '
148165
]
149166
)
150167
}
@@ -156,14 +173,14 @@ final class AsyncTests: XCTestCase {
156173
func testTypeExprs() {
157174
let _ = [() async -> ()]()
158175
let _ = [() async throws -> ()]()
159-
let _ = [() throws #^DIAG^#async -> ()]()
160-
let _ = [() -> async ()]()
176+
let _ = [() throws #^DIAG_1^#async -> ()]()
177+
let _ = [() -> #^DIAG_2^#async ()]()
161178
}
162179
""",
163180
diagnostics: [
164181
// TODO: Old parser expected error on line 5: 'async' must precede 'throws', Fix-It replacements: 22 - 28 = '', 15 - 15 = 'async '
165-
DiagnosticSpec(message: "unexpected text 'async' in array element"),
166-
// TODO: Old parser expected error on line 6: 'async' may only occur before '->', Fix-It replacements: 18 - 24 = '', 15 - 15 = 'async '
182+
DiagnosticSpec(locationMarker: "DIAG_1", message: "unexpected text 'async' in array element"),
183+
DiagnosticSpec(locationMarker: "DIAG_2", message: "'async' may only occur before '->'"),
167184
]
168185
)
169186
}

0 commit comments

Comments
 (0)