🔥 Swift 패키지 플러그인 작성하기

2020자
26분

Swift 패키지 플러그인을 만들 때는 우선 어떤 종류의 플러그인이 필요한지 정해야 합니다. 빌드에 들어가야 하는 소스 파일을 만들거나 모든 빌드가 시작될 때마다 다른 일을 해야 한다면 빌드 도구 플러그인을 만들면 됩니다. 하지만 빌드와 관계없이 사용자가 원할 때 실행할 수 있는 기능을 제공하고 싶다면 커맨드 플러그인을 만드는 게 좋습니다.

빌드 도구 플러그인

빌드 도구 플러그인은 패키지가 빌드되기 전에 실행되어 빌드에 필요한 명령어들을 설정합니다. 이 명령어에는 두 가지 종류가 있어요:

  • 사전 빌드(prebuild) 명령 - 빌드가 시작되기 전에 실행되며 명령을 실행하기 전에는 이름을 예측할 수 없는 임의 갯수의 출력 파일을 생성할 수 있습니다.
  • 빌드 명령 - 빌드 시스템의 종속성 그래프에 통합되어 있습니다. 빌드 시스템은 미리 정의된 입력과 출력 존재 여부 및 타임스탬프를 확인하여 빌드 중 적절한 시점에 실행합니다.

명령어를 실행하기 전에 모든 입출력 파일의 경로를 알 수 있다면 빌드 명령어를 쓰는 게 좋아요. 빌드 시스템이 실행 시점을 효율적으로 결정할 수 있거든요. 이런 경우는 사실 꽤 흔해요. 가령 입력 파일 하나당 출력 파일 하나가 나오는 소스 변환 도구 같은 거죠. 출력 파일명도 예측 가능하고요. 이럴 때는 플러그인이 따로 실행되지 않아도 출력 파일명을 알 수 있어요. 그러면 빌드 시스템은 출력 파일이 없거나 입력 파일이 바뀌었을 때만 명령어를 실행하면 돼요. 꼭 입력 파일 하나당 출력 파일 하나일 필요는 없어요. 플러그인은 입력을 살펴보고 출력 파일 개수를 자유롭게 정할 수 있죠.

반면 도구를 실행해 봐야 출력 파일명을 알 수 있다면 사전 빌드 명령어를 써야 해요. 입력 파일의 내용에 따라 출력 파일 개수와 이름이 달라지는 경우죠. 사전 빌드 명령어는 모든 빌드 전에 실행되어야 하니까, 증분 빌드 속도를 떨어뜨리지 않게 필요한 작업만 해야 해요. 그래서 캐싱도 직접 해야 하고요.

어느 경우든 빌드 명령어의 실제 작업은 플러그인 자체에서 하는 게 아니에요. 플러그인은 나중에 실행될 명령어를 설정하는 거고, 실제 작업은 그 명령어가 하는 거죠. 그래서 플러그인 자체는 보통 아주 작아요. 주로 실제 일 하는 빌드 명령어의 커맨드 라인을 만드는 데 집중하죠.

패키지 매니페스트에서 빌드 도구 플러그인 선언하기

다른 패키지 플러그인처럼, 빌드 도구 플러그인도 패키지 매니페스트에 선언해야 합니다. 이걸 하려면 패키지의 targets라는 부분에 pluginTarget이라는 항목을 써야 합니다. 만약 이 플러그인을 다른 패키지에서도 쓸 수 있게 하고 싶다면, products라는 부분에 plugin이라는 항목도 추가해야 해요. 쉽게 말하자면, 플러그인을 만들려면 패키지 매니페이스의 특정 부분에 플러그인에 대한 정보를 적어야 한다는 뜻이에요. 그래야 패키지 안에서 플러그인이 잘 작동하고, 필요하다면 다른 패키지에서도 그 플러그인을 가져다 쓸 수 있습니다.

// swift-tools-version: 5.6
import PackageDescription
 
let package = Package(
    name: "MyPluginPackage",
    products: [
        .plugin(
            name: "MyBuildToolPlugin",
            targets: [
                "MyBuildToolPlugin"
            ]
        )
    ],
    dependencies: [
        .package(
            url: "<https://github.com/example/sometool>",
            from: "0.1.0"
        )
    ],
    targets: [
        .plugin(
            name: "MyBuildToolPlugin",
            capability: .buildTool(),
            dependencies: [
                .product(name: "SomeTool", package: "sometool"),
            ]
        )
    ]
)
swift

plugin 타겟은 플러그인의 이름과 기능을 정의하고, 필요한 라이브러리들을 선언하는 역할을 합니다. .buildTool()은 이 플러그인이 빌드 도구 플러그인이라는 것을 나타내는 거예요. 이는 플러그인이 어떤 함수를 구현해야 하는지도 결정합니다.

플러그인의 실제 코드는 패키지 안에 Plugins 폴더 아래에, 플러그인 이름과 같은 이름의 폴더 안에 있어야 해요. 이 위치는 pluginTargetpath로 변경할 수 있습니다.

plugin 프로덕트(Product)는 이 플러그인을 쓰려는 다른 패키지에서 플러그인을 찾을 수 있게 해줍니다. 보통은 플러그인 이름과 프로덕트 이름을 같게 하지만, 꼭 그럴 필요는 없어요. 플러그인 프로덕트는 단순히 플러그인타겟의 이름만 나열하면 됩니다. 만약 빌드 도구 플러그인이 그 플러그인을 선언한 패키지 안에서만 사용한다면, plugin 프로덕트를 선언할 필요가 없습니다.

빌드 도구 타겟 종속성

종속성은 플러그인에서 구성한 명령에 사용할 수 있는 커맨드 라인 도구를 지정합니다. 각 종속성은 동일한 패키지의 executableTarget 또는 binaryTarget 타겟이거나 다른 패키지의 executable 프로덕트(SwiftPM에는 바이너리 프로덕트가 없음)일 수 있습니다. 위의 예시에서 플러그인은 플러그인을 정의하는 패키지가 의존하고 있는 sometool 패키지의 가상 SomeTool 프로덕트에 의존합니다. 하지만 플러그인이 SomeTool 프로덕트에 의존한다고 해서 플러그인이 호출될 때 SomeTool이 반드시 빌드되었음을 의미하는 것은 아닙니다. 플러그인의 SomeTool 프로덕트 의존성은 단순히 플러그인에서 구성한 명령이 실행될 때 해당 도구가 어디에 위치할 것인지를 플러그인에서 알 수 있다는 뜻입니다.

실행 파일 종속성은 빌드 과정에서 호스트 플랫폼용으로 빌드되는 반면, 바이너리 종속성은 미리 빌드된 바이너리가 포함된 artifactbundle 아카이브를 참조합니다(SE-305 참조). 바이너리 타겟은 도구를 SwiftPM이 아닌 다른 빌드 시스템으로 빌드해야 하거나, 빌드 비용이 너무 크거나, 특별한 빌드 환경이 필요한 경우에 자주 사용됩니다.

빌드 도구 플러그인 스크립트 구현하기

보통 Swift 패지지 매니저는 Plugins 폴더 안에 있는 플러그인 이름과 똑같은 하위 폴더에서 플러그인의 구현 내용을 찾습니다. 하지만 플러그인 선언 부분에서 path 매개변수를 사용하면 이 경로를 바꿀 수 있어요.

플러그인은 하나 이상의 Swift 소스 파일로 이루어져 있고, 빌드 도구 플러그인 스크립트의 시작점은 반드시 BuildToolPlugin이라는 프로토콜을 따라야 합니다.

패키지 선언문에서 SwiftPM이 제공하는 PackageDescription 모듈을 불러오는 것처럼, 패키지 플러그인도 PackagePlugin 모듈을 불러와 사용합니다. 이 모듈에는 플러그인이 SwiftPM에게 정보를 받고 결과를 돌려주는 데 필요한 API들이 들어 있어요.

import PackagePlugin
 
@main
struct MyPlugin: BuildToolPlugin {
 
    func createBuildCommands(context: PluginContext, target: Target) throws -> [Command] {
        guard let target = target.sourceModule else { return [] }
        let inputFiles = target.sourceFiles.filter({ $0.path.extension == "dat" })
        return try inputFiles.map {
            let inputFile = $0
            let inputPath = inputFile.path
            let outputName = inputPath.stem + ".swift"
            let outputPath = context.pluginWorkDirectory.appending(outputName)
            return .buildCommand(
                displayName: "Generating \(outputName) from \(inputPath.lastComponent)",
                executable: try context.tool(named: "SomeTool").path,
                arguments: [ "--verbose", "\(inputPath)", "\(outputPath)" ],
                inputFiles: [ inputPath, ],
                outputFiles: [ outputPath ]
            )
        }
    }
}
swift

플러그인 스크립트는 Foundation이나 다른 기본 라이브러리를 가져올 수 있습니다. 하지만 SwiftPM의 현재 버전에서는 다른 라이브러리를 추가로 가져올 수는 없습니다. 이 예시에서 나온 명령은 buildCommand 타입입니다. 그래서 이 명령은 빌드 시스템의 명령 그래프에 포함됩니다. 그리고 출력 파일이 없거나 입력 파일이 바뀌면 실행됩니다.

빌드 도구 플러그인은 항상 빌드 타겟에 적용되며, 이는 엔트리 포인트의 매개변수로 전달됩니다. 오직 소스 모듈 타겟만이 소스 파일을 가지고 있기 때문에, 소스 파일을 반복하는 플러그인은 일반적으로 주어진 타겟이 SourceModuleTarget을 준수하는지 확인합니다.

빌드 도구 플러그인은 prebuildCommand 타입의 명령을 반환할 수도 있는데, 이 명령은 빌드가 시작되기 전에 실행됩니다. 이 명령이 실행될 때까지 출력 파일의 이름을 알 수 없는 경우, 해당 명령을 통해 디렉터리에 무작위 이름을 갖는 출력 파일을 생성할 수 있습니다.

import PackagePlugin
import Foundation
 
@main
struct MyBuildToolPlugin: BuildToolPlugin {
 
    func createBuildCommands(context: PluginContext, target: Target) throws -> [Command] {
        // 이 예제에서는 `sometool`이 플러그인 작업 디렉터리(플러그인과 타겟마다 고유함)의
        // "GeneratedFiles" 디렉터리에 쓰도록 구성합니다.
        let outputDir = context.pluginWorkDirectory.appending("GeneratedFiles")
        try FileManager.default.createDirectory(atPath: outputDir.string,
            withIntermediateDirectories: true)
 
        // `sometool`을 사전 빌드 명령으로 실행하는 명령을 반환합니다.
        // 모든 빌드 전에 실행되어 빌드 컨텍스트에서 제공하는 출력 디렉터리에
        // 소스 파일을 생성합니다.
        return [.prebuildCommand(
            displayName: "Running SomeTool",
            executable: try context.tool(named: "SomeTool").path,
            arguments: [ "--verbose", "--outdir", outputDir ],
            outputFilesDirectory: outputDir)
        ]
    }
}
swift

사전 빌드 명령의 경우 모든 종속성은 바이너리 타겟이어야 합니다. 사전 빌드 명령은 빌드가 시작되기 전에 실행되기 때문입니다.

빌드 도구 플러그인은 빌드 도구 명령과 사전 빌드 명령을 함께 반환할 수 있습니다. 플러그인 실행 후, 모든 빌드 명령은 빌드 그래프에 통합됩니다. 이 과정에서 후속 빌드 중에 명령을 실행해야 하는 변경 사항이 발생할 수 있습니다.

사전 빌드 명령은 플러그인 실행 후, 빌드 시작 전에 실행됩니다. 사전 빌드 명령의 outputFilesDirectory에 있는 모든 파일은 타겟의 소스 파일처럼 처리됩니다. 사전 빌드 명령은 명령 실행 결과를 반영하기 위해 outputFilesDirectory에서 파일을 추가하거나 제거할 수 있습니다.

Swift 패키지 매니저의 현재 버전은 생성된 Swift 소스 파일과 리소스를 출력으로 지원하지만, Swift가 아닌 소스 파일은 아직 지원하지 않습니다. 생성된 모든 리소스는 매니페스트에서 .process() 규칙으로 선언된 것처럼 처리됩니다. Swift 패키지 매니저의 목표는 타겟의 소스 파일로 포함될 수 있는 모든 유형의 파일을 지원하고, 플러그인이 생성된 파일의 다운스트림 처리에 대해 더 큰 제어 기능을 제공하는 것입니다.

커맨드 플러그인

커맨드 플러그인은 사용자가 swift package <command> <arguments>를 호출할 때 실행됩니다. 빌드 그래프와는 무관하며 종종 커맨드 라인 도구를 하위 프로세스로 호출하여 작업을 수행합니다.

커맨드 플러그인은 빌드 도구 플러그인과 유사한 방식으로 선언되지만, .command() 기능을 선언하고 플러그인 스크립트에서 다른 진입점을 구현한다는 점에서 차이가 있습니다.

커맨드 플러그인은 명령의 의미론적 의도를 지정합니다. 이는 "문서 생성" 또는 "소스 코드 포맷팅"과 같은 미리 정의된 의도 중 하나일 수도 있고 swift package 명령에 전달할 수 있는 특수한 동사가 포함된 사용자 정의 의도일 수도 있습니다. 커맨드 플러그인은 또한 패키지 디렉터리 하위 파일을 수정할 수 있는 권한과 같이 필요한 특별 권한을 지정할 수 있습니다.

명령의 의도 선언은 커맨드 플러그인을 기능 범주별로 그룹화하는 방법을 제공하므로 SwiftPM이나 SwiftPM 패키지를 지원하는 IDE에서 특정 목적에 사용할 수 있는 명령을 표시할 수 있습니다. 예를 들어, 이 접근 방식은 패키지 문서 생성을 위한 서로 다른 커맨드 플러그인을 지원하면서도, 해당 명령을 그룹화하고 의도에 따라 검색할 수 있도록 해줍니다.

패키지 매니페스트에서 커맨드 플러그인 선언하기

다음은 커맨드 플러그인을 선언하는 패키지 매니페스트입니다:

// swift-tools-version: 5.6
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

여기서 플러그인은 소스 코드 포맷팅을 목적으로 하며, 특히 패키지 디렉터리의 파일을 수정할 수 있는 권한이 필요하다고 선언합니다. 플러그인은 네트워크 액세스와 대부분의 파일 시스템 액세스를 차단하는 샌드박스 환경에서 실행되지만, 패키지 쓰기 권한에 대한 선언은 (사용자의 승인을 받은 후) 해당 권한을 샌드박스에 부여합니다.

커맨드 플러그인 스크립트 구현하기

빌드 도구 플러그인과 마찬가지로, 커맨드 플러그인을 구현하는 스크립트는 패키지의 Plugins 하위 디렉터리에 위치해야 합니다.

커맨드 플러그인의 경우, 플러그인 스크립트의 진입점은 CommandPlugin 프로토콜을 준수해야 합니다.

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)")
            }
        }
    }
}
swift

빌드 도구 플러그인과 달리 커맨드 플러그인은 반드시 단일 패키지 타겟에만 적용되는 것은 아닙니다. context 매개변수는 커맨드 플러그인이 적용되는 패키지를 루트로 하는 패키지 그래프의 축약된 버전에 대한 액세스를 포함하여 입력에 대한 액세스를 제공합니다.

커맨드 플러그인은 인수도 받을 수 있으며, 이를 통해 플러그인 작업에 대한 옵션을 제어하거나 플러그인이 작동하는 범위를 더 좁힐 수 있습니다. 이 예에서는 패키지의 타겟 집합으로 플러그인의 범위를 제한하기 위해 --target을 전달하는 규칙을 지원합니다.

Swift 패키지 매니저의 현재 버전에서 플러그인은 표준 시스템 라이브러리만 사용할 수 있으며, SwiftArgumentParser와 같은 다른 패키지의 라이브러리는 사용할 수 없습니다. 따라서 이 플러그인은 간단한 인수 추출을 위해 PackagePlugin 모듈의 내장 ArgumentExtractor 도우미를 사용합니다.

진단

플러그인 진입점(entry point)은 throws로 표시되며, 진입점에서 발생하는 모든 오류는 플러그인 호출이 실패한 것으로 간주됩니다. 발생한 오류는 사용자에게 표시되며, 무엇이 잘못되었는지에 대한 명확한 설명을 포함해야 합니다.

또한 플러그인은 PackagePlugin의 Diagnostics API를 사용하여 경고와 오류를 내보낼 수 있습니다. 이때 선택적으로 파일 경로와 해당 파일의 행 번호에 대한 참조를 포함할 수 있습니다.

디버깅 및 테스팅

SwiftPM은 현재 플러그인 디버깅과 테스팅을 위한 특별한 지원을 제공하지 않습니다. 많은 플러그인은 실제 작업을 수행하는 도구를 호출하기 위한 커맨드 라인을 구성하는 어댑터 역할만 합니다. 플러그인에 중요한 코드가 포함되어 있는 경우, 해당 코드를 테스트하기 위한 현재 최선의 방법은 별도의 소스 파일로 분리하여 상대 경로를 사용하는 심볼릭 링크를 통해 플러그인 패키지의 단위 테스트에 포함시키는 것입니다.

PackagePlugin API에 대한 Xcode 확장

Apple의 Xcode IDE에서 호출될 때, 플러그인은 Xcode에서 제공하는 _XcodeProjectPlugin_이라는 라이브러리 모듈에 접근할 수 있습니다. 이 모듈은 PackagePlugin API를 확장하여 플러그인이 패키지뿐만 아니라 Xcode 타겟에서도 작동할 수 있게 해줍니다.

모든 환경에서 패키지로 작동하고, Xcode에서 실행될 때는 Xcode 프로젝트에서도 조건부로 작동하는 플러그인을 작성하려면, XcodeProjectPlugin 모듈을 사용할 수 있을 때 해당 모듈을 조건부로 가져와야 합니다. 예를 들면 다음과 같습니다:

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
swift

XcodePluginContext 입력 구조는 일반 PluginContext 구조와 유사하지만, Xcode 이름 지정 및 의미론을 사용하는 Xcode 프로젝트(이는 SwiftPM의 프로젝트 모델과 다소 다름)에 대한 액세스를 제공합니다. FileList, Path 등과 같은 일부 기본 유형은 PackagePluginXcodeProjectPlugin 유형에서 동일합니다.

Xcode 사용자 인터페이스에서 타겟을 선택했을 때, Xcode는 해당 이름을 --target 인수로 플러그인에 전달합니다.

SwiftPM을 사용하는 다른 IDE나 사용자 정의 환경에서도 새로운 진입점을 정의하고 핵심 PackagePlugin API의 기능을 확장하는 모듈을 제공할 수 있을 것으로 예상됩니다.

참고