Swift でスクリプトを書くためのファイル入出力操作まとめ

## はじめに

Swift でスクリプトを書こうとすると、標準で使いやすいファイル入出力周りのクラスやメソッドがないので辛いですよね………。そこで、処理に必要になるクラスや extension を整理してみました。


## ファイルパスの扱い

### ファイルパスの表現

[URL](https://developer.apple.com/documentation/foundation/url) クラスでパスを表現します。URL はほとんどの場合はリモートのサーバーを表すために利用しますが、ローカルのファイルに対しても利用できます。

```swift
# 絶対パス
let file1 = URL(fileURLWithPath: "/Users/taisukeh/bar.swift")

# 相対パス
let file2 = URL(fileURLWithPath: "bar.swift")

# 相対パス + ルートディレクトリ
let file3 = URL(fileURLWithPath: "bar.swift", relativeTo: URL(fileURLWithPath: "/Users/taisukeh/"))

# 明示的にディレクトリのパスであることを指定
let file4 = URL(fileURLWithPath: "/Users/taisukeh/bar", isDirectory: true)
```

### ファイルパスの結合、削除

```swift
let file1 = URL(string: "/Users/taisukeh")

# パス追加
let file2 = file1.appendingPathComponent("foo")

print(file2) // --> file:///Users/taisukeh/foo

# 拡張子追加
let file3 = file1.appendingPathExtension("foo")

print(file3) // --> file:///Users/taisukeh.foo
```

```swift
let file1 = URL(fileURLWithPath: "/Users/taisuke/foo.swift")

# 最後のパス削除
let file2 = file1.deletingLastPathComponent()

print(file2) // --> file:///Users/taisuke/

# 拡張子削除
let file3 = file1.deletingPathExtension()

print(file3) // --> file:///Users/taisuke/foo
```

### ファイルパスの分解、結合

```swift
let file1 = URL(fileURLWithPath: "/Users/taisukeh/foo.swift")

# 分割
print(file1.pathComponents) // --> ["/", "Users", "taisukeh", "foo.swift"]

# 最後のパス
print(file1.lastPathComponent) // --> foo.swift

# 拡張子
print(file1.pathExtension) // --> swift
```

分割したパスの要素からパスを再構築する機能は URL にはありません。NSStirng にあるので、以下のような extension を用意しておくと良さそうです。

```swift
extension String {
    public static func path(withComponents components: [String]) -> String {
        return NSString.path(withComponents: components)
    }
}
```

### 文字列への変換

```swift
let file1 = URL(fileURLWithPath: "/Users/taisukeh/foo.swift")

# file:// スキーム付きの絶対パス
print(file1.absoluteString) // --> file:///Users/taisukeh/foo.swift

# パス
print(file1.path) // --> /Users/taisukeh/foo.swift
```

### ディレクトリのパスの扱い

```swift
let file1 = URL(fileURLWithPath: "/foo/bar")
let file2 = URL(fileURLWithPath: "/foo/bar/")
let file3 = URL(fileURLWithPath: "/foo/bar", isDirectory: true)

print(file1.hasDirectoryPath) // --> false
print(file2.hasDirectoryPath) // --> true
print(file3.hasDirectoryPath) // --> true
```

### `..` や `.` の正規化

```swift
print(URL(fileURLWithPath: "/foo/bar/.././bar/baz.swift").standardized.path)
// --> /foo/bar/baz.swift
```

### `~` 展開

ホームディレクトリを表す　`~` を展開する機能は URL にはありません。そこで、NSString にある `expandingTildePath` を利用します。String に以下のような extension を定義しておくと良さそうです。

```swift
public extension String {
    public var expandingTildeInPath: String {
        return NSString(string: self).expandingTildeInPath
    }
}
```

## ファイルの操作

[FileManager](https://developer.apple.com/documentation/foundation/filemanager) クラスを利用します。

### ホームディレクトリ、カレントディレクトリ、一時ディレクトリ

```swift
let fm = FileManager.default

# ホームディレクトリ
print(fm.homeDirectoryForCurrentUser)

# 特定のユーザーのホームディレクトリ。ユーザーが存在しなければ nil
print(fm.homeDirectory(forUser: "tahori"))

# 一時ディレクトリ
print(fm.temporaryDirectory)

# カレントディレクトリ
print(fm.currentDirectoryPath)

# カレントディレクトリ変更
fm.changeCurrentDirectoryPath("/")
```

### ファイル作成

```swift
# 空ファイル
let created = fm.createFile(atPath: "/Users/tahori/hoge", contents: nil)

# Data でファイルの内容を指定して作成
let created = fm.createFile(atPath: "/Users/tahori/hoge", contents: "foo".data(using: .utf8))


# パーミッションを指定して作成
let r = fm.createFile(atPath: "/Users/tahori/hoge", contents: nil,
                      attributes: [
                       FileAttributeKey.posixPermissions: 0o777,
  ])
```

### ファイル削除

```swift
do {
    try fm.removeItem(atPath: "/Users/tahori/hogehoge3")
} catch let e as NSError {
    if e.code == NSFileNoSuchFileError {
        print("file not exist")
    }
}

# removeItem(at: URL) もあります
```

### ディレクトリ作成

```swift
do {
    try fm.createDirectory(atPath: "/Users/tahori/hoge5/hoge6", withIntermediateDirectories: false, attributes: [:])
} catch let e as NSError {
    if e.code == NSFileNoSuchFileError {
        print("parent dir not exist")
    }
}
```

### コピー、移動

```swift
func copyItem(at: URL, to: URL)
func copyItem(atPath: String, toPath: String)
func moveItem(at: URL, to: URL)
func moveItem(atPath: String, toPath: String)
```

### 存在確認

```swift
func fileExists(atPath: String) -> Bool
func fileExists(atPath: String, isDirectory: UnsafeMutablePointer<ObjCBool>?) -> Bool
```

### パーミッション確認

```swift
func isReadableFile(atPath: String) -> Bool
func isWritableFile(atPath: String) -> Bool
func isExecutableFile(atPath: String) -> Bool
func isDeletableFile(atPath: String) -> Bool
```

### パーミッション設定 ( chmod )

```swift
try self.setAttributes([FileAttributeKey.posixPermissions: 0o777], ofItemAtPath: "/foo/bar")
```

### Owner / Group Owner 設定

```swift
try self.setAttributes([FileAttributeKey.ownerAccountName: name], ofItemAtPath: "/foo/bar")
```

### Symbolic Link / Hard Link

```swift

# Creates a symbolic
func createSymbolicLink(at: URL, withDestinationURL: URL)
func createSymbolicLink(atPath: String, withDestinationPath: String)

# Creates a hard link
func linkItem(at: URL, to: URL)
func linkItem(atPath: String, toPath: String)

# Returns the path of the item pointed to by a symbolic link.
func destinationOfSymbolicLink(atPath: String) -> String
```
## ファイル入出力

ファイル入出力はいくつかのクラスに実装されているのですが、最も使いやすい形になっているのは FileHandle  です。
（InputStream / OutputStream クラスにも入出力の機能があります。）

### 標準エラー出力

`print`, `debugPrint` では `to:` パラメータで出力先を指定できます。値は TextOutputStream プロトコルに準拠する必要があります。標準では TextOutputStream に準拠した標準エラー出力の値はありません。

TextOutputStream に準拠した値を作っておきます。ここでは、FileHandle を準拠させています。


```swift
extension FileHandle: TextOutputStream {
    public func write(_ string: String) {
        self.write(string, encoding: .utf8)
    }
}

extension FileHandle {
    static var stderr: FileHandle = FileHandle(fileDescriptor: FileHandle.standardError.fileDescriptor)
    static var stdout: FileHandle = FileHandle(fileDescriptor: FileHandle.standardOutput.fileDescriptor)
}

print("hoge", to: &FileHandle.stderr)
```

### ファイルを開く

read, write, read and write でファイルを開きます。

```swift
init?(forReadingAtPath: String)
init(forReadingFrom: URL)
init?(forWritingAtPath: String)
init(forWritingTo: URL)
init?(forUpdatingAtPath: String)
init(forUpdating: URL)
```

### standardError / standardInput / standardOutput / nullDevice

（print に直接渡すことはできないのですが）標準入出力、エラー出力、ヌルデバイスへの FileHandle が定義されています。

```
class var standardError: FileHandle
Returns the file handle associated with the standard error file.

class var standardInput: FileHandle
Returns the file handle associated with the standard input file.

class var standardOutput: FileHandle
Returns the file handle associated with the standard output file.

class var nullDevice: FileHandle
Returns a file handle associated with a null device.
```

### 簡易入出力、seek など


read, write はごく簡単なメソッドしか用意されていません。一行入力などはないですね。

```
func close()
func offset() -> UInt64
func read(upToCount: Int) -> Data?
func readToEnd() -> Data?
func seek(toOffset: UInt64)
func seekToEnd() -> UInt64
func synchronize()
func truncate(atOffset: UInt64)
func write<T>(contentsOf: T)
```

### 一行入力


雑ですが、以下のような extension を生やしています。

```swift
extension FileHandle {
    func eachLine(callback: (String) throws -> ()) rethrows {
        try eachLine(encoding: .utf8, callback: callback)
    }

    func eachLine(encoding: String.Encoding, callback: (String) throws -> ()) rethrows {
        try eachLine(encoding: encoding, readData: { () -> Data? in
            return try self.readToEnd()
        }, callback: callback)
    }
    
    private func eachLine(encoding: String.Encoding, readData: () throws -> Data?, callback: (String) throws -> ()) rethrows {
        guard let data = try readData() else {
            return
        }
        guard let s = String(data: data, encoding: encoding) else {
            return
        }

        try callback(s)
    }
}
```

## さいごに

Server Side Swift がもっと盛り上がるとよいですね。