커맨드라인 인자 파싱하기

우리가 만드는 CLI 도구의 일반적인 호출은 아래와 같이 보일 것입니다:

$ grrs foobar test.txt

이때 프로그램은 test.txt를 살펴보고, foobar가 포함된 라인을 출력할 것입니다. 그런데 저 두 값을 어떻게 얻을 수 있을까요?

프로그램 이름 뒤에 나오는 텍스트는 보통 “커맨드라인 인자“라고 부르며, --this와 같이 쓰일 때는 특히 “커맨드라인 플래그“라고도 부릅니다. 내부적으로, 운영체제는 이를 문자열의 리스트로 나타냅니다. 거칠게 말해서 이때 인자들은 공백으로 분리됩니다.

주어진 인자와 그 인자를 어떻게 파싱할지에 대해 다양한 방향으로 생각해 볼 수 있습니다. 또한 여러분은 프로그램 사용자에게 어떤 인자를 어떤 형식으로 전달해야 하는지 알려줘야 합니다.

인자 얻기

표준 라이브러리에는 주어진 인자의 이터레이터를 제공하는 std::env::args() 함수가 있습니다. 첫 엔트리(0번 인덱스)는 프로그램의 이름입니다. (예를 들어 grrs.) 이후 인자는 사용자가 입력하는 값이 됩니다.

이 방식으로 인자를 그대로 얻기는 매우 쉽습니다 (src/main.rs 파일에서 fn main() { 다음 부분):

let pattern = std::env::args().nth(1).expect("no pattern given");
let path = std::env::args().nth(2).expect("no path given");

데이터 타입으로서의 CLI 인자

주어진 인자들을 텍스트의 묶음으로 취급하는 대신, CLI 인자를 프로그램의 입력을 표현하는 임의의 데이터 타입으로 생각해 볼 수 있습니다.

grrs foobar test.txt를 봅시다: 여기에는 두 개의 인자가 있습니다. 첫 번째로 pattern (찾을 문자열)이 있고, 이어서 path (문자열을 찾을 파일)이 있습니다.

이것에 대해 무엇을 더 논의할 수 있을까요? 프로그램을 시작하기 위해서는 두 인자가 모두 필요합니다. 우리가 기본값을 지정한 적이 없으므로, 사용자는 항상 두 값을 제공할 것이라고 예상할 수 있습니다. 더 나아가, 인자의 타입에 대해 첫 번째 인자 pattern은 문자열이 될 것이고, 두 번째 인자 path는 파일의 경로가 될 것이라고 말할 수 있습니다.

러스트에서는 프로그램이 다루는 데이터를 중심으로 프로그램을 구성하는 것이 일반적이므로, 이러한 방식으로 CLI 인자를 처리하는 것이 매우 적합합니다. 이것부터 시작하겠습니다 (src/main.rs 파일에서 fn main() { 앞 부분):

struct Cli {
    pattern: String,
    path: std::path::PathBuf,
}

위 코드는 patternpath 두 필드에 데이터를 저장하는 새로운 구조체(struct)를 정의힙니다.

인자를 앞서 정의한 구조체 형태로 만들기 위해서는 프로그램에 입력된 실제 인자를 얻어야 합니다. 한 가지 방법은 운영체제로부터 얻은 문자열 리스트를 하나씩 파싱하고, 구조체를 직접 만드는 것입니다. 이는 아래와 같이 할 수 있습니다:

let pattern = std::env::args().nth(1).expect("no pattern given");
let path = std::env::args().nth(2).expect("no path given");
let args = Cli {
    pattern: pattern,
    path: std::path::PathBuf::from(path),
};

위 코드는 잘 동작하기는 하지만, 별로 편리하지 않습니다. 이 방법으로 --pattern="foo"--pattern "foo"와 같은 요구사항은 어떻게 지원할 수 있을까요? --help는 어떻게 구현해야 할까요?

Clap으로 CLI 인자 파싱하기

더 좋은 방법은 다양한 라이브러리 중 하나를 사용하는 것입니다. 커맨드라인 인자를 파싱하는 데 가장 인기있는 라이브러리는 clap입니다. clap은 서브 커맨드, 셸 자동완성, 도움말 메시지 등, 여러분이 생각하는 모든 기능을 지원합니다.

먼저 Cargo.toml 파일의 [dependencies] 섹션에 clap = { version = "4.0", features = ["derive"] }을 추가해 clap을 가져와 봅시다.

이제 우리의 코드에 use clap::Parser;를 추가하고, struct Cli 바로 위에 #[derive(Parser)]를 작성합니다. 그리고 문서화 주석도 작성해 봅시다.

아래와 같이 하면 됩니다 (src/main.rs 파일의 fn main() { 앞 부분):

use clap::Parser;

/// 파일에서 패턴을 찾고 패턴을 포함한 라인을 보여준다.
#[derive(Parser)]
struct Cli {
    /// 찾을 패턴
    pattern: String,
    /// 읽을 파일 경로
    path: std::path::PathBuf,
}

Cli 구조체 바로 아래에 main 함수가 있습니다. 프로그램이 실행되면, 프로그램은 main 함수를 호출하게 됩니다. 함수의 첫 줄은 아래와 같습니다:

fn main() {
    let args = Cli::parse();
}

위 코드는 인자를 파싱해 Cli 구조체로 변환합니다.

이때 문제가 생기면 어떻게 될까요? 이 지점이 Clap을 사용하는 접근법의 아름다운 부분입니다. Clap은 어떤 필드가 주어져야 하는지, 그 필드가 어떤 형식으로 주어져야 하는지 알고 있습니다. Clap은 자동으로 --help 메시지를 생성해 줄 뿐만 아니라, --output이 아닌 --putput을 입력한 사용자에게 에러를 제공해 줍니다.

마무리

여러분의 코드는 이제 아래와 같아야 합니다:

#![allow(unused)]

use clap::Parser;

/// 파일에서 패턴을 찾고 패턴을 포함한 라인을 보여준다.
#[derive(Parser)]
struct Cli {
    /// 찾을 패턴
    pattern: String,
    /// 읽을 파일 경로
    path: std::path::PathBuf,
}

fn main() {
    let args = Cli::parse();
}

아무 인자 없이 실행하는 경우:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 10.16s
     Running `target/debug/grrs`
error: The following required arguments were not provided:
    <pattern>
    <path>

USAGE:
    grrs <pattern> <path>

For more information try --help

cargo run을 실행할 때는 -- 뒤에 인자를 전달할 수 있습니다.

$ cargo run -- some-pattern some-file
    Finished dev [unoptimized + debuginfo] target(s) in 0.11s
     Running `target/debug/grrs some-pattern some-file`

보다시피 프로그램은 아무것도 출력하지 않습니다. 이는 오류 없이 프로그램이 종료되었다는 것을 의미합니다.