[TypeScript] タグ付きテンプレートの書き方 3. 応用編: インデント

# 

前回まで

- [[TypeScript] タグ付きテンプレートの書き方 1. 基礎編](https://qiita.com/sugoroku_y/items/af674c288a681b5f83eb)
- [[TypeScript] タグ付きテンプレートの書き方 2. 応用編: 正規表現](https://qiita.com/sugoroku_y/items/3fa6b550399db629647b)

今回はちょっと違う方向性で。

## インデント

JavaScriptやTypeScriptでテンプレートリテラルを使って複数行の文字列を扱うとき、こんなことを思ったことはないですか?

- `` ` ``の直後から文字列として扱われるので、次の行と見た目上の開始位置が揃わない。
- 前後の行と一緒に選択してインデントを変えてしまうと、文字列中のインデントも変わってしまう。

こういうのがイヤでいつも

```ts
const text = `
  ここに複数行文字列があると思いねえ
  あいうえお
  カキクケコ
`.replace(/^ {2}/gm, '').slice(1, -1);
```

こんな風に自前で修正したりしていました。

でもこれでもインデントが深くなったりすると、置換する正規表現も変える必要があったり、挿入する値があると、その中身まで修正されてしまったりして、ちょっとめんどくさいなー、と思ってました。

そのときSwitftという言語の複数行文字列リテラルのことを知りました。

[Swiftの複数行Stringリテラルまとめ](https://medium.com/swift-column/multiline-string-literals-ba5fa8f4853e)

抜粋

```md
## 基本

3つのダブルクオテーションで次のように囲む。

## 単独の`"`の扱い

ひとつの`"`を終わりの記号と見なさないので、自由に`"`を含めることが出来る。

## 3つの`"`の扱い

`"`を3つ連続したいときは、`\`を用いて、裸の`"`が3つ並ぶのを回避する。

## 改行コードを入れない

特定の行に改行コードを入れないことが出来る。`\`を用いる。

## 始めと終わりに改行コードを入れる

始めと終わりに改行コードをいれるには空の行を追加すればよい。

## インデント処理

行の左端から文字を書くのはコードが見にくくなるので次のように先頭にスペースを複数書いてもよい。
```

こういうのをタグ付きテンプレートで作ればいいのでは!

ということで作ってみましょう。

## 仕様

Swiftの複数行リテラルの仕様はこんな感じでした。

- 最初の`` ` ``の直後は改行文字がなければならない。
- 末尾の`` ` ``がある行には空白もしくはタブのみ。
- 各行の行頭は末尾の行と同じインデントがなければならない。
- 各行のインデントは除去する。
- 先頭の改行は除去する。
- 末尾の改行(+インデント)は除去する。

でもVS Codeでコードを編集していると、空白だけの行があると色が変わったりして目に引っかかるので、追加で

- ただし空行であれば行頭にインデントがなくてもOK。

を追加したいと思います。

その他は普通のテンプレートリテラルと同じです。

## 実装

行末に`\`を入れるとその行の改行文字を除去する、という仕様は通常のテンプレートリテラルでも同じで、タグ付きテンプレートでも自動で除去してくれるんですが、その場合インデントは除去してくれません。

なのでテンプレート文字列をそのまま使ってしまうと`\`が行末にあって改行が除去されたときに、インデントが残ってしまうことになります。

そこで`raw`プロパティの方を使うことでインデントを除去した後に行末の`\`と改行文字を除去するようにします。

```ts
function unindent({raw: template}: TemplateStringsArray): readonly string[] {
```

まずは最初の`` ` ``の直後の改行文字をチェック。

```ts
  if (template[0].charAt(0) !== '\n') {
    throw new Error('最初の\`の直後は改行文字がなければならない。')
  }
```

末尾の`` ` ``がある行からインデントを取得、と同時にチェック。

```ts
  // 最後の改行の後ろにある空白/タブをインデントとみなす
  const [indent] =
    /(?<=\n)[ \t]*$/.exec(template[template.length - 1]) ??
    error`末尾の\`がある行には空白もしくはタブのみ。`;
```

各行の行頭もチェック。

```ts
  const check = regexp`\n(?!\n|${indent})`;
  if (template.some(t => check.test(t))) {
    throw new Error('各行の行頭は末尾の行と同じインデントがなければならない。ただし空行であれば行頭にインデントがなくてもOK。');
  }
```

インデントを除去。

```ts
  const searchValue = `\n${indent}`;
  return template.map((e, i, a) => {
    // 各行のインデントは除去する。
    e = e.replaceAll(searchValue, '\n');
```

先頭の改行も除去。

```ts
    if (i === 0) {
      // 先頭の改行は除去する。
      e = e.slice(1);
    }
```

末尾の改行も除去。

```ts
    if (i === a.length - 1) {
      // 末尾の改行(+インデント)は除去する。
      e = e.slice(0, -1);
    }
```

末尾のインデントについては各行のインデント除去のときに除去済みです。

```ts
    return e;
  });
}
```

そしてエスケープシーケンスの解決に[基本編の安全版](https://qiita.com/sugoroku_y/items/af674c288a681b5f83eb#%E5%AE%9F%E8%A3%85%E5%AE%89%E5%85%A8%E7%89%88)で用意した`unescape`を使います。

```ts
unindent(template).map(unescape)
```

こうして改変したテンプレート文字列に対して基本と同じように結合します。

```ts
function indented(template: TemplateStringsArray, ...values: unknown[]): string {
  return unindent(template).map(unescape).reduce((r, e, i) => r.concat(String(values[i - 1]), e));
}
```

## 実装: 生版

`basic`と同じように生版も用意しましょう。

```ts
function indentedRaw(template: TemplateStringsArray, ...values: unknown[]): string {
  return unindent(template).reduce((r, e, i) => r.concat(String(values[i - 1]), e));
}
```

`unindent`に`unescape`をかけなければいいだけなので簡単ですね。

## 実装: 安全版

同様に安全版も用意します。

```ts
function indentedSafe(template: TemplateStringsArray, ...values: unknown[]): string {
  try {
    return unindent(template).map(unescapeSafe).reduce((r, e, i) => r.concat(String(values[i - 1]), e));
  } catch (ex) {
    // unindentでのチェックでエラーが出たときはbasicSafeで代用
    return basicSafe(template, ...values);
  }
}
```

エスケープシーケンスの解除に`unescapeSafe`を使うことで例外が投げられないようにします。

あと`unindent`でチェック時に例外を投げるので、そのときは諦めて`basicSafe`を呼び出します。

それ以外は例外を投げないはずなのでそのまま。

## まとめ

今回の実装も前回と同じリポジトリにあります。

https://github.com/sugoroku-y/tagged-templates

npmのパッケージとしても登録してみました。

https://www.npmjs.com/package/tagged-templates

ひねりのない名前なので、すでにあるっていわれてはじかれるかと思ったけど、そんなことはなかったぜ。…よかったんかな…