diff --git a/Sources/SkipFoundation/URL.swift b/Sources/SkipFoundation/URL.swift index bfc3eb7..f6723e2 100644 --- a/Sources/SkipFoundation/URL.swift +++ b/Sources/SkipFoundation/URL.swift @@ -15,9 +15,20 @@ //===----------------------------------------------------------------------===// #if SKIP +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + public typealias NSURL = URL public struct URL : Hashable, CustomStringConvertible, Codable, KotlinConverting, SwiftCustomBridged { + public enum DirectoryHint { + case isDirectory + case notDirectory + case checkFileSystem + case inferFromPath + } + internal let platformValue: java.net.URI private let isDirectoryFlag: Bool? @@ -117,7 +128,12 @@ public struct URL : Hashable, CustomStringConvertible, Codable, KotlinConverting self.baseURL = baseURL // Use the same logic as the constructor so that `URL(fileURLWithPath: "/tmp/") == URL(string: "file:///tmp/")` let scheme = baseURL?.platformValue.scheme ?? self.platformValue.scheme - self.isDirectoryFlag = scheme == "file" && string.hasSuffix("/") + let isDir: Bool? = (scheme == "file" ? string.hasSuffix("/") : nil) + self.isDirectoryFlag = isDir + if isDir == true, let p = self.platformValue.path, !p.isEmpty, p != "/", p.hasSuffix("/") { + // store the platform value without a trailing slash + self.platformValue = java.net.URI(self.platformValue.scheme, nil, String(p.dropLast()), self.platformValue.query, self.platformValue.fragment) + } } public init?(string: String, encodingInvalidCharacters: Bool) { @@ -139,13 +155,71 @@ public struct URL : Hashable, CustomStringConvertible, Codable, KotlinConverting } } self.baseURL = nil - self.isDirectoryFlag = self.platformValue.scheme == "file" && string.hasSuffix("/") + let isDir: Bool? = (self.platformValue.scheme == "file" ? string.hasSuffix("/") : nil) + self.isDirectoryFlag = isDir + if isDir == true, let p = self.platformValue.path, !p.isEmpty, p != "/", p.hasSuffix("/") { + // store the platform value without a trailing slash + self.platformValue = java.net.URI(self.platformValue.scheme, nil, String(p.dropLast()), self.platformValue.query, self.platformValue.fragment) + } } public init(fileURLWithPath path: String, isDirectory: Bool? = nil, relativeTo base: URL? = nil) { - self.platformValue = java.net.URI("file://" + path) // TODO: escaping + self.init( + filePath: path, + // TODO: This "should" be .checkFileSystem, but nobody _likes_ that + directoryHint: (isDirectory == true ? .isDirectory : (isDirectory == false ? .notDirectory : .inferFromPath)), + relativeTo: base + ) + } + + private static func _isDirectoryFor(filePath path: String, directoryHint: DirectoryHint, relativeTo base: URL?) -> Bool { + switch directoryHint { + case .isDirectory: + return true + case .inferFromPath, .notDirectory: // Swift Foundation treats .notDirectory as .inferFromPath + return path.hasSuffix("/") + case .checkFileSystem: + let isBaseFileURL = base?.isFileURL ?? true + if !isBaseFileURL || path.hasSuffix("/") { + return true + } + + let pathToCheck: Path + if Paths.get(path).isAbsolute() { + pathToCheck = Paths.get(path) + } else if let base = base, base.isFileURL { + pathToCheck = Paths.get(base.path).resolve(path) + } else { + pathToCheck = Paths.get(path) + } + + return Files.exists(pathToCheck) && Files.isDirectory(pathToCheck) + } + } + + private static func _escapedFilePathForURI(_ path: String) -> String { + // Encode path-only invalid characters (e.g. spaces) while preserving valid path punctuation. + return java.net.URI(nil, nil, path, nil).rawPath ?? path + } + + public init(filePath path: String, directoryHint: DirectoryHint = .inferFromPath, relativeTo base: URL? = nil) { + let isDirectory = URL._isDirectoryFor(filePath: path, directoryHint: directoryHint, relativeTo: base) + + let resolvedPath: String + if let base, !path.hasPrefix("/") { + let baseForResolution = base.hasDirectoryPath ? base : base.deletingLastPathComponent() + resolvedPath = baseForResolution.appending(path: path, directoryHint: .inferFromPath).platformValue.path + } else { + resolvedPath = path + } + if isDirectory && resolvedPath != "/" && !resolvedPath.isEmpty && resolvedPath.hasSuffix("/") { + // Store without trailing slash so hasDirectoryPath controls rendering. + self.platformValue = java.net.URI("file://" + URL._escapedFilePathForURI(String(resolvedPath.dropLast()))) + } else { + self.platformValue = java.net.URI("file://" + URL._escapedFilePathForURI(resolvedPath)) + } self.baseURL = base - self.isDirectoryFlag = isDirectory ?? path.hasSuffix("/") // TODO: should we hit the file system like NSURL does? + self.isDirectoryFlag = isDirectory } @available(*, unavailable) @@ -208,12 +282,12 @@ public struct URL : Hashable, CustomStringConvertible, Codable, KotlinConverting } public var description: String { - return platformValue.toString() + return absoluteString } /// Converts this URL to a `java.nio.file.Path`. - public func toPath() -> java.nio.file.Path { - return java.nio.file.Paths.get(absoluteURL.platformValue) + public func toPath() -> Path { + return Paths.get(absoluteURL.platformValue) } /// Converts this URL to a `android.net.Uri`. @@ -296,7 +370,12 @@ public struct URL : Hashable, CustomStringConvertible, Codable, KotlinConverting } public var absoluteString: String { - return absoluteURL.platformValue.toString() + let absolute = absoluteURL + let s = absolute.platformValue.toString() + guard absolute.isFileURL, absolute.isDirectoryFlag == true, !s.hasSuffix("/") else { + return s + } + return s + "/" } public var lastPathComponent: String { @@ -364,32 +443,20 @@ public struct URL : Hashable, CustomStringConvertible, Codable, KotlinConverting public var absoluteURL: URL { if let baseURL = self.baseURL { - return URL(platformValue: baseURL.platformValue.resolve(platformValue)) + let resolved = URL(platformValue: baseURL.platformValue.resolve(platformValue), isDirectory: self.isDirectoryFlag) + return resolved } else { return self } } - private func _appendingPathComponent(_ pathComponent: String) -> URL { - guard var components = URLComponents(url: self, resolvingAgainstBaseURL: true) else { - return self - } - var newPath = components.percentEncodedPath - if !newPath.hasSuffix("/") { - newPath += "/" - } - newPath += pathComponent - components.percentEncodedPath = newPath - return components.url(relativeTo: baseURL)! - } - public func appendingPathComponent(_ pathComponent: String) -> URL { - _appendingPathComponent(pathComponent) + // TODO: This "should" use directoryHint: .checkFileSystem, but nobody _likes_ that + appending(path: pathComponent) } public func appendingPathComponent(_ pathComponent: String, isDirectory: Bool) -> URL { - let string = _appendingPathComponent(pathComponent).absoluteString - return URL(platformValue: java.net.URI(string), isDirectory: isDirectory) + return appending(path: pathComponent, directoryHint: (isDirectory ? .isDirectory : .notDirectory)) } public mutating func appendPathComponent(_ pathComponent: String) { @@ -400,6 +467,31 @@ public struct URL : Hashable, CustomStringConvertible, Codable, KotlinConverting self = appendingPathComponent(pathComponent, isDirectory: isDirectory) } + public func appending(path: String, directoryHint: DirectoryHint = .inferFromPath) -> URL { + let hasTrailingSlash = path.hasSuffix("/") + let isDirectory = URL._isDirectoryFor(filePath: path, directoryHint: directoryHint, relativeTo: self) + + let adjustedPath = (isDirectory && !hasTrailingSlash) ? (path + "/") : path + guard var components = URLComponents(url: self, resolvingAgainstBaseURL: true) else { + return self + } + var newPath = components.percentEncodedPath + if !newPath.hasSuffix("/") { + newPath += "/" + } + let encodedPathComponent = adjustedPath.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlPathAllowed)! + newPath += encodedPathComponent + components.percentEncodedPath = newPath + guard let string = components.string else { + return self + } + return URL(platformValue: java.net.URI(string), isDirectory: isDirectory, baseURL: baseURL) + } + + public mutating func append(path: String, directoryHint: DirectoryHint = .inferFromPath) { + self = appending(path: path, directoryHint: directoryHint) + } + @available(*, unavailable) public func appendingPathComponent(_ pathComponent: String, conformingTo type: Any) -> URL { fatalError() @@ -508,7 +600,19 @@ public struct URL : Hashable, CustomStringConvertible, Codable, KotlinConverting // return URL(platformValue: normalized.toUri().toURL()) //} do { - return URL(platformValue: originalPath.toRealPath().toUri()) + let resolved = originalPath.toRealPath().toUri() + let stored: java.net.URI + if resolved.scheme == "file" { + let p = resolved.path + if !p.isEmpty, p != "/", p.hasSuffix("/") { + stored = java.net.URI(resolved.scheme, nil, String(p.dropLast()), resolved.query, resolved.fragment) + } else { + stored = resolved + } + } else { + stored = resolved + } + return URL(platformValue: stored, isDirectory: self.isDirectoryFlag) } catch { // this will fail if the file does not exist, but Foundation expects it to return the path itself return self diff --git a/Sources/SkipFoundation/URLComponents.swift b/Sources/SkipFoundation/URLComponents.swift index ba2cf39..1a2e0ee 100644 --- a/Sources/SkipFoundation/URLComponents.swift +++ b/Sources/SkipFoundation/URLComponents.swift @@ -67,6 +67,10 @@ public struct URLComponents : Hashable, Equatable, Sendable { if let port { string += ":\(port)" } + } else if scheme == "file" { + // Opaque "file:path" strings are *not* equivalent to the hierarchical `file:///...` form used + // by `java.net.URI` / Foundation for local absolute paths, and will confuse path appends. + string += "//" } string += percentEncodedPath if let fragment { diff --git a/Tests/SkipFoundationTests/Network/TestURL.swift b/Tests/SkipFoundationTests/Network/TestURL.swift index 83cf53a..8b465be 100644 --- a/Tests/SkipFoundationTests/Network/TestURL.swift +++ b/Tests/SkipFoundationTests/Network/TestURL.swift @@ -529,6 +529,66 @@ class TestURL : XCTestCase { #endif } + func test_init_filePath_directoryHint_relativeTo() throws { + let fileManager = FileManager.default + let baseDirectoryURL = URL(fileURLWithPath: TestURL.gBaseTemporaryDirectoryPath, isDirectory: true) + + try? fileManager.removeItem(atPath: TestURL.gBaseTemporaryDirectoryPath) + try fileManager.createDirectory(atPath: TestURL.gBaseTemporaryDirectoryPath, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(atPath: TestURL.gBaseTemporaryDirectoryPath) } + + try Data("test".utf8).write(to: URL(fileURLWithPath: TestURL.gFileExistsPath)) + try fileManager.createDirectory(atPath: TestURL.gDirectoryExistsPath, withIntermediateDirectories: false) + + // Native Foundation behavior: relativeTo is not reflected in absoluteString for these relative filePath inputs. + let inferredDirectoryURL = URL(filePath: "docs/", directoryHint: .inferFromPath, relativeTo: baseDirectoryURL) + XCTAssertTrue(inferredDirectoryURL.hasDirectoryPath) + XCTAssertEqual(inferredDirectoryURL.path, "\(TestURL.gBaseTemporaryDirectoryPath)/docs") + XCTAssertEqual(inferredDirectoryURL.absoluteString, "file://\(TestURL.gBaseTemporaryDirectoryPath)/docs/") + + let explicitDirectoryURL = URL(filePath: "docs", directoryHint: .isDirectory, relativeTo: baseDirectoryURL) + XCTAssertTrue(explicitDirectoryURL.hasDirectoryPath) + XCTAssertEqual(explicitDirectoryURL.path, "\(TestURL.gBaseTemporaryDirectoryPath)/docs") + XCTAssertEqual(explicitDirectoryURL.absoluteString, "file://\(TestURL.gBaseTemporaryDirectoryPath)/docs/") + + let missingNoSlash = URL(filePath: TestURL.gFileDoesNotExistPath, directoryHint: .checkFileSystem, relativeTo: baseDirectoryURL) + XCTAssertFalse(missingNoSlash.hasDirectoryPath) + XCTAssertEqual(missingNoSlash.path, TestURL.gFileDoesNotExistPath) + XCTAssertEqual(missingNoSlash.absoluteString, "file://\(TestURL.gFileDoesNotExistPath)") + + let missingWithSlash = URL(filePath: "\(TestURL.gFileDoesNotExistPath)/", directoryHint: .checkFileSystem, relativeTo: baseDirectoryURL) + XCTAssertTrue(missingWithSlash.hasDirectoryPath) + XCTAssertEqual(missingWithSlash.path, TestURL.gFileDoesNotExistPath) + XCTAssertEqual(missingWithSlash.absoluteString, "file://\(TestURL.gFileDoesNotExistPath)/") + + let existingFile = URL(filePath: TestURL.gFileExistsPath, directoryHint: .checkFileSystem, relativeTo: baseDirectoryURL) + XCTAssertFalse(existingFile.hasDirectoryPath) + XCTAssertEqual(existingFile.path, TestURL.gFileExistsPath) + XCTAssertEqual(existingFile.absoluteString, "file://\(TestURL.gFileExistsPath)") + + let escapedName = "foo bar (final) (final2).pdf" + let escapedPath = "\(TestURL.gBaseTemporaryDirectoryPath)/\(escapedName)" + let escapedFile = URL(filePath: escapedPath, directoryHint: .checkFileSystem, relativeTo: baseDirectoryURL) + XCTAssertFalse(escapedFile.hasDirectoryPath) + XCTAssertEqual(escapedFile.path, escapedPath) + XCTAssertEqual(escapedFile.absoluteString, "file://\(TestURL.gBaseTemporaryDirectoryPath)/foo%20bar%20(final)%20(final2).pdf") + + let existingFileWithSlash = URL(filePath: "\(TestURL.gFileExistsPath)/", directoryHint: .checkFileSystem, relativeTo: baseDirectoryURL) + XCTAssertTrue(existingFileWithSlash.hasDirectoryPath) + XCTAssertEqual(existingFileWithSlash.path, TestURL.gFileExistsPath) + XCTAssertEqual(existingFileWithSlash.absoluteString, "file://\(TestURL.gFileExistsPath)/") + + let existingDirectory = URL(filePath: TestURL.gDirectoryExistsPath, directoryHint: .checkFileSystem, relativeTo: baseDirectoryURL) + XCTAssertTrue(existingDirectory.hasDirectoryPath) + XCTAssertEqual(existingDirectory.path, TestURL.gDirectoryExistsPath) + XCTAssertEqual(existingDirectory.absoluteString, "file://\(TestURL.gDirectoryExistsPath)/") + + let existingDirectoryWithSlash = URL(filePath: "\(TestURL.gDirectoryExistsPath)/", directoryHint: .checkFileSystem, relativeTo: baseDirectoryURL) + XCTAssertTrue(existingDirectoryWithSlash.hasDirectoryPath) + XCTAssertEqual(existingDirectoryWithSlash.path, TestURL.gDirectoryExistsPath) + XCTAssertEqual(existingDirectoryWithSlash.absoluteString, "file://\(TestURL.gDirectoryExistsPath)/") + } + func test_URLByResolvingSymlinksInPathShouldRemoveDuplicatedPathSeparators() { let url = URL(fileURLWithPath: "//foo///bar////baz/") let result = url.resolvingSymlinksInPath() @@ -779,14 +839,15 @@ class TestURL : XCTestCase { } func test_appendingPathComponent() { - let appendComponent = "foo" let urlsExpected = [ - ("www.swift.org", "www.swift.org/\(appendComponent)"), - ("https://www.swift.org/", "https://www.swift.org/\(appendComponent)"), - ("https://www.swift.org/a+b#hash", "https://www.swift.org/a+b/\(appendComponent)#hash"), - ("https://www.swift.org/a%20b/#hash?q", "https://www.swift.org/a%20b/\(appendComponent)#hash?q"), + ("www.swift.org", "foo", "www.swift.org/foo"), + ("https://www.swift.org/", "foo", "https://www.swift.org/foo"), + ("https://www.swift.org/a+b#hash", "foo", "https://www.swift.org/a+b/foo#hash"), + ("https://www.swift.org/a%20b/#hash?q", "foo", "https://www.swift.org/a%20b/foo#hash?q"), + ("https://example.com/", "foo bar (1).png", "https://example.com/foo%20bar%20(1).png"), + ("https://example.com/", "foo/bar", "https://example.com/foo/bar"), ] - for (urlString, expected) in urlsExpected { + for (urlString, appendComponent, expected) in urlsExpected { let url = URL(string: urlString)! XCTAssertEqual(url.appendingPathComponent(appendComponent).absoluteString, expected) } @@ -795,6 +856,67 @@ class TestURL : XCTestCase { #endif } + func test_appending_path_directoryHint() { + let baseURL = URL(string: "https://example.com/tmp")! + + let explicitDirectoryURL = baseURL.appending(path: "docs", directoryHint: .isDirectory) + XCTAssertEqual(explicitDirectoryURL.absoluteString, "https://example.com/tmp/docs/") + XCTAssertTrue(explicitDirectoryURL.hasDirectoryPath) + + let explicitDirectoryURLSlash = baseURL.appending(path: "docs/", directoryHint: .isDirectory) + XCTAssertEqual(explicitDirectoryURLSlash.absoluteString, "https://example.com/tmp/docs/") + XCTAssertTrue(explicitDirectoryURLSlash.hasDirectoryPath) + + let inferredDirectoryURL = baseURL.appending(path: "docs/", directoryHint: .inferFromPath) + XCTAssertEqual(inferredDirectoryURL.absoluteString, "https://example.com/tmp/docs/") + XCTAssertTrue(inferredDirectoryURL.hasDirectoryPath) + + let inferredFileURL = baseURL.appending(path: "notes.txt", directoryHint: .inferFromPath) + XCTAssertEqual(inferredFileURL.absoluteString, "https://example.com/tmp/notes.txt") + XCTAssertFalse(inferredFileURL.hasDirectoryPath) + + // Swift Foundation treats .notDirectory as .inferFromPath + // (It is kind of silly to say "append docs/, but this is not a directory"!) + let explicitFileURLFromSlashPath = baseURL.appending(path: "docs/", directoryHint: .notDirectory) + XCTAssertEqual(explicitFileURLFromSlashPath.absoluteString, "https://example.com/tmp/docs/") + XCTAssertTrue(explicitFileURLFromSlashPath.hasDirectoryPath) + } + + func test_appending_path_directoryHint_checkFileSystem() throws { + try withTemporaryDirectory { temporaryDirectoryURL, _ in + let fileManager = FileManager.default + let existingFileName = "existing-file.txt" + let existingDirectoryName = "existing-directory" + let missingName = "missing-entry" + + let existingFileURL = temporaryDirectoryURL.appendingPathComponent(existingFileName) + let existingDirectoryURL = temporaryDirectoryURL.appendingPathComponent(existingDirectoryName, isDirectory: true) + try Data("test".utf8).write(to: existingFileURL) + try fileManager.createDirectory(at: existingDirectoryURL, withIntermediateDirectories: false) + + func assertCheckFileSystemHint(path: String, expectedIsDirectory: Bool, expectedPath: String) { + let resultURL = temporaryDirectoryURL.appending(path: path, directoryHint: .checkFileSystem) + XCTAssertEqual(resultURL.hasDirectoryPath, expectedIsDirectory, "unexpected hasDirectoryPath for \(path)") + let expectedURL = temporaryDirectoryURL.appending(path: expectedPath, directoryHint: .inferFromPath) + XCTAssertEqual(resultURL.absoluteString, expectedURL.absoluteString, "unexpected absoluteString for \(path), isDirectory=\(expectedIsDirectory) expected=\(expectedURL.absoluteString) actual=\(resultURL.absoluteString)") + } + + // Missing file falls back to path inference. + assertCheckFileSystemHint(path: missingName, expectedIsDirectory: false, expectedPath: missingName) + assertCheckFileSystemHint(path: "\(missingName)/", expectedIsDirectory: true, expectedPath: "\(missingName)/") + + // Existing file + assertCheckFileSystemHint(path: existingFileName, expectedIsDirectory: false, expectedPath: existingFileName) + + // Existing file with trailing slash is actually a missing directory, inferred from path. + assertCheckFileSystemHint(path: "\(existingFileName)/", expectedIsDirectory: true, expectedPath: "\(existingFileName)/") + + // Existing directory always ends with slash. + assertCheckFileSystemHint(path: existingDirectoryName, expectedIsDirectory: true, expectedPath: "\(existingDirectoryName)/") + assertCheckFileSystemHint(path: "\(existingDirectoryName)/", expectedIsDirectory: true, expectedPath: "\(existingDirectoryName)/") + } + } + func test_appendingPathExtension() { let ext = "foo" let dotExt = ".\(ext)"