Skip to content

Handle on-type formatting requests #1815

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 22, 2024
Merged
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
2 changes: 1 addition & 1 deletion Documentation/Configuration File.md
Original file line number Diff line number Diff line change
@@ -51,5 +51,5 @@ The structure of the file is currently not guaranteed to be stable. Options may
- `noLazy`: Prepare a target without generating object files but do not do lazy type checking and function body skipping
- `enabled`: Prepare a target without generating object files and the like
- `cancelTextDocumentRequestsOnEditAndClose: bool`: Whether sending a `textDocument/didChange` or `textDocument/didClose` notification for a document should cancel all pending requests for that document.
- `experimentalFeatures: string[]`: Experimental features to enable
- `experimentalFeatures: string[]`: Experimental features to enable. Available features: on-type-formatting
- `swiftPublishDiagnosticsDebounceDuration: double`: The time that `SwiftLanguageService` should wait after an edit before starting to compute diagnostics and sending a `PublishDiagnosticsNotification`.
8 changes: 4 additions & 4 deletions Sources/SKOptions/ExperimentalFeatures.swift
Original file line number Diff line number Diff line change
@@ -10,9 +10,9 @@
//
//===----------------------------------------------------------------------===//

/// An experimental feature that can be enabled by passing `--experimental-feature` to `sourcekit-lsp` on the command
/// line. The raw value of this feature is how it is named on the command line.
/// An experimental feature that can be enabled by passing `--experimental-feature`
/// to `sourcekit-lsp` on the command line or through the configuration file.
/// The raw value of this feature is how it is named on the command line and in the configuration file.
public enum ExperimentalFeature: String, Codable, Sendable, CaseIterable {
/* This is here to silence the errors when the enum doesn't have any cases */
case exampleCase = "example-case"
case onTypeFormatting = "on-type-formatting"
}
4 changes: 4 additions & 0 deletions Sources/SourceKitLSP/Clang/ClangLanguageService.swift
Original file line number Diff line number Diff line change
@@ -581,6 +581,10 @@ extension ClangLanguageService {
return try await forwardRequestToClangd(req)
}

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

func codeAction(_ req: CodeActionRequest) async throws -> CodeActionRequestResponse? {
return try await forwardRequestToClangd(req)
}
1 change: 1 addition & 0 deletions Sources/SourceKitLSP/LanguageService.swift
Original file line number Diff line number Diff line change
@@ -210,6 +210,7 @@ package protocol LanguageService: AnyObject, Sendable {
func documentDiagnostic(_ req: DocumentDiagnosticsRequest) async throws -> DocumentDiagnosticReport
func documentFormatting(_ req: DocumentFormattingRequest) async throws -> [TextEdit]?
func documentRangeFormatting(_ req: DocumentRangeFormattingRequest) async throws -> [TextEdit]?
func documentOnTypeFormatting(_ req: DocumentOnTypeFormattingRequest) async throws -> [TextEdit]?

// MARK: - Rename

22 changes: 20 additions & 2 deletions Sources/SourceKitLSP/SourceKitLSPServer.swift
Original file line number Diff line number Diff line change
@@ -720,6 +720,8 @@ extension SourceKitLSPServer: QueueBasedMessageHandler {
await self.handleRequest(for: request, requestHandler: self.documentFormatting)
case let request as RequestAndReply<DocumentRangeFormattingRequest>:
await self.handleRequest(for: request, requestHandler: self.documentRangeFormatting)
case let request as RequestAndReply<DocumentOnTypeFormattingRequest>:
await self.handleRequest(for: request, requestHandler: self.documentOnTypeFormatting)
case let request as RequestAndReply<DocumentHighlightRequest>:
await self.handleRequest(for: request, requestHandler: self.documentSymbolHighlight)
case let request as RequestAndReply<DocumentSemanticTokensDeltaRequest>:
@@ -971,7 +973,8 @@ extension SourceKitLSPServer {
let result = InitializeResult(
capabilities: await self.serverCapabilities(
for: req.capabilities,
registry: self.capabilityRegistry!
registry: self.capabilityRegistry!,
options: options
)
)
logger.logFullObjectInMultipleLogMessages(header: "Initialize response", AnyRequestType(request: req))
@@ -980,7 +983,8 @@ extension SourceKitLSPServer {

func serverCapabilities(
for client: ClientCapabilities,
registry: CapabilityRegistry
registry: CapabilityRegistry,
options: SourceKitLSPOptions
) async -> ServerCapabilities {
let completionOptions =
await registry.clientHasDynamicCompletionRegistration
@@ -990,6 +994,11 @@ extension SourceKitLSPServer {
triggerCharacters: [".", "("]
)

let onTypeFormattingOptions =
options.hasExperimentalFeature(.onTypeFormatting)
? DocumentOnTypeFormattingOptions(triggerCharacters: ["\n", "\r\n", "\r", "{", "}", ";", ".", ":", "#"])
: nil

let foldingRangeOptions =
await registry.clientHasDynamicFoldingRangeRegistration
? nil
@@ -1039,6 +1048,7 @@ extension SourceKitLSPServer {
codeLensProvider: CodeLensOptions(),
documentFormattingProvider: .value(DocumentFormattingOptions(workDoneProgress: false)),
documentRangeFormattingProvider: .value(DocumentRangeFormattingOptions(workDoneProgress: false)),
documentOnTypeFormattingProvider: onTypeFormattingOptions,
renameProvider: .value(RenameOptions(prepareProvider: true)),
colorProvider: .bool(true),
foldingRangeProvider: foldingRangeOptions,
@@ -1537,6 +1547,14 @@ extension SourceKitLSPServer {
return try await languageService.documentRangeFormatting(req)
}

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

func colorPresentation(
_ req: ColorPresentationRequest,
workspace: Workspace,
30 changes: 27 additions & 3 deletions Sources/SourceKitLSP/Swift/DocumentFormatting.swift
Original file line number Diff line number Diff line change
@@ -15,6 +15,7 @@ import Foundation
package import LanguageServerProtocol
import LanguageServerProtocolExtensions
import SKLogging
import SKUtilities
import SwiftExtensions
import SwiftParser
import SwiftSyntax
@@ -28,6 +29,7 @@ import Foundation
import LanguageServerProtocol
import LanguageServerProtocolExtensions
import SKLogging
import SKUtilities
import SwiftExtensions
import SwiftParser
import SwiftSyntax
@@ -143,26 +145,44 @@ private func edits(from original: DocumentSnapshot, to edited: String) -> [TextE
extension SwiftLanguageService {
package func documentFormatting(_ req: DocumentFormattingRequest) async throws -> [TextEdit]? {
return try await format(
snapshot: documentManager.latestSnapshot(req.textDocument.uri),
textDocument: req.textDocument,
options: req.options
)
}

package func documentRangeFormatting(_ req: DocumentRangeFormattingRequest) async throws -> [TextEdit]? {
return try await format(
snapshot: documentManager.latestSnapshot(req.textDocument.uri),
textDocument: req.textDocument,
options: req.options,
range: req.range
)
}

package func documentOnTypeFormatting(_ req: DocumentOnTypeFormattingRequest) async throws -> [TextEdit]? {
let snapshot = try documentManager.latestSnapshot(req.textDocument.uri)
guard let line = snapshot.lineTable.line(at: req.position.line) else {
return nil
}

let lineStartPosition = snapshot.position(of: line.startIndex, fromLine: req.position.line)
let lineEndPosition = snapshot.position(of: line.endIndex, fromLine: req.position.line)

return try await format(
snapshot: snapshot,
textDocument: req.textDocument,
options: req.options,
range: lineStartPosition..<lineEndPosition
)
}

private func format(
snapshot: DocumentSnapshot,
textDocument: TextDocumentIdentifier,
options: FormattingOptions,
range: Range<Position>? = nil
) async throws -> [TextEdit]? {
let snapshot = try documentManager.latestSnapshot(textDocument.uri)

guard let swiftFormat else {
throw ResponseError.unknown(
"Formatting not supported because the toolchain is missing the swift-format executable"
@@ -176,9 +196,13 @@ extension SwiftLanguageService {
swiftFormatConfiguration(for: textDocument.uri, options: options),
]
if let range {
let utf8Range = snapshot.utf8OffsetRange(of: range)
// swift-format takes an inclusive range, but Swift's `Range.upperBound` is exclusive.
// Also make sure `upperBound` does not go less than `lowerBound`.
let utf8UpperBound = max(utf8Range.lowerBound, utf8Range.upperBound - 1)
args += [
"--offsets",
"\(snapshot.utf8Offset(of: range.lowerBound)):\(snapshot.utf8Offset(of: range.upperBound))",
"\(utf8Range.lowerBound):\(utf8UpperBound)",
]
}
let process = TSCBasic.Process(arguments: args)
6 changes: 6 additions & 0 deletions Sources/SourceKitLSP/Swift/SwiftLanguageService.swift
Original file line number Diff line number Diff line change
@@ -1226,6 +1226,12 @@ extension DocumentSnapshot {
return Position(line: zeroBasedLine, utf16index: utf16Column)
}

/// Converts the given `String.Index` to a UTF-16-based line:column position.
func position(of index: String.Index, fromLine: Int = 0) -> Position {
let (line, utf16Column) = lineTable.lineAndUTF16ColumnOf(index, fromLine: fromLine)
return Position(line: line, utf16index: utf16Column)
}

// MARK: Position <-> AbsolutePosition

/// Converts the given UTF-8-offset-based `AbsolutePosition` to a UTF-16-based line:column.
127 changes: 127 additions & 0 deletions Tests/SourceKitLSPTests/OnTypeFormattingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//===----------------------------------------------------------------------===//
//
// 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 LanguageServerProtocol
import SKLogging
import SKTestSupport
import SourceKitLSP
import XCTest

final class OnTypeFormattingTests: XCTestCase {
func testOnlyFormatsSpecifiedLine() async throws {
try await SkipUnless.toolchainContainsSwiftFormat()
let testClient = try await TestSourceKitLSPClient()
let uri = DocumentURI(for: .swift)

let positions = testClient.openDocument(
"""
func foo() {
if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() {
1️⃣// do stuff
}
}
""",
uri: uri
)

let response = try await testClient.send(
DocumentOnTypeFormattingRequest(
textDocument: TextDocumentIdentifier(uri),
position: positions["1️⃣"],
ch: "\n",
options: FormattingOptions(tabSize: 2, insertSpaces: true)
)
)

let edits = try XCTUnwrap(response)
XCTAssertEqual(
edits,
[
TextEdit(range: Range(positions["1️⃣"]), newText: " ")
]
)
}

func testFormatsFullLineAndDoesNotFormatNextLine() async throws {
try await SkipUnless.toolchainContainsSwiftFormat()
let testClient = try await TestSourceKitLSPClient()
let uri = DocumentURI(for: .swift)

let positions = testClient.openDocument(
"""
func foo() {
1️⃣if let SomeReallyLongVar = 2️⃣ 3️⃣Some.More.Stuff(), let a = 4️⃣ 5️⃣myfunc() 6️⃣{
}
}
""",
uri: uri
)

let response = try await testClient.send(
DocumentOnTypeFormattingRequest(
textDocument: TextDocumentIdentifier(uri),
position: positions["6️⃣"],
ch: "{",
options: FormattingOptions(tabSize: 4, insertSpaces: true)
)
)

let edits = try XCTUnwrap(response)
XCTAssertEqual(
edits,
[
TextEdit(range: Range(positions["1️⃣"]), newText: " "),
TextEdit(range: positions["2️⃣"]..<positions["3️⃣"], newText: ""),
TextEdit(range: positions["4️⃣"]..<positions["5️⃣"], newText: ""),
]
)
}

/// Should not remove empty lines when formatting is triggered on a new empty line.
/// Otherwise could mess up writing code. You'd write {} and try to go into the braces to write more code,
/// only for on-type formatting to immediately close the braces again.
func testDoesNothingWhenInAnEmptyLine() async throws {
try await SkipUnless.toolchainContainsSwiftFormat()
let testClient = try await TestSourceKitLSPClient()
let uri = DocumentURI(for: .swift)

let positions = testClient.openDocument(
"""
func foo() {
if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() {


1️⃣


}
}
""",
uri: uri
)

let response = try await testClient.send(
DocumentOnTypeFormattingRequest(
textDocument: TextDocumentIdentifier(uri),
position: positions["1️⃣"],
ch: "\n",
options: FormattingOptions(tabSize: 2, insertSpaces: true)
)
)

let edits = try XCTUnwrap(response)
XCTAssertEqual(
edits,
[]
)
}
}