diff --git a/Package.swift b/Package.swift index bc00da05d..6300efcbf 100644 --- a/Package.swift +++ b/Package.swift @@ -381,6 +381,10 @@ let package = Package( .product(name: "SwiftParser", package: "swift-syntax"), .product(name: "SwiftSyntax", package: "swift-syntax"), .product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"), + // Depend on `SwiftCompilerPlugin` and `SwiftSyntaxMacros` so the modules are built before running tests and can + // be used by test cases that test macros (see `SwiftPMTestProject.macroPackageManifest`). + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), ] ), ] diff --git a/Sources/SKTestSupport/SkipUnless.swift b/Sources/SKTestSupport/SkipUnless.swift index d3248b1bc..9289c6fae 100644 --- a/Sources/SKTestSupport/SkipUnless.swift +++ b/Sources/SKTestSupport/SkipUnless.swift @@ -58,17 +58,11 @@ public actor SkipUnless { line: UInt, featureCheck: () async throws -> Bool ) async throws { - let checkResult: FeatureCheckResult - if let cachedResult = checkCache[featureName] { - checkResult = cachedResult - } else if ProcessEnv.block["SWIFTCI_USE_LOCAL_DEPS"] != nil { - // Never skip tests in CI. Toolchain should be up-to-date - checkResult = .featureSupported - } else { + return try await skipUnlessSupported(featureName: featureName, file: file, line: line) { let toolchainSwiftVersion = try await unwrap(ToolchainRegistry.forTesting.default).swiftVersion let requiredSwiftVersion = SwiftVersion(swiftVersion.major, swiftVersion.minor) if toolchainSwiftVersion < requiredSwiftVersion { - checkResult = .featureUnsupported( + return .featureUnsupported( skipMessage: """ Skipping because toolchain has Swift version \(toolchainSwiftVersion) \ but test requires at least \(requiredSwiftVersion) @@ -77,15 +71,32 @@ public actor SkipUnless { } else if toolchainSwiftVersion == requiredSwiftVersion { logger.info("Checking if feature '\(featureName)' is supported") if try await !featureCheck() { - checkResult = .featureUnsupported(skipMessage: "Skipping because toolchain doesn't contain \(featureName)") + return .featureUnsupported(skipMessage: "Skipping because toolchain doesn't contain \(featureName)") } else { - checkResult = .featureSupported + return .featureSupported } logger.info("Done checking if feature '\(featureName)' is supported") } else { - checkResult = .featureSupported + return .featureSupported } } + } + + private func skipUnlessSupported( + featureName: String = #function, + file: StaticString, + line: UInt, + featureCheck: () async throws -> FeatureCheckResult + ) async throws { + let checkResult: FeatureCheckResult + if let cachedResult = checkCache[featureName] { + checkResult = cachedResult + } else if ProcessEnv.block["SWIFTCI_USE_LOCAL_DEPS"] != nil { + // Never skip tests in CI. Toolchain should be up-to-date + checkResult = .featureSupported + } else { + checkResult = try await featureCheck() + } checkCache[featureName] = checkResult if case .featureUnsupported(let skipMessage) = checkResult { @@ -272,6 +283,40 @@ public actor SkipUnless { } #endif } + + /// Check if we can use the build artifacts in the sourcekit-lsp build directory to build a macro package without + /// re-building swift-syntax. + public static func canBuildMacroUsingSwiftSyntaxFromSourceKitLSPBuild( + file: StaticString = #filePath, + line: UInt = #line + ) async throws { + return try await shared.skipUnlessSupported(file: file, line: line) { + do { + let project = try await SwiftPMTestProject( + files: [ + "MyMacros/MyMacros.swift": #""" + import SwiftCompilerPlugin + import SwiftSyntax + import SwiftSyntaxBuilder + import SwiftSyntaxMacros + """#, + "MyMacroClient/MyMacroClient.swift": """ + """, + ], + manifest: SwiftPMTestProject.macroPackageManifest + ) + try await SwiftPMTestProject.build(at: project.scratchDirectory) + return .featureSupported + } catch { + return .featureUnsupported( + skipMessage: """ + Skipping because macro could not be built using build artifacts in the sourcekit-lsp build directory. \ + This usually happens if sourcekit-lsp was built using a different toolchain than the one used at test-time. + """ + ) + } + } + } } // MARK: - Parsing Swift compiler version diff --git a/Sources/SKTestSupport/SwiftPMTestProject.swift b/Sources/SKTestSupport/SwiftPMTestProject.swift index 4861ef954..e6aef9824 100644 --- a/Sources/SKTestSupport/SwiftPMTestProject.swift +++ b/Sources/SKTestSupport/SwiftPMTestProject.swift @@ -16,6 +16,8 @@ import LanguageServerProtocol import SourceKitLSP import TSCBasic +private struct SwiftSyntaxCShimsModulemapNotFoundError: Error {} + public class SwiftPMTestProject: MultiFileTestProject { enum Error: Swift.Error { /// The `swift` executable could not be found. @@ -33,6 +35,114 @@ public class SwiftPMTestProject: MultiFileTestProject { ) """ + /// A manifest that defines two targets: + /// - A macro target named `MyMacro` + /// - And executable target named `MyMacroClient` + /// + /// It builds the macro using the swift-syntax that was already built as part of the SourceKit-LSP build. + /// Re-using the SwiftSyntax modules that are already built is significantly faster than building swift-syntax in + /// each test case run and does not require internet access. + public static var macroPackageManifest: String { + get async throws { + // Directories that we should search for the swift-syntax package. + // We prefer a checkout in the build folder. If that doesn't exist, we are probably using local dependencies + // (SWIFTCI_USE_LOCAL_DEPS), so search next to the sourcekit-lsp source repo + let swiftSyntaxSearchPaths = [ + productsDirectory + .deletingLastPathComponent() // arm64-apple-macosx + .deletingLastPathComponent() // debug + .appendingPathComponent("checkouts"), + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // SwiftPMTestProject.swift + .deletingLastPathComponent() // SKTestSupport + .deletingLastPathComponent() // Sources + .deletingLastPathComponent(), // sourcekit-lsp + ] + + let swiftSyntaxCShimsModulemap = + swiftSyntaxSearchPaths.map { swiftSyntaxSearchPath in + swiftSyntaxSearchPath + .appendingPathComponent("swift-syntax") + .appendingPathComponent("Sources") + .appendingPathComponent("_SwiftSyntaxCShims") + .appendingPathComponent("include") + .appendingPathComponent("module.modulemap") + } + .first { FileManager.default.fileExists(atPath: $0.path) } + + guard let swiftSyntaxCShimsModulemap else { + throw SwiftSyntaxCShimsModulemapNotFoundError() + } + + let swiftSyntaxModulesToLink = [ + "SwiftBasicFormat", + "SwiftCompilerPlugin", + "SwiftCompilerPluginMessageHandling", + "SwiftDiagnostics", + "SwiftOperators", + "SwiftParser", + "SwiftParserDiagnostics", + "SwiftSyntax", + "SwiftSyntaxBuilder", + "SwiftSyntaxMacroExpansion", + "SwiftSyntaxMacros", + ] + + var objectFiles: [String] = [] + for moduleName in swiftSyntaxModulesToLink { + let dir = productsDirectory.appendingPathComponent("\(moduleName).build") + let enumerator = FileManager.default.enumerator(at: dir, includingPropertiesForKeys: nil) + while let file = enumerator?.nextObject() as? URL { + if file.pathExtension == "o" { + objectFiles.append(file.path) + } + } + } + + let linkerFlags = objectFiles.map { + """ + "-l", "\($0)", + """ + }.joined(separator: "\n") + + let moduleSearchPath: String + if let toolchainVersion = try await ToolchainRegistry.forTesting.default?.swiftVersion, + toolchainVersion < SwiftVersion(6, 0) + { + moduleSearchPath = productsDirectory.path + } else { + moduleSearchPath = "\(productsDirectory.path)/Modules" + } + + return """ + // swift-tools-version: 5.10 + + import PackageDescription + import CompilerPluginSupport + + let package = Package( + name: "MyMacro", + platforms: [.macOS(.v10_15)], + targets: [ + .macro( + name: "MyMacros", + swiftSettings: [.unsafeFlags([ + "-I", "\(moduleSearchPath)", + "-Xcc", "-fmodule-map-file=\(swiftSyntaxCShimsModulemap.path)" + ])], + linkerSettings: [ + .unsafeFlags([ + \(linkerFlags) + ]) + ] + ), + .executableTarget(name: "MyMacroClient", dependencies: ["MyMacros"]), + ] + ) + """ + } + } + /// Create a new SwiftPM package with the given files. /// /// If `index` is `true`, then the package will be built, indexing all modules within the package. diff --git a/Sources/SKTestSupport/TestBundle.swift b/Sources/SKTestSupport/TestBundle.swift new file mode 100644 index 000000000..e8351cecd --- /dev/null +++ b/Sources/SKTestSupport/TestBundle.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +/// The bundle of the currently executing test. +public let testBundle: Bundle = { + #if os(macOS) + if let bundle = Bundle.allBundles.first(where: { $0.bundlePath.hasSuffix(".xctest") }) { + return bundle + } + fatalError("couldn't find the test bundle") + #else + return Bundle.main + #endif +}() + +/// The path to the built products directory, ie. `.build/debug/arm64-apple-macosx` or the platform-specific equivalent. +public let productsDirectory: URL = { + #if os(macOS) + return testBundle.bundleURL.deletingLastPathComponent() + #else + return testBundle.bundleURL + #endif +}() diff --git a/Tests/SKCoreTests/BuildServerBuildSystemTests.swift b/Tests/SKCoreTests/BuildServerBuildSystemTests.swift index 26d1d19a3..934dcffaf 100644 --- a/Tests/SKCoreTests/BuildServerBuildSystemTests.swift +++ b/Tests/SKCoreTests/BuildServerBuildSystemTests.swift @@ -20,27 +20,6 @@ import SKTestSupport import TSCBasic import XCTest -/// The bundle of the currently executing test. -private let testBundle: Bundle = { - #if os(macOS) - if let bundle = Bundle.allBundles.first(where: { $0.bundlePath.hasSuffix(".xctest") }) { - return bundle - } - fatalError("couldn't find the test bundle") - #else - return Bundle.main - #endif -}() - -/// The path to the built products directory. -private let productsDirectory: URL = { - #if os(macOS) - return testBundle.bundleURL.deletingLastPathComponent() - #else - return testBundle.bundleURL - #endif -}() - /// The path to the INPUTS directory of shared test projects. private let skTestSupportInputsDirectory: URL = { #if os(macOS) diff --git a/Tests/SKSwiftPMWorkspaceTests/SwiftPMBuildSystemTests.swift b/Tests/SKSwiftPMWorkspaceTests/SwiftPMBuildSystemTests.swift index 07fdcfe22..c1a426152 100644 --- a/Tests/SKSwiftPMWorkspaceTests/SwiftPMBuildSystemTests.swift +++ b/Tests/SKSwiftPMWorkspaceTests/SwiftPMBuildSystemTests.swift @@ -772,6 +772,53 @@ final class SwiftPMBuildSystemTests: XCTestCase { assertArgumentsContain(aswift.pathString, arguments: arguments) } } + + func testBuildMacro() async throws { + try await SkipUnless.canBuildMacroUsingSwiftSyntaxFromSourceKitLSPBuild() + // This test is just a dummy to show how to create a `SwiftPMTestProject` that builds a macro using the SwiftSyntax + // modules that were already built during the build of SourceKit-LSP. + // It should be removed once we have a real test that tests macros (like macro expansion). + let project = try await SwiftPMTestProject( + files: [ + "MyMacros/MyMacros.swift": #""" + import SwiftCompilerPlugin + import SwiftSyntax + import SwiftSyntaxBuilder + import SwiftSyntaxMacros + + public struct StringifyMacro: ExpressionMacro { + public static func expansion( + of node: some FreestandingMacroExpansionSyntax, + in context: some MacroExpansionContext + ) -> ExprSyntax { + guard let argument = node.argumentList.first?.expression else { + fatalError("compiler bug: the macro does not have any arguments") + } + + return "(\(argument), \(literal: argument.description))" + } + } + + @main + struct MyMacroPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + StringifyMacro.self, + ] + } + """#, + "MyMacroClient/MyMacroClient.swift": """ + @freestanding(expression) + public macro stringify(_ value: T) -> (T, String) = #externalMacro(module: "MyMacros", type: "StringifyMacro") + + func test() { + #stringify(1 + 2) + } + """, + ], + manifest: SwiftPMTestProject.macroPackageManifest + ) + try await SwiftPMTestProject.build(at: project.scratchDirectory) + } } private func assertArgumentsDoNotContain(