Skip to content

Commit 037a616

Browse files
authored
Allow unit test targets to import and link executable targets (#3316)
* Add a fast way to look up the package in which a particular target or product is defined. * Allow SwiftTargetDescription, ClangTargetDescription, and ProductDescription to know the tools version of the package in which they are defined. This allows compiler flags and other semantically significant changes to be conditionalized on the tools version. In the cases where tools versions are synthesized, they get the tools version of the package defining the product or target for which they are being synthesized. The fallback for anything that cannot be determined at all is always `.vNext`, which is the same as has been the case until now. * Allow unit tests to import and link any main modules of executables that are implemented in Swift. This uses a new Swift compiler flag to set the name of the entry point when emitting object code, and then uses linker flags to rename the main executable module's entry point back to `_main` again when actually linking the executable. This is guarded by a tools version check, since packages written this way won't be testable on older toolchains. Also, this is currently only done on Darwin and Linux. A supplemental PR will generate a small stub containing code that implements `main` to call the per-module `<module>_main` function, which will be linked into the executable.
1 parent 4ba446b commit 037a616

File tree

13 files changed

+293
-33
lines changed

13 files changed

+293
-33
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Swift v.Next
1111
* Improvements
1212

1313
Adding a dependency requirement can now be done with the convenience initializer `.package(url: String, branch: String)`.
14+
15+
Test targets can now link against executable targets as if they were libraries, so that they can test any data strutures or algorithms in them. All the code in the executable except for the main entry point itself is available to the unit test. Separate executables are still linked, and can be tested as a subprocess in the same way as before. This feature is available to tests defined in packages that have a tools version of `vNext` or newer.
1416

1517

1618

Fixtures/Miscellaneous/ExeTest/Package.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
// swift-tools-version:5.3
1+
// swift-tools-version: 999.0
22
import PackageDescription
33

44
let package = Package(
55
name: "ExeTest",
66
targets: [
7-
.target(
7+
.executableTarget(
88
name: "Exe",
99
dependencies: []
1010
),
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// swift-tools-version: 999.0
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "TestableExe",
6+
targets: [
7+
.target(
8+
name: "TestableExe1"
9+
),
10+
.target(
11+
name: "TestableExe2"
12+
),
13+
.target(
14+
name: "TestableExe3"
15+
),
16+
.testTarget(
17+
name: "TestableExeTests",
18+
dependencies: [
19+
"TestableExe1",
20+
"TestableExe2",
21+
"TestableExe3",
22+
]
23+
),
24+
]
25+
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
public func GetGreeting1() -> String {
2+
return "Hello, world"
3+
}
4+
5+
print("\(GetGreeting1())!")
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
public func GetGreeting2() -> String {
2+
return "Hello, planet"
3+
}
4+
5+
print("\(GetGreeting2())!")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
const char * GetGreeting3();
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#include <stdio.h>
2+
#include "include/TestableExe3.h"
3+
4+
const char * GetGreeting3() {
5+
return "Hello, universe";
6+
}
7+
8+
int main() {
9+
printf("%s!\n", GetGreeting3());
10+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import XCTest
2+
import TestableExe1
3+
import TestableExe2
4+
// import TestableExe3
5+
import class Foundation.Bundle
6+
7+
final class TestableExeTests: XCTestCase {
8+
func testExample() throws {
9+
// This is an example of a functional test case.
10+
// Use XCTAssert and related functions to verify your tests produce the correct
11+
// results.
12+
13+
print(GetGreeting1())
14+
XCTAssertEqual(GetGreeting1(), "Hello, world")
15+
print(GetGreeting2())
16+
XCTAssertEqual(GetGreeting2(), "Hello, planet")
17+
// XCTAssertEqual(String(cString: GetGreeting3()), "Hello, universe")
18+
19+
// Some of the APIs that we use below are available in macOS 10.13 and above.
20+
guard #available(macOS 10.13, *) else {
21+
return
22+
}
23+
24+
var execPath = productsDirectory.appendingPathComponent("TestableExe1")
25+
var process = Process()
26+
process.executableURL = execPath
27+
var pipe = Pipe()
28+
process.standardOutput = pipe
29+
try process.run()
30+
process.waitUntilExit()
31+
var data = pipe.fileHandleForReading.readDataToEndOfFile()
32+
var output = String(data: data, encoding: .utf8)
33+
XCTAssertEqual(output, "Hello, world!\n")
34+
35+
execPath = productsDirectory.appendingPathComponent("TestableExe2")
36+
process = Process()
37+
process.executableURL = execPath
38+
pipe = Pipe()
39+
process.standardOutput = pipe
40+
try process.run()
41+
process.waitUntilExit()
42+
data = pipe.fileHandleForReading.readDataToEndOfFile()
43+
output = String(data: data, encoding: .utf8)
44+
XCTAssertEqual(output, "Hello, planet!\n")
45+
46+
execPath = productsDirectory.appendingPathComponent("TestableExe3")
47+
process = Process()
48+
process.executableURL = execPath
49+
pipe = Pipe()
50+
process.standardOutput = pipe
51+
try process.run()
52+
process.waitUntilExit()
53+
data = pipe.fileHandleForReading.readDataToEndOfFile()
54+
output = String(data: data, encoding: .utf8)
55+
XCTAssertEqual(output, "Hello, universe!\n")
56+
}
57+
58+
/// Returns path to the built products directory.
59+
var productsDirectory: URL {
60+
#if os(macOS)
61+
for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") {
62+
return bundle.bundleURL.deletingLastPathComponent()
63+
}
64+
fatalError("couldn't find the products directory")
65+
#else
66+
return Bundle.main.bundleURL
67+
#endif
68+
}
69+
70+
static var allTests = [
71+
("testExample", testExample),
72+
]
73+
}

Sources/Build/BuildPlan.swift

Lines changed: 100 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,18 @@ extension BuildParameters {
121121
return args
122122
}
123123

124+
/// Computes the linker flags to use in order to rename a module-named main function to 'main' for the target platform, or nil if the linker doesn't support it for the platform.
125+
fileprivate func linkerFlagsForRenamingMainFunction(of target: ResolvedTarget) -> [String]? {
126+
var args: [String] = []
127+
if self.triple.isDarwin() {
128+
args = ["-alias", "_\(target.c99name)_main", "_main"]
129+
}
130+
else if self.triple.isLinux() {
131+
args = ["--defsym", "main=\(target.c99name)_main"]
132+
}
133+
return args.flatMap { ["-Xlinker", $0] }
134+
}
135+
124136
/// Returns the scoped view of build settings for a given target.
125137
fileprivate func createScope(for target: ResolvedTarget) -> BuildSettings.Scope {
126138
return BuildSettings.Scope(target.underlyingTarget.buildSettings, environment: buildEnvironment)
@@ -195,6 +207,11 @@ public final class ClangTargetBuildDescription {
195207
public var clangTarget: ClangTarget {
196208
return target.underlyingTarget as! ClangTarget
197209
}
210+
211+
/// The tools version of the package that declared the target. This can
212+
/// can be used to conditionalize semantically significant changes in how
213+
/// a target is built.
214+
public let toolsVersion: ToolsVersion
198215

199216
/// The build parameters.
200217
let buildParameters: BuildParameters
@@ -249,11 +266,12 @@ public final class ClangTargetBuildDescription {
249266
}
250267

251268
/// Create a new target description with target and build parameters.
252-
init(target: ResolvedTarget, buildParameters: BuildParameters, fileSystem: FileSystem = localFileSystem, diagnostics: DiagnosticsEngine) throws {
269+
init(target: ResolvedTarget, toolsVersion: ToolsVersion, buildParameters: BuildParameters, fileSystem: FileSystem = localFileSystem, diagnostics: DiagnosticsEngine) throws {
253270
assert(target.underlyingTarget is ClangTarget, "underlying target type mismatch \(target)")
254271
self.fileSystem = fileSystem
255272
self.diagnostics = diagnostics
256273
self.target = target
274+
self.toolsVersion = toolsVersion
257275
self.buildParameters = buildParameters
258276
self.tempsPath = buildParameters.buildPath.appending(component: target.c99name + ".build")
259277
self.derivedSources = Sources(paths: [], root: tempsPath.appending(component: "DerivedSources"))
@@ -472,6 +490,11 @@ public final class SwiftTargetBuildDescription {
472490
/// The target described by this target.
473491
public let target: ResolvedTarget
474492

493+
/// The tools version of the package that declared the target. This can
494+
/// can be used to conditionalize semantically significant changes in how
495+
/// a target is built.
496+
public let toolsVersion: ToolsVersion
497+
475498
/// The build parameters.
476499
let buildParameters: BuildParameters
477500

@@ -504,7 +527,9 @@ public final class SwiftTargetBuildDescription {
504527

505528
/// The path to the swiftmodule file after compilation.
506529
var moduleOutputPath: AbsolutePath {
507-
let dirPath = (target.type == .executable) ? tempsPath : buildParameters.buildPath
530+
// If we're an executable and we're not allowing test targets to link against us, we hide the module.
531+
let allowLinkingAgainstExecutables = (buildParameters.triple.isDarwin() || buildParameters.triple.isLinux()) && toolsVersion >= .vNext
532+
let dirPath = (target.type == .executable && !allowLinkingAgainstExecutables) ? tempsPath : buildParameters.buildPath
508533
return dirPath.appending(component: target.c99name + ".swiftmodule")
509534
}
510535

@@ -555,6 +580,7 @@ public final class SwiftTargetBuildDescription {
555580
/// Create a new target description with target and build parameters.
556581
init(
557582
target: ResolvedTarget,
583+
toolsVersion: ToolsVersion,
558584
buildParameters: BuildParameters,
559585
pluginInvocationResults: [PluginInvocationResult] = [],
560586
prebuildCommandResults: [PrebuildCommandResult] = [],
@@ -564,6 +590,7 @@ public final class SwiftTargetBuildDescription {
564590
) throws {
565591
assert(target.underlyingTarget is SwiftTarget, "underlying target type mismatch \(target)")
566592
self.target = target
593+
self.toolsVersion = toolsVersion
567594
self.buildParameters = buildParameters
568595
// Unless mentioned explicitly, use the target type to determine if this is a test target.
569596
self.isTestTarget = isTestTarget ?? (target.type == .test)
@@ -677,6 +704,24 @@ public final class SwiftTargetBuildDescription {
677704
args += buildParameters.sanitizers.compileSwiftFlags()
678705
args += ["-parseable-output"]
679706

707+
// If we're compiling the main module of an executable other than the one that
708+
// implements a test suite, and if the package tools version indicates that we
709+
// should, we rename the `_main` entry point to `_<modulename>_main`.
710+
//
711+
// This will allow tests to link against the module without any conflicts. And
712+
// when we link the executable, we will ask the linker to rename the entry point
713+
// symbol to just `_main` again (or if the linker doesn't support it, we'll
714+
// generate a source containing a redirect).
715+
if target.type == .executable && !isTestTarget && toolsVersion >= .vNext {
716+
// We only do this if the linker supports it, as indicated by whether we
717+
// can construct the linker flags. In the future we will use a generated
718+
// code stub for the cases in which the linker doesn't support it, so that
719+
// we can rename the symbol unconditionally.
720+
if buildParameters.linkerFlagsForRenamingMainFunction(of: target) != nil {
721+
args += ["-Xfrontend", "-entry-point-function-name", "-Xfrontend", "\(target.c99name)_main"]
722+
}
723+
}
724+
680725
// Only add the build path to the framework search path if there are binary frameworks to link against.
681726
if !libraryBinaryPaths.isEmpty {
682727
args += ["-F", buildParameters.buildPath.pathString]
@@ -1018,6 +1063,11 @@ public final class ProductBuildDescription {
10181063
/// The reference to the product.
10191064
public let product: ResolvedProduct
10201065

1066+
/// The tools version of the package that declared the product. This can
1067+
/// can be used to conditionalize semantically significant changes in how
1068+
/// a target is built.
1069+
public let toolsVersion: ToolsVersion
1070+
10211071
/// The build parameters.
10221072
let buildParameters: BuildParameters
10231073

@@ -1029,7 +1079,7 @@ public final class ProductBuildDescription {
10291079
return buildParameters.binaryPath(for: product)
10301080
}
10311081

1032-
/// The objects in this product.
1082+
/// All object files to link into this product.
10331083
///
10341084
// Computed during build planning.
10351085
public fileprivate(set) var objects = SortedArray<AbsolutePath>()
@@ -1067,9 +1117,10 @@ public final class ProductBuildDescription {
10671117
let diagnostics: DiagnosticsEngine
10681118

10691119
/// Create a build description for a product.
1070-
init(product: ResolvedProduct, buildParameters: BuildParameters, fs: FileSystem, diagnostics: DiagnosticsEngine) {
1120+
init(product: ResolvedProduct, toolsVersion: ToolsVersion, buildParameters: BuildParameters, fs: FileSystem, diagnostics: DiagnosticsEngine) {
10711121
assert(product.type != .library(.automatic), "Automatic type libraries should not be described.")
10721122
self.product = product
1123+
self.toolsVersion = toolsVersion
10731124
self.buildParameters = buildParameters
10741125
self.fs = fs
10751126
self.diagnostics = diagnostics
@@ -1148,6 +1199,20 @@ public final class ProductBuildDescription {
11481199
}
11491200
}
11501201
args += ["-emit-executable"]
1202+
1203+
// If we're linking an executable whose main module is implemented in Swift,
1204+
// we rename the `_<modulename>_main` entry point symbol to `_main` again.
1205+
// This is because executable modules implemented in Swift are compiled with
1206+
// a main symbol named that way to allow tests to link against it without
1207+
// conflicts. If we're using a linker that doesn't support symbol renaming,
1208+
// we will instead have generated a source file containing the redirect.
1209+
// Support for linking tests againsts executables is conditional on the tools
1210+
// version of the package that defines the executable product.
1211+
if product.executableModule.underlyingTarget is SwiftTarget, toolsVersion >= .vNext {
1212+
if let flags = buildParameters.linkerFlagsForRenamingMainFunction(of: product.executableModule) {
1213+
args += flags
1214+
}
1215+
}
11511216
case .plugin:
11521217
throw InternalError("unexpectedly asked to generate linker arguments for a plugin product")
11531218
}
@@ -1327,9 +1392,11 @@ public class BuildPlan {
13271392
// if test manifest exists, prefer that over test detection,
13281393
// this is designed as an escape hatch when test discovery is not appropriate
13291394
// and for backwards compatibility for projects that have existing test manifests (LinuxMain.swift)
1395+
let toolsVersion = graph.package(for: testProduct)?.manifest.toolsVersion ?? .vNext
13301396
if let testManifestTarget = testProduct.testManifestTarget, !generate {
13311397
let desc = try SwiftTargetBuildDescription(
13321398
target: testManifestTarget,
1399+
toolsVersion: toolsVersion,
13331400
buildParameters: buildParameters,
13341401
isTestTarget: true
13351402
)
@@ -1361,6 +1428,7 @@ public class BuildPlan {
13611428

13621429
let target = try SwiftTargetBuildDescription(
13631430
target: testManifestTarget,
1431+
toolsVersion: toolsVersion,
13641432
buildParameters: buildParameters,
13651433
isTestTarget: true,
13661434
testDiscoveryTarget: true
@@ -1403,18 +1471,24 @@ public class BuildPlan {
14031471
}
14041472
}
14051473
}
1474+
1475+
// Determine the appropriate tools version to use for the target.
1476+
// This can affect what flags to pass and other semantics.
1477+
let toolsVersion = graph.package(for: target)?.manifest.toolsVersion ?? .vNext
14061478

14071479
switch target.underlyingTarget {
14081480
case is SwiftTarget:
14091481
targetMap[target] = try .swift(SwiftTargetBuildDescription(
14101482
target: target,
1483+
toolsVersion: toolsVersion,
14111484
buildParameters: buildParameters,
14121485
pluginInvocationResults: pluginInvocationResults[target] ?? [],
14131486
prebuildCommandResults: prebuildCommandResults[target] ?? [],
14141487
fs: fileSystem))
14151488
case is ClangTarget:
14161489
targetMap[target] = try .clang(ClangTargetBuildDescription(
14171490
target: target,
1491+
toolsVersion: toolsVersion,
14181492
buildParameters: buildParameters,
14191493
fileSystem: fileSystem,
14201494
diagnostics: diagnostics))
@@ -1448,8 +1522,14 @@ public class BuildPlan {
14481522
// Create product description for each product we have in the package graph except
14491523
// for automatic libraries and plugins, because they don't produce any output.
14501524
for product in graph.allProducts where product.type != .library(.automatic) && product.type != .plugin {
1525+
1526+
// Determine the appropriate tools version to use for the product.
1527+
// This can affect what flags to pass and other semantics.
1528+
let toolsVersion = graph.package(for: product)?.manifest.toolsVersion ?? .vNext
14511529
productMap[product] = ProductBuildDescription(
1452-
product: product, buildParameters: buildParameters,
1530+
product: product,
1531+
toolsVersion: toolsVersion,
1532+
buildParameters: buildParameters,
14531533
fs: fileSystem,
14541534
diagnostics: diagnostics
14551535
)
@@ -1635,9 +1715,21 @@ public class BuildPlan {
16351715
switch dependency {
16361716
case .target(let target, _):
16371717
switch target.type {
1638-
// Include executable and tests only if they're top level contents
1639-
// of the product. Otherwise they are just build time dependency.
1640-
case .executable, .test:
1718+
// Executable target have historically only been included if they are directly in the product's
1719+
// target list. Otherwise they have always been just build-time dependencies.
1720+
// In tool version .vNext or greater, we also include executable modules implemented in Swift in
1721+
// any test products... this is to allow testing of executables. Note that they are also still
1722+
// built as separate products that the test can invoke as subprocesses.
1723+
case .executable:
1724+
if product.targets.contains(target) {
1725+
staticTargets.append(target)
1726+
} else if product.type == .test && target.underlyingTarget is SwiftTarget {
1727+
if let toolsVersion = graph.package(for: product)?.manifest.toolsVersion, toolsVersion >= .vNext {
1728+
staticTargets.append(target)
1729+
}
1730+
}
1731+
// Test targets should be included only if they are directly in the product's target list.
1732+
case .test:
16411733
if product.targets.contains(target) {
16421734
staticTargets.append(target)
16431735
}

0 commit comments

Comments
 (0)