diff --git a/Sources/FoundationEssentials/String/String+Path.swift b/Sources/FoundationEssentials/String/String+Path.swift index 7047b9be8..3657e6fd4 100644 --- a/Sources/FoundationEssentials/String/String+Path.swift +++ b/Sources/FoundationEssentials/String/String+Path.swift @@ -194,7 +194,11 @@ extension String { guard let lastDot = self.utf8.lastIndex(of: dot) else { return self } - return String(self[.. Bool { @@ -214,7 +218,16 @@ extension String { guard validatePathExtension(pathExtension) else { return self } - return self + ".\(pathExtension)" + var result = self._droppingTrailingSlashes + guard result != "/" else { + // Path was all slashes + return self + ".\(pathExtension)" + } + result += ".\(pathExtension)" + if utf8.last == ._slash { + result += "/" + } + return result } internal var pathExtension: String { @@ -366,7 +379,7 @@ extension String { return String(cString: output) } - #if !NO_FILESYSTEM +#if !NO_FILESYSTEM internal static func homeDirectoryPath(forUser user: String? = nil) -> String { #if os(Windows) if let user { @@ -529,8 +542,10 @@ extension String { #else return "/tmp/" #endif -#endif +#endif // os(Windows) } +#endif // !NO_FILESYSTEM + /// Replaces any number of sequential `/` /// characters with / /// NOTE: Internal so it's testable @@ -569,7 +584,7 @@ extension String { } } - private var _droppingTrailingSlashes: String { + internal var _droppingTrailingSlashes: String { guard !self.isEmpty else { return self } @@ -579,7 +594,9 @@ extension String { } return String(self[...lastNonSlash]) } - + +#if !NO_FILESYSTEM + static var NETWORK_PREFIX: String { #"\\"# } private var _standardizingPath: String { @@ -616,7 +633,8 @@ extension String { var standardizingPath: String { expandingTildeInPath._standardizingPath } - #endif // !NO_FILESYSTEM + +#endif // !NO_FILESYSTEM // _NSPathComponents var pathComponents: [String] { diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index 22cd44515..ba2f02c79 100644 --- a/Sources/FoundationEssentials/URL/URL.swift +++ b/Sources/FoundationEssentials/URL/URL.swift @@ -1319,13 +1319,32 @@ public struct URL: Equatable, Sendable, Hashable { } } - private static func fileSystemPath(for urlPath: String) -> String { - var result = urlPath - if result.count > 1 && result.utf8.last == UInt8(ascii: "/") { - _ = result.popLast() + private static func windowsPath(for posixPath: String) -> String { + let utf8 = posixPath.utf8 + guard utf8.count >= 4 else { + return posixPath + } + // "C:\" is standardized to "/C:/" on initialization + let array = Array(utf8) + if array[0] == ._slash, + array[1].isAlpha, + array[2] == ._colon, + array[3] == ._slash { + return String(Substring(utf8.dropFirst())) } + return posixPath + } + + private static func fileSystemPath(for urlPath: String) -> String { let charsToLeaveEncoded: Set = [._slash, 0] - return Parser.percentDecode(result, excluding: charsToLeaveEncoded) ?? "" + guard let posixPath = Parser.percentDecode(urlPath._droppingTrailingSlashes, excluding: charsToLeaveEncoded) else { + return "" + } + #if os(Windows) + return windowsPath(for: posixPath) + #else + return posixPath + #endif } var fileSystemPath: String { @@ -2026,55 +2045,65 @@ extension URL { #if !NO_FILESYSTEM private static func isDirectory(_ path: String) -> Bool { -#if !FOUNDATION_FRAMEWORK + #if os(Windows) + let path = path.replacing(._slash, with: ._backslash) + #endif + #if !FOUNDATION_FRAMEWORK var isDirectory: Bool = false _ = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) return isDirectory -#else + #else var isDirectory: ObjCBool = false _ = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) return isDirectory.boolValue -#endif + #endif } #endif // !NO_FILESYSTEM /// 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 { + if filePath.utf8.first == ._slash { + return true + } #if os(Windows) - var isAbsolute = false let utf8 = filePath.utf8 - if utf8.first == ._backslash { - // 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 == ._colon || second == ._pipe) - && third == ._backslash - ) - - if isAbsolute { - // Standardize to "\[drive-letter]:\..." - if second == ._pipe { - var filePathArray = Array(utf8) - filePathArray[1] = ._colon - filePathArray.insert(._backslash, at: 0) - filePath = String(decoding: filePathArray, as: UTF8.self) - } else { - filePath = "\\" + filePath - } + 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 } } - #else - let isAbsolute = filePath.utf8.first == UInt8(ascii: "/") || filePath.utf8.first == UInt8(ascii: "~") - #endif return isAbsolute + #else // os(Windows) + #if !NO_FILESYSTEM + // Expand the tilde if present + if filePath.utf8.first == UInt8(ascii: "~") { + filePath = filePath.expandingTildeInPath + } + #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. @@ -2111,10 +2140,9 @@ extension URL { } #if os(Windows) - let slash = UInt8(ascii: "\\") - var filePath = path.replacing(UInt8(ascii: "/"), with: slash) + // Convert any "\" to "/" before storing the URL parse info + var filePath = path.replacing(._backslash, with: ._slash) #else - let slash = UInt8(ascii: "/") var filePath = path #endif @@ -2126,41 +2154,31 @@ extension URL { } #endif - func absoluteFilePath() -> String { - guard !isAbsolute, let baseURL else { - return filePath - } - let basePath = baseURL.path() - #if os(Windows) - let urlPath = filePath.replacing(UInt8(ascii: "\\"), with: UInt8(ascii: "/")) - return URL.fileSystemPath(for: basePath.merging(relativePath: urlPath)).replacing(UInt8(ascii: "/"), with: UInt8(ascii: "\\")) - #else - return URL.fileSystemPath(for: basePath.merging(relativePath: filePath)) - #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 + isDirectory = filePath.utf8.last == ._slash #endif case .inferFromPath: - isDirectory = filePath.utf8.last == slash + isDirectory = filePath.utf8.last == ._slash } - #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 { + if isDirectory && !filePath.isEmpty && filePath.utf8.last != ._slash { filePath += "/" } var components = URLComponents() @@ -2438,6 +2456,9 @@ extension URL { 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 } diff --git a/Tests/FoundationEssentialsTests/StringTests.swift b/Tests/FoundationEssentialsTests/StringTests.swift index b5fff9aee..c7d650638 100644 --- a/Tests/FoundationEssentialsTests/StringTests.swift +++ b/Tests/FoundationEssentialsTests/StringTests.swift @@ -812,6 +812,19 @@ final class StringTests : XCTestCase { } } + func testAppendingPathExtension() { + 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/") + XCTAssertEqual("/path//".appendingPathExtension("foo"), "/path.foo/") + XCTAssertEqual("path".appendingPathExtension("foo"), "path.foo") + XCTAssertEqual("path/".appendingPathExtension("foo"), "path.foo/") + XCTAssertEqual("path//".appendingPathExtension("foo"), "path.foo/") + } + func testDeletingPathExtenstion() { XCTAssertEqual("".deletingPathExtension(), "") XCTAssertEqual("/".deletingPathExtension(), "/") @@ -834,6 +847,15 @@ final class StringTests : XCTestCase { XCTAssertEqual("/foo.bar/bar.baz/baz.zip".deletingPathExtension(), "/foo.bar/bar.baz/baz") XCTAssertEqual("/.././.././a.zip".deletingPathExtension(), "/.././.././a") XCTAssertEqual("/.././.././.".deletingPathExtension(), "/.././.././.") + + XCTAssertEqual("path.foo".deletingPathExtension(), "path") + XCTAssertEqual("path.foo.zip".deletingPathExtension(), "path.foo") + XCTAssertEqual("/path.foo".deletingPathExtension(), "/path") + XCTAssertEqual("/path.foo.zip".deletingPathExtension(), "/path.foo") + XCTAssertEqual("path.foo/".deletingPathExtension(), "path/") + XCTAssertEqual("path.foo//".deletingPathExtension(), "path/") + XCTAssertEqual("/path.foo/".deletingPathExtension(), "/path/") + XCTAssertEqual("/path.foo//".deletingPathExtension(), "/path/") } func test_dataUsingEncoding() { diff --git a/Tests/FoundationEssentialsTests/URLTests.swift b/Tests/FoundationEssentialsTests/URLTests.swift index ca2dec76c..d00ef806d 100644 --- a/Tests/FoundationEssentialsTests/URLTests.swift +++ b/Tests/FoundationEssentialsTests/URLTests.swift @@ -330,6 +330,18 @@ final class URLTests : XCTestCase { try FileManager.default.removeItem(at: URL(filePath: "\(tempDirectory.path)/tmp-dir")) } + #if os(Windows) + func testURLWindowsDriveLetterPath() throws { + let url = URL(filePath: "C:\\test\\path", directoryHint: .notDirectory) + // .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 + XCTAssertEqual(url.path, "C:/test/path") + XCTAssertEqual(url.fileSystemPath, "C:/test/path") + } + #endif + func testURLFilePathRelativeToBase() throws { try FileManagerPlayground { Directory("dir") { @@ -571,11 +583,103 @@ final class URLTests : XCTestCase { XCTAssertEqual(appended.relativePath, "relative/with:slash") } + func testURLFilePathDropsTrailingSlashes() throws { + 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") + + url = URL(filePath: "/path/slashes/") + XCTAssertEqual(url.path(), "/path/slashes/") + XCTAssertEqual(url.fileSystemPath, "/path/slashes") + + url = URL(filePath: "/path/slashes") + XCTAssertEqual(url.path(), "/path/slashes") + XCTAssertEqual(url.fileSystemPath, "/path/slashes") + } + + func testURLNotDirectoryHintStripsTrailingSlash() throws { + // Supply a path with a trailing slash but say it's not a direcotry + var url = URL(filePath: "/path/", directoryHint: .notDirectory) + XCTAssertFalse(url.hasDirectoryPath) + XCTAssertEqual(url.path(), "/path") + + url = URL(fileURLWithPath: "/path/", isDirectory: false) + XCTAssertFalse(url.hasDirectoryPath) + XCTAssertEqual(url.path(), "/path") + + url = URL(filePath: "/path///", directoryHint: .notDirectory) + XCTAssertFalse(url.hasDirectoryPath) + XCTAssertEqual(url.path(), "/path") + + url = URL(fileURLWithPath: "/path///", isDirectory: false) + XCTAssertFalse(url.hasDirectoryPath) + XCTAssertEqual(url.path(), "/path") + + // With .checkFileSystem, don't modify the path for a non-existent file + url = URL(filePath: "/my/non/existent/path/", directoryHint: .checkFileSystem) + XCTAssertTrue(url.hasDirectoryPath) + XCTAssertEqual(url.path(), "/my/non/existent/path/") + + url = URL(fileURLWithPath: "/my/non/existent/path/") + XCTAssertTrue(url.hasDirectoryPath) + XCTAssertEqual(url.path(), "/my/non/existent/path/") + + url = URL(filePath: "/my/non/existent/path", directoryHint: .checkFileSystem) + XCTAssertFalse(url.hasDirectoryPath) + XCTAssertEqual(url.path(), "/my/non/existent/path") + + url = URL(fileURLWithPath: "/my/non/existent/path") + XCTAssertFalse(url.hasDirectoryPath) + XCTAssertEqual(url.path(), "/my/non/existent/path") + } + func testURLHostRetainsIDNAEncoding() throws { let url = URL(string: "ftp://user:password@*.xn--poema-9qae5a.com.br:4343/cat.txt")! XCTAssertEqual(url.host, "*.xn--poema-9qae5a.com.br") } + func testURLTildeFilePath() throws { + var url = URL(filePath: "~") + // "~" must either be expanded to an absolute path or resolved against a base URL + XCTAssertTrue( + url.relativePath.utf8.first == ._slash || (url.baseURL != nil && url.path().utf8.first == ._slash) + ) + + url = URL(filePath: "~", directoryHint: .isDirectory) + XCTAssertTrue( + url.relativePath.utf8.first == ._slash || (url.baseURL != nil && url.path().utf8.first == ._slash) + ) + XCTAssertEqual(url.path().utf8.last, ._slash) + + url = URL(filePath: "~/") + XCTAssertTrue( + url.relativePath.utf8.first == ._slash || (url.baseURL != nil && url.path().utf8.first == ._slash) + ) + XCTAssertEqual(url.path().utf8.last, ._slash) + } + + func testURLPathExtensions() throws { + var url = URL(filePath: "/path", directoryHint: .notDirectory) + url.appendPathExtension("foo") + XCTAssertEqual(url.path(), "/path.foo") + url.deletePathExtension() + XCTAssertEqual(url.path(), "/path") + + url = URL(filePath: "/path", directoryHint: .isDirectory) + url.appendPathExtension("foo") + XCTAssertEqual(url.path(), "/path.foo/") + url.deletePathExtension() + XCTAssertEqual(url.path(), "/path/") + + url = URL(filePath: "/path/", directoryHint: .inferFromPath) + url.appendPathExtension("foo") + XCTAssertEqual(url.path(), "/path.foo/") + url.append(path: "/////") + url.deletePathExtension() + XCTAssertEqual(url.path(), "/path/") + } + func testURLComponentsPercentEncodedUnencodedProperties() throws { var comp = URLComponents()