見出し画像

Denoでlsコマンドっぽいものを作ってみる

あけましておめでとうございます。
1年ぶりくらいに記事を書いているションローです。
昨年は大変にやる気がでずなかなか筆が進まなかったのですが、今年はちょこちょこ書いていけるように頑張ろうと思います。ホント頑張ろう。

Denoについて

DenoはJavaScript用のランタイム環境ですが、CLI用のアプリケーションを作るのに便利という噂を聞いて今回勉強してみました。
TypeScriptのネイティブサポートや、許可するパーミッションを明示的に指定する点などが特徴のようです。詳しくは公式の説明をご確認ください。

lsコマンドっぽいものを作ってみる

lsコマンドの再実装は新しい言語・環境を勉強する際にちょうど良いのでオススメです。(と、前職の先輩に言われてからやるようになりました)
言語の基本的な機能はもちろん、引数や環境変数の扱い、ファイルシステム周り、テストコードの実装など、抑えておくと割と役立つ機能を一通り触れるなーという実感があります。

lsコマンドについて

macOSで man ls を実行した際の一部を拝借すると以下のようになっています。

NAME
ls -- list directory contents

特定のディレクトリ以下に存在するコンテンツ(ファイルやディレクトリ等)の一覧を出力してくれるコマンドですね。
実際に実行してみると以下の様に出力されます。

lsdeno git:(main) ls
Makefile  README.md build     lsdeno.ts src       test

また、いくつかオプションが存在しておりよく使われるのは a と l なんじゃないかなと思います。(偏見)

lsdeno git:(main) ls -a
.           .git        .gitignore  .vscode     README.md   lsdeno.ts   test
..          .github     .prettierrc Makefile    build       srclsdeno git:(main) ls -l
total 24
-rw-r--r--  1 takayuki  staff  251  1  3 20:09 Makefile
-rw-r--r--  1 takayuki  staff  351  1  3 20:03 README.md
drwxr-xr-x  3 takayuki  staff   96  1  3 20:02 build
-rw-r--r--  1 takayuki  staff  175  1  2 23:48 lsdeno.ts
drwxr-xr-x  7 takayuki  staff  224  1  3 02:18 src
drwxr-xr-x  7 takayuki  staff  224  1  3 02:26 test

a オプションはドットで始まるファイルとディレクトリを含んで表示する、l オプションはコンテンツをロングフォーマットで表示するオプションです。

ロングフォーマットの詳細は man ls で確認できますが、見て分かる通り「権限、ハードリンク数、ユーザー、グループ、ファイルサイズ、最終編集日時、パス」を出力するフォーマットです。
今回は簡易的にですが a と l オプションも実装してみました。

実装してみた

以下が実装してみたリポジトリです。

Denoでコンパイルする

Deno は compile で実行可能ファイルを生成することが出来ます。
クロスコンパイルも可能なようなので、Linux上でWindowsやmacOS用のバイナリを生成して配布することも可能です。便利ですね!

動かしてみる

ls, lsdeno の出力としては(改行が発生しない場合)同等の出力になりました。

改行時のフォーマットを合わせるところまでは作れなかったのと、a オプションでカレント(.)と1階層上(..)がファイルシステムでサクッと取れなかったので今回はスルーしました。

l オプションについては、ハードリンク数は何をとっているのかいまいちわかんなかったので実装しなかったのと、権限はフォーマットを頑張るのが大変だったので8進数表示にしました。
あとユーザ名、グループはidが取得できたものの文字列の取得方法がわからんかったので今回は諦めました。

若干足りない部分はありつつ、大枠は実装できたかなと思います。
以降は実装の説明になります。

エントリーポイント

import Args from "./src/args.ts"
import Executor from "./src/executor.ts"

const args = Args.parse(Deno.args, Deno.cwd())
const command = new Executor(args)
command.execute()

エントリーポイントは lsdeno.ts です。
引数を受け取る Args クラスに Deno.args で取得した引数(文字列)の配列と実行時のパスを渡しています。
その後コマンド本体の Executorクラスのインスタンスを作り、Argsを渡して引数の解析をしつつ処理を実行する構成です。

引数の解析

import { parse } from "https://deno.land/std@0.113.0/flags/mod.ts"

export default class Args {
    path: string
    hasOptionA: boolean
    hasOptionL: boolean

    constructor(path: string, hasOptionA: boolean, hasOptionL: boolean) {
        this.path = path
        this.hasOptionA = hasOptionA
        this.hasOptionL = hasOptionL
    }

    public static parse(args: string[], currentPath: string): Args {
        const parsedArgs = parse(args)
        const pathsInArgs = args.filter(arg => !arg.startsWith("-"))
        const fst = pathsInArgs[0] // 今回は複数パスには対応させない
        const challenge = fst === undefined ? currentPath : `${currentPath}/${fst}`
        const existsFileOrDir = Deno.statSync(challenge)
        const path = existsFileOrDir ? challenge : currentPath
        const hasOptionA = parsedArgs["a"] !== undefined
        const hasOptionL = parsedArgs["l"] !== undefined
        return new Args(path, hasOptionA, hasOptionL)
    }
}

引数は標準ライブラリに存在する parse 関数を使うと簡単に解析可能です。

コマンド本体

export default class Executor {
    path: string
    entries: DirEntry[]
    formatter: Formatter
    filter: EntriesFilter

    constructor(args: Args) {
        this.path = args.path
        const denoEntries = Array.from(Deno.readDirSync(args.path))
        this.entries = denoEntries.map(e => new DirEntry(e, Deno.statSync(this.path + "/" + e.name)))
        this.formatter = args.hasOptionL ? new LongFormatter() : new DefaultFormatter()
        this.filter = args.hasOptionA ? new EmptyFilter() : new DotfileFilter()
    }

    public execute() {
        const filtered = this.filter.filter(this.entries)
        const output = this.formatter.format(filtered)
        console.log(output)
    }
}

コマンド本体が行っているのは以下です。

  1.  渡された引数を解析し、オプションの有無とパスを受け取る

  2. パスを元にエントリー(ファイルとディレクトリ)の一覧を取得する

  3. オプションを元に選択された Filter を使ってエントリーをフィルタリングする

  4. フィルタリングされたエントリーをFormatterに渡し、標準出力に出力する文字列を得る

  5. 出力する

フィルタとフォーマット

フィルタは a オプション、フォーマットは l オプションに紐付いています。
フィルタはシンプルで、a オプションの有無でドット始まりのエントリーをフィルタリングするかどうかを判定しているだけです。

export class EmptyFilter implements EntriesFilter {
    filter(entries: DirEntry[]): DirEntry[] {
        return entries
    }
}

export class DotfileFilter implements EntriesFilter {
    filter(entries: DirEntry[]): DirEntry[] {
        return entries.filter(e => !e.denoEntry.name.startsWith("."))
    }
}

フォーマットは渡されたエントリーの一覧を文字列に変換する処理を持っています。

export class DefaultFormatter implements Formatter {
    public format(entries: DirEntry[]): string {
        const stringLength = Math.max(...entries.map(e => e.denoEntry.name.length))
        const sorted = entries.sort((a, b) => (a.denoEntry.name > b.denoEntry.name ? 1 : -1))
        const decorated = sorted.map(e => {
            const name = e.denoEntry.name
            if (name.length >= stringLength) {
                return e.denoEntry.isDirectory ? decorateDir(name) : name
            }
            const sabun = stringLength - name.length
            const coloered = e.denoEntry.isDirectory ? decorateDir(name) : name
            const padded = coloered.concat("", "".padEnd(sabun, " "))
            return padded
        })
        return decorated.reduce((acc, val) => `${acc} ${val}`)
    }
}

// ファイルモード、リンク数、所有者名、グループ名、ファイルのバイト数、月日、ファイルの最終更新時刻、時間、分、パス名
export class LongFormatter implements Formatter {
    public format(entries: DirEntry[]): string {
        const sizeLength = Math.max(...entries.map(e => e.fileInfo.size.toString().length))
        const sorted = entries
            .sort((a, b) => (a.denoEntry.name > b.denoEntry.name ? 1 : -1))
            .map(e => {
                const mode = (e.fileInfo.mode! & parseInt("7777", 8)).toString(8) // 数字で表示する
                const links = 0 // 今回は実装なし
                const uid = e.fileInfo.uid // idで表示する
                const gid = e.fileInfo.gid // idで表示する
                const fileSize = e.fileInfo.size.toString().padStart(sizeLength, " ")
                const mtime = e.fileInfo.mtime!
                const month = dateformat(mtime, "M")
                const day = dateformat(mtime, "d")
                const time = dateformat(mtime, "HH:mm")
                const name = e.denoEntry.isDirectory ? decorateDir(e.denoEntry.name) : e.denoEntry.name
                return `${mode}  ${links}  ${uid}  ${gid}  ${fileSize}  ${month}  ${day}  ${time}  ${name}`
            })
        const output = sorted.reduce((acc, val) => `${acc}\n${val}`)
        return output
    }
}

テスト

フィルタとフォーマットについてはテストも実装しました。
Denoは組み込みのテストランナーが存在するので、それを呼び出すだけでテストが実行出来て楽ちんですね!

Deno.test("何もフィルタリングされない", () => {
    const filter = new EmptyFilter()
    const result = filter.filter(mockedEntries)
    const expected = mockedEntries

    assertEquals(result, expected)
})

Deno.test("名前がドットで始まるファイルがフィルタリングされる", () => {
    const filter = new DotfileFilter()
    const result = filter.filter(mockedEntries)
    const expected = [
        new DirEntry(new MockEntry("hoge", true, false, false), mockedFileInfo),
        new DirEntry(new MockEntry("fuga", true, false, false), mockedFileInfo),
        new DirEntry(new MockEntry("nyassu", false, true, false), mockedFileInfo),
        new DirEntry(new MockEntry("mechakuchanagaitext", false, true, false), mockedFileInfo),
    ]

    assertEquals(result, expected)
})

Deno.test("文字列がソートされかつ文字列長が一番長い文字列にあわせてパディングされた状態になる", () => {
    const formatter = new DefaultFormatter()
    const result = formatter.format(mockedEntries)
    const expected =
        ".hoge               \u001b[1;96m.nyassu\u001b[0m             fuga                hoge                \u001b[1;96mmechakuchanagaitext\u001b[0m \u001b[1;96mnyassu\u001b[0m             "
    assertEquals(result, expected)
})

Deno.test("文字列がソートされかつls -lっぽいフォーマットになる", () => {
    const formatter = new LongFormatter()
    const result = formatter.format(mockedEntries)
    const expected =
        "0  0  0  0  0  2  1  00:00  .hoge\n" +
        "0  0  0  0  0  2  1  00:00  \u001b[1;96m.nyassu\u001b[0m\n" +
        "0  0  0  0  0  2  1  00:00  fuga\n" +
        "0  0  0  0  0  2  1  00:00  hoge\n" +
        "0  0  0  0  0  2  1  00:00  \u001b[1;96mmechakuchanagaitext\u001b[0m\n" +
        "0  0  0  0  0  2  1  00:00  \u001b[1;96mnyassu\u001b[0m"
    assertEquals(result, expected)
})

また、GitHub ActionsでCIを回しているのでそこでテストが実行されている様子を見ることも出来ます。

まとめ

今回は Deno を使ってlsっぽいコマンドを実装してみました。
DenoはNode.jsに比べて、TypeScriptをネイティブサポートしている点や、パッケージ管理がシンプルな点など、開発の立ち上がりが非常に早い印象でした。
コマンドライン引数へのアクセスも容易で、オプションの取得周り書き味が良いし、クロスコンパイルも可能ということでCLI用アプリケーションの作成には非常に向いているなと思いました。

また、Node.jsで実装されたリソースも一部利用可能だったりはするのですが、それなりの制約があり全てが簡単に使えるわけでは無いようです。
標準ライブラリの範囲で実装可能なアプリケーションならDenoで十分ですが、サードパーティ製のライブラリを使いたくなるような状況だと、現時点ではNode.jsが現実的かもしれません。

この辺りは今後も色々試して行こうかなと思います。
それでは!