使用 Swift Package Manager 建立 Command line tool

作为iOS开发,我们的 CI 经常使用 Ruby 的命令行工具,像 fastlane, CocoaPods, Xcodeproj。
随着 Ruby 逐渐没落,维护成本逐渐上升。
通过 Swift Package Manager,使用 Apple Swift 语言建立 Command line tool,让团队中的iOS开发者更易于开发维护。

An example: Creating a xcode helper

使用 Swift Package Manager 创建一个示例, 用于查看 xcode 的 cache 文件。如图:


image

Creating a command-line tool

mkdir xcode-helper && cd xcode-helper
swift package init --type executable

type

  • library 创建 library。
  • executable. 创建命令行工具。

Build and run an executable product

命令行运行

swift run
> swift run
[3/3] Linking xcode-helper

* Build Completed!
Hello, world!

使用 Xcode 运行

swift package generate-xcodeproj
open *.xcodeproj

Adding dependencies

添加 apple/swift-argument-parser 来获取命令行参数。

vi Package.swift
.package(
    url: "https://github.com/apple/swift-argument-parser", 
    from: "0.4.0"
)

Include "ArgumentParser" as a dependency for your executable target:

.product(name: "ArgumentParser", package: "swift-argument-parser"),
image

Package.swift Example:

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

import PackageDescription

let package = Package(
  name: "xcode-helper",
  dependencies: [
    .package(
      url: "https://github.com/apple/swift-argument-parser",
      from: "0.4.0"
    )
  ],
  targets: [
    .target(
      name: "xcode-helper",
      dependencies: [
        .product(name: "ArgumentParser", package: "swift-argument-parser"),
      ]),
    .testTarget(
      name: "xcode-helperTests",
      dependencies: ["xcode-helper"]),
  ]
)

Installing dependencies
修改后,通过swift package update拉取依赖

swift package update

Creating the main execution command

Sources//main.swift,加入处理逻辑

vi Sources/xcode-helper/main.swift
import Foundation
import ArgumentParser

struct Constant {
  struct App {
    static let version = "0.0.1"
  }
}

@discardableResult
func shell(_ command: String) -> String {
  let task = Process()
  let pipe = Pipe()
  
  task.standardOutput = pipe
  task.standardError = pipe
  task.arguments = ["-c", command]
  task.launchPath = "/bin/zsh"
  task.launch()
  
  let data = pipe.fileHandleForReading.readDataToEndOfFile()
  let output = String(data: data, encoding: .utf8)!
  
  return output
}

struct Print {
  enum Color: String {
    case reset = "\u{001B}[0;0m"
    case black = "\u{001B}[0;30m"
    case red = "\u{001B}[0;31m"
    case green = "\u{001B}[0;32m"
    case yellow = "\u{001B}[0;33m"
    case blue = "\u{001B}[0;34m"
    case magenta = "\u{001B}[0;35m"
    case cyan = "\u{001B}[0;36m"
    case white = "\u{001B}[0;37m"
  }
  
  static func h3(_ items: Any..., separator: String = " ", terminator: String = "\n") {
    // https://stackoverflow.com/questions/39026752/swift-extending-functionality-of-print-function
    let output = items.map { "\($0)" }.joined(separator: separator)
    print("\(Color.green.rawValue)\(output)\(Color.reset.rawValue)")
  }
  
  static func h6(_ verbose: Bool, _ items: Any..., separator: String = " ", terminator: String = "\n") {
    if verbose {
      let output = items.map { "\($0)" }.joined(separator: separator)
      print("\(output)")
    }
  }
}

extension XcodeHelper {
  enum CacheFolder: String, ExpressibleByArgument, CaseIterable {
    case all
    case archives
    case simulators
    case deviceSupport
    case derivedData
    case previews
    case coreSimulatorCaches
  }
}

fileprivate extension XcodeHelper.CacheFolder {
  var paths: [String] {
    switch self {
    case .archives:
      return ["~/Library/Developer/Xcode/Archives"]
    case .simulators:
      return ["~/Library/Developer/CoreSimulator/Devices"]
    case .deviceSupport:
      return ["~/Library/Developer/Xcode"]
    case .derivedData:
      return ["~/Library/Developer/Xcode/DerivedData"]
    case .previews:
      return ["~/Library/Developer/Xcode/UserData/Previews/Simulator Devices"]
    case .coreSimulatorCaches:
      return ["~/Library/Developer/CoreSimulator/Caches/dyld"]
    case .all:
      var paths: [String] = []
      for caseValue in Self.allCases {
        if caseValue != self {
          paths.append(contentsOf: caseValue.paths)
        }
      }
      return paths
    }
  }
  
  static var suggestion: String {
    let suggestion = Self.allCases.map { caseValue in
      return caseValue.rawValue
    }.joined(separator: " | ")
    return "[ \(suggestion) ]"
  }
}

struct XcodeHelper: ParsableCommand {
  public static let configuration = CommandConfiguration(
    abstract: "Xcode helper",
    version: "xcode-helper version \(Constant.App.version)",
    subcommands: [
      Cache.self
    ]
  )
}

extension XcodeHelper {
  struct Cache: ParsableCommand {
    public static let configuration = CommandConfiguration(
      abstract: "Xcode cache helper",
      subcommands: [
        List.self
      ]
    )
  }
}

extension XcodeHelper.Cache {
  struct List: ParsableCommand {
    public static let configuration = CommandConfiguration(
      abstract: "Show Xcode cache files"
    )
    
    @Option(name: .shortAndLong, help: "The cache folder")
    private var cacheFolder: XcodeHelper.CacheFolder = .all
    
    @Flag(name: .shortAndLong, help: "Show extra logging for debugging purposes.")
    private var verbose: Bool = false
    
    func run() throws {
      Print.h3("list cache files:")
      Print.h3("------------------------")
      
      if cacheFolder == .all {
        var allCases = XcodeHelper.CacheFolder.allCases
        allCases.remove(at: allCases.firstIndex(of: .all)!)
        handleList(allCases)
      } else {
        handleList([cacheFolder])
      }
    }
    
    private func handleList(_ folders: [XcodeHelper.CacheFolder]) {
      for folder in folders {
        Print.h3(folder.rawValue)
        for path in folder.paths {
          let cmd = "du -hs \(path)"
          Print.h6(verbose, cmd)
          let output =  shell(cmd)
          print(output)
        }
      }
    }
  }
}

XcodeHelper.main()

Build and run an executable product

Get all targets
获取当前项目下所有的 targets。

python3 -c "\
import sys, json, subprocess;\
package_data = subprocess.Popen('swift package dump-package', shell=True, stdout=subprocess.PIPE).stdout.read().decode('utf-8');\
targets = json.loads(package_data)['targets'];\
target_names = list(map(lambda x: x['name'], targets));\
print(target_names)\
"

Start using command-line
使用 swift run 看下效果

swift run xcode-helper

image

Start using subcommand
为保证 xcode-helper 的扩展,实现时 cache 是子命令

swift run xcode-helper cache list
image

Writing Unit testing

Tests/Tests/Tests.swift, 添加必要的单元测试。

vi Tests/xcode-helperTests/xcode_helperTests.swift
import XCTest
import class Foundation.Bundle

extension XCTest {
  public var debugURL: URL {
    let bundleURL = Bundle(for: type(of: self)).bundleURL
    return bundleURL.lastPathComponent.hasSuffix("xctest")
      ? bundleURL.deletingLastPathComponent()
      : bundleURL
  }
  
  public func AssertExecuteCommand(
    command: String,
    expected: String? = nil,
    exitCode: Int32 = EXIT_SUCCESS,
    file: StaticString = #file, line: UInt = #line) {
    let splitCommand = command.split(separator: " ")
    let arguments = splitCommand.dropFirst().map(String.init)
    
    let commandName = String(splitCommand.first!)
    let commandURL = debugURL.appendingPathComponent(commandName)
    guard (try? commandURL.checkResourceIsReachable()) ?? false else {
      XCTFail("No executable at '\(commandURL.standardizedFileURL.path)'.",
              file: (file), line: line)
      return
    }
    
    let process = Process()
    if #available(macOS 10.13, *) {
      process.executableURL = commandURL
    } else {
      process.launchPath = commandURL.path
    }
    process.arguments = arguments
    
    let output = Pipe()
    process.standardOutput = output
    let error = Pipe()
    process.standardError = error
    
    if #available(macOS 10.13, *) {
      guard (try? process.run()) != nil else {
        XCTFail("Couldn't run command process.", file: (file), line: line)
        return
      }
    } else {
      process.launch()
    }
    process.waitUntilExit()
    
    let outputData = output.fileHandleForReading.readDataToEndOfFile()
    let outputActual = String(data: outputData, encoding: .utf8)!.trimmingCharacters(in: .whitespacesAndNewlines)
    
    let errorData = error.fileHandleForReading.readDataToEndOfFile()
    let errorActual = String(data: errorData, encoding: .utf8)!.trimmingCharacters(in: .whitespacesAndNewlines)
    
    if let expected = expected {
      XCTAssertEqual(expected, errorActual + outputActual)
    }
    
    XCTAssertEqual(process.terminationStatus, exitCode, file: (file), line: line)
  }
}

final class xcode_helperTests: XCTestCase {
  func test_Xcode_Helper_Versions() throws {
    AssertExecuteCommand(command: "xcode-helper --version",
                         expected: "xcode-helper version 0.0.1")
  }
  
  func test_Xcode_Helper_Help() throws {
    let helpText = """
        OVERVIEW: Xcode helper
        
        USAGE: xcode-helper 
        
        OPTIONS:
          --version               Show the version.
          -h, --help              Show help information.
        
        SUBCOMMANDS:
          cache                   Xcode cache helper
        
          See 'xcode-helper help ' for detailed help.
        """
    
    AssertExecuteCommand(command: "xcode-helper", expected: helpText)
    AssertExecuteCommand(command: "xcode-helper -h", expected: helpText)
    AssertExecuteCommand(command: "xcode-helper --help", expected: helpText)
  }
}

通过 swift test 运行单元测试。

swift test
> swift test

Test Suite 'All tests' started at 2021-07-17 14:01:47.357
Test Suite 'xcode-helperPackageTests.xctest' started at 2021-07-17 14:01:47.358
Test Suite 'xcode_helperTests' started at 2021-07-17 14:01:47.358
Test Case '-[xcode_helperTests.xcode_helperTests test_Xcode_Helper_Help]' started.
Test Case '-[xcode_helperTests.xcode_helperTests test_Xcode_Helper_Help]' passed (0.202 seconds).
Test Case '-[xcode_helperTests.xcode_helperTests test_Xcode_Helper_Versions]' started.
Test Case '-[xcode_helperTests.xcode_helperTests test_Xcode_Helper_Versions]' passed (0.074 seconds).
Test Suite 'xcode_helperTests' passed at 2021-07-17 14:01:47.634.
     Executed 2 tests, with 0 failures (0 unexpected) in 0.276 (0.276) seconds
Test Suite 'xcode-helperPackageTests.xctest' passed at 2021-07-17 14:01:47.634.
     Executed 2 tests, with 0 failures (0 unexpected) in 0.276 (0.276) seconds
Test Suite 'All tests' passed at 2021-07-17 14:01:47.634.
     Executed 2 tests, with 0 failures (0 unexpected) in 0.276 (0.277) seconds

也可以使用 Xcode Command-U 跑测试。

image

Installing your command line tool

测试通过,release 打包,并移至/usr/local/bin

swift build -c release
cp -f .build/release/xcode-helper /usr/local/bin/xcode-helper
xcode-helper --version
> xcode-helper --version
xcode-helper version 0.0.1

Demo

  • https://github.com/QiuZhiFei/xcode-helper

References

  • https://github.com/apple/swift-package-manager/blob/main/Documentation/Usage.md
  • https://www.avanderlee.com/swift/command-line-tool-package-manager/
  • https://www.swiftbysundell.com/articles/building-a-command-line-tool-using-the-swift-package-manager/
  • https://docs.swift.org/package-manager/PackageDescription/PackageDescription.html
  • https://developer.apple.com/swift/blog/?id=7
  • swift-argument-parser Documentation
  • https://github.com/apple/swift-package-manager/blob/4fd4df4275627ebc91a0f288c38658738cd9fa0f/Sources/Commands/SwiftPackageTool.swift

你可能感兴趣的:(使用 Swift Package Manager 建立 Command line tool)