From a2eb7b9b2c5ce666112cdd67626fcd0bc05615e4 Mon Sep 17 00:00:00 2001 From: MahdiBM Date: Thu, 21 Nov 2024 16:07:22 +0330 Subject: [PATCH] Handle on-type formatting requests --- Documentation/Configuration File.md | 2 +- Sources/SKOptions/ExperimentalFeatures.swift | 8 +- .../Clang/ClangLanguageService.swift | 4 + Sources/SourceKitLSP/LanguageService.swift | 1 + Sources/SourceKitLSP/SourceKitLSPServer.swift | 22 ++- .../Swift/DocumentFormatting.swift | 30 ++++- .../Swift/SwiftLanguageService.swift | 6 + .../OnTypeFormattingTests.swift | 127 ++++++++++++++++++ 8 files changed, 190 insertions(+), 10 deletions(-) create mode 100644 Tests/SourceKitLSPTests/OnTypeFormattingTests.swift diff --git a/Documentation/Configuration File.md b/Documentation/Configuration File.md index d19dcca04..48b84eb30 100644 --- a/Documentation/Configuration File.md +++ b/Documentation/Configuration File.md @@ -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`. diff --git a/Sources/SKOptions/ExperimentalFeatures.swift b/Sources/SKOptions/ExperimentalFeatures.swift index 591f87e03..39f9ae954 100644 --- a/Sources/SKOptions/ExperimentalFeatures.swift +++ b/Sources/SKOptions/ExperimentalFeatures.swift @@ -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" } diff --git a/Sources/SourceKitLSP/Clang/ClangLanguageService.swift b/Sources/SourceKitLSP/Clang/ClangLanguageService.swift index 34f744f90..255bd8f39 100644 --- a/Sources/SourceKitLSP/Clang/ClangLanguageService.swift +++ b/Sources/SourceKitLSP/Clang/ClangLanguageService.swift @@ -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) } diff --git a/Sources/SourceKitLSP/LanguageService.swift b/Sources/SourceKitLSP/LanguageService.swift index bfcc09b2c..aecc2c552 100644 --- a/Sources/SourceKitLSP/LanguageService.swift +++ b/Sources/SourceKitLSP/LanguageService.swift @@ -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 diff --git a/Sources/SourceKitLSP/SourceKitLSPServer.swift b/Sources/SourceKitLSP/SourceKitLSPServer.swift index 6cedb6c50..f76805de3 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer.swift @@ -720,6 +720,8 @@ extension SourceKitLSPServer: QueueBasedMessageHandler { await self.handleRequest(for: request, requestHandler: self.documentFormatting) case let request as RequestAndReply: await self.handleRequest(for: request, requestHandler: self.documentRangeFormatting) + case let request as RequestAndReply: + await self.handleRequest(for: request, requestHandler: self.documentOnTypeFormatting) case let request as RequestAndReply: await self.handleRequest(for: request, requestHandler: self.documentSymbolHighlight) case let request as RequestAndReply: @@ -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, diff --git a/Sources/SourceKitLSP/Swift/DocumentFormatting.swift b/Sources/SourceKitLSP/Swift/DocumentFormatting.swift index 82e6c6a61..1c42c2cf5 100644 --- a/Sources/SourceKitLSP/Swift/DocumentFormatting.swift +++ b/Sources/SourceKitLSP/Swift/DocumentFormatting.swift @@ -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,6 +145,7 @@ 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 ) @@ -150,19 +153,36 @@ extension SwiftLanguageService { 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..? = 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) diff --git a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift index ca83985b7..fa86f15c9 100644 --- a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift +++ b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift @@ -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. diff --git a/Tests/SourceKitLSPTests/OnTypeFormattingTests.swift b/Tests/SourceKitLSPTests/OnTypeFormattingTests.swift new file mode 100644 index 000000000..114dcfa8a --- /dev/null +++ b/Tests/SourceKitLSPTests/OnTypeFormattingTests.swift @@ -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️⃣"]..