Skip to content

Commit 98718d4

Browse files
authored
Merge pull request #1367 from ahoppen/macro-test-project
Add infrastructure to write tests for Swift macros
2 parents d6a72ef + 6599292 commit 98718d4

File tree

6 files changed

+251
-32
lines changed

6 files changed

+251
-32
lines changed

Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,10 @@ let package = Package(
381381
.product(name: "SwiftParser", package: "swift-syntax"),
382382
.product(name: "SwiftSyntax", package: "swift-syntax"),
383383
.product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"),
384+
// Depend on `SwiftCompilerPlugin` and `SwiftSyntaxMacros` so the modules are built before running tests and can
385+
// be used by test cases that test macros (see `SwiftPMTestProject.macroPackageManifest`).
386+
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
387+
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
384388
]
385389
),
386390
]

Sources/SKTestSupport/SkipUnless.swift

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,17 +58,11 @@ public actor SkipUnless {
5858
line: UInt,
5959
featureCheck: () async throws -> Bool
6060
) async throws {
61-
let checkResult: FeatureCheckResult
62-
if let cachedResult = checkCache[featureName] {
63-
checkResult = cachedResult
64-
} else if ProcessEnv.block["SWIFTCI_USE_LOCAL_DEPS"] != nil {
65-
// Never skip tests in CI. Toolchain should be up-to-date
66-
checkResult = .featureSupported
67-
} else {
61+
return try await skipUnlessSupported(featureName: featureName, file: file, line: line) {
6862
let toolchainSwiftVersion = try await unwrap(ToolchainRegistry.forTesting.default).swiftVersion
6963
let requiredSwiftVersion = SwiftVersion(swiftVersion.major, swiftVersion.minor)
7064
if toolchainSwiftVersion < requiredSwiftVersion {
71-
checkResult = .featureUnsupported(
65+
return .featureUnsupported(
7266
skipMessage: """
7367
Skipping because toolchain has Swift version \(toolchainSwiftVersion) \
7468
but test requires at least \(requiredSwiftVersion)
@@ -77,15 +71,32 @@ public actor SkipUnless {
7771
} else if toolchainSwiftVersion == requiredSwiftVersion {
7872
logger.info("Checking if feature '\(featureName)' is supported")
7973
if try await !featureCheck() {
80-
checkResult = .featureUnsupported(skipMessage: "Skipping because toolchain doesn't contain \(featureName)")
74+
return .featureUnsupported(skipMessage: "Skipping because toolchain doesn't contain \(featureName)")
8175
} else {
82-
checkResult = .featureSupported
76+
return .featureSupported
8377
}
8478
logger.info("Done checking if feature '\(featureName)' is supported")
8579
} else {
86-
checkResult = .featureSupported
80+
return .featureSupported
8781
}
8882
}
83+
}
84+
85+
private func skipUnlessSupported(
86+
featureName: String = #function,
87+
file: StaticString,
88+
line: UInt,
89+
featureCheck: () async throws -> FeatureCheckResult
90+
) async throws {
91+
let checkResult: FeatureCheckResult
92+
if let cachedResult = checkCache[featureName] {
93+
checkResult = cachedResult
94+
} else if ProcessEnv.block["SWIFTCI_USE_LOCAL_DEPS"] != nil {
95+
// Never skip tests in CI. Toolchain should be up-to-date
96+
checkResult = .featureSupported
97+
} else {
98+
checkResult = try await featureCheck()
99+
}
89100
checkCache[featureName] = checkResult
90101

91102
if case .featureUnsupported(let skipMessage) = checkResult {
@@ -272,6 +283,40 @@ public actor SkipUnless {
272283
}
273284
#endif
274285
}
286+
287+
/// Check if we can use the build artifacts in the sourcekit-lsp build directory to build a macro package without
288+
/// re-building swift-syntax.
289+
public static func canBuildMacroUsingSwiftSyntaxFromSourceKitLSPBuild(
290+
file: StaticString = #filePath,
291+
line: UInt = #line
292+
) async throws {
293+
return try await shared.skipUnlessSupported(file: file, line: line) {
294+
do {
295+
let project = try await SwiftPMTestProject(
296+
files: [
297+
"MyMacros/MyMacros.swift": #"""
298+
import SwiftCompilerPlugin
299+
import SwiftSyntax
300+
import SwiftSyntaxBuilder
301+
import SwiftSyntaxMacros
302+
"""#,
303+
"MyMacroClient/MyMacroClient.swift": """
304+
""",
305+
],
306+
manifest: SwiftPMTestProject.macroPackageManifest
307+
)
308+
try await SwiftPMTestProject.build(at: project.scratchDirectory)
309+
return .featureSupported
310+
} catch {
311+
return .featureUnsupported(
312+
skipMessage: """
313+
Skipping because macro could not be built using build artifacts in the sourcekit-lsp build directory. \
314+
This usually happens if sourcekit-lsp was built using a different toolchain than the one used at test-time.
315+
"""
316+
)
317+
}
318+
}
319+
}
275320
}
276321

277322
// MARK: - Parsing Swift compiler version

Sources/SKTestSupport/SwiftPMTestProject.swift

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import LanguageServerProtocol
1616
import SourceKitLSP
1717
import TSCBasic
1818

19+
private struct SwiftSyntaxCShimsModulemapNotFoundError: Error {}
20+
1921
public class SwiftPMTestProject: MultiFileTestProject {
2022
enum Error: Swift.Error {
2123
/// The `swift` executable could not be found.
@@ -33,6 +35,114 @@ public class SwiftPMTestProject: MultiFileTestProject {
3335
)
3436
"""
3537

38+
/// A manifest that defines two targets:
39+
/// - A macro target named `MyMacro`
40+
/// - And executable target named `MyMacroClient`
41+
///
42+
/// It builds the macro using the swift-syntax that was already built as part of the SourceKit-LSP build.
43+
/// Re-using the SwiftSyntax modules that are already built is significantly faster than building swift-syntax in
44+
/// each test case run and does not require internet access.
45+
public static var macroPackageManifest: String {
46+
get async throws {
47+
// Directories that we should search for the swift-syntax package.
48+
// We prefer a checkout in the build folder. If that doesn't exist, we are probably using local dependencies
49+
// (SWIFTCI_USE_LOCAL_DEPS), so search next to the sourcekit-lsp source repo
50+
let swiftSyntaxSearchPaths = [
51+
productsDirectory
52+
.deletingLastPathComponent() // arm64-apple-macosx
53+
.deletingLastPathComponent() // debug
54+
.appendingPathComponent("checkouts"),
55+
URL(fileURLWithPath: #filePath)
56+
.deletingLastPathComponent() // SwiftPMTestProject.swift
57+
.deletingLastPathComponent() // SKTestSupport
58+
.deletingLastPathComponent() // Sources
59+
.deletingLastPathComponent(), // sourcekit-lsp
60+
]
61+
62+
let swiftSyntaxCShimsModulemap =
63+
swiftSyntaxSearchPaths.map { swiftSyntaxSearchPath in
64+
swiftSyntaxSearchPath
65+
.appendingPathComponent("swift-syntax")
66+
.appendingPathComponent("Sources")
67+
.appendingPathComponent("_SwiftSyntaxCShims")
68+
.appendingPathComponent("include")
69+
.appendingPathComponent("module.modulemap")
70+
}
71+
.first { FileManager.default.fileExists(atPath: $0.path) }
72+
73+
guard let swiftSyntaxCShimsModulemap else {
74+
throw SwiftSyntaxCShimsModulemapNotFoundError()
75+
}
76+
77+
let swiftSyntaxModulesToLink = [
78+
"SwiftBasicFormat",
79+
"SwiftCompilerPlugin",
80+
"SwiftCompilerPluginMessageHandling",
81+
"SwiftDiagnostics",
82+
"SwiftOperators",
83+
"SwiftParser",
84+
"SwiftParserDiagnostics",
85+
"SwiftSyntax",
86+
"SwiftSyntaxBuilder",
87+
"SwiftSyntaxMacroExpansion",
88+
"SwiftSyntaxMacros",
89+
]
90+
91+
var objectFiles: [String] = []
92+
for moduleName in swiftSyntaxModulesToLink {
93+
let dir = productsDirectory.appendingPathComponent("\(moduleName).build")
94+
let enumerator = FileManager.default.enumerator(at: dir, includingPropertiesForKeys: nil)
95+
while let file = enumerator?.nextObject() as? URL {
96+
if file.pathExtension == "o" {
97+
objectFiles.append(file.path)
98+
}
99+
}
100+
}
101+
102+
let linkerFlags = objectFiles.map {
103+
"""
104+
"-l", "\($0)",
105+
"""
106+
}.joined(separator: "\n")
107+
108+
let moduleSearchPath: String
109+
if let toolchainVersion = try await ToolchainRegistry.forTesting.default?.swiftVersion,
110+
toolchainVersion < SwiftVersion(6, 0)
111+
{
112+
moduleSearchPath = productsDirectory.path
113+
} else {
114+
moduleSearchPath = "\(productsDirectory.path)/Modules"
115+
}
116+
117+
return """
118+
// swift-tools-version: 5.10
119+
120+
import PackageDescription
121+
import CompilerPluginSupport
122+
123+
let package = Package(
124+
name: "MyMacro",
125+
platforms: [.macOS(.v10_15)],
126+
targets: [
127+
.macro(
128+
name: "MyMacros",
129+
swiftSettings: [.unsafeFlags([
130+
"-I", "\(moduleSearchPath)",
131+
"-Xcc", "-fmodule-map-file=\(swiftSyntaxCShimsModulemap.path)"
132+
])],
133+
linkerSettings: [
134+
.unsafeFlags([
135+
\(linkerFlags)
136+
])
137+
]
138+
),
139+
.executableTarget(name: "MyMacroClient", dependencies: ["MyMacros"]),
140+
]
141+
)
142+
"""
143+
}
144+
}
145+
36146
/// Create a new SwiftPM package with the given files.
37147
///
38148
/// If `index` is `true`, then the package will be built, indexing all modules within the package.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import Foundation
14+
15+
/// The bundle of the currently executing test.
16+
public let testBundle: Bundle = {
17+
#if os(macOS)
18+
if let bundle = Bundle.allBundles.first(where: { $0.bundlePath.hasSuffix(".xctest") }) {
19+
return bundle
20+
}
21+
fatalError("couldn't find the test bundle")
22+
#else
23+
return Bundle.main
24+
#endif
25+
}()
26+
27+
/// The path to the built products directory, ie. `.build/debug/arm64-apple-macosx` or the platform-specific equivalent.
28+
public let productsDirectory: URL = {
29+
#if os(macOS)
30+
return testBundle.bundleURL.deletingLastPathComponent()
31+
#else
32+
return testBundle.bundleURL
33+
#endif
34+
}()

Tests/SKCoreTests/BuildServerBuildSystemTests.swift

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,27 +20,6 @@ import SKTestSupport
2020
import TSCBasic
2121
import XCTest
2222

23-
/// The bundle of the currently executing test.
24-
private let testBundle: Bundle = {
25-
#if os(macOS)
26-
if let bundle = Bundle.allBundles.first(where: { $0.bundlePath.hasSuffix(".xctest") }) {
27-
return bundle
28-
}
29-
fatalError("couldn't find the test bundle")
30-
#else
31-
return Bundle.main
32-
#endif
33-
}()
34-
35-
/// The path to the built products directory.
36-
private let productsDirectory: URL = {
37-
#if os(macOS)
38-
return testBundle.bundleURL.deletingLastPathComponent()
39-
#else
40-
return testBundle.bundleURL
41-
#endif
42-
}()
43-
4423
/// The path to the INPUTS directory of shared test projects.
4524
private let skTestSupportInputsDirectory: URL = {
4625
#if os(macOS)

Tests/SKSwiftPMWorkspaceTests/SwiftPMBuildSystemTests.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,53 @@ final class SwiftPMBuildSystemTests: XCTestCase {
772772
assertArgumentsContain(aswift.pathString, arguments: arguments)
773773
}
774774
}
775+
776+
func testBuildMacro() async throws {
777+
try await SkipUnless.canBuildMacroUsingSwiftSyntaxFromSourceKitLSPBuild()
778+
// This test is just a dummy to show how to create a `SwiftPMTestProject` that builds a macro using the SwiftSyntax
779+
// modules that were already built during the build of SourceKit-LSP.
780+
// It should be removed once we have a real test that tests macros (like macro expansion).
781+
let project = try await SwiftPMTestProject(
782+
files: [
783+
"MyMacros/MyMacros.swift": #"""
784+
import SwiftCompilerPlugin
785+
import SwiftSyntax
786+
import SwiftSyntaxBuilder
787+
import SwiftSyntaxMacros
788+
789+
public struct StringifyMacro: ExpressionMacro {
790+
public static func expansion(
791+
of node: some FreestandingMacroExpansionSyntax,
792+
in context: some MacroExpansionContext
793+
) -> ExprSyntax {
794+
guard let argument = node.argumentList.first?.expression else {
795+
fatalError("compiler bug: the macro does not have any arguments")
796+
}
797+
798+
return "(\(argument), \(literal: argument.description))"
799+
}
800+
}
801+
802+
@main
803+
struct MyMacroPlugin: CompilerPlugin {
804+
let providingMacros: [Macro.Type] = [
805+
StringifyMacro.self,
806+
]
807+
}
808+
"""#,
809+
"MyMacroClient/MyMacroClient.swift": """
810+
@freestanding(expression)
811+
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "MyMacros", type: "StringifyMacro")
812+
813+
func test() {
814+
#stringify(1 + 2)
815+
}
816+
""",
817+
],
818+
manifest: SwiftPMTestProject.macroPackageManifest
819+
)
820+
try await SwiftPMTestProject.build(at: project.scratchDirectory)
821+
}
775822
}
776823

777824
private func assertArgumentsDoNotContain(

0 commit comments

Comments
 (0)