🦕

DenoでCLIツールを作ってみる:treeコマンドの実装

15 min read

7/7は七夕🎋です。
この絵文字が:tanabata_tree:ということで、Denoでtreeコマンドを作ってみました。

以下のような表示結果が得られます。

今回の完成品はGitHub Gistに上げています。

https://gist.github.com/kawarimidoll/c6f1c1007370b00bd4d345525490cdb8

作業記憶(Zenn Scrap)はこちら

はじめに

treeコマンドは、現在のディレクトリ以下のファイルを再帰的に階層付けて表示するコマンドです。

https://www.atmarkit.co.jp/ait/articles/1802/01/news025.html
こちらをDenoで実装していきます。

また、今回はstd/fsに定義されているwalkを参考にします。

https://deno.land/std@0.100.0/fs/walk.ts

これはカレントディレクトリ配下のファイルをAsyncIteratorで返却するため、Generatorを使った実装となっています。
今回作成するtreeは単に表示させるだけなので、普通にconsole.log()するだけとします。

ファイルを一覧表示する

Denoでは、Deno.readDir()で現在のディレクトリ直下のファイルを確認できます。

https://doc.deno.land/builtin/stable#Deno.readDir
これを用いて、カレントディレクトリ内のファイルを表示していきます。

カレントディレクトリ直下のファイルを一覧する

まずはDeno.readDir()の動きを確認しましょう。
std/pathjoinresolveでパスの処理を行います。

tree.ts
import { join, resolve } from "https://deno.land/std@0.100.0/path/mod.ts";

const tree = async (root: string) => {
  for await (const entry of Deno.readDir(root)) {
    console.log(join(root, entry.name));
  }
};

const dir = ".";
await tree(resolve(Deno.cwd(), String(dir)));

実行します。Deno.cwd()の実行のため--allow-readオプションが必要です。

❯ deno run --allow-read tree.ts
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/.vim
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/.DS_Store
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/main.ts
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/LICENSE
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/line_notify.ts
(略)

カレントディレクトリ直下のファイルがフルパスで表示されます。

後から使いやすいように、連結したパスをオブジェクトに含め、配列の順序を辞書順に直しておきましょう。

tree.ts
  import { join, resolve } from "https://deno.land/std@0.100.0/path/mod.ts";

+ export interface TreeEntry extends Deno.DirEntry {
+   path: string;
+ }

  const tree = async (root: string) => {
+   const entries: TreeEntry[] = [];
    for await (const entry of Deno.readDir(root)) {
-     console.log(join(root, entry.name));
+     const treeEntry = { ...entry, path: join(root, entry.name) };
+     entries.push(treeEntry);
    }

+   const sortedEntries = entries.sort((a, b) =>
+     a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1
+   );

+   for await (const entry of sortedEntries) {
+     console.log(entry.path);
+   }
  };

  const dir = ".";
  await tree(resolve(Deno.cwd(), String(dir)));

これで実行すると、ファイルが辞書順に並んで表示されます。

❯ deno run --allow-read tree.ts
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/.DS_Store
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/.env
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/.env.example
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/.git
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/.github
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/.gitignore
(略)

再帰的に深い階層のファイルを表示する

カレントディレクトリ直下のファイルを見るだけだとtreeになりません。

「表示したとき、それがディレクトリならそこをルートとして再帰」する処理を追加します。

tree.ts
(略)
    console.log(entry.path);
+   if (entry.isDirectory && entry.name !== ".git") {
+     await tree(entry.path);
+   }
(略)

本質的ではないのですが、.gitディレクトリを掘り進んでしまうと表示が増えて大変なので、決め打ちで除外しています。

実行しましょう。深い階層まで表示されます。

❯ deno run --allow-read tree.ts
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/.DS_Store
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/.env
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/.env.example
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/.git
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/.github
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/.github/workflows
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/.github/workflows/promote_zenn_article.yml
/Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/.github/workflows/welcome-deno.yml
(略)

表示を調整する

treeの特徴として、単純に全ファイルを表示するのではなく、ファイルの階層構造を可視化してくれるという点があります。
これを実装していきます。

階層に応じて字下げする

深い階層へ再帰するときにprefixを渡して字下げしてみましょう。
また、ここからはフルパスではなくファイル名だけを表示するようにします。

tree.ts
(略)
- const tree = async (root: string) => {
+ const tree = async (root: string, prefix = "") => {
(略)
-     console.log(entry.path);
+     console.log(prefix + entry.name);
    if (entry.isDirectory && entry.name !== ".git") {
-       await tree(entry.path);
+       await tree(entry.path, prefix + "  ");
    }
(略)

実行します。

❯ deno run --allow-read ttree.ts
.DS_Store
.env
.env.example
.git
.github
  workflows
    promote_zenn_article.yml
    welcome-deno.yml
.gitignore
.vim
  coc-settings.json
app.log
(略)

ディレクトリに入るごとに1段階字下げされています。だいぶtreeに近づいてきました。

罫線でディレクトリ構造を表現する

ファイル名の前に罫線("├── ")を表示させればtreeになります。
また、表示対象がそのディレクトリ内の最後の項目である場合には下への枝を省略します("└── ")。

以下のようになります。
ディレクトリが空の場合にスキップする処理も追加しています。

tree.ts
(略)
+ if (entries.length == 0) {
+   return;
+ }

  const sortedEntries = entries.sort((a, b) =>
    a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1
  );
+ const lastOne = sortedEntries[entries.length - 1];

  for await (const entry of sortedEntries) {
+   const branch = entry === lastOne ? "└── " : "├── ";

-   console.log(prefix + entry.name);
+   console.log(prefix + branch + entry.name);

    if (entry.isDirectory && entry.name !== ".git") {
      await tree(entry.path, prefix + "  ");
    }
(略)

実行すると罫線が表示されます。

❯ deno run --allow-read tree.ts
├── .DS_Store
├── .env
├── .env.example
├── .git
├── .github
  └── workflows
    ├── promote_zenn_article.yml
    └── welcome-deno.yml
├── .gitignore
(略)

でも、深い階層を表示したとき、上位の枝が途切れていますね。

上位階層の罫線をつなげる

字下げに使っているprefixが単純な空白なので途切れが発生しています。
これを調整して、途切れた部分をつなげましょう。

tree.ts
    if (entry.isDirectory && entry.name !== ".git") {
+      const indent = entry === lastOne ? "  " : "│  ";
-      await tree(entry.path, prefix + "  ");
+      await tree(entry.path, prefix + indent);
    }
❯ deno run --allow-read tree.ts
├── .DS_Store
├── .env
├── .env.example
├── .git
├── .github
│  └── workflows
│    ├── promote_zenn_article.yml
│    └── welcome-deno.yml
├── .gitignore
(略)

繋がりました。これにて階層構造の表示は完成です。

引数・オプションに対応する

せっかくなので、引数で表示対象のディレクトリを選択したり、オプションで表示内容を調整できるようにしましょう。
ハイフンなしの引数として、treeの起点となるディレクトリを指定できるようにしましょう。

さらに、もともとのtreeを参考に、以下のオプションを追加します。

  • -aでdotfiles表示(デフォルトでは表示しない)
  • -dでディレクトリのみ(デフォルトではファイル・ディレクトリ両方表示)
  • -L numで表示する階層を制限(デフォルトでは無限)

コマンド引数の基本

Deno.argsで引数を取得できます。ちょっとtree.tsとは違うファイルで実験してみましょう。

console.log(Deno.args);
❯ deno run main.ts param1 param2 param3
[ "param1", "param2", "param3" ]

引数が配列として取得できます。

これだけだと流石に使いづらいので、標準ライブラリのstg/flagsparseを使ってみましょう。

main.ts
+ import { parse } from "https://deno.land/std@0.100.0/flags/mod.ts";

  console.log(Deno.args);

+ console.log(parse(Deno.args));
❯ deno run main.ts -a AA -b BB --long LONG param1 param2 param3
[
  "-a",     "AA",
  "-b",     "BB",
  "--long", "LONG",
  "param1", "param2",
  "param3"
]
{ _: [ "param1", "param2", "param3" ], a: "AA", b: "BB", long: "LONG" }

Deno.argsの中身は単に配列ですが、parseを使うことでオブジェクトになっています。
-aのようにハイフンつきで渡されたものはオプションとみなされ、そのオプション名をkeyとして保存されます。
ハイフンのない通常の引数は_をkeyとして保存されます。

オプションパラメータを渡さなかったり、同名のオプションが含まれるとどうなるのでしょうか。

❯ deno run main.ts -a -b --long=LONG --long=LONG2 param1 param2 param3
[
  "-a",
  "-b",
  "--long=LONG",
  "--long=LONG2",
  "param1",
  "param2",
  "param3"
]
{ _: [ "param1", "param2", "param3" ], a: true, b: true, long: [ "LONG", "LONG2" ] }

上記のa bのように、パラメータが渡されなかった引数はtrueが保存されます。
longのように、同名のものが複数ある場合、配列にまとめられます。

オプションで表示を出し分ける

オプションに関しては最初の方に出していたstd/fswalkを参考にします。

https://deno.land/std@0.100.0/fs/walk.ts

treeを調整するオプションのインターフェースの定義はこのようにします。

tree.ts
// tree.tsの適当な箇所に追加
export interface TreeOptions {
  maxDepth?: number;
  includeFiles?: boolean;
  skip?: RegExp[];
}

このオプションをtree(root, prefix, options)の形で受け取ります。
そして、maxDepthの階層までたどり着いたら再帰をストップし、includeFilesfalseならファイルの表示をスキップします。skipには-aおよび-uオプションの有無に応じて、スキップ対象の正規表現を格納し、それとマッチした場合に表示をスキップします。

表示対象に含まれるかの条件判断を追加します。 walkの実装を流用したのでincludeという名前になっています。のちのちwalkに含まれている別のオプションも追加できるよう、このままとしました。

tree.ts
// tree.tsの適当な箇所に追加
function include(
  path: string,
  skip?: RegExp[],
): boolean {
  if (skip && skip.some((pattern): boolean => !!path.match(pattern))) {
    return false;
  }
  return true;
}

これをtree()に適用しましょう。diffが多くなるので、適用後のtree()を示します。コメントも追加しているので確認してみてください。

tree.ts
const tree = async (
  root: string,
  prefix = "",
  {
    maxDepth = Infinity,
    includeFiles = true,
    skip = undefined,
  }: TreeOptions = {},
) => {
  // depthが0 または スキップ対象なら終了
  if (maxDepth < 1 || !include(root, skip)) {
    return;
  }

  const entries: TreeEntry[] = [];
  for await (const entry of Deno.readDir(root)) {
    if (entry.isFile && !includeFiles) {
      continue;
    }
    entries.push({ ...entry, path: join(root, entry.name) });
  }

  // ディレクトリ内に項目が一つもなければ終了
  if (entries.length == 0) {
    return;
  }

  const sortedEntries = entries.sort((a, b) =>
    a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1
  );
  const lastOne = sortedEntries[entries.length - 1];

  for await (const entry of sortedEntries) {
    // 最後の項目かどうかで枝の形を調整
    const branch = entry === lastOne ? "└── " : "├── ";

    // ディレクトリなら末尾に "/" をつける
    const suffix = (entry.isDirectory) ? "/" : "";

    // 表示対象に該当するなら表示
    if (include(entry.path, skip)) {
      console.log(prefix + branch + entry.name + suffix);
    }

    // ディレクトリなら再帰
    if (entry.isDirectory) {
      const indent = entry === lastOne ? "  " : "│  ";
      // depthを減らす
      await tree(entry.path, prefix + indent, {
        maxDepth: maxDepth - 1,
        includeFiles,
        skip,
      });
    }
  }
};

なお、ここまでは再帰の際に.gitディレクトリかの判定を入れていましたが、これは次のaオプションの判定に含まれるので削除しています。
これで、オプションによって表示を変更できるようになりました。

コマンド引数をオプションとしてtree()に渡す

今回受け付けるオプションは、

  • -aでdotfiles表示(デフォルトでは表示しない)
  • -dでディレクトリのみ(デフォルトではファイル・ディレクトリ両方表示)
  • -L numで表示する階層を制限(デフォルトでは無限)

でした。
また、ハイフンなしの引数として、treeの起点となるディレクトリも受け取るのでした。

これは、以下の形で受け取れます。

tree.ts
const {
  a,
  d,
  L,
  _: [dir = "."],
} = parse(Deno.args);

dTreeoptionsincludeFilesに、Lは同じくmaxDepthに対応しているので、そのまま使えます。

aオプションがある、つまりa=trueの場合、頭文字が.のファイルをskipのリストに追加します。

tree.ts
const skip = [];
if (!a) {
  skip.push(/(^|\/)\./);
}

これを最初のtree()の呼び出し時に渡します。

tree.ts
await tree(resolve(Deno.cwd(), String(dir)), "", {
  maxDepth: L,
  includeFiles: !d,
  skip,
});

オプションによって表示の出し分けが可能になりました。

https://twitter.com/KawarimiDoll/status/1412339128943874060

おまけ:.gitignoreを認識する

「自動で.gitignoreを認識する」という機能もつけてみようと思います。これを解除するオプションの名前はripgrepを参考にuとします。

  • -u.gitignoreを無視(デフォルトでは.gitignoreを認識)

git管理から除外されているファイルは、git status --ignored -sコマンドの出力結果で先頭に!!が付きます。
したがって、tree呼び出し前にこのコマンドをDenoスクリプト内で実行し、その結果をskipに追加することで、.gitignoreの内容を利用できることになります。

これには、Deno.run()が使えます。

https://doc.deno.land/builtin/stable#Deno.run
tree.ts
if (!u) {
  const process = Deno.run({
    cmd: ["git", "status", "--ignored", "-s"],
    stdout: "piped",
    stderr: "piped",
  });
  const outStr = new TextDecoder().decode(await process.output());
  process.close();
  const ignoredList = outStr.replace(/^[^!].+$/gm, "").replace(/^!! /mg, "")
    .split("\n").filter((item) => item).concat(".git");

  ignoredList.forEach((str) => {
    // `.`は正規表現ではなくファイル名の一部なのでエスケープする
    skip.push(new RegExp(str.replace(".", "\\.")));
  });
}

この中に.gitディレクトリの判定も追加してしまいました。

Deno.run()を使うので、実行にはallow-runオプションが必要です。

❯ deno run --allow-read --allow-run tree.ts -a -u

おまけ:ヘルプを追加する

もはやtreeの動作とは関係ありませんが、良い機会なのでhオプションでヘルプも表示させましょう。

tree.ts
if (h) {
  const msg = `denotree
  'tree' powered by Deno

  USAGE
    denotree [dirname] : Show children of dirname. default dirname is pwd.

  OPTIONS
    a     : Show dotfiles
    d     : Show only directories
    u     : Show git ignored files
    L=num : Limit depth
    h     : Show this help message
  `;
  console.log(msg);
  Deno.exit(0);
}

インストールして使えるようにする

練習とはいえせっかく作ったので、deno installでどこからでも使えるようにしましょう。

https://deno.land/manual@v1.11.5/tools/script_installer

deno installのオプションには、実行に必要なパーミッションを渡します。
また、ファイル名はnameオプションで指定できます。今回はdenotreeとしました。
さらに、forceを追加すると同一名のファイルがあるときに上書き保存します。自作のスクリプトであれば上書き更新してしまって問題ないでしょう。

❯ deno install --allow-read --allow-run --force --name denotree tree.ts

なお、インストール先は~/.deno/binです[1]。このディレクトリにPATHが通っていることを確認してください。

これで、どこからでもdenotreeが使えるようになります。

https://twitter.com/KawarimiDoll/status/1412375243667111939

おわりに

以上、denotreeの実装でした。
車輪の再開発は勉強になりますね。

最初にも示しましたが、今回の完成品はGitHub Gistに上げています。

https://gist.github.com/kawarimidoll/c6f1c1007370b00bd4d345525490cdb8

本文中ではローカルのtree.tsdeno installしましたが、DenoはGistのファイルをdeno installすることもできます。
動作が気になる方は、とりあえず上のGistからインストールして確かめることもできます。

インストールされたファイルの中身は、以下のように「対象のファイルをオプションつけてdeno runするだけ」のシェルスクリプトです。潔いですね。

~/.deno/bin/denotree
#!/bin/sh
# generated by deno install
exec deno run --allow-read --allow-run 'https://gist.githubusercontent.com/kawarimidoll/c6f1c1007370b00bd4d345525490cdb8/raw/af0ed893aec412bbf1dcb0e4a87f56507ef831b8/tree.ts' "$@"

なお、公開されているものをインストールする場合、外部のスクリプトに自身の環境内での実行権限を与えてしまうことになります。パーミッションの指定(特にallow-netとかallow-runとか)には十分注意してください。

参考

https://numb86-tech.hatenablog.com/entry/2020/08/25/213828

https://levelup.gitconnected.com/build-a-cli-tool-deno-by-example-79d39f25eb0a
脚注
  1. これもオプションで変更可能です ↩︎