Skip to content

Add edits to FixIt for sourcekit-lsp to access #2314

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Nov 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Release Notes/511.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
# Swift Syntax 511 Release Notes

## New APIs
- FixIt now has a new computed propery named edits
- Description: the edits represent the non-overlapping textual edits that need to be performed when the Fix-It is applied.
- Issue: https://github.com/apple/sourcekit-lsp/issues/909
- Pull Request: https://github.com/apple/swift-syntax/pull/2314

- SourceEdit
- Description: SourceEdit has been moved from SwiftRefactor to SwiftSyntax
- Issue: https://github.com/apple/sourcekit-lsp/issues/909
- Pull Request: https://github.com/apple/swift-syntax/pull/2314

## API Behavior Changes

Expand Down
42 changes: 42 additions & 0 deletions Sources/SwiftDiagnostics/FixIt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,45 @@ public struct FixIt {
self.changes = changes
}
}

extension FixIt {
/// The edits represent the non-overlapping textual edits that need to be performed when the Fix-It is applied.
public var edits: [SourceEdit] {
var existingEdits = [SourceEdit]()
for change in changes {
let edit = change.edit
let isOverlapping = existingEdits.contains { edit.range.overlaps($0.range) }
if !isOverlapping {
// The edit overlaps with the previous edit. We can't apply both
// without conflicts. Apply the one that's listed first and drop the
// later edit.
existingEdits.append(edit)
}
}
return existingEdits
}
}

private extension FixIt.Change {
var edit: SourceEdit {
switch self {
case .replace(let oldNode, let newNode):
return SourceEdit(
range: oldNode.position..<oldNode.endPosition,
replacement: newNode.description
)

case .replaceLeadingTrivia(let token, let newTrivia):
return SourceEdit(
range: token.position..<token.positionAfterSkippingLeadingTrivia,
replacement: newTrivia.description
)

case .replaceTrailingTrivia(let token, let newTrivia):
return SourceEdit(
range: token.endPositionBeforeTrailingTrivia..<token.endPosition,
replacement: newTrivia.description
)
}
}
}
61 changes: 0 additions & 61 deletions Sources/SwiftRefactor/RefactoringProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,64 +109,3 @@ extension SyntaxRefactoringProvider {
return [SourceEdit.replace(syntax, with: output.description)]
}
}

/// A textual edit to the original source represented by a range and a
/// replacement.
public struct SourceEdit: Equatable {
/// The half-open range that this edit applies to.
public let range: Range<AbsolutePosition>
/// The text to replace the original range with. Empty for a deletion.
public let replacement: String

/// Length of the original source range that this edit applies to. Zero if
/// this is an addition.
public var length: SourceLength {
return SourceLength(utf8Length: range.lowerBound.utf8Offset - range.upperBound.utf8Offset)
}

/// Create an edit to replace `range` in the original source with
/// `replacement`.
public init(range: Range<AbsolutePosition>, replacement: String) {
self.range = range
self.replacement = replacement
}

/// Convenience function to create a textual addition after the given node
/// and its trivia.
public static func insert(_ newText: String, after node: some SyntaxProtocol) -> SourceEdit {
return SourceEdit(range: node.endPosition..<node.endPosition, replacement: newText)
}

/// Convenience function to create a textual addition before the given node
/// and its trivia.
public static func insert(_ newText: String, before node: some SyntaxProtocol) -> SourceEdit {
return SourceEdit(range: node.position..<node.position, replacement: newText)
}

/// Convenience function to create a textual replacement of the given node,
/// including its trivia.
public static func replace(_ node: some SyntaxProtocol, with replacement: String) -> SourceEdit {
return SourceEdit(range: node.position..<node.endPosition, replacement: replacement)
}

/// Convenience function to create a textual deletion the given node and its
/// trivia.
public static func remove(_ node: some SyntaxProtocol) -> SourceEdit {
return SourceEdit(range: node.position..<node.endPosition, replacement: "")
}
}

extension SourceEdit: CustomDebugStringConvertible {
public var debugDescription: String {
let hasNewline = replacement.contains { $0.isNewline }
if hasNewline {
return #"""
\#(range.lowerBound.utf8Offset)-\#(range.upperBound.utf8Offset)
"""
\#(replacement)
"""
"""#
}
return "\(range.lowerBound.utf8Offset)-\(range.upperBound.utf8Offset) \"\(replacement)\""
}
}
1 change: 1 addition & 0 deletions Sources/SwiftSyntax/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ add_swift_syntax_library(SwiftSyntax
Convenience.swift
MemoryLayout.swift
MissingNodeInitializers.swift
SourceEdit.swift
SourceLength.swift
SourceLocation.swift
SourcePresence.swift
Expand Down
72 changes: 72 additions & 0 deletions Sources/SwiftSyntax/SourceEdit.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

/// A textual edit to the original source represented by a range and a
/// replacement.
public struct SourceEdit: Equatable {
/// The half-open range that this edit applies to.
public let range: Range<AbsolutePosition>
/// The text to replace the original range with. Empty for a deletion.
public let replacement: String

/// Length of the original source range that this edit applies to. Zero if
/// this is an addition.
public var length: SourceLength {
return SourceLength(utf8Length: range.lowerBound.utf8Offset - range.upperBound.utf8Offset)
}

/// Create an edit to replace `range` in the original source with
/// `replacement`.
public init(range: Range<AbsolutePosition>, replacement: String) {
self.range = range
self.replacement = replacement
}

/// Convenience function to create a textual addition after the given node
/// and its trivia.
public static func insert(_ newText: String, after node: some SyntaxProtocol) -> SourceEdit {
return SourceEdit(range: node.endPosition..<node.endPosition, replacement: newText)
}

/// Convenience function to create a textual addition before the given node
/// and its trivia.
public static func insert(_ newText: String, before node: some SyntaxProtocol) -> SourceEdit {
return SourceEdit(range: node.position..<node.position, replacement: newText)
}

/// Convenience function to create a textual replacement of the given node,
/// including its trivia.
public static func replace(_ node: some SyntaxProtocol, with replacement: String) -> SourceEdit {
return SourceEdit(range: node.position..<node.endPosition, replacement: replacement)
}

/// Convenience function to create a textual deletion the given node and its
/// trivia.
public static func remove(_ node: some SyntaxProtocol) -> SourceEdit {
return SourceEdit(range: node.position..<node.endPosition, replacement: "")
}
}

extension SourceEdit: CustomDebugStringConvertible {
public var debugDescription: String {
let hasNewline = replacement.contains { $0.isNewline }
if hasNewline {
return #"""
\#(range.lowerBound.utf8Offset)-\#(range.upperBound.utf8Offset)
"""
\#(replacement)
"""
"""#
}
return "\(range.lowerBound.utf8Offset)-\(range.upperBound.utf8Offset) \"\(replacement)\""
}
}
63 changes: 19 additions & 44 deletions Sources/_SwiftSyntaxTestSupport/FixItApplier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,6 @@ import SwiftDiagnostics
import SwiftSyntax

public enum FixItApplier {
struct Edit: Equatable {
var startUtf8Offset: Int
var endUtf8Offset: Int
let replacement: String

var replacementLength: Int {
return replacement.utf8.count
}

var replacementRange: Range<Int> {
return startUtf8Offset..<endUtf8Offset
}
}

/// Applies selected or all Fix-Its from the provided diagnostics to a given syntax tree.
///
/// - Parameters:
Expand All @@ -44,13 +30,12 @@ public enum FixItApplier {
) -> String {
let messages = messages ?? diagnostics.compactMap { $0.fixIts.first?.message.message }

let changes =
var edits =
diagnostics
.flatMap(\.fixIts)
.filter { messages.contains($0.message.message) }
.flatMap(\.changes)
.flatMap(\.edits)

var edits: [Edit] = changes.map(\.edit)
var source = tree.description

while let edit = edits.first {
Expand All @@ -61,9 +46,7 @@ public enum FixItApplier {

source.replaceSubrange(startIndex..<endIndex, with: edit.replacement)

edits = edits.compactMap { remainingEdit -> FixItApplier.Edit? in
var remainingEdit = remainingEdit

edits = edits.compactMap { remainingEdit -> SourceEdit? in
if remainingEdit.replacementRange.overlaps(edit.replacementRange) {
// The edit overlaps with the previous edit. We can't apply both
// without conflicts. Apply the one that's listed first and drop the
Expand All @@ -74,8 +57,9 @@ public enum FixItApplier {
// If the remaining edit starts after or at the end of the edit that we just applied,
// shift it by the current edit's difference in length.
if edit.endUtf8Offset <= remainingEdit.startUtf8Offset {
remainingEdit.startUtf8Offset = remainingEdit.startUtf8Offset - edit.replacementRange.count + edit.replacementLength
remainingEdit.endUtf8Offset = remainingEdit.endUtf8Offset - edit.replacementRange.count + edit.replacementLength
let startPosition = AbsolutePosition(utf8Offset: remainingEdit.startUtf8Offset - edit.replacementRange.count + edit.replacementLength)
let endPosition = AbsolutePosition(utf8Offset: remainingEdit.endUtf8Offset - edit.replacementRange.count + edit.replacementLength)
return SourceEdit(range: startPosition..<endPosition, replacement: remainingEdit.replacement)
}

return remainingEdit
Expand All @@ -86,29 +70,20 @@ public enum FixItApplier {
}
}

fileprivate extension FixIt.Change {
var edit: FixItApplier.Edit {
switch self {
case .replace(let oldNode, let newNode):
return FixItApplier.Edit(
startUtf8Offset: oldNode.position.utf8Offset,
endUtf8Offset: oldNode.endPosition.utf8Offset,
replacement: newNode.description
)
private extension SourceEdit {
var startUtf8Offset: Int {
return range.lowerBound.utf8Offset
}

var endUtf8Offset: Int {
return range.upperBound.utf8Offset
}

case .replaceLeadingTrivia(let token, let newTrivia):
return FixItApplier.Edit(
startUtf8Offset: token.position.utf8Offset,
endUtf8Offset: token.positionAfterSkippingLeadingTrivia.utf8Offset,
replacement: newTrivia.description
)
var replacementLength: Int {
return replacement.utf8.count
}

case .replaceTrailingTrivia(let token, let newTrivia):
return FixItApplier.Edit(
startUtf8Offset: token.endPositionBeforeTrailingTrivia.utf8Offset,
endUtf8Offset: token.endPosition.utf8Offset,
replacement: newTrivia.description
)
}
var replacementRange: Range<Int> {
return startUtf8Offset..<endUtf8Offset
}
}
42 changes: 42 additions & 0 deletions Tests/SwiftDiagnosticsTest/FixItTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

import SwiftParser
import SwiftParserDiagnostics
import SwiftSyntax
import XCTest
import _SwiftSyntaxTestSupport

final class FixItTests: XCTestCase {
func testEditsForFixIt() throws {
let markedSource = "protocol 1️⃣Multi 2️⃣ident 3️⃣{}"
let (markers, source) = extractMarkers(markedSource)
let positions = markers.mapValues { AbsolutePosition(utf8Offset: $0) }
XCTAssertEqual(positions.count, 3)

let expectedEdits = [
SourceEdit(range: positions["1️⃣"]!..<positions["2️⃣"]!, replacement: "Multiident "),
SourceEdit(range: positions["2️⃣"]!..<positions["3️⃣"]!, replacement: ""),
]
let tree = Parser.parse(source: source)
let diags = ParseDiagnosticsGenerator.diagnostics(for: tree)
XCTAssertEqual(diags.count, 1)
let diag = try XCTUnwrap(diags.first)
XCTAssertEqual(diag.fixIts.count, 2)

let fixIt = try XCTUnwrap(diag.fixIts.first)
let changes = fixIt.changes
let edits = fixIt.edits
XCTAssertNotEqual(changes.count, edits.count)
XCTAssertEqual(expectedEdits, edits)
}
}