Swift 脚本方案简析

几年前,”人生苦短,我用 Python” 这种说法还颇为流行,受此影响,我也试着 Python 写了几个脚本。Python 作为动态语言,写起来确实是行云流水,酣畅淋漓。可惜好景不长,几个月后我再次打开这些 Python 脚本,看着这些代码的时候不禁发出了哲学三问:“你是什么类型?你在哪里被定义?你在哪里被使用”?(当然这和我薄弱的 Python 熟练度有关)。这时候我想,我能否使用强类型的 Swift 编写脚本呢?答案是肯定的,而且已经有不少开源大佬都在这条路上作出了贡献。

本文基于 macOS & Swift 5.1 编写。

先驱:Marathon

相信每一位学习过 Swift 的同学都或多或少从 Swift by Sundell 博客中获益过。 Marathon 是高产的 Sundell 基于 Swift Package Mananger 开发的帮助开发者开发命令行的工具,提供了依赖管理、编译、安装等功能。

作为示例,让我们来编写一个脚本,在终端中用浏览器打开当前目录 Git 仓库的地址。举个🌰,在 Alamofire 的目录下执行这个脚本,就会在浏览器中打开 https://github.com/Alamofire/Alamofire.

首先使用 marathon 新建一个脚本:

marathon create ogu # ogu is short for 'open git url'

Marathon 内部在 ~/.marathon/Script/Cache 目录下创建了一个新的 Swift Package,后续的依赖管理、编译都基于这个 Package 展开。

接着开始编辑脚本:

marathon edit ogu

键入实际的代码:

import Foundation
import ShellOut // marathon:https://github.com/JohnSundell/ShellOut.git
import Rainbow // marathon:https://github.com/onevcat/Rainbow.git
import Files // marathon:https://github.com/JohnSundell/Files.git

func main() {
    let notAGitRepoDescription = "fatal: not a git repository (or any of the parent directories): .git"
    
    guard let origins = try? shellOut(to: "git remote") else {
        print(">>> Failed to parse git remote address")
        return
    }

    guard origins != notAGitRepoDescription else {
        print(">>> Not a git repo")
        return
    }

    let originList = origins.components(separatedBy: "\n")
    if originList.isEmpty {
        print(">>> Don't have remote yet")
    } else if originList.count == 1, let onlyOrigin = originList.first {
        openURL(from: onlyOrigin)
    } else {
        for (i, origin) in originList.enumerated() {
            print("\(i): \(origin)")
        }
        print(">>> Please select one of the remote to open: [0..<\(originList.count)]")
        
        guard let input = readLine(),
            let selectedIndex = Int(input),
            0..<originList.count ~= selectedIndex else {
                print(">>> Not a validate input")
                return
        }
        openURL(from: originList[selectedIndex])
    }
}

func openURL(from origin: String) {
    guard let gitAddress = try? shellOut(to: "git remote get-url \(origin)"),
        let address = convertGitAddressToWebURL(from: gitAddress) else {
            print(">>> Failed to parse git remote address")
            return
    }
    
    print(">>> Opening remote [\(origin.red)]: " + address.underline)
    do {
        try shellOut(to: "open \(address)")
    } catch {
        print("Failed to open \(address), error: \(error.localizedDescription)")
    }
}

func convertGitAddressToWebURL(from urlString: String) -> String? {
    if urlString.hasPrefix("https:") && urlString.hasSuffix(".git") {
        return String(urlString.prefix(urlString.count - 4))
    } else if urlString.hasPrefix("git@") && urlString.hasSuffix(".git") {
        let webAddress = urlString.replacingOccurrences(of: ":", with: "/")
            .replacingOccurrences(of: "git@", with: "https://")
        return String(webAddress.prefix(webAddress.count - 4))
    }
    return nil
}

main()

可以看到,和普通的 Swift 代码唯一的不同支出就是 import 之后的注释,它让 marathon 知道这个依赖的地址。

编写完成后,通过 marathon run ogu --verbose 执行这个脚本。确认实现符合预期后,执行 marathon install ogu 将编译好的执行文件安装到 /usr/local/bin,这样在命令行输入 ogu 就可以直接执行我们的脚本了。

Marathon 对 SPM 的封装大幅的简化了编写脚本的难度。但目前并不推荐使用它来编写脚本,为什么呢?接着往下看。

swift-sh

homebrew 的作者 mxcl 在 Apple 短暂参与过 Swift Package Manager 的开发后,开源了 swift-sh,同样是一款帮助开发者简化脚本编写工作的工具。 在使用上,swift-sh 和 Marathon 除了命令不同外并没有很大区别。

将上面的代码替换掉头部,我们便得到了一个 swift-sh 脚本:

#!/usr/bin/swift sh

import Foundation
import ShellOut // @JohnSundell ~> 2.0.0
import Rainbow // @onevcat ~> 3.1.5
import Files // @JohnSundell ~> 4.0.0

// ...

执行 swift sh edit ogu 可以执行这个脚本。swift-sh 巧妙的利用了 swift 命令提供的子命令(subcommand)拓展特性,swift sh abc 实际执行的是 swift-sh abc

由于一些原因,目前 swift-sh 的自动补全失效 了。

执行 swift sh ogu.swift 可以直接执行这个脚本。swift-sh 内部也使用了 SPM 对依赖进行管理。

此外,swift sh 非常贴心的支持将脚本转换成一个 Swift Package:

swift sh eject ogu.swift

遗憾的是,swift-sh 并没有提供方法将编译后的可执行文件直接安装到 usr/bin/local 中,可以通过 ~/Library/Developer/swift-sh.cache/ogu/.build/release 里找到编译结果放到 usr/bin/local

Swift Package Manager

上述的两款工具都是基于 SPM 开发,那能否用 SPM 直接写脚本呢?当然可以,经过数年的开发,SPM 也日渐成熟,原先繁琐的操作得以简化,也正是这个原因,Marathon 宣布放弃维护,转而推荐开发者直接使用 SPM。

新建一个可执行文件的 Swift Package:

swift package init --type executable

核心脚本代码部分并不需要改动。类似的,我们需要以某种方式指定依赖,这一次是 Package.swift:

// swift-tools-version:5.1
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "spm",
    products: [.library(name: "MarathonDependencies", type: .dynamic, targets: ["spm"])],
    dependencies: [
        .package(url: "https://github.com/JohnSundell/Files.git", from: "4.0.0"),
        .package(url: "https://github.com/onevcat/Rainbow.git", from: "3.0.0"),
        .package(url: "https://github.com/JohnSundell/ShellOut.git", from: "2.0.0")
    ],
    targets: [
        .target(
            name: "spm",
            dependencies: ["Files", "Rainbow", "ShellOut"])
    ],
    swiftLanguageVersions: [.version("5")]
)

执行 swift package reslove 解析并获取依赖。通过 swift package generate-xcodeproj 生成一个 Xcode 工程后,可以直接用 Xcode 编辑。

记得将运行环境修改为 Mac!

可以直接使用 swift build 将 Package 编译为一个二进制文件,这个二进制文件位于当前目录下 .build/release 目录下。

swift build -c release

最后,手动将编译好的文件放入 /usr/local/bin 中就大功告成了。

install .build/release/ogu /usr/local/bin

总结

对于单文件的脚本,swift-sh 无疑是最佳的选择。而当脚本日益增大,将它转换成一个 Swift Package 维护则是一个更好的选择。

参考链接