Skip to content

Commit d8e77df

Browse files
committed
Add an Environment type to represent environment variables
This starts with a slightly modified version of the implementation from SwiftPM. This is not yet adopted anywhere, it's just adding the initial implementation. Closes #186
1 parent 13a2346 commit d8e77df

File tree

5 files changed

+514
-16
lines changed

5 files changed

+514
-16
lines changed

Sources/SWBUtil/Environment.swift

Lines changed: 148 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,25 +12,157 @@
1212

1313
import Foundation
1414

15-
@TaskLocal fileprivate var processEnvironment = ProcessInfo.processInfo.environment
15+
public struct Environment {
16+
var storage: [EnvironmentKey: String]
17+
}
18+
19+
// MARK: - Accessors
20+
21+
extension Environment {
22+
package init() {
23+
self.storage = .init()
24+
}
25+
26+
package subscript(_ key: EnvironmentKey) -> String? {
27+
_read { yield self.storage[key] }
28+
_modify { yield &self.storage[key] }
29+
}
30+
}
31+
32+
// MARK: - Conversions between Dictionary<String, String>
33+
34+
extension Environment {
35+
public init(_ dictionary: [String: String]) {
36+
self.storage = .init()
37+
let sorted = dictionary.sorted { $0.key < $1.key }
38+
for (key, value) in sorted {
39+
if let existing = self.storage[.init(key)] {
40+
// Will only be thrown on Windows, but don't want to make this initializer throwing or raise a fatal error quite yet, so just assert in debug mode for now.
41+
assertionFailure("Duplicate environment keys")
42+
}
43+
self.storage[.init(key)] = value
44+
}
45+
}
46+
}
47+
48+
extension [String: String] {
49+
public init(_ environment: Environment) {
50+
self.init()
51+
let sorted = environment.sorted { $0.key < $1.key }
52+
for (key, value) in sorted {
53+
self[key.rawValue] = value
54+
}
55+
}
56+
}
57+
58+
// MARK: - Path Modification
59+
60+
extension Environment {
61+
package mutating func prependPath(key: EnvironmentKey, value: String) {
62+
guard !value.isEmpty else { return }
63+
if let existing = self[key] {
64+
self[key] = "\(value)\(Path.pathEnvironmentSeparator)\(existing)"
65+
} else {
66+
self[key] = value
67+
}
68+
}
69+
70+
package mutating func appendPath(key: EnvironmentKey, value: String) {
71+
guard !value.isEmpty else { return }
72+
if let existing = self[key] {
73+
self[key] = "\(existing)\(Path.pathEnvironmentSeparator)\(value)"
74+
} else {
75+
self[key] = value
76+
}
77+
}
78+
}
79+
80+
// MARK: - Global Environment
81+
82+
extension Environment {
83+
fileprivate static let _cachedCurrent = SWBMutex<Self?>(nil)
1684

17-
/// Binds the internal defaults to the specified `environment` for the duration of the synchronous `operation`.
18-
/// - parameter clean: `true` to start with a clean environment, `false` to merge the input environment over the existing process environment.
19-
/// - note: This is implemented via task-local values.
20-
@_spi(Testing) public func withEnvironment<R>(_ environment: [String: String], clean: Bool = false, operation: () throws -> R) rethrows -> R {
21-
try $processEnvironment.withValue(clean ? environment : processEnvironment.addingContents(of: environment), operation: operation)
85+
/// Vends a copy of the current process's environment variables.
86+
///
87+
/// Mutations to the current process's global environment are not reflected
88+
/// in the returned value.
89+
public static var current: Self {
90+
Self._cachedCurrent.withLock { cachedValue in
91+
if let cachedValue = cachedValue {
92+
return cachedValue
93+
} else {
94+
let current = Self(ProcessInfo.processInfo.environment)
95+
cachedValue = current
96+
return current
97+
}
98+
}
99+
}
22100
}
23101

24-
/// Binds the internal defaults to the specified `environment` for the duration of the asynchronous `operation`.
25-
/// - parameter clean: `true` to start with a clean environment, `false` to merge the input environment over the existing process environment.
26-
/// - note: This is implemented via task-local values.
27-
@_spi(Testing) public func withEnvironment<R>(_ environment: [String: String], clean: Bool = false, operation: () async throws -> R) async rethrows -> R {
28-
try await $processEnvironment.withValue(clean ? environment : processEnvironment.addingContents(of: environment), operation: operation)
102+
// MARK: - Protocol Conformances
103+
104+
extension Environment: Collection {
105+
public struct Index: Comparable {
106+
public static func < (lhs: Self, rhs: Self) -> Bool {
107+
lhs.underlying < rhs.underlying
108+
}
109+
110+
var underlying: Dictionary<EnvironmentKey, String>.Index
111+
}
112+
113+
public typealias Element = (key: EnvironmentKey, value: String)
114+
115+
public var startIndex: Index {
116+
Index(underlying: self.storage.startIndex)
117+
}
118+
119+
public var endIndex: Index {
120+
Index(underlying: self.storage.endIndex)
121+
}
122+
123+
public subscript(index: Index) -> Element {
124+
self.storage[index.underlying]
125+
}
126+
127+
public func index(after index: Self.Index) -> Self.Index {
128+
Index(underlying: self.storage.index(after: index.underlying))
129+
}
130+
}
131+
132+
extension Environment: CustomStringConvertible {
133+
public var description: String {
134+
let body = self
135+
.sorted { $0.key < $1.key }
136+
.map { "\"\($0.rawValue)=\($1)\"" }
137+
.joined(separator: ", ")
138+
return "[\(body)]"
139+
}
140+
}
141+
142+
extension Environment: Encodable {
143+
public func encode(to encoder: any Swift.Encoder) throws {
144+
try self.storage.encode(to: encoder)
145+
}
146+
}
147+
148+
extension Environment: Equatable {}
149+
150+
extension Environment: ExpressibleByDictionaryLiteral {
151+
public typealias Key = EnvironmentKey
152+
public typealias Value = String
153+
154+
public init(dictionaryLiteral elements: (Key, Value)...) {
155+
self.storage = .init()
156+
for (key, value) in elements {
157+
self.storage[key] = value
158+
}
159+
}
29160
}
30161

31-
/// Gets the value of the named variable from the process' environment.
32-
/// - parameter name: The name of the environment variable.
33-
/// - returns: The value of the variable as a `String`, or `nil` if it is not defined in the environment.
34-
public func getEnvironmentVariable(_ name: String) -> String? {
35-
processEnvironment[name]
162+
extension Environment: Decodable {
163+
public init(from decoder: any Swift.Decoder) throws {
164+
self.storage = try .init(from: decoder)
165+
}
36166
}
167+
168+
extension Environment: Sendable {}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import Foundation
14+
15+
@TaskLocal fileprivate var processEnvironment = ProcessInfo.processInfo.environment
16+
17+
/// Binds the internal defaults to the specified `environment` for the duration of the synchronous `operation`.
18+
/// - parameter clean: `true` to start with a clean environment, `false` to merge the input environment over the existing process environment.
19+
/// - note: This is implemented via task-local values.
20+
@_spi(Testing) public func withEnvironment<R>(_ environment: [String: String], clean: Bool = false, operation: () throws -> R) rethrows -> R {
21+
try $processEnvironment.withValue(clean ? environment : processEnvironment.addingContents(of: environment), operation: operation)
22+
}
23+
24+
/// Binds the internal defaults to the specified `environment` for the duration of the asynchronous `operation`.
25+
/// - parameter clean: `true` to start with a clean environment, `false` to merge the input environment over the existing process environment.
26+
/// - note: This is implemented via task-local values.
27+
@_spi(Testing) public func withEnvironment<R>(_ environment: [String: String], clean: Bool = false, operation: () async throws -> R) async rethrows -> R {
28+
try await $processEnvironment.withValue(clean ? environment : processEnvironment.addingContents(of: environment), operation: operation)
29+
}
30+
31+
/// Gets the value of the named variable from the process' environment.
32+
/// - parameter name: The name of the environment variable.
33+
/// - returns: The value of the variable as a `String`, or `nil` if it is not defined in the environment.
34+
public func getEnvironmentVariable(_ name: String) -> String? {
35+
processEnvironment[name]
36+
}

Sources/SWBUtil/EnvironmentKey.swift

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
/// A key used to access values in an ``Environment``.
14+
///
15+
/// This type respects the compiled platform's case sensitivity requirements.
16+
public struct EnvironmentKey {
17+
public var rawValue: String
18+
19+
package init(_ rawValue: String) {
20+
self.rawValue = rawValue
21+
}
22+
}
23+
24+
extension EnvironmentKey {
25+
package static let path: Self = "PATH"
26+
}
27+
28+
extension EnvironmentKey: CodingKeyRepresentable {}
29+
30+
extension EnvironmentKey: Comparable {
31+
public static func < (lhs: Self, rhs: Self) -> Bool {
32+
// Even on windows use a stable sort order.
33+
lhs.rawValue < rhs.rawValue
34+
}
35+
}
36+
37+
extension EnvironmentKey: CustomStringConvertible {
38+
public var description: String { self.rawValue }
39+
}
40+
41+
extension EnvironmentKey: Encodable {
42+
public func encode(to encoder: any Swift.Encoder) throws {
43+
try self.rawValue.encode(to: encoder)
44+
}
45+
}
46+
47+
extension EnvironmentKey: Equatable {
48+
public static func == (_ lhs: Self, _ rhs: Self) -> Bool {
49+
#if os(Windows)
50+
lhs.rawValue.lowercased() == rhs.rawValue.lowercased()
51+
#else
52+
lhs.rawValue == rhs.rawValue
53+
#endif
54+
}
55+
}
56+
57+
extension EnvironmentKey: ExpressibleByStringLiteral {
58+
public init(stringLiteral rawValue: String) {
59+
self.init(rawValue)
60+
}
61+
}
62+
63+
extension EnvironmentKey: Decodable {
64+
public init(from decoder: any Swift.Decoder) throws {
65+
self.rawValue = try String(from: decoder)
66+
}
67+
}
68+
69+
extension EnvironmentKey: Hashable {
70+
public func hash(into hasher: inout Hasher) {
71+
#if os(Windows)
72+
self.rawValue.lowercased().hash(into: &hasher)
73+
#else
74+
self.rawValue.hash(into: &hasher)
75+
#endif
76+
}
77+
}
78+
79+
extension EnvironmentKey: RawRepresentable {
80+
public init?(rawValue: String) {
81+
self.rawValue = rawValue
82+
}
83+
}
84+
85+
extension EnvironmentKey: Sendable {}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import SWBUtil
14+
import Foundation
15+
import Testing
16+
17+
@Suite fileprivate struct EnvironmentKeyTests {
18+
let isCaseInsensitive: Bool
19+
20+
init() throws {
21+
isCaseInsensitive = try ProcessInfo.processInfo.hostOperatingSystem() == .windows
22+
}
23+
24+
@Test func comparable() {
25+
let key0 = EnvironmentKey("Test")
26+
let key1 = EnvironmentKey("Test1")
27+
#expect(key0 < key1)
28+
29+
let key2 = EnvironmentKey("test")
30+
#expect(key0 < key2)
31+
}
32+
33+
@Test func customStringConvertible() {
34+
let key = EnvironmentKey("Test")
35+
#expect(key.description == "Test")
36+
}
37+
38+
@Test func encodable() throws {
39+
let key = EnvironmentKey("Test")
40+
let data = try JSONEncoder().encode(key)
41+
let string = String(data: data, encoding: .utf8)
42+
#expect(string == #""Test""#)
43+
}
44+
45+
@Test func equatable() {
46+
let key0 = EnvironmentKey("Test")
47+
let key1 = EnvironmentKey("Test")
48+
#expect(key0 == key1)
49+
50+
let key2 = EnvironmentKey("Test2")
51+
#expect(key0 != key2)
52+
53+
if isCaseInsensitive {
54+
// Test case insensitivity on windows
55+
let key3 = EnvironmentKey("teSt")
56+
#expect(key0 == key3)
57+
}
58+
}
59+
60+
@Test func expressibleByStringLiteral() {
61+
let key0 = EnvironmentKey("Test")
62+
#expect(key0 == "Test")
63+
}
64+
65+
@Test func decodable() throws {
66+
let jsonString = #""Test""#
67+
let data = jsonString.data(using: .utf8)!
68+
let key = try JSONDecoder().decode(EnvironmentKey.self, from: data)
69+
#expect(key.rawValue == "Test")
70+
}
71+
72+
@Test func hashable() {
73+
var set = Set<EnvironmentKey>()
74+
let key0 = EnvironmentKey("Test")
75+
#expect(set.insert(key0).inserted)
76+
77+
let key1 = EnvironmentKey("Test")
78+
#expect(set.contains(key1))
79+
#expect(!set.insert(key1).inserted)
80+
81+
let key2 = EnvironmentKey("Test2")
82+
#expect(!set.contains(key2))
83+
#expect(set.insert(key2).inserted)
84+
85+
if isCaseInsensitive {
86+
// Test case insensitivity on windows
87+
let key3 = EnvironmentKey("teSt")
88+
#expect(set.contains(key3))
89+
#expect(!set.insert(key3).inserted)
90+
}
91+
92+
#expect(set == ["Test", "Test2"])
93+
}
94+
95+
@Test func rawRepresentable() {
96+
let key = EnvironmentKey(rawValue: "Test")
97+
#expect(key?.rawValue == "Test")
98+
}
99+
}

0 commit comments

Comments
 (0)