Skip to content

Commit

Permalink
Merge pull request #769 from ahoppen/ahoppen/document-formatting
Browse files Browse the repository at this point in the history
Support formatting of entire documents
  • Loading branch information
ahoppen committed Jan 24, 2024
2 parents 441a369 + 79cc4f9 commit a9242f8
Show file tree
Hide file tree
Showing 10 changed files with 472 additions and 3 deletions.
2 changes: 1 addition & 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 || |
| Rename || |
| Local Refactoring || |
| Formatting | | |
| Formatting | | Whole file only |
| Folding || |
| Syntax Highlighting || Both syntactic and semantic tokens |
| Document Symbols || |
Expand Down
11 changes: 11 additions & 0 deletions Sources/SKCore/Toolchain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ public final class Toolchain {
/// The path to the Swift compiler if available.
public var swiftc: AbsolutePath?

/// The path to the swift-format executable, if available.
public var swiftFormat: AbsolutePath?

/// The path to the clangd language server if available.
public var clangd: AbsolutePath?

Expand All @@ -67,6 +70,7 @@ public final class Toolchain {
clang: AbsolutePath? = nil,
swift: AbsolutePath? = nil,
swiftc: AbsolutePath? = nil,
swiftFormat: AbsolutePath? = nil,
clangd: AbsolutePath? = nil,
sourcekitd: AbsolutePath? = nil,
libIndexStore: AbsolutePath? = nil
Expand All @@ -77,6 +81,7 @@ public final class Toolchain {
self.clang = clang
self.swift = swift
self.swiftc = swiftc
self.swiftFormat = swiftFormat
self.clangd = clangd
self.sourcekitd = sourcekitd
self.libIndexStore = libIndexStore
Expand Down Expand Up @@ -159,6 +164,12 @@ extension Toolchain {
foundAny = true
}

let swiftFormatPath = binPath.appending(component: "swift-format\(execExt)")
if fs.isExecutableFile(swiftFormatPath) {
self.swiftFormat = swiftFormatPath
foundAny = true
}

// If 'currentPlatform' is nil it's most likely an unknown linux flavor.
let dylibExt: String
if let dynamicLibraryExtension = Platform.current?.dynamicLibraryExtension {
Expand Down
3 changes: 1 addition & 2 deletions Sources/SKSupport/LineTable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ public struct LineTable: Hashable {
}

/// Translate String.Index to logical line/utf16 pair.
@usableFromInline
func lineAndUTF16ColumnOf(_ index: String.Index, fromLine: Int = 0) -> (line: Int, utf16Column: Int) {
public func lineAndUTF16ColumnOf(_ index: String.Index, fromLine: Int = 0) -> (line: Int, utf16Column: Int) {
precondition(0 <= fromLine && fromLine < count)

// Binary search.
Expand Down
1 change: 1 addition & 0 deletions Sources/SourceKitLSP/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ target_sources(SourceKitLSP PRIVATE
Swift/CursorInfo.swift
Swift/Diagnostic.swift
Swift/DiagnosticReportManager.swift
Swift/DocumentFormatting.swift
Swift/DocumentSymbols.swift
Swift/EditorPlaceholder.swift
Swift/FoldingRange.swift
Expand Down
4 changes: 4 additions & 0 deletions Sources/SourceKitLSP/Clang/ClangLanguageServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,10 @@ extension ClangLanguageServerShim {
return try await forwardRequestToClangd(req)
}

func documentFormatting(_ req: DocumentFormattingRequest) async throws -> [TextEdit]? {
return try await forwardRequestToClangd(req)
}

func codeAction(_ req: CodeActionRequest) async throws -> CodeActionRequestResponse? {
return try await forwardRequestToClangd(req)
}
Expand Down
11 changes: 11 additions & 0 deletions Sources/SourceKitLSP/SourceKitServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,8 @@ extension SourceKitServer: MessageHandler {
await self.handleRequest(for: request, requestHandler: self.symbolInfo)
case let request as RequestAndReply<DocumentHighlightRequest>:
await self.handleRequest(for: request, requestHandler: self.documentSymbolHighlight)
case let request as RequestAndReply<DocumentFormattingRequest>:
await self.handleRequest(for: request, requestHandler: self.documentFormatting)
case let request as RequestAndReply<FoldingRangeRequest>:
await self.handleRequest(for: request, requestHandler: self.foldingRange)
case let request as RequestAndReply<DocumentSymbolRequest>:
Expand Down Expand Up @@ -1178,6 +1180,7 @@ extension SourceKitServer {
supportsCodeActions: true
)
),
documentFormattingProvider: .value(DocumentFormattingOptions(workDoneProgress: false)),
renameProvider: .value(RenameOptions(prepareProvider: true)),
colorProvider: .bool(true),
foldingRangeProvider: .bool(!registry.clientHasDynamicFoldingRangeRegistration),
Expand Down Expand Up @@ -1629,6 +1632,14 @@ extension SourceKitServer {
return try await languageService.documentSemanticTokensRange(req)
}

func documentFormatting(
_ req: DocumentFormattingRequest,
workspace: Workspace,
languageService: ToolchainLanguageServer
) async throws -> [TextEdit]? {
return try await languageService.documentFormatting(req)
}

func colorPresentation(
_ req: ColorPresentationRequest,
workspace: Workspace,
Expand Down
224 changes: 224 additions & 0 deletions Sources/SourceKitLSP/Swift/DocumentFormatting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
//===----------------------------------------------------------------------===//
//
// 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 Foundation
import LanguageServerProtocol

import struct TSCBasic.AbsolutePath
import class TSCBasic.Process
import func TSCBasic.withTemporaryFile

fileprivate extension String {
init?(bytes: [UInt8], encoding: Encoding) {
let data = bytes.withUnsafeBytes { buffer in
guard let baseAddress = buffer.baseAddress else {
return Data()
}
return Data(bytes: baseAddress, count: buffer.count)
}
self.init(data: data, encoding: encoding)
}
}

/// If a parent directory of `fileURI` contains a `.swift-format` file, return the path to that file.
/// Otherwise, return `nil`.
private func swiftFormatFile(for fileURI: DocumentURI) -> AbsolutePath? {
guard var path = try? AbsolutePath(validating: fileURI.pseudoPath) else {
return nil
}
repeat {
path = path.parentDirectory
let configFile = path.appending(component: ".swift-format")
if FileManager.default.isReadableFile(atPath: configFile.pathString) {
return configFile
}
} while !path.isRoot
return nil
}

/// If a `.swift-format` file is discovered that applies to `fileURI`, return the path to that file.
/// Otherwise, return a JSON object containing the configuration parameters from `options`.
///
/// The result of this function can be passed to the `--configuration` parameter of swift-format.
private func swiftFormatConfiguration(
for fileURI: DocumentURI,
options: FormattingOptions
) throws -> String {
if let configFile = swiftFormatFile(for: fileURI) {
// If we find a .swift-format file, we ignore the options passed to us by the editor.
// Most likely, the editor inferred them from the current document and thus the options
// passed by the editor are most likely less correct than those in .swift-format.
return configFile.pathString
}

// The following options are not supported by swift-format and ignored:
// - trimTrailingWhitespace: swift-format always trims trailing whitespace
// - insertFinalNewline: swift-format always inserts a final newline to the file
// - trimFinalNewlines: swift-format always trims final newlines

if options.insertSpaces {
return """
{
"version": 1,
"tabWidth": \(options.tabSize),
"indentation": { "spaces": \(options.tabSize) }
}
"""
} else {
return """
{
"version": 1,
"tabWidth": \(options.tabSize),
"indentation": { "tabs": 1 }
}
"""
}
}

extension CollectionDifference.Change {
var offset: Int {
switch self {
case .insert(offset: let offset, element: _, associatedWith: _):
return offset
case .remove(offset: let offset, element: _, associatedWith: _):
return offset
}
}
}

/// Compute the text edits that need to be made to transform `original` into `edited`.
private func edits(from original: DocumentSnapshot, to edited: String) -> [TextEdit] {
let difference = edited.difference(from: original.text)

// `Collection.difference` returns sequential edits that are expected to be applied on-by-one. Offsets reference
// the string that results if all previous edits are applied.
// LSP expects concurrent edits that are applied simultaneously. Translate between them.

struct StringBasedEdit {
/// Offset into the collection originalString.
/// Ie. to get a string index out of this, run `original(original.startIndex, offsetBy: range.lowerBound)`.
var range: Range<Int>
/// The string the range is being replaced with.
var replacement: String
}

var edits: [StringBasedEdit] = []
for change in difference {
// Adjust the index offset based on changes that `Collection.difference` expects to already have been applied.
var adjustment: Int = 0
for edit in edits {
if edit.range.upperBound < change.offset {
adjustment = adjustment + edit.range.count - edit.replacement.count
}
}
let adjustedOffset = change.offset + adjustment
let edit =
switch change {
case .insert(offset: _, element: let element, associatedWith: _):
StringBasedEdit(range: adjustedOffset..<adjustedOffset, replacement: String(element))
case .remove(offset: _, element: _, associatedWith: _):
StringBasedEdit(range: adjustedOffset..<(adjustedOffset + 1), replacement: "")
}

// If we have an existing edit that is adjacent to this one, merge them.
// Otherwise, just append them.
if let mergableEditIndex = edits.firstIndex(where: {
$0.range.upperBound == edit.range.lowerBound || edit.range.upperBound == $0.range.lowerBound
}) {
let mergableEdit = edits[mergableEditIndex]
if mergableEdit.range.upperBound == edit.range.lowerBound {
edits[mergableEditIndex] = StringBasedEdit(
range: mergableEdit.range.lowerBound..<edit.range.upperBound,
replacement: mergableEdit.replacement + edit.replacement
)
} else {
precondition(edit.range.upperBound == mergableEdit.range.lowerBound)
edits[mergableEditIndex] = StringBasedEdit(
range: edit.range.lowerBound..<mergableEdit.range.upperBound,
replacement: edit.replacement + mergableEdit.replacement
)
}
} else {
edits.append(edit)
}
}

// Map the string-based edits to line-column based edits to be consumed by LSP

return edits.map { edit in
let (startLine, startColumn) = original.lineTable.lineAndUTF16ColumnOf(
original.text.index(original.text.startIndex, offsetBy: edit.range.lowerBound)
)
let (endLine, endColumn) = original.lineTable.lineAndUTF16ColumnOf(
original.text.index(original.text.startIndex, offsetBy: edit.range.upperBound)
)

return TextEdit(
range: Position(line: startLine, utf16index: startColumn)..<Position(line: endLine, utf16index: endColumn),
newText: edit.replacement
)
}
}

extension SwiftLanguageServer {
public func documentFormatting(_ req: DocumentFormattingRequest) async throws -> [TextEdit]? {
let snapshot = try documentManager.latestSnapshot(req.textDocument.uri)

guard let swiftFormat else {
throw ResponseError.unknown(
"Formatting not supported because the toolchain is missing the swift-format executable"
)
}

let process = TSCBasic.Process(
args: swiftFormat.pathString,
"format",
"--configuration",
try swiftFormatConfiguration(for: req.textDocument.uri, options: req.options)
)
let writeStream = try process.launch()

// Send the file to format to swift-format's stdin. That way we don't have to write it to a file.
writeStream.send(snapshot.text)
try writeStream.close()

let result = try await process.waitUntilExit()
guard result.exitStatus == .terminated(code: 0) else {
let swiftFormatErrorMessage: String
switch result.stderrOutput {
case .success(let stderrBytes):
swiftFormatErrorMessage = String(bytes: stderrBytes, encoding: .utf8) ?? "unknown error"
case .failure(let error):
swiftFormatErrorMessage = String(describing: error)
}
throw ResponseError.unknown(
"""
Running swift-format failed
\(swiftFormatErrorMessage)
"""
)
}
let formattedBytes: [UInt8]
switch result.output {
case .success(let bytes):
formattedBytes = bytes
case .failure(let error):
throw error
}

guard let formattedString = String(bytes: formattedBytes, encoding: .utf8) else {
throw ResponseError.unknown("Failed to decode response from swift-format as UTF-8")
}

return edits(from: snapshot, to: formattedString)
}
}
6 changes: 6 additions & 0 deletions Sources/SourceKitLSP/Swift/SwiftLanguageServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import SwiftParser
import SwiftParserDiagnostics
import SwiftSyntax

import struct TSCBasic.AbsolutePath

#if os(Windows)
import WinSDK
#endif
Expand Down Expand Up @@ -93,6 +95,9 @@ public actor SwiftLanguageServer: ToolchainLanguageServer {

let sourcekitd: SourceKitD

/// Path to the swift-format executable if it exists in the toolchain.
let swiftFormat: AbsolutePath?

/// Queue on which notifications from sourcekitd are handled to ensure we are
/// handling them in-order.
let sourcekitdNotificationHandlingQueue = AsyncQueue<Serial>()
Expand Down Expand Up @@ -152,6 +157,7 @@ public actor SwiftLanguageServer: ToolchainLanguageServer {
) throws {
guard let sourcekitd = toolchain.sourcekitd else { return nil }
self.sourceKitServer = sourceKitServer
self.swiftFormat = toolchain.swiftFormat
self.sourcekitd = try SourceKitDImpl.getOrCreate(dylibPath: sourcekitd)
self.capabilityRegistry = workspace.capabilityRegistry
self.serverOptions = options
Expand Down
1 change: 1 addition & 0 deletions Sources/SourceKitLSP/ToolchainLanguageServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ public protocol ToolchainLanguageServer: AnyObject {
func codeAction(_ req: CodeActionRequest) async throws -> CodeActionRequestResponse?
func inlayHint(_ req: InlayHintRequest) async throws -> [InlayHint]
func documentDiagnostic(_ req: DocumentDiagnosticsRequest) async throws -> DocumentDiagnosticReport
func documentFormatting(_ req: DocumentFormattingRequest) async throws -> [TextEdit]?

// MARK: - Rename

Expand Down

0 comments on commit a9242f8

Please sign in to comment.