Skip to content

Commit 75a3f5a

Browse files
authored
[Windows] Standardize slashes before path processing (#731)
* Standardize backslashes before string path processing on Windows * Call _standardizingSlashes() from normalizedPath(with:) * Return self when possible on non-Windows
1 parent 7f9238e commit 75a3f5a

File tree

2 files changed

+65
-16
lines changed

2 files changed

+65
-16
lines changed

Sources/FoundationEssentials/String/String+Path.swift

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,28 @@ import WinSDK
2222

2323
internal import _FoundationCShims
2424

25+
extension StringProtocol {
26+
fileprivate func _standardizingSlashes() -> String {
27+
#if os(Windows)
28+
// The string functions below all assume that the path separator is a forward slash
29+
// Standardize the path to use forward slashes before processing for consistency
30+
return self.replacing(._backslash, with: ._slash)
31+
#else
32+
if let str = _specializingCast(self, to: String.self) {
33+
return str
34+
} else {
35+
return String(self)
36+
}
37+
#endif
38+
}
39+
}
40+
2541
extension String {
2642
internal func deletingLastPathComponent() -> String {
43+
_standardizingSlashes()._deletingLastPathComponent()
44+
}
45+
46+
private func _deletingLastPathComponent() -> String {
2747
let lastSlash = self.lastIndex { $0 == "/" }
2848
guard let lastSlash else {
2949
// No slash
@@ -50,6 +70,10 @@ extension String {
5070
}
5171

5272
internal func appendingPathComponent(_ component: String) -> String {
73+
_standardizingSlashes()._appendingPathComponent(component)
74+
}
75+
76+
private func _appendingPathComponent(_ component: String) -> String {
5377
var result = self
5478
if !component.isEmpty {
5579
var needsSlash = true
@@ -103,6 +127,10 @@ extension String {
103127
}
104128

105129
internal var lastPathComponent: String {
130+
_standardizingSlashes()._lastPathComponent
131+
}
132+
133+
private var _lastPathComponent: String {
106134
let lastSlash = self.lastIndex { $0 == "/" }
107135
guard let lastSlash else {
108136
// No slash, just return self
@@ -170,11 +198,11 @@ extension String {
170198
return false
171199
}
172200
if let lastDot = pathExtension.utf8.lastIndex(of: UInt8(ascii: ".")) {
173-
let beforeDot = pathExtension[..<lastDot].unicodeScalars
174-
let afterDot = pathExtension[pathExtension.index(after: lastDot)...].unicodeScalars
201+
let beforeDot = pathExtension[..<lastDot]._standardizingSlashes().unicodeScalars
202+
let afterDot = pathExtension[pathExtension.index(after: lastDot)...]._standardizingSlashes().unicodeScalars
175203
return beforeDot.allSatisfy { $0 != "/" } && afterDot.allSatisfy { !String.invalidExtensionScalars.contains($0) }
176204
} else {
177-
return pathExtension.unicodeScalars.allSatisfy { !String.invalidExtensionScalars.contains($0) }
205+
return pathExtension._standardizingSlashes().unicodeScalars.allSatisfy { !String.invalidExtensionScalars.contains($0) }
178206
}
179207
}
180208

@@ -202,6 +230,10 @@ extension String {
202230
}
203231

204232
internal func merging(relativePath: String) -> String {
233+
_standardizingSlashes()._merging(relativePath: relativePath)
234+
}
235+
236+
private func _merging(relativePath: String) -> String {
205237
guard relativePath.utf8.first != UInt8(ascii: "/") else {
206238
return relativePath
207239
}
@@ -212,6 +244,10 @@ extension String {
212244
}
213245

214246
internal var removingDotSegments: String {
247+
_standardizingSlashes()._removingDotSegments
248+
}
249+
250+
private var _removingDotSegments: String {
215251
let input = self.utf8
216252
guard !input.isEmpty else {
217253
return ""
@@ -440,18 +476,12 @@ extension String {
440476

441477
// From swift-corelibs-foundation's NSTemporaryDirectory. Internal for now, pending a better public API.
442478
internal static var temporaryDirectoryPath: String {
443-
#if os(Windows)
444-
let validPathSeps: [Character] = ["\\", "/"]
445-
#else
446-
let validPathSeps: [Character] = ["/"]
447-
#endif
448-
449479
func normalizedPath(with path: String) -> String {
450-
if validPathSeps.contains(where: { path.hasSuffix(String($0)) }) {
451-
return path
452-
} else {
453-
return path + String(validPathSeps.last!)
480+
var result = path._standardizingSlashes()
481+
guard result.utf8.last != ._slash else {
482+
return result
454483
}
484+
return result + "/"
455485
}
456486
#if os(Windows)
457487
let cchLength: DWORD = GetTempPathW(0, nil)
@@ -547,7 +577,7 @@ extension String {
547577
static var NETWORK_PREFIX: String { #"\\"# }
548578

549579
private var _standardizingPath: String {
550-
var result = _transmutingCompressingSlashes()._droppingTrailingSlashes
580+
var result = _standardizingSlashes()._transmutingCompressingSlashes()._droppingTrailingSlashes
551581
let postNetStart = if result.starts(with: String.NETWORK_PREFIX) {
552582
result.firstIndex(of: "/") ?? result.endIndex
553583
} else {
@@ -558,7 +588,7 @@ extension String {
558588
result = resolved
559589
}
560590

561-
result = result.removingDotSegments
591+
result = result._removingDotSegments
562592

563593
// Automounted paths need to be stripped for various flavors of paths
564594
let exclusionList = ["/Applications", "/Library", "/System", "/Users", "/Volumes", "/bin", "/cores", "/dev", "/opt", "/private", "/sbin", "/usr"]
@@ -584,6 +614,10 @@ extension String {
584614

585615
// _NSPathComponents
586616
var pathComponents: [String] {
617+
_standardizingSlashes()._pathComponents
618+
}
619+
620+
private var _pathComponents: [String] {
587621
var components = self.components(separatedBy: "/").filter { !$0.isEmpty }
588622
if self.first == "/" {
589623
components.insert("/", at: 0)
@@ -596,6 +630,10 @@ extension String {
596630

597631
#if !NO_FILESYSTEM
598632
var abbreviatingWithTildeInPath: String {
633+
_standardizingSlashes()._abbreviatingWithTildeInPath
634+
}
635+
636+
private var _abbreviatingWithTildeInPath: String {
599637
guard !self.isEmpty && self != "/" else { return self }
600638
let homeDir = String.homeDirectoryPath()
601639
guard self.starts(with: homeDir) else { return self }
@@ -605,6 +643,10 @@ extension String {
605643
}
606644

607645
var expandingTildeInPath: String {
646+
_standardizingSlashes()._expandingTildeInPath
647+
}
648+
649+
private var _expandingTildeInPath: String {
608650
guard self.first == "~" else { return self }
609651
var user: String? = nil
610652
let firstSlash = self.firstIndex(of: "/") ?? self.endIndex
@@ -781,6 +823,7 @@ extension StringProtocol {
781823
}
782824
}
783825

826+
// Internal for testing purposes
784827
internal func _hasDotDotComponent() -> Bool {
785828
let input = self.utf8
786829
guard input.count >= 2 else {

Tests/FoundationEssentialsTests/FileManager/FileManagerTests.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,10 +282,16 @@ final class FileManagerTests : XCTestCase {
282282
try $0.createDirectory(atPath: "create_dir_test2/nested2", withIntermediateDirectories: true)
283283
XCTAssertEqual(try $0.contentsOfDirectory(atPath: "create_dir_test2").sorted(), ["nested", "nested2"])
284284
XCTAssertNoThrow(try $0.createDirectory(atPath: "create_dir_test2/nested2", withIntermediateDirectories: true))
285+
286+
#if os(Windows)
287+
try $0.createDirectory(atPath: "create_dir_test3\\nested", withIntermediateDirectories: true)
288+
XCTAssertEqual(try $0.contentsOfDirectory(atPath: "create_dir_test3"), ["nested"])
289+
#endif
290+
285291
XCTAssertThrowsError(try $0.createDirectory(atPath: "create_dir_test", withIntermediateDirectories: false)) {
286292
XCTAssertEqual(($0 as? CocoaError)?.code, .fileWriteFileExists)
287293
}
288-
XCTAssertThrowsError(try $0.createDirectory(atPath: "create_dir_test3/nested", withIntermediateDirectories: false)) {
294+
XCTAssertThrowsError(try $0.createDirectory(atPath: "create_dir_test4/nested", withIntermediateDirectories: false)) {
289295
XCTAssertEqual(($0 as? CocoaError)?.code, .fileNoSuchFile)
290296
}
291297
XCTAssertThrowsError(try $0.createDirectory(atPath: "preexisting_file", withIntermediateDirectories: false)) {

0 commit comments

Comments
 (0)