![見出し画像](https://assets.st-note.com/production/uploads/images/69130973/rectangle_large_type_2_4888bf2bc2035c16ec552d445bb0fbf3.png?width=1200)
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 src
➜ lsdeno 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用のバイナリを生成して配布することも可能です。便利ですね!
![](https://assets.st-note.com/img/1641221738381-Xsd3S5ZsEB.png?width=1200)
動かしてみる
ls, lsdeno の出力としては(改行が発生しない場合)同等の出力になりました。
![](https://assets.st-note.com/img/1641220841155-2dvR5Mg9tZ.png?width=1200)
改行時のフォーマットを合わせるところまでは作れなかったのと、a オプションでカレント(.)と1階層上(..)がファイルシステムでサクッと取れなかったので今回はスルーしました。
![](https://assets.st-note.com/img/1641220822683-dU4X78FyEw.png?width=1200)
l オプションについては、ハードリンク数は何をとっているのかいまいちわかんなかったので実装しなかったのと、権限はフォーマットを頑張るのが大変だったので8進数表示にしました。
あとユーザ名、グループはidが取得できたものの文字列の取得方法がわからんかったので今回は諦めました。
![](https://assets.st-note.com/img/1641221328433-e8UuErePNE.png?width=1200)
若干足りない部分はありつつ、大枠は実装できたかなと思います。
以降は実装の説明になります。
エントリーポイント
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)
}
}
コマンド本体が行っているのは以下です。
渡された引数を解析し、オプションの有無とパスを受け取る
パスを元にエントリー(ファイルとディレクトリ)の一覧を取得する
オプションを元に選択された Filter を使ってエントリーをフィルタリングする
フィルタリングされたエントリーをFormatterに渡し、標準出力に出力する文字列を得る
出力する
フィルタとフォーマット
フィルタは 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が現実的かもしれません。
この辺りは今後も色々試して行こうかなと思います。
それでは!