Skip to content

Commit 2d466a5

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 2d466a5

File tree

5 files changed

+506
-16
lines changed

5 files changed

+506
-16
lines changed

Sources/SWBUtil/Environment.swift

Lines changed: 144 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,25 +12,153 @@
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+
self.storage[.init(key)] = value
40+
}
41+
}
42+
}
43+
44+
extension [String: String] {
45+
public init(_ environment: Environment) {
46+
self.init()
47+
let sorted = environment.sorted { $0.key < $1.key }
48+
for (key, value) in sorted {
49+
self[key.rawValue] = value
50+
}
51+
}
52+
}
53+
54+
// MARK: - Path Modification
55+
56+
extension Environment {
57+
package mutating func prependPath(key: EnvironmentKey, value: String) {
58+
guard !value.isEmpty else { return }
59+
if let existing = self[key] {
60+
self[key] = "\(value)\(Path.pathEnvironmentSeparator)\(existing)"
61+
} else {
62+
self[key] = value
63+
}
64+
}
65+
66+
package mutating func appendPath(key: EnvironmentKey, value: String) {
67+
guard !value.isEmpty else { return }
68+
if let existing = self[key] {
69+
self[key] = "\(existing)\(Path.pathEnvironmentSeparator)\(value)"
70+
} else {
71+
self[key] = value
72+
}
73+
}
74+
}
75+
76+
// MARK: - Global Environment
77+
78+
extension Environment {
79+
fileprivate static let _cachedCurrent = SWBMutex<Self?>(nil)
1680

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)
81+
/// Vends a copy of the current process's environment variables.
82+
///
83+
/// Mutations to the current process's global environment are not reflected
84+
/// in the returned value.
85+
public static var current: Self {
86+
Self._cachedCurrent.withLock { cachedValue in
87+
if let cachedValue = cachedValue {
88+
return cachedValue
89+
} else {
90+
let current = Self(ProcessInfo.processInfo.environment)
91+
cachedValue = current
92+
return current
93+
}
94+
}
95+
}
2296
}
2397

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)
98+
// MARK: - Protocol Conformances
99+
100+
extension Environment: Collection {
101+
public struct Index: Comparable {
102+
public static func < (lhs: Self, rhs: Self) -> Bool {
103+
lhs.underlying < rhs.underlying
104+
}
105+
106+
var underlying: Dictionary<EnvironmentKey, String>.Index
107+
}
108+
109+
public typealias Element = (key: EnvironmentKey, value: String)
110+
111+
public var startIndex: Index {
112+
Index(underlying: self.storage.startIndex)
113+
}
114+
115+
public var endIndex: Index {
116+
Index(underlying: self.storage.endIndex)
117+
}
118+
119+
public subscript(index: Index) -> Element {
120+
self.storage[index.underlying]
121+
}
122+
123+
public func index(after index: Self.Index) -> Self.Index {
124+
Index(underlying: self.storage.index(after: index.underlying))
125+
}
126+
}
127+
128+
extension Environment: CustomStringConvertible {
129+
public var description: String {
130+
let body = self
131+
.sorted { $0.key < $1.key }
132+
.map { "\"\($0.rawValue)=\($1)\"" }
133+
.joined(separator: ", ")
134+
return "[\(body)]"
135+
}
136+
}
137+
138+
extension Environment: Encodable {
139+
public func encode(to encoder: any Swift.Encoder) throws {
140+
try self.storage.encode(to: encoder)
141+
}
142+
}
143+
144+
extension Environment: Equatable {}
145+
146+
extension Environment: ExpressibleByDictionaryLiteral {
147+
public typealias Key = EnvironmentKey
148+
public typealias Value = String
149+
150+
public init(dictionaryLiteral elements: (Key, Value)...) {
151+
self.storage = .init()
152+
for (key, value) in elements {
153+
self.storage[key] = value
154+
}
155+
}
29156
}
30157

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]
158+
extension Environment: Decodable {
159+
public init(from decoder: any Swift.Decoder) throws {
160+
self.storage = try .init(from: decoder)
161+
}
36162
}
163+
164+
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)