Skip to content

Commit 62b4086

Browse files
committed
Generalize Linkage Testing Infrastructure
Allow for linkage conditions based on the language version and build configuration.
1 parent b22c64e commit 62b4086

File tree

1 file changed

+274
-87
lines changed

1 file changed

+274
-87
lines changed

Tests/SwiftParserTest/Linkage.swift

Lines changed: 274 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,150 @@ final class LinkageTest: XCTestCase {
99
// a SwiftSyntax maintainer to see if there's a way to avoid adding the
1010
// dependency.
1111
func testLinkage() throws {
12+
guard let baseURL = try self.findEnclosingTestBundle() else {
13+
XCTFail("Unable to determine path to enclosing xctest bundle")
14+
return
15+
}
16+
17+
try assertLinkage(of: "SwiftSyntax", in: baseURL, assertions: [
18+
.library("-lobjc"),
19+
.library("-lswiftCompatibility51", condition: .mayBeAbsent("Starting in Xcode 14 this library is not always autolinked")),
20+
.library("-lswiftCompatibilityConcurrency"),
21+
.library("-lswiftCore"),
22+
.library("-lswiftDarwin"),
23+
.library("-lswiftSwiftOnoneSupport", condition: .when(configuration: .debug)),
24+
.library("-lswift_Concurrency"),
25+
.library("-lswift_StringProcessing", condition: .when(swiftVersionAtLeast: .v5_7)),
26+
])
27+
28+
try assertLinkage(of: "SwiftParser", in: baseURL, assertions: [
29+
.library("-lobjc"),
30+
.library("-lswiftCompatibility51", condition: .mayBeAbsent("Starting in Xcode 14 this library is not always autolinked")),
31+
.library("-lswiftCompatibilityConcurrency"),
32+
.library("-lswiftCore"),
33+
.library("-lswiftDarwin"),
34+
.library("-lswiftSwiftOnoneSupport", condition: .when(configuration: .debug)),
35+
.library("-lswift_Concurrency"),
36+
.library("-lswift_StringProcessing", condition: .when(swiftVersionAtLeast: .v5_7)),
37+
])
38+
}
39+
}
40+
41+
extension LinkageTest {
42+
public struct MachHeader {
43+
var magic: UInt32
44+
var cputype: UInt32
45+
var cpusubtype: UInt32
46+
var filetype: UInt32
47+
var ncmds: UInt32
48+
var sizeofcmds: UInt32
49+
var flags: UInt32
50+
var reserved: UInt32
51+
52+
public struct LoadCommand: OptionSet {
53+
public var rawValue: UInt32
54+
55+
public init(rawValue: UInt32) {
56+
self.rawValue = rawValue
57+
}
58+
59+
/// A load command that defines a list of linker options strings embedded
60+
/// directly into this file.
61+
public static let linkerOption = Self(rawValue: 0x2D)
62+
}
63+
}
64+
65+
struct LoadCommand {
66+
var cmd: MachHeader.LoadCommand
67+
var cmdsize: UInt32
68+
}
69+
70+
struct LinkerOptionCommand {
71+
var cmd: MachHeader.LoadCommand
72+
var cmdsize: UInt32
73+
var count: UInt32
74+
/* concatenation of zero terminated UTF8 strings.
75+
Zero filled at end to align */
76+
}
77+
}
78+
79+
extension LinkageTest {
80+
private func assertLinkage(
81+
of library: String,
82+
in bundle: EnclosingTestBundle,
83+
assertions: [Linkage.Assertion]
84+
) throws {
85+
let linkages = try bundle.objectFiles(for: library).reduce(into: []) { acc, next in
86+
acc += try self.extractAutolinkingHints(in: next)
87+
}
88+
89+
let sortedLinkages = Set(linkages).sorted()
90+
var expectedLinkagesIdx = sortedLinkages.startIndex
91+
var assertions = assertions.makeIterator()
92+
while let assert = assertions.next() {
93+
// Evaluate the condition first, if any.
94+
if let condition = assert.condition, !condition.evaluate() {
95+
continue
96+
}
97+
98+
// Make sure there's a linkage to even assert against. If not, then we've
99+
// got too many assertions and not enough link libraries. This isn't
100+
// always a bad thing, but it's worth calling out so we document this
101+
// with `.mayBeAbsent(...)`.
102+
guard expectedLinkagesIdx < sortedLinkages.endIndex else {
103+
XCTFail("Expected linkage was not present: \(assert.linkage)",
104+
file: assert.file, line: assert.line)
105+
continue
106+
}
107+
108+
let linkage = sortedLinkages[expectedLinkagesIdx]
109+
110+
// Check the linkage assertion. If it doesn't hold, the real fun begins.
111+
guard !assert.matches(linkage) else {
112+
expectedLinkagesIdx += 1
113+
continue
114+
}
115+
116+
// Skip flaky linkages if they're absent.
117+
if case .flaky = assert.condition {
118+
continue
119+
}
120+
121+
XCTFail("Expected linkage to \(assert.linkage), but recieved linkage to \(linkage.linkage); Perhaps linkage assertions are out of order?",
122+
file: assert.file, line: assert.line)
123+
expectedLinkagesIdx += 1
124+
}
125+
126+
for superfluousLinkage in sortedLinkages[expectedLinkagesIdx..<sortedLinkages.endIndex] {
127+
XCTFail("Found unasserted link-time dependency: \(superfluousLinkage.linkage)")
128+
}
129+
}
130+
131+
private enum EnclosingTestBundle {
132+
case incremental(URL)
133+
case unified(URL)
134+
135+
func objectFiles(for library: String) throws -> [URL] {
136+
switch self {
137+
case .incremental(let baseURL):
138+
return [baseURL.appendingPathComponent(library + ".o")]
139+
case .unified(let baseURL):
140+
return try FileManager.default
141+
.contentsOfDirectory(at: baseURL.appendingPathComponent(library + ".build"),
142+
includingPropertiesForKeys: nil)
143+
.filter({ $0.pathExtension == "o" })
144+
}
145+
}
146+
}
147+
148+
private func findEnclosingTestBundle() throws -> EnclosingTestBundle? {
12149
for i in 0..<_dyld_image_count() {
13150
let name = try XCTUnwrap(_dyld_get_image_name(i))
14151
let path = String(cString: name)
15-
guard path.hasSuffix("SwiftParserTest") else {
152+
// We can wind up in SwiftParserTest.xctest when built via the IDE or
153+
// in SwiftSyntaxPackageTests.xctest when built at the command line
154+
// via the package manager.
155+
guard path.hasSuffix("SwiftParserTest") || path.hasSuffix("SwiftSyntaxPackageTests") else {
16156
continue
17157
}
18158

@@ -22,109 +162,156 @@ final class LinkageTest: XCTestCase {
22162
}
23163

24164
if baseURL.pathComponents.isEmpty {
25-
XCTFail("Unable to determine path to enclosing xctest bundle")
26-
return
165+
continue
166+
}
167+
168+
let url = baseURL.deletingLastPathComponent()
169+
if path.hasSuffix("SwiftParserTest") {
170+
return .incremental(url)
171+
} else {
172+
return .unified(url)
27173
}
174+
}
175+
return nil
176+
}
28177

29-
baseURL = baseURL.deletingLastPathComponent()
30-
let swiftSyntaxURL = baseURL.appendingPathComponent("SwiftSyntax.o")
31-
try assertLinkage(of: swiftSyntaxURL) { linkages in
32-
XCTAssertEqual(linkages, [
33-
.library("-lobjc"),
34-
.library("-lswiftCompatibilityConcurrency"),
35-
.library("-lswiftCore"),
36-
.library("-lswiftDarwin"),
37-
.library("-lswiftSwiftOnoneSupport"),
38-
.library("-lswift_Concurrency"),
39-
])
178+
private func extractAutolinkingHints(in object: URL) throws -> [Linkage] {
179+
let data = try Data(contentsOf: object, options: .mappedIfSafe)
180+
assert(data.starts(with: [0xcf,0xfa,0xed,0xfe]), "Not a mach object file?")
181+
return data.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> [Linkage] in
182+
var result = [Linkage]()
183+
guard let base = buf.baseAddress else {
184+
return []
40185
}
41-
let swiftParserURL = baseURL.appendingPathComponent("SwiftParser.o")
42-
try assertLinkage(of: swiftParserURL) { linkages in
43-
XCTAssertEqual(linkages, [
44-
.library("-lobjc"),
45-
.library("-lswiftCompatibilityConcurrency"),
46-
.library("-lswiftCore"),
47-
.library("-lswiftDarwin"),
48-
.library("-lswiftSwiftOnoneSupport"),
49-
.library("-lswift_Concurrency"),
50-
])
186+
187+
let hdr = base.bindMemory(to: MachHeader.self, capacity: 1)
188+
189+
var commandStart = base + MemoryLayout<MachHeader>.size
190+
for _ in 0..<Int(hdr.pointee.ncmds) {
191+
let command = commandStart.load(as: LoadCommand.self)
192+
defer {
193+
commandStart = commandStart.advanced(by: Int(command.cmdsize))
194+
}
195+
196+
switch command.cmd {
197+
case .linkerOption:
198+
let (namePtr, cmdCount) = commandStart.withMemoryRebound(to: LinkerOptionCommand.self, capacity: 1, { cmd in
199+
return cmd.withMemoryRebound(to: CChar.self, capacity: 1) { p in
200+
return (p.advanced(by: MemoryLayout<LinkerOptionCommand>.size), Int(cmd.pointee.count))
201+
}
202+
})
203+
if cmdCount == 1 {
204+
result.append(.library(String(cString: namePtr)))
205+
} else if cmdCount == 2 {
206+
guard String(cString: namePtr) == "-framework" else {
207+
continue
208+
}
209+
210+
let frameworkName = String(cString: namePtr.advanced(by: "-framework".utf8.count + 1))
211+
result.append(.framework(frameworkName))
212+
} else {
213+
XCTFail("Unexpected number of linker options: \(cmdCount)")
214+
}
215+
default:
216+
continue
217+
}
51218
}
219+
return result
52220
}
53221
}
222+
}
223+
#endif
54224

55-
private enum Linkage: Comparable {
56-
case library(String)
57-
case framework(String)
225+
fileprivate enum Linkage: Comparable, Hashable {
226+
case library(String)
227+
case framework(String)
228+
229+
var linkage: String {
230+
switch self {
231+
case .library(let s): return s
232+
case .framework(let s): return s
233+
}
58234
}
59235

60-
private func readLoadCommands(in object: URL) throws -> [String] {
61-
let result = Process()
62-
result.executableURL = try XCTUnwrap(URL(fileURLWithPath: "/usr/bin/xcrun"))
63-
result.arguments = [
64-
"otool", "-l", object.path,
65-
]
66-
let outputPipe = Pipe()
67-
let errorPipe = Pipe()
68-
69-
result.standardOutput = outputPipe
70-
result.standardError = errorPipe
71-
try result.run()
72-
result.waitUntilExit()
73-
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
74-
let output = String(decoding: outputData, as: UTF8.self)
75-
return output.components(separatedBy: .newlines)
236+
func hasPrefix(_ prefix: String) -> Bool {
237+
return self.linkage.hasPrefix(prefix)
76238
}
239+
}
77240

78-
private func assertLinkage(of object: URL, assertion: ([Linkage]) throws -> Void) throws {
79-
var linkages = [Linkage]()
80-
var lines = try self.readLoadCommands(in: object).makeIterator()
81-
while let line = lines.next() {
82-
guard line.starts(with: "Load command") else {
83-
continue
84-
}
241+
extension Linkage {
242+
fileprivate struct Assertion {
243+
var linkage: Linkage
244+
var condition: Condition?
245+
var file: StaticString
246+
var line: UInt
85247

86-
guard
87-
let command = lines.next(),
88-
command.hasSuffix("LC_LINKER_OPTION"),
89-
let _ = lines.next(),
90-
let count = lines.next()
91-
else {
92-
continue
93-
}
248+
func matches(_ linkage: Linkage) -> Bool {
249+
return self.linkage == linkage
250+
}
94251

95-
let countString = count.trimmingCharacters(in: .whitespaces)
96-
.suffix(from: count.index(count.startIndex, offsetBy: "count ".count))
97-
guard let count = Int(countString), count == 1 || count == 2 else {
98-
XCTFail("Malformed load command: \(line)")
99-
return
100-
}
252+
static func library(_ linkage: String, condition: Condition? = nil, file: StaticString = #file, line: UInt = #line) -> Assertion {
253+
return Linkage.Assertion(linkage: .library(linkage), condition: condition, file: file, line: line)
254+
}
101255

102-
if count == 1 {
103-
guard let library = lines.next() else {
104-
XCTFail("No load command payload: \(line)")
105-
return
106-
}
256+
static func framework(_ linkage: String, condition: Condition? = nil, file: StaticString = #file, line: UInt = #line) -> Assertion {
257+
return Linkage.Assertion(linkage: .framework(linkage), condition: condition, file: file, line: line)
258+
}
259+
}
260+
}
107261

108-
let linkLibrary = library.trimmingCharacters(in: .whitespaces)
109-
.suffix(from: library.index(library.startIndex, offsetBy: "string #1 ".count))
110-
linkages.append(.library(String(linkLibrary)))
111-
} else {
112-
assert(count == 2)
113-
guard
114-
let frameworkArg = lines.next(),
115-
frameworkArg.trimmingCharacters(in: .whitespaces) == "string #1 -framework",
116-
let framework = lines.next()
117-
else {
118-
XCTFail("No load command payload: \(line)")
119-
return
120-
}
262+
extension Linkage.Assertion {
263+
fileprivate enum Condition {
264+
case swiftVersionAtLeast(versionBound: SwiftVersion)
265+
case configuration(ProductConfiguration)
266+
case flaky
121267

122-
let linkedFramework = framework.trimmingCharacters(in: .whitespaces)
123-
.suffix(from: framework.index(framework.startIndex, offsetBy: "string #2 ".count))
124-
linkages.append(.framework(String(linkedFramework)))
268+
enum SwiftVersion: Comparable {
269+
case v5_5
270+
case v5_6
271+
case v5_7
272+
// We don't support compiling with <=5.4
273+
}
274+
275+
enum ProductConfiguration: Equatable {
276+
case debug
277+
case release
278+
}
279+
280+
fileprivate static func when(swiftVersionAtLeast version: SwiftVersion) -> Condition {
281+
return .swiftVersionAtLeast(versionBound: version)
282+
}
283+
284+
fileprivate static func when(configuration: ProductConfiguration) -> Condition {
285+
return .configuration(configuration)
286+
}
287+
288+
fileprivate static func mayBeAbsent(_ reason: StaticString) -> Condition {
289+
return .flaky
290+
}
291+
292+
fileprivate func evaluate() -> Bool {
293+
switch self {
294+
case let .swiftVersionAtLeast(versionBound: bound):
295+
#if swift(>=5.7)
296+
let version: SwiftVersion = .v5_7
297+
#elseif swift(>=5.6)
298+
let version: SwiftVersion = .v5_6
299+
#elseif swift(>=5.5)
300+
let version: SwiftVersion = .v5_5
301+
#else
302+
#error("Swift version is too old!")
303+
#endif
304+
return version >= bound
305+
case let .configuration(expectation):
306+
#if DEBUG
307+
let config: ProductConfiguration = .debug
308+
#else
309+
let config: ProductConfiguration = .release
310+
#endif
311+
return config == expectation
312+
case .flaky:
313+
return true
125314
}
126315
}
127-
return try assertion(linkages.sorted())
128316
}
129317
}
130-
#endif

0 commit comments

Comments
 (0)