diff --git a/Package.swift b/Package.swift index a7f1c6990..6e3af58d5 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/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift b/Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift index e669f5e68..2e056a92c 100644 --- a/Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift +++ b/Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift @@ -263,7 +263,7 @@ extension SwiftPMBuildSystem { self.fileToTarget = [AbsolutePath: SwiftBuildTarget]( modulesGraph.allTargets.flatMap { target in return target.sources.paths.compactMap { - guard let buildTarget = buildDescription.getBuildTarget(for: target) else { + guard let buildTarget = buildDescription.getBuildTarget(for: target, in: modulesGraph) else { return nil } return (key: $0, value: buildTarget) @@ -277,7 +277,7 @@ extension SwiftPMBuildSystem { self.sourceDirToTarget = [AbsolutePath: SwiftBuildTarget]( modulesGraph.allTargets.compactMap { (target) -> (AbsolutePath, SwiftBuildTarget)? in - guard let buildTarget = buildDescription.getBuildTarget(for: target) else { + guard let buildTarget = buildDescription.getBuildTarget(for: target, in: modulesGraph) else { return nil } return (key: target.sources.root, value: buildTarget) @@ -439,8 +439,13 @@ extension SwiftPMBuildSystem: SKCore.BuildSystem { } public func testFiles() -> [DocumentURI] { - // We should only include source files from test targets (https://github.com/apple/sourcekit-lsp/issues/1174). - return fileToTarget.map { DocumentURI($0.key.asURL) } + return fileToTarget.compactMap { (path, target) -> DocumentURI? in + guard target.isPartOfRootPackage else { + // Don't consider files from package dependencies as possible test files. + return nil + } + return DocumentURI(path.asURL) + } } public func addTestFilesDidChangeCallback(_ callback: @Sendable @escaping () async -> Void) async { diff --git a/Sources/SKTestSupport/MultiFileTestProject.swift b/Sources/SKTestSupport/MultiFileTestProject.swift index 1484c4a64..cd9b8f000 100644 --- a/Sources/SKTestSupport/MultiFileTestProject.swift +++ b/Sources/SKTestSupport/MultiFileTestProject.swift @@ -31,6 +31,15 @@ public struct RelativeFileLocation: Hashable, ExpressibleByStringLiteral { let components = value.components(separatedBy: "/") self.init(directories: components.dropLast(), components.last!) } + + public func url(relativeTo: URL) -> URL { + var url = relativeTo + for directory in directories { + url = url.appendingPathComponent(directory) + } + url = url.appendingPathComponent(fileName) + return url + } } /// A test project that writes multiple files to disk and opens a `TestSourceKitLSPClient` client with a workspace @@ -69,7 +78,7 @@ public class MultiFileTestProject { /// File contents can also contain `$TEST_DIR`, which gets replaced by the temporary directory. public init( files: [RelativeFileLocation: String], - workspaces: (URL) -> [WorkspaceFolder] = { [WorkspaceFolder(uri: DocumentURI($0))] }, + workspaces: (URL) async throws -> [WorkspaceFolder] = { [WorkspaceFolder(uri: DocumentURI($0))] }, usePullDiagnostics: Bool = true, testName: String = #function ) async throws { @@ -79,11 +88,7 @@ public class MultiFileTestProject { var fileData: [String: FileData] = [:] for (fileLocation, markedText) in files { let markedText = markedText.replacingOccurrences(of: "$TEST_DIR", with: scratchDirectory.path) - var fileURL = scratchDirectory - for directory in fileLocation.directories { - fileURL = fileURL.appendingPathComponent(directory) - } - fileURL = fileURL.appendingPathComponent(fileLocation.fileName) + let fileURL = fileLocation.url(relativeTo: scratchDirectory) try FileManager.default.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true diff --git a/Sources/SKTestSupport/SwiftPMDependencyProject.swift b/Sources/SKTestSupport/SwiftPMDependencyProject.swift new file mode 100644 index 000000000..ba97a512d --- /dev/null +++ b/Sources/SKTestSupport/SwiftPMDependencyProject.swift @@ -0,0 +1,108 @@ +//===----------------------------------------------------------------------===// +// +// 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 Foundation +import ISDBTibs +import XCTest + +import struct TSCBasic.AbsolutePath +import class TSCBasic.Process +import enum TSCBasic.ProcessEnv +import struct TSCBasic.ProcessResult + +/// A SwiftPM package that gets written to disk and for which a Git repository is initialized with a commit tagged +/// `1.0.0`. This repository can then be used as a dependency for another package, usually a `SwiftPMTestProject`. +public class SwiftPMDependencyProject { + /// The directory in which the repository lives. + public let packageDirectory: URL + + private func runCommand(_ toolName: String, _ arguments: [String], workingDirectory: URL) async throws { + enum Error: Swift.Error { + case cannotFindTool(toolName: String) + case processedTerminatedWithNonZeroExitCode(ProcessResult) + } + guard let toolUrl = findTool(name: toolName) else { + if ProcessEnv.block["SWIFTCI_USE_LOCAL_DEPS"] == nil { + // Never skip the test in CI, similar to what SkipUnless does. + throw XCTSkip("\(toolName) cannot be found") + } + throw Error.cannotFindTool(toolName: toolName) + } + print([toolUrl.path] + arguments) + let process = TSCBasic.Process( + arguments: [toolUrl.path] + arguments, + workingDirectory: try AbsolutePath(validating: workingDirectory.path) + ) + try process.launch() + let processResult = try await process.waitUntilExit() + guard processResult.exitStatus == .terminated(code: 0) else { + throw Error.processedTerminatedWithNonZeroExitCode(processResult) + } + } + + public static let defaultPackageManifest: String = """ + // swift-tools-version: 5.7 + + import PackageDescription + + let package = Package( + name: "MyDependency", + products: [.library(name: "MyDependency", targets: ["MyDependency"])], + targets: [.target(name: "MyDependency")] + ) + """ + + public init( + files: [RelativeFileLocation: String], + manifest: String = defaultPackageManifest, + testName: String = #function + ) async throws { + packageDirectory = try testScratchDir(testName: testName).appendingPathComponent("MyDependency") + + var files = files + files["Package.swift"] = manifest + + for (fileLocation, contents) in files { + let fileURL = fileLocation.url(relativeTo: packageDirectory) + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try contents.write(to: fileURL, atomically: true, encoding: .utf8) + } + + try await runCommand("git", ["init"], workingDirectory: packageDirectory) + try await runCommand( + "git", + ["add"] + files.keys.map { $0.url(relativeTo: packageDirectory).path }, + workingDirectory: packageDirectory + ) + try await runCommand( + "git", + ["-c", "user.name=Dummy", "-c", "user.email=noreply@swift.org", "commit", "-m", "Initial commit"], + workingDirectory: packageDirectory + ) + try await runCommand("git", ["tag", "1.0.0"], workingDirectory: packageDirectory) + } + + deinit { + if cleanScratchDirectories { + try? FileManager.default.removeItem(at: packageDirectory) + } + } + + /// Function that makes sure the project stays alive until this is called. Otherwise, the `SwiftPMDependencyProject` + /// might get deinitialized, which deletes the package on disk. + public func keepAlive() { + withExtendedLifetime(self) { _ in } + } +} diff --git a/Sources/SKTestSupport/SwiftPMTestProject.swift b/Sources/SKTestSupport/SwiftPMTestProject.swift index 958a9d1b2..fa0a9dea7 100644 --- a/Sources/SKTestSupport/SwiftPMTestProject.swift +++ b/Sources/SKTestSupport/SwiftPMTestProject.swift @@ -38,7 +38,7 @@ public class SwiftPMTestProject: MultiFileTestProject { public init( files: [RelativeFileLocation: String], manifest: String = SwiftPMTestProject.defaultPackageManifest, - workspaces: (URL) -> [WorkspaceFolder] = { [WorkspaceFolder(uri: DocumentURI($0))] }, + workspaces: (URL) async throws -> [WorkspaceFolder] = { [WorkspaceFolder(uri: DocumentURI($0))] }, build: Bool = false, allowBuildFailure: Bool = false, usePullDiagnostics: Bool = true, @@ -59,6 +59,7 @@ public class SwiftPMTestProject: MultiFileTestProject { filesByPath[RelativeFileLocation(directories: directories, fileLocation.fileName)] = contents } filesByPath["Package.swift"] = manifest + try await super.init( files: filesByPath, workspaces: workspaces, @@ -96,4 +97,18 @@ public class SwiftPMTestProject: MultiFileTestProject { environment["SWIFTPM_ENABLE_CLANG_INDEX_STORE"] = "1" try await Process.checkNonZeroExit(arguments: arguments, environmentBlock: environment) } + + /// Resolve package dependencies for the package at `path`. + public static func resolvePackageDependencies(at path: URL) async throws { + guard let swift = await ToolchainRegistry.forTesting.default?.swift?.asURL else { + throw Error.swiftNotFound + } + let arguments = [ + swift.path, + "package", + "resolve", + "--package-path", path.path, + ] + try await Process.checkNonZeroExit(arguments: arguments) + } } 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/SourceKitLSPServer.swift b/Sources/SourceKitLSP/SourceKitLSPServer.swift index c4865ec90..3f1575983 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer.swift @@ -821,7 +821,7 @@ public actor SourceKitLSPServer { return nil } - logger.info("Using toolchain \(toolchain.displayName) (\(toolchain.identifier)) for \(uri.forLogging)") + logger.log("Using toolchain \(toolchain.displayName) (\(toolchain.identifier)) for \(uri.forLogging)") if let concurrentlySetService = workspace.documentService[uri] { // Since we await the construction of `service`, another call to this 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/Sources/SourceKitLSP/Swift/SwiftTestingScanner.swift b/Sources/SourceKitLSP/Swift/SwiftTestingScanner.swift index 84b9e0316..ecda43039 100644 --- a/Sources/SourceKitLSP/Swift/SwiftTestingScanner.swift +++ b/Sources/SourceKitLSP/Swift/SwiftTestingScanner.swift @@ -95,7 +95,18 @@ struct TestingAttributeData { return false } }.flatMap(\.arguments) - .compactMap { $0.expression.as(StringLiteralExprSyntax.self)?.representedLiteralValue } + .compactMap { + if let memberAccess = $0.expression.as(MemberAccessExprSyntax.self) { + var components = memberAccess.components[...] + if components.starts(with: ["Testing", "Tag"]) { + components = components.dropFirst(2) + } else if components.starts(with: ["Tag"]) { + components = components.dropFirst(1) + } + return components.joined(separator: ".") + } + return nil + } self.isDisabled = traitArguments.lazy .compactMap { $0.as(FunctionCallExprSyntax.self) } @@ -281,7 +292,7 @@ final class SyntacticSwiftTestingTestScanner: SyntaxVisitor { } let name = - node.name.text + "(" + node.signature.parameterClause.parameters.map { "\($0.firstName):" }.joined() + ")" + node.name.text + "(" + node.signature.parameterClause.parameters.map { "\($0.firstName.text):" }.joined() + ")" let range = snapshot.range(of: node.positionAfterSkippingLeadingTrivia..