📖

TypeScript でミニマム ls コマンドを作ろう

2022/10/03に公開約9,700字

TypeScript でミニマム ls コマンドを作ろう

あらまし

本年度インターンのメンターを務める機会があったので、研修課題用教材をいくつか作成した内の一つである。
今回は TypeScript と Deno を使って ミニマムな ls コマンドを作る課題を考えた。
課題作成には ls -GF コマンドからインスパイアを得ている。

課題資料

minils

概要

ディレクトリコンテンツをリストする

前提

  • コマンドオプション、多バイト文字は考慮しない

仕様

アーキテクチャ

  • ランタイムは Deno を使用
  • 言語は TypeScript を使用
  • Docker 並びに Docker Compose を用いてランタイムを定義すること

基本

  • ディレクトリエントリからファイルおよびディレクトリ名を取得する
  • ドットファイルを出力しない
  • コマンドの第 1 引数にディレクトリパスを入力
  • コマンドの第 1 引数に入力がない場合カレントディレクトリを入力
  • ファイルまたはディレクトリが存在しない場合に、エラーメッセージをファイルディスクリプタ 2 に出力
  • ファイル名の色をデフォルトの色で出力
  • ディレクトリ名は青色で出力
  • ディレクトリ名末尾に / を出力(デフォルトの色)
  • 最大文字数のファイル名にスペースを 2 つ追加または末尾に / を含めたディレクトリ名にスペースを 1 つ追加した文字数を算出する(以下、この文字列数を「最大名前サイズ」と呼ぶ)
  • 最大名前サイズからファイル名または末尾に / を含めたディレクトリ名の文字数を引いた数のスペースをそれぞれのリストする要素の末尾に出力
  • 最大列数をターミナルのウィンドウサイズと最大名前サイズにて算出する
  • 最大行数をドットファイルを除いたファイル、ディレクトリ数と最大列数にて算出する
  • アルファベット順にソート
  • 最大行数まで列に出力
  • 列の最大行数を超えたら次の列に出力

その他

  • 上記仕様について不明点、不足があればメンターに相談

解答

開発環境を Docker や  Docker Compose を活用して、互換性のある開発環境を用意できる能力を身につけさせたい。
また、TypeScript を用いて型定義を意識させるということ、メモリ管理を意識できるかなどを目的としている。

Docker , Docker Compose による開発環境の用意

ディレクトリ構造

.
├── Makefile
├── deno
│   └── Dockerfile
├── docker-compose.yml
└── minils.ts

ランタイムの定義

Dockerfile
FROM denoland/deno:latest

RUN apt update && apt install -y zip

Docker Compose の定義

docker-compose.yml
version: "3.8"
services:
  deno:
    build:
      context: deno
      dockerfile: Dockerfile
    volumes:
      - .:/app

Makefile の用意

Makefile
.PHONY: build
build:
	docker compose build

.PHONY: test
test: compile
	./minils testdir

.PHONY: compile
compile:
	docker compose run deno bash -c "cd /app && deno compile --target x86_64-apple-darwin --allow-read --allow-run minils.ts"

ls コマンドの作成

まず、色のエスケープシーケンスや、部品をコンスタントに定義しておく。

minils.ts
const color: {
  Blue: string;
  Reset: string;
} = { Blue: "\u001b[34m", Reset: "\u001b[0m" };

const parts: {
  OneSpace: string;
  Slash: string;
  NewLine: string;
  Dot: string;
  Empty: string;
} = { OneSpace: " ", Slash: "/", NewLine: "\n", Dot: ".", Empty: "" };

次に、interface を定義する

minils.ts
interface OSOriginListEntry extends Deno.DirEntry {
  name: string;
}

interface DotCheckedMax {
  entry: OSOriginListEntry;
  size: number;
}

interface EditedDotCheckedMax {
  entry: OSOriginListEntry;
  size: number;
}

interface ViewMatrix {
  rowsize: number;
  colsize: number;
}

また、callback に渡す print 関数に対して型をあらかじめ宣言しておく。

minils.ts
type Print = typeof print;
minils.ts
const print = (filename: string, next: boolean) => {
  if (next) {
    Deno.stdout.write(new TextEncoder().encode(filename + parts.NewLine));
  } else {
    Deno.stdout.write(new TextEncoder().encode(filename));
  }
};

Dotfile かどうかをチェックする関数を定義する。

minils.ts
const dotCheck = (filename: string) => {
  if (filename.indexOf(parts.Dot)) {
    return true;
  }
  return false;
};

引数から与えられたパスをチェックする関数を定義する。
入力がなければカレントディレクトリを表す . を返す。

minils.ts
const pathCheck = (path: string) => {
  if (path === undefined) {
    return parts.Dot;
  }
  return path;
};

tput コマンドで window サイズを取得する。

minils.ts
const tpusCols = async () => {
  const cmd = Deno.run({ cmd: ["tput", "cols"], stdout: "piped" });
  const columnsUintArray = await cmd.output();
  cmd.close();

  // Decode geted window size with tput cols and cast from decoded string to number
  return Number(new TextDecoder().decode(columnsUintArray));
};

main 関数を定義する。
ここで、ディレクトリエントリから読み出した情報を不要な 2 次元配列を作らせないでソート、抽出して行列に出力させること。
これにより不要なリソース、計算コストを削減できることを理解させる。

minils.ts
const main = async (path: string, windowsize: number, callback: Print) => {
  const OSEntriesWithoutDotfiles: OSOriginListEntry[] = [];
  for await (const directoryEntry of Deno.readDir(pathCheck(path))) {
    if (dotCheck(directoryEntry.name)) {
      OSEntriesWithoutDotfiles.push(directoryEntry);
    }
  }

  // initialize Max
  const maxentry = OSEntriesWithoutDotfiles.reduce((a, b) =>
    a.name.length > b.name.length ? a : b
  );
  const Max: DotCheckedMax = {
    entry: maxentry,
    size: maxentry.name.length,
  };
  const EditedMax: EditedDotCheckedMax = { entry: Max.entry, size: Max.size };
  EditedMax.size += 2;

  // calc col and row size
  const Matrix: ViewMatrix = { rowsize: 0, colsize: 0 };
  Matrix.colsize = Math.floor(windowsize / EditedMax.size);
  Matrix.rowsize = Math.ceil(OSEntriesWithoutDotfiles.length / Matrix.colsize);

  // extract 2 vector directory entry from 1 vector directory entry
  OSEntriesWithoutDotfiles.sort((a, b) => (a.name > b.name ? 1 : -1));
  for (let i = 0; i < Matrix.rowsize; i++) {
    if (i != 0) {
      callback(parts.Empty, true);
    }
    for (let j = i; j < Matrix.colsize * Matrix.rowsize; j += Matrix.rowsize) {
      if (OSEntriesWithoutDotfiles[j] === undefined) {
        break;
      }
      // region layout
      const addSize = Max.size - OSEntriesWithoutDotfiles[j].name.length;
      let sizewithslash = 1;
      if (OSEntriesWithoutDotfiles[j].isDirectory) {
        OSEntriesWithoutDotfiles[j].name =
          color.Blue +
          OSEntriesWithoutDotfiles[j].name +
          color.Reset +
          parts.Slash;
        sizewithslash -= 1;
      }
      for (let k = 0; k <= addSize + sizewithslash; k++) {
        OSEntriesWithoutDotfiles[j].name += parts.OneSpace;
      }
      // endregion layout
      callback(OSEntriesWithoutDotfiles[j].name, false);
    }
  }
};

最後に Deno の top level await などを活用しながら main 関数等を呼び出す。
また、エラーハンドリングを記述すること。

minils.ts
try {
  // top level await with Deno
  const windowsize = await tpusCols();
  await main(Deno.args[0], windowsize, print);
  print(parts.Empty, true);
} catch (e) {
  console.error(e.message);
}

全体像

minils.ts
const color: {
  Blue: string;
  Reset: string;
} = { Blue: "\u001b[34m", Reset: "\u001b[0m" };

const parts: {
  OneSpace: string;
  Slash: string;
  NewLine: string;
  Dot: string;
  Empty: string;
} = { OneSpace: " ", Slash: "/", NewLine: "\n", Dot: ".", Empty: "" };

interface OSOriginListEntry extends Deno.DirEntry {
  name: string;
}

interface DotCheckedMax {
  entry: OSOriginListEntry;
  size: number;
}

interface EditedDotCheckedMax {
  entry: OSOriginListEntry;
  size: number;
}

interface ViewMatrix {
  rowsize: number;
  colsize: number;
}

type Print = typeof print;

const print = (filename: string, next: boolean) => {
  if (next) {
    Deno.stdout.write(new TextEncoder().encode(filename + parts.NewLine));
  } else {
    Deno.stdout.write(new TextEncoder().encode(filename));
  }
};

const dotCheck = (filename: string) => {
  if (filename.indexOf(parts.Dot)) {
    return true;
  }
  return false;
};

const pathCheck = (path: string) => {
  if (path === undefined) {
    return parts.Dot;
  }
  return path;
};

const tpusCols = async () => {
  const cmd = Deno.run({ cmd: ["tput", "cols"], stdout: "piped" });
  const columnsUintArray = await cmd.output();
  cmd.close();

  // Decode geted window size with tput cols and cast from decoded string to number
  return Number(new TextDecoder().decode(columnsUintArray));
};

const main = async (path: string, windowsize: number, callback: Print) => {
  const OSEntriesWithoutDotfiles: OSOriginListEntry[] = [];
  for await (const directoryEntry of Deno.readDir(pathCheck(path))) {
    if (dotCheck(directoryEntry.name)) {
      OSEntriesWithoutDotfiles.push(directoryEntry);
    }
  }

  // initialize Max
  const maxentry = OSEntriesWithoutDotfiles.reduce((a, b) =>
    a.name.length > b.name.length ? a : b
  );
  const Max: DotCheckedMax = {
    entry: maxentry,
    size: maxentry.name.length,
  };
  const EditedMax: EditedDotCheckedMax = { entry: Max.entry, size: Max.size };
  EditedMax.size += 2;

  // calc col and row size
  const Matrix: ViewMatrix = { rowsize: 0, colsize: 0 };
  Matrix.colsize = Math.floor(windowsize / EditedMax.size);
  Matrix.rowsize = Math.ceil(OSEntriesWithoutDotfiles.length / Matrix.colsize);

  // extract 2 vector directory entry from 1 vector directory entry
  OSEntriesWithoutDotfiles.sort((a, b) => (a.name > b.name ? 1 : -1));
  for (let i = 0; i < Matrix.rowsize; i++) {
    if (i != 0) {
      callback(parts.Empty, true);
    }
    for (let j = i; j < Matrix.colsize * Matrix.rowsize; j += Matrix.rowsize) {
      if (OSEntriesWithoutDotfiles[j] === undefined) {
        break;
      }
      // region layout
      const addSize = Max.size - OSEntriesWithoutDotfiles[j].name.length;
      let sizewithslash = 1;
      if (OSEntriesWithoutDotfiles[j].isDirectory) {
        OSEntriesWithoutDotfiles[j].name =
          color.Blue +
          OSEntriesWithoutDotfiles[j].name +
          color.Reset +
          parts.Slash;
        sizewithslash -= 1;
      }
      for (let k = 0; k <= addSize + sizewithslash; k++) {
        OSEntriesWithoutDotfiles[j].name += parts.OneSpace;
      }
      // endregion layout
      callback(OSEntriesWithoutDotfiles[j].name, false);
    }
  }
};

try {
  // top level await with Deno
  const windowsize = await tpusCols();
  await main(Deno.args[0], windowsize, print);
  print(parts.Empty, true);
} catch (e) {
  console.error(e.message);
}

まとめ

プログラミングの入門として 100 行を少し超える程度なのでちょうど良いのではないかと考えています。
Deno や TypeScript など、比較的新しい技術スタックを使って学習者の興味関心を向上させることも図りました。
今回は、これ以外にも課題を与えていたので、テストなどは省きましたが、次回はテストの記述なども盛り込みたいと思いました。

今回のソースはここに残してあります。

https://github.com/Iovesophy/minils

今回初めてメンターというものをやってみましたが、色々とどう伝えると本人のためになるのか悩む場面も多々ありました。
どうか今回の経験を糧に、世に貢献できる能力を身につけ、少しでもご活躍いただける助けになりますように。

GitHubで編集を提案

Discussion

ログインするとコメントできます