denoが楽しい

TypeScriptの実装系として爆速進化中のdenoです。ちょっとしたシェルスクリプトで書いたりするようなことを、denoで書くのが楽しい。今朝は花粉症の鼻づまりが原因で早起きしてしまったので、午前中は遊びコード書いてた。前に書いたコード断片をかき集めてきて半分ぐらい終わったのですぐ。

# ~/.bash_profile
export PATH=~/.deno/bin:~/bin:$PATH

denoのインストール後、上記の通りパスを通しておく。~/binをスクリプト置き場とする。

今はまだIDEの支援も得られないのでコードどうやって書くかと言うと、テキストエディタでゴリゴリ書くしかない。そのうち愛用中エディタATOMプラグインでtslintやPrettierを使ってみようかと思うけどまだやってない。TDDで書くために、deno_stdと呼ばれる標準のTypeScriptライブラリ中のテストツールを用いた。簡単なテストランナーとアサーションが用意されている。書いたのは写真ファイルのごちゃごちゃしたファイル名を整理するスクリプト

// ~/bin/renames_test.ts
import { test, runTests } from 'https://deno.land/std/testing/mod.ts';
import { assertEquals } from 'https://deno.land/std/testing/asserts.ts';
import { getFileDescriptor, getNumberStr, getOption } from './renames_lib.ts';

test(function NO_EXT() {
  const f = getFileDescriptor('example#1');
  assertEquals(f.skip, true);
});

test(function MULTI_DOT() {
  const f = getFileDescriptor('example.#1.jpeg');
  assertEquals(f.name, 'example.#1');
  assertEquals(f.ext, '.jpg');
});

test(function DOT_FILE_Large() {
  const f = getFileDescriptor('.IGNORE');
  assertEquals(f.skip, true);
});

test(function SKIP() {
  assertEquals(getFileDescriptor('test.bmp').skip, true);
  assertEquals(getFileDescriptor('test.jpeg').skip, false);
});

test(function GET_NUMBER_STRING() {
  assertEquals(getNumberStr('t3st#12p.png'), '012');
  assertEquals(getNumberStr('t3st#12p.png', true, 0, 4), '0003');
  assertEquals(getNumberStr('t3st#12p.png', false, 0, 4), '0012');
  assertEquals(getNumberStr('t3st#12p.png', false, 5, 4), '0017');
  assertEquals(getNumberStr('t3st#12p.png', false, -5, 4), '0007');
});

test(function GET_OPTION() {
  assertEquals(
    getOption('P', 'pre', ['CMD', '-P', 'test']),
    { flag: true, value: 'test' },
  );
  assertEquals(
    getOption('P', 'pre', ['CMD', '--pre', 'test']),
    { flag: true, value: 'test' },
  );
  assertEquals(
    getOption('P', 'pre', ['CMD', 'x']),
    { flag: false, value: '' },
  );
  assertEquals(
    getOption('P', 'pre', ['CMD', '-P', '-test']),
    { flag: true, value: '' },
  );
});

runTests();

面白いのは、testに渡すテスト関数の名前がテスト名になるところ。「NO_EXT」とか「MULTI_DOT」とかが用いられる。無駄がない。

// ~/bin/renames_lib.ts
const KNOWN_EXTS = ['.jpg', '.jpeg', '.png', '.tiff', '.gif', '.svg'];

type Descriptor = {
  ext: string;
  name: string;
  skip: boolean;
}

export function getFileDescriptor(
  fileName: string,
): Descriptor {
  const result = fileName.match(/(.*)(\..*)$/);
  if (result && result[1].length !== 0) {
    let ext = result[2].toLowerCase();
    if (ext === '.jpeg') {
      ext = '.jpg';
    }
    if (KNOWN_EXTS.includes(ext)) {
      return {
        ext,
        name: result[1],
        skip: false,
      };
    }
  }
  return {
    ext: '',
    name: fileName,
    skip: true,
  }
}

export function getNumberStr(
  name: string,
  start: boolean = false,
  offset: number = 0,
  len: number = 3,
): string {
  const result = name.match(/([0-9]+)/g);
  if (result) {
    const str1 = start ? result[0] : result[result.length - 1];
    const num = Number(str1) + offset
    const zero = new Array<string>(len - 1);
    zero.fill('0');
    const str2 = zero.join('') + num;
    return str2.slice(-(len)); // ほんまかいなコード
  }
  return '';
}

type Option = {
  flag: boolean;
  value: string;
}

export function getOption(
  s: string,
  l: string,
  args: string[],
): Option {
  let i = args.indexOf(`-${s}`);
  if (i === -1) {
    i = args.indexOf(`--${l}`);
  }
  if (i !== -1 && i < args.length - 1 && !args[i + 1].startsWith('-')) {
    return {
      flag: true,
      value: args[i + 1],
    }
  }
  return {
    flag: i !== -1,
    value: '',
  }
}

export function renames() {
  const dir = getOption('R', 'dir', Deno.args);
  const start = getOption('S', 'start', Deno.args);
  const offset = getOption('O', 'offset', Deno.args);
  const len = getOption('L', 'len', Deno.args);
  const prefix = getOption('P', 'prefix', Deno.args);
  const postfix = getOption('T', 'postfix', Deno.args);
  const dryrun = getOption('N', 'dryrun', Deno.args);

  console.log(Deno.args.join(' '));
  console.log(`dir: { flag: ${dir.flag}, value: ${dir.value} }`);
  console.log(`start: { flag: ${start.flag}, value: ${start.value} }`);
  console.log(`offset: { flag: ${offset.flag}, value: ${offset.value} }`);
  console.log(`len: { flag: ${len.flag}, value: ${len.value} }`);
  console.log(`prefix: { flag: ${prefix.flag}, value: ${prefix.value} }`);
  console.log(`postfix: { flag: ${postfix.flag}, value: ${postfix.value} }`);
  console.log(`dryrun: { flag: ${dryrun.flag}, value: ${dryrun.value} }`);

  for (const file of Deno.readDirSync(Deno.cwd())) {
    // 2019.5.21 deno v0.6.0からFileInfo.pathが廃止され、.nameに統一
    // const oldPath = file.path;
    const oldPath = file.name;
    if (file.isDirectory() && !dir.flag) {
      console.log(`${oldPath} skip: directory`);
      continue;
    }
    const descriptor = getFileDescriptor(file.name);
    if (descriptor.skip && !file.isDirectory()) {
      console.log(`${oldPath} skip: ext`);
      continue;
    }
    const o = offset.flag ? Number(offset.value) : 0;
    const l = len.flag ? Number(len.value) : 3;
    const num = getNumberStr(descriptor.name, start.flag, o, l);
    if (num.length === 0) {
      console.log(`${oldPath} skip: no number`);
      continue;
    }
    const p = prefix.flag ? prefix.value : '';
    const t = postfix.flag ? postfix.value : '';
    const newName = `${p}${num}${t}${descriptor.ext}`;
    if (dryrun.flag) {
      console.log(`Dry run: ${oldPath} -> ${newName}`);
      continue;
    }
    console.log(`${oldPath} -> ${newName}`);
    Deno.renameSync(oldPath, newName);
  }
}

グローバル変数「Deno」が、ファイル操作やネットワークI/O等のdenoが用意するAPIのエンドポイントになっている。importソースの表記はちょっと違うけど、あとは普通にTypeScript3.3。上記コードで考え込んだのは、文字列のフォーマットを行うところ。この環境で使えるスマートなやり方がわからなかったので、配列を0で初期化してjoinして文字列にくっつけてsliceする、ほんまかいなと。

#!/usr/bin/env deno --allow-read --allow-write
import { renames } from './renames_lib.ts';
renames();

最後にコマンドとしてchmod +xしたファイルを用意する。テストしやすくするために分けた。この最後の実行ファイルの名前をrenames(拡張子なし)として、あとは普通にシェルでタイプすると使える。例外処理が甘いけど、自分で使う分には便利。