diff --git a/Sources/SWBUtil/Environment.swift b/Sources/SWBUtil/Environment.swift index 3d82bbb7..2c49fd8c 100644 --- a/Sources/SWBUtil/Environment.swift +++ b/Sources/SWBUtil/Environment.swift @@ -12,25 +12,153 @@ import Foundation -@TaskLocal fileprivate var processEnvironment = ProcessInfo.processInfo.environment +public struct Environment { + var storage: [EnvironmentKey: String] +} + +// MARK: - Accessors + +extension Environment { + package init() { + self.storage = .init() + } + + package subscript(_ key: EnvironmentKey) -> String? { + _read { yield self.storage[key] } + _modify { yield &self.storage[key] } + } +} + +// MARK: - Conversions between Dictionary + +extension Environment { + public init(_ dictionary: [String: String]) { + self.storage = .init() + let sorted = dictionary.sorted { $0.key < $1.key } + for (key, value) in sorted { + self.storage[.init(key)] = value + } + } +} + +extension [String: String] { + public init(_ environment: Environment) { + self.init() + let sorted = environment.sorted { $0.key < $1.key } + for (key, value) in sorted { + self[key.rawValue] = value + } + } +} + +// MARK: - Path Modification + +extension Environment { + package mutating func prependPath(key: EnvironmentKey, value: String) { + guard !value.isEmpty else { return } + if let existing = self[key] { + self[key] = "\(value)\(Path.pathEnvironmentSeparator)\(existing)" + } else { + self[key] = value + } + } + + package mutating func appendPath(key: EnvironmentKey, value: String) { + guard !value.isEmpty else { return } + if let existing = self[key] { + self[key] = "\(existing)\(Path.pathEnvironmentSeparator)\(value)" + } else { + self[key] = value + } + } +} + +// MARK: - Global Environment + +extension Environment { + fileprivate static let _cachedCurrent = SWBMutex(nil) -/// Binds the internal defaults to the specified `environment` for the duration of the synchronous `operation`. -/// - parameter clean: `true` to start with a clean environment, `false` to merge the input environment over the existing process environment. -/// - note: This is implemented via task-local values. -@_spi(Testing) public func withEnvironment(_ environment: [String: String], clean: Bool = false, operation: () throws -> R) rethrows -> R { - try $processEnvironment.withValue(clean ? environment : processEnvironment.addingContents(of: environment), operation: operation) + /// Vends a copy of the current process's environment variables. + /// + /// Mutations to the current process's global environment are not reflected + /// in the returned value. + public static var current: Self { + Self._cachedCurrent.withLock { cachedValue in + if let cachedValue = cachedValue { + return cachedValue + } else { + let current = Self(ProcessInfo.processInfo.environment) + cachedValue = current + return current + } + } + } } -/// Binds the internal defaults to the specified `environment` for the duration of the asynchronous `operation`. -/// - parameter clean: `true` to start with a clean environment, `false` to merge the input environment over the existing process environment. -/// - note: This is implemented via task-local values. -@_spi(Testing) public func withEnvironment(_ environment: [String: String], clean: Bool = false, operation: () async throws -> R) async rethrows -> R { - try await $processEnvironment.withValue(clean ? environment : processEnvironment.addingContents(of: environment), operation: operation) +// MARK: - Protocol Conformances + +extension Environment: Collection { + public struct Index: Comparable { + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.underlying < rhs.underlying + } + + var underlying: Dictionary.Index + } + + public typealias Element = (key: EnvironmentKey, value: String) + + public var startIndex: Index { + Index(underlying: self.storage.startIndex) + } + + public var endIndex: Index { + Index(underlying: self.storage.endIndex) + } + + public subscript(index: Index) -> Element { + self.storage[index.underlying] + } + + public func index(after index: Self.Index) -> Self.Index { + Index(underlying: self.storage.index(after: index.underlying)) + } +} + +extension Environment: CustomStringConvertible { + public var description: String { + let body = self + .sorted { $0.key < $1.key } + .map { "\"\($0.rawValue)=\($1)\"" } + .joined(separator: ", ") + return "[\(body)]" + } +} + +extension Environment: Encodable { + public func encode(to encoder: any Swift.Encoder) throws { + try self.storage.encode(to: encoder) + } +} + +extension Environment: Equatable {} + +extension Environment: ExpressibleByDictionaryLiteral { + public typealias Key = EnvironmentKey + public typealias Value = String + + public init(dictionaryLiteral elements: (Key, Value)...) { + self.storage = .init() + for (key, value) in elements { + self.storage[key] = value + } + } } -/// Gets the value of the named variable from the process' environment. -/// - parameter name: The name of the environment variable. -/// - returns: The value of the variable as a `String`, or `nil` if it is not defined in the environment. -public func getEnvironmentVariable(_ name: String) -> String? { - processEnvironment[name] +extension Environment: Decodable { + public init(from decoder: any Swift.Decoder) throws { + self.storage = try .init(from: decoder) + } } + +extension Environment: Sendable {} diff --git a/Sources/SWBUtil/EnvironmentHelpers.swift b/Sources/SWBUtil/EnvironmentHelpers.swift new file mode 100644 index 00000000..3d82bbb7 --- /dev/null +++ b/Sources/SWBUtil/EnvironmentHelpers.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation + +@TaskLocal fileprivate var processEnvironment = ProcessInfo.processInfo.environment + +/// Binds the internal defaults to the specified `environment` for the duration of the synchronous `operation`. +/// - parameter clean: `true` to start with a clean environment, `false` to merge the input environment over the existing process environment. +/// - note: This is implemented via task-local values. +@_spi(Testing) public func withEnvironment(_ environment: [String: String], clean: Bool = false, operation: () throws -> R) rethrows -> R { + try $processEnvironment.withValue(clean ? environment : processEnvironment.addingContents(of: environment), operation: operation) +} + +/// Binds the internal defaults to the specified `environment` for the duration of the asynchronous `operation`. +/// - parameter clean: `true` to start with a clean environment, `false` to merge the input environment over the existing process environment. +/// - note: This is implemented via task-local values. +@_spi(Testing) public func withEnvironment(_ environment: [String: String], clean: Bool = false, operation: () async throws -> R) async rethrows -> R { + try await $processEnvironment.withValue(clean ? environment : processEnvironment.addingContents(of: environment), operation: operation) +} + +/// Gets the value of the named variable from the process' environment. +/// - parameter name: The name of the environment variable. +/// - returns: The value of the variable as a `String`, or `nil` if it is not defined in the environment. +public func getEnvironmentVariable(_ name: String) -> String? { + processEnvironment[name] +} diff --git a/Sources/SWBUtil/EnvironmentKey.swift b/Sources/SWBUtil/EnvironmentKey.swift new file mode 100644 index 00000000..bfd1fff4 --- /dev/null +++ b/Sources/SWBUtil/EnvironmentKey.swift @@ -0,0 +1,85 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// A key used to access values in an ``Environment``. +/// +/// This type respects the compiled platform's case sensitivity requirements. +public struct EnvironmentKey { + public var rawValue: String + + package init(_ rawValue: String) { + self.rawValue = rawValue + } +} + +extension EnvironmentKey { + package static let path: Self = "PATH" +} + +extension EnvironmentKey: CodingKeyRepresentable {} + +extension EnvironmentKey: Comparable { + public static func < (lhs: Self, rhs: Self) -> Bool { + // Even on windows use a stable sort order. + lhs.rawValue < rhs.rawValue + } +} + +extension EnvironmentKey: CustomStringConvertible { + public var description: String { self.rawValue } +} + +extension EnvironmentKey: Encodable { + public func encode(to encoder: any Swift.Encoder) throws { + try self.rawValue.encode(to: encoder) + } +} + +extension EnvironmentKey: Equatable { + public static func == (_ lhs: Self, _ rhs: Self) -> Bool { + #if os(Windows) + lhs.rawValue.lowercased() == rhs.rawValue.lowercased() + #else + lhs.rawValue == rhs.rawValue + #endif + } +} + +extension EnvironmentKey: ExpressibleByStringLiteral { + public init(stringLiteral rawValue: String) { + self.init(rawValue) + } +} + +extension EnvironmentKey: Decodable { + public init(from decoder: any Swift.Decoder) throws { + self.rawValue = try String(from: decoder) + } +} + +extension EnvironmentKey: Hashable { + public func hash(into hasher: inout Hasher) { + #if os(Windows) + self.rawValue.lowercased().hash(into: &hasher) + #else + self.rawValue.hash(into: &hasher) + #endif + } +} + +extension EnvironmentKey: RawRepresentable { + public init?(rawValue: String) { + self.rawValue = rawValue + } +} + +extension EnvironmentKey: Sendable {} diff --git a/Tests/SWBUtilTests/EnvironmentKeyTests.swift b/Tests/SWBUtilTests/EnvironmentKeyTests.swift new file mode 100644 index 00000000..4cc38480 --- /dev/null +++ b/Tests/SWBUtilTests/EnvironmentKeyTests.swift @@ -0,0 +1,99 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SWBUtil +import Foundation +import Testing + +@Suite fileprivate struct EnvironmentKeyTests { + let isCaseInsensitive: Bool + + init() throws { + isCaseInsensitive = try ProcessInfo.processInfo.hostOperatingSystem() == .windows + } + + @Test func comparable() { + let key0 = EnvironmentKey("Test") + let key1 = EnvironmentKey("Test1") + #expect(key0 < key1) + + let key2 = EnvironmentKey("test") + #expect(key0 < key2) + } + + @Test func customStringConvertible() { + let key = EnvironmentKey("Test") + #expect(key.description == "Test") + } + + @Test func encodable() throws { + let key = EnvironmentKey("Test") + let data = try JSONEncoder().encode(key) + let string = String(data: data, encoding: .utf8) + #expect(string == #""Test""#) + } + + @Test func equatable() { + let key0 = EnvironmentKey("Test") + let key1 = EnvironmentKey("Test") + #expect(key0 == key1) + + let key2 = EnvironmentKey("Test2") + #expect(key0 != key2) + + if isCaseInsensitive { + // Test case insensitivity on windows + let key3 = EnvironmentKey("teSt") + #expect(key0 == key3) + } + } + + @Test func expressibleByStringLiteral() { + let key0 = EnvironmentKey("Test") + #expect(key0 == "Test") + } + + @Test func decodable() throws { + let jsonString = #""Test""# + let data = jsonString.data(using: .utf8)! + let key = try JSONDecoder().decode(EnvironmentKey.self, from: data) + #expect(key.rawValue == "Test") + } + + @Test func hashable() { + var set = Set() + let key0 = EnvironmentKey("Test") + #expect(set.insert(key0).inserted) + + let key1 = EnvironmentKey("Test") + #expect(set.contains(key1)) + #expect(!set.insert(key1).inserted) + + let key2 = EnvironmentKey("Test2") + #expect(!set.contains(key2)) + #expect(set.insert(key2).inserted) + + if isCaseInsensitive { + // Test case insensitivity on windows + let key3 = EnvironmentKey("teSt") + #expect(set.contains(key3)) + #expect(!set.insert(key3).inserted) + } + + #expect(set == ["Test", "Test2"]) + } + + @Test func rawRepresentable() { + let key = EnvironmentKey(rawValue: "Test") + #expect(key?.rawValue == "Test") + } +} diff --git a/Tests/SWBUtilTests/EnvironmentTests.swift b/Tests/SWBUtilTests/EnvironmentTests.swift new file mode 100644 index 00000000..d5eb91f8 --- /dev/null +++ b/Tests/SWBUtilTests/EnvironmentTests.swift @@ -0,0 +1,142 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SWBUtil +import Foundation +import Testing + +@Suite fileprivate struct EnvironmentTests { + let isCaseInsensitive: Bool + + init() throws { + isCaseInsensitive = try ProcessInfo.processInfo.hostOperatingSystem() == .windows + } + + @Test func empty() { + let environment = Environment() + #expect(environment.isEmpty) + } + + @Test func `subscript`() { + var environment = Environment() + let key = EnvironmentKey("TestKey") + environment[key] = "TestValue" + #expect(environment[key] == "TestValue") + } + + @Test func initDictionaryFromSelf() { + let dictionary = [ + "TestKey": "TestValue", + "testKey": "TestValue2", + ] + let environment = Environment(dictionary) + + if isCaseInsensitive { + #expect(environment["TestKey"] == "TestValue2") // uppercase sorts before lowercase, so the second value overwrites the first + #expect(environment.count == 1) + } else { + #expect(environment["TestKey"] == "TestValue") + #expect(environment.count == 2) + } + } + + @Test func initSelfFromDictionary() { + let dictionary = ["TestKey": "TestValue"] + let environment = Environment(dictionary) + #expect(environment["TestKey"] == "TestValue") + } + + func path(_ components: String...) -> String { + components.joined(separator: String(Path.pathEnvironmentSeparator)) + } + + @Test func prependPath() { + var environment = Environment() + let key = EnvironmentKey(UUID().uuidString) + #expect(environment[key] == nil) + + environment.prependPath(key: key, value: "/bin") + #expect(environment[key] == path("/bin")) + + environment.prependPath(key: key, value: "/usr/bin") + #expect(environment[key] == path("/usr/bin", "/bin")) + + environment.prependPath(key: key, value: "/usr/local/bin") + #expect(environment[key] == path("/usr/local/bin", "/usr/bin", "/bin")) + + environment.prependPath(key: key, value: "") + #expect(environment[key] == path("/usr/local/bin", "/usr/bin", "/bin")) + } + + @Test func appendPath() { + var environment = Environment() + let key = EnvironmentKey(UUID().uuidString) + #expect(environment[key] == nil) + + environment.appendPath(key: key, value: "/bin") + #expect(environment[key] == path("/bin")) + + environment.appendPath(key: key, value: "/usr/bin") + #expect(environment[key] == path("/bin", "/usr/bin")) + + environment.appendPath(key: key, value: "/usr/local/bin") + #expect(environment[key] == path("/bin", "/usr/bin", "/usr/local/bin")) + + environment.appendPath(key: key, value: "") + #expect(environment[key] == path("/bin", "/usr/bin", "/usr/local/bin")) + } + + @Test func collection() { + let environment: Environment = ["TestKey": "TestValue"] + #expect(environment.count == 1) + #expect(environment.first?.key == EnvironmentKey("TestKey")) + #expect(environment.first?.value == "TestValue") + } + + @Test func description() { + var environment = Environment() + environment[EnvironmentKey("TestKey")] = "TestValue" + #expect(environment.description == #"["TestKey=TestValue"]"#) + } + + @Test func encodable() throws { + var environment = Environment() + environment["TestKey"] = "TestValue" + let data = try JSONEncoder().encode(environment) + let jsonString = String(decoding: data, as: UTF8.self) + #expect(jsonString == #"{"TestKey":"TestValue"}"#) + } + + @Test func equatable() { + let environment0: Environment = ["TestKey": "TestValue"] + let environment1: Environment = ["TestKey": "TestValue"] + #expect(environment0 == environment1) + + if isCaseInsensitive { + // Test case insensitivity on windows + let environment2: Environment = ["testKey": "TestValue"] + #expect(environment0 == environment2) + } + } + + @Test func expressibleByDictionaryLiteral() { + let environment: Environment = ["TestKey": "TestValue"] + #expect(environment["TestKey"] == "TestValue") + } + + @Test func decodable() throws { + let jsonString = #"{"TestKey":"TestValue"}"# + let data = Data(jsonString.utf8) + let environment = try JSONDecoder().decode(Environment.self, from: data) + #expect(environment[EnvironmentKey("TestKey")] == "TestValue") + } +}