diff --git a/Package.swift b/Package.swift index e7d298c99..5702d6f76 100644 --- a/Package.swift +++ b/Package.swift @@ -309,6 +309,7 @@ let package = Package( .product(name: "SwiftRefactor", package: "swift-syntax"), .product(name: "SwiftSyntax", package: "swift-syntax"), .product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"), + .product(name: "SwiftPM-auto", package: "swift-package-manager"), ], exclude: ["CMakeLists.txt"] ), diff --git a/Sources/LanguageServerProtocol/SupportTypes/VersionedTextDocumentIdentifier.swift b/Sources/LanguageServerProtocol/SupportTypes/VersionedTextDocumentIdentifier.swift index e1d7fa4ff..d0e628be9 100644 --- a/Sources/LanguageServerProtocol/SupportTypes/VersionedTextDocumentIdentifier.swift +++ b/Sources/LanguageServerProtocol/SupportTypes/VersionedTextDocumentIdentifier.swift @@ -53,4 +53,18 @@ public struct OptionalVersionedTextDocumentIdentifier: Hashable, Codable, Sendab self.uri = uri self.version = version } + + enum CodingKeys: CodingKey { + case uri + case version + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.uri, forKey: .uri) + + // Note: we use encode(_:forKey:) here instead of encodeIf(_:forKey:) + // because VSCode will drop requests without the explicit 'null'. + try container.encode(self.version, forKey: .version) + } } diff --git a/Sources/SourceKitLSP/CMakeLists.txt b/Sources/SourceKitLSP/CMakeLists.txt index 0d83ba4ae..ac4c0245f 100644 --- a/Sources/SourceKitLSP/CMakeLists.txt +++ b/Sources/SourceKitLSP/CMakeLists.txt @@ -23,6 +23,7 @@ target_sources(SourceKitLSP PRIVATE target_sources(SourceKitLSP PRIVATE Swift/AdjustPositionToStartOfIdentifier.swift Swift/CodeActions/ConvertIntegerLiteral.swift + Swift/CodeActions/PackageManifestEdits.swift Swift/CodeActions/SyntaxCodeActionProvider.swift Swift/CodeActions/SyntaxCodeActions.swift Swift/CodeActions/SyntaxRefactoringCodeActionProvider.swift @@ -74,5 +75,6 @@ target_link_libraries(SourceKitLSP PUBLIC SwiftSyntax::SwiftRefactor SwiftSyntax::SwiftSyntax) target_link_libraries(SourceKitLSP PRIVATE + PackageModelSyntax $<$>:FoundationXML>) diff --git a/Sources/SourceKitLSP/Swift/CodeActions/PackageManifestEdits.swift b/Sources/SourceKitLSP/Swift/CodeActions/PackageManifestEdits.swift new file mode 100644 index 000000000..27603f144 --- /dev/null +++ b/Sources/SourceKitLSP/Swift/CodeActions/PackageManifestEdits.swift @@ -0,0 +1,243 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 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 PackageModel +import PackageModelSyntax +import SwiftRefactor +import SwiftSyntax + +/// Syntactic code action provider to provide refactoring actions that +/// edit a package manifest. +struct PackageManifestEdits: SyntaxCodeActionProvider { + static func codeActions(in scope: SyntaxCodeActionScope) -> [CodeAction] { + guard let token = scope.firstToken, + let call = token.findEnclosingCall() + else { + return [] + } + + return addTestTargetActions(call: call, in: scope) + addProductActions(call: call, in: scope) + } + + /// Produce code actions to add test target(s) if we are currently on + /// a target for which we know how to create a test. + static func addTestTargetActions( + call: FunctionCallExprSyntax, + in scope: SyntaxCodeActionScope + ) -> [CodeAction] { + guard let calledMember = call.findMemberAccessCallee(), + targetsThatAllowTests.contains(calledMember), + let targetName = call.findStringArgument(label: "name") + else { + return [] + } + + do { + // Describe the target we are going to create. + let target = try TargetDescription( + name: "\(targetName)Tests", + dependencies: [.byName(name: targetName, condition: nil)], + type: .test + ) + + let edits = try AddTarget.addTarget(target, to: scope.file) + return [ + CodeAction( + title: "Add test target", + kind: .refactor, + edit: edits.asWorkspaceEdit(snapshot: scope.snapshot) + ) + ] + } catch { + return [] + } + } + + /// A list of target kinds that allow the creation of tests. + static let targetsThatAllowTests: Set = [ + "executableTarget", + "macro", + "target", + ] + + /// Produce code actions to add a product if we are currently on + /// a target for which we can create a product. + static func addProductActions( + call: FunctionCallExprSyntax, + in scope: SyntaxCodeActionScope + ) -> [CodeAction] { + guard let calledMember = call.findMemberAccessCallee(), + targetsThatAllowProducts.contains(calledMember), + let targetName = call.findStringArgument(label: "name") + else { + return [] + } + + do { + let type: ProductType = + calledMember == "executableTarget" + ? .executable + : .library(.automatic) + + // Describe the target we are going to create. + let product = try ProductDescription( + name: targetName, + type: type, + targets: [targetName] + ) + + let edits = try AddProduct.addProduct(product, to: scope.file) + return [ + CodeAction( + title: "Add product to export this target", + kind: .refactor, + edit: edits.asWorkspaceEdit(snapshot: scope.snapshot) + ) + ] + } catch { + return [] + } + } + + /// A list of target kinds that allow the creation of tests. + static let targetsThatAllowProducts: Set = [ + "executableTarget", + "target", + ] +} + +fileprivate extension PackageEditResult { + /// Translate package manifest edits into a workspace edit. This can + /// involve both modifications to the manifest file as well as the creation + /// of new files. + /// `snapshot` is the latest snapshot of the `Package.swift` file. + func asWorkspaceEdit(snapshot: DocumentSnapshot) -> WorkspaceEdit { + // The edits to perform on the manifest itself. + let manifestTextEdits = manifestEdits.map { edit in + TextEdit( + range: snapshot.range(of: edit.range), + newText: edit.replacement + ) + } + + // If we couldn't figure out the manifest directory, or there are no + // files to add, the only changes are the manifest edits. We're done + // here. + let manifestDirectoryURL = snapshot.uri.fileURL? + .deletingLastPathComponent() + guard let manifestDirectoryURL, !auxiliaryFiles.isEmpty else { + return WorkspaceEdit( + changes: [snapshot.uri: manifestTextEdits] + ) + } + + // Use the more full-featured documentChanges, which takes precedence + // over the individual changes to documents. + var documentChanges: [WorkspaceEditDocumentChange] = [] + + // Put the manifest changes into the array. + documentChanges.append( + .textDocumentEdit( + TextDocumentEdit( + textDocument: .init(snapshot.uri, version: snapshot.version), + edits: manifestTextEdits.map { .textEdit($0) } + ) + ) + ) + + // Create an populate all of the auxiliary files. + for (relativePath, contents) in auxiliaryFiles { + guard + let url = URL( + string: relativePath.pathString, + relativeTo: manifestDirectoryURL + ) + else { + continue + } + + let documentURI = DocumentURI(url) + let createFile = CreateFile( + uri: documentURI + ) + + let zeroPosition = Position(line: 0, utf16index: 0) + let edit = TextEdit( + range: zeroPosition.. FunctionCallExprSyntax? { + var current = Syntax(self) + while true { + if let call = current.as(FunctionCallExprSyntax.self) { + return call + } + + if let parent = current.parent { + current = parent + continue + } + + return nil + } + } +} + +fileprivate extension FunctionCallExprSyntax { + /// Find an argument with the given label that has a string literal as + /// its argument. + func findStringArgument(label: String) -> String? { + for arg in arguments { + if arg.label?.text == label { + return arg.expression.as(StringLiteralExprSyntax.self)? + .representedLiteralValue + } + } + + return nil + } + + /// Find the callee when it is a member access expression referencing + /// a declaration when a specific name. + func findMemberAccessCallee() -> String? { + guard + let memberAccess = self.calledExpression + .as(MemberAccessExprSyntax.self) + else { + return nil + } + + return memberAccess.declName.baseName.text + } +} diff --git a/Sources/SourceKitLSP/Swift/CodeActions/SyntaxCodeActions.swift b/Sources/SourceKitLSP/Swift/CodeActions/SyntaxCodeActions.swift index 6839b48f9..b4b2ce85c 100644 --- a/Sources/SourceKitLSP/Swift/CodeActions/SyntaxCodeActions.swift +++ b/Sources/SourceKitLSP/Swift/CodeActions/SyntaxCodeActions.swift @@ -20,5 +20,6 @@ let allSyntaxCodeActions: [SyntaxCodeActionProvider.Type] = [ FormatRawStringLiteral.self, MigrateToNewIfLetSyntax.self, OpaqueParameterToGeneric.self, + PackageManifestEdits.self, RemoveSeparatorsFromIntegerLiteral.self, ] diff --git a/Tests/LanguageServerProtocolTests/CodingTests.swift b/Tests/LanguageServerProtocolTests/CodingTests.swift index 7cea8cd04..94415d42f 100644 --- a/Tests/LanguageServerProtocolTests/CodingTests.swift +++ b/Tests/LanguageServerProtocolTests/CodingTests.swift @@ -74,7 +74,8 @@ final class CodingTests: XCTestCase { OptionalVersionedTextDocumentIdentifier(uri, version: nil), json: """ { - "uri" : "\(urljson)" + "uri" : "\(urljson)", + "version" : null } """ ) diff --git a/Tests/SourceKitLSPTests/CodeActionTests.swift b/Tests/SourceKitLSPTests/CodeActionTests.swift index e8057bebd..657c5c78a 100644 --- a/Tests/SourceKitLSPTests/CodeActionTests.swift +++ b/Tests/SourceKitLSPTests/CodeActionTests.swift @@ -290,6 +290,13 @@ final class CodeActionTests: XCTestCase { } XCTAssertTrue(codeActions.contains(expectedCodeAction)) + + // Make sure we get one of the swift-syntax refactoring actions. + XCTAssertTrue( + codeActions.contains { action in + return action.title == "Convert string literal to minimal number of \'#\'s" + } + ) } func testSemanticRefactorRangeCodeActionResult() async throws { @@ -482,4 +489,115 @@ final class CodeActionTests: XCTestCase { ] XCTAssertEqual(expectedCodeActions, codeActions) } + + func testPackageManifestEditingCodeActionResult() async throws { + let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport()) + let uri = DocumentURI.for(.swift) + let positions = testClient.openDocument( + """ + // swift-tools-version: 5.5 + let package = Package( + name: "packages", + targets: [ + .tar1️⃣get(name: "MyLib"), + ] + ) + """, + uri: uri + ) + + let testPosition = positions["1️⃣"] + let request = CodeActionRequest( + range: Range(testPosition), + context: .init(), + textDocument: TextDocumentIdentifier(uri) + ) + let result = try await testClient.send(request) + + guard case .codeActions(let codeActions) = result else { + XCTFail("Expected code actions") + return + } + + // Make sure we get the expected package manifest editing actions. + let addTestAction = codeActions.first { action in + return action.title == "Add test target" + } + XCTAssertNotNil(addTestAction) + + guard let addTestChanges = addTestAction?.edit?.documentChanges else { + XCTFail("Didn't have changes in the 'Add test target' action") + return + } + + guard + let addTestEdit = addTestChanges.lazy.compactMap({ change in + switch change { + case .textDocumentEdit(let edit): edit + default: nil + } + }).first + else { + XCTFail("Didn't have edits") + return + } + + XCTAssertTrue( + addTestEdit.edits.contains { edit in + switch edit { + case .textEdit(let edit): edit.newText.contains("testTarget") + case .annotatedTextEdit(let edit): edit.newText.contains("testTarget") + } + } + ) + + XCTAssertTrue( + codeActions.contains { action in + return action.title == "Add product to export this target" + } + ) + } + + func testPackageManifestEditingCodeActionNoTestResult() async throws { + let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport()) + let uri = DocumentURI.for(.swift) + let positions = testClient.openDocument( + """ + // swift-tools-version: 5.5 + let package = Package( + name: "packages", + targets: [ + .testTar1️⃣get(name: "MyLib"), + ] + ) + """, + uri: uri + ) + + let testPosition = positions["1️⃣"] + let request = CodeActionRequest( + range: Range(testPosition), + context: .init(), + textDocument: TextDocumentIdentifier(uri) + ) + let result = try await testClient.send(request) + + guard case .codeActions(let codeActions) = result else { + XCTFail("Expected code actions") + return + } + + // Make sure we get the expected package manifest editing actions. + XCTAssertTrue( + !codeActions.contains { action in + return action.title == "Add test target" + } + ) + + XCTAssertTrue( + !codeActions.contains { action in + return action.title == "Add product to export this target" + } + ) + } }