diff --git a/Sources/ArgumentParser/CMakeLists.txt b/Sources/ArgumentParser/CMakeLists.txt index ea7d83f93..77dc92e54 100644 --- a/Sources/ArgumentParser/CMakeLists.txt +++ b/Sources/ArgumentParser/CMakeLists.txt @@ -43,6 +43,7 @@ add_library(ArgumentParser Utilities/CollectionExtensions.swift Utilities/Mutex.swift + Utilities/Foundation.swift Utilities/Platform.swift Utilities/SequenceExtensions.swift Utilities/StringExtensions.swift diff --git a/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift b/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift index 85da31704..c4e7c914d 100644 --- a/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift +++ b/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift @@ -190,10 +190,10 @@ extension CommandInfoV0 { shopt -s extglob set +o history +o posix - local -xr \(CompletionShell.shellEnvironmentVariableName)=bash - local -x \(CompletionShell.shellVersionEnvironmentVariableName) - \(CompletionShell.shellVersionEnvironmentVariableName)="$(IFS='.';printf %s "${BASH_VERSINFO[*]}")" - local -r \(CompletionShell.shellVersionEnvironmentVariableName) + local -xr \(Platform.Environment.Key.shellName.rawValue)=bash + local -x \(Platform.Environment.Key.shellVersion.rawValue) + \(Platform.Environment.Key.shellVersion.rawValue)="$(IFS='.';printf %s "${BASH_VERSINFO[*]}")" + local -r \(Platform.Environment.Key.shellVersion.rawValue) local -r cur="${2}" local -r prev="${3}" diff --git a/Sources/ArgumentParser/Completions/CompletionsGenerator.swift b/Sources/ArgumentParser/Completions/CompletionsGenerator.swift index 87e3932a0..264a2d418 100644 --- a/Sources/ArgumentParser/Completions/CompletionsGenerator.swift +++ b/Sources/ArgumentParser/Completions/CompletionsGenerator.swift @@ -11,14 +11,14 @@ #if compiler(>=6.0) internal import ArgumentParserToolInfo -internal import Foundation #else import ArgumentParserToolInfo -import Foundation #endif /// A shell for which the parser can generate a completion script. -public struct CompletionShell: RawRepresentable, Hashable, CaseIterable { +public struct CompletionShell: RawRepresentable, Hashable, CaseIterable, + Sendable +{ public var rawValue: String /// Creates a new instance from the given string. @@ -87,20 +87,6 @@ public struct CompletionShell: RawRepresentable, Hashable, CaseIterable { Self._requestingVersion.withLock { $0 } } - /// The name of the environment variable whose value is the name of the shell - /// for which completions are being requested from a custom completion - /// handler. - /// - /// The environment variable is set in generated completion scripts. - static let shellEnvironmentVariableName = "SAP_SHELL" - - /// The name of the environment variable whose value is the version of the - /// shell for which completions are being requested from a custom completion - /// handler. - /// - /// The environment variable is set in generated completion scripts. - static let shellVersionEnvironmentVariableName = "SAP_SHELL_VERSION" - func format(completions: [String]) -> String { var completions = completions if self == .zsh { @@ -157,12 +143,82 @@ extension String { func shellEscapeForSingleQuotedString(iterationCount: UInt64 = 1) -> Self { iterationCount == 0 ? self - : replacingOccurrences(of: "'", with: "'\\''") + : self + .replacing("'", with: "'\\''") .shellEscapeForSingleQuotedString(iterationCount: iterationCount - 1) } func shellEscapeForVariableName() -> Self { - replacingOccurrences(of: "-", with: "_") + self.replacing("-", with: "_") + } + + func replacing(_ old: Self, with new: Self) -> Self { + guard !old.isEmpty else { return self } + + var result = "" + var startIndex = self.startIndex + + // Look for occurrences of the old string. + while let matchRange = self.firstMatch(of: old, at: startIndex) { + // Add the substring before the match. + result.append(contentsOf: self[startIndex.. (start: Self.Index, end: Self.Index)? { + guard !match.isEmpty else { return nil } + guard match.count <= self.count else { return nil } + + var startIndex = startIndex + while startIndex < self.endIndex { + // Check if theres a match. + if let endIndex = self.matches(match, at: startIndex) { + // Return the match. + return (startIndex, endIndex) + } + + // Move to the next of index. + self.formIndex(after: &startIndex) + } + + return nil + } + + func matches( + _ match: Self, + at startIndex: Self.Index + ) -> Self.Index? { + var selfIndex = startIndex + var matchIndex = match.startIndex + + while true { + // Only continue checking if there is more match to check + guard matchIndex < match.endIndex else { return selfIndex } + + // Exit early if there is no more "self" to check. + guard selfIndex < self.endIndex else { return nil } + + // Check match and self are the the same. + guard self[selfIndex] == match[matchIndex] else { return nil } + + // Move to the next pair of indices. + self.formIndex(after: &selfIndex) + match.formIndex(after: &matchIndex) + } } } diff --git a/Sources/ArgumentParser/Completions/FishCompletionsGenerator.swift b/Sources/ArgumentParser/Completions/FishCompletionsGenerator.swift index 549fdf846..3591ee252 100644 --- a/Sources/ArgumentParser/Completions/FishCompletionsGenerator.swift +++ b/Sources/ArgumentParser/Completions/FishCompletionsGenerator.swift @@ -11,10 +11,8 @@ #if compiler(>=6.0) internal import ArgumentParserToolInfo -internal import Foundation #else import ArgumentParserToolInfo -import Foundation #endif extension ToolInfoV0 { @@ -75,8 +73,8 @@ extension CommandInfoV0 { end function \(customCompletionFunctionName) - set -x \(CompletionShell.shellEnvironmentVariableName) fish - set -x \(CompletionShell.shellVersionEnvironmentVariableName) $FISH_VERSION + set -x \(Platform.Environment.Key.shellName.rawValue) fish + set -x \(Platform.Environment.Key.shellVersion.rawValue) $FISH_VERSION set -l tokens (\(tokensFunctionName) -p) if test -z (\(tokensFunctionName) -t) @@ -315,8 +313,9 @@ extension String { ) -> Self { iterationCount == 0 ? self - : replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "'", with: "\\'") + : self + .replacing("\\", with: "\\\\") + .replacing("'", with: "\\'") .fishEscapeForSingleQuotedString(iterationCount: iterationCount - 1) } } diff --git a/Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift b/Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift index 4ed8c90cf..2953d9399 100644 --- a/Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift +++ b/Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift @@ -11,10 +11,8 @@ #if compiler(>=6.0) internal import ArgumentParserToolInfo -internal import Foundation #else import ArgumentParserToolInfo -import Foundation #endif extension ToolInfoV0 { @@ -111,10 +109,10 @@ extension CommandInfoV0 { setopt extendedglob nullglob numericglobsort unsetopt aliases banghist - local -xr \(CompletionShell.shellEnvironmentVariableName)=zsh - local -x \(CompletionShell.shellVersionEnvironmentVariableName) - \(CompletionShell.shellVersionEnvironmentVariableName)="$(builtin emulate zsh -c 'printf %s "${ZSH_VERSION}"')" - local -r \(CompletionShell.shellVersionEnvironmentVariableName) + local -xr \(Platform.Environment.Key.shellName.rawValue)=zsh + local -x \(Platform.Environment.Key.shellVersion.rawValue) + \(Platform.Environment.Key.shellVersion.rawValue)="$(builtin emulate zsh -c 'printf %s "${ZSH_VERSION}"')" + local -r \(Platform.Environment.Key.shellVersion.rawValue) local context state state_descr line local -A opt_args @@ -271,19 +269,17 @@ extension ArgumentInfoV0 { extension String { fileprivate func zshEscapeForSingleQuotedDescribeCompletion() -> String { - replacingOccurrences( - of: #"[:\\]"#, - with: #"\\$0"#, - options: .regularExpression - ) - .shellEscapeForSingleQuotedString() + self + .replacing("\\", with: "\\\\") + .replacing(":", with: "\\:") + .shellEscapeForSingleQuotedString() } fileprivate func zshEscapeForSingleQuotedOptionSpec() -> String { - replacingOccurrences( - of: #"[:\\\[\]]"#, - with: #"\\$0"#, - options: .regularExpression - ) - .shellEscapeForSingleQuotedString() + self + .replacing("\\", with: "\\\\") + .replacing(":", with: "\\:") + .replacing("[", with: "\\[") + .replacing("]", with: "\\]") + .shellEscapeForSingleQuotedString() } } diff --git a/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift b/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift index 6eb0d5483..a93308b31 100644 --- a/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift +++ b/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift @@ -9,12 +9,6 @@ // //===----------------------------------------------------------------------===// -#if compiler(>=6.0) -internal import Foundation -#else -import Foundation -#endif - /// A type that can be parsed from a program's command-line arguments. /// /// When you implement a `ParsableArguments` type, all properties must be declared with @@ -41,13 +35,14 @@ struct _WrappedParsableCommand: ParsableCommand { // If the type is named something like "TransformOptions", we only want // to use "transform" as the command name. - if let optionsRange = name.range(of: "_options"), - optionsRange.upperBound == name.endIndex - { - return String(name[.. + parser: + @escaping (InputKey, InputOrigin, Name?, String) throws -> Container.Contained, initial: Container.Initial?, completion: CompletionKind? diff --git a/Sources/ArgumentParser/Parsing/CommandParser.swift b/Sources/ArgumentParser/Parsing/CommandParser.swift index e4b42f440..b3296d675 100644 --- a/Sources/ArgumentParser/Parsing/CommandParser.swift +++ b/Sources/ArgumentParser/Parsing/CommandParser.swift @@ -13,14 +13,10 @@ #if canImport(Dispatch) @preconcurrency private import class Dispatch.DispatchSemaphore #endif -internal import class Foundation.NSLock -internal import class Foundation.ProcessInfo #else #if canImport(Dispatch) @preconcurrency import class Dispatch.DispatchSemaphore #endif -import class Foundation.NSLock -import class Foundation.ProcessInfo #endif struct CommandError: Error { @@ -455,16 +451,13 @@ extension CommandParser { _ argument: ArgumentDefinition, forArguments args: [String] ) throws { - let environment = ProcessInfo.processInfo.environment - if let completionShellName = environment[ - CompletionShell.shellEnvironmentVariableName] - { + if let completionShellName = Platform.Environment[.shellName] { let shell = CompletionShell(rawValue: completionShellName) CompletionShell._requesting.withLock { $0 = shell } } CompletionShell._requestingVersion.withLock { - $0 = environment[CompletionShell.shellVersionEnvironmentVariableName] + $0 = Platform.Environment[.shellVersion] } let completions: [String] @@ -550,46 +543,23 @@ private func asyncCustomCompletions( let (args, completingArgumentIndex, completingPrefix) = try parseCustomCompletionArguments(from: args) - let completionsBox = SendableBox<[String]>([]) + let completionsBox = Mutex<[String]>([]) let semaphore = DispatchSemaphore(value: 0) Task { - completionsBox.value = await complete( + let completion = await complete( args, completingArgumentIndex, - completingPrefix - ) + completingPrefix) + completionsBox.withLock { $0 = completion } semaphore.signal() } semaphore.wait() - return completionsBox.value + return completionsBox.withLock { $0 } #endif } -// Helper class to make values sendable across concurrency boundaries -private final class SendableBox: @unchecked Sendable { - private let lock = NSLock() - private var _value: T - - init(_ value: T) { - self._value = value - } - - var value: T { - get { - lock.lock() - defer { lock.unlock() } - return _value - } - set { - lock.lock() - defer { lock.unlock() } - _value = newValue - } - } -} - // MARK: Building Command Stacks extension CommandParser { diff --git a/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift b/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift index 895b27a6d..3ed2ac260 100644 --- a/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift +++ b/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift @@ -11,10 +11,8 @@ #if compiler(>=6.0) internal import ArgumentParserToolInfo -internal import class Foundation.JSONEncoder #else import ArgumentParserToolInfo -import class Foundation.JSONEncoder #endif internal struct DumpHelpGenerator { @@ -29,11 +27,7 @@ internal struct DumpHelpGenerator { } func rendered() -> String { - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - encoder.outputFormatting.insert(.sortedKeys) - guard let encoded = try? encoder.encode(self.toolInfo) else { return "" } - return String(data: encoded, encoding: .utf8) ?? "" + JSONEncoder.encode(self.toolInfo) } } diff --git a/Sources/ArgumentParser/Usage/MessageInfo.swift b/Sources/ArgumentParser/Usage/MessageInfo.swift index 7d0c8a2c3..8bf4528c1 100644 --- a/Sources/ArgumentParser/Usage/MessageInfo.swift +++ b/Sources/ArgumentParser/Usage/MessageInfo.swift @@ -9,14 +9,6 @@ // //===----------------------------------------------------------------------===// -#if compiler(>=6.0) -internal import protocol Foundation.LocalizedError -internal import class Foundation.NSError -#else -import protocol Foundation.LocalizedError -import class Foundation.NSError -#endif - enum MessageInfo { case help(text: String) case validation(message: String, usage: String, help: String) @@ -130,16 +122,8 @@ enum MessageInfo { } case let exitCode as ExitCode: self = .other(message: "", exitCode: exitCode) - case let error as LocalizedError where error.errorDescription != nil: - // swift-format-ignore: NeverForceUnwrap - // No way to unwrap bind description in pattern - self = .other(message: error.errorDescription!, exitCode: .failure) default: - if Swift.type(of: error) is NSError.Type { - self = .other(message: error.localizedDescription, exitCode: .failure) - } else { - self = .other(message: String(describing: error), exitCode: .failure) - } + self = .other(message: error.describe(), exitCode: .failure) } } else if let parserError = parserError { let usage: String = { diff --git a/Sources/ArgumentParser/Usage/UsageGenerator.swift b/Sources/ArgumentParser/Usage/UsageGenerator.swift index f73d80d1f..154ac90bf 100644 --- a/Sources/ArgumentParser/Usage/UsageGenerator.swift +++ b/Sources/ArgumentParser/Usage/UsageGenerator.swift @@ -9,12 +9,6 @@ // //===----------------------------------------------------------------------===// -#if compiler(>=6.0) -internal import protocol Foundation.LocalizedError -#else -import protocol Foundation.LocalizedError -#endif - struct UsageGenerator { var toolName: String var definition: ArgumentSet @@ -223,21 +217,14 @@ extension ErrorMessageGenerator { case .missingSubcommand: return "Missing required subcommand." case .userValidationError(let error): - switch error { - case let error as LocalizedError: - return error.errorDescription - default: - return String(describing: error) - } + return error.describe() case .noArguments(let error): switch error { case let error as ParserError: return ErrorMessageGenerator(arguments: self.arguments, error: error) .makeErrorMessage() - case let error as LocalizedError: - return error.errorDescription default: - return String(describing: error) + return error.describe() } } } @@ -479,18 +466,13 @@ extension ErrorMessageGenerator { // We favor `LocalizedError.errorDescription` and fall back to // `CustomStringConvertible`. To opt in, return your custom error message // as the `description` property of `CustomStringConvertible`. - let customErrorMessage: String = { - switch error { - case let err as LocalizedError where err.errorDescription != nil: - // swift-format-ignore: NeverForceUnwrap - // Checked above that this will not be nil - return ": " + err.errorDescription! - case let err?: - return ": " + String(describing: err) - default: - return argumentValue?.formattedValueList ?? "" - } - }() + let customErrorMessage: String + switch error { + case .some(let error): + customErrorMessage = ": " + error.describe() + case .none: + customErrorMessage = argumentValue?.formattedValueList ?? "" + } switch (name, valueName) { case (let n?, let v?): diff --git a/Sources/ArgumentParser/Utilities/Foundation.swift b/Sources/ArgumentParser/Utilities/Foundation.swift new file mode 100644 index 000000000..ef13551a6 --- /dev/null +++ b/Sources/ArgumentParser/Utilities/Foundation.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +#if compiler(>=6.0) +#if canImport(FoundationEssentials) +internal import FoundationEssentials +#else +internal import Foundation +#endif +#else +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif +#endif + +extension Error { + func describe() -> String { + if let description = (self as? LocalizedError)?.errorDescription { + return description + } else { + #if canImport(FoundationEssentials) + return String(describing: self) + #else + if Swift.type(of: self) is NSError.Type { + return self.localizedDescription + } else { + return String(describing: self) + } + #endif + } + } +} + +enum JSONEncoder { + static func encode(_ value: T) -> String { + #if canImport(FoundationEssentials) + let encoder = FoundationEssentials.JSONEncoder() + #else + let encoder = Foundation.JSONEncoder() + #endif + encoder.outputFormatting = .prettyPrinted + encoder.outputFormatting.insert(.sortedKeys) + guard let encoded = try? encoder.encode(value) else { return "" } + return String(data: encoded, encoding: .utf8) ?? "" + } +} diff --git a/Sources/ArgumentParser/Utilities/Mutex.swift b/Sources/ArgumentParser/Utilities/Mutex.swift index 3a3c4244b..129d0433c 100644 --- a/Sources/ArgumentParser/Utilities/Mutex.swift +++ b/Sources/ArgumentParser/Utilities/Mutex.swift @@ -9,52 +9,135 @@ // //===----------------------------------------------------------------------===// -#if compiler(>=6.0) -internal import Foundation -#else -import Foundation +#if canImport(os) +internal import os +#if canImport(C.os.lock) +internal import C.os.lock #endif +#elseif canImport(Bionic) +@preconcurrency import Bionic +#elseif canImport(Glibc) +@preconcurrency import Glibc +#elseif canImport(Musl) +@preconcurrency import Musl +#elseif canImport(WinSDK) +import WinSDK +#endif + +struct Mutex { + // Internal implementation for a cheap lock to aid sharing code across platforms + private struct _Lock { + #if canImport(os) + typealias Primitive = os_unfair_lock + #elseif os(FreeBSD) || os(OpenBSD) + typealias Primitive = pthread_mutex_t? + #elseif canImport(Bionic) || canImport(Glibc) || canImport(Musl) + typealias Primitive = pthread_mutex_t + #elseif canImport(WinSDK) + typealias Primitive = SRWLOCK + #elseif os(WASI) + // WASI is single-threaded, so we don't need a lock. + typealias Primitive = Void + #endif + + typealias PlatformLock = UnsafeMutablePointer + var _platformLock: PlatformLock + + fileprivate static func initialize(_ platformLock: PlatformLock) { + #if canImport(os) + platformLock.initialize(to: os_unfair_lock()) + #elseif canImport(Bionic) || canImport(Glibc) || canImport(Musl) + pthread_mutex_init(platformLock, nil) + #elseif canImport(WinSDK) + InitializeSRWLock(platformLock) + #elseif os(WASI) + // no-op + #else + #error("Lock._Lock.initialize is unimplemented on this platform") + #endif + } + + fileprivate static func deinitialize(_ platformLock: PlatformLock) { + #if canImport(Bionic) || canImport(Glibc) || canImport(Musl) + pthread_mutex_destroy(platformLock) + #endif + platformLock.deinitialize(count: 1) + } + + static fileprivate func lock(_ platformLock: PlatformLock) { + #if canImport(os) + os_unfair_lock_lock(platformLock) + #elseif canImport(Bionic) || canImport(Glibc) || canImport(Musl) + pthread_mutex_lock(platformLock) + #elseif canImport(WinSDK) + AcquireSRWLockExclusive(platformLock) + #elseif os(WASI) + // no-op + #else + #error("Lock._Lock.lock is unimplemented on this platform") + #endif + } + + static fileprivate func unlock(_ platformLock: PlatformLock) { + #if canImport(os) + os_unfair_lock_unlock(platformLock) + #elseif canImport(Bionic) || canImport(Glibc) || canImport(Musl) + pthread_mutex_unlock(platformLock) + #elseif canImport(WinSDK) + ReleaseSRWLockExclusive(platformLock) + #elseif os(WASI) + // no-op + #else + #error("Lock._Lock.unlock is unimplemented on this platform") + #endif + } + } -/// A synchronization primitive that protects shared mutable state via mutual -/// exclusion. -/// -/// The `Mutex` type offers non-recursive exclusive access to the state it is -/// protecting by blocking threads attempting to acquire the lock. Only one -/// execution context at a time has access to the value stored within the -/// `Mutex` allowing for exclusive access. -class Mutex: @unchecked Sendable { - /// The lock used to synchronize access to the value. - var lock: NSLock - /// The value protected by the mutex. - var value: T - - /// Initializes a new `Mutex` with the provided value. - /// - /// - Parameter value: The initial value to be protected by the mutex. - init(_ value: T) { - self.lock = .init() - self.value = value + private class _Buffer: ManagedBuffer { + deinit { + withUnsafeMutablePointerToElements { + _Lock.deinitialize($0) + } + } } - /// Calls the given closure after acquiring the lock and then releases - /// ownership. - /// - /// - Warning: Recursive calls to `withLock` within the closure parameter has - /// behavior that is platform dependent. Some platforms may choose to panic - /// the process, deadlock, or leave this behavior unspecified. This will - /// never reacquire the lock however. - /// - /// - Parameter body: A closure with a parameter of `Value` that has exclusive - /// access to the value being stored within this mutex. This closure is - /// considered the critical section as it will only be executed once the - /// calling thread has acquired the lock. - /// - /// - Returns: The return value, if any, of the `body` closure parameter. - func withLock( - _ body: (inout T) throws -> U - ) rethrows -> U { - self.lock.lock() - defer { self.lock.unlock() } - return try body(&self.value) + private let _buffer: ManagedBuffer + + init(_ initialState: State) { + _buffer = _Buffer.create( + minimumCapacity: 1, + makingHeaderWith: { buf in + buf.withUnsafeMutablePointerToElements { + _Lock.initialize($0) + } + return initialState + }) + } + + func withLock(_ body: @Sendable (inout State) throws -> T) rethrows -> T { + try withLockUnchecked(body) + } + + func withLockUnchecked(_ body: (inout State) throws -> T) rethrows -> T { + try _buffer.withUnsafeMutablePointers { state, lock in + _Lock.lock(lock) + defer { _Lock.unlock(lock) } + return try body(&state.pointee) + } + } + + // Ensures the managed state outlives the locked scope. + func withLockExtendingLifetimeOfState( + _ body: @Sendable (inout State) throws -> T + ) rethrows -> T { + try _buffer.withUnsafeMutablePointers { state, lock in + _Lock.lock(lock) + return try withExtendedLifetime(state.pointee) { + defer { _Lock.unlock(lock) } + return try body(&state.pointee) + } + } } } + +extension Mutex: @unchecked Sendable where State: Sendable {} diff --git a/Sources/ArgumentParser/Utilities/Platform.swift b/Sources/ArgumentParser/Utilities/Platform.swift index 788697a7e..9364a1590 100644 --- a/Sources/ArgumentParser/Utilities/Platform.swift +++ b/Sources/ArgumentParser/Utilities/Platform.swift @@ -32,6 +32,87 @@ import Darwin enum Platform {} +// MARK: Environment + +extension Platform { + enum Environment { + struct Key { + static let shell = Self(rawValue: "SHELL") + static let columns = Self(rawValue: "COLUMNS") + static let lines = Self(rawValue: "LINES") + + /// The name of the environment variable whose value is the name of the shell + /// for which completions are being requested from a custom completion + /// handler. + /// + /// The environment variable is set in generated completion scripts. + static let shellName = Self(rawValue: "SAP_SHELL") + + /// The name of the environment variable whose value is the version of the + /// shell for which completions are being requested from a custom completion + /// handler. + /// + /// The environment variable is set in generated completion scripts. + static let shellVersion = Self(rawValue: "SAP_SHELL_VERSION") + + var rawValue: String + } + + @_disfavoredOverload + static subscript(_ key: Key) -> String? { + get { + #if !os(Windows) && !os(WASI) + guard let cString = getenv(key.rawValue) else { return nil } + return String(cString: cString) + #else + return nil + #endif + } + set { + #if !os(Windows) && !os(WASI) + if let newValue = newValue { + setenv(key.rawValue, newValue, 1) + } else { + unsetenv(key.rawValue) + } + #endif + } + } + + static subscript(_ key: Key, as _: Value.Type) -> Value? + where Value: LosslessStringConvertible + { + get { + guard let stringValue = self[key] else { return nil } + return Value(stringValue) + } + set { + if let newValue = newValue { + self[key] = newValue.description + } else { + self[key] = nil + } + } + } + + static subscript(_ key: Key, as _: Value.Type) -> Value? + where Value: RawRepresentable, Value.RawValue == String + { + get { + guard let stringValue = self[key] else { return nil } + return Value(rawValue: stringValue) + } + set { + if let newValue = newValue { + self[key] = newValue.rawValue + } else { + self[key] = nil + } + } + } + } +} + // MARK: Shell extension Platform { @@ -42,8 +123,8 @@ extension Platform { return nil #else // FIXME: This retrieves the user's preferred shell, not necessarily the one currently in use. - guard let shellVar = getenv("SHELL") else { return nil } - let shellParts = String(cString: shellVar).split(separator: "/") + guard let shellVar = Environment[.shell] else { return nil } + let shellParts = shellVar.split(separator: "/") return shellParts.last.map(String.init) #endif } @@ -167,15 +248,11 @@ extension Platform { var height: Int? = nil #if !os(Windows) && !os(WASI) - if let colsCStr = getenv("COLUMNS"), - let colsVal = Int(String(cString: colsCStr)) - { - width = colsVal + if let columns = Platform.Environment[.columns, as: Int.self] { + width = columns } - if let linesCStr = getenv("LINES"), - let linesVal = Int(String(cString: linesCStr)) - { - height = linesVal + if let lines = Platform.Environment[.lines, as: Int.self] { + height = lines } #endif diff --git a/Sources/ArgumentParserTestHelpers/CMakeLists.txt b/Sources/ArgumentParserTestHelpers/CMakeLists.txt index 2af1eb833..3b6d74fdc 100644 --- a/Sources/ArgumentParserTestHelpers/CMakeLists.txt +++ b/Sources/ArgumentParserTestHelpers/CMakeLists.txt @@ -1,5 +1,4 @@ add_library(ArgumentParserTestHelpers - StringHelpers.swift TestHelpers.swift) set_target_properties(ArgumentParserTestHelpers PROPERTIES INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY}) diff --git a/Sources/ArgumentParserTestHelpers/StringHelpers.swift b/Sources/ArgumentParserTestHelpers/StringHelpers.swift deleted file mode 100644 index 639ed4479..000000000 --- a/Sources/ArgumentParserTestHelpers/StringHelpers.swift +++ /dev/null @@ -1,40 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift Argument Parser open source project -// -// Copyright (c) 2020 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// -//===----------------------------------------------------------------------===// - -#if compiler(>=6.0) -internal import Foundation -#else -import Foundation -#endif - -extension Substring { - func trimmed() -> Substring { - guard let i = lastIndex(where: { $0 != " " }) else { - return "" - } - return self[...i] - } -} - -extension String { - public func trimmingLines() -> String { - self - .split(separator: "\n", omittingEmptySubsequences: false) - .map { $0.trimmed() } - .joined(separator: "\n") - } - - public func normalizingLineEndings() -> String { - self - .replacingOccurrences(of: "\r\n", with: "\n") - .replacingOccurrences(of: "\r", with: "\n") - } -} diff --git a/Sources/ArgumentParserTestHelpers/TestHelpers.swift b/Sources/ArgumentParserTestHelpers/TestHelpers.swift index a71e324f8..91a1eadd4 100644 --- a/Sources/ArgumentParserTestHelpers/TestHelpers.swift +++ b/Sources/ArgumentParserTestHelpers/TestHelpers.swift @@ -162,8 +162,14 @@ public func AssertEqualStrings( line: UInt = #line ) { // Normalize line endings to '\n'. - let actual = actual.normalizingLineEndings() - let expected = expected.normalizingLineEndings() + let actual = + actual + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + let expected = + expected + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") // If the input strings are not equal, create a simple diff for debugging... guard actual != expected else { diff --git a/Tests/ArgumentParserExampleTests/CountLinesExampleTests.swift b/Tests/ArgumentParserExampleTests/CountLinesExampleTests.swift index 4cfae3dc8..29373de9b 100644 --- a/Tests/ArgumentParserExampleTests/CountLinesExampleTests.swift +++ b/Tests/ArgumentParserExampleTests/CountLinesExampleTests.swift @@ -13,12 +13,11 @@ import XCTest import ArgumentParserTestHelpers +@testable import ArgumentParser final class CountLinesExampleTests: XCTestCase { override func setUp() { - #if !os(Windows) && !os(WASI) - unsetenv("COLUMNS") - #endif + Platform.Environment[.columns] = nil } func testCountLines() throws { diff --git a/Tests/ArgumentParserExampleTests/MathExampleTests.swift b/Tests/ArgumentParserExampleTests/MathExampleTests.swift index 2c9a666cb..64f1ac0e0 100644 --- a/Tests/ArgumentParserExampleTests/MathExampleTests.swift +++ b/Tests/ArgumentParserExampleTests/MathExampleTests.swift @@ -16,9 +16,7 @@ import XCTest final class MathExampleTests: XCTestCase { override func setUp() { - #if !os(Windows) && !os(WASI) - unsetenv("COLUMNS") - #endif + Platform.Environment[.columns] = nil } func testMath_Simple() throws { @@ -255,7 +253,7 @@ extension MathExampleTests { "heliotrope", ]) + "\n", environment: [ - CompletionShell.shellEnvironmentVariableName: shell.rawValue + Platform.Environment.Key.shellName.rawValue: shell.rawValue ] ) @@ -267,7 +265,7 @@ extension MathExampleTests { "heliotrope", ]) + "\n", environment: [ - CompletionShell.shellEnvironmentVariableName: shell.rawValue + Platform.Environment.Key.shellName.rawValue: shell.rawValue ] ) @@ -278,7 +276,7 @@ extension MathExampleTests { "aaaaalbert", ]) + "\n", environment: [ - CompletionShell.shellEnvironmentVariableName: shell.rawValue + Platform.Environment.Key.shellName.rawValue: shell.rawValue ] ) } diff --git a/Tests/ArgumentParserExampleTests/RepeatExampleTests.swift b/Tests/ArgumentParserExampleTests/RepeatExampleTests.swift index 4410289eb..ae9882c34 100644 --- a/Tests/ArgumentParserExampleTests/RepeatExampleTests.swift +++ b/Tests/ArgumentParserExampleTests/RepeatExampleTests.swift @@ -9,15 +9,14 @@ // //===----------------------------------------------------------------------===// -import ArgumentParser import ArgumentParserTestHelpers import XCTest +@testable import ArgumentParser + final class RepeatExampleTests: XCTestCase { override func setUp() { - #if !os(Windows) && !os(WASI) - unsetenv("COLUMNS") - #endif + Platform.Environment[.columns] = nil } func testRepeat() throws { diff --git a/Tests/ArgumentParserExampleTests/RollDiceExampleTests.swift b/Tests/ArgumentParserExampleTests/RollDiceExampleTests.swift index e8abeaf21..5144f8ca7 100644 --- a/Tests/ArgumentParserExampleTests/RollDiceExampleTests.swift +++ b/Tests/ArgumentParserExampleTests/RollDiceExampleTests.swift @@ -9,15 +9,14 @@ // //===----------------------------------------------------------------------===// -import ArgumentParser import ArgumentParserTestHelpers import XCTest +@testable import ArgumentParser + final class RollDiceExampleTests: XCTestCase { override func setUp() { - #if !os(Windows) && !os(WASI) - unsetenv("COLUMNS") - #endif + Platform.Environment[.columns] = nil } func testRollDice() throws { diff --git a/Tests/ArgumentParserPackageManagerTests/HelpTests.swift b/Tests/ArgumentParserPackageManagerTests/HelpTests.swift index 8ebad46b1..62c87a287 100644 --- a/Tests/ArgumentParserPackageManagerTests/HelpTests.swift +++ b/Tests/ArgumentParserPackageManagerTests/HelpTests.swift @@ -16,9 +16,7 @@ import XCTest final class HelpTests: XCTestCase { override func setUp() { - #if !os(Windows) && !os(WASI) - unsetenv("COLUMNS") - #endif + Platform.Environment[.columns] = nil } } @@ -55,7 +53,7 @@ func getErrorText( extension HelpTests { func testGlobalHelp() throws { XCTAssertEqual( - getErrorText(Package.self, ["help"]).trimmingLines(), + getErrorText(Package.self, ["help"]), """ USAGE: package @@ -69,12 +67,12 @@ extension HelpTests { generate-xcodeproj See 'package help ' for detailed help. - """.trimmingLines()) + """) } func testGlobalHelp_messageForCleanExit_helpRequest() throws { XCTAssertEqual( - Package.message(for: CleanExit.helpRequest()).trimmingLines(), + Package.message(for: CleanExit.helpRequest()), """ USAGE: package @@ -88,22 +86,21 @@ extension HelpTests { generate-xcodeproj See 'package help ' for detailed help. - """.trimmingLines() + """ ) } func testGlobalHelp_messageForCleanExit_message() throws { let expectedMessage = "Failure" XCTAssertEqual( - Package.message(for: CleanExit.message(expectedMessage)).trimmingLines(), + Package.message(for: CleanExit.message(expectedMessage)), expectedMessage ) } func testConfigHelp() throws { XCTAssertEqual( - getErrorText(Package.self, ["help", "config"], screenWidth: 80) - .trimmingLines(), + getErrorText(Package.self, ["help", "config"], screenWidth: 80), """ USAGE: package config @@ -116,14 +113,14 @@ extension HelpTests { unset-mirror See 'package help config ' for detailed help. - """.trimmingLines()) + """) } func testGetMirrorHelp() throws { XCTAssertEqual( getErrorText( Package.self, ["help", "config", "get-mirror"], screenWidth: 80 - ).trimmingLines(), + ), """ USAGE: package config get-mirror [] --package-url @@ -169,7 +166,7 @@ extension HelpTests { The package dependency URL -h, --help Show help information. - """.trimmingLines()) + """) } } @@ -189,16 +186,16 @@ struct Simple: ParsableArguments { --min -h, --help Show help information. - """.trimmingLines() + """ } extension HelpTests { func testSimpleHelp() throws { XCTAssertEqual( - getErrorText(Simple.self, ["--help"]).trimmingLines(), + getErrorText(Simple.self, ["--help"]), Simple.helpText) XCTAssertEqual( - getErrorText(Simple.self, ["-h"]).trimmingLines(), + getErrorText(Simple.self, ["-h"]), Simple.helpText) } } @@ -250,7 +247,7 @@ extension HelpTests { """) XCTAssertEqual( - NoHelp.message(for: CleanExit.helpRequest()).trimmingLines(), + NoHelp.message(for: CleanExit.helpRequest()), """ USAGE: no-help --count diff --git a/Tests/ArgumentParserPackageManagerTests/Tests.swift b/Tests/ArgumentParserPackageManagerTests/Tests.swift index 7c9775db6..212cdb213 100644 --- a/Tests/ArgumentParserPackageManagerTests/Tests.swift +++ b/Tests/ArgumentParserPackageManagerTests/Tests.swift @@ -9,15 +9,14 @@ // //===----------------------------------------------------------------------===// -import ArgumentParser import ArgumentParserTestHelpers import XCTest +@testable import ArgumentParser + final class Tests: XCTestCase { override func setUp() { - #if !os(Windows) && !os(WASI) - unsetenv("COLUMNS") - #endif + Platform.Environment[.columns] = nil } } diff --git a/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift b/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift index 1d56a6edd..2e25e31f4 100644 --- a/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift +++ b/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift @@ -193,8 +193,8 @@ extension CompletionScriptTests { ) throws { #if !os(Windows) && !os(WASI) do { - setenv(CompletionShell.shellEnvironmentVariableName, shell.rawValue, 1) - defer { unsetenv(CompletionShell.shellEnvironmentVariableName) } + Platform.Environment[.shellName, as: CompletionShell.self] = shell + defer { Platform.Environment[.shellName] = nil } _ = try Custom.parse(["---completion", "--", arg, "0", "0"]) XCTFail("Didn't error as expected", file: file, line: line) } catch let error as CommandError { diff --git a/Tests/ArgumentParserUnitTests/ExitCodeTests.swift b/Tests/ArgumentParserUnitTests/ExitCodeTests.swift index fbe62da78..137fcdc0e 100644 --- a/Tests/ArgumentParserUnitTests/ExitCodeTests.swift +++ b/Tests/ArgumentParserUnitTests/ExitCodeTests.swift @@ -85,18 +85,34 @@ extension ExitCodeTests { extension ExitCodeTests { func testNSErrorIsHandled() { struct NSErrorCommand: ParsableCommand { + static let message = + "The file “foo/bar” couldn’t be opened because there is no such file" + static let fileNotFoundNSError = NSError( - domain: "", code: 1, - userInfo: [ - NSLocalizedDescriptionKey: - "The file “foo/bar” couldn’t be opened because there is no such file" - ]) + domain: "TestError", + code: 1, + userInfo: [NSLocalizedDescriptionKey: Self.message]) } XCTAssertEqual( NSErrorCommand.exitCode(for: NSErrorCommand.fileNotFoundNSError), ExitCode(rawValue: 1)) + + #if canImport(FoundationEssentials) + let prefix = "Error Domain=TestError Code=1 \"(null)\"" + #if compiler(<6.1) + XCTAssertEqual( + NSErrorCommand.message(for: NSErrorCommand.fileNotFoundNSError), + "\(prefix)") + #else + XCTAssertEqual( + NSErrorCommand.message(for: NSErrorCommand.fileNotFoundNSError), + "\(prefix)UserInfo={NSLocalizedDescription=\(NSErrorCommand.message)}" + ) + #endif + #else XCTAssertEqual( NSErrorCommand.message(for: NSErrorCommand.fileNotFoundNSError), - "The file “foo/bar” couldn’t be opened because there is no such file") + NSErrorCommand.message) + #endif } } diff --git a/Tests/ArgumentParserUnitTests/HelpGenerationTests.swift b/Tests/ArgumentParserUnitTests/HelpGenerationTests.swift index 198834936..fa0a2c6b1 100644 --- a/Tests/ArgumentParserUnitTests/HelpGenerationTests.swift +++ b/Tests/ArgumentParserUnitTests/HelpGenerationTests.swift @@ -1372,8 +1372,8 @@ extension HelpGenerationTests { func testColumnsEnvironmentOverride() throws { #if !(os(Windows) || os(WASI)) - defer { unsetenv("COLUMNS") } - unsetenv("COLUMNS") + defer { Platform.Environment[.columns] = nil } + Platform.Environment[.columns] = nil AssertHelp( .default, for: WideHelp.self, columns: nil, equals: """ @@ -1387,7 +1387,7 @@ extension HelpGenerationTests { """) - setenv("COLUMNS", "60", 1) + Platform.Environment[.columns, as: Int.self] = 60 AssertHelp( .default, for: WideHelp.self, columns: nil, equals: """ @@ -1402,7 +1402,7 @@ extension HelpGenerationTests { """) - setenv("COLUMNS", "79", 1) + Platform.Environment[.columns, as: Int.self] = 79 AssertHelp( .default, for: WideHelp.self, columns: nil, equals: """