Rust로 ls 명령 구현하기 1

내 이 세상 도처에서 쉴 곳을 찾아보았으나, 마침내 찾아낸, 컴퓨터가 있는 구석방보다 나은 곳은 없더라.

Rust로 ls 명령 구현하기 1

ls는 유닉스 계열 OS에서 파일 목록을 표시하는 명령이다. 터미널에서 작업할 때 cd와 함께 가장 자주 사용하는 명령이 아닐까 싶다. ls를 Rust로 구현한 다른 프로젝트(eza, lsd 등)가 이미 있지만, ls와 같은 명령을 어떤 식으로 구현하는 지 직접 확인해보고 싶다.

시작

처음에는 외부 라이브러리를 사용하지 않고 모든 것을 직접 구현하려고 생각했다. 그러나 파일의 user와 group 이름을 얻으려면 libc 바인딩을 사용해야 하는 것 같다. 어차피 외부 라이브러리를 써야 한다면 사용하기 쉬워 보이는 users가 나아 보였다. 날짜 포매팅을 위해 chrono를, 명령행 인자 파싱에는 clap을 쓰기로 했다. 테이블 형식의 텍스트 출력을 위해 tabular를 사용할 것이다. 그리드 형식 출력을 위해 uutils_term_grid를, 그리드 생성 시 넘길 터미널 폭을 알기 위해 terminal_size를 사용할 것이다.

Cargo.toml[dependencies] 섹션에 다음과 같이 종속성을 추가한다.

[dependencies]
chrono = "0.4"
clap = { version = "4", features = ["derive"] }
terminal_size = "0.3"
tabular = "0.2"
users = "0.11"
uutils_term_grid = "0.6"

파일 목록

ls 명령의 가장 기본은 주어진 디렉터리에 있은 파일과 디렉터리를 보여주는 것이다. 따라서 주어진 디렉터리 안에 있는 파일 목록을 구하는 함수가 필요다.

fn files_in(path: &Path, all: bool) -> io::Result<Vec<PathBuf>> {
    let mut results = vec![];
    for entry in fs::read_dir(path)? {
        let entry = entry?;
        let path = entry.path();
        let is_hidden = entry.file_name().as_os_str().as_bytes()[0] == b'.';

        if all || !is_hidden {
            results.push(path);
        }
    }

    results.sort();

    if all {
        results.insert(0, PathBuf::from("."));
        results.insert(1, PathBuf::from(".."));
    }

    Ok(results)
}

파일 이름이 .로 시작하면 숨김 파일로 판단한다. Unix 파생 OS에서나 유효한 방법이지만, 모든 OS에서 동작하는 명령을 만드려는 것은 아니므로, 그냥 넘어간다.

-a 옵션이 지정된 경우 ...를 표시해야 한다. fs::read_dir()의 리턴 값에는 ...이 포함되지 않으므로, -a 옵션이 지정된 경우에는 직접 추가하도록 했다.

files_in이 제대로 동작하는 지 확인하기 위해 main 함수를 다음과 같이 수정해보자.

fn main() -> io::Result<()> {
    for path in files_in(Path::new("."), true)? {
        println!("{path:?}");
    }

    Ok(())
}

cargo run을 실행하면 다음과 같이 현재 디렉터리에 있는 파일과 디렉터리 목록이 표시된다.

$ cargo run
   Compiling lsr v0.1.0 (/Users/ntalbs/ws/lsr)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.17s
     Running `target/debug/lsr`
"."
".."
"./.git"
"./.gitignore"
"./Cargo.lock"
"./Cargo.toml"
"./src"
"./target"

이 코드는 현재 디렉터리의 내용만 표시할 수 있다. 사용자가 지정한 디렉터리 내용을 표시할 수 있도록 하려면 명령행 인자 처리가 필요하다.

인자 처리

명령행 인자 처리에는 처음에 언급한대로 Clap을 사용할 것이다. main.rs에 명령행 인자를 저장할 구조체를 정의한다. 여기서는 -a-l 옵션만 지원할 것이다.

#[derive(Debug, Default, Parser)]
#[clap(version, about = "A very basic ls clone")]
pub struct Args {
    #[clap(default_value = ".", help = "List of files/directories")]
    paths: Vec<String>,
    #[clap(
        short('l'),
        long("long"),
        default_value_t = false,
        help = "Show hidden and 'dot' files including '.' and '..' directories"
    )]
    long: bool,
    #[clap(
        short('a'),
        long("all"),
        default_value_t = false,
        help = "Display extended file metadata as a table"
    )]
    all: bool,
}

모든 옵션 값은 디폴트로 false고, 아무런 인자를 주지 않으면 현재 디렉터리를 보여줄 것이므로 paths"."으로 설정한다.

명령행 인자가 제대로 처리되는지 확인하기 위해 main 함수 시작 부분에 다음 두 줄을 추가한다.

fn main() {
    let args = Args::parse();
    println!("{args:?}");
    ...
}

cargo run을 실행하면 다음과 같이 표시된다.

$ cargo run
   Compiling lsr v0.1.0 (/Users/ntalbs/ws/lsr)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.12s
     Running `target/debug/lsr`
Args { paths: ["."], long: false, all: false }
...

cargo run -- --help를 실행하면 다음과 같이 도움말이 표시된다.

A very basic ls clone

Usage: lsr [OPTIONS] [PATHS]...

Arguments:
  [PATHS]...  List of files/directories [default: .]

Options:
  -l, --long     Show hidden and 'dot' files including '.' and '..' directories
  -a, --all      Display extended file metadata as a table
  -h, --help     Print help
  -V, --version  Print version

목록 표시

ls 명령은 인자로 파일과 디렉터리를 목록으로 받을 수 있다. ls 명령으로 몇 가지 테스트 후 다음과 같은 사실을 알 수 있었다.

  • 인자가 파일일 경우는 해당 파일을 목록에 표시하고 디렉터리일 경우에는 디렉터리 안의 파일 목록을 표시한다.
  • 인자에 파일과 디렉터리가 섞여 있는 경우 알파벳 순으로 정렬된 파일이 먼저 출력되고 다음에 디렉터리 안의 파일 목록이 표시된다.
  • 디렉터리 안의 목록을 표시하기 전에 디렉터리 이름이 표시된다.

따라서 파일과 디렉터리를 다르게 처리하기 위해 분류할 필요가 있고, 인자로 전달된 파일/디렉터리 목록을 처리할 루프가 필요하다.

인자로 받은 파일/디렉터리 목록은 args.paths에 있다. Vec<String>으로 저장되어 있는데, 먼저 다음과 같이 Vec<PathBuf>로 바꿔둔다.

fn main() -> io::Result<()> {
    let args = Args::parse();

    let mut paths = args.paths
        .iter()
        .map(PathBuf::from)
        .collect::<Vec<PathBuf>>();
    ...

그 다음 파일, 디렉터리 중 파일이 먼저 오도록 하고, 이름으로 정렬한다.

...
    paths.sort_by(|a, b| {
        if a.is_dir() && !b.is_dir() {
            std::cmp::Ordering::Greater
        } else if !a.is_dir() && b.is_dir() {
            std::cmp::Ordering::Less
        } else {
            a.cmp(b)
        }
    });
...

그리고 다음과 같이 파일, 디렉터리로 파티션한다.

...
    let (files, directories): (Vec<_>, Vec<_>) = paths.iter()
        .cloned()
        .partition(|f| !f.is_dir());
...

마지막으로 파일과 디렉터리를 출력한다.

...
    for file in files {
        println!("{file:?}");
    }

    for dir in directories {
        println!("{dir:?}");
        for file in files_in(&dir, false)? {
            println!("\t{file:?}");
        }
    }

다음 단계

지금까지 주어진 인자에 대해 파일과 디렉터리 목록을 출력하는 코드를 작성했다. 아직 ls의 출력과는 거리가 멀지만, 기본 골격은 거의 구현한 셈이다. ls와 비슷해지려면 파일 목록을 그리드 형태로 출력할 수 있어야 하고, -l 옵션이 지정된 경우 파일의 크기, 모드, 생성일시 등의 부가 정보를 함께 출력할 수 있어야 한다.

ls 명령과 비슷하게 표시하는 방법은 2부에서 다룰 것이다.

참고

  • Command-Line Rust, Ken Youens-Clark: 마지막 장(14장)에서 간단한 ls 클론을 만드는 방법을 설명한다. 책에 나오는 소스는 깃헙 저장소에서 확인할 수 있다.
  • lsr lsr의 전체 소스 코드를 확인할 수 있다. -a, -l 외에도 몇 가지 옵션을 더 구현했다.
  • Rust에서 명령행 인자 파싱 Clap을 사용한 명령행 인자 파싱 방법을 조금 더 자세히 설명한다.
  • eza A modern, maintained replacement for ls. Rust로 구현한 ls 대체 명령. 원래 프로젝트는 exa인데 무슨 이유에서인지 몰라도 더 이상 유지보수가 되지 않고, eza로 포크되었다. 이름은 exa가 나은 것 같다.
  • lsd The next gen ls command. Rust로 구현한 차세대 ls 명령.