diff --git a/Sources/FoundationEssentials/CodableUtilities.swift b/Sources/FoundationEssentials/CodableUtilities.swift index fcdc15e98..121bdd8ed 100644 --- a/Sources/FoundationEssentials/CodableUtilities.swift +++ b/Sources/FoundationEssentials/CodableUtilities.swift @@ -166,6 +166,7 @@ extension UInt8 { internal static var _exclamation: UInt8 { UInt8(ascii: "!") } internal static var _ampersand: UInt8 { UInt8(ascii: "&") } internal static var _pipe: UInt8 { UInt8(ascii: "|") } + internal static var _percent: UInt8 { UInt8(ascii: "%") } internal static var _period: UInt8 { UInt8(ascii: ".") } internal static var _e: UInt8 { UInt8(ascii: "e") } internal static var _E: UInt8 { UInt8(ascii: "E") } diff --git a/Sources/FoundationEssentials/String/String+Path.swift b/Sources/FoundationEssentials/String/String+Path.swift index 31d1ff52a..975d3f51b 100644 --- a/Sources/FoundationEssentials/String/String+Path.swift +++ b/Sources/FoundationEssentials/String/String+Path.swift @@ -210,10 +210,11 @@ extension String { guard !pathExtension.isEmpty, validatePathExtension(pathExtension) else { return self } + if self == "/" { return "/.\(pathExtension)"} var result = self._droppingTrailingSlashes - guard result != "/" else { + if result == "/" { // Path was all slashes - return self + ".\(pathExtension)" + return Substring(self.utf8.dropLast()) + ".\(pathExtension)/" } result += ".\(pathExtension)" if utf8.last == ._slash { @@ -404,6 +405,13 @@ extension String { } } + internal var _droppingTrailingSlash: String { + guard utf8.last == ._slash, utf8.count > 1 else { + return self + } + return String(Substring(utf8.dropLast())) + } + internal var _droppingTrailingSlashes: String { guard !self.isEmpty else { return self diff --git a/Sources/FoundationEssentials/URL/CMakeLists.txt b/Sources/FoundationEssentials/URL/CMakeLists.txt index 90407d9bc..3fe555e7e 100644 --- a/Sources/FoundationEssentials/URL/CMakeLists.txt +++ b/Sources/FoundationEssentials/URL/CMakeLists.txt @@ -14,6 +14,10 @@ target_sources(FoundationEssentials PRIVATE URL.swift + URL_Bridge.swift + URL_ObjC.swift + URL_Protocol.swift + URL_Swift.swift URLComponents.swift URLComponents_ObjC.swift URLParser.swift) diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index 5d006d1d7..8b6d8fa37 100644 --- a/Sources/FoundationEssentials/URL/URL.swift +++ b/Sources/FoundationEssentials/URL/URL.swift @@ -13,20 +13,6 @@ public struct URLResourceKey {} #endif -#if canImport(Darwin) -import Darwin -#elseif canImport(Android) -@preconcurrency import Android -#elseif canImport(Glibc) -@preconcurrency import Glibc -#elseif canImport(Musl) -@preconcurrency import Musl -#elseif os(Windows) -import WinSDK -#elseif os(WASI) -@preconcurrency import WASILibc -#endif - #if FOUNDATION_FRAMEWORK internal import _ForSwiftFoundation internal import CoreFoundation_Private.CFURL @@ -622,6 +608,9 @@ extension URLResourceValues : Sendable {} internal func foundation_swift_url_enabled() -> Bool { return _foundation_swift_url_feature_enabled() } +internal func foundation_swift_nsurl_enabled() -> Bool { + return _foundation_swift_nsurl_feature_enabled() +} #else internal func foundation_swift_url_enabled() -> Bool { return true } #endif @@ -644,161 +633,40 @@ public struct URL: Equatable, Sendable, Hashable { }() #endif - internal static let fileIDPrefix = Array("/.file/id=".utf8) - #if FOUNDATION_FRAMEWORK + private static var _type: any _URLProtocol.Type { + return foundation_swift_url_enabled() ? _SwiftURL.self : _BridgedURL.self + } +#else + private static let _type = _SwiftURL.self +#endif - private var _url: NSURL - - private static func _nsURL(from parseInfo: URLParseInfo, baseParseInfo: URLParseInfo?) -> NSURL { - var baseURL: CFURL? - if let baseParseInfo { - baseURL = _cfURL(from: baseParseInfo, baseURL: nil) - } - return _cfURL(from: parseInfo, baseURL: baseURL) as NSURL - } - - struct _CFURLFlags: OptionSet { - let rawValue: UInt32 - - // These must match the CFURL flags defined in CFURL.m - static let hasScheme = _CFURLFlags(rawValue: 0x00000001) - static let hasUser = _CFURLFlags(rawValue: 0x00000002) - static let hasPassword = _CFURLFlags(rawValue: 0x00000004) - static let hasHost = _CFURLFlags(rawValue: 0x00000008) - static let hasPort = _CFURLFlags(rawValue: 0x00000010) - static let hasPath = _CFURLFlags(rawValue: 0x00000020) - static let hasParameters = _CFURLFlags(rawValue: 0x00000040) // Unused - static let hasQuery = _CFURLFlags(rawValue: 0x00000080) - static let hasFragment = _CFURLFlags(rawValue: 0x00000100) - static let isIPLiteral = _CFURLFlags(rawValue: 0x00000400) - static let isDirectory = _CFURLFlags(rawValue: 0x00000800) - static let isCanonicalFileURL = _CFURLFlags(rawValue: 0x00001000) // Unused - static let pathHasFileID = _CFURLFlags(rawValue: 0x00002000) - static let isDecomposable = _CFURLFlags(rawValue: 0x00004000) - static let posixAndURLPathsMatch = _CFURLFlags(rawValue: 0x00008000) - static let originalAndURLStringsMatch = _CFURLFlags(rawValue: 0x00010000) - static let originatedFromSwift = _CFURLFlags(rawValue: 0x00020000) - } - - private static func _cfURL(from parseInfo: URLParseInfo, baseURL: CFURL?) -> CFURL { - let string = parseInfo.urlString - var ranges = [CFRange]() - var flags: _CFURLFlags = [ - .originalAndURLStringsMatch, - .originatedFromSwift, - ] - - // CFURL considers a URL decomposable if it does not have a scheme - // or if there is a slash directly following the scheme. - if parseInfo.scheme == nil || parseInfo.hasAuthority || parseInfo.path.utf8.first == ._slash { - flags.insert(.isDecomposable) - } - - if let schemeRange = parseInfo.schemeRange { - flags.insert(.hasScheme) - let nsRange = string._toRelativeNSRange(schemeRange) - ranges.append(CFRange(location: nsRange.location, length: nsRange.length)) - } - - if let userRange = parseInfo.userRange { - flags.insert(.hasUser) - let nsRange = string._toRelativeNSRange(userRange) - ranges.append(CFRange(location: nsRange.location, length: nsRange.length)) - } - - if let passwordRange = parseInfo.passwordRange { - flags.insert(.hasPassword) - let nsRange = string._toRelativeNSRange(passwordRange) - ranges.append(CFRange(location: nsRange.location, length: nsRange.length)) - } - - if parseInfo.portRange != nil { - flags.insert(.hasPort) - } - - // CFURL considers an empty host nil unless there's another authority component - if let hostRange = parseInfo.hostRange, - (!hostRange.isEmpty || !flags.isDisjoint(with: [.hasUser, .hasPassword, .hasPort])) { - flags.insert(.hasHost) - let nsRange = string._toRelativeNSRange(hostRange) - ranges.append(CFRange(location: nsRange.location, length: nsRange.length)) - } - - if let portRange = parseInfo.portRange { - let nsRange = string._toRelativeNSRange(portRange) - ranges.append(CFRange(location: nsRange.location, length: nsRange.length)) - } - - flags.insert(.hasPath) - if let pathRange = parseInfo.pathRange { - let nsRange = string._toRelativeNSRange(pathRange) - ranges.append(CFRange(location: nsRange.location, length: nsRange.length)) - } else { - ranges.append(CFRange(location: kCFNotFound, length: 0)) - } - - if let queryRange = parseInfo.queryRange { - flags.insert(.hasQuery) - let nsRange = string._toRelativeNSRange(queryRange) - ranges.append(CFRange(location: nsRange.location, length: nsRange.length)) - } - - if let fragmentRange = parseInfo.fragmentRange { - flags.insert(.hasFragment) - let nsRange = string._toRelativeNSRange(fragmentRange) - ranges.append(CFRange(location: nsRange.location, length: nsRange.length)) - } - - let path = parseInfo.path.utf8 - let isDirectory = path.last == UInt8(ascii: "/") - - if parseInfo.isIPLiteral { - flags.insert(.isIPLiteral) - } - if isDirectory { - flags.insert(.isDirectory) - } - if parseInfo.pathHasFileID { - flags.insert(.pathHasFileID) - } - if !isDirectory && !parseInfo.pathHasPercent { - flags.insert(.posixAndURLPathsMatch) - } - return ranges.withUnsafeBufferPointer { - _CFURLCreateWithRangesAndFlags(string as CFString, $0.baseAddress!, UInt8($0.count), flags.rawValue, baseURL) - } +#if FOUNDATION_FRAMEWORK + internal let _url: any _URLProtocol & AnyObject + internal init(_ url: any _URLProtocol & AnyObject) { + _url = url + } +#else + private let _url: _SwiftURL + internal init(_ url: _SwiftURL) { + _url = url } +#endif -#if !NO_FILESYSTEM +#if os(Linux) + // Workaround to fix a Linux-only crash in swift_release. + // Add padding to return struct URL to its original size + // before the _SwiftURL refactor. + private var _padding: URLParseInfo? +#endif + +#if FOUNDATION_FRAMEWORK && !NO_FILESYSTEM public typealias BookmarkResolutionOptions = NSURL.BookmarkResolutionOptions public typealias BookmarkCreationOptions = NSURL.BookmarkCreationOptions -#endif // !NO_FILESYSTEM - -#endif // FOUNDATION_FRAMEWORK - - typealias Parser = RFC3986Parser - internal var _parseInfo: URLParseInfo! - private var _baseParseInfo: URLParseInfo? - - private static func parse(urlString: String, encodingInvalidCharacters: Bool = true) -> URLParseInfo? { - return Parser.parse(urlString: urlString, encodingInvalidCharacters: encodingInvalidCharacters, compatibility: .allowEmptyScheme) - } +#endif - internal init(parseInfo: URLParseInfo, relativeTo url: URL? = nil) { - _parseInfo = parseInfo - if parseInfo.scheme == nil { - _baseParseInfo = url?.absoluteURL._parseInfo - } - #if FOUNDATION_FRAMEWORK - _url = URL._nsURL(from: _parseInfo, baseParseInfo: _baseParseInfo) - if self.isFileURL && _parseInfo.pathHasFileID { - // _baseParseInfo cannot have a file ID because it came from `URL` - self = URL(reference: _url) - } - #endif // FOUNDATION_FRAMEWORK - } + internal static let fileIDPrefix = Array("/.file/id=".utf8) /// The public initializers don't allow the empty string, and we must maintain that behavior /// for compatibility. However, there are cases internally where we need to create a URL with @@ -806,77 +674,36 @@ public struct URL: Equatable, Sendable, Hashable { /// component. This previously worked since `URL` just wrapped an `NSURL`, which /// allows the empty string. internal init?(stringOrEmpty: String, relativeTo url: URL? = nil) { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - guard let inner = NSURL(string: stringOrEmpty, relativeTo: url) else { return nil } - _url = URL._converted(from: inner) - return - } - #endif // FOUNDATION_FRAMEWORK - guard let parseInfo = URL.parse(urlString: stringOrEmpty) else { - return nil - } - _parseInfo = parseInfo - if parseInfo.scheme == nil { - _baseParseInfo = url?.absoluteURL._parseInfo - } - #if FOUNDATION_FRAMEWORK - _url = URL._nsURL(from: _parseInfo, baseParseInfo: _baseParseInfo) - if self.isFileURL && _parseInfo.pathHasFileID { - self = URL(reference: _url) - } - #endif // FOUNDATION_FRAMEWORK + guard let inner = URL._type.init(stringOrEmpty: stringOrEmpty, relativeTo: url) else { return nil } + _url = inner.convertingFileReference() } /// Initialize with string. /// /// Returns `nil` if a `URL` cannot be formed with the string (for example, if the string contains characters that are illegal in a URL, or is an empty string). public init?(string: __shared String) { - guard !string.isEmpty else { return nil } - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - guard let inner = NSURL(string: string) else { return nil } - _url = URL._converted(from: inner) - return - } - #endif // FOUNDATION_FRAMEWORK - guard let parseInfo = URL.parse(urlString: string) else { - return nil - } - _parseInfo = parseInfo - #if FOUNDATION_FRAMEWORK - _url = URL._nsURL(from: _parseInfo, baseParseInfo: _baseParseInfo) - if self.isFileURL && _parseInfo.pathHasFileID { - self = URL(reference: _url) - } - #endif // FOUNDATION_FRAMEWORK + guard let inner = URL._type.init(string: string) else { return nil } + _url = inner.convertingFileReference() } /// Initialize with string, relative to another URL. /// /// Returns `nil` if a `URL` cannot be formed with the string (for example, if the string contains characters that are illegal in a URL, or is an empty string). public init?(string: __shared String, relativeTo url: __shared URL?) { - guard !string.isEmpty else { return nil } - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - guard let inner = NSURL(string: string, relativeTo: url) else { return nil } - _url = URL._converted(from: inner) + #if os(Linux) + // Workaround for a Linux-only crash where swift-corelibs-foundation's + // NSURL.baseURL.getter returns a value of 0x1 when bridging to URL. + // Crash doesn't occur when swift-corelibs-foundation is rebuilt with + // the new swift-foundation URL code, so this is temporary to get + // swift-foundation CI to pass. + if unsafeBitCast(url, to: (UnsafeRawPointer, UnsafeRawPointer).self) == (UnsafeRawPointer(bitPattern: 0x1), UnsafeRawPointer(bitPattern: 0x0)) { + guard let inner = URL._type.init(string: string, relativeTo: nil) else { return nil } + _url = inner.convertingFileReference() return } - #endif // FOUNDATION_FRAMEWORK - guard let parseInfo = URL.parse(urlString: string) else { - return nil - } - _parseInfo = parseInfo - if parseInfo.scheme == nil { - _baseParseInfo = url?.absoluteURL._parseInfo - } - #if FOUNDATION_FRAMEWORK - _url = URL._nsURL(from: _parseInfo, baseParseInfo: _baseParseInfo) - if self.isFileURL && _parseInfo.pathHasFileID { - self = URL(reference: _url) - } - #endif // FOUNDATION_FRAMEWORK + #endif + guard let inner = URL._type.init(string: string, relativeTo: url) else { return nil } + _url = inner.convertingFileReference() } /// Initialize with a URL string and the option to add (or skip) IDNA- and percent-encoding of invalid characters. @@ -886,45 +713,21 @@ public struct URL: Equatable, Sendable, Hashable { /// If the URL string is still invalid after encoding, `nil` is returned. @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) public init?(string: __shared String, encodingInvalidCharacters: Bool) { - guard !string.isEmpty else { return nil } - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - guard let inner = NSURL(string: string, encodingInvalidCharacters: encodingInvalidCharacters) else { return nil } - _url = URL._converted(from: inner) - return - } - #endif // FOUNDATION_FRAMEWORK - guard let parseInfo = URL.parse(urlString: string, encodingInvalidCharacters: encodingInvalidCharacters) else { - return nil - } - _parseInfo = parseInfo - #if FOUNDATION_FRAMEWORK - _url = URL._nsURL(from: _parseInfo, baseParseInfo: _baseParseInfo) - if self.isFileURL && _parseInfo.pathHasFileID { - self = URL(reference: _url) - } - #endif // FOUNDATION_FRAMEWORK + guard let inner = URL._type.init(string: string, encodingInvalidCharacters: encodingInvalidCharacters) else { return nil } + _url = inner.convertingFileReference() } /// Initializes a newly created file URL referencing the local file or directory at path, relative to a base URL. /// /// If an empty string is used for the path, then the path is assumed to be ".". - /// - note: This function avoids an extra file system access to check if the file URL is a directory. You should use it if you know the answer already. + /// - Note: This function avoids an extra file system access to check if the file URL is a directory. You should use it if you know the answer already. @available(macOS, introduced: 10.10, deprecated: 100000.0, message: "Use init(filePath:directoryHint:relativeTo:) instead") @available(iOS, introduced: 8.0, deprecated: 100000.0, message: "Use init(filePath:directoryHint:relativeTo:) instead") @available(tvOS, introduced: 9.0, deprecated: 100000.0, message: "Use init(filePath:directoryHint:relativeTo:) instead") @available(watchOS, introduced: 2.0, deprecated: 100000.0, message: "Use init(filePath:directoryHint:relativeTo:) instead") @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Use init(filePath:directoryHint:relativeTo:) instead") public init(fileURLWithPath path: __shared String, isDirectory: Bool, relativeTo base: __shared URL?) { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - let url = URL._converted(from: NSURL(fileURLWithPath: path.isEmpty ? "." : path, isDirectory: isDirectory, relativeTo: base)) - self.init(convertedReference: url) - return - } - #endif - let directoryHint: DirectoryHint = isDirectory ? .isDirectory : .notDirectory - self.init(filePath: path.isEmpty ? "." : path, directoryHint: directoryHint, relativeTo: base) + _url = URL._type.init(fileURLWithPath: path, isDirectory: isDirectory, relativeTo: base).convertingFileReference() } /// Initializes a newly created file URL referencing the local file or directory at path, relative to a base URL. @@ -936,14 +739,7 @@ public struct URL: Equatable, Sendable, Hashable { @available(watchOS, introduced: 2.0, deprecated: 100000.0, message: "Use init(filePath:directoryHint:relativeTo:) instead") @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Use init(filePath:directoryHint:relativeTo:) instead") public init(fileURLWithPath path: __shared String, relativeTo base: __shared URL?) { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - let url = URL._converted(from: NSURL(fileURLWithPath: path.isEmpty ? "." : path, relativeTo: base)) - self.init(convertedReference: url) - return - } - #endif - self.init(filePath: path.isEmpty ? "." : path, directoryHint: .checkFileSystem, relativeTo: base) + _url = URL._type.init(fileURLWithPath: path, relativeTo: base).convertingFileReference() } /// Initializes a newly created file URL referencing the local file or directory at path. @@ -956,15 +752,7 @@ public struct URL: Equatable, Sendable, Hashable { @available(watchOS, introduced: 2.0, deprecated: 100000.0, message: "Use init(filePath:directoryHint:relativeTo:) instead") @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Use init(filePath:directoryHint:relativeTo:) instead") public init(fileURLWithPath path: __shared String, isDirectory: Bool) { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - let url = URL._converted(from: NSURL(fileURLWithPath: path.isEmpty ? "." : path, isDirectory: isDirectory)) - self.init(convertedReference: url) - return - } - #endif - let directoryHint: DirectoryHint = isDirectory ? .isDirectory : .notDirectory - self.init(filePath: path.isEmpty ? "." : path, directoryHint: directoryHint) + _url = URL._type.init(fileURLWithPath: path, isDirectory: isDirectory).convertingFileReference() } /// Initializes a newly created file URL referencing the local file or directory at path. @@ -976,14 +764,7 @@ public struct URL: Equatable, Sendable, Hashable { @available(watchOS, introduced: 2.0, deprecated: 100000.0, message: "Use init(filePath:directoryHint:relativeTo:) instead") @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Use init(filePath:directoryHint:relativeTo:) instead") public init(fileURLWithPath path: __shared String) { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - let url = URL._converted(from: NSURL(fileURLWithPath: path.isEmpty ? "." : path)) - self.init(convertedReference: url) - return - } - #endif - self.init(filePath: path.isEmpty ? "." : path, directoryHint: .checkFileSystem) + _url = URL._type.init(fileURLWithPath: path).convertingFileReference() } // NSURL(fileURLWithPath:) can return nil incorrectly for some malformed paths @@ -991,16 +772,16 @@ public struct URL: Equatable, Sendable, Hashable { internal init?(_fileManagerFailableFileURLWithPath path: __shared String) { #if FOUNDATION_FRAMEWORK guard foundation_swift_url_enabled() else { - let url = URL._converted(from: NSURL(fileURLWithPath: path.isEmpty ? "." : path, isDirectory: path.utf8.last == ._slash)) + let url = _BridgedURL(fileURLWithPath: path, isDirectory: path.utf8.last == ._slash) guard unsafeBitCast(url, to: UnsafeRawPointer?.self) != nil else { return nil } - self.init(convertedReference: url) + _url = url.convertingFileReference() return } #endif // Infer from the path to prevent a file system check for what is likely a non-existant, malformed, or inaccessible path - self.init(filePath: path, directoryHint: .inferFromPath) + _url = _SwiftURL(filePath: path, directoryHint: .inferFromPath).convertingFileReference() } /// Initializes a newly created URL using the contents of the given data, relative to a base URL. @@ -1008,32 +789,8 @@ public struct URL: Equatable, Sendable, Hashable { /// If the data representation is not a legal URL string as ASCII bytes, the URL object may not behave as expected. If the URL cannot be formed then this will return nil. @available(macOS 10.11, iOS 9.0, watchOS 2.0, tvOS 9.0, *) public init?(dataRepresentation: __shared Data, relativeTo base: __shared URL?, isAbsolute: Bool = false) { - guard !dataRepresentation.isEmpty else { return nil } - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - if isAbsolute { - _url = URL._converted(from: NSURL(absoluteURLWithDataRepresentation: dataRepresentation, relativeTo: base)) - } else { - _url = URL._converted(from: NSURL(dataRepresentation: dataRepresentation, relativeTo: base)) - } - return - } - #endif - var url: URL? - if let string = String(data: dataRepresentation, encoding: .utf8) { - url = URL(stringOrEmpty: string, relativeTo: base) - } - if url == nil, let string = String(data: dataRepresentation, encoding: .isoLatin1) { - url = URL(stringOrEmpty: string, relativeTo: base) - } - guard let url else { - return nil - } - if isAbsolute { - self = url.absoluteURL - } else { - self = url - } + guard let inner = URL._type.init(dataRepresentation: dataRepresentation, relativeTo: base, isAbsolute: isAbsolute) else { return nil } + _url = inner.convertingFileReference() } #if !NO_FILESYSTEM && FOUNDATION_FRAMEWORK @@ -1041,40 +798,16 @@ public struct URL: Equatable, Sendable, Hashable { /// Initializes a URL that refers to a location specified by resolving bookmark data. @available(swift, obsoleted: 4.2) public init?(resolvingBookmarkData data: __shared Data, options: BookmarkResolutionOptions = [], relativeTo url: __shared URL? = nil, bookmarkDataIsStale: inout Bool) throws { - var stale: ObjCBool = false - _url = URL._converted(from: try NSURL(resolvingBookmarkData: data, options: options, relativeTo: url, bookmarkDataIsStale: &stale)) - bookmarkDataIsStale = stale.boolValue - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return - } - #endif - guard let parseInfo = URL.parse(urlString: _url.relativeString) else { - return nil - } - _parseInfo = parseInfo - if parseInfo.scheme == nil { - _baseParseInfo = url?.absoluteURL._parseInfo - } + try self.init(resolvingBookmarkData: data, options: options, relativeTo: url, bookmarkDataIsStale: &bookmarkDataIsStale) } /// Initializes a URL that refers to a location specified by resolving bookmark data. @available(swift, introduced: 4.2) public init(resolvingBookmarkData data: __shared Data, options: BookmarkResolutionOptions = [], relativeTo url: __shared URL? = nil, bookmarkDataIsStale: inout Bool) throws { var stale: ObjCBool = false - _url = URL._converted(from: try NSURL(resolvingBookmarkData: data, options: options, relativeTo: url, bookmarkDataIsStale: &stale)) - bookmarkDataIsStale = stale.boolValue - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return - } - #endif + let nsURL = try NSURL(resolvingBookmarkData: data, options: options, relativeTo: url, bookmarkDataIsStale: &stale) bookmarkDataIsStale = stale.boolValue - let parseInfo = URL.parse(urlString: _url.relativeString)! - _parseInfo = parseInfo - if parseInfo.scheme == nil { - _baseParseInfo = url?.absoluteURL._parseInfo - } + self.init(reference: nsURL) } /// Creates and initializes a URL that refers to the location specified by resolving the alias file at `url`. If the `url` argument does not refer to an alias file as defined by the `.isAliasFileKey` property, the URL returned is the same as the `url` argument. This method fails and returns `nil` if the `url` argument is unreachable, or if the original file or directory could not be located or is not reachable, or if the original file or directory is on a volume that could not be located or mounted. The `URLBookmarkResolutionWithSecurityScope` option is not supported by this method. @@ -1086,17 +819,8 @@ public struct URL: Equatable, Sendable, Hashable { #endif // !NO_FILESYSTEM && FOUNDATION_FRAMEWORK /// Initializes a newly created URL referencing the local file or directory at the file system representation of the path. File system representation is a null-terminated C string with canonical UTF-8 encoding. - public init(fileURLWithFileSystemRepresentation path: UnsafePointer, isDirectory: Bool, relativeTo baseURL: __shared URL?) { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - let url = URL._converted(from: NSURL(fileURLWithFileSystemRepresentation: path, isDirectory: isDirectory, relativeTo: baseURL)) - self.init(convertedReference: url) - return - } - #endif - let pathString = String(cString: path) - let directoryHint: DirectoryHint = isDirectory ? .isDirectory : .notDirectory - self.init(filePath: pathString, directoryHint: directoryHint, relativeTo: baseURL) + public init(fileURLWithFileSystemRepresentation path: UnsafePointer, isDirectory: Bool, relativeTo base: __shared URL?) { + _url = URL._type.init(fileURLWithFileSystemRepresentation: path, isDirectory: isDirectory, relativeTo: base).convertingFileReference() } /// Returns the data representation of the URL's relativeString. @@ -1104,160 +828,47 @@ public struct URL: Equatable, Sendable, Hashable { /// If the URL was initialized with `init?(dataRepresentation:relativeTo:isAbsolute:)`, the data representation returned are the same bytes as those used at initialization; otherwise, the data representation returned are the bytes of the `relativeString` encoded with UTF8 string encoding. @available(macOS 10.11, iOS 9.0, watchOS 2.0, tvOS 9.0, *) public var dataRepresentation: Data { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return _url.dataRepresentation - } - #endif - return Data(_parseInfo.urlString.utf8) - } - - private func mergedPath(for relativePath: String) -> String { - precondition(relativePath.utf8.first != UInt8(ascii: "/")) - guard let _baseParseInfo else { - return relativePath - } - let basePath = String(_baseParseInfo.path) - if _baseParseInfo.hasAuthority && basePath.isEmpty { - return "/" + relativePath - } - return basePath.merging(relativePath: relativePath) - } - - /// Calculate the "merged" path that is resolved against the base URL - private var mergedPath: String { - return mergedPath(for: relativePath()) + return _url.dataRepresentation } /// Returns the absolute string for the URL. public var absoluteString: String { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - // This should never fail for non-file reference URLs - return _url.absoluteString ?? "" - } - #endif - guard let _baseParseInfo else { - return relativeString - } - var result = URLComponents(parseInfo: _parseInfo) - - if _parseInfo.scheme != nil { - result.percentEncodedPath = relativePath().removingDotSegments - return result.string ?? relativeString - } - - if let baseScheme = _baseParseInfo.scheme { - // Scheme might be empty, which URL allows for compatibility, - // but URLComponents does not, so we force it internally. - result.forceScheme(String(baseScheme)) - } - - if hasAuthority { - return result.string ?? relativeString - } - - if let baseUser = _baseParseInfo.user { - result.percentEncodedUser = String(baseUser) - } - if let basePassword = _baseParseInfo.password { - result.percentEncodedPassword = String(basePassword) - } - if let baseHost = _baseParseInfo.host { - result.encodedHost = String(baseHost) - } - if let basePort = _baseParseInfo.port { - result.port = basePort - } - - if relativePath().isEmpty { - result.percentEncodedPath = String(_baseParseInfo.path) - if _parseInfo.query == nil, let baseQuery = _baseParseInfo.query { - result.percentEncodedQuery = String(baseQuery) - } - } else { - if relativePath().utf8.first == UInt8(ascii: "/") { - result.percentEncodedPath = relativePath().removingDotSegments - } else { - result.percentEncodedPath = mergedPath.removingDotSegments - } - } - return result.string ?? relativeString + return _url.absoluteString } /// Returns the relative portion of a URL. /// /// If `baseURL` is nil, or if the receiver is itself absolute, this is the same as `absoluteString`. public var relativeString: String { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return _url.relativeString - } - #endif - return _parseInfo.urlString + return _url.relativeString } /// Returns the base URL. /// /// If the URL is itself absolute, then this value is nil. public var baseURL: URL? { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return _url.baseURL - } - #endif - guard let _baseParseInfo else { - return nil - } - return URL(parseInfo: _baseParseInfo) + return _url.baseURL } /// Returns the absolute URL. /// /// If the URL is itself absolute, this will return self. public var absoluteURL: URL { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - // This should never fail for non-file reference URLs - return _url.absoluteURL ?? self - } - #endif - guard _baseParseInfo != nil else { - return self - } - return URL(string: absoluteString) ?? self + return _url.absoluteURL ?? self } /// Returns the scheme of the URL. public var scheme: String? { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return _url.scheme - } - #endif - guard let scheme = _parseInfo.scheme ?? _baseParseInfo?.scheme else { return nil } - return String(scheme) + return _url.scheme } /// Returns true if the scheme is `file:`. public var isFileURL: Bool { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return _url.isFileURL - } - #endif - guard let scheme else { return false } - return scheme.lowercased() == "file" - } - - private var hasAuthority: Bool { - return _parseInfo.hasAuthority + return _url.isFileURL } - private var encodedHost: String? { - let encodedHost = hasAuthority ? _parseInfo.host : _baseParseInfo?.host - guard let encodedHost else { return nil } - return String(encodedHost) + internal var hasAuthority: Bool { + return _url.hasAuthority } /// Returns the host component of the URL if present, otherwise returns `nil`. @@ -1269,12 +880,7 @@ public struct URL: Equatable, Sendable, Hashable { @available(watchOS, introduced: 2.0, deprecated: 100000.0, message: "Use host(percentEncoded:) instead") @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Use host(percentEncoded:) instead") public var host: String? { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return _url.host - } - #endif - return host(percentEncoded: false) + return _url.host } /// Returns the host component of the URL if present, otherwise returns `nil`. @@ -1284,68 +890,14 @@ public struct URL: Equatable, Sendable, Hashable { /// - Returns: The host component of the URL @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) public func host(percentEncoded: Bool = true) -> String? { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - let cf = _url._cfurl().takeUnretainedValue() - if let host = _CFURLCopyHostName(cf, !percentEncoded) { - return host.takeRetainedValue() as String - } - return nil - } - #endif - guard let encodedHost else { - return nil - } - - // According to RFC 3986, a host always exists if there is an authority - // component, it just might be empty. However, the old implementation - // of URL.host() returned nil for URLs like "https:///", and apps rely - // on this behavior, so keep it for bincompat. - if encodedHost.isEmpty, user() == nil, password() == nil, port == nil { - return nil - } - - func requestedHost() -> String? { - let didPercentEncodeHost = hasAuthority ? _parseInfo.didPercentEncodeHost : _baseParseInfo?.didPercentEncodeHost ?? false - if percentEncoded { - if didPercentEncodeHost { - return encodedHost - } - guard let decoded = Parser.IDNADecodeHost(encodedHost) else { - return encodedHost - } - return Parser.percentEncode(decoded, component: .host) - } else { - if didPercentEncodeHost { - return Parser.percentDecode(encodedHost) - } - return encodedHost - } - } - - guard let requestedHost = requestedHost() else { - return nil - } - - let isIPLiteral = hasAuthority ? _parseInfo.isIPLiteral : _baseParseInfo?.isIPLiteral ?? false - if isIPLiteral { - // Strip square brackets to be compatible with old URL.host behavior - return String(requestedHost.utf8.dropFirst().dropLast()) - } else { - return requestedHost - } + return _url.host(percentEncoded: percentEncoded) } /// Returns the port component of the URL if present, otherwise returns `nil`. /// /// - note: This function will resolve against the base `URL`. public var port: Int? { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return _url.port?.intValue - } - #endif - return hasAuthority ? _parseInfo.port : _baseParseInfo?.port + return _url.port } /// Returns the user component of the URL if present, otherwise returns `nil`. @@ -1357,12 +909,7 @@ public struct URL: Equatable, Sendable, Hashable { @available(watchOS, introduced: 2.0, deprecated: 100000.0, message: "Use user(percentEncoded:) instead") @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Use user(percentEncoded:) instead") public var user: String? { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return _url.user - } - #endif - return user() + return _url.user } /// Returns the user component of the URL if present, otherwise returns `nil`. @@ -1371,22 +918,7 @@ public struct URL: Equatable, Sendable, Hashable { /// - Returns: The user component of the URL. @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) public func user(percentEncoded: Bool = true) -> String? { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - let cf = _url._cfurl().takeUnretainedValue() - if let username = _CFURLCopyUserName(cf, !percentEncoded) { - return username.takeRetainedValue() as String - } - return nil - } - #endif - let user = hasAuthority ? _parseInfo.user : _baseParseInfo?.user - guard let user else { return nil } - if percentEncoded { - return String(user) - } else { - return Parser.percentDecode(user) - } + return _url.user(percentEncoded: percentEncoded) } /// Returns the password component of the URL if present, otherwise returns `nil`. @@ -1398,12 +930,7 @@ public struct URL: Equatable, Sendable, Hashable { @available(watchOS, introduced: 2.0, deprecated: 100000.0, message: "Use password(percentEncoded:) instead") @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Use password(percentEncoded:) instead") public var password: String? { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return _url.password - } - #endif - return password() + return _url.password } /// Returns the password component of the URL if present, otherwise returns `nil`. @@ -1412,71 +939,11 @@ public struct URL: Equatable, Sendable, Hashable { /// - Returns: The password component of the URL. @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) public func password(percentEncoded: Bool = true) -> String? { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - let cf = _url._cfurl().takeUnretainedValue() - if let password = _CFURLCopyPassword(cf, !percentEncoded) { - return password.takeRetainedValue() as String - } - return nil - } - #endif - let password = hasAuthority ? _parseInfo.password : _baseParseInfo?.password - guard let password else { return nil } - if percentEncoded { - return String(password) - } else { - return Parser.percentDecode(password) - } - } - - #if os(Windows) - private static func windowsPath(for urlPath: String) -> String { - var iter = urlPath.utf8.makeIterator() - guard iter.next() == ._slash else { - return decodeFilePath(urlPath._droppingTrailingSlashes) - } - // "C:\" is standardized to "/C:/" on initialization. - if let driveLetter = iter.next(), driveLetter.isAlpha, - iter.next() == ._colon, - iter.next() == ._slash { - // Strip trailing slashes from the path, which preserves a root "/". - let path = String(Substring(urlPath.utf8.dropFirst(3)))._droppingTrailingSlashes - // Don't include a leading slash before the drive letter - return "\(Unicode.Scalar(driveLetter)):\(decodeFilePath(path))" - } - // There are many flavors of UNC paths, so use PathIsRootW to ensure - // we don't strip a trailing slash that represents a root. - let path = decodeFilePath(urlPath) - return path.replacing(._slash, with: ._backslash).withCString(encodedAs: UTF16.self) { pwszPath in - guard !PathIsRootW(pwszPath) else { - return path - } - return path._droppingTrailingSlashes - } - } - #endif - - private static func decodeFilePath(_ path: some StringProtocol) -> String { - let charsToLeaveEncoded: Set = [._slash, 0] - return Parser.percentDecode(path, excluding: charsToLeaveEncoded) ?? "" - } - - private static func fileSystemPath(for urlPath: String) -> String { - #if os(Windows) - return windowsPath(for: urlPath) - #else - return decodeFilePath(urlPath._droppingTrailingSlashes) - #endif - } - - var fileSystemPath: String { - return URL.fileSystemPath(for: path()) + return _url.password(percentEncoded: percentEncoded) } - /// True if the URL's relative path would resolve against a base URL path - private var pathResolvesAgainstBase: Bool { - return _parseInfo.scheme == nil && !hasAuthority && relativePath().utf8.first != ._slash + internal func absolutePath(percentEncoded: Bool = true) -> String { + return _url.absolutePath(percentEncoded: percentEncoded) } /// Returns the path component of the URL if present, otherwise returns an empty string. @@ -1489,23 +956,7 @@ public struct URL: Equatable, Sendable, Hashable { @available(watchOS, introduced: 2.0, deprecated: 100000.0, message: "Use path(percentEncoded:) instead") @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Use path(percentEncoded:) instead") public var path: String { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - if let parameterString = _url._parameterString { - if __NSURLSupportDeprecatedParameterComponent(), - let path = _url.path { - return path + ";" + parameterString - } else { - return ";" + parameterString - } - } else if let path = _url.path { - return path - } else { - return "" - } - } - #endif - return fileSystemPath + return _url.path } /// Returns the path component of the URL if present, otherwise returns an empty string. @@ -1515,54 +966,18 @@ public struct URL: Equatable, Sendable, Hashable { /// - Returns: The path component of the URL. @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) public func path(percentEncoded: Bool = true) -> String { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - let cf = _url._cfurl().takeUnretainedValue() - if let path = _CFURLCopyPath(cf, !percentEncoded) { - return path.takeRetainedValue() as String - } - return "" - } - #endif - if _baseParseInfo != nil { - return absoluteURL.relativePath(percentEncoded: percentEncoded) - } - if percentEncoded { - return String(_parseInfo.path) - } else { - return Parser.percentDecode(_parseInfo.path) ?? "" - } + return _url.path(percentEncoded: percentEncoded) } /// Returns the relative path of the URL if present, otherwise returns an empty string. This is the same as `path` if `baseURL` is `nil`. /// /// - returns: The relative path, or an empty string if the URL has an empty path. public var relativePath: String { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - if __NSURLSupportDeprecatedParameterComponent(), - let parameterString = _url._parameterString { - if let path = _url.relativePath { - return path + ";" + parameterString - } else { - return ";" + parameterString - } - } else if let path = _url.relativePath { - return path - } else { - return "" - } - } - #endif - return URL.fileSystemPath(for: relativePath()) + return _url.relativePath } - private func relativePath(percentEncoded: Bool = true) -> String { - if percentEncoded { - return String(_parseInfo.path) - } else { - return Parser.percentDecode(_parseInfo.path) ?? "" - } + internal func relativePath(percentEncoded: Bool = true) -> String { + return _url.relativePath(percentEncoded: percentEncoded) } /// Returns the query component of the URL if present, otherwise returns `nil`. @@ -1574,12 +989,7 @@ public struct URL: Equatable, Sendable, Hashable { @available(watchOS, introduced: 2.0, deprecated: 100000.0, message: "Use query(percentEncoded:) instead") @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Use query(percentEncoded:) instead") public var query: String? { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return _url.query - } - #endif - return query() + return _url.query } /// Returns the password component of the URL if present, otherwise returns `nil`. @@ -1588,25 +998,7 @@ public struct URL: Equatable, Sendable, Hashable { /// - Returns: The query component of the URL. @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) public func query(percentEncoded: Bool = true) -> String? { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - let cf = _url._cfurl().takeUnretainedValue() - if let queryString = _CFURLCopyQueryString(cf, !percentEncoded) { - return queryString.takeRetainedValue() as String - } - return nil - } - #endif - var query = _parseInfo.query - if query == nil && relativePath().isEmpty { - query = _baseParseInfo?.query - } - guard let query else { return nil } - if percentEncoded { - return String(query) - } else { - return Parser.percentDecode(query) - } + return _url.query(percentEncoded: percentEncoded) } /// Returns the fragment component of the URL if present, otherwise returns `nil`. @@ -1618,12 +1010,7 @@ public struct URL: Equatable, Sendable, Hashable { @available(watchOS, introduced: 2.0, deprecated: 100000.0, message: "Use fragment(percentEncoded:) instead") @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Use fragment(percentEncoded:) instead") public var fragment: String? { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return _url.fragment - } - #endif - return fragment() + return _url.fragment } /// Returns the password component of the URL if present, otherwise returns `nil`. @@ -1632,21 +1019,7 @@ public struct URL: Equatable, Sendable, Hashable { /// - Returns: The fragment component of the URL. @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) public func fragment(percentEncoded: Bool = true) -> String? { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - let cf = _url._cfurl().takeUnretainedValue() - if let fragment = _CFURLCopyFragment(cf, !percentEncoded) { - return fragment.takeRetainedValue() as String - } - return nil - } - #endif - guard let fragment = _parseInfo.fragment else { return nil } - if percentEncoded { - return String(fragment) - } else { - return Parser.percentDecode(fragment) - } + return _url.fragment(percentEncoded: percentEncoded) } /// Passes the URL's path in file system representation to `block`. @@ -1655,59 +1028,29 @@ public struct URL: Equatable, Sendable, Hashable { /// - note: The pointer is not valid outside the context of the block. @available(macOS 10.10, iOS 8.0, watchOS 2.0, tvOS 9.0, *) public func withUnsafeFileSystemRepresentation(_ block: (UnsafePointer?) throws -> ResultType) rethrows -> ResultType { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return try block(_url.fileSystemRepresentation) - } - #endif - return try fileSystemPath.withFileSystemRepresentation { try block($0) } + return try _url.withUnsafeFileSystemRepresentation(block) } // MARK: - Path manipulation /// Returns true if the URL path represents a directory. @available(macOS 10.11, iOS 9.0, watchOS 2.0, tvOS 9.0, *) public var hasDirectoryPath: Bool { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return _url.hasDirectoryPath - } - #endif - return path().utf8.last == UInt8(ascii: "/") + return _url.hasDirectoryPath } /// Returns the path components of the URL, or an empty array if the path is an empty string. public var pathComponents: [String] { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - // In accordance with our change to never return a nil path, here we return an empty array. - return _url.pathComponents ?? [] - } - #endif - var result = path().pathComponents.map { Parser.percentDecode($0) ?? "" } - if result.count > 1 && result.last == "/" { - _ = result.popLast() - } - return result + return _url.pathComponents } /// Returns the last path component of the URL, or an empty string if the path is an empty string. public var lastPathComponent: String { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return _url.lastPathComponent ?? "" - } - #endif - return fileSystemPath.lastPathComponent + return _url.lastPathComponent } /// Returns the path extension of the URL, or an empty string if the path is an empty string. public var pathExtension: String { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return _url.pathExtension ?? "" - } - #endif - return fileSystemPath.pathExtension + return _url.pathExtension } /// Returns a URL constructed by appending the given path component to self. @@ -1720,24 +1063,7 @@ public struct URL: Equatable, Sendable, Hashable { @available(watchOS, introduced: 2.0, deprecated: 100000.0, message: "Use appending(path:directoryHint:) instead") @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Use appending(path:directoryHint:) instead") public func appendingPathComponent(_ pathComponent: String, isDirectory: Bool) -> URL { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - if let result = _url.appendingPathComponent(pathComponent, isDirectory: isDirectory) { - return result - } - // Now we need to do something more expensive - if var c = URLComponents(url: self, resolvingAgainstBaseURL: true) { - let path = (c.path as NSString).appendingPathComponent(pathComponent) - c.path = isDirectory ? path + "/" : path - return c.url ?? self - } else { - // Ultimate fallback: - return self - } - } - #endif - let directoryHint: DirectoryHint = isDirectory ? .isDirectory : .notDirectory - return appending(path: pathComponent, directoryHint: directoryHint) + return _url.appendingPathComponent(pathComponent, isDirectory: isDirectory) ?? self } /// Returns a URL constructed by appending the given path component to self. @@ -1750,22 +1076,7 @@ public struct URL: Equatable, Sendable, Hashable { @available(watchOS, introduced: 2.0, deprecated: 100000.0, message: "Use appending(path:directoryHint:) instead") @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Use appending(path:directoryHint:) instead") public func appendingPathComponent(_ pathComponent: String) -> URL { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - if let result = _url.appendingPathComponent(pathComponent) { - return result - } - // Now we need to do something more expensive - if var c = URLComponents(url: self, resolvingAgainstBaseURL: true) { - c.path = (c.path as NSString).appendingPathComponent(pathComponent) - return c.url ?? self - } else { - // Ultimate fallback: - return self - } - } - #endif - return appending(path: pathComponent, directoryHint: .checkFileSystem) + return _url.appendingPathComponent(pathComponent) ?? self } /// Returns a URL constructed by removing the last path component of self. @@ -1775,45 +1086,7 @@ public struct URL: Equatable, Sendable, Hashable { /// (e.g., `http://www.example.com`), /// then this function will return the URL unchanged. public func deletingLastPathComponent() -> URL { - #if FOUNDATION_FRAMEWORK - /// Compatibility path for apps that loop on: - /// `url = url.deletingPathComponent().standardized` until `url.path.isEmpty`. - /// - /// This used to work due to a combination of bugs where: - /// `URL("/").deletingLastPathComponent == URL("/../")` - /// `URL("/../").standardized == URL("")` - guard foundation_swift_url_enabled(), !Self.compatibility4 else { - // This is a slight behavior change from NSURL, but better than returning "http://www.example.com../". - guard !path.isEmpty, let result = _url.deletingLastPathComponent.map({ URL(reference: $0 as NSURL) }) else { return self } - return result - } - #endif - let path = relativePath() - let shouldAppendDotDot = ( - pathResolvesAgainstBase && ( - path.isEmpty - || path.lastPathComponent == "." - || path.lastPathComponent == ".." - ) - ) - - var newPath = path - if newPath.lastPathComponent != ".." { - newPath = newPath.deletingLastPathComponent() - } - if shouldAppendDotDot { - newPath = newPath.appendingPathComponent("..") - } - if newPath.isEmpty && pathResolvesAgainstBase { - newPath = "." - } - // .deletingLastPathComponent() removes the trailing "/", but we know it's a directory - if !newPath.isEmpty && newPath.utf8.last != ._slash { - newPath += "/" - } - var components = URLComponents(parseInfo: _parseInfo) - components.percentEncodedPath = newPath - return components.url(relativeTo: baseURL)! + return _url.deletingLastPathComponent() ?? self } /// Returns a URL constructed by appending the given path extension to self. @@ -1823,35 +1096,14 @@ public struct URL: Equatable, Sendable, Hashable { /// Certain special characters (for example, Unicode Right-To-Left marks) cannot be used as path extensions. If any of those are contained in `pathExtension`, the function will return the URL unchanged. /// - parameter pathExtension: The extension to append. public func appendingPathExtension(_ pathExtension: String) -> URL { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - guard !path.isEmpty, let result = _url.appendingPathExtension(pathExtension) else { return self } - return result - } - #endif - guard !pathExtension.isEmpty, !relativePath().isEmpty else { return self } - var components = URLComponents(parseInfo: _parseInfo) - // pathExtension might need to be percent-encoded, so use .path - let newPath = components.path.appendingPathExtension(pathExtension) - components.path = newPath - return components.url(relativeTo: baseURL)! + return _url.appendingPathExtension(pathExtension) ?? self } /// Returns a URL constructed by removing any path extension. /// /// If the URL has an empty path (e.g., `http://www.example.com`), then this function will return the URL unchanged. public func deletingPathExtension() -> URL { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - guard !path.isEmpty, let result = _url.deletingPathExtension.map({ URL(reference: $0 as NSURL) }) else { return self } - return result - } - #endif - guard !relativePath().isEmpty else { return self } - var components = URLComponents(parseInfo: _parseInfo) - let newPath = components.percentEncodedPath.deletingPathExtension() - components.percentEncodedPath = newPath - return components.url(relativeTo: baseURL)! + return _url.deletingPathExtension() ?? self } /// Appends a path component to the URL. @@ -1908,24 +1160,7 @@ public struct URL: Equatable, Sendable, Hashable { /// Returns a `URL` with any instances of ".." or "." removed from its path. /// - note: This method does not consult the file system. public var standardized: URL { - #if FOUNDATION_FRAMEWORK - /// Compatibility path for apps that loop on: - /// `url = url.deletingPathComponent().standardized` until `url.path.isEmpty`. - /// - /// This used to work due to a combination of bugs where: - /// `URL("/").deletingLastPathComponent == URL("/../")` - /// `URL("/../").standardized == URL("")` - guard foundation_swift_url_enabled(), !Self.compatibility4 else { - // NSURL should not return nil here unless this is a file reference URL, which should be impossible - guard let result = _url.standardized.map({ URL(reference: $0 as NSURL) }) else { return self } - return result - } - #endif - guard !path.isEmpty else { return self } - var components = URLComponents(parseInfo: _parseInfo) - let newPath = components.percentEncodedPath.removingDotSegments - components.percentEncodedPath = newPath - return components.url(relativeTo: baseURL) ?? self + return _url.standardized ?? self } /// Standardizes the path of a file URL by removing dot segments. @@ -1940,30 +1175,14 @@ public struct URL: Equatable, Sendable, Hashable { /// If the `isFileURL` is false, this method returns `self`. /// - note: This method consults the file system. public var standardizedFileURL: URL { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - // NSURL should not return nil here unless this is a file reference URL, which should be impossible - guard let result = _url.standardizingPath.map({ URL(reference: $0 as NSURL) }) else { return self } - return result - } - #endif - guard isFileURL && !fileSystemPath.isEmpty else { return self } - return URL(filePath: fileSystemPath.standardizingPath, directoryHint: hasDirectoryPath ? .isDirectory : .notDirectory) + return _url.standardizedFileURL ?? self } /// Resolves any symlinks in the path of a file URL. /// /// If the `isFileURL` is false, this method returns `self`. public func resolvingSymlinksInPath() -> URL { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - // NSURL should not return nil here unless this is a file reference URL, which should be impossible - guard let result = _url.resolvingSymlinksInPath.map({ URL(reference: $0 as NSURL) }) else { return self } - return result - } - #endif - guard isFileURL && !fileSystemPath.isEmpty else { return self } - return URL(filePath: fileSystemPath.resolvingSymlinksInPath, directoryHint: hasDirectoryPath ? .isDirectory : .notDirectory) + return _url.resolvingSymlinksInPath() ?? self } /// Resolves any symlinks in the path of a file URL. @@ -1982,7 +1201,7 @@ public struct URL: Equatable, Sendable, Hashable { /// This method synchronously checks if the resource's backing store is reachable. Checking reachability is appropriate when making decisions that do not require other immediate operations on the resource, e.g. periodic maintenance of UI state that depends on the existence of a specific document. When performing operations such as opening a file or copying resource properties, it is more efficient to simply try the operation and handle failures. This method is currently applicable only to URLs for file system resources. For other URL types, `false` is returned. public func checkResourceIsReachable() throws -> Bool { var error: NSError? - let result = _url.checkResourceIsReachableAndReturnError(&error) + let result = ns.checkResourceIsReachableAndReturnError(&error) if let e = error { throw e } else { @@ -1996,7 +1215,7 @@ public struct URL: Equatable, Sendable, Hashable { @available(macOS 10.10, iOS 8.0, watchOS 2.0, tvOS 9.0, *) public func checkPromisedItemIsReachable() throws -> Bool { var error: NSError? - let result = _url.checkPromisedItemIsReachableAndReturnError(&error) + let result = ns.checkPromisedItemIsReachableAndReturnError(&error) if let e = error { throw e } else { @@ -2012,7 +1231,7 @@ public struct URL: Equatable, Sendable, Hashable { /// /// `URLResourceValues` keeps track of which of its properties have been set. Those values are the ones used by this function to determine which properties to write. public mutating func setResourceValues(_ values: URLResourceValues) throws { - try _url.setResourceValues(values._values) + try ns.setResourceValues(values._values) } /// Return a collection of resource values identified by the given resource keys. @@ -2023,7 +1242,7 @@ public struct URL: Equatable, Sendable, Hashable { /// /// Only the values for the keys specified in `keys` will be populated. public func resourceValues(forKeys keys: Set) throws -> URLResourceValues { - return URLResourceValues(keys: keys, values: try _url.resourceValues(forKeys: Array(keys))) + return URLResourceValues(keys: keys, values: try ns.resourceValues(forKeys: Array(keys))) } /// Sets a temporary resource value on the URL object. @@ -2033,21 +1252,21 @@ public struct URL: Equatable, Sendable, Hashable { /// To remove a temporary resource value from the URL object, use `func removeCachedResourceValue(forKey:)`. Care should be taken to ensure the key that identifies a temporary resource value is unique and does not conflict with system defined keys (using reverse domain name notation in your temporary resource value keys is recommended). This method is currently applicable only to URLs for file system resources. @preconcurrency public mutating func setTemporaryResourceValue(_ value: Sendable, forKey key: URLResourceKey) { - _url.setTemporaryResourceValue(value, forKey: key) + ns.setTemporaryResourceValue(value, forKey: key) } /// Removes all cached resource values and all temporary resource values from the URL object. /// /// This method is currently applicable only to URLs for file system resources. public mutating func removeAllCachedResourceValues() { - _url.removeAllCachedResourceValues() + ns.removeAllCachedResourceValues() } /// Removes the cached resource value identified by a given resource value key from the URL object. /// /// Removing a cached resource value may remove other cached resource values because some resource values are cached as a set of values, and because some resource values depend on other resource values (temporary resource values have no dependencies). This method is currently applicable only to URLs for file system resources. public mutating func removeCachedResourceValue(forKey key: URLResourceKey) { - _url.removeCachedResourceValue(forKey: key) + ns.removeCachedResourceValue(forKey: key) } /// Get resource values from URLs of 'promised' items. @@ -2064,7 +1283,7 @@ public struct URL: Equatable, Sendable, Hashable { /// Most of the URL resource value keys will work with these APIs. However, there are some that are tied to the item's contents that will not work, such as `contentAccessDateKey` or `generationIdentifierKey`. If one of these keys is used, the method will return a `URLResourceValues` value, but the value for that property will be nil. @available(macOS 10.10, iOS 8.0, watchOS 2.0, tvOS 9.0, *) public func promisedItemResourceValues(forKeys keys: Set) throws -> URLResourceValues { - return URLResourceValues(keys: keys, values: try _url.promisedItemResourceValues(forKeys: Array(keys))) + return URLResourceValues(keys: keys, values: try ns.promisedItemResourceValues(forKeys: Array(keys))) } #endif // FOUNDATION_FRAMEWORK @@ -2075,7 +1294,7 @@ public struct URL: Equatable, Sendable, Hashable { /// Returns bookmark data for the URL, created with specified options and resource values. public func bookmarkData(options: BookmarkCreationOptions = [], includingResourceValuesForKeys keys: Set? = nil, relativeTo url: URL? = nil) throws -> Data { - let result = try _url.bookmarkData(options: options, includingResourceValuesForKeys: keys.flatMap { Array($0) }, relativeTo: url) + let result = try ns.bookmarkData(options: options, includingResourceValuesForKeys: keys.flatMap { Array($0) }, relativeTo: url) return result } @@ -2099,13 +1318,13 @@ public struct URL: Equatable, Sendable, Hashable { /// Given an NSURL created by resolving a bookmark data created with security scope, make the resource referenced by the url accessible to the process. When access to this resource is no longer needed the client must call stopAccessingSecurityScopedResource. Each call to startAccessingSecurityScopedResource must be balanced with a call to stopAccessingSecurityScopedResource (Note: this is not reference counted). @available(macOS 10.10, iOS 8.0, watchOS 2.0, tvOS 9.0, *) public func startAccessingSecurityScopedResource() -> Bool { - return _url.startAccessingSecurityScopedResource() + return ns.startAccessingSecurityScopedResource() } /// Revokes the access granted to the url by a prior successful call to startAccessingSecurityScopedResource. @available(macOS 10.10, iOS 8.0, watchOS 2.0, tvOS 9.0, *) public func stopAccessingSecurityScopedResource() { - _url.stopAccessingSecurityScopedResource() + ns.stopAccessingSecurityScopedResource() } #endif // FOUNDATION_FRAMEWORK @@ -2115,73 +1334,53 @@ public struct URL: Equatable, Sendable, Hashable { // MARK: - Bridging Support - /// We must not store an NSURL without running it through this function. This makes sure that we do not hold a file reference URL, which changes the nullability of many NSURL functions. - private static func _converted(from url: NSURL) -> NSURL { - #if NO_FILESYSTEM - return url - #else - // Future readers: file reference URL here is not the same as playgrounds "file reference" - if url.isFileReferenceURL() { - // Convert to a file path URL, or use an invalid scheme - return (url.filePathURL ?? URL(string: "com-apple-unresolvable-file-reference-url:")!) as NSURL + private init(reference: __shared NSURL) { + guard foundation_swift_nsurl_enabled() else { + _url = _BridgedURL(reference).convertingFileReference() + return + } + if let swift = reference as? _NSSwiftURL { + _url = _BridgedNSSwiftURL(swift).convertingFileReference() } else { - return url + // This is a custom NSURL subclass + _url = _BridgedURL(reference).convertingFileReference() } - #endif } - private init(convertedReference: __shared NSURL) { - _url = convertedReference + internal init(_ url: _NSSwiftURL) { + _url = _BridgedNSSwiftURL(url) } - private init(reference: __shared NSURL) { - _url = URL._converted(from: reference).copy() as! NSURL - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return - } - #endif - if let parseInfo = RFC3986Parser.parse(urlString: _url.relativeString, encodingInvalidCharacters: true, compatibility: [.allowEmptyScheme, .allowAnyPort]) { - _parseInfo = parseInfo - } else { - // Go to compatibility jail (allow `URL` as a dummy string container for `NSURL` instead of crashing) - _parseInfo = URLParseInfo(urlString: _url.relativeString, urlParser: .RFC3986, schemeRange: nil, userRange: nil, passwordRange: nil, hostRange: nil, portRange: nil, pathRange: nil, queryRange: nil, fragmentRange: nil, isIPLiteral: false, didPercentEncodeHost: false, pathHasPercent: false, pathHasFileID: false) - } - _baseParseInfo = reference.baseURL?.absoluteURL._parseInfo + private var ns: NSURL { + return _url.bridgeToNSURL() } - private var reference: NSURL { - return _url + internal func isFileReferenceURL() -> Bool { + return _url.isFileReferenceURL() } #endif // FOUNDATION_FRAMEWORK - public func hash(into hasher: inout Hasher) { + internal var _swiftURL: _SwiftURL? { #if FOUNDATION_FRAMEWORK - hasher.combine(_url) - guard foundation_swift_url_enabled() else { - return + if let swift = _url as? _SwiftURL { return swift } + if let bridged = _url as? _BridgedNSSwiftURL { return bridged._wrapped.url } + if foundation_swift_nsurl_enabled(), let swift = ns._trueSelf()._url as? _SwiftURL { + return swift } + return _SwiftURL(stringOrEmpty: _url.relativeString, relativeTo: _url.baseURL) + #else + return _url #endif - hasher.combine(_parseInfo.urlString) - hasher.combine(_baseParseInfo?.urlString) } - public static func ==(lhs: URL, rhs: URL) -> Bool { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return lhs.reference.isEqual(rhs.reference) - } - #endif // FOUNDATION_FRAMEWORK - let isEqual = ( - lhs._parseInfo.urlString == rhs._parseInfo.urlString && - lhs._baseParseInfo?.urlString == rhs._baseParseInfo?.urlString - ) - #if FOUNDATION_FRAMEWORK - return isEqual && lhs.reference.isEqual(rhs.reference) - #else - return isEqual - #endif // FOUNDATION_FRAMEWORK + public func hash(into hasher: inout Hasher) { + hasher.combine(relativeString) + } + + public static func == (lhs: URL, rhs: URL) -> Bool { + if lhs._url === rhs._url { return true } + return lhs.relativeString == rhs.relativeString && lhs.baseURL == rhs.baseURL } } @@ -2207,71 +1406,56 @@ extension URL { } #endif // FOUNDATION_FRAMEWORK -#if !NO_FILESYSTEM - private static func isDirectory(_ path: String) -> Bool { - guard !path.isEmpty else { return false } - #if os(Windows) - let path = path.replacing(._slash, with: ._backslash) - return (try? path.withNTPathRepresentation { pwszPath in - // If path points to a symlink (reparse point), get a handle to - // the symlink itself using FILE_FLAG_OPEN_REPARSE_POINT. - let handle = CreateFileW(pwszPath, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nil, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nil) - guard handle != INVALID_HANDLE_VALUE else { return false } - defer { CloseHandle(handle) } - var info: BY_HANDLE_FILE_INFORMATION = BY_HANDLE_FILE_INFORMATION() - guard GetFileInformationByHandle(handle, &info) else { return false } - if (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == FILE_ATTRIBUTE_REPARSE_POINT { return false } - return (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == FILE_ATTRIBUTE_DIRECTORY - }) ?? false - #else - // FileManager uses stat() to check if the file exists. - // URL historically won't follow a symlink at the end - // of the path, so use lstat() here instead. - return path.withFileSystemRepresentation { fsRep in - guard let fsRep else { return false } - var fileInfo = stat() - guard lstat(fsRep, &fileInfo) == 0 else { return false } - return (mode_t(fileInfo.st_mode) & S_IFMT) == S_IFDIR - } - #endif + internal enum PathStyle: Sendable { + case posix + case windows } -#endif // !NO_FILESYSTEM + + internal func fileSystemPath(style: URL.PathStyle = URL.defaultPathStyle, resolveAgainstBase: Bool = true, compatibility: Bool = false) -> String { + _url.fileSystemPath(style: style, resolveAgainstBase: resolveAgainstBase, compatibility: compatibility) + } + + #if os(Windows) + internal static let defaultPathStyle = PathStyle.windows + #else + internal static let defaultPathStyle = PathStyle.posix + #endif /// Checks if a file path is absolute and standardizes the inputted file path on Windows /// Assumes the path only contains `/` as the path separator - internal static func isAbsolute(standardizing filePath: inout String) -> Bool { + internal static func isAbsolute(standardizing filePath: inout String, pathStyle: PathStyle = URL.defaultPathStyle) -> Bool { if filePath.utf8.first == ._slash { return true } - #if os(Windows) - let utf8 = filePath.utf8 - guard utf8.count >= 3 else { - return false - } - // Check if this is a drive letter - let first = utf8.first! - let secondIndex = utf8.index(after: utf8.startIndex) - let second = utf8[secondIndex] - let thirdIndex = utf8.index(after: secondIndex) - let third = utf8[thirdIndex] - let isAbsolute = ( - first.isAlpha - && (second == ._colon || second == ._pipe) - && third == ._slash - ) - if isAbsolute { - // Standardize to "/[drive-letter]:/..." - if second == ._pipe { - var filePathArray = Array(utf8) - filePathArray[1] = ._colon - filePathArray.insert(._slash, at: 0) - filePath = String(decoding: filePathArray, as: UTF8.self) - } else { - filePath = "/" + filePath + if pathStyle == .windows { + let utf8 = filePath.utf8 + guard utf8.count >= 3 else { + return false } + // Check if this is a drive letter + let first = utf8.first! + let secondIndex = utf8.index(after: utf8.startIndex) + let second = utf8[secondIndex] + let thirdIndex = utf8.index(after: secondIndex) + let third = utf8[thirdIndex] + let isAbsolute = ( + first.isAlpha + && (second == ._colon || second == ._pipe) + && third == ._slash + ) + if isAbsolute { + // Standardize to "/[drive-letter]:/..." + if second == ._pipe { + var filePathArray = Array(utf8) + filePathArray[1] = ._colon + filePathArray.insert(._slash, at: 0) + filePath = String(decoding: filePathArray, as: UTF8.self) + } else { + filePath = "/" + filePath + } + } + return isAbsolute } - return isAbsolute - #else // os(Windows) #if !NO_FILESYSTEM // Expand the tilde if present if filePath.utf8.first == UInt8(ascii: "~") { @@ -2280,7 +1464,6 @@ extension URL { #endif // Make sure the expanded path is absolute return filePath.utf8.first == ._slash - #endif // os(Windows) } /// Initializes a newly created file URL referencing the local file or directory at path, relative to a base URL. @@ -2288,199 +1471,8 @@ extension URL { /// If an empty string is used for the path, then the path is assumed to be ".". @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) public init(filePath path: String, directoryHint: DirectoryHint = .inferFromPath, relativeTo base: URL? = nil) { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - let filePath = path.isEmpty ? "./" : path - let url: NSURL - switch directoryHint { - case .isDirectory: - url = URL._converted(from: NSURL(fileURLWithPath: filePath, isDirectory: true, relativeTo: base)) - case .notDirectory: - url = URL._converted(from: NSURL(fileURLWithPath: filePath, isDirectory: false, relativeTo: base)) - case .checkFileSystem: - url = URL._converted(from: NSURL(fileURLWithPath: filePath, relativeTo: base)) - case .inferFromPath: - let isDirectory = filePath.hasSuffix("/") - url = URL._converted(from: NSURL(fileURLWithPath: filePath, isDirectory: isDirectory, relativeTo: base)) - } - self.init(convertedReference: url) - return - } - #endif // FOUNDATION_FRAMEWORK - var baseURL = base - guard !path.isEmpty else { - #if !NO_FILESYSTEM - baseURL = baseURL ?? .currentDirectoryOrNil() - #endif - self.init(string: "./", relativeTo: baseURL)! - return - } - - #if os(Windows) - // Convert any "\" to "/" before storing the URL parse info - var filePath = path.replacing(._backslash, with: ._slash) - #else - var filePath = path - #endif - - #if FOUNDATION_FRAMEWORK - // Linked-on-or-after check for apps which incorrectly pass a full URL - // string with a scheme. In the old implementation, this could work - // rarely if the app immediately called .appendingPathComponent(_:), - // which used to accidentally interpret a relative path starting with - // "scheme:" as an absolute "scheme:" URL string. - if Self.compatibility1 { - if filePath.utf8.starts(with: "file:".utf8) { - #if canImport(os) - URL.logger.fault("API MISUSE: URL(filePath:) called with a \"file:\" scheme. Input must only contain a path. Dropping \"file:\" scheme.") - #endif - filePath = String(filePath.dropFirst(5))._compressingSlashes() - } else if filePath.utf8.starts(with: "http:".utf8) || filePath.utf8.starts(with: "https:".utf8) { - #if canImport(os) - URL.logger.fault("API MISUSE: URL(filePath:) called with an HTTP URL string. Using URL(string:) instead.") - #endif - guard let httpURL = URL(string: filePath) else { - fatalError("API MISUSE: URL(filePath:) called with an HTTP URL string. URL(string:) returned nil.") - } - self = httpURL - return - } - } - #endif - - let isAbsolute = URL.isAbsolute(standardizing: &filePath) - - #if !NO_FILESYSTEM - if !isAbsolute { - baseURL = baseURL ?? .currentDirectoryOrNil() - } - #endif - - let isDirectory: Bool - switch directoryHint { - case .isDirectory: - isDirectory = true - case .notDirectory: - filePath = filePath._droppingTrailingSlashes - isDirectory = false - case .checkFileSystem: - #if !NO_FILESYSTEM - func absoluteFilePath() -> String { - guard !isAbsolute, let baseURL else { - return filePath - } - let absolutePath = baseURL.path().merging(relativePath: filePath) - return URL.fileSystemPath(for: absolutePath) - } - isDirectory = URL.isDirectory(absoluteFilePath()) - #else - isDirectory = filePath.utf8.last == ._slash - #endif - case .inferFromPath: - isDirectory = filePath.utf8.last == ._slash - } - - if isDirectory && !filePath.isEmpty && filePath.utf8.last != ._slash { - filePath += "/" - } - var components = URLComponents() - if isAbsolute { - components.scheme = "file" - components.encodedHost = "" - } - components.path = filePath - - if !isAbsolute { - self = components.url(relativeTo: baseURL)! - } else { - // Drop the baseURL if the URL is absolute - self = components.url! - } - } - - private func appending(path: S, directoryHint: DirectoryHint, encodingSlashes: Bool) -> URL { - #if os(Windows) - var path = path.replacing(._backslash, with: ._slash) - #else - var path = String(path) - #endif - - var insertedSlash = false - if !relativePath().isEmpty && path.utf8.first != ._slash { - // Don't treat as first path segment when encoding - path = "/" + path - insertedSlash = true - } - - guard var pathToAppend = Parser.percentEncode(path, component: .path) else { - return self - } - if encodingSlashes { - var utf8 = Array(pathToAppend.utf8) - utf8[(insertedSlash ? 1 : 0)...].replace([._slash], with: [UInt8(ascii: "%"), UInt8(ascii: "2"), UInt8(ascii: "F")]) - pathToAppend = String(decoding: utf8, as: UTF8.self) - } - - func appendedPath() -> String { - var currentPath = relativePath() - if currentPath.isEmpty && !hasAuthority { - guard _parseInfo.scheme == nil else { - // Scheme only, append directly to the empty path, e.g. - // URL("scheme:").appending(path: "path") == scheme:path - return pathToAppend - } - // No scheme or authority, treat the empty path as "." - currentPath = "." - } - - // If currentPath is empty, pathToAppend is relative, and we have an authority, - // we must append a slash to separate the path from authority, which happens below. - - if currentPath.utf8.last != ._slash && pathToAppend.utf8.first != ._slash { - currentPath += "/" - } else if currentPath.utf8.last == ._slash && pathToAppend.utf8.first == ._slash { - _ = currentPath.popLast() - } - return currentPath + pathToAppend - } - - var newPath = appendedPath() - - let hasTrailingSlash = newPath.utf8.last == ._slash - let isDirectory: Bool - switch directoryHint { - case .isDirectory: - isDirectory = true - case .notDirectory: - isDirectory = false - case .checkFileSystem: - #if !NO_FILESYSTEM - // We can only check file system if the URL is a file URL - if isFileURL { - let filePath: String - if newPath.utf8.first == ._slash { - filePath = URL.fileSystemPath(for: newPath) - } else { - filePath = URL.fileSystemPath(for: mergedPath(for: newPath)) - } - isDirectory = URL.isDirectory(filePath) - } else { - // For web addresses, trust the trailing slash - isDirectory = hasTrailingSlash - } - #else // !NO_FILESYSTEM - isDirectory = hasTrailingSlash - #endif // !NO_FILESYSTEM - case .inferFromPath: - isDirectory = hasTrailingSlash - } - if isDirectory && newPath.utf8.last != ._slash { - newPath += "/" - } - - var components = URLComponents(parseInfo: _parseInfo) - components.percentEncodedPath = newPath - return components.url(relativeTo: baseURL) ?? self + let inner = URL._type.init(filePath: path, directoryHint: directoryHint, relativeTo: base) + _url = inner.convertingFileReference() } /// Returns a URL constructed by appending the given path to self. @@ -2489,53 +1481,7 @@ extension URL { /// - directoryHint: A hint to whether this URL will point to a directory @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) public func appending(path: S, directoryHint: DirectoryHint = .inferFromPath) -> URL { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - let isDirectory: Bool? - let hasTrailingSlash = path.hasSuffix("/") - switch directoryHint { - case .isDirectory: - isDirectory = true - case .notDirectory: - isDirectory = false - case .checkFileSystem: - // We can only check file system if the URL is a file URL - if self.isFileURL { - isDirectory = nil - } else { - // For web addresses we'll have to trust the caller to - // do the right ting with the trailing slash - isDirectory = hasTrailingSlash - } - case .inferFromPath: - isDirectory = hasTrailingSlash - } - - let result: URL? - if let isDirectory { - result = _url.appendingPathComponent(String(path), isDirectory: isDirectory) - } else { - result = _url.appendingPathComponent(String(path)) - } - - if let result { - return result - } - // Now we need to do something more expensive - if var c = URLComponents(url: self, resolvingAgainstBaseURL: true) { - let newPath = (c.path as NSString).appendingPathComponent(String(path)) - c.path = newPath - if let isDirectory, isDirectory, !newPath.hasSuffix("/") { - c.path = newPath + "/" - } - return c.url ?? self - } else { - // Ultimate fallback: - return self - } - } - #endif // FOUNDATION_FRAMEWORK - return appending(path: path, directoryHint: directoryHint, encodingSlashes: false) + return _url.appending(path: path, directoryHint: directoryHint) ?? self } /// Appends a path to the receiver. @@ -2555,60 +1501,7 @@ extension URL { /// - Returns: The new URL @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) public func appending(component: S, directoryHint: DirectoryHint = .inferFromPath) -> URL { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - let pathComponent = String(component) - let hasTrailingSlash = pathComponent.hasSuffix("/") - let isDirectory: Bool? - switch directoryHint { - case .isDirectory: isDirectory = true - case .notDirectory: isDirectory = false - case .inferFromPath: isDirectory = hasTrailingSlash - case .checkFileSystem: - // We can only check file system if the URL is a file URL - if self.isFileURL { - isDirectory = nil - } else { - // For web addresses we'll have to trust the caller to - // do the right ting with the trailing slash - isDirectory = hasTrailingSlash - } - } - if let isDirectory { - let cf = _url._cfurl().takeUnretainedValue() - return _CFURLCreateCopyAppendingPathComponent(cf, pathComponent as CFString, isDirectory).takeRetainedValue() as URL - } - // We need to check the file system. This is the same behavior - // as `NSURL.URLByAppendingPathComponent` - // Crate a new URL without the trailing slash - let result = self.appending(component: component, directoryHint: .notDirectory) - // See if it refers to a directory - #if NO_FILESYSTEM - // Fall back to `inferFromPath` - let cf = _url._cfurl().takeUnretainedValue() - return _CFURLCreateCopyAppendingPathComponent(cf, pathComponent as CFString, hasTrailingSlash).takeRetainedValue() as URL - #else // NO_FILESYSTEM - if let resourceValues = try? result.resourceValues(forKeys: [.isDirectoryKey]), - let isDirectoryValue = resourceValues.isDirectory { - let cf = _url._cfurl().takeUnretainedValue() - return _CFURLCreateCopyAppendingPathComponent(cf, pathComponent as CFString, isDirectoryValue).takeRetainedValue() as URL - } else { - // Fall back to `inferFromPath` - let cf = _url._cfurl().takeUnretainedValue() - return _CFURLCreateCopyAppendingPathComponent(cf, pathComponent as CFString, hasTrailingSlash).takeRetainedValue() as URL - } - #endif // NO_FILESYSTEM - } - #endif // FOUNDATION_FRAMEWORK - - // The old .appending(component:) implementation did not actually percent-encode - // "/" for file URLs as the documentation suggests. Many apps accidentally use - // .appending(component: "path/with/slashes") instead of using .appending(path:), - // so changing this behavior would cause breakage. - if isFileURL { - return appending(path: component, directoryHint: directoryHint, encodingSlashes: false) - } - return appending(path: component, directoryHint: directoryHint, encodingSlashes: true) + return _url.appending(component: component, directoryHint: directoryHint) ?? self } /// Appends a path component to the receiver. The path component is first @@ -2625,15 +1518,11 @@ extension URL { /// - Parameter queryItems: A list of `URLQueryItem` to append to the receiver. @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) public func appending(queryItems: [URLQueryItem]) -> URL { - if var c = URLComponents(url: self, resolvingAgainstBaseURL: true) { - var newItems = c.queryItems ?? [] - newItems.append(contentsOf: queryItems) - c.queryItems = newItems - if let url = c.url { - return url - } - } - return self + guard var c = URLComponents(url: self, resolvingAgainstBaseURL: true) else { return self } + var newItems = c.queryItems ?? [] + newItems.append(contentsOf: queryItems) + c.queryItems = newItems + return c.url ?? self } /// Appends a list of `URLQueryItem` to the receiver. @@ -2643,7 +1532,7 @@ extension URL { self = appending(queryItems: queryItems) } - /// Returns a URL constructed by appending the given varidic list of path components to self. + /// Returns a URL constructed by appending the given variadic list of path components to self. /// /// - Parameters: /// - components: The list of components to add. @@ -2653,7 +1542,7 @@ extension URL { return _appending(components: components, directoryHint: directoryHint) } - /// Appends a varidic list of path components to the URL. + /// Appends a variadic list of path components to the URL. /// /// - parameter components: The list of components to add. /// - parameter directoryHint: A hint to whether this URL will point to a directory. @@ -2677,20 +1566,6 @@ extension URL { #if !NO_FILESYSTEM extension URL { - private static func currentDirectoryOrNil() -> URL? { - let path: String? = FileManager.default.currentDirectoryPath - guard var filePath = path else { - return nil - } - #if os(Windows) - filePath = filePath.replacing(._backslash, with: ._slash) - #endif - guard URL.isAbsolute(standardizing: &filePath) else { - return nil - } - return URL(filePath: filePath, directoryHint: .isDirectory) - } - /// The working directory of the current process. /// Calling this property will issue a `getcwd` syscall. @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) @@ -2899,7 +1774,7 @@ extension URL: ReferenceConvertible, _ObjectiveCBridgeable { @_semantics("convertToObjectiveC") public func _bridgeToObjectiveC() -> NSURL { - return _url + return _url.bridgeToNSURL() } public static func _forceBridgeFromObjectiveC(_ source: NSURL, result: inout URL?) { @@ -2925,30 +1800,11 @@ extension URL: ReferenceConvertible, _ObjectiveCBridgeable { @available(macOS 10.10, iOS 8.0, watchOS 2.0, tvOS 9.0, *) extension URL: CustomStringConvertible, CustomDebugStringConvertible { public var description: String { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return _url.description - } - #endif - let urlString: String - if scheme?.lowercased() == "data" && relativeString.count > 128 { - urlString = "\(relativeString.prefix(120)) ... \(relativeString.suffix(8))" - } else { - urlString = relativeString - } - if let baseURL { - return "\(urlString) -- \(baseURL)" - } - return urlString + return _url.description } public var debugDescription: String { - #if FOUNDATION_FRAMEWORK - guard foundation_swift_url_enabled() else { - return _url.debugDescription - } - #endif - return description + return _url.debugDescription } } @@ -3013,14 +1869,3 @@ extension URL: _ExpressibleByFileReferenceLiteral { public typealias _FileReferenceLiteralType = URL #endif // FOUNDATION_FRAMEWORK - -fileprivate extension UInt8 { - var isAlpha: Bool { - switch self { - case UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "a")...UInt8(ascii: "z"): - return true - default: - return false - } - } -} diff --git a/Sources/FoundationEssentials/URL/URLComponents.swift b/Sources/FoundationEssentials/URL/URLComponents.swift index 43bd493be..127f9a90e 100644 --- a/Sources/FoundationEssentials/URL/URLComponents.swift +++ b/Sources/FoundationEssentials/URL/URLComponents.swift @@ -489,6 +489,56 @@ public struct URLComponents: Hashable, Equatable, Sendable { return result } + internal func _uncheckedString(original: Bool) -> String { + let componentsToDecode = original ? urlParseInfo?.encodedComponents ?? [] : [] + var result = "" + if let scheme { + result += "\(scheme):" + } + if hasAuthority { + result += "//" + } + if componentsToDecode.contains(.user), let user { + result += user + } else if let percentEncodedUser { + result += percentEncodedUser + } + if componentsToDecode.contains(.password), let password { + result += ":\(password)" + } else if let percentEncodedPassword { + result += ":\(percentEncodedPassword)" + } + if percentEncodedUser != nil || percentEncodedPassword != nil { + result += "@" + } + if componentsToDecode.contains(.host), let host { + result += host + } else if let encodedHost { + result += encodedHost + } + if parseInfoIsValidForPort, let portString = urlParseInfo?.portString { + result += ":\(portString)" + } else if let port { + result += ":\(port)" + } + if componentsToDecode.contains(.path) { + result += path + } else { + result += percentEncodedPath + } + if componentsToDecode.contains(.query), let query { + result += "?\(query)" + } else if let percentEncodedQuery { + result += "?\(percentEncodedQuery)" + } + if componentsToDecode.contains(.fragment), let fragment { + result += "#\(fragment)" + } else if let percentEncodedFragment { + result += "#\(percentEncodedFragment)" + } + return result + } + func rangeOf(_ component: Component) -> Range? { if let urlParseInfo, parseInfoIsValidForAllRanges { switch component { @@ -694,6 +744,21 @@ public struct URLComponents: Hashable, Equatable, Sendable { self.components = _URLComponents(parseInfo: parseInfo) } + #if FOUNDATION_FRAMEWORK + internal init?(url: _BridgedURL, resolvingAgainstBaseURL resolve: Bool) { + let string: String + if resolve { + string = url.absoluteString + } else { + string = url.relativeString + } + guard let components = _URLComponents(string: string) else { + return nil + } + self.components = components + } + #endif + /// Returns a URL created from the URLComponents. /// /// If the URLComponents has an authority component (user, password, host or port) and a path component, then the path must either begin with "/" or be an empty string. If the NSURLComponents does not have an authority component (user, password, host or port) and has a path component, the path component must not start with "//". If those requirements are not met, nil is returned. @@ -729,6 +794,12 @@ public struct URLComponents: Hashable, Equatable, Sendable { components.string } + /// For use by URL to get a non-nil string, potentially decoding components to return an original string. + /// This does not provide any validation, since URL is historically less strict than URLComponents. + internal func _uncheckedString(original: Bool) -> String { + components._uncheckedString(original: original) + } + /// The scheme subcomponent of the URL. /// /// The getter for this property removes any percent encoding this component may have (if the component allows percent encoding). Setting this property assumes the subcomponent or component string is not percent encoded and will add percent encoding (if the component allows percent encoding). diff --git a/Sources/FoundationEssentials/URL/URLComponents_ObjC.swift b/Sources/FoundationEssentials/URL/URLComponents_ObjC.swift index 70017e6a9..fc8373700 100644 --- a/Sources/FoundationEssentials/URL/URLComponents_ObjC.swift +++ b/Sources/FoundationEssentials/URL/URLComponents_ObjC.swift @@ -34,8 +34,8 @@ extension NSURLComponents { return _NSSwiftURLComponents(components: components) } - static func _parseString(_ string: String, encodingInvalidCharacters: Bool, compatibility: URLParserCompatibility.RawValue) -> String? { - return RFC3986Parser.parse(urlString: string, encodingInvalidCharacters: encodingInvalidCharacters, compatibility: .init(rawValue: compatibility))?.urlString + static func _parseString(_ string: String, encodingInvalidCharacters: Bool, allowEmptyScheme: Bool) -> String? { + return RFC3986Parser.parse(urlString: string, encodingInvalidCharacters: encodingInvalidCharacters, allowEmptyScheme: allowEmptyScheme)?.urlString } } diff --git a/Sources/FoundationEssentials/URL/URLParser.swift b/Sources/FoundationEssentials/URL/URLParser.swift index 570b476ac..3063d0759 100644 --- a/Sources/FoundationEssentials/URL/URLParser.swift +++ b/Sources/FoundationEssentials/URL/URLParser.swift @@ -17,7 +17,6 @@ internal import _ForSwiftFoundation // Source of truth for a parsed URL final class URLParseInfo: Sendable { let urlString: String - let urlParser: URLParserKind let schemeRange: Range? let userRange: Range? @@ -30,12 +29,24 @@ final class URLParseInfo: Sendable { let isIPLiteral: Bool let didPercentEncodeHost: Bool - let pathHasPercent: Bool let pathHasFileID: Bool - init(urlString: String, urlParser: URLParserKind, schemeRange: Range?, userRange: Range?, passwordRange: Range?, hostRange: Range?, portRange: Range?, pathRange: Range?, queryRange: Range?, fragmentRange: Range?, isIPLiteral: Bool, didPercentEncodeHost: Bool, pathHasPercent: Bool, pathHasFileID: Bool) { + struct EncodedComponentSet: OptionSet { + let rawValue: UInt8 + static let user = EncodedComponentSet(rawValue: 1 << 0) + static let password = EncodedComponentSet(rawValue: 1 << 1) + static let host = EncodedComponentSet(rawValue: 1 << 2) + static let path = EncodedComponentSet(rawValue: 1 << 3) + static let query = EncodedComponentSet(rawValue: 1 << 4) + static let fragment = EncodedComponentSet(rawValue: 1 << 5) + } + + /// Empty unless we initialized with a string, data, or bytes that required percent-encoding. + /// Used to return the appropriate dataRepresentation or bytes for CFURL. + let encodedComponents: EncodedComponentSet + + init(urlString: String, schemeRange: Range?, userRange: Range?, passwordRange: Range?, hostRange: Range?, portRange: Range?, pathRange: Range?, queryRange: Range?, fragmentRange: Range?, isIPLiteral: Bool, didPercentEncodeHost: Bool, pathHasFileID: Bool, encodedComponents: EncodedComponentSet) { self.urlString = urlString - self.urlParser = urlParser self.schemeRange = schemeRange self.userRange = userRange self.passwordRange = passwordRange @@ -46,8 +57,8 @@ final class URLParseInfo: Sendable { self.fragmentRange = fragmentRange self.isIPLiteral = isIPLiteral self.didPercentEncodeHost = didPercentEncodeHost - self.pathHasPercent = pathHasPercent self.pathHasFileID = pathHasFileID + self.encodedComponents = encodedComponents } var hasAuthority: Bool { @@ -61,6 +72,41 @@ final class URLParseInfo: Sendable { return urlString[schemeRange] } + var netLocationRange: Range? { + guard hasAuthority else { + return nil + } + guard let startIndex = userRange?.lowerBound + ?? passwordRange?.lowerBound + ?? hostRange?.lowerBound + ?? portRange?.lowerBound else { + return nil + } + guard let endIndex = portRange?.upperBound + ?? hostRange?.upperBound + ?? passwordRange?.upperBound + ?? userRange?.upperBound else { + return nil + } + return (startIndex..? { + guard let startIndex = queryRange?.lowerBound + ?? fragmentRange?.lowerBound else { + return nil + } + return startIndex.. URLParseInfo? - static func parse(urlString: String, encodingInvalidCharacters: Bool, compatibility: URLParserCompatibility) -> URLParseInfo? - - static func validate(_ string: (some StringProtocol)?, component: URLComponents.Component) -> Bool - static func validate(_ string: (some StringProtocol)?, component: URLComponents.Component, percentEncodingAllowed: Bool) -> Bool - - static func percentEncode(_ string: (some StringProtocol)?, component: URLComponents.Component) -> String? - static func percentDecode(_ string: (some StringProtocol)?) -> String? - static func percentDecode(_ string: (some StringProtocol)?, excluding: Set) -> String? - - static func shouldPercentEncodeHost(_ host: some StringProtocol, forScheme: (some StringProtocol)?) -> Bool - static func IDNAEncodeHost(_ host: (some StringProtocol)?) -> String? - static func IDNADecodeHost(_ host: (some StringProtocol)?) -> String? -} - package protocol UIDNAHook { static func encode(_ host: some StringProtocol) -> String? static func decode(_ host: some StringProtocol) -> String? @@ -179,64 +196,59 @@ dynamic package func _uidnaHook() -> UIDNAHook.Type? { } #endif -internal struct RFC3986Parser: URLParserProtocol { - static let kind: URLParserKind = .RFC3986 +internal struct RFC3986Parser { // MARK: - Encoding - static func percentEncode(_ string: (some StringProtocol)?, component: URLComponents.Component) -> String? { + static func percentEncode(_ string: (some StringProtocol)?, component: URLComponents.Component, skipAlreadyEncoded: Bool = false) -> String? { guard let string else { return nil } guard !string.isEmpty else { return "" } switch component { case .scheme: fatalError("Scheme cannot be percent-encoded.") case .user: - return string.addingPercentEncoding(forURLComponent: .user) + return string.addingPercentEncoding(forURLComponent: .user, skipAlreadyEncoded: skipAlreadyEncoded) case .password: - return string.addingPercentEncoding(forURLComponent: .password) + return string.addingPercentEncoding(forURLComponent: .password, skipAlreadyEncoded: skipAlreadyEncoded) case .host: - return percentEncodeHost(string) + return percentEncodeHost(string, skipAlreadyEncoded: skipAlreadyEncoded) case .port: fatalError("Port cannot be percent-encoded.") case .path: - return percentEncodePath(string) + return percentEncodePath(string, skipAlreadyEncoded: skipAlreadyEncoded) case .query: - return string.addingPercentEncoding(forURLComponent: .query) + return string.addingPercentEncoding(forURLComponent: .query, skipAlreadyEncoded: skipAlreadyEncoded) case .queryItem: - return string.addingPercentEncoding(forURLComponent: .queryItem) + return string.addingPercentEncoding(forURLComponent: .queryItem, skipAlreadyEncoded: skipAlreadyEncoded) case .fragment: - return string.addingPercentEncoding(forURLComponent: .fragment) + return string.addingPercentEncoding(forURLComponent: .fragment, skipAlreadyEncoded: skipAlreadyEncoded) } } - static func percentDecode(_ string: (some StringProtocol)?) -> String? { - return percentDecode(string, excluding: []) - } - - static func percentDecode(_ string: (some StringProtocol)?, excluding: Set) -> String? { + static func percentDecode(_ string: (some StringProtocol)?, excluding: Set = [], encoding: String.Encoding = .utf8) -> String? { guard let string else { return nil } guard !string.isEmpty else { return "" } - return string.removingURLPercentEncoding(excluding: excluding) - } - - private static let schemesToPercentEncodeHost = Set([ - "tel", - "telemergencycall", - "telprompt", - "callto", - "facetime", - "facetime-prompt", - "facetime-audio", - "facetime-audio-prompt", - "imap", - "pop", - "addressbook", - "contact", - "phasset", - "http+unix", - "https+unix", - "ws+unix", - "wss+unix", + return string.removingURLPercentEncoding(excluding: excluding, encoding: encoding) + } + + private static let schemesToPercentEncodeHost = [[UInt8]]([ + Array("tel".utf8), + Array("telemergencycall".utf8), + Array("telprompt".utf8), + Array("callto".utf8), + Array("facetime".utf8), + Array("facetime-prompt".utf8), + Array("facetime-audio".utf8), + Array("facetime-audio-prompt".utf8), + Array("imap".utf8), + Array("pop".utf8), + Array("addressbook".utf8), + Array("contact".utf8), + Array("phasset".utf8), + Array("http+unix".utf8), + Array("https+unix".utf8), + Array("ws+unix".utf8), + Array("wss+unix".utf8), ]) private static func looksLikeIPLiteral(_ host: some StringProtocol) -> Bool { @@ -260,7 +272,8 @@ internal struct RFC3986Parser: URLParserProtocol { guard let scheme else { return false } - return schemesToPercentEncodeHost.contains(scheme.lowercased()) + let lowercased = scheme.lowercased().utf8 + return schemesToPercentEncodeHost.contains { $0.elementsEqual(lowercased) } } private static func percentEncodeIPLiteralHost(_ host: some StringProtocol) -> String? { @@ -283,13 +296,17 @@ internal struct RFC3986Parser: URLParserProtocol { return "\(host[.. String? { + private static func percentEncodeHost(_ host: (some StringProtocol)?, skipAlreadyEncoded: Bool = false) -> String? { guard let host else { return nil } guard !host.isEmpty else { return "" } if looksLikeIPLiteral(host) { + if skipAlreadyEncoded { + let innerHost = String(decoding: host.utf8.dropFirst().dropLast(), as: UTF8.self) + return "[\(innerHost.addingPercentEncoding(forURLComponent: .host, skipAlreadyEncoded: true))]" + } return percentEncodeIPLiteralHost(host) } - return host.addingPercentEncoding(forURLComponent: .host) + return host.addingPercentEncoding(forURLComponent: .host, skipAlreadyEncoded: skipAlreadyEncoded) } static func IDNAEncodeHost(_ host: (some StringProtocol)?) -> String? { @@ -305,46 +322,22 @@ internal struct RFC3986Parser: URLParserProtocol { return uidnaHook.decode(host) } - private static func percentEncodePath(_ path: some StringProtocol) -> String { + private static func percentEncodePath(_ path: some StringProtocol, skipAlreadyEncoded: Bool = false) -> String { guard !path.isEmpty else { return "" } guard let slashIndex = path.utf8.firstIndex(of: UInt8(ascii: "/")) else { - return path.addingPercentEncoding(forURLComponent: .pathFirstSegment) + return path.addingPercentEncoding(forURLComponent: .pathFirstSegment, skipAlreadyEncoded: skipAlreadyEncoded) } guard slashIndex != path.startIndex else { - return path.addingPercentEncoding(forURLComponent: .path) + return path.addingPercentEncoding(forURLComponent: .path, skipAlreadyEncoded: skipAlreadyEncoded) } - let firstSegment = path[.. Bool { - guard let string else { return true } - switch component { - case .scheme: - return validate(scheme: string) - case .user: - return validate(user: string) - case .password: - return validate(password: string) - case .host: - return validate(host: string) - case .port: - return validate(portString: string) - case .path: - return validate(path: string) - case .query: - return validate(query: string) - case .queryItem: - return validate(queryItemPart: string) - case .fragment: - return validate(fragment: string) - } - } - - static func validate(_ string: (some StringProtocol)?, component: URLComponents.Component, percentEncodingAllowed: Bool) -> Bool { + static func validate(_ string: (some StringProtocol)?, component: URLComponents.Component, percentEncodingAllowed: Bool = true) -> Bool { guard let string else { return true } switch component { case .scheme: @@ -412,9 +405,9 @@ internal struct RFC3986Parser: URLParserProtocol { } /// Fast path used during initial URL buffer parsing. - private static func validate(schemeBuffer: Slice>, compatibility: URLParserCompatibility = .init()) -> Bool { + private static func validate(schemeBuffer: Slice>, allowEmptyScheme: Bool = false) -> Bool { guard let first = schemeBuffer.first else { - return compatibility.contains(.allowEmptyScheme) + return allowEmptyScheme } guard first >= UInt8(ascii: "A"), validate(buffer: schemeBuffer, component: .scheme, percentEncodingAllowed: false) else { @@ -423,7 +416,6 @@ internal struct RFC3986Parser: URLParserProtocol { return true } - /// Only used by URLComponents, don't need to consider `URLParserCompatibility.allowEmptyScheme` private static func validate(scheme: some StringProtocol) -> Bool { // A valid scheme must start with an ALPHA character. // If first >= "A" and is in schemeAllowed, then first is ALPHA. @@ -443,6 +435,41 @@ internal struct RFC3986Parser: URLParserProtocol { return validate(string: password, component: .password, percentEncodingAllowed: percentEncodingAllowed) } + private static func isIPvFuture(_ innerHost: some StringProtocol) -> Bool { + // precondition: IP-literal == "[" innerHost [ "%25" zoneID ] "]" + var iter = innerHost.utf8.makeIterator() + guard iter.next() == UInt8(ascii: "v") else { return false } + guard let second = iter.next(), second.isValidHexDigit else { return false } + while let next = iter.next() { + if next.isValidHexDigit { continue } + if next == ._dot { return true } + return false + } + return false + } + + /// Only checks that the characters are allowed in an IPv6 address. + /// Does not validate the format of the IPv6 address. + private static func validateIPv6Address(_ address: some StringProtocol) -> Bool { + let isValid = address.utf8.withContiguousStorageIfAvailable { + $0.allSatisfy { $0.isValidHexDigit || $0 == UInt8(ascii: ":") || $0 == UInt8(ascii: ".") } + } + if let isValid { + return isValid + } + #if FOUNDATION_FRAMEWORK + if let fastCharacters = address._ns._fastCharacterContents() { + let charsBuffer = UnsafeBufferPointer(start: fastCharacters, count: address._ns.length) + return charsBuffer.allSatisfy { + guard $0 < 128 else { return false } + let v = UInt8($0) + return v.isValidHexDigit || v == UInt8(ascii: ":") || v == UInt8(ascii: ".") + } + } + #endif + return address.utf8.allSatisfy { $0.isValidHexDigit || $0 == UInt8(ascii: ":") || $0 == UInt8(ascii: ".") } + } + /// Validates an IP-literal host string that has leading and trailing brackets. /// If the host string contains a zone ID delimiter "%", this must be percent encoded to "%25" to be valid. /// The zone ID may contain any `reg_name` characters, including percent-encoding. @@ -456,7 +483,11 @@ internal struct RFC3986Parser: URLParserProtocol { guard let percentIndex = utf8.firstIndex(of: UInt8(ascii: "%")) else { // There is no zoneID, so the whole innerHost must be the IP-literal address. - return validate(string: innerHost, component: .hostIPLiteral, percentEncodingAllowed: false) + if isIPvFuture(innerHost) { + return validate(string: innerHost, component: .hostIPvFuture, percentEncodingAllowed: false) + } else { + return validateIPv6Address(innerHost) + } } // The first "%" in an IP-literal must be the zone ID delimiter. @@ -472,7 +503,11 @@ internal struct RFC3986Parser: URLParserProtocol { return false } - return validate(string: innerHost[.. Bool { @@ -566,18 +601,7 @@ internal struct RFC3986Parser: URLParserProtocol { return true } - private struct InvalidComponentSet: OptionSet { - let rawValue: UInt8 - static let scheme = InvalidComponentSet(rawValue: 1 << 0) - static let user = InvalidComponentSet(rawValue: 1 << 1) - static let password = InvalidComponentSet(rawValue: 1 << 2) - static let host = InvalidComponentSet(rawValue: 1 << 3) - static let port = InvalidComponentSet(rawValue: 1 << 4) - static let path = InvalidComponentSet(rawValue: 1 << 5) - static let query = InvalidComponentSet(rawValue: 1 << 6) - static let fragment = InvalidComponentSet(rawValue: 1 << 7) - } - + typealias InvalidComponentSet = URLParseInfo.EncodedComponentSet private static func invalidComponents(of parseInfo: URLParseInfo) -> InvalidComponentSet { var invalidComponents: InvalidComponentSet = [] if let user = parseInfo.user, !validate(user: user) { @@ -604,17 +628,53 @@ internal struct RFC3986Parser: URLParserProtocol { // MARK: - Parsing - /// Parses a URL string into `URLParseInfo`, with the option to add (or skip) encoding of invalid characters. - /// If `encodingInvalidCharacters` is `true`, this function handles encoding of invalid components. - static func parse(urlString: String, encodingInvalidCharacters: Bool) -> URLParseInfo? { - return parse(urlString: urlString, encodingInvalidCharacters: encodingInvalidCharacters, compatibility: .init()) + /// Optimization for URLs initialized with just a file path. + static func parse(filePath: String, isAbsolute: Bool) -> URLParseInfo { + if isAbsolute { + precondition(filePath.utf8.first == ._slash) + let string = "file://" + filePath + let utf8 = string.utf8 + return URLParseInfo( + urlString: string, + schemeRange: utf8.startIndex.. URLParseInfo? { + /// Parses a URL string into `URLParseInfo`, with the option to add (or skip) encoding of invalid characters. + /// If `encodingInvalidCharacters` is `true`, this function handles encoding of invalid components. + static func parse(urlString: String, encodingInvalidCharacters: Bool, allowEmptyScheme: Bool = false) -> URLParseInfo? { #if os(Windows) let urlString = urlString.replacing(UInt8(ascii: "\\"), with: UInt8(ascii: "/")) #endif - guard let parseInfo = parse(urlString: urlString, compatibility: compatibility) else { + guard let parseInfo = parse(urlString: urlString, allowEmptyScheme: allowEmptyScheme) else { return nil } @@ -667,6 +727,12 @@ internal struct RFC3986Parser: URLParserProtocol { guard let percentEncodedHost = percentEncode(host, component: .host) else { return nil } + if parseInfo.isIPLiteral { + // The IP-literal may still be invalid after percent-encoding the zoneID + guard validate(host: percentEncodedHost, knownIPLiteral: true) else { + return nil + } + } finalURLString += percentEncodedHost } else if let idnaEncoded = IDNAEncodeHost(String(host)), validate(host: idnaEncoded, knownIPLiteral: false) { @@ -703,15 +769,15 @@ internal struct RFC3986Parser: URLParserProtocol { } } - return parse(urlString: finalURLString, compatibility: compatibility) + return parse(urlString: finalURLString, allowEmptyScheme: allowEmptyScheme, encodedComponents: invalidComponents) } /// Parses a URL string into its component parts and stores these ranges in a `URLParseInfo`. /// This function calls `parse(buffer:)`, then converts the buffer ranges into string ranges. - private static func parse(urlString: String, compatibility: URLParserCompatibility = .init()) -> URLParseInfo? { + private static func parse(urlString: String, allowEmptyScheme: Bool = false, encodedComponents: URLParseInfo.EncodedComponentSet = []) -> URLParseInfo? { var string = urlString let bufferParseInfo = string.withUTF8 { - parse(buffer: $0, compatibility: compatibility) + parse(buffer: $0, allowEmptyScheme: allowEmptyScheme) } guard let bufferParseInfo else { return nil @@ -727,7 +793,6 @@ internal struct RFC3986Parser: URLParserProtocol { return URLParseInfo( urlString: string, - urlParser: .RFC3986, schemeRange: convert(bufferParseInfo.schemeRange), userRange: convert(bufferParseInfo.userRange), passwordRange: convert(bufferParseInfo.passwordRange), @@ -738,14 +803,14 @@ internal struct RFC3986Parser: URLParserProtocol { fragmentRange: convert(bufferParseInfo.fragmentRange), isIPLiteral: bufferParseInfo.isIPLiteral, didPercentEncodeHost: bufferParseInfo.didPercentEncodeHost, - pathHasPercent: bufferParseInfo.pathHasPercent, - pathHasFileID: bufferParseInfo.pathHasFileID + pathHasFileID: bufferParseInfo.pathHasFileID, + encodedComponents: encodedComponents ) } /// Parses a URL string into its component parts and stores these ranges in a `URLBufferParseInfo`. /// This function only parses based on delimiters and does not do any encoding. - private static func parse(buffer: UnsafeBufferPointer, compatibility: URLParserCompatibility = .init()) -> URLBufferParseInfo? { + private static func parse(buffer: UnsafeBufferPointer, allowEmptyScheme: Bool = false) -> URLBufferParseInfo? { // A URI is either: // 1. scheme ":" hier-part [ "?" query ] [ "#" fragment ] // 2. relative-ref @@ -765,12 +830,12 @@ internal struct RFC3986Parser: URLParserProtocol { let v = buffer[currentIndex] if v == UInt8(ascii: ":") { // Scheme must be at least 1 character, otherwise this is a relative-ref. - if currentIndex != buffer.startIndex || compatibility.contains(.allowEmptyScheme) { + if currentIndex != buffer.startIndex || allowEmptyScheme { parseInfo.schemeRange = buffer.startIndex..>, into parseInfo: inout URLBufferParseInfo) -> Bool { + private static func parseAuthority(_ authority: Slice>, into parseInfo: inout URLBufferParseInfo, allowEmptyScheme: Bool) -> Bool { var hostStartIndex = authority.startIndex var hostEndIndex = authority.endIndex @@ -929,8 +986,9 @@ internal struct RFC3986Parser: URLParserProtocol { } } else if let colonIndex = authority[hostStartIndex...].firstIndex(of: UInt8(ascii: ":")) { hostEndIndex = colonIndex - if authority.index(after: colonIndex) != authority.endIndex { + if authority.index(after: colonIndex) != authority.endIndex || allowEmptyScheme { // Port only exists if non-empty, otherwise RFC 3986 suggests removing the ":". + // But, in cases where we allow empty scheme (NS/URL), also allow empty port. parseInfo.portRange = authority.index(after: colonIndex).. UInt8 { + switch hex { + case 0x0: + return UInt8(ascii: "0") + case 0x1: + return UInt8(ascii: "1") + case 0x2: + return UInt8(ascii: "2") + case 0x3: + return UInt8(ascii: "3") + case 0x4: + return UInt8(ascii: "4") + case 0x5: + return UInt8(ascii: "5") + case 0x6: + return UInt8(ascii: "6") + case 0x7: + return UInt8(ascii: "7") + case 0x8: + return UInt8(ascii: "8") + case 0x9: + return UInt8(ascii: "9") + case 0xA: + return UInt8(ascii: "A") + case 0xB: + return UInt8(ascii: "B") + case 0xC: + return UInt8(ascii: "C") + case 0xD: + return UInt8(ascii: "D") + case 0xE: + return UInt8(ascii: "E") + case 0xF: + return UInt8(ascii: "F") + default: + fatalError("Invalid hex digit: \(hex)") + } +} - func hexToAscii(_ hex: UInt8) -> UInt8 { - switch hex { - case 0x0: - return UInt8(ascii: "0") - case 0x1: - return UInt8(ascii: "1") - case 0x2: - return UInt8(ascii: "2") - case 0x3: - return UInt8(ascii: "3") - case 0x4: - return UInt8(ascii: "4") - case 0x5: - return UInt8(ascii: "5") - case 0x6: - return UInt8(ascii: "6") - case 0x7: - return UInt8(ascii: "7") - case 0x8: - return UInt8(ascii: "8") - case 0x9: - return UInt8(ascii: "9") - case 0xA: - return UInt8(ascii: "A") - case 0xB: - return UInt8(ascii: "B") - case 0xC: - return UInt8(ascii: "C") - case 0xD: - return UInt8(ascii: "D") - case 0xE: - return UInt8(ascii: "E") - case 0xF: - return UInt8(ascii: "F") - default: - fatalError("Invalid hex digit: \(hex)") - } +fileprivate func asciiToHex(_ ascii: UInt8) -> UInt8? { + switch ascii { + case UInt8(ascii: "0"): + return 0x0 + case UInt8(ascii: "1"): + return 0x1 + case UInt8(ascii: "2"): + return 0x2 + case UInt8(ascii: "3"): + return 0x3 + case UInt8(ascii: "4"): + return 0x4 + case UInt8(ascii: "5"): + return 0x5 + case UInt8(ascii: "6"): + return 0x6 + case UInt8(ascii: "7"): + return 0x7 + case UInt8(ascii: "8"): + return 0x8 + case UInt8(ascii: "9"): + return 0x9 + case UInt8(ascii: "A"), UInt8(ascii: "a"): + return 0xA + case UInt8(ascii: "B"), UInt8(ascii: "b"): + return 0xB + case UInt8(ascii: "C"), UInt8(ascii: "c"): + return 0xC + case UInt8(ascii: "D"), UInt8(ascii: "d"): + return 0xD + case UInt8(ascii: "E"), UInt8(ascii: "e"): + return 0xE + case UInt8(ascii: "F"), UInt8(ascii: "f"): + return 0xF + default: + return nil } +} + +fileprivate extension StringProtocol { - func addingPercentEncoding(forURLComponent component: URLComponentSet) -> String { + func addingPercentEncoding(forURLComponent component: URLComponentSet, skipAlreadyEncoded: Bool = false) -> String { let fastResult = utf8.withContiguousStorageIfAvailable { - addingPercentEncoding(utf8Buffer: $0, component: component) + addingPercentEncoding(utf8Buffer: $0, component: component, skipAlreadyEncoded: skipAlreadyEncoded) } if let fastResult { return fastResult } else { - return addingPercentEncoding(utf8Buffer: utf8, component: component) + return addingPercentEncoding(utf8Buffer: utf8, component: component, skipAlreadyEncoded: skipAlreadyEncoded) } } - func addingPercentEncoding(utf8Buffer: some Collection, component: URLComponentSet) -> String { + func addingPercentEncoding(utf8Buffer: some Collection, component: URLComponentSet, skipAlreadyEncoded: Bool = false) -> String { + let percent = UInt8(ascii: "%") let maxLength = utf8Buffer.count * 3 - let result = withUnsafeTemporaryAllocation(of: UInt8.self, capacity: maxLength + 1) { _buffer in - var buffer = OutputBuffer(initializing: _buffer.baseAddress!, capacity: _buffer.count) - for v in utf8Buffer { + return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: maxLength) { outputBuffer -> String in + var i = 0 + var index = utf8Buffer.startIndex + while index != utf8Buffer.endIndex { + let v = utf8Buffer[index] if v.isAllowedIn(component) { - buffer.appendElement(v) + outputBuffer[i] = v + i += 1 + } else if skipAlreadyEncoded, v == percent, + utf8Buffer.index(index, offsetBy: 1) != utf8Buffer.endIndex, + utf8Buffer[utf8Buffer.index(index, offsetBy: 1)].isValidHexDigit, + utf8Buffer.index(index, offsetBy: 2) != utf8Buffer.endIndex, + utf8Buffer[utf8Buffer.index(index, offsetBy: 2)].isValidHexDigit { + let inclusiveEnd = utf8Buffer.index(index, offsetBy: 2) + i = outputBuffer[i...i+2].initialize(fromContentsOf: utf8Buffer[index...inclusiveEnd]) + index = inclusiveEnd // Incremented below, too } else { - buffer.appendElement(UInt8(ascii: "%")) - buffer.appendElement(hexToAscii(v >> 4)) - buffer.appendElement(hexToAscii(v & 0xF)) + i = outputBuffer[i...i+2].initialize(fromContentsOf: [percent, hexToAscii(v >> 4), hexToAscii(v & 0xF)]) } + index = utf8Buffer.index(after: index) } - buffer.appendElement(0) // NULL-terminated - let initialized = buffer.relinquishBorrowedMemory() - return String(cString: initialized.baseAddress!) - } - return result - } - - func asciiToHex(_ ascii: UInt8) -> UInt8? { - switch ascii { - case UInt8(ascii: "0"): - return 0x0 - case UInt8(ascii: "1"): - return 0x1 - case UInt8(ascii: "2"): - return 0x2 - case UInt8(ascii: "3"): - return 0x3 - case UInt8(ascii: "4"): - return 0x4 - case UInt8(ascii: "5"): - return 0x5 - case UInt8(ascii: "6"): - return 0x6 - case UInt8(ascii: "7"): - return 0x7 - case UInt8(ascii: "8"): - return 0x8 - case UInt8(ascii: "9"): - return 0x9 - case UInt8(ascii: "A"), UInt8(ascii: "a"): - return 0xA - case UInt8(ascii: "B"), UInt8(ascii: "b"): - return 0xB - case UInt8(ascii: "C"), UInt8(ascii: "c"): - return 0xC - case UInt8(ascii: "D"), UInt8(ascii: "d"): - return 0xD - case UInt8(ascii: "E"), UInt8(ascii: "e"): - return 0xE - case UInt8(ascii: "F"), UInt8(ascii: "f"): - return 0xF - default: - return nil + return String(decoding: outputBuffer[.. = []) -> String? { + func removingURLPercentEncoding(excluding: Set = [], encoding: String.Encoding = .utf8) -> String? { let fastResult = utf8.withContiguousStorageIfAvailable { - removingURLPercentEncoding(utf8Buffer: $0, excluding: excluding) + removingURLPercentEncoding(utf8Buffer: $0, excluding: excluding, encoding: encoding) } if let fastResult { return fastResult } else { - return removingURLPercentEncoding(utf8Buffer: utf8, excluding: excluding) + return removingURLPercentEncoding(utf8Buffer: utf8, excluding: excluding, encoding: encoding) } } - func removingURLPercentEncoding(utf8Buffer: some Collection, excluding: Set) -> String? { - let result: String? = withUnsafeTemporaryAllocation(of: UInt8.self, capacity: utf8Buffer.count) { buffer in + func removingURLPercentEncoding(utf8Buffer: some Collection, excluding: Set, encoding: String.Encoding = .utf8) -> String? { + return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: utf8Buffer.count) { outputBuffer -> String? in var i = 0 var byte: UInt8 = 0 var hexDigitsRequired = 0 @@ -1087,25 +1153,56 @@ fileprivate extension StringProtocol { byte += hex if excluding.contains(byte) { // Keep the original percent-encoding for this byte - i = buffer[i...i+2].initialize(fromContentsOf: [UInt8(ascii: "%"), hexToAscii(byte >> 4), v]) + i = outputBuffer[i...i+2].initialize(fromContentsOf: [UInt8(ascii: "%"), hexToAscii(byte >> 4), v]) } else { - buffer[i] = byte + outputBuffer[i] = byte i += 1 byte = 0 } } hexDigitsRequired -= 1 } else { - buffer[i] = v + outputBuffer[i] = v i += 1 } } guard hexDigitsRequired == 0 else { return nil } - return String(_validating: buffer[.. = []) -> String { + precondition(including.allSatisfy { $0.isAllowedIn(.path) }) + let encoded = pathComponent.addingPercentEncoding(forURLComponent: .path) + if including.isEmpty { + return encoded + } + guard let start = encoded.utf8.firstIndex(where: { including.contains($0) }) else { + return encoded + } + var toEncode = encoded[start...] + let extraEncoded = toEncode.withUTF8 { inputBuffer in + return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: inputBuffer.count * 3) { outputBuffer -> String in + var i = 0 + for v in inputBuffer { + if including.contains(v) { + i = outputBuffer[i...i+2].initialize(fromContentsOf: [._percent, hexToAscii(v >> 4), hexToAscii(v & 0xF)]) + } else { + outputBuffer[i] = v + i += 1 + } + } + return String(decoding: outputBuffer[.. URLParseInfo? { + // Can only be nil if the port string is wildly invalid. + return compatibilityParse(urlString: urlString) + } + + static func compatibilityParse(urlString: String, encodingInvalidCharacters: Bool) -> URLParseInfo? { + guard let parseInfo = compatibilityParse(urlString: urlString) else { + return nil + } + + if !encodingInvalidCharacters { + guard validate(parseInfo: parseInfo) else { + return nil + } + return parseInfo + } + + let invalidComponents = invalidComponents(of: parseInfo) + if invalidComponents.isEmpty { + return parseInfo + } + + // One or more components were invalid, encode them. + + // Note: If we made it this far, we are performing CFURL byte encoding. + // (CFURL string parsing uses encodingInvalidCharacters: false.) + + // CFURL percent-encoding was different since it left already percent- + // encoded characters alone, e.g. "%20 %20" became "%20%20%20". + + var finalURLString = "" + + if let scheme = parseInfo.scheme { + finalURLString += "\(scheme):" + } + + if parseInfo.hasAuthority { + finalURLString += "//" + } + + if let user = parseInfo.user { + if invalidComponents.contains(.user) { + finalURLString += percentEncode(user, component: .user, skipAlreadyEncoded: true)! + } else { + finalURLString += user + } + + if let password = parseInfo.password { + if invalidComponents.contains(.password) { + finalURLString += ":\(percentEncode(password, component: .password, skipAlreadyEncoded: true)!)" + } else { + finalURLString += ":\(password)" + } + } + + finalURLString += "@" + } + + if let host = parseInfo.host { + if !invalidComponents.contains(.host) { + finalURLString += host + } else { + // For compatibility, always percent-encode instead of IDNA-encoding. + guard let percentEncodedHost = percentEncode(host, component: .host, skipAlreadyEncoded: true) else { + return nil + } + finalURLString += percentEncodedHost + } + } + + // For compatibility, append the port *string*, which may not be numeric. + // Use the .fragment component for lenient parsing of the port string. + if let portString = parseInfo.portString?.addingPercentEncoding(forURLComponent: .fragment, skipAlreadyEncoded: true) { + finalURLString += ":\(portString)" + } + + let path = parseInfo.path + if invalidComponents.contains(.path) { + // For compatibility, don't percent-encode ":" in the first path segment. + finalURLString += path.addingPercentEncoding(forURLComponent: .path, skipAlreadyEncoded: true) + } else { + finalURLString += path + } + + if let query = parseInfo.query { + if invalidComponents.contains(.query) { + finalURLString += "?\(percentEncode(query, component: .query, skipAlreadyEncoded: true)!)" + } else { + finalURLString += "?\(query)" + } + } + + if let fragment = parseInfo.fragment { + if invalidComponents.contains(.fragment) { + finalURLString += "#\(percentEncode(fragment, component: .fragment, skipAlreadyEncoded: true)!)" + } else { + finalURLString += "#\(fragment)" + } + } + + return compatibilityParse(urlString: finalURLString, encodedComponents: invalidComponents) + } + + /// Parses a URL string into its component parts and stores these ranges in a `URLParseInfo`. + /// This function calls `compatibilityParse(buffer:)`, then converts the buffer ranges into string ranges. + private static func compatibilityParse(urlString: String, encodedComponents: URLParseInfo.EncodedComponentSet = []) -> URLParseInfo? { + var string = urlString + let bufferParseInfo = string.withUTF8 { + compatibilityParse(buffer: $0) + } + guard let bufferParseInfo else { + return nil + } + + typealias URLBuffer = UnsafeBufferPointer + func convert(_ range: Range?) -> Range? { + guard let range else { return nil } + let lower = string.utf8.index(string.utf8.startIndex, offsetBy: range.lowerBound) + let upper = string.utf8.index(string.utf8.startIndex, offsetBy: range.upperBound) + return lower..) -> URLBufferParseInfo? { + // A URI is either: + // 1. scheme ":" hier-part [ "?" query ] [ "#" fragment ] + // 2. relative-ref + + var parseInfo = URLBufferParseInfo() + guard !buffer.isEmpty else { + // Path always exists, even if it's the empty string. + parseInfo.pathRange = buffer.startIndex..>, into parseInfo: inout URLBufferParseInfo) { + + var hostStartIndex = authority.startIndex + var hostEndIndex = authority.endIndex + + // MARK: User and Password + + // NOTE: The previous URL parser used the first index of "@", but WHATWG and + // other RFC 3986 parsers use the last index, so we should align with those. + if let atIndex = authority.lastIndex(of: UInt8(ascii: "@")) { + if let colonIndex = authority[.., isDirectory: Bool, relativeTo base: URL?) { + _url = NSURL(fileURLWithFileSystemRepresentation: path, isDirectory: isDirectory, relativeTo: base) + } + + var dataRepresentation: Data { + return _url.dataRepresentation + } + + var relativeString: String { + return _url.relativeString + } + + var absoluteString: String { + // This should never fail for non-file reference URLs + return _url.absoluteString ?? "" + } + + var baseURL: URL? { + return _url.baseURL + } + + var absoluteURL: URL? { + // This should never fail for non-file reference URLs + return _url.absoluteURL + } + + var scheme: String? { + return _url.scheme + } + + var isFileURL: Bool { + return _url.isFileURL + } + + var hasAuthority: Bool { + return user != nil || password != nil || host != nil || port != nil + } + + var user: String? { + return _url.user + } + + func user(percentEncoded: Bool) -> String? { + let cf = _url._cfurl().takeUnretainedValue() + if let username = _CFURLCopyUserName(cf, !percentEncoded) { + return username.takeRetainedValue() as String + } + return nil + } + + var password: String? { + return _url.password + } + + func password(percentEncoded: Bool) -> String? { + let cf = _url._cfurl().takeUnretainedValue() + if let password = _CFURLCopyPassword(cf, !percentEncoded) { + return password.takeRetainedValue() as String + } + return nil + } + + var host: String? { + return _url.host + } + + func host(percentEncoded: Bool) -> String? { + let cf = _url._cfurl().takeUnretainedValue() + if let host = _CFURLCopyHostName(cf, !percentEncoded) { + return host.takeRetainedValue() as String + } + return nil + } + + var port: Int? { + return _url.port?.intValue + } + + var relativePath: String { + let path = _url.relativePath ?? "" + if __NSURLSupportDeprecatedParameterComponent(), + let parameterString = _url._parameterString { + return path + ";" + parameterString + } + return path + } + + func relativePath(percentEncoded: Bool) -> String { + let cf = _url._cfurl().takeUnretainedValue() + if let path = _CFURLCopyPath(cf, !percentEncoded) { + return path.takeRetainedValue() as String + } + return "" + } + + func absolutePath(percentEncoded: Bool) -> String { + return absoluteURL?.relativePath(percentEncoded: percentEncoded) ?? relativePath(percentEncoded: percentEncoded) + } + + var path: String { + let path = _url.path ?? "" + if __NSURLSupportDeprecatedParameterComponent(), + let parameterString = _url._parameterString { + return path + ";" + parameterString + } + return path + } + + func path(percentEncoded: Bool) -> String { + if foundation_swift_url_enabled() { + return absolutePath(percentEncoded: percentEncoded) + } + return relativePath(percentEncoded: percentEncoded) + } + + var query: String? { + return _url.query + } + + func query(percentEncoded: Bool) -> String? { + let cf = _url._cfurl().takeUnretainedValue() + if let queryString = _CFURLCopyQueryString(cf, !percentEncoded) { + return queryString.takeRetainedValue() as String + } + return nil + } + + var fragment: String? { + return _url.fragment + } + + func fragment(percentEncoded: Bool) -> String? { + let cf = _url._cfurl().takeUnretainedValue() + if let fragment = _CFURLCopyFragment(cf, !percentEncoded) { + return fragment.takeRetainedValue() as String + } + return nil + } + + func fileSystemPath(style: URL.PathStyle, resolveAgainstBase: Bool, compatibility: Bool) -> String { + let path = resolveAgainstBase ? absolutePath(percentEncoded: true) : relativePath(percentEncoded: true) + return _SwiftURL.fileSystemPath(for: path, style: style, compatibility: compatibility) + } + + func withUnsafeFileSystemRepresentation(_ block: (UnsafePointer?) throws -> ResultType) rethrows -> ResultType { + return try block(_url.fileSystemRepresentation) + } + + var hasDirectoryPath: Bool { + return _url.hasDirectoryPath + } + + var pathComponents: [String] { + return _url.pathComponents ?? [] + } + + var lastPathComponent: String { + return _url.lastPathComponent ?? "" + } + + var pathExtension: String { + return _url.pathExtension ?? "" + } + + func appendingPathComponent(_ pathComponent: String, isDirectory: Bool) -> URL? { + if let result = _url.appendingPathComponent(pathComponent, isDirectory: isDirectory) { + return result + } + guard var c = URLComponents(url: self, resolvingAgainstBaseURL: true) else { + return nil + } + let path = (c.path as NSString).appendingPathComponent(pathComponent) + c.path = isDirectory ? path + "/" : path + return c.url + } + + func appendingPathComponent(_ pathComponent: String) -> URL? { + if let result = _url.appendingPathComponent(pathComponent) { + return result + } + guard var c = URLComponents(url: self, resolvingAgainstBaseURL: true) else { + return nil + } + c.path = (c.path as NSString).appendingPathComponent(pathComponent) + return c.url + } + + func deletingLastPathComponent() -> URL? { + guard !path.isEmpty else { return nil } + return _url.deletingLastPathComponent + } + + func appendingPathExtension(_ pathExtension: String) -> URL? { + guard !path.isEmpty else { return nil } + return _url.appendingPathExtension(pathExtension) + } + + func deletingPathExtension() -> URL? { + guard !path.isEmpty else { return nil } + return _url.deletingPathExtension + } + + func appending(path: S, directoryHint: URL.DirectoryHint) -> URL? where S : StringProtocol { + let path = String(path) + let hasTrailingSlash = (path.utf8.last == ._slash) + let isDirectory: Bool? + switch directoryHint { + case .isDirectory: + isDirectory = true + case .notDirectory: + isDirectory = false + case .checkFileSystem: + if self.isFileURL { + // We can only check file system if the URL is a file URL + isDirectory = nil + } else { + // For web addresses, trust the caller's trailing slash + isDirectory = hasTrailingSlash + } + case .inferFromPath: + isDirectory = hasTrailingSlash + } + + let result = if let isDirectory { + _url.appendingPathComponent(path, isDirectory: isDirectory) + } else { + // This method consults the file system + _url.appendingPathComponent(path) + } + + if let result { + return result + } + + guard var c = URLComponents(url: self, resolvingAgainstBaseURL: true) else { + return nil + } + var newPath = (c.path as NSString).appendingPathComponent(path) + if let isDirectory, isDirectory, newPath.utf8.last != ._slash { + newPath += "/" + } + c.path = newPath + return c.url + } + + func appending(component: S, directoryHint: URL.DirectoryHint) -> URL? where S : StringProtocol { + let pathComponent = String(component) + let hasTrailingSlash = (pathComponent.utf8.last == ._slash) + let isDirectory: Bool? + switch directoryHint { + case .isDirectory: + isDirectory = true + case .notDirectory: + isDirectory = false + case .checkFileSystem: + if self.isFileURL { + // We can only check file system if the URL is a file URL + isDirectory = nil + } else { + // For web addresses, trust the caller's trailing slash + isDirectory = hasTrailingSlash + } + case .inferFromPath: + isDirectory = hasTrailingSlash + } + + let cf = _url._cfurl().takeUnretainedValue() + if let isDirectory { + return _CFURLCreateCopyAppendingPathComponent(cf, pathComponent as CFString, isDirectory).takeRetainedValue() as URL + } + + #if !NO_FILESYSTEM + // Create a new URL without the trailing slash + let url = self.appending(component: component, directoryHint: .notDirectory) ?? URL(self) + // See if it refers to a directory + if let resourceValues = try? url.resourceValues(forKeys: [.isDirectoryKey]), + let isDirectoryValue = resourceValues.isDirectory { + return _CFURLCreateCopyAppendingPathComponent(cf, pathComponent as CFString, isDirectoryValue).takeRetainedValue() as URL + } + #endif + + // Fall back to inferring from the trailing slash + return _CFURLCreateCopyAppendingPathComponent(cf, pathComponent as CFString, hasTrailingSlash).takeRetainedValue() as URL + } + + var standardized: URL? { + return _url.standardized + } + +#if !NO_FILESYSTEM + var standardizedFileURL: URL? { + return _url.standardizingPath + } + + func resolvingSymlinksInPath() -> URL? { + return _url.resolvingSymlinksInPath + } +#endif + + var description: String { + return _url.description + } + + var debugDescription: String { + return _url.debugDescription + } + + func bridgeToNSURL() -> NSURL { + return _url + } + + func isFileReferenceURL() -> Bool { + #if NO_FILESYSTEM + return false + #else + return _url.isFileReferenceURL() + #endif + } + + func convertingFileReference() -> any _URLProtocol & AnyObject { + #if NO_FILESYSTEM + return self + #else + guard _url.isFileReferenceURL() else { return self } + if let url = _url.filePathURL { + return Self.init(url as NSURL) + } + return Self.init(string: "com-apple-unresolvable-file-reference-url:")! + #endif + } +} + + +/// `_BridgedNSSwiftURL` wraps an `_NSSwiftURL`, which is the Swift subclass of `NSURL`. +/// `_BridgedNSSwiftURL` is used when an `_NSSwiftURL` is bridged to Swift, allowing us to +/// return the same object (pointer) when bridging back to ObjC, such as in cases where `.absoluteURL` +/// should return `self`, or `.baseURL` should return a pointer to the same `NSURL` from initialization. +/// At the same time, this allows us to use the new `_SwiftURL` for `NSURL`s bridged to Swift. +internal final class _BridgedNSSwiftURL: _URLProtocol, @unchecked Sendable { + internal let _wrapped: _NSSwiftURL + internal init(_ url: _NSSwiftURL) { + _wrapped = url + } + + private var _url: _SwiftURL { + return _wrapped.url + } + + init?(string: String) { + guard !string.isEmpty, let inner = _SwiftURL(string: string) else { return nil } + _wrapped = _NSSwiftURL(url: inner) + } + + init?(string: String, relativeTo url: URL?) { + guard !string.isEmpty, let inner = _SwiftURL(string: string, relativeTo: url) else { return nil } + _wrapped = _NSSwiftURL(url: inner) + } + + init?(string: String, encodingInvalidCharacters: Bool) { + guard !string.isEmpty, let inner = _SwiftURL(string: string, encodingInvalidCharacters: encodingInvalidCharacters) else { return nil } + _wrapped = _NSSwiftURL(url: inner) + } + + init?(stringOrEmpty: String, relativeTo url: URL?) { + guard let inner = _SwiftURL(string: stringOrEmpty, relativeTo: url) else { return nil } + _wrapped = _NSSwiftURL(url: inner) + } + + init(fileURLWithPath path: String, isDirectory: Bool, relativeTo base: URL?) { + let inner = _SwiftURL(fileURLWithPath: path.isEmpty ? "." : path, isDirectory: isDirectory, relativeTo: base) + _wrapped = _NSSwiftURL(url: inner) + } + + init(fileURLWithPath path: String, relativeTo base: URL?) { + let inner = _SwiftURL(fileURLWithPath: path.isEmpty ? "." : path, relativeTo: base) + _wrapped = _NSSwiftURL(url: inner) + } + + init(fileURLWithPath path: String, isDirectory: Bool) { + let inner = _SwiftURL(fileURLWithPath: path.isEmpty ? "." : path, isDirectory: isDirectory) + _wrapped = _NSSwiftURL(url: inner) + } + + init(fileURLWithPath path: String) { + let inner = _SwiftURL(fileURLWithPath: path.isEmpty ? "." : path) + _wrapped = _NSSwiftURL(url: inner) + } + + init(filePath path: String, directoryHint: URL.DirectoryHint, relativeTo base: URL?) { + let filePath = path.isEmpty ? "./" : path + let inner = switch directoryHint { + case .isDirectory: + _SwiftURL(fileURLWithPath: filePath, isDirectory: true, relativeTo: base) + case .notDirectory: + _SwiftURL(fileURLWithPath: filePath, isDirectory: false, relativeTo: base) + case .checkFileSystem: + _SwiftURL(fileURLWithPath: filePath, relativeTo: base) + case .inferFromPath: + _SwiftURL(fileURLWithPath: filePath, isDirectory: (filePath.utf8.last == ._slash), relativeTo: base) + } + _wrapped = _NSSwiftURL(url: inner) + } + + init?(dataRepresentation: Data, relativeTo base: URL?, isAbsolute: Bool) { + guard !dataRepresentation.isEmpty, + let inner = _SwiftURL(dataRepresentation: dataRepresentation, relativeTo: base, isAbsolute: isAbsolute) else { + return nil + } + _wrapped = _NSSwiftURL(url: inner) + } + + init(fileURLWithFileSystemRepresentation path: UnsafePointer, isDirectory: Bool, relativeTo base: URL?) { + let inner = _SwiftURL(fileURLWithFileSystemRepresentation: path, isDirectory: isDirectory, relativeTo: base) + _wrapped = _NSSwiftURL(url: inner) + } + + var dataRepresentation: Data { + return _url.dataRepresentation + } + + var relativeString: String { + return _url.relativeString + } + + var absoluteString: String { + // This should never fail for non-file reference URLs + return _url.absoluteString + } + + var baseURL: URL? { + return _url.baseURL + } + + var absoluteURL: URL? { + // This should never fail for non-file reference URLs + return _url.absoluteURL + } + + var scheme: String? { + return _url.scheme + } + + var isFileURL: Bool { + return _url.isFileURL + } + + var hasAuthority: Bool { + return user != nil || password != nil || host != nil || port != nil + } + + var user: String? { + return _url.user + } + + func user(percentEncoded: Bool) -> String? { + return _url.user(percentEncoded: percentEncoded) + } + + var password: String? { + return _url.password + } + + func password(percentEncoded: Bool) -> String? { + return _url.password(percentEncoded: percentEncoded) + } + + var host: String? { + return _url.host + } + + func host(percentEncoded: Bool) -> String? { + return _url.host(percentEncoded: percentEncoded) + } + + var port: Int? { + return _url.port + } + + var relativePath: String { + return _url.relativePath + } + + func relativePath(percentEncoded: Bool) -> String { + return _url.relativePath(percentEncoded: percentEncoded) + } + + func absolutePath(percentEncoded: Bool) -> String { + return _url.absolutePath(percentEncoded: percentEncoded) + } + + var path: String { + return _url.path + } + + func path(percentEncoded: Bool) -> String { + return absolutePath(percentEncoded: percentEncoded) + } + + var query: String? { + return _url.query + } + + func query(percentEncoded: Bool) -> String? { + return _url.query(percentEncoded: percentEncoded) + } + + var fragment: String? { + return _url.fragment + } + + func fragment(percentEncoded: Bool) -> String? { + return _url.fragment(percentEncoded: percentEncoded) + } + + func fileSystemPath(style: URL.PathStyle, resolveAgainstBase: Bool, compatibility: Bool) -> String { + return _url.fileSystemPath(style: style, resolveAgainstBase: resolveAgainstBase, compatibility: compatibility) + } + + func withUnsafeFileSystemRepresentation(_ block: (UnsafePointer?) throws -> ResultType) rethrows -> ResultType { + return try _url.withUnsafeFileSystemRepresentation(block) + } + + var hasDirectoryPath: Bool { + return _url.hasDirectoryPath + } + + var pathComponents: [String] { + return _url.pathComponents + } + + var lastPathComponent: String { + return _url.lastPathComponent + } + + var pathExtension: String { + return _url.pathExtension + } + + func appendingPathComponent(_ pathComponent: String, isDirectory: Bool) -> URL? { + return _url.appendingPathComponent(pathComponent, isDirectory: isDirectory) + } + + func appendingPathComponent(_ pathComponent: String) -> URL? { + return _url.appendingPathComponent(pathComponent) + } + + func deletingLastPathComponent() -> URL? { + return _url.deletingLastPathComponent() + } + + func appendingPathExtension(_ pathExtension: String) -> URL? { + return _url.appendingPathExtension(pathExtension) + } + + func deletingPathExtension() -> URL? { + return _url.deletingPathExtension() + } + + func appending(path: S, directoryHint: URL.DirectoryHint) -> URL? where S : StringProtocol { + return _url.appending(path: path, directoryHint: directoryHint) + } + + func appending(component: S, directoryHint: URL.DirectoryHint) -> URL? where S : StringProtocol { + return _url.appending(component: component, directoryHint: directoryHint) + } + + var standardized: URL? { + return _url.standardized + } + +#if !NO_FILESYSTEM + var standardizedFileURL: URL? { + return _url.standardizedFileURL + } + + func resolvingSymlinksInPath() -> URL? { + return _url.resolvingSymlinksInPath() + } +#endif + + var description: String { + return _url.description + } + + var debugDescription: String { + return _url.debugDescription + } + + func bridgeToNSURL() -> NSURL { + return _wrapped + } + + func isFileReferenceURL() -> Bool { + #if NO_FILESYSTEM + return false + #else + return _url.isFileReferenceURL() + #endif + } + + func convertingFileReference() -> any _URLProtocol & AnyObject { + #if NO_FILESYSTEM + return self + #else + guard _url.isFileReferenceURL() else { return self } + if let url = _wrapped.filePathURL { + return url._url + } + return _SwiftURL(string: "com-apple-unresolvable-file-reference-url:")! + #endif + } +} + +#endif diff --git a/Sources/FoundationEssentials/URL/URL_ObjC.swift b/Sources/FoundationEssentials/URL/URL_ObjC.swift new file mode 100644 index 000000000..3b3f836b5 --- /dev/null +++ b/Sources/FoundationEssentials/URL/URL_ObjC.swift @@ -0,0 +1,781 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024-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 +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +#if FOUNDATION_FRAMEWORK + +internal import _ForSwiftFoundation + +@objc +extension NSURL { + + /// `encodingInvalidCharacters: false` is equivalent to `CFURLCreateWithString`. + /// + /// `encodingInvalidCharacters: true` is equivalent to `CFURLCreateWithBytes`. + /// + /// `forceBaseURL` is used for compatibility-mode `CFURLCreateAbsoluteURLWithBytes`. + /// Usually, we drop the base URL if the relative string contains a scheme, but in this specific case, + /// we need to keep the base URL around until with call `.compatibilityAbsoluteURL`, + /// which has special behavior for a relative and base URL with the same scheme. + static func _cfurlWith(string: String, encoding: CFStringEncoding, relativeToURL base: URL?, encodingInvalidCharacters: Bool, forceBaseURL: Bool) -> NSURL? { + let encoding = String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(encoding)) + guard let url = _SwiftURL(stringOrEmpty: string, relativeTo: base, encodingInvalidCharacters: encodingInvalidCharacters, encoding: encoding, compatibility: true, forceBaseURL: forceBaseURL) else { return nil } + return _NSSwiftURL(url: url) + } + + /// Equivalent to `+[NSURL URLWithString:relativeToURL:encodingInvalidCharacters:]` + static func _urlWith(string: String, relativeToURL base: URL?, encodingInvalidCharacters: Bool) -> NSURL? { + guard let url = _SwiftURL(stringOrEmpty: string, relativeTo: base, encodingInvalidCharacters: encodingInvalidCharacters) else { return nil } + return _NSSwiftURL(url: url) + } + + /// Equivalent to `+[NSURL URLWithDataRepresentation:relativeToURL:]` or + /// `+[NSURL absoluteURLWithDataRepresentation:relativeToURL:]` based + /// on the value of `isAbsolute`. + /// + /// Uses the same parsing logic as `CFURLCreateWithBytes`. + static func _urlWith(dataRepresentation: Data, relativeToURL base: URL?, isAbsolute: Bool) -> NSURL? { + guard let url = _SwiftURL(dataRepresentation: dataRepresentation, relativeTo: base, isAbsolute: isAbsolute) else { return nil } + return _NSSwiftURL(url: url) + } + + /// Equivalent to `+[NSURL fileURLWithPath:relativeToURL:]` + static func _fileURLWith(path: String, relativeToURL base: URL?) -> NSURL? { + if path.isEmpty { + return base as NSURL? + } + let directoryHint: URL.DirectoryHint = path.utf8.last == ._slash ? .isDirectory : .checkFileSystem + let url = _SwiftURL(filePath: path, directoryHint: directoryHint, relativeTo: base) + return _NSSwiftURL(url: url) + } + + /// Equivalent to `+[NSURL fileURLWithPath:isDirectory:relativeToURL:]` + static func _fileURLWith(path: String, isDirectory: Bool, relativeToURL base: URL?) -> NSURL? { + if path.isEmpty { + return base as NSURL? + } + let directoryHint: URL.DirectoryHint = isDirectory ? .isDirectory : .notDirectory + let url = _SwiftURL(filePath: path, directoryHint: directoryHint, relativeTo: base) + return _NSSwiftURL(url: url) + } + + /// Equivalent to `CFURLCreateWithFileSystemPathRelativeToBase`. + static func _fileURLWith(path: String, pathStyle: CFURLPathStyle, isDirectory: Bool, relativeToURL base: URL?) -> NSURL? { + if path.isEmpty { + return base as NSURL? + } + let directoryHint: URL.DirectoryHint = isDirectory ? .isDirectory : .notDirectory + let url = _SwiftURL(filePath: path, pathStyle: pathStyle.swiftValue, directoryHint: directoryHint, relativeTo: base) + return _NSSwiftURL(url: url) + } + +} + + +@objc(_NSSwiftURL) +internal class _NSSwiftURL: _NSURLBridge, @unchecked Sendable { + let url: _SwiftURL + let string: String + + // Important flags for NS/CFURL-specific logic + let isDecomposable: Bool + let hasNetLocation: Bool + let hasPath: Bool + + init(url: _SwiftURL) { + self.url = url + + // Store the string here to prevent a premature + // release when it's bridged to an NS or CFString. + self.string = url._parseInfo.urlString + + self.isDecomposable = url.isDecomposable + self.hasNetLocation = (url._parseInfo.netLocationRange?.isEmpty == false) + self.hasPath = self.isDecomposable && (!url._parseInfo.path.isEmpty || self.hasNetLocation) + super.init() + } + + override var classForCoder: AnyClass { + NSURL.self + } + + override static var supportsSecureCoding: Bool { true } + + required init?(coder: NSCoder) { + fatalError("Only NSURL should be encoded in an archive") + } + + override func isEqual(_ object: Any?) -> Bool { + if let other = object as? _NSSwiftURL { + return url == other.url + } else if let other = object as? NSURL { + return url == other._trueSelf()._swiftURL + } else { + return false + } + } + + // Note: copy(with:) is just a retain in NSURL + + override var hash: Int { + return url.hashValue + } + + override var description: String { + return url.description + } + + override var dataRepresentation: Data { + return url.dataRepresentation + } + + override var absoluteString: String? { + guard !relativeString.isEmpty else { return baseURL?.absoluteString ?? "" } // Compatibility behavior + return url.absoluteString + } + + override var relativeString: String { + return string + } + + override var baseURL: URL? { + return url.baseURL + } + + override var absoluteURL: URL? { + guard baseURL != nil else { return URL(self) } + guard !relativeString.isEmpty else { return baseURL } // Compatibility behavior + #if !NO_FILESYSTEM + if let baseURL, baseURL.isFileReferenceURL(), !baseURL.hasDirectoryPath { + guard let baseFilePathURL = (baseURL as NSURL).filePathURL else { + return nil + } + return _SwiftURL(string: relativeString, relativeTo: baseFilePathURL)?.absoluteURL + } + #endif + return url.absoluteURL + } + + override var scheme: String? { + url.scheme + } + + // Note: This is NOT the same as CFURLCopyResourceSpecifier. + override var resourceSpecifier: String? { + guard scheme != nil && baseURL == nil else { + return relativeString + } + // We have a scheme and no base + guard isDecomposable else { + return _cfurlResourceSpecifier + } + var result: String? + if let _netLocation { + result = "//" + _netLocation + } + if let path = _relativePath(true) { + result = (result ?? "") + path + } + if let _cfurlResourceSpecifier { + result = (result ?? "") + _cfurlResourceSpecifier + } + return result + } + + override var user: String? { + url.user + } + + override var password: String? { + url.password + } + + override var host: String? { + url.host + } + + override var port: NSNumber? { + url.port as NSNumber? + } + + override var path: String? { + guard isDecomposable else { + return nil + } + if isFileURL { + return _fileSystemPath() + } else { + return url.path + } + } + + #if !NO_FILESYSTEM + private func filePath(for fileReferencePath: String) -> String? { + var fileReferencePath = fileReferencePath + return fileReferencePath.withUTF8 { buffer -> String? in + guard buffer.starts(with: URL.fileIDPrefix) else { + return nil + } + let volumeIDStart = URL.fileIDPrefix.count + guard let volumeIDEnd = buffer[volumeIDStart...].firstIndex(of: ._dot) else { + return nil + } + let volumeIDStr = String(decoding: buffer[volumeIDStart.. String? { + guard hasPath else { + return resolveAgainstBase ? baseURL?.fileSystemPath(style: pathStyle, resolveAgainstBase: true, compatibility: true) : nil + } + guard !url._parseInfo.path.isEmpty else { + if resolveAgainstBase, let baseURL { + return baseURL.fileSystemPath(style: pathStyle, resolveAgainstBase: true, compatibility: true).deletingLastPathComponent() + } + return "" + } + #if !NO_FILESYSTEM + if (!resolveAgainstBase || baseURL == nil) && isFileReferenceURL() { + guard let fileReferencePath = filePath(for: url.relativePath(percentEncoded: true)) else { + return nil + } + return _SwiftURL.fileSystemPath(for: fileReferencePath, style: pathStyle, compatibility: true) + } + #endif + return url.fileSystemPath(style: pathStyle, resolveAgainstBase: resolveAgainstBase, compatibility: true) + } + + override var relativePath: String? { + return _fileSystemPath(resolveAgainstBase: false) + } + + override var query: String? { + guard isDecomposable else { + return nil + } + return url.query + } + + override var fragment: String? { + guard isDecomposable else { + return nil + } + return url.fragment + } + + override var hasDirectoryPath: Bool { + if url.hasDirectoryPath { + return true + } + return url.path.isEmpty && baseURL?.hasDirectoryPath ?? false + } + + override var isFileURL: Bool { + url.isFileURL + } + + override var standardized: URL? { + return url.standardized ?? URL(self) + } + + #if !NO_FILESYSTEM + override func isFileReferenceURL() -> Bool { + url.isFileReferenceURL() + } + #endif + + // Note: fileReferenceURL() calls into NSURL since CFURL is needed + + // Note: filePathURL calls into NSURL since CFURL is needed + + // Used by CFURL, which expects "" on empty path + override var _lastPathComponent: String? { + #if !NO_FILESYSTEM + if isFileReferenceURL(), let filePathURL { + return (filePathURL as NSURL)._lastPathComponent + } + #endif + guard hasPath else { + return "" + } + let result = url.lastPathComponent + if result == "/" && url._parseInfo.path != "/" { return "" } + return result + } + + // NSURL and CFURL share exact behavior for this method. + override var deletingLastPathComponent: URL? { + #if !NO_FILESYSTEM + if isFileReferenceURL() { + return filePathURL?.deletingLastPathComponent() + } + #endif + guard hasPath else { + return nil + } + if url.path == "/" || url.path == "/." || url.lastPathComponent == ".." { + return url.appending(path: "../", directoryHint: .isDirectory) + } + if url.lastPathComponent == "." { + var comp = URLComponents(parseInfo: url._parseInfo) + let newPath = comp.percentEncodedPath._droppingTrailingSlashes.dropLast() + "../" + comp.percentEncodedPath = String(newPath) + if let result = comp.url(relativeTo: baseURL) { + return result + } + } + return url.deletingLastPathComponent() ?? URL(self) + } + + // NSURL and CFURL share exact behavior for this method. + override var deletingPathExtension: URL? { + #if !NO_FILESYSTEM + if isFileReferenceURL() { + return filePathURL?.deletingPathExtension() + } + #endif + guard hasPath else { + return nil + } + return url.deletingPathExtension() ?? URL(self) + } + +} + +// MARK: - Internal overrides for NSURL + +extension _NSSwiftURL { + + // Don't override these appending methods directly so we can + // check input and throw an exception in ObjC if necessary. + + // NSURL and CFURL share exact behavior for this method. + override func _URL(byAppendingPathComponent pathComponent: String, isDirectory: Bool, encodingSlashes: Bool) -> URL? { + if let nulIndex = pathComponent.utf8.firstIndex(of: 0), + !pathComponent[nulIndex...].utf8.allSatisfy({ $0 == 0 }) { + return nil + } + guard hasPath else { + return nil + } + var url = url + #if !NO_FILESYSTEM + if isFileReferenceURL(), let filePathSwiftURL = filePathURL?._swiftURL { + url = filePathSwiftURL + } + #endif + let directoryHint: URL.DirectoryHint = isDirectory ? .isDirectory : .notDirectory + return url.appending(path: pathComponent, directoryHint: directoryHint, encodingSlashes: encodingSlashes, compatibility: true) + } + + // NSURL and CFURL share exact behavior for this method. + override func _URL(byAppendingPathExtension pathExtension: String) -> URL? { + guard !pathExtension.isEmpty else { + return self as URL + } + guard hasPath else { + return nil + } + var url = url + #if !NO_FILESYSTEM + if isFileReferenceURL() { + guard let filePathSwiftURL = filePathURL?._swiftURL else { + return nil + } + url = filePathSwiftURL + } + #endif + return url.appendingPathExtension(pathExtension, compatibility: true) ?? URL(self) + } + +} + +// MARK: - Internal overrides for CFURL + +extension CFURLPathStyle { + var swiftValue: URL.PathStyle { + return switch self { + case .cfurlposixPathStyle: .posix + case .cfurlWindowsPathStyle: .windows + case .cfurlhfsPathStyle: fatalError("HFS path style is deprecated") + default: URL.defaultPathStyle + } + } +} + +extension _NSSwiftURL { + + override var _originalString: String { + return url.originalString + } + + override var _encoding: CFStringEncoding { + CFStringConvertNSStringEncodingToEncoding(url._encoding.rawValue) + } + + override var _resourceInfoPtr: UnsafeMutableRawPointer? { + get { + url._resourceInfo.ref.withLock { + guard let cf = $0 else { return nil } + return Unmanaged.passUnretained(cf).toOpaque() + } + } + set { + url._resourceInfo.ref.withLockUnchecked { + guard let newValue else { + $0 = nil + return + } + // URL._resourceInfo is responsible for releasing this on deinit + $0 = Unmanaged.fromOpaque(newValue).takeUnretainedValue() + } + } + } + + override var _compatibilityAbsolute: URL { + return url.compatibilityAbsoluteURL ?? URL(self) + } + + override var _isDecomposable: Bool { + return isDecomposable + } + + override var _netLocation: String? { + guard let netLocation = url.netLocation, + !netLocation.isEmpty else { + return nil + } + return netLocation + } + + override var _cfurlResourceSpecifier: String? { + guard isDecomposable else { + // Return everything after the scheme + guard let colonIndex = relativeString.utf8.firstIndex(where: { $0 == ._colon }) else { + return nil + } + let start = relativeString.utf8.index(after: colonIndex) + return String(relativeString[start...]) + } + var result: String? + if let query = url._parseInfo.query { + result = "?\(query)" + } + if let fragment = url._parseInfo.fragment { + result = (result ?? "") + "#\(fragment)" + } + return result + } + + override func _user(_ percentEncoded: Bool) -> String? { + url.user(percentEncoded: percentEncoded) + } + + override func _password(_ percentEncoded: Bool) -> String? { + url.password(percentEncoded: percentEncoded) + } + + override func _host(_ percentEncoded: Bool) -> String? { + url.host(percentEncoded: percentEncoded) + } + + override func _relativePath(_ percentEncoded: Bool) -> String? { + guard hasPath else { + return nil + } + return url.relativePath(percentEncoded: percentEncoded) + } + + override func _fileSystemPath(_ pathStyle: CFURLPathStyle, resolveAgainstBase: Bool) -> String? { + let path = _fileSystemPath(pathStyle.swiftValue, resolveAgainstBase: resolveAgainstBase) + if pathStyle == .cfurlWindowsPathStyle { + return path?.replacing(._slash, with: ._backslash) + } + return path + } + + override func _query(_ charsToLeaveEscaped: String?) -> String? { + guard isDecomposable, let query else { + return nil + } + guard let charsToLeaveEscaped else { + return query + } + return RFC3986Parser.percentDecode(query, excluding: Set(charsToLeaveEscaped.utf8)) + } + + override func _fragment(_ charsToLeaveEscaped: String?) -> String? { + guard isDecomposable, let fragment else { + return nil + } + guard let charsToLeaveEscaped else { + return fragment + } + return RFC3986Parser.percentDecode(fragment, excluding: Set(charsToLeaveEscaped.utf8)) + } + + private func _nonDecomposableRange(for component: CFURLComponentType, rangeIncludingSeparators: UnsafeMutablePointer) -> CFRange { + // URL must be of the form "scheme:resource-specifier". + guard let scheme else { + rangeIncludingSeparators.pointee = CFRange(location: kCFNotFound, length: 0) + return CFRange(location: kCFNotFound, length: 0) + } + // Scheme must be ASCII, so UTF-8 length can be used. + let schemeLength = scheme.utf8.count + switch component { + case .scheme: + rangeIncludingSeparators.pointee = CFRange(location: 0, length: schemeLength + 1) + return CFRange(location: 0, length: schemeLength) + case .resourceSpecifier: + let stringLength = url.originalString.lengthOfBytes(using: url._encoding) + if schemeLength + 1 == stringLength { + rangeIncludingSeparators.pointee = CFRange(location: stringLength, length: 0) + return CFRange(location: kCFNotFound, length: 0) + } + rangeIncludingSeparators.pointee = CFRange(location: schemeLength, length: stringLength - schemeLength) + return CFRange(location: schemeLength + 1, length: stringLength - schemeLength - 1) + default: + rangeIncludingSeparators.pointee = CFRange(location: kCFNotFound, length: 0) + return CFRange(location: kCFNotFound, length: 0) + } + } + + private func _decomposableRange(for component: CFURLComponentType, rangeIncludingSeparators: UnsafeMutablePointer) -> CFRange { + let parseInfo = if url.encodedComponents.isEmpty { + url._parseInfo + } else { + RFC3986Parser.rawParse(urlString: url.originalString) + } + guard let parseInfo else { + rangeIncludingSeparators.pointee = CFRange(location: kCFNotFound, length: 0) + return CFRange(location: kCFNotFound, length: 0) + } + let string = url.originalString + let encoding = url._encoding + switch component { + case .scheme: + if let scheme = parseInfo.scheme { + // Scheme must be ASCII, so we can use UTF8 length. + let schemeLength = scheme.utf8.count + var afterSeparatorLength = parseInfo.hasAuthority ? 3 : 1 + if !hasNetLocation && !hasPath { + afterSeparatorLength = 0 + } + rangeIncludingSeparators.pointee = CFRange(location: 0, length: schemeLength + afterSeparatorLength) + return CFRange(location: 0, length: schemeLength) + } + case .netLocation: + if let netLocationRange = parseInfo.netLocationRange, + !netLocationRange.isEmpty { + let beforeLength = string[.. Int { + let parseInfo = if url.encodedComponents.isEmpty { + url._parseInfo + } else { + RFC3986Parser.rawParse(urlString: url.originalString) + } + guard let parseInfo else { + return 0 + } + let string = parseInfo.urlString + let encoding = url._encoding + + var index = string.startIndex + if component == .scheme { + return 0 + } + if let schemeEnd = parseInfo.schemeRange?.upperBound { + index = string.utf8.index(after: schemeEnd) + } + if component == .netLocation { + let result = string[..) -> CFRange { + guard isDecomposable else { + return _nonDecomposableRange(for: component, rangeIncludingSeparators: rangeIncludingSeparators) + } + let range = _decomposableRange(for: component, rangeIncludingSeparators: rangeIncludingSeparators) + if range.location == kCFNotFound { + rangeIncludingSeparators.pointee = CFRange(location: _locationToInsert(component: component), length: 0) + } + return range + } +} + +#endif // FOUNDATION_FRAMEWORK diff --git a/Sources/FoundationEssentials/URL/URL_Protocol.swift b/Sources/FoundationEssentials/URL/URL_Protocol.swift new file mode 100644 index 000000000..9ade0e8bf --- /dev/null +++ b/Sources/FoundationEssentials/URL/URL_Protocol.swift @@ -0,0 +1,102 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org 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 +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +#if FOUNDATION_FRAMEWORK +/// In `FOUNDATION_FRAMEWORK`, the inner class types of `struct URL` conform to this protocol. +/// Outside `FOUNDATION_FRAMEWORK`, only `_SwiftURL` is used, so the protocol is not needed. +/// - `class _SwiftURL` is the new Swift implementation for a true Swift `URL`. +/// - `class _BridgedURL` wraps the old `NSURL` implementation, which is used for custom `NSURL` subclasses that are bridged to Swift. +/// - `class _BridgedNSSwiftURL` wraps a `_NSSwiftURL` (Swift subclass/implementation for `NSURL`) to maintain pointers when bridging. +/// - Note: Except for `baseURL`, a nil `URL?` return value means that `struct URL` will return `self`. +internal protocol _URLProtocol: AnyObject, Sendable { + init?(string: String) + init?(string: String, relativeTo url: URL?) + init?(string: String, encodingInvalidCharacters: Bool) + init?(stringOrEmpty: String, relativeTo url: URL?) + + init(fileURLWithPath path: String, isDirectory: Bool, relativeTo base: URL?) + init(fileURLWithPath path: String, relativeTo base: URL?) + init(fileURLWithPath path: String, isDirectory: Bool) + init(fileURLWithPath path: String) + init(filePath path: String, directoryHint: URL.DirectoryHint, relativeTo base: URL?) + + init?(dataRepresentation: Data, relativeTo base: URL?, isAbsolute: Bool) + init(fileURLWithFileSystemRepresentation path: UnsafePointer, isDirectory: Bool, relativeTo base: URL?) + + var dataRepresentation: Data { get } + var relativeString: String { get } + var absoluteString: String { get } + var baseURL: URL? { get } + var absoluteURL: URL? { get } + + var scheme: String? { get } + var isFileURL: Bool { get } + var hasAuthority: Bool { get } + + var user: String? { get } + func user(percentEncoded: Bool) -> String? + + var password: String? { get } + func password(percentEncoded: Bool) -> String? + + var host: String? { get } + func host(percentEncoded: Bool) -> String? + + var port: Int? { get } + + var relativePath: String { get } + func relativePath(percentEncoded: Bool) -> String + func absolutePath(percentEncoded: Bool) -> String + var path: String { get } + func path(percentEncoded: Bool) -> String + + var query: String? { get } + func query(percentEncoded: Bool) -> String? + + var fragment: String? { get } + func fragment(percentEncoded: Bool) -> String? + + func fileSystemPath(style: URL.PathStyle, resolveAgainstBase: Bool, compatibility: Bool) -> String + func withUnsafeFileSystemRepresentation(_ block: (UnsafePointer?) throws -> ResultType) rethrows -> ResultType + + var hasDirectoryPath: Bool { get } + var pathComponents: [String] { get } + var lastPathComponent: String { get } + var pathExtension: String { get } + + func appendingPathComponent(_ pathComponent: String, isDirectory: Bool) -> URL? + func appendingPathComponent(_ pathComponent: String) -> URL? + func appending(path: S, directoryHint: URL.DirectoryHint) -> URL? + func appending(component: S, directoryHint: URL.DirectoryHint) -> URL? + func deletingLastPathComponent() -> URL? + func appendingPathExtension(_ pathExtension: String) -> URL? + func deletingPathExtension() -> URL? + var standardized: URL? { get } + +#if !NO_FILESYSTEM + var standardizedFileURL: URL? { get } + func resolvingSymlinksInPath() -> URL? +#endif + + var description: String { get } + var debugDescription: String { get } + + func bridgeToNSURL() -> NSURL + func isFileReferenceURL() -> Bool + + /// We must not store a `_URLProtocol` in `URL` without running it through this function. + /// This makes sure that we do not hold a file reference URL, which changes the nullability of many functions. + /// - Note: File reference URL here is not the same as playground's "file reference". + /// - Note: This is a no-op `#if !FOUNDATION_FRAMEWORK`. + func convertingFileReference() -> any _URLProtocol & AnyObject +} +#endif // FOUNDATION_FRAMEWORK diff --git a/Sources/FoundationEssentials/URL/URL_Swift.swift b/Sources/FoundationEssentials/URL/URL_Swift.swift new file mode 100644 index 000000000..b0ee65606 --- /dev/null +++ b/Sources/FoundationEssentials/URL/URL_Swift.swift @@ -0,0 +1,1220 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org 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 +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +#if canImport(Darwin) +import Darwin +#elseif canImport(Android) +@preconcurrency import Android +#elseif canImport(Glibc) +@preconcurrency import Glibc +#elseif canImport(Musl) +@preconcurrency import Musl +#elseif os(Windows) +import WinSDK +#elseif os(WASI) +@preconcurrency import WASILibc +#endif + +/// `_SwiftURL` provides the new Swift implementation for `URL`, using the same parser +/// and `URLParseInfo` as `URLComponents`, but with a few compatibility behaviors. +/// +/// Outside of `FOUNDATION_FRAMEWORK`, `_SwiftURL` provides the sole implementation +/// for `URL`. In `FOUNDATION_FRAMEWORK`, there are additional classes to handle `NSURL` +/// subclassing and bridging from ObjC. +/// +/// - Note: For functions returning `URL?`, a `nil` return value allows `struct URL` to return `self` without creating a new struct. +internal final class _SwiftURL: Sendable, Hashable, Equatable { + typealias Parser = RFC3986Parser + internal let _parseInfo: URLParseInfo + internal let _baseURL: URL? + internal let _encoding: String.Encoding + + #if FOUNDATION_FRAMEWORK + // Used frequently for NS/CFURL behaviors + internal var isDecomposable: Bool { + return _parseInfo.scheme == nil || hasAuthority || _parseInfo.path.utf8.first == ._slash + } + + // For use by CoreServicesInternal to cache property values. + internal final class ResourceInfo: @unchecked Sendable { + let ref = LockedState(initialState: nil) + } + internal let _resourceInfo = ResourceInfo() + + // Only used if foundation_swift_nsurl_enabled() is false. + // Note: We use a lock instead of a lazy var to ensure that we always + // bridge to the same NSURL even if the URL was copied across threads. + private let _nsurlLock = LockedState(initialState: nil) + private var _nsurl: NSURL { + return _nsurlLock.withLock { + if let nsurl = $0 { return nsurl } + let nsurl = Self._makeNSURL(from: _parseInfo, baseURL: _baseURL) + $0 = nsurl + return nsurl + } + } + #endif + + internal var url: URL { + URL(self) + } + + private static func parse(string: String, encodingInvalidCharacters: Bool = true) -> URLParseInfo? { + return Parser.parse(urlString: string, encodingInvalidCharacters: encodingInvalidCharacters, allowEmptyScheme: true) + } + + private static func compatibilityParse(string: String, encodingInvalidCharacters: Bool = false) -> URLParseInfo? { + return Parser.compatibilityParse(urlString: string, encodingInvalidCharacters: encodingInvalidCharacters) + } + + init?(stringOrEmpty: String, relativeTo base: URL? = nil, encodingInvalidCharacters: Bool = true, encoding: String.Encoding = .utf8, compatibility: Bool = false, forceBaseURL: Bool = false) { + let parseInfo = if compatibility { + Self.compatibilityParse(string: stringOrEmpty, encodingInvalidCharacters: encodingInvalidCharacters) + } else { + Self.parse(string: stringOrEmpty, encodingInvalidCharacters: encodingInvalidCharacters) + } + guard let parseInfo else { return nil } + _parseInfo = parseInfo + _baseURL = (forceBaseURL || parseInfo.scheme == nil) ? base?.absoluteURL : nil + _encoding = encoding + } + + convenience init?(string: String) { + guard !string.isEmpty else { return nil } + self.init(stringOrEmpty: string) + } + + convenience init?(string: String, relativeTo base: URL?) { + guard !string.isEmpty else { return nil } + self.init(stringOrEmpty: string, relativeTo: base) + } + + convenience init?(string: String, encodingInvalidCharacters: Bool) { + guard !string.isEmpty else { return nil } + self.init(stringOrEmpty: string, encodingInvalidCharacters: encodingInvalidCharacters) + } + + convenience init?(stringOrEmpty: String, relativeTo base: URL?) { + self.init(stringOrEmpty: stringOrEmpty, relativeTo: base, encoding: .utf8) + } + + convenience init(fileURLWithPath path: String, isDirectory: Bool, relativeTo base: URL?) { + let directoryHint: URL.DirectoryHint = isDirectory ? .isDirectory : .notDirectory + self.init(filePath: path.isEmpty ? "." : path, directoryHint: directoryHint, relativeTo: base) + } + + convenience init(fileURLWithPath path: String, relativeTo base: URL?) { + let directoryHint: URL.DirectoryHint = path.utf8.last == ._slash ? .isDirectory : .checkFileSystem + self.init(filePath: path.isEmpty ? "." : path, directoryHint: directoryHint, relativeTo: base) + } + + convenience init(fileURLWithPath path: String, isDirectory: Bool) { + let directoryHint: URL.DirectoryHint = isDirectory ? .isDirectory : .notDirectory + self.init(filePath: path.isEmpty ? "." : path, directoryHint: directoryHint) + } + + convenience init(fileURLWithPath path: String) { + let directoryHint: URL.DirectoryHint = path.utf8.last == ._slash ? .isDirectory : .checkFileSystem + self.init(filePath: path.isEmpty ? "." : path, directoryHint: directoryHint) + } + + convenience init(filePath path: String, directoryHint: URL.DirectoryHint = .inferFromPath, relativeTo base: URL? = nil) { + self.init(filePath: path, pathStyle: URL.defaultPathStyle, directoryHint: directoryHint, relativeTo: base) + } + + internal init(filePath path: String, pathStyle: URL.PathStyle, directoryHint: URL.DirectoryHint = .inferFromPath, relativeTo base: URL? = nil) { + var baseURL = base + guard !path.isEmpty else { + #if !NO_FILESYSTEM + baseURL = baseURL ?? Self.currentDirectoryOrNil() + #endif + _parseInfo = Parser.parse(filePath: "./", isAbsolute: false) + _baseURL = baseURL?.absoluteURL + _encoding = .utf8 + return + } + + var filePath = if pathStyle == .windows { + // Convert any "\" to "/" before storing the URL parse info + path.replacing(._backslash, with: ._slash) + } else { + path + } + + #if FOUNDATION_FRAMEWORK + // Linked-on-or-after check for apps which incorrectly pass a full URL + // string with a scheme. In the old implementation, this could work + // rarely if the app immediately called .appendingPathComponent(_:), + // which used to accidentally interpret a relative path starting with + // "scheme:" as an absolute "scheme:" URL string. + if URL.compatibility1 { + if filePath.utf8.starts(with: "file:".utf8) { + #if canImport(os) + URL.logger.fault("API MISUSE: URL(filePath:) called with a \"file:\" scheme. Input must only contain a path. Dropping \"file:\" scheme.") + #endif + filePath = String(filePath.dropFirst(5))._compressingSlashes() + } else if filePath.utf8.starts(with: "http:".utf8) || filePath.utf8.starts(with: "https:".utf8) { + #if canImport(os) + URL.logger.fault("API MISUSE: URL(filePath:) called with an HTTP URL string. Using URL(string:) instead.") + #endif + guard let parseInfo = Self.parse(string: filePath, encodingInvalidCharacters: true) else { + fatalError("API MISUSE: URL(filePath:) called with an HTTP URL string. URL(string:) returned nil.") + } + _parseInfo = parseInfo + _baseURL = nil // Drop the base URL since we have an HTTP scheme + _encoding = .utf8 + return + } + } + #endif + + let isAbsolute = URL.isAbsolute(standardizing: &filePath, pathStyle: pathStyle) + + #if !NO_FILESYSTEM + if !isAbsolute { + baseURL = baseURL ?? Self.currentDirectoryOrNil() + } + #endif + + let isDirectory: Bool + switch directoryHint { + case .isDirectory: + isDirectory = true + case .notDirectory: + filePath = filePath._droppingTrailingSlashes + isDirectory = false + case .checkFileSystem: + #if !NO_FILESYSTEM + func absoluteFilePath() -> String { + guard !isAbsolute, let baseURL else { + return filePath + } + let absolutePath = baseURL.absolutePath(percentEncoded: true).merging(relativePath: filePath) + return Self.fileSystemPath(for: absolutePath) + } + isDirectory = Self.isDirectory(absoluteFilePath()) + #else + isDirectory = filePath.utf8.last == ._slash + #endif + case .inferFromPath: + isDirectory = filePath.utf8.last == ._slash + } + + if isDirectory && !filePath.isEmpty && filePath.utf8.last != ._slash { + filePath += "/" + } + if isAbsolute { + let encodedPath = Parser.percentEncode(filePath, component: .path) ?? "/" + _parseInfo = Parser.parse(filePath: encodedPath, isAbsolute: true) + _baseURL = nil // Drop the baseURL if the URL is absolute + } else { + let encodedPath = Parser.percentEncode(filePath, component: .path) ?? "" + _parseInfo = Parser.parse(filePath: encodedPath, isAbsolute: false) + _baseURL = baseURL?.absoluteURL + } + _encoding = .utf8 + } + + init(url: _SwiftURL) { + _parseInfo = url._parseInfo + _baseURL = url._baseURL?.absoluteURL + _encoding = url._encoding + } + + convenience init?(dataRepresentation: Data, relativeTo base: URL?, isAbsolute: Bool) { + guard !dataRepresentation.isEmpty else { return nil } + var url: _SwiftURL? + if let string = String(data: dataRepresentation, encoding: .utf8) { + url = _SwiftURL(stringOrEmpty: string, relativeTo: base, encoding: .utf8, compatibility: true) + } + if url == nil, let string = String(data: dataRepresentation, encoding: .isoLatin1) { + url = _SwiftURL(stringOrEmpty: string, relativeTo: base, encoding: .isoLatin1, compatibility: true) + } + guard let url else { + return nil + } + if isAbsolute { + self.init(url: url.absoluteSwiftURL) + } else { + self.init(url: url) + } + } + + convenience init(fileURLWithFileSystemRepresentation path: UnsafePointer, isDirectory: Bool, relativeTo base: URL?) { + let pathString = String(cString: path) + let directoryHint: URL.DirectoryHint = isDirectory ? .isDirectory : .notDirectory + self.init(filePath: pathString, directoryHint: directoryHint, relativeTo: base) + } + + internal var encodedComponents: URLParseInfo.EncodedComponentSet { + return _parseInfo.encodedComponents + } + + // MARK: - Strings, Data, and URLs + + internal var originalString: String { + guard !encodedComponents.isEmpty else { + return relativeString + } + return URLComponents(parseInfo: _parseInfo)._uncheckedString(original: true) + } + + var dataRepresentation: Data { + guard let result = originalString.data(using: _encoding) else { + fatalError("Could not convert URL.relativeString to data using encoding: \(_encoding)") + } + return result + } + + var relativeString: String { + return _parseInfo.urlString + } + + internal func absoluteString(original: Bool) -> String { + guard let baseURL else { + return original ? originalString : relativeString + } + var builder = URLStringBuilder(parseInfo: _parseInfo, original: original) + if builder.scheme != nil { + builder.path = builder.path.removingDotSegments + return builder.string + } + if let baseScheme = baseURL.scheme { + builder.scheme = baseScheme + } + if hasAuthority { + return builder.string + } + let baseParseInfo = baseURL._swiftURL?._parseInfo + let baseEncodedComponents = baseParseInfo?.encodedComponents ?? [] + if let baseUser = baseURL.user(percentEncoded: !baseEncodedComponents.contains(.user)) { + builder.user = baseUser + } + if let basePassword = baseURL.password(percentEncoded: !baseEncodedComponents.contains(.password)) { + builder.password = basePassword + } + if let baseHost = baseParseInfo?.host { + builder.host = baseEncodedComponents.contains(.host) && baseParseInfo!.didPercentEncodeHost ? Parser.percentDecode(baseHost) : String(baseHost) + } else if let baseHost = baseURL.host(percentEncoded: !baseEncodedComponents.contains(.host)) { + builder.host = baseHost + } + if let basePort = baseParseInfo?.portString { + builder.portString = String(basePort) + } else if let basePort = baseURL.port { + builder.portString = String(basePort) + } + if builder.path.isEmpty { + builder.path = baseURL.path(percentEncoded: !baseEncodedComponents.contains(.path)) + if builder.query == nil, let baseQuery = baseURL.query(percentEncoded: !baseEncodedComponents.contains(.query)) { + builder.query = baseQuery + } + } else { + let newPath = if builder.path.utf8.first == ._slash { + builder.path + } else if baseURL.hasAuthority && baseURL.path().isEmpty { + "/" + builder.path + } else { + baseURL.path(percentEncoded: !baseEncodedComponents.contains(.path)).merging(relativePath: builder.path) + } + builder.path = newPath.removingDotSegments + } + return builder.string + } + + var absoluteString: String { + return absoluteString(original: false) + } + + var baseURL: URL? { + return _baseURL + } + + private var absoluteSwiftURL: _SwiftURL { + guard baseURL != nil else { return self } + return _SwiftURL(stringOrEmpty: absoluteString(original: false), encoding: _encoding, compatibility: true) ?? self + } + + var absoluteURL: URL? { + guard baseURL != nil else { return nil } + return absoluteSwiftURL.url + } + + // Compatibility mode for CFURLCreateAbsoluteURLWithBytes + internal var compatibilityAbsoluteString: String { + guard let baseURL = baseURL?._swiftURL else { + return URLStringBuilder(parseInfo: _parseInfo, original: true).removingDotSegments.string + } + let first = originalString.utf8.first + if first == nil || first == UInt8(ascii: "?") || first == UInt8(ascii: "#") { + return URLStringBuilder(parseInfo: baseURL._parseInfo, original: true).removingDotSegments.string + originalString + } + var builder = URLStringBuilder(parseInfo: _parseInfo, original: true) + if let scheme { + guard scheme == baseURL.scheme else { + return URLStringBuilder(parseInfo: _parseInfo, original: true).removingDotSegments.string + } + builder.scheme = nil + } + guard let newURL = _SwiftURL(stringOrEmpty: builder.string, relativeTo: _baseURL, encodingInvalidCharacters: true, encoding: _encoding, compatibility: true) else { + return absoluteString(original: true) + } + return newURL.absoluteString(original: true) + } + + internal var compatibilityAbsoluteURL: URL? { + return _SwiftURL(stringOrEmpty: compatibilityAbsoluteString, encodingInvalidCharacters: true, encoding: _encoding, compatibility: true)?.url + } + + // MARK: - Components + + var scheme: String? { + guard let scheme = _parseInfo.scheme else { return baseURL?.scheme } + return String(scheme) + } + + private static let fileSchemeUTF8 = Array("file".utf8) + var isFileURL: Bool { + guard let scheme else { return false } + return scheme.lowercased().utf8.elementsEqual(Self.fileSchemeUTF8) + } + + var hasAuthority: Bool { + return _parseInfo.hasAuthority + } + + internal var netLocation: String? { + guard hasAuthority else { + return baseURL?._swiftURL?.netLocation + } + guard let netLocation = _parseInfo.netLocation else { + return nil + } + return String(netLocation) + } + + var user: String? { + return user(percentEncoded: false) + } + + func user(percentEncoded: Bool) -> String? { + if !hasAuthority { return baseURL?.user(percentEncoded: percentEncoded) } + guard let user = _parseInfo.user else { return nil } + if percentEncoded { + return String(user) + } else if encodedComponents.contains(.user) { + // If we encoded it using UTF-8, decode it using UTF-8 + return Parser.percentDecode(user) + } else { + // Otherwise, use the encoding we were given + return Parser.percentDecode(user, encoding: _encoding) + } + } + + var password: String? { + return password(percentEncoded: true) + } + + func password(percentEncoded: Bool) -> String? { + if !hasAuthority { return baseURL?.password(percentEncoded: percentEncoded) } + guard let password = _parseInfo.password else { return nil } + if percentEncoded { + return String(password) + } else if encodedComponents.contains(.password) { + return Parser.percentDecode(password) + } else { + return Parser.percentDecode(password, encoding: _encoding) + } + } + + var host: String? { + return host(percentEncoded: false) + } + + func host(percentEncoded: Bool) -> String? { + if !hasAuthority { return baseURL?.host(percentEncoded: percentEncoded) } + guard let encodedHost = _parseInfo.host.map(String.init) else { return nil } + + // According to RFC 3986, a host always exists if there is an authority + // component, it just might be empty. However, the old implementation + // of URL.host() returned nil for URLs like "https:///", and apps rely + // on this behavior, so keep it for bincompat. + if encodedHost.isEmpty && _parseInfo.user == nil && _parseInfo.password == nil && _parseInfo.portRange == nil { + return nil + } + + func requestedHost() -> String? { + if percentEncoded { + if !encodedComponents.contains(.host) || _parseInfo.didPercentEncodeHost { + return encodedHost + } + // Now we need to IDNA-decode, then percent-encode + guard let decoded = Parser.IDNADecodeHost(encodedHost) else { + return encodedHost + } + return Parser.percentEncode(decoded, component: .host) + } else if encodedComponents.contains(.host) { + if _parseInfo.didPercentEncodeHost { + return Parser.percentDecode(encodedHost) + } + // Return IDNA-encoded host, which is technically not percent-encoded + return encodedHost + } else { + return Parser.percentDecode(encodedHost, encoding: _encoding) + } + } + + guard let requestedHost = requestedHost() else { + return nil + } + + if _parseInfo.isIPLiteral { + // Strip square brackets to be compatible with old URL.host behavior + return String(requestedHost.utf8.dropFirst().dropLast()) + } else { + return requestedHost + } + } + + var port: Int? { + return hasAuthority ? _parseInfo.port : baseURL?.port + } + + var relativePath: String { + return Self.fileSystemPath(for: relativePath(percentEncoded: true)) + } + + func relativePath(percentEncoded: Bool) -> String { + if percentEncoded { + return String(_parseInfo.path) + } else if encodedComponents.contains(.path) { + return Parser.percentDecode(_parseInfo.path) ?? "" + } else { + return Parser.percentDecode(_parseInfo.path, encoding: _encoding) ?? "" + } + } + + func absolutePath(percentEncoded: Bool) -> String { + if baseURL != nil { + return absoluteURL?.relativePath(percentEncoded: percentEncoded) ?? relativePath(percentEncoded: percentEncoded) + } + if percentEncoded { + return String(_parseInfo.path) + } else if encodedComponents.contains(.path) { + return Parser.percentDecode(_parseInfo.path) ?? "" + } else { + return Parser.percentDecode(_parseInfo.path, encoding: _encoding) ?? "" + } + } + + var path: String { + if isFileURL { return fileSystemPath() } + let path = absolutePath(percentEncoded: true) + if encodedComponents.contains(.path) { + return Parser.percentDecode(path)?._droppingTrailingSlashes ?? "" + } else { + return Parser.percentDecode(path, encoding: _encoding)?._droppingTrailingSlashes ?? "" + } + } + + func path(percentEncoded: Bool) -> String { + return absolutePath(percentEncoded: percentEncoded) + } + + var query: String? { + return query(percentEncoded: true) + } + + func query(percentEncoded: Bool) -> String? { + let query = _parseInfo.query + if query == nil && !hasAuthority && _parseInfo.path.isEmpty { + return baseURL?.query(percentEncoded: percentEncoded) + } + guard let query else { return nil } + if percentEncoded { + return String(query) + } else if encodedComponents.contains(.query) { + return Parser.percentDecode(query) + } else { + return Parser.percentDecode(query, encoding: _encoding) + } + } + + var fragment: String? { + return fragment(percentEncoded: true) + } + + func fragment(percentEncoded: Bool) -> String? { + guard let fragment = _parseInfo.fragment else { return nil } + if percentEncoded { + return String(fragment) + } else if encodedComponents.contains(.fragment) { + return Parser.percentDecode(fragment) + } else { + return Parser.percentDecode(fragment, encoding: _encoding) + } + } + + // MARK: - File Paths + + private static func decodeFilePath(_ path: some StringProtocol) -> String { + // Don't decode "%2F" or "%00" + let charsToLeaveEncoded: Set = [._slash, 0] + return Parser.percentDecode(path, excluding: charsToLeaveEncoded) ?? "" + } + + private static func windowsPath(for urlPath: String, slashDropper: (String) -> String) -> String { + var iter = urlPath.utf8.makeIterator() + guard iter.next() == ._slash else { + return decodeFilePath(slashDropper(urlPath)) + } + // "C:\" is standardized to "/C:/" on initialization. + if let driveLetter = iter.next(), driveLetter.isAlpha, + iter.next() == ._colon, + iter.next() == ._slash { + // Strip trailing slashes from the path, which preserves a root "/". + let path = slashDropper(String(Substring(urlPath.utf8.dropFirst(3)))) + // Don't include a leading slash before the drive letter + return "\(Unicode.Scalar(driveLetter)):\(decodeFilePath(path))" + } + // There are many flavors of UNC paths, so use PathIsRootW to ensure + // we don't strip a trailing slash that represents a root. + let path = decodeFilePath(urlPath) + #if os(Windows) + return path.replacing(._slash, with: ._backslash).withCString(encodedAs: UTF16.self) { pwszPath in + guard !PathIsRootW(pwszPath) else { + return path + } + return slashDropper(path) + } + #else + return slashDropper(path) + #endif + } + + internal static func fileSystemPath(for urlPath: String, style: URL.PathStyle = URL.defaultPathStyle, compatibility: Bool = false) -> String { + let slashDropper: (String) -> String = if compatibility { + { $0._droppingTrailingSlash } + } else { + { $0._droppingTrailingSlashes } + } + switch style { + case .posix: return decodeFilePath(slashDropper(urlPath)) + case .windows: return windowsPath(for: urlPath, slashDropper: slashDropper) + } + } + + internal func fileSystemPath(style: URL.PathStyle = URL.defaultPathStyle, resolveAgainstBase: Bool = true, compatibility: Bool = false) -> String { + let urlPath = resolveAgainstBase ? absolutePath(percentEncoded: true) : relativePath(percentEncoded: true) + return Self.fileSystemPath(for: urlPath, style: style, compatibility: compatibility) + } + + func withUnsafeFileSystemRepresentation(_ block: (UnsafePointer?) throws -> ResultType) rethrows -> ResultType { + return try fileSystemPath().withFileSystemRepresentation { try block($0) } + } + + var hasDirectoryPath: Bool { + let path = String(_parseInfo.path) + if path.utf8.last == ._slash { + return true + } + if path.isEmpty { + return _parseInfo.scheme == nil && !hasAuthority && baseURL?.hasDirectoryPath == true + } + return path.lastPathComponent == "." || path.lastPathComponent == ".." + } + + var pathComponents: [String] { + var result = absolutePath(percentEncoded: true).pathComponents.map { Parser.percentDecode($0) ?? "" } + if result.count > 1 && result.last == "/" { + _ = result.popLast() + } + return result + } + + var lastPathComponent: String { + let component = absolutePath(percentEncoded: true).lastPathComponent + if isFileURL { + return Self.fileSystemPath(for: component) + } else { + return Parser.percentDecode(component, encoding: _encoding) ?? "" + } + } + + var pathExtension: String { + return path.pathExtension + } + + func appendingPathComponent(_ pathComponent: String, isDirectory: Bool) -> URL? { + let directoryHint: URL.DirectoryHint = isDirectory ? .isDirectory : .notDirectory + return appending(path: pathComponent, directoryHint: directoryHint) + } + + func appendingPathComponent(_ pathComponent: String) -> URL? { + return appending(path: pathComponent, directoryHint: .checkFileSystem) + } + + func appending(path: S, directoryHint: URL.DirectoryHint) -> URL? where S : StringProtocol { + return appending(path: path, directoryHint: directoryHint, encodingSlashes: false) + } + + func appending(component: S, directoryHint: URL.DirectoryHint) -> URL? where S : StringProtocol { + // The old .appending(component:) implementation did not actually percent-encode + // "/" for file URLs as the documentation suggests. Many apps accidentally use + // .appending(component: "path/with/slashes") instead of using .appending(path:), + // so changing this behavior would cause breakage. + if isFileURL { + return appending(path: component, directoryHint: directoryHint, encodingSlashes: false) + } + return appending(path: component, directoryHint: directoryHint, encodingSlashes: true) + } + + internal func appending(path: S, directoryHint: URL.DirectoryHint, encodingSlashes: Bool, compatibility: Bool = false) -> URL? { + #if os(Windows) + var pathToAppend = path.replacing(._backslash, with: ._slash) + #else + var pathToAppend = String(path) + #endif + + if !encodingSlashes && !compatibility { + pathToAppend = Parser.percentEncode(pathComponent: pathToAppend) + } else { + var toEncode = Set() + if encodingSlashes { + toEncode.insert(._slash) + } + if compatibility { + toEncode.insert(._semicolon) + } + pathToAppend = Parser.percentEncode(pathComponent: pathToAppend, including: toEncode) + } + + func appendedPath() -> String { + var currentPath = relativePath(percentEncoded: true) + if currentPath.isEmpty && !hasAuthority { + guard _parseInfo.scheme == nil else { + // Scheme only, append directly to the empty path, e.g. + // URL("scheme:").appending(path: "path") == scheme:path + return pathToAppend + } + // No scheme or authority, treat the empty path as "." + currentPath = "." + } + + // If currentPath is empty, pathToAppend is relative, and we have an authority, + // we must append a slash to separate the path from authority, which happens below. + + if currentPath.utf8.last != ._slash && pathToAppend.utf8.first != ._slash { + currentPath += "/" + } else if currentPath.utf8.last == ._slash && pathToAppend.utf8.first == ._slash { + _ = currentPath.popLast() + } + return currentPath + pathToAppend + } + + func mergedPath(for relativePath: String) -> String { + precondition(relativePath.utf8.first != UInt8(ascii: "/")) + guard let baseURL else { + return relativePath + } + let basePath = baseURL.relativePath(percentEncoded: true) + if baseURL.hasAuthority && basePath.isEmpty { + return "/" + relativePath + } + return basePath.merging(relativePath: relativePath) + } + + var newPath = appendedPath() + + let hasTrailingSlash = newPath.utf8.last == ._slash + let isDirectory: Bool + switch directoryHint { + case .isDirectory: + isDirectory = true + case .notDirectory: + isDirectory = false + case .checkFileSystem: + #if !NO_FILESYSTEM + // We can only check file system if the URL is a file URL + if isFileURL { + let filePath: String + if newPath.utf8.first == ._slash { + filePath = Self.fileSystemPath(for: newPath) + } else { + filePath = Self.fileSystemPath(for: mergedPath(for: newPath)) + } + isDirectory = Self.isDirectory(filePath) + } else { + // For web addresses, trust the trailing slash + isDirectory = hasTrailingSlash + } + #else // !NO_FILESYSTEM + isDirectory = hasTrailingSlash + #endif // !NO_FILESYSTEM + case .inferFromPath: + isDirectory = hasTrailingSlash + } + if isDirectory && newPath.utf8.last != ._slash { + newPath += "/" + } + + var components = URLComponents(parseInfo: _parseInfo) + components.percentEncodedPath = newPath + let string = components._uncheckedString(original: false) + return _SwiftURL(stringOrEmpty: string, relativeTo: baseURL)?.url + } + +#if !NO_FILESYSTEM + + private static func isDirectory(_ path: String) -> Bool { + guard !path.isEmpty else { return false } + #if os(Windows) + let path = path.replacing(._slash, with: ._backslash) + return (try? path.withNTPathRepresentation { pwszPath in + // If path points to a symlink (reparse point), get a handle to + // the symlink itself using FILE_FLAG_OPEN_REPARSE_POINT. + let handle = CreateFileW(pwszPath, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nil, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nil) + guard handle != INVALID_HANDLE_VALUE else { return false } + defer { CloseHandle(handle) } + var info: BY_HANDLE_FILE_INFORMATION = BY_HANDLE_FILE_INFORMATION() + guard GetFileInformationByHandle(handle, &info) else { return false } + if (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == FILE_ATTRIBUTE_REPARSE_POINT { return false } + return (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == FILE_ATTRIBUTE_DIRECTORY + }) ?? false + #else + // FileManager uses stat() to check if the file exists. + // URL historically won't follow a symlink at the end + // of the path, so use lstat() here instead. + return path.withFileSystemRepresentation { fsRep in + guard let fsRep else { return false } + var fileInfo = stat() + guard lstat(fsRep, &fileInfo) == 0 else { return false } + return (mode_t(fileInfo.st_mode) & S_IFMT) == S_IFDIR + } + #endif + } + + private static func currentDirectoryOrNil() -> URL? { + let path: String? = FileManager.default.currentDirectoryPath + guard var filePath = path else { + return nil + } + #if os(Windows) + filePath = filePath.replacing(._backslash, with: ._slash) + #endif + guard URL.isAbsolute(standardizing: &filePath) else { + return nil + } + return URL(filePath: filePath, directoryHint: .isDirectory) + } + +#endif + + /// True if the URL's relative path would resolve against a base URL path + private var pathResolvesAgainstBase: Bool { + return _parseInfo.scheme == nil && !hasAuthority && _parseInfo.path.utf8.first != ._slash + } + + func deletingLastPathComponent() -> URL? { + let path = relativePath(percentEncoded: true) + let shouldAppendDotDot = ( + pathResolvesAgainstBase && ( + path.isEmpty + || path.lastPathComponent == "." + || path.lastPathComponent == ".." + ) + ) + + var newPath = path + if newPath.lastPathComponent != ".." { + newPath = newPath.deletingLastPathComponent() + } + if shouldAppendDotDot { + newPath = newPath.appendingPathComponent("..") + } + if newPath.isEmpty && pathResolvesAgainstBase { + newPath = "." + } + // .deletingLastPathComponent() removes the trailing "/", but we know it's a directory + if !newPath.isEmpty && newPath.utf8.last != ._slash { + newPath += "/" + } + var components = URLComponents(parseInfo: _parseInfo) + /// Compatibility path for apps that loop on: + /// `url = url.deletingPathComponent().standardized` until `url.path.isEmpty`. + /// + /// This used to work due to a combination of bugs where: + /// `URL("/").deletingLastPathComponent == URL("/../")` + /// `URL("/../").standardized == URL("")` + #if FOUNDATION_FRAMEWORK + if URL.compatibility4 && path == "/" { + components.percentEncodedPath = "/../" + } else { + components.percentEncodedPath = newPath + } + #else + components.percentEncodedPath = newPath + #endif + let string = components._uncheckedString(original: false) + return _SwiftURL(stringOrEmpty: string, relativeTo: baseURL)?.url + } + + internal func appendingPathExtension(_ pathExtension: String, compatibility: Bool) -> URL? { + guard !pathExtension.isEmpty, !_parseInfo.path.isEmpty else { + return nil + } + var components = URLComponents(parseInfo: _parseInfo) + // pathExtension might need to be percent-encoded + let encodedExtension = if compatibility { + Parser.percentEncode(pathComponent: pathExtension, including: [._semicolon]) + } else { + Parser.percentEncode(pathComponent: pathExtension) + } + let newPath = components.percentEncodedPath.appendingPathExtension(encodedExtension) + components.percentEncodedPath = newPath + let string = components._uncheckedString(original: false) + return _SwiftURL(string: string, relativeTo: baseURL)?.url + } + + func appendingPathExtension(_ pathExtension: String) -> URL? { + return appendingPathExtension(pathExtension, compatibility: false) + } + + func deletingPathExtension() -> URL? { + guard !_parseInfo.path.isEmpty else { return nil } + var components = URLComponents(parseInfo: _parseInfo) + let newPath = components.percentEncodedPath.deletingPathExtension() + components.percentEncodedPath = newPath + let string = components._uncheckedString(original: false) + return _SwiftURL(stringOrEmpty: string, relativeTo: baseURL)?.url + } + + var standardized: URL? { + /// Compatibility path for apps that loop on: + /// `url = url.deletingPathComponent().standardized` until `url.path.isEmpty`. + /// + /// This used to work due to a combination of bugs where: + /// `URL("/").deletingLastPathComponent == URL("/../")` + /// `URL("/../").standardized == URL("")` + #if FOUNDATION_FRAMEWORK + guard isDecomposable else { return nil } + let newPath = if URL.compatibility4 && _parseInfo.path == "/../" { + "" + } else { + String(_parseInfo.path).removingDotSegments + } + #else + let newPath = String(_parseInfo.path).removingDotSegments + #endif + var components = URLComponents(parseInfo: _parseInfo) + components.percentEncodedPath = newPath.removingDotSegments + if components.scheme != nil { + // Standardize scheme:// to scheme:/// + if newPath.isEmpty && _parseInfo.netLocationRange?.isEmpty ?? false { + components.percentEncodedPath = "/" + } + // Standardize scheme:/path to scheme:///path + if components.encodedHost == nil { + components.encodedHost = "" + } + } + let string = components._uncheckedString(original: false) + return _SwiftURL(stringOrEmpty: string, relativeTo: baseURL)?.url + } + +#if !NO_FILESYSTEM + var standardizedFileURL: URL? { + guard isFileURL, !fileSystemPath().isEmpty else { return nil } + return URL(filePath: fileSystemPath().standardizingPath, directoryHint: hasDirectoryPath ? .isDirectory : .notDirectory) + } + + func resolvingSymlinksInPath() -> URL? { + guard isFileURL, !fileSystemPath().isEmpty else { return nil } + return URL(filePath: fileSystemPath().resolvingSymlinksInPath, directoryHint: hasDirectoryPath ? .isDirectory : .notDirectory) + } +#endif + + private static let dataSchemeUTF8 = Array("data".utf8) + var description: String { + var urlString = relativeString + if let scheme, scheme.lowercased().utf8.elementsEqual(Self.dataSchemeUTF8), urlString.utf8.count > 128 { + let prefix = urlString.utf8.prefix(120) + let suffix = urlString.utf8.suffix(8) + urlString = "\(prefix) ... \(suffix)" + } + if let baseURL { + return "\(urlString) -- \(baseURL.description)" + } + return urlString + } + + var debugDescription: String { + return description + } + +#if FOUNDATION_FRAMEWORK + + func bridgeToNSURL() -> NSURL { + if foundation_swift_nsurl_enabled() { + return _NSSwiftURL(url: self) + } + return _nsurl + } + + internal func isFileReferenceURL() -> Bool { + #if NO_FILESYSTEM + return false + #else + return isFileURL && _parseInfo.pathHasFileID + #endif + } + + internal func convertingFileReference() -> any _URLProtocol & AnyObject { + #if NO_FILESYSTEM + return self + #else + guard isFileReferenceURL() else { return self } + guard let url = bridgeToNSURL().filePathURL else { + return _SwiftURL(string: "com-apple-unresolvable-file-reference-url:")! + } + return url._url + #endif + } + +#else + + internal func convertingFileReference() -> _SwiftURL { + return self + } + +#endif // FOUNDATION_FRAMEWORK + + static func == (lhs: _SwiftURL, rhs: _SwiftURL) -> Bool { + return lhs.relativeString == rhs.relativeString && lhs.baseURL == rhs.baseURL + } + + func hash(into hasher: inout Hasher) { + // Historically, the CF/NSURL hash only includes the relative string + hasher.combine(relativeString) + } + + /// Convenience for constructing a URL string from components without validation. + private struct URLStringBuilder { + typealias Parser = _SwiftURL.Parser + var scheme: String? + var user: String? + var password: String? + var host: String? + var portString: String? + var path: String + var query: String? + var fragment: String? + + var hasAuthority: Bool { + return user != nil || password != nil || host != nil || portString != nil + } + + init(parseInfo: URLParseInfo, original: Bool) { + let encodedComponents = original ? parseInfo.encodedComponents : [] + if let scheme = parseInfo.scheme { + self.scheme = String(scheme) + } + if let user = parseInfo.user{ + self.user = encodedComponents.contains(.user) ? Parser.percentDecode(user) : String(user) + } + if let password = parseInfo.password { + self.password = encodedComponents.contains(.password) ? Parser.percentDecode(password) : String(password) + } + if let host = parseInfo.host { + // We don't need to check for IDNA-encoding since only CFURL uses + // the original string, and CFURL does not support INDA-encoding. + self.host = encodedComponents.contains(.host) ? Parser.percentDecode(host) : String(host) + } + if let portString = parseInfo.portString { + self.portString = String(portString) + } + self.path = encodedComponents.contains(.path) ? Parser.percentDecode(parseInfo.path) ?? "" : String(parseInfo.path) + if let query = parseInfo.query { + self.query = encodedComponents.contains(.query) ? Parser.percentDecode(query) : String(query) + } + if let fragment = parseInfo.fragment { + self.fragment = encodedComponents.contains(.fragment) ? Parser.percentDecode(fragment) : String(fragment) + } + } + + var string: String { + var result = "" + if let scheme { + result += "\(scheme):" + } + if hasAuthority { + result += "//" + } + if let user { + result += user + } + if let password { + result += ":\(password)" + } + if user != nil || password != nil { + result += "@" + } + if let host { + result += host + } + if let portString { + result += ":\(portString)" + } + result += path + if let query { + result += "?\(query)" + } + if let fragment { + result += "#\(fragment)" + } + return result + } + + var removingDotSegments: URLStringBuilder { + var result = self + result.path = result.path.removingDotSegments + return result + } + } + +} + +#if FOUNDATION_FRAMEWORK +internal import CoreFoundation_Private.CFURL + +/// This conformance is only needed in `FOUNDATION_FRAMEWORK`, +/// where `URL` can be implemented by a few different classes. +extension _SwiftURL: _URLProtocol {} + +extension _SwiftURL { + private static func _makeNSURL(from parseInfo: URLParseInfo, baseURL: URL?) -> NSURL { + return _makeCFURL(from: parseInfo, baseURL: baseURL as CFURL?) as NSURL + } + + struct _CFURLFlags: OptionSet { + let rawValue: UInt32 + + // These must match the CFURL flags defined in CFURL.m + static let hasScheme = _CFURLFlags(rawValue: 0x00000001) + static let hasUser = _CFURLFlags(rawValue: 0x00000002) + static let hasPassword = _CFURLFlags(rawValue: 0x00000004) + static let hasHost = _CFURLFlags(rawValue: 0x00000008) + static let hasPort = _CFURLFlags(rawValue: 0x00000010) + static let hasPath = _CFURLFlags(rawValue: 0x00000020) + static let hasParameters = _CFURLFlags(rawValue: 0x00000040) // Unused + static let hasQuery = _CFURLFlags(rawValue: 0x00000080) + static let hasFragment = _CFURLFlags(rawValue: 0x00000100) + static let isIPLiteral = _CFURLFlags(rawValue: 0x00000400) + static let isDirectory = _CFURLFlags(rawValue: 0x00000800) + static let isCanonicalFileURL = _CFURLFlags(rawValue: 0x00001000) // Unused + static let pathHasFileID = _CFURLFlags(rawValue: 0x00002000) + static let isDecomposable = _CFURLFlags(rawValue: 0x00004000) + static let posixAndURLPathsMatch = _CFURLFlags(rawValue: 0x00008000) + static let originalAndURLStringsMatch = _CFURLFlags(rawValue: 0x00010000) + static let originatedFromSwift = _CFURLFlags(rawValue: 0x00020000) + } + + private static func _makeCFURL(from parseInfo: URLParseInfo, baseURL: CFURL?) -> CFURL { + let string = parseInfo.urlString + var ranges = [CFRange]() + var flags: _CFURLFlags = [ + .originalAndURLStringsMatch, + .originatedFromSwift, + ] + + // CFURL considers a URL decomposable if it does not have a scheme + // or if there is a slash directly following the scheme. + if parseInfo.scheme == nil || parseInfo.hasAuthority || parseInfo.path.utf8.first == ._slash { + flags.insert(.isDecomposable) + } + + if let schemeRange = parseInfo.schemeRange { + flags.insert(.hasScheme) + let nsRange = string._toRelativeNSRange(schemeRange) + ranges.append(CFRange(location: nsRange.location, length: nsRange.length)) + } + + if let userRange = parseInfo.userRange { + flags.insert(.hasUser) + let nsRange = string._toRelativeNSRange(userRange) + ranges.append(CFRange(location: nsRange.location, length: nsRange.length)) + } + + if let passwordRange = parseInfo.passwordRange { + flags.insert(.hasPassword) + let nsRange = string._toRelativeNSRange(passwordRange) + ranges.append(CFRange(location: nsRange.location, length: nsRange.length)) + } + + if parseInfo.portRange != nil { + flags.insert(.hasPort) + } + + // CFURL considers an empty host nil unless there's another authority component + if let hostRange = parseInfo.hostRange, + (!hostRange.isEmpty || !flags.isDisjoint(with: [.hasUser, .hasPassword, .hasPort])) { + flags.insert(.hasHost) + let nsRange = string._toRelativeNSRange(hostRange) + ranges.append(CFRange(location: nsRange.location, length: nsRange.length)) + } + + if let portRange = parseInfo.portRange { + let nsRange = string._toRelativeNSRange(portRange) + ranges.append(CFRange(location: nsRange.location, length: nsRange.length)) + } + + flags.insert(.hasPath) + if let pathRange = parseInfo.pathRange { + let nsRange = string._toRelativeNSRange(pathRange) + ranges.append(CFRange(location: nsRange.location, length: nsRange.length)) + } else { + ranges.append(CFRange(location: kCFNotFound, length: 0)) + } + + if let queryRange = parseInfo.queryRange { + flags.insert(.hasQuery) + let nsRange = string._toRelativeNSRange(queryRange) + ranges.append(CFRange(location: nsRange.location, length: nsRange.length)) + } + + if let fragmentRange = parseInfo.fragmentRange { + flags.insert(.hasFragment) + let nsRange = string._toRelativeNSRange(fragmentRange) + ranges.append(CFRange(location: nsRange.location, length: nsRange.length)) + } + + let path = parseInfo.path.utf8 + let isDirectory = path.last == UInt8(ascii: "/") + + if parseInfo.isIPLiteral { + flags.insert(.isIPLiteral) + } + if isDirectory { + flags.insert(.isDirectory) + } + if parseInfo.pathHasFileID { + flags.insert(.pathHasFileID) + } + if !isDirectory && !parseInfo.path.utf8.contains(UInt8(ascii: "%")) { + flags.insert(.posixAndURLPathsMatch) + } + + return ranges.withUnsafeBufferPointer { + _CFURLCreateWithRangesAndFlags(string as CFString, $0.baseAddress!, UInt8($0.count), flags.rawValue, baseURL) + } + } +} +#endif diff --git a/Tests/FoundationEssentialsTests/StringTests.swift b/Tests/FoundationEssentialsTests/StringTests.swift index 10ae58566..7b7cb041b 100644 --- a/Tests/FoundationEssentialsTests/StringTests.swift +++ b/Tests/FoundationEssentialsTests/StringTests.swift @@ -816,7 +816,7 @@ final class StringTests : XCTestCase { func testAppendingPathExtension() { XCTAssertEqual("".appendingPathExtension("foo"), ".foo") XCTAssertEqual("/".appendingPathExtension("foo"), "/.foo") - XCTAssertEqual("//".appendingPathExtension("foo"), "//.foo") + XCTAssertEqual("//".appendingPathExtension("foo"), "/.foo/") XCTAssertEqual("/path".appendingPathExtension("foo"), "/path.foo") XCTAssertEqual("/path.zip".appendingPathExtension("foo"), "/path.zip.foo") XCTAssertEqual("/path/".appendingPathExtension("foo"), "/path.foo/") diff --git a/Tests/FoundationEssentialsTests/URLTests.swift b/Tests/FoundationEssentialsTests/URLTests.swift index 3f7c84507..f940d104a 100644 --- a/Tests/FoundationEssentialsTests/URLTests.swift +++ b/Tests/FoundationEssentialsTests/URLTests.swift @@ -99,7 +99,7 @@ final class URLTests : XCTestCase { XCTAssertEqual(relativeURLWithBase.password(), baseURL.password()) XCTAssertEqual(relativeURLWithBase.host(), baseURL.host()) XCTAssertEqual(relativeURLWithBase.port, baseURL.port) - checkBehavior(relativeURLWithBase.path(), new: "/base/relative/path", old: "relative/path") + XCTAssertEqual(relativeURLWithBase.path(), "/base/relative/path") XCTAssertEqual(relativeURLWithBase.relativePath, "relative/path") XCTAssertEqual(relativeURLWithBase.query(), "query") XCTAssertEqual(relativeURLWithBase.fragment(), "fragment") @@ -237,12 +237,12 @@ final class URLTests : XCTestCase { ] for test in tests { let url = URL(stringOrEmpty: test.key, relativeTo: base)! - XCTAssertEqual(url.path(), test.value) - if (url.hasDirectoryPath && url.path().count > 1) { + XCTAssertEqual(url.absolutePath(), test.value) + if (url.hasDirectoryPath && url.absolutePath().count > 1) { // The trailing slash is stripped in .path for file system compatibility - XCTAssertEqual(String(url.path().dropLast()), url.path) + XCTAssertEqual(String(url.absolutePath().dropLast()), url.path) } else { - XCTAssertEqual(url.path(), url.path) + XCTAssertEqual(url.absolutePath(), url.path) } } } @@ -334,27 +334,27 @@ final class URLTests : XCTestCase { // .absoluteString and .path() use the RFC 8089 URL path XCTAssertEqual(url.absoluteString, "file:///C:/test/path") XCTAssertEqual(url.path(), "/C:/test/path") - // .path and .fileSystemPath strip the leading slash + // .path and .fileSystemPath() strip the leading slash XCTAssertEqual(url.path, "C:/test/path") - XCTAssertEqual(url.fileSystemPath, "C:/test/path") + XCTAssertEqual(url.fileSystemPath(), "C:/test/path") url = URL(filePath: #"C:\"#, directoryHint: .isDirectory) XCTAssertEqual(url.absoluteString, "file:///C:/") XCTAssertEqual(url.path(), "/C:/") XCTAssertEqual(url.path, "C:/") - XCTAssertEqual(url.fileSystemPath, "C:/") + XCTAssertEqual(url.fileSystemPath(), "C:/") url = URL(filePath: #"C:\\\"#, directoryHint: .isDirectory) XCTAssertEqual(url.absoluteString, "file:///C:///") XCTAssertEqual(url.path(), "/C:///") XCTAssertEqual(url.path, "C:/") - XCTAssertEqual(url.fileSystemPath, "C:/") + XCTAssertEqual(url.fileSystemPath(), "C:/") url = URL(filePath: #"\C:\"#, directoryHint: .isDirectory) XCTAssertEqual(url.absoluteString, "file:///C:/") XCTAssertEqual(url.path(), "/C:/") XCTAssertEqual(url.path, "C:/") - XCTAssertEqual(url.fileSystemPath, "C:/") + XCTAssertEqual(url.fileSystemPath(), "C:/") let base = URL(filePath: #"\d:\path\"#, directoryHint: .isDirectory) url = URL(filePath: #"%43:\fake\letter"#, directoryHint: .notDirectory, relativeTo: base) @@ -362,7 +362,7 @@ final class URLTests : XCTestCase { XCTAssertEqual(url.relativeString, "%2543%3A/fake/letter") XCTAssertEqual(url.path(), "/d:/path/%2543%3A/fake/letter") XCTAssertEqual(url.path, "d:/path/%43:/fake/letter") - XCTAssertEqual(url.fileSystemPath, "d:/path/%43:/fake/letter") + XCTAssertEqual(url.fileSystemPath(), "d:/path/%43:/fake/letter") let cwd = URL.currentDirectory() var iter = cwd.path().utf8.makeIterator() @@ -372,7 +372,7 @@ final class URLTests : XCTestCase { let path = #"\\?\"# + "\(Unicode.Scalar(driveLetter))" + #":\"# url = URL(filePath: path, directoryHint: .isDirectory) XCTAssertEqual(url.path.last, "/") - XCTAssertEqual(url.fileSystemPath.last, "/") + XCTAssertEqual(url.fileSystemPath().last, "/") } } #endif @@ -798,15 +798,15 @@ final class URLTests : XCTestCase { var url = URL(filePath: "/path/slashes///") XCTAssertEqual(url.path(), "/path/slashes///") // TODO: Update this once .fileSystemPath uses backslashes for Windows - XCTAssertEqual(url.fileSystemPath, "/path/slashes") + XCTAssertEqual(url.fileSystemPath(), "/path/slashes") url = URL(filePath: "/path/slashes/") XCTAssertEqual(url.path(), "/path/slashes/") - XCTAssertEqual(url.fileSystemPath, "/path/slashes") + XCTAssertEqual(url.fileSystemPath(), "/path/slashes") url = URL(filePath: "/path/slashes") XCTAssertEqual(url.path(), "/path/slashes") - XCTAssertEqual(url.fileSystemPath, "/path/slashes") + XCTAssertEqual(url.fileSystemPath(), "/path/slashes") } func testURLNotDirectoryHintStripsTrailingSlash() throws {