Skip to content

Commit 217100f

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

File tree

1 file changed

+276
-87
lines changed

1 file changed

+276
-87
lines changed

Tests/SwiftParserTest/Linkage.swift

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

@@ -22,109 +164,156 @@ final class LinkageTest: XCTestCase {
22164
}
23165

24166
if baseURL.pathComponents.isEmpty {
25-
XCTFail("Unable to determine path to enclosing xctest bundle")
26-
return
167+
continue
168+
}
169+
170+
let url = baseURL.deletingLastPathComponent()
171+
if path.hasSuffix("SwiftParserTest") {
172+
return .incremental(url)
173+
} else {
174+
return .unified(url)
27175
}
176+
}
177+
return nil
178+
}
28179

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-
])
180+
private func extractAutolinkingHints(in object: URL) throws -> [Linkage] {
181+
let data = try Data(contentsOf: object, options: .mappedIfSafe)
182+
assert(data.starts(with: [0xcf,0xfa,0xed,0xfe]), "Not a mach object file?")
183+
return data.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> [Linkage] in
184+
var result = [Linkage]()
185+
guard let base = buf.baseAddress else {
186+
return []
40187
}
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-
])
188+
189+
let hdr = base.bindMemory(to: MachHeader.self, capacity: 1)
190+
191+
var commandStart = base + MemoryLayout<MachHeader>.size
192+
for _ in 0..<Int(hdr.pointee.ncmds) {
193+
let command = commandStart.load(as: LoadCommand.self)
194+
defer {
195+
commandStart = commandStart.advanced(by: Int(command.cmdsize))
196+
}
197+
198+
switch command.cmd {
199+
case .linkerOption:
200+
let (namePtr, cmdCount) = commandStart.withMemoryRebound(to: LinkerOptionCommand.self, capacity: 1, { cmd in
201+
return cmd.withMemoryRebound(to: CChar.self, capacity: 1) { p in
202+
return (p.advanced(by: MemoryLayout<LinkerOptionCommand>.size), Int(cmd.pointee.count))
203+
}
204+
})
205+
if cmdCount == 1 {
206+
result.append(.library(String(cString: namePtr)))
207+
} else if cmdCount == 2 {
208+
guard String(cString: namePtr) == "-framework" else {
209+
continue
210+
}
211+
212+
let frameworkName = String(cString: namePtr.advanced(by: "-framework".utf8.count + 1))
213+
result.append(.framework(frameworkName))
214+
} else {
215+
XCTFail("Unexpected number of linker options: \(cmdCount)")
216+
}
217+
default:
218+
continue
219+
}
51220
}
221+
return result
52222
}
53223
}
224+
}
225+
#endif
54226

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

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)
238+
func hasPrefix(_ prefix: String) -> Bool {
239+
return self.linkage.hasPrefix(prefix)
76240
}
241+
}
77242

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-
}
243+
extension Linkage {
244+
fileprivate struct Assertion {
245+
var linkage: Linkage
246+
var condition: Condition?
247+
var file: StaticString
248+
var line: UInt
85249

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-
}
250+
func matches(_ linkage: Linkage) -> Bool {
251+
return self.linkage == linkage
252+
}
94253

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-
}
254+
static func library(_ linkage: String, condition: Condition? = nil, file: StaticString = #file, line: UInt = #line) -> Assertion {
255+
return Linkage.Assertion(linkage: .library(linkage), condition: condition, file: file, line: line)
256+
}
101257

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

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-
}
264+
extension Linkage.Assertion {
265+
fileprivate enum Condition {
266+
case swiftVersionAtLeast(versionBound: SwiftVersion)
267+
case configuration(ProductConfiguration)
268+
case flaky
121269

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

0 commit comments

Comments
 (0)