Skip to content

Commit 4916151

Browse files
committed
Support formatting of entire documents
Depend on the swift-format library to discover and write the swift-format configuration file. Invoke swift-format from the toolchain to actually format a document. This makes sure that the formatting of SourceKit-LSP and the swift-format executable in the toolchain never get out of sync. Fixes swiftlang#576 rdar://96159694
1 parent 000a566 commit 4916151

10 files changed

+484
-3
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ SourceKit-LSP is still in early development, so you may run into rough edges wit
4646
| Workspace Symbols || |
4747
| Rename || |
4848
| Local Refactoring || |
49-
| Formatting | | |
49+
| Formatting | | Whole file at once only. |
5050
| Folding || |
5151
| Syntax Highlighting || Both syntactic and semantic tokens |
5252
| Document Symbols || |

Sources/SKCore/Toolchain.swift

+11
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ public final class Toolchain {
5151
/// The path to the Swift compiler if available.
5252
public var swiftc: AbsolutePath?
5353

54+
/// The path to the swift-format executable, if available.
55+
public var swiftFormat: AbsolutePath?
56+
5457
/// The path to the clangd language server if available.
5558
public var clangd: AbsolutePath?
5659

@@ -67,6 +70,7 @@ public final class Toolchain {
6770
clang: AbsolutePath? = nil,
6871
swift: AbsolutePath? = nil,
6972
swiftc: AbsolutePath? = nil,
73+
swiftFormat: AbsolutePath? = nil,
7074
clangd: AbsolutePath? = nil,
7175
sourcekitd: AbsolutePath? = nil,
7276
libIndexStore: AbsolutePath? = nil
@@ -77,6 +81,7 @@ public final class Toolchain {
7781
self.clang = clang
7882
self.swift = swift
7983
self.swiftc = swiftc
84+
self.swiftFormat = swiftFormat
8085
self.clangd = clangd
8186
self.sourcekitd = sourcekitd
8287
self.libIndexStore = libIndexStore
@@ -159,6 +164,12 @@ extension Toolchain {
159164
foundAny = true
160165
}
161166

167+
let swiftFormatPath = binPath.appending(component: "swift-format\(execExt)")
168+
if fs.isExecutableFile(swiftFormatPath) {
169+
self.swiftFormat = swiftFormatPath
170+
foundAny = true
171+
}
172+
162173
// If 'currentPlatform' is nil it's most likely an unknown linux flavor.
163174
let dylibExt: String
164175
if let dynamicLibraryExtension = Platform.current?.dynamicLibraryExtension {

Sources/SKSupport/LineTable.swift

+1-2
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,7 @@ public struct LineTable: Hashable {
4343
}
4444

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

5049
// Binary search.

Sources/SourceKitLSP/CMakeLists.txt

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ target_sources(SourceKitLSP PRIVATE
2323
Swift/CursorInfo.swift
2424
Swift/Diagnostic.swift
2525
Swift/DiagnosticReportManager.swift
26+
Swift/DocumentFormatting.swift
2627
Swift/DocumentSymbols.swift
2728
Swift/EditorPlaceholder.swift
2829
Swift/FoldingRange.swift

Sources/SourceKitLSP/Clang/ClangLanguageServer.swift

+4
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,10 @@ extension ClangLanguageServerShim {
582582
return try await forwardRequestToClangd(req)
583583
}
584584

585+
func documentFormatting(_ req: DocumentFormattingRequest) async throws -> [TextEdit]? {
586+
return try await forwardRequestToClangd(req)
587+
}
588+
585589
func codeAction(_ req: CodeActionRequest) async throws -> CodeActionRequestResponse? {
586590
return try await forwardRequestToClangd(req)
587591
}

Sources/SourceKitLSP/SourceKitServer.swift

+11
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,8 @@ extension SourceKitServer: MessageHandler {
892892
await self.handleRequest(for: request, requestHandler: self.symbolInfo)
893893
case let request as RequestAndReply<DocumentHighlightRequest>:
894894
await self.handleRequest(for: request, requestHandler: self.documentSymbolHighlight)
895+
case let request as RequestAndReply<DocumentFormattingRequest>:
896+
await self.handleRequest(for: request, requestHandler: self.documentFormatting)
895897
case let request as RequestAndReply<FoldingRangeRequest>:
896898
await self.handleRequest(for: request, requestHandler: self.foldingRange)
897899
case let request as RequestAndReply<DocumentSymbolRequest>:
@@ -1178,6 +1180,7 @@ extension SourceKitServer {
11781180
supportsCodeActions: true
11791181
)
11801182
),
1183+
documentFormattingProvider: .value(DocumentFormattingOptions(workDoneProgress: false)),
11811184
renameProvider: .value(RenameOptions(prepareProvider: true)),
11821185
colorProvider: .bool(true),
11831186
foldingRangeProvider: .bool(!registry.clientHasDynamicFoldingRangeRegistration),
@@ -1629,6 +1632,14 @@ extension SourceKitServer {
16291632
return try await languageService.documentSemanticTokensRange(req)
16301633
}
16311634

1635+
func documentFormatting(
1636+
_ req: DocumentFormattingRequest,
1637+
workspace: Workspace,
1638+
languageService: ToolchainLanguageServer
1639+
) async throws -> [TextEdit]? {
1640+
return try await languageService.documentFormatting(req)
1641+
}
1642+
16321643
func colorPresentation(
16331644
_ req: ColorPresentationRequest,
16341645
workspace: Workspace,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2020 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 Foundation
14+
import LanguageServerProtocol
15+
16+
import struct TSCBasic.AbsolutePath
17+
import class TSCBasic.Process
18+
import func TSCBasic.withTemporaryFile
19+
20+
fileprivate extension String {
21+
init?(bytes: [UInt8], encoding: Encoding) {
22+
let data = bytes.withUnsafeBytes { buffer in
23+
guard let baseAddress = buffer.baseAddress else {
24+
return Data()
25+
}
26+
return Data(bytes: baseAddress, count: buffer.count)
27+
}
28+
self.init(data: data, encoding: encoding)
29+
}
30+
}
31+
32+
/// If a parent directory of `fileURI` contains a `.swift-format` file, return the path to that file.
33+
/// Otherwise, return `nil`.
34+
private func swiftFormatFile(for fileURI: DocumentURI) -> AbsolutePath? {
35+
guard var path = try? AbsolutePath(validating: fileURI.pseudoPath) else {
36+
return nil
37+
}
38+
repeat {
39+
path = path.parentDirectory
40+
let configFile = path.appending(component: ".swift-format")
41+
if FileManager.default.isReadableFile(atPath: configFile.pathString) {
42+
return configFile
43+
}
44+
} while !path.isRoot
45+
return nil
46+
}
47+
48+
/// Return the contents of a swift-format configuration file that reflects `options`.
49+
private func swiftFormatConfigurationFile(for options: FormattingOptions) throws -> String {
50+
// The following options are not supported by swift-format and ignored:
51+
// - trimTrailingWhitespace: swift-format always trims trailing whitespace
52+
// - insertFinalNewline: swift-format always inserts a final newline to the file
53+
// - trimFinalNewlines: swift-format always trims final newlines
54+
55+
if options.insertSpaces {
56+
return """
57+
{
58+
"version": 1,
59+
"tabWidth": \(options.tabSize),
60+
"indentation": { "spaces": \(options.tabSize) }
61+
}
62+
"""
63+
} else {
64+
return """
65+
{
66+
"version": 1,
67+
"tabWidth": \(options.tabSize),
68+
"indentation": { "tabs": 1 }
69+
}
70+
"""
71+
}
72+
}
73+
74+
/// If a `.swift-format` file is discovered that applies to `fileURI`, execute `body` with the URL of that file.
75+
///
76+
/// Otherwise, create a temporary file with configuration parameters from `options` and invoke `body` with the URL of
77+
/// that file. The temporary file will automatically be deleted after `body` finishes.
78+
private func withSwiftFormatConfiguration<T>(
79+
for fileURI: DocumentURI,
80+
options: FormattingOptions,
81+
body: (AbsolutePath) async throws -> T
82+
) async throws -> T {
83+
if let configFile = swiftFormatFile(for: fileURI) {
84+
// If we find a .swift-format file, we ignore the options passed to us by the editor.
85+
// Most likely, the editor inferred them from the current document and thus the options
86+
// passed by the editor are most likely less correct than those in .swift-format.
87+
return try await body(configFile)
88+
} else {
89+
let formatFileContents = try swiftFormatConfigurationFile(for: options)
90+
91+
return try await withTemporaryFile { (temporaryFile) -> T in
92+
let url = temporaryFile.path.asURL
93+
try formatFileContents.data(using: .utf8)!.write(to: url)
94+
return try await body(temporaryFile.path)
95+
}
96+
}
97+
}
98+
99+
extension CollectionDifference.Change {
100+
var offset: Int {
101+
switch self {
102+
case .insert(offset: let offset, element: _, associatedWith: _):
103+
return offset
104+
case .remove(offset: let offset, element: _, associatedWith: _):
105+
return offset
106+
}
107+
}
108+
}
109+
110+
/// Compute the text edits that need to be made to transform `original` into `edited`.
111+
private func edits(from original: DocumentSnapshot, to edited: String) -> [TextEdit] {
112+
let difference = edited.difference(from: original.text)
113+
114+
// `Collection.difference` returns sequential edits that are expected to be applied on-by-one. Offsets reference
115+
// the string that results if all previous edits are applied.
116+
// LSP expects concurrent edits that are applied simultaneously. Translate between them.
117+
118+
struct StringBasedEdit {
119+
/// Offset into the collection originalString.
120+
/// Ie. to get a string index out of this, run `original(original.startIndex, offsetBy: range.lowerBound)`.
121+
var range: Range<Int>
122+
/// The string the range is being replaced with.
123+
var replacement: String
124+
}
125+
126+
var edits: [StringBasedEdit] = []
127+
for change in difference {
128+
// Adjust the index offset based on changes that `Collection.difference` expects to already have been applied.
129+
var adjustment: Int = 0
130+
for edit in edits {
131+
if edit.range.upperBound < change.offset {
132+
adjustment = adjustment + edit.range.count - edit.replacement.count
133+
}
134+
}
135+
let adjustedOffset = change.offset + adjustment
136+
let edit =
137+
switch change {
138+
case .insert(offset: _, element: let element, associatedWith: _):
139+
StringBasedEdit(range: adjustedOffset..<adjustedOffset, replacement: String(element))
140+
case .remove(offset: _, element: _, associatedWith: _):
141+
StringBasedEdit(range: adjustedOffset..<(adjustedOffset + 1), replacement: "")
142+
}
143+
144+
// If we have an existing edit that is adjacent to this one, merge them.
145+
// Otherwise, just append them.
146+
if let mergableEditIndex = edits.firstIndex(where: {
147+
$0.range.upperBound == edit.range.lowerBound || edit.range.upperBound == $0.range.lowerBound
148+
}) {
149+
let mergableEdit = edits[mergableEditIndex]
150+
if mergableEdit.range.upperBound == edit.range.lowerBound {
151+
edits[mergableEditIndex] = StringBasedEdit(
152+
range: mergableEdit.range.lowerBound..<edit.range.upperBound,
153+
replacement: mergableEdit.replacement + edit.replacement
154+
)
155+
} else {
156+
precondition(edit.range.upperBound == mergableEdit.range.lowerBound)
157+
edits[mergableEditIndex] = StringBasedEdit(
158+
range: edit.range.lowerBound..<mergableEdit.range.upperBound,
159+
replacement: edit.replacement + mergableEdit.replacement
160+
)
161+
}
162+
} else {
163+
edits.append(edit)
164+
}
165+
}
166+
167+
// Map the string-based edits to line-column based edits to be consumed by LSP
168+
169+
return edits.map { edit in
170+
let (startLine, startColumn) = original.lineTable.lineAndUTF16ColumnOf(
171+
original.text.index(original.text.startIndex, offsetBy: edit.range.lowerBound)
172+
)
173+
let (endLine, endColumn) = original.lineTable.lineAndUTF16ColumnOf(
174+
original.text.index(original.text.startIndex, offsetBy: edit.range.upperBound)
175+
)
176+
177+
return TextEdit(
178+
range: Position(line: startLine, utf16index: startColumn)..<Position(line: endLine, utf16index: endColumn),
179+
newText: edit.replacement
180+
)
181+
}
182+
}
183+
184+
extension SwiftLanguageServer {
185+
public func documentFormatting(_ req: DocumentFormattingRequest) async throws -> [TextEdit]? {
186+
let snapshot = try documentManager.latestSnapshot(req.textDocument.uri)
187+
188+
guard let swiftFormat else {
189+
throw ResponseError.unknown("Formatting not supported because toolchain is missing the swift-format executable")
190+
}
191+
192+
let formattedBytes = try await withSwiftFormatConfiguration(for: req.textDocument.uri, options: req.options) {
193+
(formatConfigUrl) -> [UInt8] in
194+
let process = TSCBasic.Process(
195+
args: swiftFormat.pathString,
196+
"format",
197+
"--configuration",
198+
formatConfigUrl.pathString
199+
)
200+
let writeStream = try process.launch()
201+
202+
// Send the file to format to swift-format's stdin. That way we don't have to write it to a file.
203+
writeStream.send(snapshot.text)
204+
try writeStream.close()
205+
206+
let result = try await process.waitUntilExit()
207+
guard result.exitStatus == .terminated(code: 0) else {
208+
let swiftFormatErrorMessage: String
209+
switch result.stderrOutput {
210+
case .success(let stderrBytes):
211+
swiftFormatErrorMessage = String(bytes: stderrBytes, encoding: .utf8) ?? "unknown error"
212+
case .failure(let error):
213+
swiftFormatErrorMessage = String(describing: error)
214+
}
215+
throw ResponseError.unknown(
216+
"""
217+
Running swift-format failed
218+
\(swiftFormatErrorMessage)
219+
"""
220+
)
221+
}
222+
switch result.output {
223+
case .success(let bytes):
224+
return bytes
225+
case .failure(let error):
226+
throw error
227+
}
228+
}
229+
230+
guard let formattedString = String(bytes: formattedBytes, encoding: .utf8) else {
231+
throw ResponseError.unknown("Failed to decode response from swift-format as UTF-8")
232+
}
233+
234+
return edits(from: snapshot, to: formattedString)
235+
}
236+
}

Sources/SourceKitLSP/Swift/SwiftLanguageServer.swift

+6
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import SwiftParser
2121
import SwiftParserDiagnostics
2222
import SwiftSyntax
2323

24+
import struct TSCBasic.AbsolutePath
25+
2426
#if os(Windows)
2527
import WinSDK
2628
#endif
@@ -93,6 +95,9 @@ public actor SwiftLanguageServer: ToolchainLanguageServer {
9395

9496
let sourcekitd: SourceKitD
9597

98+
/// Path to the swift-format executable if it exists in the toolchain.
99+
let swiftFormat: AbsolutePath?
100+
96101
/// Queue on which notifications from sourcekitd are handled to ensure we are
97102
/// handling them in-order.
98103
let sourcekitdNotificationHandlingQueue = AsyncQueue<Serial>()
@@ -152,6 +157,7 @@ public actor SwiftLanguageServer: ToolchainLanguageServer {
152157
) throws {
153158
guard let sourcekitd = toolchain.sourcekitd else { return nil }
154159
self.sourceKitServer = sourceKitServer
160+
self.swiftFormat = toolchain.swiftFormat
155161
self.sourcekitd = try SourceKitDImpl.getOrCreate(dylibPath: sourcekitd)
156162
self.capabilityRegistry = workspace.capabilityRegistry
157163
self.serverOptions = options

Sources/SourceKitLSP/ToolchainLanguageServer.swift

+1
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ public protocol ToolchainLanguageServer: AnyObject {
140140
func codeAction(_ req: CodeActionRequest) async throws -> CodeActionRequestResponse?
141141
func inlayHint(_ req: InlayHintRequest) async throws -> [InlayHint]
142142
func documentDiagnostic(_ req: DocumentDiagnosticsRequest) async throws -> DocumentDiagnosticReport
143+
func documentFormatting(_ req: DocumentFormattingRequest) async throws -> [TextEdit]?
143144

144145
// MARK: - Rename
145146

0 commit comments

Comments
 (0)