Skip to content

Commit 66b8400

Browse files
committed
Handle on-type formatting requests
1 parent 8ba80a0 commit 66b8400

File tree

8 files changed

+192
-10
lines changed

8 files changed

+192
-10
lines changed

Documentation/Configuration File.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,5 @@ The structure of the file is currently not guaranteed to be stable. Options may
5151
- `noLazy`: Prepare a target without generating object files but do not do lazy type checking and function body skipping
5252
- `enabled`: Prepare a target without generating object files and the like
5353
- `cancelTextDocumentRequestsOnEditAndClose: bool`: Whether sending a `textDocument/didChange` or `textDocument/didClose` notification for a document should cancel all pending requests for that document.
54-
- `experimentalFeatures: string[]`: Experimental features to enable
54+
- `experimentalFeatures: string[]`: Experimental features to enable. Available features: on-type-formatting
5555
- `swiftPublishDiagnosticsDebounceDuration: double`: The time that `SwiftLanguageService` should wait after an edit before starting to compute diagnostics and sending a `PublishDiagnosticsNotification`.

Sources/SKOptions/ExperimentalFeatures.swift

+4-4
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
//
1111
//===----------------------------------------------------------------------===//
1212

13-
/// An experimental feature that can be enabled by passing `--experimental-feature` to `sourcekit-lsp` on the command
14-
/// line. The raw value of this feature is how it is named on the command line.
13+
/// An experimental feature that can be enabled by passing `--experimental-feature`
14+
/// to `sourcekit-lsp` on the command line or through the configuration file.
15+
/// The raw value of this feature is how it is named on the command line.
1516
public enum ExperimentalFeature: String, Codable, Sendable, CaseIterable {
16-
/* This is here to silence the errors when the enum doesn't have any cases */
17-
case exampleCase = "example-case"
17+
case onTypeFormatting = "on-type-formatting"
1818
}

Sources/SourceKitLSP/Clang/ClangLanguageService.swift

+4
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,10 @@ extension ClangLanguageService {
584584
return try await forwardRequestToClangd(req)
585585
}
586586

587+
func documentOnTypeFormatting(_ req: DocumentOnTypeFormattingRequest) async throws -> [TextEdit]? {
588+
return try await forwardRequestToClangd(req)
589+
}
590+
587591
func codeAction(_ req: CodeActionRequest) async throws -> CodeActionRequestResponse? {
588592
return try await forwardRequestToClangd(req)
589593
}

Sources/SourceKitLSP/LanguageService.swift

+1
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ package protocol LanguageService: AnyObject, Sendable {
210210
func documentDiagnostic(_ req: DocumentDiagnosticsRequest) async throws -> DocumentDiagnosticReport
211211
func documentFormatting(_ req: DocumentFormattingRequest) async throws -> [TextEdit]?
212212
func documentRangeFormatting(_ req: DocumentRangeFormattingRequest) async throws -> [TextEdit]?
213+
func documentOnTypeFormatting(_ req: DocumentOnTypeFormattingRequest) async throws -> [TextEdit]?
213214

214215
// MARK: - Rename
215216

Sources/SourceKitLSP/SourceKitLSPServer.swift

+20-2
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,8 @@ extension SourceKitLSPServer: QueueBasedMessageHandler {
722722
await self.handleRequest(for: request, requestHandler: self.documentFormatting)
723723
case let request as RequestAndReply<DocumentRangeFormattingRequest>:
724724
await self.handleRequest(for: request, requestHandler: self.documentRangeFormatting)
725+
case let request as RequestAndReply<DocumentOnTypeFormattingRequest>:
726+
await self.handleRequest(for: request, requestHandler: self.documentOnTypeFormatting)
725727
case let request as RequestAndReply<DocumentHighlightRequest>:
726728
await self.handleRequest(for: request, requestHandler: self.documentSymbolHighlight)
727729
case let request as RequestAndReply<DocumentSemanticTokensDeltaRequest>:
@@ -973,7 +975,8 @@ extension SourceKitLSPServer {
973975
let result = InitializeResult(
974976
capabilities: await self.serverCapabilities(
975977
for: req.capabilities,
976-
registry: self.capabilityRegistry!
978+
registry: self.capabilityRegistry!,
979+
options: options
977980
)
978981
)
979982
logger.logFullObjectInMultipleLogMessages(header: "Initialize response", AnyRequestType(request: req))
@@ -982,7 +985,8 @@ extension SourceKitLSPServer {
982985

983986
func serverCapabilities(
984987
for client: ClientCapabilities,
985-
registry: CapabilityRegistry
988+
registry: CapabilityRegistry,
989+
options: SourceKitLSPOptions
986990
) async -> ServerCapabilities {
987991
let completionOptions =
988992
await registry.clientHasDynamicCompletionRegistration
@@ -992,6 +996,11 @@ extension SourceKitLSPServer {
992996
triggerCharacters: [".", "("]
993997
)
994998

999+
let onTypeFormattingOptions =
1000+
options.hasExperimentalFeature(.onTypeFormatting)
1001+
? DocumentOnTypeFormattingOptions(triggerCharacters: ["\n", "\r\n", "\r", "{", "}", ";", ".", ":", "#"])
1002+
: nil
1003+
9951004
let foldingRangeOptions =
9961005
await registry.clientHasDynamicFoldingRangeRegistration
9971006
? nil
@@ -1041,6 +1050,7 @@ extension SourceKitLSPServer {
10411050
codeLensProvider: CodeLensOptions(),
10421051
documentFormattingProvider: .value(DocumentFormattingOptions(workDoneProgress: false)),
10431052
documentRangeFormattingProvider: .value(DocumentRangeFormattingOptions(workDoneProgress: false)),
1053+
documentOnTypeFormattingProvider: onTypeFormattingOptions,
10441054
renameProvider: .value(RenameOptions(prepareProvider: true)),
10451055
colorProvider: .bool(true),
10461056
foldingRangeProvider: foldingRangeOptions,
@@ -1542,6 +1552,14 @@ extension SourceKitLSPServer {
15421552
return try await languageService.documentRangeFormatting(req)
15431553
}
15441554

1555+
func documentOnTypeFormatting(
1556+
_ req: DocumentOnTypeFormattingRequest,
1557+
workspace: Workspace,
1558+
languageService: LanguageService
1559+
) async throws -> [TextEdit]? {
1560+
return try await languageService.documentOnTypeFormatting(req)
1561+
}
1562+
15451563
func colorPresentation(
15461564
_ req: ColorPresentationRequest,
15471565
workspace: Workspace,

Sources/SourceKitLSP/Swift/DocumentFormatting.swift

+25-3
Original file line numberDiff line numberDiff line change
@@ -139,26 +139,44 @@ private func edits(from original: DocumentSnapshot, to edited: String) -> [TextE
139139
extension SwiftLanguageService {
140140
package func documentFormatting(_ req: DocumentFormattingRequest) async throws -> [TextEdit]? {
141141
return try await format(
142+
snapshot: documentManager.latestSnapshot(req.textDocument.uri),
142143
textDocument: req.textDocument,
143144
options: req.options
144145
)
145146
}
146147

147148
package func documentRangeFormatting(_ req: DocumentRangeFormattingRequest) async throws -> [TextEdit]? {
148149
return try await format(
150+
snapshot: documentManager.latestSnapshot(req.textDocument.uri),
149151
textDocument: req.textDocument,
150152
options: req.options,
151153
range: req.range
152154
)
153155
}
154156

157+
package func documentOnTypeFormatting(_ req: DocumentOnTypeFormattingRequest) async throws -> [TextEdit]? {
158+
let snapshot = try documentManager.latestSnapshot(req.textDocument.uri)
159+
guard let line = snapshot.lineTable.line(at: req.position.line) else {
160+
return nil
161+
}
162+
163+
let lineStartPosition = snapshot.position(of: line.startIndex, fromLine: req.position.line)
164+
let lineEndPosition = snapshot.position(of: line.endIndex, fromLine: req.position.line)
165+
166+
return try await format(
167+
snapshot: snapshot,
168+
textDocument: req.textDocument,
169+
options: req.options,
170+
range: lineStartPosition..<lineEndPosition
171+
)
172+
}
173+
155174
private func format(
175+
snapshot: DocumentSnapshot,
156176
textDocument: TextDocumentIdentifier,
157177
options: FormattingOptions,
158178
range: Range<Position>? = nil
159179
) async throws -> [TextEdit]? {
160-
let snapshot = try documentManager.latestSnapshot(textDocument.uri)
161-
162180
guard let swiftFormat else {
163181
throw ResponseError.unknown(
164182
"Formatting not supported because the toolchain is missing the swift-format executable"
@@ -172,9 +190,13 @@ extension SwiftLanguageService {
172190
swiftFormatConfiguration(for: textDocument.uri, options: options),
173191
]
174192
if let range {
193+
let utf8Range = snapshot.utf8OffsetRange(of: range)
194+
// swift-format takes an inclusive range, but Swift's `Range.upperBound` is exclusive.
195+
// Also make sure `upperBound` does not go less than `lowerBound`.
196+
let utf8UpperBound = max(utf8Range.lowerBound, utf8Range.upperBound - 1)
175197
args += [
176198
"--offsets",
177-
"\(snapshot.utf8Offset(of: range.lowerBound)):\(snapshot.utf8Offset(of: range.upperBound))",
199+
"\(utf8Range.lowerBound):\(utf8UpperBound)",
178200
]
179201
}
180202
let process = TSCBasic.Process(arguments: args)

Sources/SourceKitLSP/Swift/SwiftLanguageService.swift

+10
Original file line numberDiff line numberDiff line change
@@ -1228,6 +1228,16 @@ extension DocumentSnapshot {
12281228
return Position(line: zeroBasedLine, utf16index: utf16Column)
12291229
}
12301230

1231+
/// Converts the given `String.Index` to a UTF-16-based line:column position.
1232+
///
1233+
/// If the index does not refer to a valid position within the snapshot, returns the closest valid
1234+
/// position and logs a fault containing the file and line of the caller (from `callerFile` and
1235+
/// `callerLine`).
1236+
func position(of index: String.Index, fromLine: Int = 0) -> Position {
1237+
let (line, utf16Column) = lineTable.lineAndUTF16ColumnOf(index, fromLine: fromLine)
1238+
return Position(line: line, utf16index: utf16Column)
1239+
}
1240+
12311241
// MARK: Position <-> AbsolutePosition
12321242

12331243
/// Converts the given UTF-8-offset-based `AbsolutePosition` to a UTF-16-based line:column.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2023 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 LanguageServerProtocol
14+
import SKLogging
15+
import SKTestSupport
16+
import SourceKitLSP
17+
import XCTest
18+
19+
final class OnTypeFormattingTests: XCTestCase {
20+
func testOnlyFormatsSpecifiedLine() async throws {
21+
try await SkipUnless.toolchainContainsSwiftFormat()
22+
let testClient = try await TestSourceKitLSPClient()
23+
let uri = DocumentURI(for: .swift)
24+
25+
let positions = testClient.openDocument(
26+
"""
27+
func foo() {
28+
if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() {
29+
1️⃣// do stuff
30+
}
31+
}
32+
""",
33+
uri: uri
34+
)
35+
36+
let response = try await testClient.send(
37+
DocumentOnTypeFormattingRequest(
38+
textDocument: TextDocumentIdentifier(uri),
39+
position: positions["1️⃣"],
40+
ch: "\n",
41+
options: FormattingOptions(tabSize: 2, insertSpaces: true)
42+
)
43+
)
44+
45+
let edits = try XCTUnwrap(response)
46+
XCTAssertEqual(
47+
edits,
48+
[
49+
TextEdit(range: Range(positions["1️⃣"]), newText: " ")
50+
]
51+
)
52+
}
53+
54+
func testFormatsFullLineAndDoesNotFormatNextLine() async throws {
55+
try await SkipUnless.toolchainContainsSwiftFormat()
56+
let testClient = try await TestSourceKitLSPClient()
57+
let uri = DocumentURI(for: .swift)
58+
59+
let positions = testClient.openDocument(
60+
"""
61+
func foo() {
62+
1️⃣if let SomeReallyLongVar = 2️⃣ 3️⃣Some.More.Stuff(), let a = 4️⃣ 5️⃣myfunc() 6️⃣{
63+
}
64+
}
65+
""",
66+
uri: uri
67+
)
68+
69+
let response = try await testClient.send(
70+
DocumentOnTypeFormattingRequest(
71+
textDocument: TextDocumentIdentifier(uri),
72+
position: positions["6️⃣"],
73+
ch: "{",
74+
options: FormattingOptions(tabSize: 4, insertSpaces: true)
75+
)
76+
)
77+
78+
let edits = try XCTUnwrap(response)
79+
XCTAssertEqual(
80+
edits,
81+
[
82+
TextEdit(range: Range(positions["1️⃣"]), newText: " "),
83+
TextEdit(range: positions["2️⃣"]..<positions["3️⃣"], newText: ""),
84+
TextEdit(range: positions["4️⃣"]..<positions["5️⃣"], newText: ""),
85+
]
86+
)
87+
}
88+
89+
/// Should not remove empty lines when formatting is triggered on a new empty line.
90+
/// Otherwise could mess up writing code. You'd write {} and try to go into the braces to write more code,
91+
/// only for on-type formatting to immediately close the braces again.
92+
func testDoesNothingWhenInAnEmptyLine() async throws {
93+
try await SkipUnless.toolchainContainsSwiftFormat()
94+
let testClient = try await TestSourceKitLSPClient()
95+
let uri = DocumentURI(for: .swift)
96+
97+
let positions = testClient.openDocument(
98+
"""
99+
func foo() {
100+
if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() {
101+
102+
103+
1️⃣
104+
105+
106+
}
107+
}
108+
""",
109+
uri: uri
110+
)
111+
112+
let response = try await testClient.send(
113+
DocumentOnTypeFormattingRequest(
114+
textDocument: TextDocumentIdentifier(uri),
115+
position: positions["1️⃣"],
116+
ch: "\n",
117+
options: FormattingOptions(tabSize: 2, insertSpaces: true)
118+
)
119+
)
120+
121+
let edits = try XCTUnwrap(response)
122+
XCTAssertEqual(
123+
edits,
124+
[]
125+
)
126+
}
127+
}

0 commit comments

Comments
 (0)