投稿記事の Markdown 中のコードをファイルとして取り出す （Swift版）

「[投稿記事の Markdown 中のコードをファイルとして取り出す](https://qiita.com/ikiuo/items/b037262fe91172dc6693)」の Swift 版です。

記事に、全体は小さいけど、多数のファイルで構成されたソースコードを入れるとき

- 記事にするときの手間
- 利用するときの手間

を軽減する目的で作りました。Swift の Command Line Tool です。

<details><summary>ファイルから Markdown へ</summary><div>

```swift:f2m.swift
import Foundation
import AppKit

// ############

var suffix_map = Dictionary<String, String>()
let code_suffix_table = [
    ["applescript", "scpt"],
    ["ada", "ada"],
    ["awk", "awk"],
    ["bash", "bash"],
    ["bat", "bat"],
    ["bpf", "bpf"],
    ["cl", "cl"],
    ["css", "css"],
    ["csharp", "cs"],
    ["cuda", "cu"],
    ["c++", "c", " cc", " cpp", " c++", " hpp", " h++"],
    ["d", "d"],
    ["diff", "diff"],
    ["ecl", "ecl"],
    ["elisp", "el"],
    ["elm", "elm"],
    ["email", "eml"],
    ["eps", "eps"],
    ["erl", "erl"],
    ["erb", "erb"],
    ["fea", "fea"],
    ["ff", "ff"],
    ["fortran", "f", " f90", " f95", " f03", " f15"],
    ["fsharp", "fs"],
    ["gd", "gd"],
    ["glsl", "glsl", " glslv", " frag", " vert"],
    ["go", "go"],
    ["groovy", "groovy", " gvy", " gy", " gsh"],
    ["haml", "haml"],
    ["hack", "hack"],
    ["hcl", "hcl"],
    ["hlsl", "hlsl"],
    ["hql", "hql"],
    ["hs", "hs"],
    ["html", "html"],
    ["hx", "hx"],
    ["hy", "hy"],
    ["idlang", "idl"],
    ["java", "java"],
    ["js", "js"],
    ["json", "json'"],
    ["ksh", "ksh"],
    ["latex", "latex"],
    ["lhs", "lhs"],
    ["llvm", "ll"],
    ["lua", "lua"],
    ["make", "mak", " mk"],
    ["mathematica", " nb"],
    ["matlab", "mat"],
    ["md", "md"],
    ["mf", "mf"],
    ["minizinc", "mzn"],
    ["mkd", "mkd"],
    ["moon", "moon"],
    ["nim", "nim"],
    ["objcpp", "mm"],
    ["ocaml", " mli"],
    ["pascal", "p", " pas", " pp"],
    ["patch", "patch"],
    ["php", "php"],
    ["pl", "pl"],
    ["plist", "plist"],
    ["ps", "ps"],
    ["py", "py"],
    ["pyx", "pyx"],
    ["rb", "rb"],
    ["rs", "rs"],
    ["saas", "saas"],
    ["sas", "sas"],
    ["scala", "scala"],
    ["scss", "scss"],
    ["sed", "sed"],
    ["sh", "sh"],
    ["sql", "sql"],
    ["st", "st"],
    ["swift", "swift"],
    ["tcl", "tcl"],
    ["tex", "tex"],
    ["toml", "toml"],
    ["ts", "ts"],
    ["tsx", "tsx"],
    ["vb", "vb"],
    ["xml", "xml"],
    ["yaml", "yaml", " yml"],
    ["yang", "yang"]
]

func make_suffix_map()
{
    for cs in code_suffix_table {
        let code = cs[0]
        for sfx in cs[1..<cs.count] {
            suffix_map[sfx] = code
        }
    }
}
make_suffix_map()

// ############

var cmd_args = CommandLine.arguments
var program = cmd_args.removeFirst()
var program_paths = program.split(separator: "/")
var program_name = program_paths.last
var output_file = "-"

func usage() -> Never {
    print("""
        使用方法: \(String(program_name!)) [オプション] ファイル [ファイル...]
        オプション:
            -o FILE 出力するファイルを指定する（デフォルト: 標準出力 "-"）
            -X      クリップボードへコピーする
        """)
    exit(1)
}

func cmdArgParse() -> Bool {
    var flag_pastboard = false
    while cmd_args.count > 0 {
        var arg = cmd_args[0]
        if arg.removeFirst() != "-" {
            break
        }
        if arg.count == 0 {
            break
        }
        cmd_args.removeFirst()
        while arg.count > 0 {
            switch (arg.removeFirst())
            {
            case "o":
                output_file = cmd_args.removeFirst()
            case "X":
                flag_pastboard = true
            default:
                usage()
            }
        }
    }
    if cmd_args.count == 0 {
        usage()
    }
    return flag_pastboard
}

func main() -> Int32 {
    let pastboard = cmdArgParse()
    var md = ""
    var md_sep = ""
    for fn in cmd_args {
        let data = FileManager.default.contents(atPath: fn)
        if data == nil {
            print("読み込み失敗: \(fn)")
            return 2
        }
        let fext = URL(fileURLWithPath: fn).pathExtension
        let code = suffix_map[fext] ?? "text"
        md += md_sep
        md += "```\(code):\(fn)\n"
        let src = String(data: data!, encoding: .utf8)!
        for var line in src.components(separatedBy: .newlines) {
            if line.prefix(3) == "```" {
                line = "\\" + line
            }
            md += line + "\n"
        }
        md += "```\n"
        md_sep = "\n"
    }

    if pastboard {
        NSPasteboard.general.clearContents()
        if !NSPasteboard.general.setString(md, forType: .string) {
            print("クリップボードへのテキスト設定に失敗しました")
            return 2
        }
        return 0
    }
    
    if output_file == "-" {
        print(md)
    } else if !FileManager.default.createFile(atPath: output_file, contents: md.data(using: .utf8)) {
        print("書き込み失敗: \(output_file)")
        return 2
    }
    return 0
}
exit(main())

```

</div></details>

<details><summary>Markdown からファイルへ</summary><div>

```swift:m2f.swift
import Foundation
import AppKit

// ############

var auto_filename = false
var output_dir = "."
var exclude_modes: [String] = []

// ############

class MyWebLoader {
    var sem = DispatchSemaphore(value: 0)
    var error: Error?
    var response: HTTPURLResponse?
    var data: Data?
    init(_ string: String) {
        let url = URL(string: string)!
        let req = URLRequest(url: url)
        let task = URLSession.shared.dataTask(with: req) { dat, res, err in
            self.error = err
            self.response = res as? HTTPURLResponse
            self.data = dat
            self.sem.signal()
        }
        task.resume()
        sem.wait()
    }
}

// ############

struct FileData {
    var type = ""
    var name = ""
    var data = ""
}

func parseMarkdown(_ md: String) -> [FileData] {
    var files: [FileData] = []
    var file = FileData()
    var avail = false
    var code = false
    var index = 0
    for var line in md.components(separatedBy: .newlines) {
        if line.prefix(4) == "\\```" {
            if code {
                line.removeFirst()
                file.data += "\(line)\n"
            }
            continue
        }
        if  line.prefix(3) != "```" {
            if code {
                file.data += "\(line)\n"
            }
            continue
        }
        code = !code
        if !code {
            if avail {
                files.append(file)
            }
            file = FileData()
            avail = false
            continue
        }
        let cf = line.dropFirst(3)
        let cfs = cf.split(separator: ":", maxSplits: 1)
        let ct = String(cfs[0]).trimmingCharacters(in: .whitespacesAndNewlines)
        if ct.count == 0 {
            continue
        }
        file.type = ct
        if cfs.count > 1 {
            var fn = String(cfs[1]).trimmingCharacters(in: .whitespacesAndNewlines)
            fn = fn.replacingOccurrences(of: "&#x20;", with: " ")
            fn = fn.replacingOccurrences(of: "&#x26;", with: "&")
            avail = !exclude_modes.contains(ct)
            file.name = fn
        } else {
            avail = auto_filename
            file.name = "code:\(index).\(ct)"
            index += 1
        }
    }
    return files
}

// ############

func readMarkdown(_ input: String) -> String? {
    if input == "-" {
        // 標準入力からの読み込み
        return String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8)
    }
    let input_s = input.split(separator: ":")
    switch (input_s[0]) {
    case "http":
        fallthrough
    case "https":
        // Web からの読み込み
        let hl = MyWebLoader(input)
        if hl.error != nil {
            print("読み込み失敗: \(input)")
            print("エラー: \(hl.error!.localizedDescription)")
            return nil
        }
        if hl.response != nil && !(200...209).contains(hl.response!.statusCode)  {
            print("読み込み失敗: \(input)")
            print("応答: \(hl.response!.statusCode)")
            return nil
        }
        return String(data: hl.data!, encoding: .utf8)!
    default:
        // ファイルからの読み込み
        return String(data: FileManager.default.contents(atPath: input)!, encoding: .utf8)
    }
}

// ############

var cmd_args = CommandLine.arguments
var program = cmd_args.removeFirst()
var program_paths = program.split(separator: "/")
var program_name = program_paths.last

func usage() -> Never {
    print("""
        使用方法: \(String(program_name!)) [オプション] {-X|入力}
        オプション:
            -c      console も対象にする
            -m      math も対象にする
            -n      ファイル名なしも対象にする
            -o DIR  出力するディレクトリを指定する（デフォルト: "."）
            -X      クリップボードのテキストを使用する

        入力は以下のどれか
            http(s): で始まる Web サイトのアドレス
            ファイル名 または 標準入力 "-"
        """)
    exit(1)
}

func cmdArgParse() -> Bool {
    var flag_console = false
    var flag_math = false
    var flag_pastboard = false
    while cmd_args.count > 0 {
        var arg = cmd_args[0]
        if arg.removeFirst() != "-" {
            break
        }
        if arg.count == 0 {
            break
        }
        cmd_args.removeFirst()
        while arg.count > 0 {
            switch (arg.removeFirst())
            {
            case "c":
                flag_console = true
            case "m":
                flag_math = true
            case "n":
                auto_filename = true
            case "o":
                output_dir = cmd_args.removeFirst()
            case "X":
                flag_pastboard = true
            default:
                usage()
            }
        }
    }
    if !flag_console {
        exclude_modes.append("console")
    }
    if !flag_math {
        exclude_modes.append("math")
    }
    if output_dir.last != "/" {
        output_dir += "/"
    }
    return flag_pastboard
}

func main() -> Int32 {
    var md: String?
    if cmdArgParse() {
        if cmd_args.count > 0 {
            usage()
        }
        md = NSPasteboard.general.string(forType: .string)
        if md == nil {
            print("クリップボードからテキストの取得に失敗しました")
            return 2
        }
    } else {
        if cmd_args.count < 1 {
            usage()
        }
        let input = cmd_args[0]
        md = readMarkdown(input)
        if md == nil {
            print("読み込み失敗: \(input)")
            return 2
        }
    }
    var ecd: Int32 = 0
    let pmd = parseMarkdown(md!)
    for md in pmd {
        let path = output_dir + md.name
        let pdir = (path as NSString).deletingLastPathComponent
        print(path)
        try! FileManager.default.createDirectory(atPath: pdir, withIntermediateDirectories: true)
        if !FileManager.default.createFile(atPath: path, contents: md.data.data(using: .utf8)) {
            print("書き込み失敗: \(path)")
            ecd = 2
        }
    }
    return ecd
}
exit(main())
```

</div></details>