AccentPomodoro / Accent Pomodoro / Files.swift
Files.swift
Raw
/**
 *  Files
 *
 *  Copyright (c) 2017-2019 John Sundell. Licensed under the MIT license, as follows:
 *
 *  Permission is hereby granted, free of charge, to any person obtaining a copy
 *  of this software and associated documentation files (the "Software"), to deal
 *  in the Software without restriction, including without limitation the rights
 *  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 *  copies of the Software, and to permit persons to whom the Software is
 *  furnished to do so, subject to the following conditions:
 *
 *  The above copyright notice and this permission notice shall be included in all
 *  copies or substantial portions of the Software.
 *
 *  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 *  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 *  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 *  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 *  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 *  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 *  SOFTWARE.
 */

import Foundation

// MARK: - Locations

/// Enum describing various kinds of locations that can be found on a file system.
public enum LocationKind {
    /// A file can be found at the location.
    case file
    /// A folder can be found at the location.
    case folder
}

/// Protocol adopted by types that represent locations on a file system.
public protocol Location: Equatable, CustomStringConvertible {
    /// The kind of location that is being represented (see `LocationKind`).
    static var kind: LocationKind { get }
    /// The underlying storage for the item at the represented location.
    /// You don't interact with this object as part of the public API.
    var storage: Storage<Self> { get }
    /// Initialize an instance of this location with its underlying storage.
    /// You don't call this initializer as part of the public API, instead
    /// use `init(path:)` on either `File` or `Folder`.
    init(storage: Storage<Self>)
}

public extension Location {
    static func ==(lhs: Self, rhs: Self) -> Bool {
        return lhs.storage.path == rhs.storage.path
    }

    var description: String {
        let typeName = String(describing: type(of: self))
        return "\(typeName)(name: \(name), path: \(path))"
    }

    /// The path of this location, relative to the root of the file system.
    var path: String {
        return storage.path
    }

    /// A URL representation of the location's `path`.
    var url: URL {
        return URL(fileURLWithPath: path)
    }

    /// The name of the location, including any `extension`.
    var name: String {
        return url.pathComponents.last!
    }

    /// The name of the location, excluding its `extension`.
    var nameExcludingExtension: String {
        let components = name.split(separator: ".")
        guard components.count > 1 else { return name }
        return components.dropLast().joined()
    }

    /// The file extension of the item at the location.
    var `extension`: String? {
        let components = name.split(separator: ".")
        guard components.count > 1 else { return nil }
        return String(components.last!)
    }

    /// The parent folder that this location is contained within.
    var parent: Folder? {
        return storage.makeParentPath(for: path).flatMap {
            try? Folder(path: $0)
        }
    }

    /// The date when the item at this location was created.
    /// Only returns `nil` in case the item has now been deleted.
    var creationDate: Date? {
        return storage.attributes[.creationDate] as? Date
    }

    /// The date when the item at this location was last modified.
    /// Only returns `nil` in case the item has now been deleted.
    var modificationDate: Date? {
        return storage.attributes[.modificationDate] as? Date
    }

    /// Initialize an instance of an existing location at a given path.
    /// - parameter path: The absolute path of the location.
    /// - throws: `LocationError` if the item couldn't be found.
    init(path: String) throws {
        try self.init(storage: Storage(
            path: path,
            fileManager: .default
        ))
    }

    /// Return the path of this location relative to a parent folder.
    /// For example, if this item is located at `/users/john/documents`
    /// and `/users/john` is passed, then `documents` is returned. If the
    /// passed folder isn't an ancestor of this item, then the item's
    /// absolute `path` is returned instead.
    /// - parameter folder: The folder to compare this item's path against.
    func path(relativeTo folder: Folder) -> String {
        guard path.hasPrefix(folder.path) else {
            return path
        }

        let index = path.index(path.startIndex, offsetBy: folder.path.count)
        return String(path[index...]).removingSuffix("/")
    }

    /// Rename this location, keeping its existing `extension` by default.
    /// - parameter newName: The new name to give the location.
    /// - parameter keepExtension: Whether the location's `extension` should
    ///   remain unmodified (default: `true`).
    /// - throws: `LocationError` if the item couldn't be renamed.
    func rename(to newName: String, keepExtension: Bool = true) throws {
        guard let parent = parent else {
            throw LocationError(path: path, reason: .cannotRenameRoot)
        }

        var newName = newName

        if keepExtension {
            `extension`.map {
                newName = newName.appendingSuffixIfNeeded(".\($0)")
            }
        }

        try storage.move(
            to: parent.path + newName,
            errorReasonProvider: LocationErrorReason.renameFailed
        )
    }

    /// Move this location to a new parent folder
    /// - parameter newParent: The folder to move this item to.
    /// - throws: `LocationError` if the location couldn't be moved.
    func move(to newParent: Folder) throws {
        try storage.move(
            to: newParent.path + name,
            errorReasonProvider: LocationErrorReason.moveFailed
        )
    }

    /// Copy the contents of this location to a given folder
    /// - parameter newParent: The folder to copy this item to.
    /// - throws: `LocationError` if the location couldn't be copied.
    /// - returns: The new, copied location.
    @discardableResult
    func copy(to folder: Folder) throws -> Self {
        let path = folder.path + name
        try storage.copy(to: path)
        return try Self(path: path)
    }

    /// Delete this location. It will be permanently deleted. Use with caution.
    /// - throws: `LocationError` if the item couldn't be deleted.
    func delete() throws {
        try storage.delete()
    }

    /// Assign a new `FileManager` to manage this location. Typically only used
    /// for testing, or when building custom file systems. Returns a new instance,
    /// doensn't modify the instance this is called on.
    /// - parameter manager: The new file manager that should manage this location.
    /// - throws: `LocationError` if the change couldn't be completed.
    func managedBy(_ manager: FileManager) throws -> Self {
        return try Self(storage: Storage(
            path: path,
            fileManager: manager
        ))
    }
}

// MARK: - Storage

/// Type used to store information about a given file system location. You don't
/// interact with this type as part of the public API, instead you use the APIs
/// exposed by `Location`, `File`, and `Folder`.
public final class Storage<LocationType: Location> {
    fileprivate private(set) var path: String
    private let fileManager: FileManager

    fileprivate init(path: String, fileManager: FileManager) throws {
        self.path = path
        self.fileManager = fileManager
        try validatePath()
    }

    private func validatePath() throws {
        switch LocationType.kind {
        case .file:
            guard !path.isEmpty else {
                throw LocationError(path: path, reason: .emptyFilePath)
            }
        case .folder:
            if path.isEmpty { path = fileManager.currentDirectoryPath }
            if !path.hasSuffix("/") { path += "/" }
        }

        if path.hasPrefix("~") {
            let homePath = ProcessInfo.processInfo.environment["HOME"]!
            path = homePath + path.dropFirst()
        }

        while let parentReferenceRange = path.range(of: "../") {
            let folderPath = String(path[..<parentReferenceRange.lowerBound])
            let parentPath = makeParentPath(for: folderPath) ?? "/"

            guard fileManager.locationExists(at: parentPath, kind: .folder) else {
                throw LocationError(path: parentPath, reason: .missing)
            }

            path.replaceSubrange(..<parentReferenceRange.upperBound, with: parentPath)
        }

        guard fileManager.locationExists(at: path, kind: LocationType.kind) else {
            throw LocationError(path: path, reason: .missing)
        }
    }
}

fileprivate extension Storage {
    var attributes: [FileAttributeKey : Any] {
        return (try? fileManager.attributesOfItem(atPath: path)) ?? [:]
    }

    func makeParentPath(for path: String) -> String? {
        guard path != "/" else { return nil }
        let url = URL(fileURLWithPath: path)
        let components = url.pathComponents.dropFirst().dropLast()
        guard !components.isEmpty else { return "/" }
        return "/" + components.joined(separator: "/") + "/"
    }

    func move(to newPath: String,
              errorReasonProvider: (Error) -> LocationErrorReason) throws {
        do {
            try fileManager.moveItem(atPath: path, toPath: newPath)

            switch LocationType.kind {
            case .file:
                path = newPath
            case .folder:
                path = newPath.appendingSuffixIfNeeded("/")
            }
        } catch {
            throw LocationError(path: path, reason: errorReasonProvider(error))
        }
    }

    func copy(to newPath: String) throws {
        do {
            try fileManager.copyItem(atPath: path, toPath: newPath)
        } catch {
            throw LocationError(path: path, reason: .copyFailed(error))
        }
    }

    func delete() throws {
        do {
            try fileManager.removeItem(atPath: path)
        } catch {
            throw LocationError(path: path, reason: .deleteFailed(error))
        }
    }
}

private extension Storage where LocationType == Folder {
    func makeChildSequence<T: Location>() -> Folder.ChildSequence<T> {
        return Folder.ChildSequence(
            folder: Folder(storage: self),
            fileManager: fileManager,
            isRecursive: false,
            includeHidden: false
        )
    }

    func subfolder(at folderPath: String) throws -> Folder {
        let folderPath = path + folderPath.removingPrefix("/")
        let storage = try Storage(path: folderPath, fileManager: fileManager)
        return Folder(storage: storage)
    }

    func file(at filePath: String) throws -> File {
        let filePath = path + filePath.removingPrefix("/")
        let storage = try Storage<File>(path: filePath, fileManager: fileManager)
        return File(storage: storage)
    }

    func createSubfolder(at folderPath: String) throws -> Folder {
        let folderPath = path + folderPath.removingPrefix("/")

        guard folderPath != path else {
            throw WriteError(path: folderPath, reason: .emptyPath)
        }

        do {
            try fileManager.createDirectory(
                atPath: folderPath,
                withIntermediateDirectories: true
            )

            let storage = try Storage(path: folderPath, fileManager: fileManager)
            return Folder(storage: storage)
        } catch {
            throw WriteError(path: folderPath, reason: .folderCreationFailed(error))
        }
    }

    func createFile(at filePath: String, contents: Data?) throws -> File {
        let filePath = path + filePath.removingPrefix("/")

        guard let parentPath = makeParentPath(for: filePath) else {
            throw WriteError(path: filePath, reason: .emptyPath)
        }

        if parentPath != path {
            do {
                try fileManager.createDirectory(
                    atPath: parentPath,
                    withIntermediateDirectories: true
                )
            } catch {
                throw WriteError(path: parentPath, reason: .folderCreationFailed(error))
            }
        }

        guard fileManager.createFile(atPath: filePath, contents: contents),
              let storage = try? Storage<File>(path: filePath, fileManager: fileManager) else {
            throw WriteError(path: filePath, reason: .fileCreationFailed)
        }

        return File(storage: storage)
    }
}

// MARK: - Files

/// Type that represents a file on disk. You can either reference an existing
/// file by initializing an instance with a `path`, or you can create new files
/// using the various `createFile...` APIs available on `Folder`.
public struct File: Location {
    public let storage: Storage<File>

    public init(storage: Storage<File>) {
        self.storage = storage
    }
}

public extension File {
    static var kind: LocationKind {
        return .file
    }

    /// Write a new set of binary data into the file, replacing its current contents.
    /// - parameter data: The binary data to write.
    /// - throws: `WriteError` in case the operation couldn't be completed.
    func write(_ data: Data) throws {
        do {
            try data.write(to: url)
        } catch {
            throw WriteError(path: path, reason: .writeFailed(error))
        }
    }

    /// Write a new string into the file, replacing its current contents.
    /// - parameter string: The string to write.
    /// - parameter encoding: The encoding of the string (default: `UTF8`).
    /// - throws: `WriteError` in case the operation couldn't be completed.
    func write(_ string: String, encoding: String.Encoding = .utf8) throws {
        guard let data = string.data(using: encoding) else {
            throw WriteError(path: path, reason: .stringEncodingFailed(string))
        }

        return try write(data)
    }

    /// Append a set of binary data to the file's existing contents.
    /// - parameter data: The binary data to append.
    /// - throws: `WriteError` in case the operation couldn't be completed.
    func append(_ data: Data) throws {
        do {
            let handle = try FileHandle(forWritingTo: url)
            handle.seekToEndOfFile()
            handle.write(data)
            handle.closeFile()
        } catch {
            throw WriteError(path: path, reason: .writeFailed(error))
        }
    }

    /// Append a string to the file's existing contents.
    /// - parameter string: The string to append.
    /// - parameter encoding: The encoding of the string (default: `UTF8`).
    /// - throws: `WriteError` in case the operation couldn't be completed.
    func append(_ string: String, encoding: String.Encoding = .utf8) throws {
        guard let data = string.data(using: encoding) else {
            throw WriteError(path: path, reason: .stringEncodingFailed(string))
        }

        return try append(data)
    }

    /// Read the contents of the file as binary data.
    /// - throws: `ReadError` if the file couldn't be read.
    func read() throws -> Data {
        do { return try Data(contentsOf: url) }
        catch { throw ReadError(path: path, reason: .readFailed(error)) }
    }

    /// Read the contents of the file as a string.
    /// - parameter encoding: The encoding to decode the file's data using (default: `UTF8`).
    /// - throws: `ReadError` if the file couldn't be read, or if a string couldn't
    ///   be decoded from the file's contents.
    func readAsString(encodedAs encoding: String.Encoding = .utf8) throws -> String {
        guard let string = try String(data: read(), encoding: encoding) else {
            throw ReadError(path: path, reason: .stringDecodingFailed)
        }

        return string
    }

    /// Read the contents of the file as an integer.
    /// - throws: `ReadError` if the file couldn't be read, or if the file's
    ///   contents couldn't be converted into an integer.
    func readAsInt() throws -> Int {
        let string = try readAsString()

        guard let int = Int(string) else {
            throw ReadError(path: path, reason: .notAnInt(string))
        }

        return int
    }
}

#if canImport(AppKit) && !targetEnvironment(macCatalyst)

import AppKit

public extension File {
    /// Open the file.
    func open() {
        NSWorkspace.shared.openFile(path)
    }
}

#endif

// MARK: - Folders

/// Type that represents a folder on disk. You can either reference an existing
/// folder by initializing an instance with a `path`, or you can create new
/// subfolders using this type's various `createSubfolder...` APIs.
public struct Folder: Location {
    public let storage: Storage<Folder>

    public init(storage: Storage<Folder>) {
        self.storage = storage
    }
}

public extension Folder {
    /// A sequence of child locations contained within a given folder.
    /// You obtain an instance of this type by accessing either `files`
    /// or `subfolders` on a `Folder` instance.
    struct ChildSequence<Child: Location>: Sequence {
        fileprivate let folder: Folder
        fileprivate let fileManager: FileManager
        fileprivate var isRecursive: Bool
        fileprivate var includeHidden: Bool

        public func makeIterator() -> ChildIterator<Child> {
            return ChildIterator(
                folder: folder,
                fileManager: fileManager,
                isRecursive: isRecursive,
                includeHidden: includeHidden,
                reverseTopLevelTraversal: false
            )
        }
    }

    /// The type of iterator used by `ChildSequence`. You don't interact
    /// with this type directly. See `ChildSequence` for more information.
    struct ChildIterator<Child: Location>: IteratorProtocol {
        private let folder: Folder
        private let fileManager: FileManager
        private let isRecursive: Bool
        private let includeHidden: Bool
        private let reverseTopLevelTraversal: Bool
        private lazy var itemNames = loadItemNames()
        private var index = 0
        private var nestedIterators = [ChildIterator<Child>]()

        fileprivate init(folder: Folder,
                         fileManager: FileManager,
                         isRecursive: Bool,
                         includeHidden: Bool,
                         reverseTopLevelTraversal: Bool) {
            self.folder = folder
            self.fileManager = fileManager
            self.isRecursive = isRecursive
            self.includeHidden = includeHidden
            self.reverseTopLevelTraversal = reverseTopLevelTraversal
        }

        public mutating func next() -> Child? {
            guard index < itemNames.count else {
                guard var nested = nestedIterators.first else {
                    return nil
                }

                guard let child = nested.next() else {
                    nestedIterators.removeFirst()
                    return next()
                }

                nestedIterators[0] = nested
                return child
            }

            let name = itemNames[index]
            index += 1

            if !includeHidden {
                guard !name.hasPrefix(".") else { return next() }
            }

            let childPath = folder.path + name.removingPrefix("/")
            let childStorage = try? Storage<Child>(path: childPath, fileManager: fileManager)
            let child = childStorage.map(Child.init)

            if isRecursive {
                let childFolder = (child as? Folder) ?? (try? Folder(
                    storage: Storage(path: childPath, fileManager: fileManager)
                ))

                if let childFolder = childFolder {
                    let nested = ChildIterator(
                        folder: childFolder,
                        fileManager: fileManager,
                        isRecursive: true,
                        includeHidden: includeHidden,
                        reverseTopLevelTraversal: false
                    )

                    nestedIterators.append(nested)
                }
            }

            return child ?? next()
        }

        private mutating func loadItemNames() -> [String] {
            let contents = try? fileManager.contentsOfDirectory(atPath: folder.path)
            let names = contents?.sorted() ?? []
            return reverseTopLevelTraversal ? names.reversed() : names
        }
    }
}

extension Folder.ChildSequence: CustomStringConvertible {
    public var description: String {
        return lazy.map({ $0.description }).joined(separator: "\n")
    }
}

public extension Folder.ChildSequence {
    /// Return a new instance of this sequence that'll traverse the folder's
    /// contents recursively, in a breadth-first manner. Complexity: `O(1)`.
    var recursive: Folder.ChildSequence<Child> {
        var sequence = self
        sequence.isRecursive = true
        return sequence
    }

    /// Return a new instance of this sequence that'll include all hidden
    /// (dot) files when traversing the folder's contents. Complexity: `O(1)`.
    var includingHidden: Folder.ChildSequence<Child> {
        var sequence = self
        sequence.includeHidden = true
        return sequence
    }

    /// Count the number of locations contained within this sequence.
    /// Complexity: `O(N)`.
    func count() -> Int {
        return reduce(0) { count, _ in count + 1 }
    }

    /// Gather the names of all of the locations contained within this sequence.
    /// Complexity: `O(N)`.
    func names() -> [String] {
        return map { $0.name }
    }

    /// Return the last location contained within this sequence.
    /// Complexity: `O(N)`.
    func last() -> Child? {
        var iterator = Iterator(
            folder: folder,
            fileManager: fileManager,
            isRecursive: isRecursive,
            includeHidden: includeHidden,
            reverseTopLevelTraversal: !isRecursive
        )

        guard isRecursive else { return iterator.next() }

        var child: Child?

        while let nextChild = iterator.next() {
            child = nextChild
        }

        return child
    }

    /// Return the first location contained within this sequence.
    /// Complexity: `O(1)`.
    var first: Child? {
        var iterator = makeIterator()
        return iterator.next()
    }

    /// Move all locations within this sequence to a new parent folder.
    /// - parameter folder: The folder to move all locations to.
    /// - throws: `LocationError` if the move couldn't be completed.
    func move(to folder: Folder) throws {
        try forEach { try $0.move(to: folder) }
    }

    /// Delete all of the locations within this sequence. All items will
    /// be permanently deleted. Use with caution.
    /// - throws: `LocationError` if an item couldn't be deleted. Note that
    ///   all items deleted up to that point won't be recovered.
    func delete() throws {
        try forEach { try $0.delete() }
    }
}

public extension Folder {
    static var kind: LocationKind {
        return .folder
    }

    /// The folder that the program is currently operating in.
    static var current: Folder {
        return try! Folder(path: "")
    }

    /// The root folder of the file system.
    static var root: Folder {
        return try! Folder(path: "/")
    }

    /// The current user's Home folder.
    static var home: Folder {
        return try! Folder(path: "~")
    }

    /// The system's temporary folder.
    static var temporary: Folder {
        return try! Folder(path: NSTemporaryDirectory())
    }

    /// A sequence containing all of this folder's subfolders. Initially
    /// non-recursive, use `recursive` on the returned sequence to change that.
    var subfolders: ChildSequence<Folder> {
        return storage.makeChildSequence()
    }

    /// A sequence containing all of this folder's files. Initially
    /// non-recursive, use `recursive` on the returned sequence to change that.
    var files: ChildSequence<File> {
        return storage.makeChildSequence()
    }

    /// Return a subfolder at a given path within this folder.
    /// - parameter path: A relative path within this folder.
    /// - throws: `LocationError` if the subfolder couldn't be found.
    func subfolder(at path: String) throws -> Folder {
        return try storage.subfolder(at: path)
    }

    /// Return a subfolder with a given name.
    /// - parameter name: The name of the subfolder to return.
    /// - throws: `LocationError` if the subfolder couldn't be found.
    func subfolder(named name: String) throws -> Folder {
        return try storage.subfolder(at: name)
    }

    /// Return whether this folder contains a subfolder at a given path.
    /// - parameter path: The relative path of the subfolder to look for.
    func containsSubfolder(at path: String) -> Bool {
        return (try? subfolder(at: path)) != nil
    }

    /// Return whether this folder contains a subfolder with a given name.
    /// - parameter name: The name of the subfolder to look for.
    func containsSubfolder(named name: String) -> Bool {
        return (try? subfolder(named: name)) != nil
    }

    /// Create a new subfolder at a given path within this folder. In case
    /// the intermediate folders between this folder and the new one don't
    /// exist, those will be created as well. This method throws an error
    /// if a folder already exists at the given path.
    /// - parameter path: The relative path of the subfolder to create.
    /// - throws: `WriteError` if the operation couldn't be completed.
    @discardableResult
    func createSubfolder(at path: String) throws -> Folder {
        return try storage.createSubfolder(at: path)
    }

    /// Create a new subfolder with a given name. This method throws an error
    /// if a subfolder with the given name already exists.
    /// - parameter name: The name of the subfolder to create.
    /// - throws: `WriteError` if the operation couldn't be completed.
    @discardableResult
    func createSubfolder(named name: String) throws -> Folder {
        return try storage.createSubfolder(at: name)
    }

    /// Create a new subfolder at a given path within this folder. In case
    /// the intermediate folders between this folder and the new one don't
    /// exist, those will be created as well. If a folder already exists at
    /// the given path, then it will be returned without modification.
    /// - parameter path: The relative path of the subfolder.
    /// - throws: `WriteError` if a new folder couldn't be created.
    @discardableResult
    func createSubfolderIfNeeded(at path: String) throws -> Folder {
        return try (try? subfolder(at: path)) ?? createSubfolder(at: path)
    }

    /// Create a new subfolder with a given name. If a subfolder with the given
    /// name already exists, then it will be returned without modification.
    /// - parameter name: The name of the subfolder.
    /// - throws: `WriteError` if a new folder couldn't be created.
    @discardableResult
    func createSubfolderIfNeeded(withName name: String) throws -> Folder {
        return try (try? subfolder(named: name)) ?? createSubfolder(named: name)
    }

    /// Return a file at a given path within this folder.
    /// - parameter path: A relative path within this folder.
    /// - throws: `LocationError` if the file couldn't be found.
    func file(at path: String) throws -> File {
        return try storage.file(at: path)
    }

    /// Return a file within this folder with a given name.
    /// - parameter name: The name of the file to return.
    /// - throws: `LocationError` if the file couldn't be found.
    func file(named name: String) throws -> File {
        return try storage.file(at: name)
    }

    /// Return whether this folder contains a file at a given path.
    /// - parameter path: The relative path of the file to look for.
    func containsFile(at path: String) -> Bool {
        return (try? file(at: path)) != nil
    }

    /// Return whether this folder contains a file with a given name.
    /// - parameter name: The name of the file to look for.
    func containsFile(named name: String) -> Bool {
        return (try? file(named: name)) != nil
    }

    /// Create a new file at a given path within this folder. In case
    /// the intermediate folders between this folder and the new file don't
    /// exist, those will be created as well. This method throws an error
    /// if a file already exists at the given path.
    /// - parameter path: The relative path of the file to create.
    /// - parameter contents: The initial `Data` that the file should contain.
    /// - throws: `WriteError` if the operation couldn't be completed.
    @discardableResult
    func createFile(at path: String, contents: Data? = nil) throws -> File {
        return try storage.createFile(at: path, contents: contents)
    }

    /// Create a new file with a given name. This method throws an error
    /// if a file with the given name already exists.
    /// - parameter name: The name of the file to create.
    /// - parameter contents: The initial `Data` that the file should contain.
    /// - throws: `WriteError` if the operation couldn't be completed.
    @discardableResult
    func createFile(named fileName: String, contents: Data? = nil) throws -> File {
        return try storage.createFile(at: fileName, contents: contents)
    }

    /// Create a new file at a given path within this folder. In case
    /// the intermediate folders between this folder and the new file don't
    /// exist, those will be created as well. If a file already exists at
    /// the given path, then it will be returned without modification.
    /// - parameter path: The relative path of the file.
    /// - parameter contents: The initial `Data` that any newly created file
    ///   should contain. Will only be evaluated if needed.
    /// - throws: `WriteError` if a new file couldn't be created.
    @discardableResult
    func createFileIfNeeded(at path: String,
                            contents: @autoclosure () -> Data? = nil) throws -> File {
        return try (try? file(at: path)) ?? createFile(at: path, contents: contents())
    }

    /// Create a new file with a given name. If a file with the given
    /// name already exists, then it will be returned without modification.
    /// - parameter name: The name of the file.
    /// - parameter contents: The initial `Data` that any newly created file
    ///   should contain. Will only be evaluated if needed.
    /// - throws: `WriteError` if a new file couldn't be created.
    @discardableResult
    func createFileIfNeeded(withName name: String,
                            contents: @autoclosure () -> Data? = nil) throws -> File {
        return try (try? file(named: name)) ?? createFile(named: name, contents: contents())
    }

    /// Return whether this folder contains a given location as a direct child.
    /// - parameter location: The location to find.
    func contains<T: Location>(_ location: T) -> Bool {
        switch T.kind {
        case .file: return containsFile(named: location.name)
        case .folder: return containsSubfolder(named: location.name)
        }
    }

    /// Move the contents of this folder to a new parent
    /// - parameter folder: The new parent folder to move this folder's contents to.
    /// - parameter includeHidden: Whether hidden files should be included (default: `false`).
    /// - throws: `LocationError` if the operation couldn't be completed.
    func moveContents(to folder: Folder, includeHidden: Bool = false) throws {
        var files = self.files
        files.includeHidden = includeHidden
        try files.move(to: folder)

        var folders = subfolders
        folders.includeHidden = includeHidden
        try folders.move(to: folder)
    }

    /// Empty this folder, permanently deleting all of its contents. Use with caution.
    /// - parameter includeHidden: Whether hidden files should also be deleted (default: `false`).
    /// - throws: `LocationError` if the operation couldn't be completed.
    func empty(includingHidden includeHidden: Bool = false) throws {
        var files = self.files
        files.includeHidden = includeHidden
        try files.delete()

        var folders = subfolders
        folders.includeHidden = includeHidden
        try folders.delete()
    }
    
    func isEmpty(includingHidden includeHidden: Bool = false) -> Bool {
        var files = self.files
        files.includeHidden = includeHidden
        
        if files.first != nil {
            return false
        }

        var folders = subfolders
        folders.includeHidden = includeHidden
        return folders.first == nil
    }
}

#if os(iOS) || os(tvOS) || os(macOS)
public extension Folder {
    /// Resolve a folder that matches a search path within a given domain.
    /// - parameter searchPath: The directory path to search for.
    /// - parameter domain: The domain to search in.
    /// - parameter fileManager: Which file manager to search using.
    /// - throws: `LocationError` if no folder could be resolved.
    static func matching(
        _ searchPath: FileManager.SearchPathDirectory,
        in domain: FileManager.SearchPathDomainMask = .userDomainMask,
        resolvedBy fileManager: FileManager = .default
    ) throws -> Folder {
        let urls = fileManager.urls(for: searchPath, in: domain)

        guard let match = urls.first else {
            throw LocationError(
                path: "",
                reason: .unresolvedSearchPath(searchPath, domain: domain)
            )
        }

        return try Folder(storage: Storage(
            path: match.relativePath,
            fileManager: fileManager
        ))
    }

    /// The current user's Documents folder
    static var documents: Folder? {
        return try? .matching(.documentDirectory)
    }

    /// The current user's Library folder
    static var library: Folder? {
        return try? .matching(.libraryDirectory)
    }
}
#endif

// MARK: - Errors

/// Error type thrown by all of Files' throwing APIs.
public struct FilesError<Reason>: Error {
    /// The absolute path that the error occured at.
    public var path: String
    /// The reason that the error occured.
    public var reason: Reason

    /// Initialize an instance with a path and a reason.
    /// - parameter path: The absolute path that the error occured at.
    /// - parameter reason: The reason that the error occured.
    public init(path: String, reason: Reason) {
        self.path = path
        self.reason = reason
    }
}

extension FilesError: CustomStringConvertible {
    public var description: String {
        return """
        Files encountered an error at '\(path)'.
        Reason: \(reason)
        """
    }
}

/// Enum listing reasons that a location manipulation could fail.
public enum LocationErrorReason {
    /// The location couldn't be found.
    case missing
    /// An empty path was given when refering to a file.
    case emptyFilePath
    /// The user attempted to rename the file system's root folder.
    case cannotRenameRoot
    /// A rename operation failed with an underlying system error.
    case renameFailed(Error)
    /// A move operation failed with an underlying system error.
    case moveFailed(Error)
    /// A copy operation failed with an underlying system error.
    case copyFailed(Error)
    /// A delete operation failed with an underlying system error.
    case deleteFailed(Error)
    /// A search path couldn't be resolved within a given domain.
    case unresolvedSearchPath(
        FileManager.SearchPathDirectory,
        domain: FileManager.SearchPathDomainMask
    )
}

/// Enum listing reasons that a write operation could fail.
public enum WriteErrorReason {
    /// An empty path was given when writing or creating a location.
    case emptyPath
    /// A folder couldn't be created because of an underlying system error.
    case folderCreationFailed(Error)
    /// A file couldn't be created.
    case fileCreationFailed
    /// A file couldn't be written to because of an underlying system error.
    case writeFailed(Error)
    /// Failed to encode a string into binary data.
    case stringEncodingFailed(String)
}

/// Enum listing reasons that a read operation could fail.
public enum ReadErrorReason {
    /// A file couldn't be read because of an underlying system error.
    case readFailed(Error)
    /// Failed to decode a given set of data into a string.
    case stringDecodingFailed
    /// Encountered a string that doesn't contain an integer.
    case notAnInt(String)
}

/// Error thrown by location operations - such as find, move, copy and delete.
public typealias LocationError = FilesError<LocationErrorReason>
/// Error thrown by write operations - such as file/folder creation.
public typealias WriteError = FilesError<WriteErrorReason>
/// Error thrown by read operations - such as when reading a file's contents.
public typealias ReadError = FilesError<ReadErrorReason>

// MARK: - Private system extensions

private extension FileManager {
    func locationExists(at path: String, kind: LocationKind) -> Bool {
        var isFolder: ObjCBool = false

        guard fileExists(atPath: path, isDirectory: &isFolder) else {
            return false
        }

        switch kind {
        case .file: return !isFolder.boolValue
        case .folder: return isFolder.boolValue
        }
    }
}

private extension String {
    func removingPrefix(_ prefix: String) -> String {
        guard hasPrefix(prefix) else { return self }
        return String(dropFirst(prefix.count))
    }

    func removingSuffix(_ suffix: String) -> String {
        guard hasSuffix(suffix) else { return self }
        return String(dropLast(suffix.count))
    }

    func appendingSuffixIfNeeded(_ suffix: String) -> String {
        guard !hasSuffix(suffix) else { return self }
        return appending(suffix)
    }
}