DenoでCLIツールを作ってみる:treeコマンドの実装
7/7は七夕🎋です。
この絵文字が:tanabata_tree:
ということで、Denoでtree
コマンドを作ってみました。
以下のような表示結果が得られます。
今回の完成品はGitHub Gistに上げています。
作業記憶(Zenn Scrap)はこちら
はじめに
tree
コマンドは、現在のディレクトリ以下のファイルを再帰的に階層付けて表示するコマンドです。
こちらをDenoで実装していきます。
また、今回はstd/fs
に定義されているwalk
を参考にします。
これはカレントディレクトリ配下のファイルをAsyncIterator
で返却するため、Generatorを使った実装となっています。
今回作成するtree
は単に表示させるだけなので、普通にconsole.log()
するだけとします。
ファイルを一覧表示する
Denoでは、Deno.readDir()
で現在のディレクトリ直下のファイルを確認できます。
これを用いて、カレントディレクトリ内のファイルを表示していきます。
カレントディレクトリ直下のファイルを一覧する
まずはDeno.readDir()
の動きを確認しましょう。
std/path
のjoin
とresolve
でパスの処理を行います。
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
(略)
カレントディレクトリ直下のファイルがフルパスで表示されます。
後から使いやすいように、連結したパスをオブジェクトに含め、配列の順序を辞書順に直しておきましょう。
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
になりません。
「表示したとき、それがディレクトリならそこをルートとして再帰」する処理を追加します。
(略)
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
を渡して字下げしてみましょう。
また、ここからはフルパスではなくファイル名だけを表示するようにします。
(略)
- 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になります。
また、表示対象がそのディレクトリ内の最後の項目である場合には下への枝を省略します("└── "
)。
以下のようになります。
ディレクトリが空の場合にスキップする処理も追加しています。
(略)
+ 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
が単純な空白なので途切れが発生しています。
これを調整して、途切れた部分をつなげましょう。
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/flags
のparse
を使ってみましょう。
+ 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/fs
のwalk
を参考にします。
tree
を調整するオプションのインターフェースの定義はこのようにします。
// tree.tsの適当な箇所に追加
export interface TreeOptions {
maxDepth?: number;
includeFiles?: boolean;
skip?: RegExp[];
}
このオプションをtree(root, prefix, options)
の形で受け取ります。
そして、maxDepth
の階層までたどり着いたら再帰をストップし、includeFiles
がfalse
ならファイルの表示をスキップします。skip
には-a
および-u
オプションの有無に応じて、スキップ対象の正規表現を格納し、それとマッチした場合に表示をスキップします。
表示対象に含まれるかの条件判断を追加します。 walk
の実装を流用したのでinclude
という名前になっています。のちのちwalk
に含まれている別のオプションも追加できるよう、このままとしました。
// 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()
を示します。コメントも追加しているので確認してみてください。
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
の起点となるディレクトリも受け取るのでした。
これは、以下の形で受け取れます。
const {
a,
d,
L,
_: [dir = "."],
} = parse(Deno.args);
d
はTreeoptions
のincludeFiles
に、L
は同じくmaxDepth
に対応しているので、そのまま使えます。
a
オプションがある、つまりa=true
の場合、頭文字が.
のファイルをskip
のリストに追加します。
const skip = [];
if (!a) {
skip.push(/(^|\/)\./);
}
これを最初のtree()
の呼び出し時に渡します。
await tree(resolve(Deno.cwd(), String(dir)), "", {
maxDepth: L,
includeFiles: !d,
skip,
});
オプションによって表示の出し分けが可能になりました。
.gitignore
を認識する
おまけ:「自動で.gitignore
を認識する」という機能もつけてみようと思います。これを解除するオプションの名前はripgrepを参考にu
とします。
-
-u
で.gitignore
を無視(デフォルトでは.gitignore
を認識)
git管理から除外されているファイルは、git status --ignored -s
コマンドの出力結果で先頭に!!
が付きます。
したがって、tree
呼び出し前にこのコマンドをDenoスクリプト内で実行し、その結果をskip
に追加することで、.gitignore
の内容を利用できることになります。
これには、Deno.run()
が使えます。
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
オプションでヘルプも表示させましょう。
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
でどこからでも使えるようにしましょう。
deno install
のオプションには、実行に必要なパーミッションを渡します。
また、ファイル名はname
オプションで指定できます。今回はdenotree
としました。
さらに、force
を追加すると同一名のファイルがあるときに上書き保存します。自作のスクリプトであれば上書き更新してしまって問題ないでしょう。
❯ deno install --allow-read --allow-run --force --name denotree tree.ts
なお、インストール先は~/.deno/bin
です[1]。このディレクトリにPATHが通っていることを確認してください。
これで、どこからでもdenotree
が使えるようになります。
おわりに
以上、denotree
の実装でした。
車輪の再開発は勉強になりますね。
最初にも示しましたが、今回の完成品はGitHub Gistに上げています。
本文中ではローカルのtree.ts
をdeno install
しましたが、DenoはGistのファイルをdeno install
することもできます。
動作が気になる方は、とりあえず上のGistからインストールして確かめることもできます。
インストールされたファイルの中身は、以下のように「対象のファイルをオプションつけてdeno run
するだけ」のシェルスクリプトです。潔いですね。
#!/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
とか)には十分注意してください。
参考
-
これもオプションで変更可能です ↩︎
Discussion