Skip to content

Support Windows URL paths in FoundationEssentials #602

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/FoundationEssentials/Data/Data+Writing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,7 @@ private func writeToFileNoAux(path inPath: PathOrURL, buffer: UnsafeRawBufferPoi
assert(!options.contains(.atomic))

#if os(Windows)
try inPath.path.withCString(encodedAs: UTF16.self) { pwszPath in
try inPath.path.withNTPathRepresentation { pwszPath in
let hFile = CreateFileW(pwszPath, GENERIC_WRITE, FILE_SHARE_READ, nil, CREATE_ALWAYS | (options.contains(.withoutOverwriting) ? CREATE_NEW : 0), FILE_ATTRIBUTE_NORMAL, nil)
if hFile == INVALID_HANDLE_VALUE {
throw CocoaError.errorWithFilePath(inPath, win32: GetLastError(), reading: false)
Expand Down
29 changes: 23 additions & 6 deletions Sources/FoundationEssentials/String/String+Path.swift
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ extension String {
}
return result
}

var standardizingPath: String {
expandingTildeInPath._standardizingPath
}
Expand Down Expand Up @@ -586,10 +586,6 @@ extension String {
return userDir + self[firstSlash...]
}

private var _isAbsolutePath: Bool {
first == "~" || first == "/"
}

private static func _resolvingSymlinksInPathUsingFullPathAttribute(_ fsRep: UnsafePointer<CChar>) -> String? {
#if canImport(Darwin)
var attrs = attrlist()
Expand Down Expand Up @@ -636,7 +632,8 @@ extension String {
// If not using the cache (which may not require hitting the disk at all if it's warm), try getting the full path from getattrlist.
// If it succeeds, this approach always returns an absolute path starting from the root. Since this function returns relative paths when given a relative path to a relative symlink, dont use this approach unless the path is absolute.

if self._isAbsolutePath, let resolved = Self._resolvingSymlinksInPathUsingFullPathAttribute(fsPtr) {
var path = self
if URL.isAbsolute(standardizing: &path), let resolved = Self._resolvingSymlinksInPathUsingFullPathAttribute(fsPtr) {
return resolved
}

Expand Down Expand Up @@ -726,3 +723,23 @@ extension String {
}
#endif // !NO_FILESYSTEM
}

extension StringProtocol {
internal func replacing(_ a: UInt8, with b: UInt8) -> String {
var utf8Array = Array(self.utf8)
var didReplace = false
// ~300x faster than Array.replace([UInt8], with: [UInt8]) for one element
for i in 0..<utf8Array.count {
if utf8Array[i] == a {
utf8Array[i] = b
didReplace = true
}
}
guard didReplace else {
return String(self)
}
return String(unsafeUninitializedCapacity: utf8Array.count) { buffer in
buffer.initialize(fromContentsOf: utf8Array)
}
}
}
122 changes: 102 additions & 20 deletions Sources/FoundationEssentials/URL/URL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2003,6 +2003,48 @@ extension URL {
}
#endif // !NO_FILESYSTEM

/// Checks if a file path is absolute and standardizes the inputted file path on Windows
internal static func isAbsolute(standardizing filePath: inout String) -> Bool {
#if os(Windows)
var isAbsolute = false
let utf8 = filePath.utf8
if utf8.first == UInt8(ascii: "\\") {
// Either an absolute path or a UNC path
isAbsolute = true
} else if utf8.count >= 3 {
// 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]
isAbsolute = (
first.isAlpha
&& (second == UInt8(ascii: ":") || second == UInt8(ascii: "|"))
&& third == UInt8(ascii: "\\")
)

if !isAbsolute {
// Strip the drive letter so it's not mistaken as a scheme
filePath = String(filePath[thirdIndex...])
} else {
// Standardize to "\[drive-letter]:\..."
if second == UInt8(ascii: "|") {
var filePathArray = Array(utf8)
filePathArray[1] = UInt8(ascii: ":")
filePathArray.insert(UInt8(ascii: "\\"), at: 0)
filePath = String(decoding: filePathArray, as: UTF8.self)
} else {
filePath = "\\" + filePath
}
}
}
#else
let isAbsolute = filePath.utf8.first == UInt8(ascii: "/") || filePath.utf8.first == UInt8(ascii: "~")
#endif
return isAbsolute
}

/// 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 ".".
Expand All @@ -2027,17 +2069,43 @@ extension URL {
return
}
#endif // FOUNDATION_FRAMEWORK
var baseURL = base
guard !path.isEmpty else {
#if NO_FILESYSTEM
let baseURL = base
#else
let baseURL = base ?? .currentDirectoryOrNil()
#if !NO_FILESYSTEM
baseURL = baseURL ?? .currentDirectoryOrNil()
#endif
self.init(string: "", relativeTo: baseURL)!
return
}

#if os(Windows)
let slash = UInt8(ascii: "\\")
var filePath = path.replacing(UInt8(ascii: "/"), with: slash)
#else
let slash = UInt8(ascii: "/")
var filePath = path
#endif

let isAbsolute = URL.isAbsolute(standardizing: &filePath)

#if !NO_FILESYSTEM
if !isAbsolute {
baseURL = baseURL ?? .currentDirectoryOrNil()
}
#endif

func absoluteFilePath() -> String {
guard !isAbsolute, let baseURL else {
return filePath
}
#if os(Windows)
let urlPath = filePath.replacing(UInt8(ascii: "\\"), with: UInt8(ascii: "/"))
return baseURL.mergedPath(for: urlPath).replacing(UInt8(ascii: "/"), with: UInt8(ascii: "\\"))
#else
return baseURL.mergedPath(for: filePath)
#endif
}

let isDirectory: Bool
switch directoryHint {
case .isDirectory:
Expand All @@ -2046,24 +2114,28 @@ extension URL {
isDirectory = false
case .checkFileSystem:
#if !NO_FILESYSTEM
isDirectory = URL.isDirectory(path)
isDirectory = URL.isDirectory(absoluteFilePath())
#else
isDirectory = path.utf8.last == slash
isDirectory = filePath.utf8.last == slash
#endif
case .inferFromPath:
isDirectory = path.utf8.last == slash
isDirectory = filePath.utf8.last == slash
}

var filePath = path
let isAbsolute = filePath.utf8.first == slash || filePath.utf8.first == UInt8(ascii: "~")
if !isAbsolute {
#if !NO_FILESYSTEM
filePath = filePath.standardizingPath
#else
filePath = filePath.removingDotSegments
#endif
}
if !filePath.isEmpty && filePath.utf8.last != slash && isDirectory {

#if os(Windows)
// Convert any "\" back to "/" before storing the URL parse info
filePath = filePath.replacing(UInt8(ascii: "\\"), with: UInt8(ascii: "/"))
#endif

if !filePath.isEmpty && filePath.utf8.last != UInt8(ascii: "/") && isDirectory {
filePath += "/"
}
var components = URLComponents()
Expand All @@ -2074,11 +2146,6 @@ extension URL {
components.path = filePath

if !isAbsolute {
#if NO_FILESYSTEM
let baseURL = base
#else
let baseURL = base ?? .currentDirectoryOrNil()
#endif
self = components.url(relativeTo: baseURL)!
} else {
// Drop the baseURL if the URL is absolute
Expand All @@ -2087,15 +2154,16 @@ extension URL {
}

private func appending<S: StringProtocol>(path: S, directoryHint: DirectoryHint, encodingSlashes: Bool) -> URL {
#if os(Windows)
let path = path.replacing(UInt8(ascii: "\\"), with: UInt8(ascii: "/"))
#endif
guard var pathToAppend = Parser.percentEncode(path, component: .path) else {
return self
}
if encodingSlashes {
var utf8 = Array(pathToAppend.utf8)
utf8.replace([UInt8(ascii: "/")], with: [UInt8(ascii: "%"), UInt8(ascii: "2"), UInt8(ascii: "F")])
pathToAppend = String(unsafeUninitializedCapacity: utf8.count) { buffer in
buffer.initialize(fromContentsOf: utf8)
}
pathToAppend = String(decoding: utf8, as: UTF8.self)
}

let slash = UInt8(ascii: "/")
Expand Down Expand Up @@ -2332,10 +2400,13 @@ extension URL {
extension URL {
private static func currentDirectoryOrNil() -> URL? {
let path: String? = FileManager.default.currentDirectoryPath
guard let path, path.utf8.first == UInt8(ascii: "/") else {
guard var filePath = path else {
return nil
}
return URL(filePath: path, directoryHint: .isDirectory)
guard URL.isAbsolute(standardizing: &filePath) else {
return nil
}
return URL(filePath: filePath, directoryHint: .isDirectory)
}

/// The working directory of the current process.
Expand Down Expand Up @@ -2660,3 +2731,14 @@ 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
}
}
}
3 changes: 3 additions & 0 deletions Sources/FoundationEssentials/URL/URLParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,9 @@ internal struct RFC3986Parser: URLParserProtocol {
/// 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? {
#if os(Windows)
let urlString = urlString.replacing(UInt8(ascii: "\\"), with: UInt8(ascii: "/"))
#endif
guard let parseInfo = parse(urlString: urlString) else {
return nil
}
Expand Down