프로그래밍/iOS,macOS

[swift] 커맨드라인 실행 파일 만들기(swift-argument-parser)

chance 2025. 10. 16. 05:16

swift 파일을 입력받아 swift 클래스목록을 파일로 출력하는 간단한 명령줄 실행 파일을 만들어보자.

 

명령줄 실행 및 인자 파싱을 위해 swift-argument-parser 사용

swift 스크립트 파싱을 위해 swift-syntax 사용

 

패키지 설정 및 dependencies 추가

타겟 설정

let package = Package(
  name: "MyPackage",
  products: [
    .excutable(name: "MyCommand", targets: ["MyCommand"]),
  ], 
  dependencies: [
    .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"),
    .package(url: "https://github.com/siwftlang/swift-syntax", from: "600.0.1"),
  ],
  targets: [
    .executableTarget(
      name: "MyCommand",
      dependencies: [
        .product(name: "ArgumentParser", package: "swift-argument-parser"),
        .product(name: "SwiftSyntax", package: "swift-syntax"),
        .product(name: "SwiftParser", package: "swift-syntax"),
      ]
    )
  ]
)

 

 

 

구현

swift-argument-parser 는 ParsableCommand 프로토콜을 준수해 구현해 줘야 한다.

ParsableCommand 구현하는 객체를 @main 으로 엔트리포인트로 설정한다.

엔트리포인트 객체는 static func main() 메쏘드를 제공해야 하는데,

PasableCommand main 함수가 구현되어 있어, 실행시 run() 메쏘드를 호출해준다.

 

 

MyCommand.swift

import Foundation
import ArgumentParser
import SwiftParser

@main
struct MyCommand: ParsableCommand {

  // 기본 실행파일명이 케밥 케이스(my-command)로 표시되므로, 실제 파일명으로 정의해 준다.
  // abstract: 는 파일 실행 시 OVERVIEW : 옆에 표시할 문자열
  static let configuration = CommandConfiguration(
    commandName: "MyCommand", 
    abstract: "show swift classes"
  )

  // 옵션과 인자목록은 아래 선언 순서에 따라 USAGE에 표시된다.
  // 실행파일 사용시 정의된 변수명이 아닌 케밥 케이스로 변경된 이름으로 사용하게 된다.
  
  // 옵션 정의
  // .shortAndLong의 형식은 -o, —output-file 두가지 모두 사용
  @Option(name: .shortAndLong, help: "Output file")
  var outputFile: String
  
  // 기본값이 있는 옵션, 입력하지 않아도 오류가 나지 않고, usage에 [--flag <flag>] 로 표시
  @Option(name: .shortAndLong, help: "Flag")
  var flag: Bool = false
  
  
  // 입력받을 인자 목록 정의
  @Argument(help: "Source files")
  var sourceFiles: [String]
  
  
  // run 구현
  mutating func run() throws {
    // swift 파일을 파싱하기 위한 SyntaxVisitor
    let visitor = MySwiftVisitor(viewMode: .all, logger: log)


    // 입력받은 argument 목록을 돌며 파일 가져오기
    log("source files")
    for file in sourceFiles {
      log("file : \(file)")
      let source = try String(contentsOf: URL(fileURLWithPath: file))
      
      // swift-syntax 로 AST 로 변경
      let fileSyntax = Parser.parse(source: source)
      
      // visitor의 visit 실행
      visitor.walk(fileSyntax)
    }

    // 출력할 데이터
    let count = visitor.classes.count
    let names = visitor.classes.map { $0 }.joined(separator: "\n")
    let output = "count: \(count)\nclasses:\(names)"

    // 출력 파일
    let outURL = URL(fileURLWithPath: outputFile)
    try output.write(to: outURL, atomically: true, encoding: .utf8)
    log("finished")
  }


  // 로그 출력
  func log(_ message: String) {
    print(message)
  }
}

 

 

 

 

MySwiftVisitor.swift

swift 파싱하기 위한 visitor 작성

import Foundation
import SwiftSyntax

public class MySwiftVisitor: SyntaxVisitor {
  internal init(viewMode: SyntaxTreeViewMode, logger: @escaping ((String) -> Void)) {
    self.logger = logger
    super.init(viewMode: viewMode)
  }
  
  public let logger: ((String) -> Void)
  public var classes: [String] = []
  
  public override func visit(_ classDecl: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
    let className = classDecl.name.trimmedDescription
    logger("class: \(className)")
    classes.append(className)

    for attribute in classDecl.attributes {
      if let attributeSyntax = attribute.as(AttributeSyntax.self) {
        let attributeName = attributeSyntax.attributeName.trimmedDescription
        logger("attribute: \(attributeName)")
      }
    }

    return .skipChildren
  }
}

 

 

실행

./MyCommand --output-file out /swift-file-path/swift-file.swift

 

 

 


플러그인

위에 작성한 커맨드라인 실행파일을 타겟 빌드 과정에서 자동으로 실행시키려면 plugin 을 설정하면 된다.

혹시 나중에 쓸일이 있을까 해서 참고삼아 정리.

 

패키지 추가

products와 targets에 plugin을 추가한다.

let package = Package(
  name: "MyPackage",
  products: [
    .excutable(name: "MyCommand", targets: ["MyCommand"]),
    .plugin(name: "RunnerPlugin", targets: ["RunnerPlugin"]),
  ], 
  dependencies: [
    .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"),
    .package(url: "https://github.com/siwftlang/swift-syntax", from: "600.0.1"),
  ],
  targets: [
    .executableTarget(
      name: "MyCommand",
      dependencies: [
        .product(name: "ArgumentParser", package: "swift-argument-parser"),
        .product(name: "SwiftSyntax", package: "swift-syntax"),
        .product(name: "SwiftParser", package: "swift-syntax"),
      ]
    ),
    .plugin(
      name: "RunnerPlugin",
      capability: .buildTool(),
      dependencies: ["MyCommand"]
    )
  ]
)

 

플러그인 코드 작성

플러그인은 /package/Plugins/plugin-name 에 위치해야 한다.

RunnerPlugin.swift

import Foundation
import PackagePlugin

@main
struct RunnerPlugin: BuildToolPlugin {
  func createBuildCommands(context: PluginContext, 
                           target: any Target) async throws -> [Command] {
  
    // 타겟에 포함된 소스 파일 목록
    guard let sourcesFiles = target.sourceModule?.sourceFiles else { return [] }
    
    // 실행할 커맨드라인 실행 파일
    let executableFilePath = try context.tool(named: "MyCommand").url
    
    // 결과 출력 파일
    let output = context.pluginWorkDirectoryURL
    	.appending(path: "Output")
        .appending(path: "out.dat")
    
    // 입력 파일 목록
    let inputFiles = sourceFile.map(\.url)
    
    // 실행 파일에 전달할 인자 목록
    let arguments = ["-o", output.path] + inputFiles.map(\.path)
    
    return [
      Command.buildCommand(
        displayName: "run MyCommand",
        executable: executableFilePath,
        arguments: arguments,
        inputFiles: inputFiles,
        outputFiles: [output]
      )
    ]
  }
}

 

Command.buildCommand() 는 빌드 프로세스의 한 부분으로 동작하며, 입력과 출력을 지정한다.

Command.prebuildCommand() 는 빌드가 진행되기전에 실행된다.