deno-cliffyでCLIツールを作る:`denosay`の実装
始まりは いつも とつZenn
先日、DenoでCLIツールを作る記事を書きました。
この記事ではDenoの標準ライブラリのみを使っていましたが、今回はCLIフレームワークのdeno-cliffyを使用し、より実践的なCLIツールを作成していきます。
こちらのcowsay
を参考に、denosay
を作成します。
完成品のイメージはこんな感じです。
いいか、AAに前振りは無え
denosay
に必須となるものは何でしょうか。Ascii Artですね。
公式のArtworkにはそれっぽいものはありません。
ということで作ります。
上記のArtworkのページや、以下の記事のヘッダーを参考にしました。
const AA = String.raw`
_
( ・ヽ
\ \
⎞ \
| `ヽ
⎩ ト、
u¯u︶u
`;
作りました。
改行を含む文字列ではテンプレートリテラル(バッククォート囲み文字列)を使うと書きやすいですが、テンプレートリテラルではバックスラッシュがエスケープされるため、AAの作成では扱いづらい場合があります。
具体的には、Deno君の首が吹き飛びます。
このため、今回はString.raw()
を使用しています。これなら、バッククォート以外の文字列をそのまま記述できるため、定義と出力の乖離を抑えられます。
バッククォートを使う方法
テンプレートリテラルでも同様ですが、変数展開を使うのが一番簡単です。
const bq = "`"
const str = String.raw`This is back quote: ${bq}`;
console.log(str);
AA 初期案
さらっと書いてますが、AAを作ったのは初めてだったので、丸っこいデザインに近づけるのに試行錯誤しました。
顔文字紹介サイトや特殊文字紹介サイトを回り、良さげなカーブの文字を探し、コピペして表示を確認する…ということを繰り返して調整しました。
___
( o)
\ \
\ \
| \__
| \
u-u---u
( ゚)
\ \
\ \
| `ヽ
! ト、
u'u~~u
理論的な正しさを確認しようがないので、完璧な出来とは言えないし、決定稿よりこちらの初期案のほうが好みという方もいるかもしれません。
また、同様の方法で別のAAを作れるかもわかりません。
AAは職人芸みたいなイメージがありますが、描線抽出や文字形状マッチングなどの技術で自動化できたりするんですかね?
吹き出しに…釣られてみる?
文字を囲む処理
cowsay
がやっていることは「入力した文字を吹き出しで囲んで表示、その下にAAを表示」の2つです。
AAの方は既に作成したので、文字を囲む処理を作ります。といっても、今回はcowsay
のコードというお手本があります。このballoon.js
を再実装しましょう。
以下のように、「入力した文字を吹き出しで囲んで表示、その下にAAを表示」するrender()
と、それを呼び出すdenosay()
を定義します。
delimiters
はごちゃごちゃ書いていますが、吹き出しの外形を決めているだけです。
type Delimiters = {
first: [string, string];
middle: [string, string];
last: [string, string];
only: [string, string];
vertical: [string, string];
};
function render(text: string, delimiters: Delimiters, thoughts: string) {
return renderBalloon(text, delimiters) + renderAA(thoughts);
}
export function denosay(text: string) {
const delimiters: Delimiters = {
first: ["/", "\\"],
middle: ["|", "|"],
last: ["\\", "/"],
only: ["<", ">"],
vertical: ["_", "-"],
};
return render(text, delimiters, "\\");
}
render()
内では吹き出しを描画するrenderBaloon()
とAA本体を描画するrenderAA()
を定義しています。
まずrenderBaloon()
の実装を提示しましょう。
import stringWidth from "https://cdn.skypack.dev/string-width";
function surround(cover: [string, string], text: string, padding = " ") {
return cover[0] + padding + text + padding + cover[1];
}
function renderBalloon(text: string, delimiters: Delimiters) {
if (!text) {
throw new Error("Input text is required");
}
const lines = text.split("\n");
const maxLength = lines.reduce(
(acc, current) => Math.max(stringWidth(current), acc),
0,
);
const top = " " + delimiters.vertical[0].repeat(maxLength + 2);
const bottom = " " + delimiters.vertical[1].repeat(maxLength + 2);
const balloon = [top];
if (lines.length == 1) {
balloon.push(surround(delimiters.only, lines[0]));
} else {
for (let i = 0, len = lines.length; i < len; i++) {
const delimiter = (i === 0)
? delimiters.first
: (i === len - 1)
? delimiters.last
: delimiters.middle;
balloon.push(
surround(
delimiter,
lines[i] + " ".repeat(maxLength - stringWidth(lines[i])),
),
);
}
}
balloon.push(bottom);
return balloon.join("\n");
}
まず、入力値text
の存在確認をしたあと、各行を分解して、含まれる最大の文字列幅を調べています。hello
とこんにちは
では、おなじ5文字でも表示幅が異なりますよね。そこで、こちらのstring-width
を読み込んで使用しています。
npmのモジュールをSkypack経由で読み込んでDenoで使用する方法については以下の記事を参考にしました [1]。
その後は数えた文字幅に従って上部(top
)と下部(bottom
)の囲みを作り、中間部はsurround()
を利用して「左右から挟む」処理を行っています。
また、最初の行、途中の行、最後の行では左右に使う文字が異なるため、カウンタを使ったfor
ループで処理しています。
そして、各行の文字幅の差に対応できるよう、" ".repeat()
で行末空白を設定しています。ここは.padEnd()
が使えるともっとシンプルになるのですが、文字幅の差を吸収するためにはこうするしかないようです。
ということで、ちょっとネストが深くなっていますが、読めばそれほど複雑なコードではないことがわかると思います。
つづいてrenderAA()
です。基本的には単にAA
を返すだけなのですが、AAから吹き出しへ伸びる線の処理を行っています。引数名はcowsay
のリポジトリに倣い、thoughts
としました。
const AA = String.raw`
$T
$T _
( ・ヽ
\ \
⎞ \
| `ヽ
⎩ ト、
u¯u︶u
`;
function renderAA(thoughts = " "): string {
return AA.replaceAll("$T", thoughts);
}
これで、「入力した文字を吹き出しで囲んで表示、その下にAAを表示」することができました。
テストとして、行数の異なる文字列を表示させてみます。
含まれる文字の最大幅に合わせて吹き出しが作られていますね。
この時点での`mod.ts`全体像
import stringWidth from "https://cdn.skypack.dev/string-width";
function surround(cover: [string, string], text: string, padding = " ") {
return cover[0] + padding + text + padding + cover[1];
}
function renderBalloon(text: string, delimiters: Delimiters) {
if (!text) {
throw new Error("Input text is required");
}
const lines = text.split("\n");
const maxLength = lines.reduce(
(acc, current) => Math.max(stringWidth(current), acc),
0,
);
const top = " " + delimiters.vertical[0].repeat(maxLength + 2);
const bottom = " " + delimiters.vertical[1].repeat(maxLength + 2);
const balloon = [top];
if (lines.length == 1) {
balloon.push(surround(delimiters.only, lines[0]));
} else {
for (let i = 0, len = lines.length; i < len; i++) {
const delimiter = (i === 0)
? delimiters.first
: (i === len - 1)
? delimiters.last
: delimiters.middle;
balloon.push(
surround(
delimiter,
lines[i] + " ".repeat(maxLength - stringWidth(lines[i])),
),
);
}
}
balloon.push(bottom);
return balloon.join("\n");
}
const AA = String.raw`
$T
$T _
( ・ヽ
\ \
⎞ \
| `ヽ
⎩ ト、
u¯u︶u
`;
function renderAA(thoughts = " "): string {
return AA.replaceAll("$T", thoughts);
}
type Delimiters = {
first: [string, string];
middle: [string, string];
last: [string, string];
only: [string, string];
vertical: [string, string];
};
function render(text: string, delimiters: Delimiters, thoughts: string) {
return renderBalloon(text, delimiters) + renderAA(thoughts);
}
export function denosay(text: string) {
const delimiters: Delimiters = {
first: ["/", "\\"],
middle: ["|", "|"],
last: ["\\", "/"],
only: ["<", ">"],
vertical: ["_", "-"],
};
return render(text, delimiters, "\\");
}
const messages = [
"コーヒーいかがですか?",
`ゆったり構えていれば、
星は自然とめぐるんです`,
`記憶こそが時間。
そしてそれこそが、
人を支える`,
];
messages.forEach((message) => {
console.log(denosay(message));
});
最下部のmessages
の定義とその表示は今回の動作確認のために載せているので、この後モジュールとして使っていく場合は削除してください。
なお、吹き出しの線として、なぜAA
内に直接\
を書くのではなくrenderAA()
内で$T
を置換しているのかは、次のパートでわかります。
吹き出しのバリエーション
普通に喋る吹き出しの他に、「考える」吹き出しと「叫ぶ」吹き出しも追加してみましょう。
render()
へ渡すdelimiters
およびthoughts
を変化させます。
export function denothink(text: string) {
const delimiters: Delimiters = {
first: ["(", ")"],
middle: ["(", ")"],
last: ["(", ")"],
only: ["(", ")"],
vertical: ["◠", "◡"],
};
return render(text, delimiters, "o");
}
export function denoshout(text: string) {
const delimiters: Delimiters = {
first: ["<", ">"],
middle: ["<", ">"],
last: ["<", ">"],
only: ["<", ">"],
vertical: ["^", "v"],
};
return render(text, delimiters, "\\");
}
これをやりたいためにdelimiters
やthoughts
を動的に設定できるようにしていたのでした。
これでモジュールとしては完成です。denosay
denothink
denoshout
がexport
されているので、これを他のファイルでimport
し、text
を渡して呼び出せば、それぞれに応じた表示を生成できます。
Command
で拭いとき!
涙は
Command
API
deno-cliffyのでは、deno-cliffyを使ってコマンドを作っていきます。Command
APIでは、メソッドチェーンの形でCLIコマンドを定義することができます。
簡単な例を作ってみます。
import { Command } from "https://deno.land/x/cliffy@v0.19.2/command/mod.ts";
const { options, args } = await new Command()
.name("cliffy sample command")
.version("0.1.0")
.description("Command line arguments parser")
.option("-a, --all", "show all.")
.arguments("<arg>")
.parse(Deno.args);
console.log({ args });
console.log({ options });
これを見ただけで、どのようなことを定義しているか、何となく分かるかと思います。
-
new Command()
でコマンドのインスタンスを生成します -
.name()
でコマンドの名前を定義しています -
.version()
でコマンドのバージョンを定義しています- これにより
-V
および--version
オプションが自動で追加されます
- これにより
-
.description()
でコマンドの説明を設定しています -
.option()
でコマンドのオプションを定義しています -
.arguments()
でコマンドの引数を定義しています -
parse()
スクリプトに渡された引数(Deno.args
)をコマンドへ渡してパースしています- この結果が全体のチェーンが返すオブジェクトに入ります
- これにより
const { options, args }
で受け取れます
実行例を示しましょう。
まずは何も引数を渡さず実行してみます。
今回は引数arg
を受け取ることを想定している(<>
で定義すると必須引数、[]
で定義するとオプショナル引数となる)ため、エラーとなります。この場合、コマンドの説明が表示されます。
定義した内容が表示されていますね。良い感じに色もついています。
記載されている通り、-h
または--help
オプションでこの説明を表示できます。
次に、オプションと引数を渡してみましょう。
❯ deno run command.ts -a ok
{ args: [ "ok" ] }
{ options: { all: true } }
❯ deno run command.ts no-option
{ args: [ "no-option" ] }
{ options: {} }
このように、オプションは長い名前(a
ではなくall
)のほうをキーとしたオブジェクトで、引数は配列として返されます。
細かい設定はまだまだあるのですが、それを網羅しようとするとCLIツールの実装に進めないので、これ移行は実際にdenosay
を作りつつ確認していきましょう。
なお、deno-cliffyのWebページ上に載っている例は、基本的に同一内容のコードがモジュールライブラリに保存されているため、コピペしなくてもdeno run https://deno.land/x/cliffy/examples/...
で直接実行することができます。
ドキュメントを読んでいく際は、これで動作を確かめながら進めていくと良いと思います。
モジュールをCLI化する
使い方もわかったところで、いよいよdenosay
をCLI化します。
上の方で作ったmod.ts
をcli.ts
で読み込み、コマンドで受け取ったオプションや引数に応じて呼び出します。
import { Command } from "https://deno.land/x/cliffy@v0.19.2/command/mod.ts";
import { denosay, denoshout, denothink } from "./mod.ts";
const { options, args } = await new Command()
.name("denosay")
.description("Command line arguments parser")
.description("Say your awesome text with ascii art.")
.option("-t, --think", "Change balloon to rounded.")
.option("-s, --shout", "Change balloon to spiky.", { conflicts: ["think"] })
.arguments("<text>")
.parse(Deno.args);
const {
think,
shout,
} = options;
const runner = think ? denothink : shout ? denoshout : denosay;
console.log(runner(String(args[0])));
-t
または--think
オプションでdenothink()
を、-s
または--shout
オプションでdenoshout()
を、そのどちらも設定されていなければdenosay()
を呼び出しているだけです。
既に見たように引数は配列として返されるので、args[0]
として要素を取り出しています。
ここで、-s
オプションを定義しているoption()
の第三引数のオブジェクトにconflicts
を追加しています。
これだけで、shout
はthink
と同時に使えないという制限を追加できます。簡単ですね。
オプション、足すけどいいよね? 答えは聞いてない!
rain
オプション
Denoのロゴのモチーフは「夜の雨と恐竜」です。以下の記事で詳しく記載されています。
standing in the rain at night - stoically facing the dark battle that is software engineering
かっこいいですね。Deno DeployのTOPも「夜の雨」ですし。
ということでrain
オプションを実装します。
AAの背景に,
や'
を良い感じに配置すること雨っぽさを出します。
最初はランダムに追加しようと思ったのですが、そもそもAAの「図と地」を区別して背景を認識するのが難しい上に、一直線に配置されちゃったりするとバランスがおかしくなるという問題がありました。
そこで、はじめから決め打ちで雨をAA内に入れておき、rain
オプションがない場合にそれを消す、という実装にしました。
AA
およびrenderAA()
が以下のように変わります。
const AA = String.raw`
, $T , ' , ' , '
' $T _ , ' ,
, ( ・ヽ, , , ,
, , , \ \ , ,
, ' ,⎞ \ , , '
, , | `ヽ , , '
' , , ⎩ ト、 , ,
' ' u¯u︶u '
`;
function renderAA(thoughts = " ", rain = false): string {
return (rain ? AA : AA.replaceAll(/,|'/g, " ")).replaceAll("$T", thoughts);
}
cli.ts
の方にもオプション定義を追加しましょう。これをrunner
を経由してrenderAA
へ渡せばOKです。
(略)
.option("-r, --rain", "Make it rain.")
(略)
const {
think,
shout,
rain,
} = options;
const runner = think ? denothink : shout ? denoshout : denosay;
console.log(runner(String(args[0]), { rain }));
各runner
の修正は省略します。
eye
オプション
もともとのcowsay
は、眼に使っている文字を変えられるオプションが大量にあります。
ただ、これを全部実装してもCLIの勉強としては面白くないと思ったのと、各々conflict
設定を足すのが嫌だったので、任意の文字を眼に使える-e
および--eye
オプションのみ実装することにしました[2]。
rain
と同様に、AA
およびrenderAA()
を修正します。
コード内の表示がちょっと崩れてしまいますが、この程度なら許容範囲ということにしました。
const AA = String.raw`
, $T , ' , ' , '
' $T _ , ' ,
, ( $Eヽ, , , ,
, , , \ \ , ,
, ' ,⎞ \ , , '
, , | `ヽ , , '
' , , ⎩ ト、 , ,
' ' u¯u︶u '
`;
function renderAA(thoughts = " ", eye = "・", rain = false): string {
return (rain ? AA : AA.replaceAll(/,|'/g, " ")).replaceAll("$T", thoughts)
.replace("$E", eye);
}
そしてcli.ts
の方にもオプション定義を追加します。
ここで、eye
は他のオプションとちょっと違う点があります。
ひとつは引数を受け取る点です。think
もshout
もrain
も「指定されているかどうか」で処理が変わるboolean
のオプションでしたが、eye
は「眼に使う文字」を必須引数として取ります。
また、引数が渡されても、それが2字以上の文字列の場合は不適切なのでエラーにしたいところです。コマンド引数のエラーはValidationError
をインポートして使います。
具体的なコードで説明しましょう。以下のようになります。
(略)
.option("-e, --eye <eye>", "Select the appearance of the deno's eyes.", {
default: "・",
value: (value: string): string => {
if (stringWidth(value) !== 1) {
throw new ValidationError(
"Invalid eye parameter. This must be a single width character.",
{ exitCode: 1 },
);
}
return value;
},
})
(略)
const {
think,
shout,
eye,
rain,
} = options;
const runner = think ? denothink : shout ? denoshout : denosay;
console.log(runner(String(args[0]), { eye, rain }));
まずoption()
の第一引数ですが、"-e, --eye <eye>"
のように記述することで引数を指定できます。<>
で囲まれていれば必須引数、[]
で囲まれていればオプショナル引数となります。また、デフォルトで文字列型として扱われます。
そして第三引数のオブジェクトのvalue
が渡された引数を扱うコールバック関数となります。この中で文字長が1を超えていたらエラーを出し、そうでなければ引数をそのまま返すという形にしています。
なお、default
も指定していますが、今回は必須引数なのでこれが利用されることはありません。ただし、help
で表示されるので、説明のために記載しています。
deno install
をよろしく!
完成したスクリプトはdeno run cli.ts hello
のように実行できますが、せっかくCLIツールとして作ったので、もっと簡単に呼び出したいところです。
deno install
でグローバルにインストールしましょう。
以下のコマンドでdenosay
をインストールできます。
deno install --force --name denosay cli.ts
普通にインストールすると.ts
ファイルの名前がそのままコマンド名になるのですが、main
mod
index
cli
の場合は親ディレクトリ名がコマンド名になります。これは--name
オプションで指定できます。
インストール先は
-
--root
オプションで指定されている場合はそのディレクトリ - 環境変数
$DENO_INSTALL_ROOT
が定義されている場合はそのディレクトリ - 特に定義がなければ
$HOME/.deno
…の中のbin
ディレクトリです。こちらにPATH
が通っていることを確認してください。
--force
オプションは同名のコマンドが存在するときに上書きするオプションで、スクリプトを書き換えたときに上書きインストールすることができます。ただし関連ファイルのキャッシュまでは更新されないので、「上書きインストールしたのに処理が更新されない」という場合は--reload
オプションを付けると良いでしょう。
これで単にdenosay
だけで使えるようになりました。
STDIN… 満を持して
Deno.stdin
の処理
以上で基本的なスクリプトとしては完成なのですが、CLIツールという以上、パイプ入力も受け付けたいと思いませんか?思いますよね。
現状、echo hello | denosay
としても、「引数なし」としてエラーを出してしまいます。これを修正しまして、
-
denosay hello
で実行:hello
を表示 -
echo hello | denosay
で実行:1と同様にhello
を表示 -
denosay
のみで実行:引数がないのでエラー
という挙動を作りたいと思います。
パイプで渡された入力は、Deno.stdin
をstd/io
のreadLines
で読み込むことで使用できます。
以下のコードで試してみます。
import { readLines } from "https://deno.land/std@0.101.0/io/mod.ts";
const stdin = [];
for await (const line of readLines(Deno.stdin)) {
stdin.push(line);
}
console.log(stdin);
実行すると、パイプからの入力が行ごとに捌かれて処理されていることがわかると思います。
❯ echo -e 'apple\nbanana\ncherry'
apple
banana
cherry
❯ echo -e 'apple\nbanana\ncherry' | deno run stdin.ts
[ "apple", "banana", "cherry" ]
これを使えばパイプ入力への対応はOK…なのですが、このままではパイプからの入力が無い場合、つまり普通に呼び出したときに動作しません。
❯ deno run stdin.ts
# 戻ってこない
# ctrl-cで脱出
実際は標準入力を待ち続けている状態なので、適当に入力してEnterを押すと、for
ループが一回動作し、入力した内容が配列にpush
されます。これはループのたびに表示を挟むことで確認できます。
入力に対して反応を返すようなCLIであれば使えるかもしれませんが、今回はインタラクティブな入力を受け付けたいわけではないため、この状態に入ってしまうのは避けたいです。
そこでDeno.isatty()
関数を使います。
こちらにDeno.stdin.rid
を渡すと、普通に呼び出した(パイプからの入力がない)状態ならtrue
が返ってきます。
import { readLines } from "https://deno.land/std@0.101.0/io/mod.ts";
+ if (Deno.isatty(Deno.stdin.rid)) {
+ console.log("no input");
+ } else {
const stdin = [];
for await (const line of readLines(Deno.stdin)) {
stdin.push(line);
}
console.log(stdin);
+ }
実行してみましょう。
❯ echo 'hello' | deno run stdin.ts
[ "hello" ]
❯ deno run stdin.ts
no input
パイプ入力の有無を認識し、ある場合にその入力を取得することができました[3]。
ここで作成したstdin
は行ごとに分解された配列となっているので、コマンド内で使用する場合はstdin.join("\n")
で接続してやると良いでしょう。
Command
での取り扱い
では、これをdeno-cliffyのコマンドに渡していきます。
cli.ts
を修正しましょう。
前述したstdin
をどうやってコマンドに渡すかがポイントです。
結論を言うと、.parse()
の引数にstdin.join("\n")
を追加します[4]。
変更箇所は以下のとおりです。
// (略)
+ const stdin = [];
+ const isatty = Deno.isatty(Deno.stdin.rid);
+ if (!isatty) {
+ for await (const line of readLines(Deno.stdin)) {
+ stdin.push(line);
+ }
+ }
const { options, args } = await Command()
.name("denosay")
// (略)
.arguments("<text>")
- .parse(Deno.args);
+ .parse(isatty ? Deno.args : [...Deno.args, stdin.join("\n")]);
// (略)
isatty
でパイプ入力の有無を判断し、それによってstdin
を使うかを分岐しています。
出力結果の例として、最初のツイートを再掲します。
こちらの3枚目の画像がパイプからの入力を表示しているものとなります。
上記ではcat
の結果をそのまま渡していますが、もちろん他のコマンドも利用できます。
シェル芸の終点としてお使いください。
貢献とかそんなんじゃなくて、できることがあったらやるだけなんだ
以上、denosay
の実装を通してdeno-cliffyを使ってみました。
完成品はこちらのrepoに上げています。
Twitter、GitHub、あるいはこのZennでのフォローや拡散、Likeなどしてもらえると励みになります。よろしくおねがいします(唐突な宣伝)。
また、勉強中にdeno-cliffyのコードに不備を見つけたためPRを出しました。
勉強中の身でありながら、その勉強対象を改善する機会があるというのはとても幸せなことだと思います。OSS活動の醍醐味ですね。
こちらの記事でdeno-cliffyを使う方が増えてくれると、コントリビューターの端くれとしても、非常に嬉しいです。
The past should give us hope.
(本文中で触れなかったけど参考にしたページ)
Discussion