Skip to content

Commit 4a52663

Browse files
authored
Support Windows URL paths in FoundationEssentials (#602)
1 parent 51190dd commit 4a52663

File tree

4 files changed

+129
-27
lines changed

4 files changed

+129
-27
lines changed

Sources/FoundationEssentials/Data/Data+Writing.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,7 @@ private func writeToFileNoAux(path inPath: PathOrURL, buffer: UnsafeRawBufferPoi
549549
assert(!options.contains(.atomic))
550550

551551
#if os(Windows)
552-
try inPath.path.withCString(encodedAs: UTF16.self) { pwszPath in
552+
try inPath.path.withNTPathRepresentation { pwszPath in
553553
let hFile = CreateFileW(pwszPath, GENERIC_WRITE, FILE_SHARE_READ, nil, CREATE_ALWAYS | (options.contains(.withoutOverwriting) ? CREATE_NEW : 0), FILE_ATTRIBUTE_NORMAL, nil)
554554
if hFile == INVALID_HANDLE_VALUE {
555555
throw CocoaError.errorWithFilePath(inPath, win32: GetLastError(), reading: false)

Sources/FoundationEssentials/String/String+Path.swift

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,7 @@ extension String {
546546
}
547547
return result
548548
}
549-
549+
550550
var standardizingPath: String {
551551
expandingTildeInPath._standardizingPath
552552
}
@@ -586,10 +586,6 @@ extension String {
586586
return userDir + self[firstSlash...]
587587
}
588588

589-
private var _isAbsolutePath: Bool {
590-
first == "~" || first == "/"
591-
}
592-
593589
private static func _resolvingSymlinksInPathUsingFullPathAttribute(_ fsRep: UnsafePointer<CChar>) -> String? {
594590
#if canImport(Darwin)
595591
var attrs = attrlist()
@@ -636,7 +632,8 @@ extension String {
636632
// 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.
637633
// 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.
638634

639-
if self._isAbsolutePath, let resolved = Self._resolvingSymlinksInPathUsingFullPathAttribute(fsPtr) {
635+
var path = self
636+
if URL.isAbsolute(standardizing: &path), let resolved = Self._resolvingSymlinksInPathUsingFullPathAttribute(fsPtr) {
640637
return resolved
641638
}
642639

@@ -726,3 +723,23 @@ extension String {
726723
}
727724
#endif // !NO_FILESYSTEM
728725
}
726+
727+
extension StringProtocol {
728+
internal func replacing(_ a: UInt8, with b: UInt8) -> String {
729+
var utf8Array = Array(self.utf8)
730+
var didReplace = false
731+
// ~300x faster than Array.replace([UInt8], with: [UInt8]) for one element
732+
for i in 0..<utf8Array.count {
733+
if utf8Array[i] == a {
734+
utf8Array[i] = b
735+
didReplace = true
736+
}
737+
}
738+
guard didReplace else {
739+
return String(self)
740+
}
741+
return String(unsafeUninitializedCapacity: utf8Array.count) { buffer in
742+
buffer.initialize(fromContentsOf: utf8Array)
743+
}
744+
}
745+
}

Sources/FoundationEssentials/URL/URL.swift

Lines changed: 102 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2003,6 +2003,48 @@ extension URL {
20032003
}
20042004
#endif // !NO_FILESYSTEM
20052005

2006+
/// Checks if a file path is absolute and standardizes the inputted file path on Windows
2007+
internal static func isAbsolute(standardizing filePath: inout String) -> Bool {
2008+
#if os(Windows)
2009+
var isAbsolute = false
2010+
let utf8 = filePath.utf8
2011+
if utf8.first == UInt8(ascii: "\\") {
2012+
// Either an absolute path or a UNC path
2013+
isAbsolute = true
2014+
} else if utf8.count >= 3 {
2015+
// Check if this is a drive letter
2016+
let first = utf8.first!
2017+
let secondIndex = utf8.index(after: utf8.startIndex)
2018+
let second = utf8[secondIndex]
2019+
let thirdIndex = utf8.index(after: secondIndex)
2020+
let third = utf8[thirdIndex]
2021+
isAbsolute = (
2022+
first.isAlpha
2023+
&& (second == UInt8(ascii: ":") || second == UInt8(ascii: "|"))
2024+
&& third == UInt8(ascii: "\\")
2025+
)
2026+
2027+
if !isAbsolute {
2028+
// Strip the drive letter so it's not mistaken as a scheme
2029+
filePath = String(filePath[thirdIndex...])
2030+
} else {
2031+
// Standardize to "\[drive-letter]:\..."
2032+
if second == UInt8(ascii: "|") {
2033+
var filePathArray = Array(utf8)
2034+
filePathArray[1] = UInt8(ascii: ":")
2035+
filePathArray.insert(UInt8(ascii: "\\"), at: 0)
2036+
filePath = String(decoding: filePathArray, as: UTF8.self)
2037+
} else {
2038+
filePath = "\\" + filePath
2039+
}
2040+
}
2041+
}
2042+
#else
2043+
let isAbsolute = filePath.utf8.first == UInt8(ascii: "/") || filePath.utf8.first == UInt8(ascii: "~")
2044+
#endif
2045+
return isAbsolute
2046+
}
2047+
20062048
/// Initializes a newly created file URL referencing the local file or directory at path, relative to a base URL.
20072049
///
20082050
/// If an empty string is used for the path, then the path is assumed to be ".".
@@ -2027,17 +2069,43 @@ extension URL {
20272069
return
20282070
}
20292071
#endif // FOUNDATION_FRAMEWORK
2072+
var baseURL = base
20302073
guard !path.isEmpty else {
2031-
#if NO_FILESYSTEM
2032-
let baseURL = base
2033-
#else
2034-
let baseURL = base ?? .currentDirectoryOrNil()
2074+
#if !NO_FILESYSTEM
2075+
baseURL = baseURL ?? .currentDirectoryOrNil()
20352076
#endif
20362077
self.init(string: "", relativeTo: baseURL)!
20372078
return
20382079
}
20392080

2081+
#if os(Windows)
2082+
let slash = UInt8(ascii: "\\")
2083+
var filePath = path.replacing(UInt8(ascii: "/"), with: slash)
2084+
#else
20402085
let slash = UInt8(ascii: "/")
2086+
var filePath = path
2087+
#endif
2088+
2089+
let isAbsolute = URL.isAbsolute(standardizing: &filePath)
2090+
2091+
#if !NO_FILESYSTEM
2092+
if !isAbsolute {
2093+
baseURL = baseURL ?? .currentDirectoryOrNil()
2094+
}
2095+
#endif
2096+
2097+
func absoluteFilePath() -> String {
2098+
guard !isAbsolute, let baseURL else {
2099+
return filePath
2100+
}
2101+
#if os(Windows)
2102+
let urlPath = filePath.replacing(UInt8(ascii: "\\"), with: UInt8(ascii: "/"))
2103+
return baseURL.mergedPath(for: urlPath).replacing(UInt8(ascii: "/"), with: UInt8(ascii: "\\"))
2104+
#else
2105+
return baseURL.mergedPath(for: filePath)
2106+
#endif
2107+
}
2108+
20412109
let isDirectory: Bool
20422110
switch directoryHint {
20432111
case .isDirectory:
@@ -2046,24 +2114,28 @@ extension URL {
20462114
isDirectory = false
20472115
case .checkFileSystem:
20482116
#if !NO_FILESYSTEM
2049-
isDirectory = URL.isDirectory(path)
2117+
isDirectory = URL.isDirectory(absoluteFilePath())
20502118
#else
2051-
isDirectory = path.utf8.last == slash
2119+
isDirectory = filePath.utf8.last == slash
20522120
#endif
20532121
case .inferFromPath:
2054-
isDirectory = path.utf8.last == slash
2122+
isDirectory = filePath.utf8.last == slash
20552123
}
20562124

2057-
var filePath = path
2058-
let isAbsolute = filePath.utf8.first == slash || filePath.utf8.first == UInt8(ascii: "~")
20592125
if !isAbsolute {
20602126
#if !NO_FILESYSTEM
20612127
filePath = filePath.standardizingPath
20622128
#else
20632129
filePath = filePath.removingDotSegments
20642130
#endif
20652131
}
2066-
if !filePath.isEmpty && filePath.utf8.last != slash && isDirectory {
2132+
2133+
#if os(Windows)
2134+
// Convert any "\" back to "/" before storing the URL parse info
2135+
filePath = filePath.replacing(UInt8(ascii: "\\"), with: UInt8(ascii: "/"))
2136+
#endif
2137+
2138+
if !filePath.isEmpty && filePath.utf8.last != UInt8(ascii: "/") && isDirectory {
20672139
filePath += "/"
20682140
}
20692141
var components = URLComponents()
@@ -2074,11 +2146,6 @@ extension URL {
20742146
components.path = filePath
20752147

20762148
if !isAbsolute {
2077-
#if NO_FILESYSTEM
2078-
let baseURL = base
2079-
#else
2080-
let baseURL = base ?? .currentDirectoryOrNil()
2081-
#endif
20822149
self = components.url(relativeTo: baseURL)!
20832150
} else {
20842151
// Drop the baseURL if the URL is absolute
@@ -2087,15 +2154,16 @@ extension URL {
20872154
}
20882155

20892156
private func appending<S: StringProtocol>(path: S, directoryHint: DirectoryHint, encodingSlashes: Bool) -> URL {
2157+
#if os(Windows)
2158+
let path = path.replacing(UInt8(ascii: "\\"), with: UInt8(ascii: "/"))
2159+
#endif
20902160
guard var pathToAppend = Parser.percentEncode(path, component: .path) else {
20912161
return self
20922162
}
20932163
if encodingSlashes {
20942164
var utf8 = Array(pathToAppend.utf8)
20952165
utf8.replace([UInt8(ascii: "/")], with: [UInt8(ascii: "%"), UInt8(ascii: "2"), UInt8(ascii: "F")])
2096-
pathToAppend = String(unsafeUninitializedCapacity: utf8.count) { buffer in
2097-
buffer.initialize(fromContentsOf: utf8)
2098-
}
2166+
pathToAppend = String(decoding: utf8, as: UTF8.self)
20992167
}
21002168

21012169
let slash = UInt8(ascii: "/")
@@ -2332,10 +2400,13 @@ extension URL {
23322400
extension URL {
23332401
private static func currentDirectoryOrNil() -> URL? {
23342402
let path: String? = FileManager.default.currentDirectoryPath
2335-
guard let path, path.utf8.first == UInt8(ascii: "/") else {
2403+
guard var filePath = path else {
23362404
return nil
23372405
}
2338-
return URL(filePath: path, directoryHint: .isDirectory)
2406+
guard URL.isAbsolute(standardizing: &filePath) else {
2407+
return nil
2408+
}
2409+
return URL(filePath: filePath, directoryHint: .isDirectory)
23392410
}
23402411

23412412
/// The working directory of the current process.
@@ -2660,3 +2731,14 @@ extension URL: _ExpressibleByFileReferenceLiteral {
26602731

26612732
public typealias _FileReferenceLiteralType = URL
26622733
#endif // FOUNDATION_FRAMEWORK
2734+
2735+
fileprivate extension UInt8 {
2736+
var isAlpha: Bool {
2737+
switch self {
2738+
case UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "a")...UInt8(ascii: "z"):
2739+
return true
2740+
default:
2741+
return false
2742+
}
2743+
}
2744+
}

Sources/FoundationEssentials/URL/URLParser.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,9 @@ internal struct RFC3986Parser: URLParserProtocol {
588588
/// Parses a URL string into `URLParseInfo`, with the option to add (or skip) encoding of invalid characters.
589589
/// If `encodingInvalidCharacters` is `true`, this function handles encoding of invalid components.
590590
static func parse(urlString: String, encodingInvalidCharacters: Bool) -> URLParseInfo? {
591+
#if os(Windows)
592+
let urlString = urlString.replacing(UInt8(ascii: "\\"), with: UInt8(ascii: "/"))
593+
#endif
591594
guard let parseInfo = parse(urlString: urlString) else {
592595
return nil
593596
}

0 commit comments

Comments
 (0)