6.1.3 编写命令插件

原文链接: https://docs.swift.org/latest/documentation/packagemanagerdocs/writingcommandplugin/

6.1.3 编写命令插件

创建一个命令插件,提供扩展包管理器的命令。

概述

编写包插件的第一步是确定你需要哪种插件。

如果你要提供的是用户可以随时执行、且与构建无关的操作,就实现命令插件。

注意:如果你的目标是生成应当参与构建的源文件,或者在每次构建开始时执行其他操作, 那么应当实现构建工具插件。 关于创建构建工具插件的细节,参见编写构建工具插件。

命令插件由用户随时通过 swift package <command> <arguments> 调用。它们与构建图无关,通常通过把命令行工具作为子进程调用来完成工作。

命令插件的声明方式与构建工具插件类似,区别在于它们声明的是 .command() 能力,并在插件脚本中实现不同的入口点。

命令插件会说明该命令的语义意图——它可以是「生成文档」或「源代码格式化」这类预定义意图之一,也可以是带专门动词、可传给 swift package 命令的自定义意图。命令插件还可以声明它需要的任何特殊权限,例如修改包目录下文件的权限。

命令的意图声明提供了一种按功能类别对命令插件分组的方式,这样包管理器——或者支持包管理器包的 IDE——就可以把可用于某个特定目的的命令展示出来。例如,这种做法支持为生成包文档提供多个不同的命令插件,同时仍允许按意图对它们进行分组和发现。

插件对定义它的包可用;如果存在对应的插件产品,那么对任何直接依赖该定义包的包也可用。

在包清单中声明命令插件

声明命令插件的包,其清单文件可能像这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import PackageDescription

let package = Package(
  name: "MyPluginPackage",
  products: [
    .plugin(
      name: "MyCommandPlugin",
      targets: [
        "MyCommandPlugin"
      ]
    )
  ],
  dependencies: [
    .package(
      url: "https://github.com/example/sometool",
      from: "0.1.0"
    )
  ],
  targets: [
    .plugin(
      name: "MyCommandPlugin",
      capability: .command(
        intent: .sourceCodeFormatting(),
        permissions: [
          .writeToPackageDirectory(reason: "This command reformats source files")
        ]
      ),
      dependencies: [
        .product(name: "SomeTool", package: "sometool"),
      ]
    )
  ]
)

在上面的例子中,插件声明自己的用途是源代码格式化,并且它需要修改包目录中文件的权限。包管理器在沙箱中运行插件,阻止网络访问和大部分文件系统访问。当你在插件中声明额外权限并获得用户批准之后,包管理器会允许网络访问或文件系统访问。

定义插件的工具依赖

当插件需要在 Swift Package Manager、IDE 和其他宿主中都能工作时,请把命令行工具声明为插件目标的直接依赖。根据所声明的依赖类型,把可执行目标的名称、可执行产品的名称或可执行产物的名称传给 PluginContext.tool(named:)。可执行依赖会针对宿主机平台构建,二进制产物包必须提供支持宿主机平台的变体。

当包管理器从命令行调用命令插件、而没有任何已声明的依赖匹配时,它会先搜索包含所选 Swift 编译器的目录,然后搜索调用进程 PATH 中的目录。这种回退行为是包管理器命令行接口特有的;IDE 和其他宿主可能提供不同的搜索目录。如果命令插件有意要求用户安装某个工具,请把这一要求写入文档,并在 PluginContext.tool(named:) 找不到该工具时给出清晰的诊断信息。

实现命令插件脚本

实现命令插件的源码应当位于包中的 Plugins 子目录下。让插件的入口点遵循 CommandPlugin 协议:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import PackagePlugin
import Foundation

@main
struct MyCommandPlugin: CommandPlugin {
  func performCommand(
    context: PluginContext,
    arguments: [String]
  ) throws {
    // 要调用 `sometool` 格式化代码,先定位它。
    let sometool = try context.tool(named: "sometool")

    // 按惯例,使用包根目录中的配置文件。
    // 这样包的所有者就可以把格式化设置
    // 提交到自己的仓库里。
    let configFile = context
      .package
      .directory
      .appending(".sometoolconfig")

    // 提取目标参数(如果没有,则假定是所有目标)。
    var argExtractor = ArgumentExtractor(arguments)
    let targetNames = argExtractor.extractOption(named: "target")
    let targets = targetNames.isEmpty
      ? context.package.targets
      : try context.package.targets(named: targetNames)

    // 遍历要格式化的这些目标。
    for target in targets {
      // 跳过任何没有源文件的目标类型。
      // 注意:这里也可以改为发出警告或错误。
      guard let target = target.sourceModule else { continue }

      // 在目标目录上调用 `sometool`,并传入
      // 包目录中的配置文件。
      let sometoolExec = URL(fileURLWithPath: sometool.path.string)
      let sometoolArgs = [
        "--config",
        "\(configFile)",
        "--cache", 
        "\(context.pluginWorkDirectory.appending("cache-dir"))",
        "\(target.directory)"
      ]
      let process = try Process.run(sometoolExec, 
                                    arguments: sometoolArgs)
      process.waitUntilExit()

      // 检查子进程调用是否成功。
      if process.terminationReason == .exit 
        && process.terminationStatus == 0
      {
        print("Formatted the source code in \(target.directory).")
      } else {
        let problem = "\(process.terminationReason):\(process.terminationStatus)"
        Diagnostics.error("Formatting invocation failed: \(problem)")
      }
    }
  }
}

构建工具插件只作用于单个包目标,而命令插件不一定只操作单个目标。context 参数提供了对输入的访问,包括以命令插件所作用的包为根的一份精简包图。

命令插件可以接受参数,你用这些参数来控制插件操作的选项,或者进一步缩小插件的作用范围。这个例子采用传递 --target 的惯例,把插件的范围限制在包中的某一组目标上。

插件只能使用标准系统库,不能使用其他包(例如 SwiftArgumentParser)提供的库。因此,这个插件例子使用了 PackagePlugin 模块中内置的 ArgumentExtractor 辅助工具来提取参数。

诊断信息

插件入口点被标记为 throws,从入口点抛出的任何错误都会导致该插件调用被标记为失败。抛出的错误会呈现给用户,因此应当清晰地描述出了什么问题。

此外,插件可以使用 PackagePlugin 中的 Diagnostics API 发出警告和错误,并可选择在其中引用文件路径和这些文件中的行号。

调试与测试

包管理器目前还没有针对插件调试和测试的专门支持。许多插件充当适配器,负责构造命令行来调用真正干活的工具。在插件中包含非平凡代码的情况下,一个好办法是把这些代码抽到单独的源文件中,再通过带相对路径的符号链接把它们纳入单元测试。

Xcode 对 PackagePlugin API 的扩展

当你在 Apple 的 Xcode IDE 中调用插件时,插件可以访问 Xcode 提供的一个名为 XcodeProjectPlugin 的库模块。这个模块扩展了 PackagePlugin 的 API,让插件除了包之外还能处理 Xcode 目标。

为了写出既能在各种环境中处理包、又能在 Xcode 中运行时条件性地处理 Xcode 项目的插件,插件应当在 XcodeProjectPlugin 模块可用时条件性地导入它。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import PackagePlugin

@main
struct MyCommandPlugin: CommandPlugin {
    /// 处理 Swift 包时调用这个入口点。
    func performCommand(context: PluginContext,
                        arguments: [String]) throws {
        debugPrint(context)
    }
}

#if canImport(XcodeProjectPlugin)
import XcodeProjectPlugin

extension MyCommandPlugin: XcodeCommandPlugin {
    /// 处理 Xcode 项目时调用这个入口点。
    func performCommand(context: XcodePluginContext, 
                        arguments: [String]) throws {
        debugPrint(context)
    }
}
#endif

XcodePluginContext 输入结构与 PluginContext 结构类似,只是它提供对 Xcode 项目的访问。Xcode 项目在项目模型上使用 Xcode 的命名和语义,与包管理器有些不同。有一些底层类型,例如 FileList 或 Path,在 PackagePlugin 和 XcodeProjectPlugin 中是相同的。

如果在 Xcode 用户界面中选中了某些目标,Xcode 会把它们的名称作为 --target 参数传给插件。

其他 IDE 或使用包管理器的自定义环境,同样可以提供模块来定义新的入口点,并扩展核心 PackagePlugin API 的功能。