Skip to content

Implement document formatting using swift-format #220

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

Closed
Closed
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
3 changes: 3 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ let package = Package(
"SourceKitD",
"SKSwiftPMWorkspace",
"SwiftToolsSupport-auto",
"SwiftFormat",
]
),

Expand Down Expand Up @@ -231,11 +232,13 @@ if getenv("SWIFTCI_USE_LOCAL_DEPS") == nil {
.package(url: "https://github.com/apple/indexstore-db.git", .branch("master")),
.package(url: "https://github.com/apple/swift-package-manager.git", .branch("master")),
.package(url: "https://github.com/apple/swift-tools-support-core.git", .branch("master")),
.package(url: "https://github.com/apple/swift-format.git", .branch("master")),
]
} else {
package.dependencies += [
.package(path: "../indexstore-db"),
.package(path: "../swiftpm"),
.package(path: "../swiftpm/swift-tools-support-core"),
.package(path: "../swift-format"),
]
}
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ SourceKit-LSP is still in early development, so you may run into rough edges wit
| Workspace Symbols | ✅ | |
| Global Rename | ❌ | |
| Local Refactoring | ✅ | |
| Formatting | ❌ | |
| Formatting | ✅ | Whole file at once only. |
| Folding | ✅ | |
| Syntax Highlighting | ❌ | Not currently part of LSP. |
| Document Symbols | ✅ | |
Expand All @@ -59,3 +59,5 @@ SourceKit-LSP is still in early development, so you may run into rough edges wit

* SourceKit-LSP does not update its global index in the background, but instead relies on indexing-while-building to provide data. This only affects global queries like find-references and jump-to-definition.
* **Workaround**: build the project to update the index

* Formatting uses swift-format, which requires a specific toolchain version. You can learn more at the [swift-format readme.](https://github.com/apple/swift-format#matching-swift-format-to-your-swift-version)
10 changes: 10 additions & 0 deletions Sources/LanguageServerProtocol/Requests/FormattingRequests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ public struct DocumentFormattingRequest: TextDocumentRequest, Hashable {

/// Options to customize the formatting.
public var options: FormattingOptions

public init(textDocument: TextDocumentIdentifier, options: FormattingOptions) {
self.textDocument = textDocument
self.options = options
}
}

/// Request to format a specified range within a document.
Expand Down Expand Up @@ -97,4 +102,9 @@ public struct FormattingOptions: Codable, Hashable {

/// Whether to use spaces instead of tabs.
public var insertSpaces: Bool

public init(tabSize: Int, insertSpaces: Bool) {
self.tabSize = tabSize
self.insertSpaces = insertSpaces
}
}
4 changes: 4 additions & 0 deletions Sources/SourceKitLSP/Clang/ClangLanguageServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,10 @@ extension ClangLanguageServerShim {
forwardRequest(req, to: clangd)
}

func documentFormatting(_ req: Request<DocumentFormattingRequest>) {
forwardRequest(req, to: clangd)
}

func documentColor(_ req: Request<DocumentColorRequest>) {
if capabilities?.colorProvider?.isSupported == true {
forwardRequest(req, to: clangd)
Expand Down
10 changes: 10 additions & 0 deletions Sources/SourceKitLSP/SourceKitServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ public final class SourceKitServer: LanguageServer {
registerToolchainTextDocumentRequest(SourceKitServer.documentSymbolHighlight, nil)
registerToolchainTextDocumentRequest(SourceKitServer.foldingRange, nil)
registerToolchainTextDocumentRequest(SourceKitServer.documentSymbol, nil)
registerToolchainTextDocumentRequest(SourceKitServer.documentFormatting, nil)
registerToolchainTextDocumentRequest(SourceKitServer.documentColor, [])
registerToolchainTextDocumentRequest(SourceKitServer.colorPresentation, [])
registerToolchainTextDocumentRequest(SourceKitServer.codeAction, nil)
Expand Down Expand Up @@ -475,6 +476,7 @@ extension SourceKitServer {
codeActionOptions: CodeActionOptions(codeActionKinds: nil),
supportsCodeActions: true
)),
documentFormattingProvider: true,
colorProvider: .bool(true),
foldingRangeProvider: .bool(true),
executeCommandProvider: ExecuteCommandOptions(
Expand Down Expand Up @@ -717,6 +719,14 @@ extension SourceKitServer {
languageService.documentSymbol(req)
}

func documentFormatting(
_ req: Request<DocumentFormattingRequest>,
workspace: Workspace,
languageService: ToolchainLanguageServer
) {
languageService.documentFormatting(req)
}

func documentColor(
_ req: Request<DocumentColorRequest>,
workspace: Workspace,
Expand Down
45 changes: 45 additions & 0 deletions Sources/SourceKitLSP/Swift/SwiftLanguageServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import SKCore
import SKSupport
import SourceKitD
import TSCBasic
import SwiftFormat
import SwiftFormatConfiguration

fileprivate extension Range {
/// Checks if this range overlaps with the other range, counting an overlap with an empty range as a valid overlap.
Expand Down Expand Up @@ -209,6 +211,7 @@ extension SwiftLanguageServer {
clientCapabilities: initialize.capabilities.textDocument?.codeAction,
codeActionOptions: CodeActionOptions(codeActionKinds: [.quickFix, .refactor]),
supportsCodeActions: true)),
documentFormattingProvider: true,
colorProvider: .bool(true),
foldingRangeProvider: .bool(true),
executeCommandProvider: ExecuteCommandOptions(
Expand Down Expand Up @@ -742,6 +745,48 @@ extension SwiftLanguageServer {
}
}

public func documentFormatting(_ req: Request<DocumentFormattingRequest>) {
self.queue.async {
guard let file = req.params.textDocument.uri.fileURL else {
req.reply(nil)
return
}
guard let snapshot = self.documentManager.latestSnapshot(req.params.textDocument.uri) else {
log("failed to find snapshot for url \(req.params.textDocument.uri)")
req.reply(nil)
return
}
let configuration: SwiftFormatConfiguration.Configuration
// try to load swift-format configuration from a ".swift-format" file
// if it fails, use values provided by the lsp
if let configUrl = Configuration.url(forConfigurationFileApplyingTo: file),
let config = try? Configuration(contentsOf: configUrl) {
configuration = config
} else {
var config = SwiftFormatConfiguration.Configuration()
let options = req.params.options
config.indentation = options.insertSpaces ? .spaces(options.tabSize) : .tabs(1)
config.tabWidth = options.tabSize
configuration = config
}

let formatter = SwiftFormat.SwiftFormatter(configuration: configuration)
do {
guard let lastLine = snapshot.lineTable.last else {
req.reply(nil)
return
}
let lastPosition = Position(line: snapshot.lineTable.count-1, utf16index: lastLine.utf16.count)
var edit = TextEdit(range: Position(line: 0, utf16index: 0)..<lastPosition, newText: "")
try formatter.format(source: snapshot.text, assumingFileURL: file, to: &edit.newText)
req.reply([edit])
} catch {
log("failed to format document: \(error)", level: .error)
req.reply(nil)
}
}
}

public func documentColor(_ req: Request<DocumentColorRequest>) {
let keys = self.keys

Expand Down
1 change: 1 addition & 0 deletions Sources/SourceKitLSP/ToolchainLanguageServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public protocol ToolchainLanguageServer: AnyObject {
func documentSymbolHighlight(_ req: Request<DocumentHighlightRequest>)
func foldingRange(_ req: Request<FoldingRangeRequest>)
func documentSymbol(_ req: Request<DocumentSymbolRequest>)
func documentFormatting(_ req: Request<DocumentFormattingRequest>)
func documentColor(_ req: Request<DocumentColorRequest>)
func colorPresentation(_ req: Request<ColorPresentationRequest>)
func codeAction(_ req: Request<CodeActionRequest>)
Expand Down
4 changes: 4 additions & 0 deletions Tests/INPUTS/Formatting/.swift-format
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
This file is intentionally an invalid swift-format configuration file. It is used to test
the behavior when program cannot find a valid file. We cannot just left this directory blank,
because swift-format searches for the configuration in parent directories, and it's possible
that user has another configuration file somewhere outside the Tests.
6 changes: 6 additions & 0 deletions Tests/INPUTS/Formatting/Directory/.swift-format
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"version": 1,
"indentation": {
"spaces": 1
},
}
4 changes: 4 additions & 0 deletions Tests/INPUTS/Formatting/Directory/Directory.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/*Directory*/
struct Directory {
var bar = 123
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"version": 1,
"indentation": {
"spaces": 4
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/*NestedWithConfig*/
struct NestedWithConfig {
var bar = 123
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/*NestedWithoutConfig*/
struct NestedWithoutConfig {
var bar = 123
}
4 changes: 4 additions & 0 deletions Tests/INPUTS/Formatting/Root.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/*Root*/
struct Root {
var bar = 123
}
8 changes: 8 additions & 0 deletions Tests/INPUTS/Formatting/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"sources": [
"Root.swift",
"Directory/Directory.swift",
"Directory/NestedWithConfig/NestedWithConfig.swift",
"Directory/NestedWithoutConfig/NestedWithoutConfig.swift"
]
}
144 changes: 144 additions & 0 deletions Tests/SourceKitLSPTests/FormattingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2020 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 LanguageServerProtocol
import SKTestSupport
import XCTest
import ISDBTestSupport


// Note that none of the indentation values choosen are equal to the default
// indentation level, which is two spaces.
final class FormattingTests: XCTestCase {
var workspace: SKTibsTestWorkspace! = nil

func initialize() throws {
workspace = try XCTUnwrap(staticSourceKitTibsWorkspace(name: "Formatting"))
try workspace.buildAndIndex()
try workspace.openDocument(workspace.testLoc("Root").url, language: .swift)
try workspace.openDocument(workspace.testLoc("Directory").url, language: .swift)
try workspace.openDocument(workspace.testLoc("NestedWithConfig").url, language: .swift)
try workspace.openDocument(workspace.testLoc("NestedWithoutConfig").url, language: .swift)
}

override func tearDown() {
workspace = nil
}

func performFormattingRequest(file url: URL, options: FormattingOptions) throws -> [TextEdit]? {
let request = DocumentFormattingRequest(
textDocument: TextDocumentIdentifier(url),
options: options
)
return try workspace.sk.sendSync(request)
}

func testSpaces() throws {
try initialize()
let url = workspace.testLoc("Root").url
let options = FormattingOptions(tabSize: 3, insertSpaces: true)
let edits = try XCTUnwrap(performFormattingRequest(file: url, options: options))
XCTAssertEqual(edits.count, 1)
let firstEdit = try XCTUnwrap(edits.first)
XCTAssertEqual(firstEdit.range.lowerBound, Position(line: 0, utf16index: 0))
XCTAssertEqual(firstEdit.range.upperBound, Position(line: 3, utf16index: 1))
// var bar needs to be indented with three spaces
// which is the value from lsp
XCTAssertEqual(firstEdit.newText, """
/*Root*/
struct Root {
var bar = 123
}

""")
}

func testTabs() throws {
try initialize()
let url = workspace.testLoc("Root").url
let options = FormattingOptions(tabSize: 3, insertSpaces: false)
let edits = try XCTUnwrap(performFormattingRequest(file: url, options: options))
XCTAssertEqual(edits.count, 1)
let firstEdit = try XCTUnwrap(edits.first)
XCTAssertEqual(firstEdit.range.lowerBound, Position(line: 0, utf16index: 0))
XCTAssertEqual(firstEdit.range.upperBound, Position(line: 3, utf16index: 1))
// var bar needs to be indented with a tab
// which is the value from lsp
XCTAssertEqual(firstEdit.newText, """
/*Root*/
struct Root {
\tvar bar = 123
}

""")
}

func testConfigFile() throws {
try initialize()
let url = workspace.testLoc("Directory").url
let options = FormattingOptions(tabSize: 3, insertSpaces: true)
let edits = try XCTUnwrap(performFormattingRequest(file: url, options: options))
XCTAssertEqual(edits.count, 1)
let firstEdit = try XCTUnwrap(edits.first)
XCTAssertEqual(firstEdit.range.lowerBound, Position(line: 0, utf16index: 0))
XCTAssertEqual(firstEdit.range.upperBound, Position(line: 3, utf16index: 1))
// var bar needs to be indented with one space
// which is the value from ".swift-format" in "Directory"
XCTAssertEqual(firstEdit.newText, """
/*Directory*/
struct Directory {
var bar = 123
}

""")
}

func testConfigFileInParentDirectory() throws {
try initialize()
let url = workspace.testLoc("NestedWithoutConfig").url
let options = FormattingOptions(tabSize: 3, insertSpaces: true)
let edits = try XCTUnwrap(performFormattingRequest(file: url, options: options))
XCTAssertEqual(edits.count, 1)
let firstEdit = try XCTUnwrap(edits.first)
XCTAssertEqual(firstEdit.range.lowerBound, Position(line: 0, utf16index: 0))
XCTAssertEqual(firstEdit.range.upperBound, Position(line: 3, utf16index: 1))
// var bar needs to be indented with one space
// which is the value from ".swift-format" in "Directory"
XCTAssertEqual(firstEdit.newText, """
/*NestedWithoutConfig*/
struct NestedWithoutConfig {
var bar = 123
}

""")
}

func testConfigFileInNestedDirectory() throws {
try initialize()
let url = workspace.testLoc("NestedWithConfig").url
let options = FormattingOptions(tabSize: 3, insertSpaces: true)
let edits = try XCTUnwrap(performFormattingRequest(file: url, options: options))
XCTAssertEqual(edits.count, 1)
let firstEdit = try XCTUnwrap(edits.first)
XCTAssertEqual(firstEdit.range.lowerBound, Position(line: 0, utf16index: 0))
XCTAssertEqual(firstEdit.range.upperBound, Position(line: 3, utf16index: 1))
// var bar needs to be indented with four spaces
// which is the value from ".swift-format" in "NestedWithConfig"
XCTAssertEqual(firstEdit.newText, """
/*NestedWithConfig*/
struct NestedWithConfig {
var bar = 123
}

""")
}
}
14 changes: 14 additions & 0 deletions Tests/SourceKitLSPTests/XCTestManifests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ extension FoldingRangeTests {
]
}

extension FormattingTests {
// DO NOT MODIFY: This is autogenerated, use:
// `swift test --generate-linuxmain`
// to regenerate.
static let __allTests__FormattingTests = [
("testConfigFile", testConfigFile),
("testConfigFileInNestedDirectory", testConfigFileInNestedDirectory),
("testConfigFileInParentDirectory", testConfigFileInParentDirectory),
("testSpaces", testSpaces),
("testTabs", testTabs),
]
}

extension ImplementationTests {
// DO NOT MODIFY: This is autogenerated, use:
// `swift test --generate-linuxmain`
Expand Down Expand Up @@ -197,6 +210,7 @@ public func __allTests() -> [XCTestCaseEntry] {
testCase(DocumentSymbolTest.__allTests__DocumentSymbolTest),
testCase(ExecuteCommandTests.__allTests__ExecuteCommandTests),
testCase(FoldingRangeTests.__allTests__FoldingRangeTests),
testCase(FormattingTests.__allTests__FormattingTests),
testCase(ImplementationTests.__allTests__ImplementationTests),
testCase(LocalClangTests.__allTests__LocalClangTests),
testCase(LocalSwiftTests.__allTests__LocalSwiftTests),
Expand Down