Skip to content

Commit 8705819

Browse files
authored
Abstract away our dependency on Foundation for JSON encoding/decoding (#355)
This PR moves all our uses of `JSONEncoder` and `JSONDecoder` to a new namespace enum, `JSON`, and provides interfaces that don't use Foundation. This change does _not_ remove our dependency on Foundation's JSON functionality, but it should allow us to replace that functionality in the future with JSON encoding/decoding logic from another library (or home-grown, if necessary.) ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated.
1 parent 87e4034 commit 8705819

17 files changed

+133
-79
lines changed

Sources/Testing/ExitTests/ExitTest.swift

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@
99
//
1010

1111
private import TestingInternals
12-
#if canImport(Foundation)
13-
private import Foundation
14-
#endif
1512

1613
#if !SWT_NO_EXIT_TESTS
1714
/// A type describing an exit test.
@@ -223,10 +220,12 @@ extension ExitTest {
223220
/// `__swiftPMEntryPoint()` function. The effect of using it under other
224221
/// configurations is undefined.
225222
static func findInEnvironmentForSwiftPM() -> Self? {
226-
let sourceLocationString = Environment.variable(named: "SWT_EXPERIMENTAL_EXIT_TEST_SOURCE_LOCATION")
227-
if let sourceLocationData = sourceLocationString?.data(using: .utf8),
228-
let sourceLocation = try? JSONDecoder().decode(SourceLocation.self, from: sourceLocationData) {
229-
return find(at: sourceLocation)
223+
if var sourceLocationString = Environment.variable(named: "SWT_EXPERIMENTAL_EXIT_TEST_SOURCE_LOCATION") {
224+
return try? sourceLocationString.withUTF8 { sourceLocationBuffer in
225+
let sourceLocationBuffer = UnsafeRawBufferPointer(sourceLocationBuffer)
226+
let sourceLocation = try JSON.decode(SourceLocation.self, from: sourceLocationBuffer)
227+
return find(at: sourceLocation)
228+
}
230229
}
231230
return nil
232231
}
@@ -286,10 +285,12 @@ extension ExitTest {
286285
#endif
287286
// Insert a specific variable that tells the child process which exit test
288287
// to run.
289-
childEnvironment["SWT_EXPERIMENTAL_EXIT_TEST_SOURCE_LOCATION"] = try String(data: JSONEncoder().encode(exitTest.sourceLocation), encoding: .utf8)!
288+
try JSON.withEncoding(of: exitTest.sourceLocation) { json in
289+
childEnvironment["SWT_EXPERIMENTAL_EXIT_TEST_SOURCE_LOCATION"] = String(decoding: json, as: UTF8.self)
290+
}
290291

291292
return try await _spawnAndWait(
292-
forExecutableAtPath: childProcessExecutablePath,
293+
forExecutableAtPath: childProcessExecutablePath,
293294
arguments: childArguments,
294295
environment: childEnvironment
295296
)

Sources/Testing/Parameterization/CustomTestArgumentEncodable.swift

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,6 @@
88
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
99
//
1010

11-
#if canImport(Foundation)
12-
private import Foundation
13-
#endif
14-
1511
/// A protocol for customizing how arguments passed to parameterized tests are
1612
/// encoded, which is used to match against when running specific arguments.
1713
///
@@ -107,15 +103,7 @@ extension Test.Case.Argument.ID {
107103
///
108104
/// - Throws: Any error encountered during encoding.
109105
private static func _encode(_ value: some Encodable, parameter: Test.Parameter) throws -> [UInt8] {
110-
let encoder = JSONEncoder()
111-
112-
// Keys must be sorted to ensure deterministic matching of encoded data.
113-
encoder.outputFormatting.insert(.sortedKeys)
114-
115-
// Set user info keys which clients may wish to use during encoding.
116-
encoder.userInfo[._testParameterUserInfoKey] = parameter
117-
118-
return .init(try encoder.encode(value))
106+
try JSON.withEncoding(of: value, userInfo: [._testParameterUserInfoKey: parameter], Array.init)
119107
}
120108
#endif
121109
}

Sources/Testing/Running/EntryPoint.swift

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@
99
//
1010

1111
private import TestingInternals
12-
#if canImport(Foundation)
13-
private import Foundation
14-
#endif
1512

1613
/// The entry point to the testing library used by Swift Package Manager.
1714
///
@@ -321,17 +318,17 @@ private func _eventHandlerForStreamingEvents(toFileAtPath path: String) throws -
321318
event: Event.Snapshot(snapshotting: event),
322319
eventContext: Event.Context.Snapshot(snapshotting: context)
323320
)
324-
if var snapshotJSON = try? JSONEncoder().encode(snapshot) {
321+
try? JSON.withEncoding(of: snapshot) { snapshotJSON in
325322
func isASCIINewline(_ byte: UInt8) -> Bool {
326323
byte == 10 || byte == 13
327324
}
328325

329326
#if DEBUG
330-
// We don't actually expect JSONEncoder() to produce output containing
327+
// We don't actually expect the JSON encoder to produce output containing
331328
// newline characters, so in debug builds we'll log a diagnostic message.
332329
if snapshotJSON.contains(where: isASCIINewline) {
333330
let message = Event.ConsoleOutputRecorder.warning(
334-
"JSONEncoder() produced one or more newline characters while encoding an event snapshot with kind '\(event.kind)'. Please file a bug report at https://github.com/apple/swift-testing/issues/new",
331+
"JSON encoder produced one or more newline characters while encoding an event snapshot with kind '\(event.kind)'. Please file a bug report at https://github.com/apple/swift-testing/issues/new",
335332
options: .for(.stderr)
336333
)
337334
#if SWT_TARGET_OS_APPLE
@@ -343,6 +340,7 @@ private func _eventHandlerForStreamingEvents(toFileAtPath path: String) throws -
343340
#endif
344341

345342
// Remove newline characters to conform to JSON lines specification.
343+
var snapshotJSON = Array(snapshotJSON)
346344
snapshotJSON.removeAll(where: isASCIINewline)
347345
if !snapshotJSON.isEmpty {
348346
try? file.withLock {

Sources/Testing/Support/JSON.swift

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
#if canImport(Foundation)
12+
private import Foundation
13+
#endif
14+
15+
enum JSON {
16+
/// Encode a value as JSON.
17+
///
18+
/// - Parameters:
19+
/// - value: The value to encode.
20+
/// - userInfo: Any user info to pass into the encoder during encoding.
21+
/// - body: A function to call.
22+
///
23+
/// - Returns: Whatever is returned by `body`.
24+
///
25+
/// - Throws: Whatever is thrown by `body` or by the encoding process.
26+
static func withEncoding<R>(of value: some Encodable, userInfo: [CodingUserInfoKey: Any] = [:], _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
27+
#if canImport(Foundation)
28+
let encoder = JSONEncoder()
29+
30+
// Keys must be sorted to ensure deterministic matching of encoded data.
31+
encoder.outputFormatting.insert(.sortedKeys)
32+
33+
// Set user info keys that clients want to use during encoding.
34+
encoder.userInfo.merge(userInfo, uniquingKeysWith: { _, rhs in rhs})
35+
36+
let data = try encoder.encode(value)
37+
return try data.withUnsafeBytes(body)
38+
#else
39+
throw SystemError(description: "JSON encoding requires Foundation which is not available in this environment.")
40+
#endif
41+
}
42+
43+
/// Decode a value from JSON data.
44+
///
45+
/// - Parameters:
46+
/// - type: The type of value to decode.
47+
/// - jsonRepresentation: The JSON encoding of the value to decode.
48+
///
49+
/// - Returns: An instance of `T` decoded from `jsonRepresentation`.
50+
///
51+
/// - Throws: Whatever is thrown by the decoding process.
52+
static func decode<T>(_ type: T.Type, from jsonRepresentation: UnsafeRawBufferPointer) throws -> T where T: Decodable {
53+
#if canImport(Foundation)
54+
try withExtendedLifetime(jsonRepresentation) {
55+
let data = Data(
56+
bytesNoCopy: .init(mutating: jsonRepresentation.baseAddress!),
57+
count: jsonRepresentation.count,
58+
deallocator: .none
59+
)
60+
return try JSONDecoder().decode(type, from: data)
61+
}
62+
#else
63+
throw SystemError(description: "JSON decoding requires Foundation which is not available in this environment.")
64+
#endif
65+
}
66+
}

Sources/Testing/Traits/Tags/Tag.Color+Loading.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ func loadTagColors(fromFileInDirectoryAtPath swiftTestingDirectoryPath: String =
9999
// nil is a valid decoded color value (representing "no color") that we can
100100
// use for merging tag color data from multiple sources, but it is not valid
101101
// as an actual tag color, so we have a step here that filters it.
102-
return try JSONDecoder().decode([Tag: Tag.Color?].self, from: tagColorsData)
103-
.compactMapValues { $0 }
102+
return try tagColorsData.withUnsafeBytes { tagColorsData in
103+
try JSON.decode([Tag: Tag.Color?].self, from: tagColorsData)
104+
.compactMapValues { $0 }
105+
}
104106
}
105107
#endif

Sources/TestingInternals/include/Includes.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@
4343
#include <unistd.h>
4444
#endif
4545

46+
#if __has_include(<sys/fcntl.h>)
47+
#include <sys/fcntl.h>
48+
#endif
49+
4650
#if __has_include(<sys/stat.h>)
4751
#include <sys/stat.h>
4852
#endif
@@ -75,6 +79,10 @@
7579
#include <limits.h>
7680
#endif
7781

82+
#if __has_include(<spawn.h>)
83+
#include <spawn.h>
84+
#endif
85+
7886
#if __has_include(<crt_externs.h>)
7987
#include <crt_externs.h>
8088
#endif

Tests/TestingTests/BacktraceTests.swift

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@
99
//
1010

1111
@testable @_spi(ForToolsIntegrationOnly) import Testing
12-
#if canImport(Foundation)
13-
import Foundation
14-
#endif
1512

1613
struct BacktracedError: Error {}
1714

@@ -51,8 +48,7 @@ struct BacktraceTests {
5148
@Test("Encoding/decoding")
5249
func encodingAndDecoding() throws {
5350
let original = Backtrace.current()
54-
let data = try JSONEncoder().encode(original)
55-
let copy = try JSONDecoder().decode(Backtrace.self, from: data)
51+
let copy = try JSON.encodeAndDecode(original)
5652
#expect(original == copy)
5753
}
5854
#endif

Tests/TestingTests/ClockTests.swift

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,6 @@
88
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
99
//
1010

11-
#if canImport(Foundation)
12-
import Foundation
13-
#endif
1411
@testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing
1512
private import TestingInternals
1613

@@ -129,9 +126,7 @@ struct ClockTests {
129126
func codable() async throws {
130127
let now = Test.Clock.Instant()
131128
let instant = now.advanced(by: .nanoseconds(100))
132-
let decoded = try JSONDecoder().decode(Test.Clock.Instant.self,
133-
from: JSONEncoder().encode(instant))
134-
129+
let decoded = try JSON.encodeAndDecode(instant)
135130
#expect(instant == decoded)
136131
#expect(instant != now)
137132
}

Tests/TestingTests/EventTests.swift

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,6 @@
88
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
99
//
1010

11-
#if canImport(Foundation)
12-
import Foundation
13-
#endif
1411
@testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing
1512
private import TestingInternals
1613

@@ -62,8 +59,7 @@ struct EventTests {
6259
let testCaseID = Test.Case.ID(argumentIDs: nil)
6360
let event = Event(kind, testID: testID, testCaseID: testCaseID, instant: .now)
6461
let eventSnapshot = Event.Snapshot(snapshotting: event)
65-
let encoded = try JSONEncoder().encode(eventSnapshot)
66-
let decoded = try JSONDecoder().decode(Event.Snapshot.self, from: encoded)
62+
let decoded = try JSON.encodeAndDecode(eventSnapshot)
6763

6864
#expect(String(describing: decoded) == String(describing: eventSnapshot))
6965
}
@@ -73,8 +69,7 @@ struct EventTests {
7369
let eventContext = Event.Context()
7470
let snapshot = Event.Context.Snapshot(snapshotting: eventContext)
7571

76-
let encoded = try JSONEncoder().encode(snapshot)
77-
let decoded = try JSONDecoder().decode(Event.Context.Snapshot.self, from: encoded)
72+
let decoded = try JSON.encodeAndDecode(snapshot)
7873

7974
#expect(String(describing: decoded.test) == String(describing: eventContext.test.map(Test.Snapshot.init(snapshotting:))))
8075
#expect(String(describing: decoded.testCase) == String(describing: eventContext.testCase.map(Test.Case.Snapshot.init(snapshotting:))))

Tests/TestingTests/IssueTests.swift

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
@testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing
1212
private import TestingInternals
13-
import Foundation
1413

1514
#if canImport(XCTest)
1615
import XCTest
@@ -1408,8 +1407,7 @@ struct IssueCodingTests {
14081407
comments: ["Comment"],
14091408
sourceContext: SourceContext(backtrace: Backtrace.current(), sourceLocation: SourceLocation()))
14101409
let issueSnapshot = Issue.Snapshot(snapshotting: issue)
1411-
let encoded = try JSONEncoder().encode(issueSnapshot)
1412-
let decoded = try JSONDecoder().decode(Issue.Snapshot.self, from: encoded)
1410+
let decoded = try JSON.encodeAndDecode(issueSnapshot)
14131411

14141412
#expect(String(describing: decoded) == String(describing: issueSnapshot))
14151413
}

0 commit comments

Comments
 (0)