Skip to content

Commit 5f90433

Browse files
committed
Allow diagnostics to have Fix-Its
1 parent e2cc07e commit 5f90433

File tree

7 files changed

+162
-16
lines changed

7 files changed

+162
-16
lines changed

Sources/SwiftDiagnostics/Diagnostic.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,14 @@ public struct Diagnostic: CustomDebugStringConvertible {
1919
/// The node at whose start location the message should be displayed.
2020
public let node: Syntax
2121

22-
public init(node: Syntax, message: DiagnosticMessage) {
22+
/// Fix-Its that can be applied to resolve this diagnostic.
23+
/// Each Fix-It offers a different way to resolve the diagnostic. Usually, there's only one.
24+
public let fixIts: [FixIt]
25+
26+
public init(node: Syntax, message: DiagnosticMessage, fixIts: [FixIt] = []) {
2327
self.diagMessage = message
2428
self.node = node
29+
self.fixIts = fixIts
2530
}
2631

2732
/// The message that should be displayed to the user.

Sources/SwiftDiagnostics/FixIt.swift

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
//===--- FixIt.swift ------------------------------------------------------===//
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 SwiftSyntax
14+
15+
/// Types conforming to this protocol represent Fix-It messages that can be
16+
/// shown to the client.
17+
/// The messages should describe the change that the Fix-It will perform
18+
public protocol FixItMessage {
19+
/// The diagnostic message that should be displayed in the client.
20+
var message: String { get }
21+
22+
/// See ``MessageID``.
23+
var fixItID: MessageID { get }
24+
}
25+
26+
27+
/// A Fix-It that can be applied to resolve a diagnostic.
28+
public struct FixIt {
29+
public enum Change {
30+
/// Replace `oldNode` by `newNode`.
31+
case replace(oldNode: Syntax, newNode: Syntax)
32+
}
33+
34+
/// A description of what this Fix-It performs.
35+
public let message: FixItMessage
36+
37+
/// The changes that need to be performed when the Fix-It is applied.
38+
public let changes: [Change]
39+
40+
public init(message: FixItMessage, changes: [Change]) {
41+
assert(!changes.isEmpty, "A Fix-It must have at least one diagnostic associated with it")
42+
self.message = message
43+
self.changes = changes
44+
}
45+
}
46+

Sources/SwiftParser/Declarations.swift

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,21 +1155,25 @@ extension Parser {
11551155
arena: self.arena)
11561156
}
11571157

1158+
/// If a `throws` keyword appears right in front of the `arrow`, it is returned as `misplacedThrowsKeyword` so it can be synthesized in front of the arrow.
11581159
@_spi(RawSyntax)
1159-
public mutating func parseFunctionReturnClause() -> RawReturnClauseSyntax {
1160+
public mutating func parseFunctionReturnClause() -> (returnClause: RawReturnClauseSyntax, misplacedThrowsKeyword: RawTokenSyntax?) {
11601161
let arrow = self.eat(.arrow)
1162+
var misplacedThrowsKeyword: RawTokenSyntax? = nil
11611163
let unexpectedBeforeReturnType: RawUnexpectedNodesSyntax?
1162-
if let unexpectedToken = self.consume(ifAny: .tryKeyword, .throwKeyword, .throwsKeyword) {
1163-
unexpectedBeforeReturnType = RawUnexpectedNodesSyntax(elements: [RawSyntax(unexpectedToken)], arena: self.arena)
1164+
if let throwsKeyword = self.consume(ifAny: .rethrowsKeyword, .throwsKeyword) {
1165+
misplacedThrowsKeyword = throwsKeyword
1166+
unexpectedBeforeReturnType = RawUnexpectedNodesSyntax(elements: [RawSyntax(throwsKeyword)], arena: self.arena)
11641167
} else {
11651168
unexpectedBeforeReturnType = nil
11661169
}
11671170
let result = self.parseType()
1168-
return RawReturnClauseSyntax(
1171+
let returnClause = RawReturnClauseSyntax(
11691172
arrow: arrow,
11701173
unexpectedBeforeReturnType,
11711174
returnType: result,
11721175
arena: self.arena)
1176+
return (returnClause, misplacedThrowsKeyword)
11731177
}
11741178
}
11751179

@@ -1227,7 +1231,7 @@ extension Parser {
12271231
async = nil
12281232
}
12291233

1230-
let throwsKeyword: RawTokenSyntax?
1234+
var throwsKeyword: RawTokenSyntax?
12311235
if self.at(.throwsKeyword) || self.at(.rethrowsKeyword) {
12321236
throwsKeyword = self.consumeAnyToken()
12331237
} else {
@@ -1236,7 +1240,11 @@ extension Parser {
12361240

12371241
let output: RawReturnClauseSyntax?
12381242
if self.at(.arrow) {
1239-
output = self.parseFunctionReturnClause()
1243+
let result = self.parseFunctionReturnClause()
1244+
output = result.returnClause
1245+
if let misplacedThrowsKeyword = result.misplacedThrowsKeyword, throwsKeyword == nil {
1246+
throwsKeyword = RawTokenSyntax(missing: misplacedThrowsKeyword.tokenKind, arena: self.arena)
1247+
}
12401248
} else {
12411249
output = nil
12421250
}
@@ -1275,7 +1283,7 @@ extension Parser {
12751283

12761284
let result: RawReturnClauseSyntax
12771285
if self.at(.arrow) {
1278-
result = self.parseFunctionReturnClause()
1286+
result = self.parseFunctionReturnClause().returnClause
12791287
} else {
12801288
result = RawReturnClauseSyntax(
12811289
arrow: RawTokenSyntax(missing: .arrow, arena: self.arena),

Sources/SwiftParser/Diagnostics/ParseDiagnosticsGenerator.swift

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,25 @@ extension UnexpectedNodesSyntax {
2323
}
2424
}
2525

26+
fileprivate extension FixIt.Change {
27+
/// Replaced a present node with a missing node
28+
static func makeMissing(node: TokenSyntax) -> FixIt.Change {
29+
assert(node.presence == .present)
30+
return .replace(
31+
oldNode: Syntax(node),
32+
newNode: Syntax(TokenSyntax(node.tokenKind, leadingTrivia: [], trailingTrivia: [], presence: .missing))
33+
)
34+
}
35+
36+
static func makePresent(node: TokenSyntax, leadingTrivia: Trivia = [], trailingTrivia: Trivia = []) -> FixIt.Change {
37+
assert(node.presence == .missing)
38+
return .replace(
39+
oldNode: Syntax(node),
40+
newNode: Syntax(TokenSyntax(node.tokenKind, leadingTrivia: leadingTrivia, trailingTrivia: trailingTrivia, presence: .present))
41+
)
42+
}
43+
}
44+
2645
public class ParseDiagnosticsGenerator: SyntaxAnyVisitor {
2746
private var diagnostics: [Diagnostic] = []
2847

@@ -43,13 +62,13 @@ public class ParseDiagnosticsGenerator: SyntaxAnyVisitor {
4362
// MARK: - Private helper functions
4463

4564
/// Produce a diagnostic.
46-
private func addDiagnostic<T: SyntaxProtocol>(_ node: T, _ message: DiagnosticMessage) {
47-
diagnostics.append(Diagnostic(node: Syntax(node), message: message))
65+
private func addDiagnostic<T: SyntaxProtocol>(_ node: T, _ message: DiagnosticMessage, fixIts: [FixIt] = []) {
66+
diagnostics.append(Diagnostic(node: Syntax(node), message: message, fixIts: fixIts))
4867
}
4968

5069
/// Produce a diagnostic.
51-
private func addDiagnostic<T: SyntaxProtocol>(_ node: T, _ message: StaticParserError) {
52-
addDiagnostic(node, message as DiagnosticMessage)
70+
private func addDiagnostic<T: SyntaxProtocol>(_ node: T, _ message: StaticParserError, fixIts: [FixIt] = []) {
71+
addDiagnostic(node, message as DiagnosticMessage, fixIts: fixIts)
5372
}
5473

5574
/// If a diagnostic is generated that covers multiple syntax nodes, mark them as handles so they don't produce the generic diagnostics anymore.
@@ -111,11 +130,18 @@ public class ParseDiagnosticsGenerator: SyntaxAnyVisitor {
111130
return .skipChildren
112131
}
113132
if let output = node.output,
133+
let missingThrowsKeyword = node.throwsOrRethrowsKeyword,
134+
missingThrowsKeyword.presence == .missing,
114135
let unexpectedBeforeReturnType = output.unexpectedBetweenArrowAndReturnType,
115136
let throwsInReturnPosition = unexpectedBeforeReturnType.tokens(withKind: .throwsKeyword).first {
116-
addDiagnostic(throwsInReturnPosition, .throwsInReturnPosition)
117-
markNodesAsHandled(unexpectedBeforeReturnType.id, throwsInReturnPosition.id)
118-
return .visitChildren
137+
addDiagnostic(throwsInReturnPosition, .throwsInReturnPosition, fixIts: [
138+
FixIt(message: StaticParserFixIt.moveThrowBeforeArrow, changes: [
139+
.makeMissing(node: throwsInReturnPosition),
140+
.makePresent(node: missingThrowsKeyword, trailingTrivia: .space),
141+
])
142+
])
143+
markNodesAsHandled(unexpectedBeforeReturnType.id, missingThrowsKeyword.id, throwsInReturnPosition.id)
144+
return .visitChildren
119145
}
120146
return .visitChildren
121147
}

Sources/SwiftParser/Diagnostics/ParserDiagnosticMessages.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,20 @@ public extension ParserError {
5757
}
5858
}
5959

60+
public protocol ParserFixIt: FixItMessage {
61+
var fixItID: MessageID { get }
62+
}
63+
64+
public extension ParserFixIt {
65+
static var fixItID: MessageID {
66+
return MessageID(domain: diagnosticDomain, id: "\(self)")
67+
}
68+
69+
var fixItID: MessageID {
70+
return Self.fixItID
71+
}
72+
}
73+
6074
// MARK: - Static diagnostics
6175

6276
/// Please order the cases in this enum alphabetically by case name.
@@ -73,6 +87,16 @@ public enum StaticParserError: String, DiagnosticMessage {
7387
public var severity: DiagnosticSeverity { .error }
7488
}
7589

90+
public enum StaticParserFixIt: String, FixItMessage {
91+
case moveThrowBeforeArrow = "Move 'throws' in before of '->'"
92+
93+
public var message: String { self.rawValue }
94+
95+
public var fixItID: MessageID {
96+
MessageID(domain: diagnosticDomain, id: "\(type(of: self)).\(self)")
97+
}
98+
}
99+
76100
// MARK: - Diagnostics (please sort alphabetically)
77101

78102
public struct MissingTokenError: ParserError {

Tests/SwiftParserTest/DiagnosticAssertions.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,32 @@ import SwiftParser
44
import XCTest
55
import _SwiftSyntaxTestSupport
66

7+
public class FixItApplier: SyntaxRewriter {
8+
var changes: [FixIt.Change]
9+
10+
init(diagnostics: [Diagnostic]) {
11+
self.changes = diagnostics.flatMap({ $0.fixIts }).flatMap({ $0.changes })
12+
}
13+
14+
public override func visitAny(_ node: Syntax) -> Syntax? {
15+
for change in changes {
16+
switch change {
17+
case .replace(oldNode: let oldNode, newNode: let newNode) where oldNode.id == node.id:
18+
return newNode
19+
default:
20+
break
21+
}
22+
}
23+
return nil
24+
}
25+
26+
/// Applies all Fix-Its in `diagnostics` to `tree` and returns the fixed syntax tree.
27+
public static func applyFixes<T: SyntaxProtocol>(in diagnostics: [Diagnostic], to tree: T) -> Syntax {
28+
let applier = FixItApplier(diagnostics: diagnostics)
29+
return applier.visit(Syntax(tree))
30+
}
31+
}
32+
733
/// Asserts that the diagnostics `diag` inside `tree` occurs at `line` and
834
/// `column`.
935
/// If `message` is not `nil`, assert that the diagnostic has the given message.
@@ -34,12 +60,14 @@ func XCTAssertDiagnostic<T: SyntaxProtocol>(
3460
/// at `line` and `column`.
3561
/// If `message` is not `nil`, assert that the diagnostic has the given message.
3662
/// If `id` is not `nil`, assert that the diagnostic has the given message.
63+
/// If `expectedFixedSource` is not `nil`, assert that the source code that it is source code that result from applying all Fix-Its.
3764
func XCTAssertSingleDiagnostic<T: SyntaxProtocol>(
3865
in tree: T,
3966
line: Int,
4067
column: Int,
4168
id: MessageID? = nil,
4269
message: String? = nil,
70+
expectedFixedSource: String? = nil,
4371
testFile: StaticString = #filePath,
4472
testLine: UInt = #line
4573
) {
@@ -49,4 +77,8 @@ func XCTAssertSingleDiagnostic<T: SyntaxProtocol>(
4977
return
5078
}
5179
XCTAssertDiagnostic(diags.first!, in: tree, line: line, column: column, id: id, message: message, testFile: testFile, testLine: testLine)
80+
if let expectedFixedSource = expectedFixedSource {
81+
let fixedSource = FixItApplier.applyFixes(in: diags, to: tree).description
82+
AssertStringsEqualWithDiff(fixedSource, expectedFixedSource, file: testFile, line: testLine)
83+
}
5284
}

Tests/SwiftParserTest/DiagnosticTests.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ public class DiagnosticTests: XCTestCase {
6565
Syntax(raw: $0.parseFunctionSignature().raw).as(FunctionSignatureSyntax.self)!
6666
}
6767

68-
XCTAssertSingleDiagnostic(in: signature, line: 1, column: 7, message: "'throws' may only occur before '->'")
68+
XCTAssertSingleDiagnostic(
69+
in: signature,
70+
line: 1, column: 7,
71+
message: "'throws' may only occur before '->'",
72+
expectedFixedSource: "() throws -> Int"
73+
)
6974
}
7075
}

0 commit comments

Comments
 (0)