Pascal CaseとCamel CaseとSnake Caseを相互に変換する

変数やクラスの名前が複数の単語を連結したものである場合、プログラミング言語やコード規約によっていろいろなつなぎ方をします。

* パスカルケース (Pascal Case)
    * 単語ごとに先頭大文字、他は小文字
    * 例: PascalCase
    * 使用例: JavaScript/TypeScriptやJavaのクラス名、など
* キャメルケース (Camel Case)
    * パスカルケースとほぼ同じだが、最初の単語は全部小文字
    * 例: camelCase
    * 使用例: JavaScript/TypeScriptやJavaの変数名・メソッド名など
* スネークケース (Snake Case)
    * 単語をアンダースコア（`_`）で区切る。全部小文字
    * 例: snake_case
    * 使用例: Pythonのメソッド名、変数名、など
* ケバブケース (Kebab Case)
    * 単語をハイフン（`-`）で区切る。全部小文字
    * 例: kebab-case
    * 使用例: HTML, CSSなど

例えば API を作っていて、 JSON 内のキーはキャメルケースなんだけど、 Python で扱う Dictionary ではスネークケースで扱いたい、といった相互変換したい場合がありますよね。

何を何に変換する、と毎度考えるのもそろそろ飽きてきたので、元のケースを気にせず、所望のケースに変換する、という処理をまとめてみることにしました。

---

下記の WSL 環境です。

```bash
$ cat /etc/os-release
NAME="Ubuntu"
VERSION="20.04.3 LTS (Focal Fossa)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 20.04.3 LTS"
VERSION_ID="20.04"
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
VERSION_CODENAME=focal
UBUNTU_CODENAME=focal
```

Python 3.8 です。

```bash
$ python3 --version
Python 3.8.10
```

## ２段階の処理で共通化

いずれの変換でも、２段階の処理で共通に考えられそうです。

1. ［分割ステップ］文字列を単語に区切り、中間表現となる「単語リスト」を作る
1. ［連結ステップ］大文字小文字を変換し、単語をつなげる

分割ステップは全変換で共通の前処理、連結ステップは変換後のケースに応じて異なるイメージです。

## 連結ステップ：大文字小文字を変換し、単語をつなげる

連結ステップのほうが考え方が簡単なので、先に作ります。

分割ステップの処理をする `_parse_words()` が返した単語リストを、大文字小文字変換・連結して返す、という仕様で作っていきます。なお、`_parse_words()` はリストではなくてイテレータを返すように実装することにします。

Python の文字列には、小文字にする`lower()`に加え、先頭のみ大文字その他は小文字にする `capitalize()` という便利なメソッドがあり、どの変換も簡単に書けます。キャメルケースは先頭の単語だけ先頭大文字にしないので、先頭の単語はリストから事前に取り出して特別扱いします。

```python
def to_pascal_case(string: str) -> str:
    words_iter = _parse_words(string)
    return "".join(word.capitalize() for word in words_iter)


def to_camel_case(string: str) -> str:
    words_iter = _parse_words(string)
    try:
        first = next(word_iter)
    except StopIteration:
        return ""
    return first.lower() + "".join(word.capitalize() for word in words_iter)


def to_snake_case(string: str) -> str:
    words_iter = _parse_words(string)
    return "_".join(word.lower() for word in words_iter)


def to_kebab_case(string: str) -> str:
    words_iter = _parse_words(string)
    return "-".join(word.lower() for word in words_iter)
```

## 分割ステップ：入力文字列を単語に分ける

分割ステップ `_parse_words()` を実装していきます。  

### 分割の仕様を考える

分割のパターンがいろいろあって複雑なので、まずテストケースの形で仕様を明確化しておくことにします。テストフレームワークとして pytest を使い、テストケースは [Parametrize test](https://docs.pytest.org/en/7.1.x/example/parametrize.html) の形式で作成しました。

`_PARSE_WORDS_TESTDATA` の各行（タプル）が１つのテストケースで、タプルの最初の要素が入力文字列、二番目の要素が期待される分割結果です。  

なお、４つのなんとかケースに加え、スペース区切りからの変換も考えてみました。
また、入力文字列が空のケース、短いケース、複数の区切りが混ざったケースもテストケースとして追加しています。

```python
import pytest

_PARSE_WORDS_TESTDATA = [
    ("originallyCamelCase", ["originally", "Camel", "Case"]),
    ("OriginallyPascalCase", ["Originally","Pascal","Case"]),
    ("originally_snake_case", ["originally","snake","case"]),
    ("originally-kebab-case", ["originally","kebab","case"]),
    ("Originally space separated", ["Originally","space","separated"]),
    ("Originally Space Separated Upper", ["Originally","Space","Separated","Upper"]),
    ("", []),
    ("One", ["One"]),
    ("OneTwo", ["One", "Two"]),
    ("Partially camelCase", ["Partially", "camel", "Case"]),
]

@pytest.mark.parametrize("source,expected", _PARSE_WORDS_TESTDATA)
def test_parse_words(source: str, expected: List[str]) -> None:
    actual = list(_parse_words(source))
    assert actual == expected
```

実行コマンドは下記のとおりです。  
詳細は省略しますが、実装を進めてはテスト、を繰り返すことで、効率よく作業ができました。

```bash
$ pytest test_caseutil.py::test_parse_words
```

### 分割処理を実装する

まず区切り文字が明確なスペース、アンダースコア、ハイフンについて区切ります。３種類の区切り文字をまとめて扱えるよう、`str.split()` ではなくて `re.split()` を使うことにします。  

また、見つけた単語をリスト変数に詰めなおす処理を省略できるよう、次々と `yield` してしまう実装にしました。

```python
import re
from typing import Iterator

def _parse_words(string: str) -> Iterator[str]:
    for block in re.split(r"[ _-]+", string):
        yield block
```

ここまでで、次のような分割が実現します。

```python
"originallyCamelCase" --> ["originallyCamelCase"]
"OriginallyPascalCase" --> ["OriginallyPascalCase"]
"originally_snake_case" --> ["originally","snake","case"]
"originally-kebab-case" --> ["originally","kebab","case"]
"Originally space separated" --> ["Originally","space","separated"]
"Originally Space Separated Upper" --> ["Originally","Space","Separated","Upper"]
"Partially camelCase" --> ["Partially", "camelCase"]
```

続いて、もともと連結された入力単語を分割する処理を加えます。  
今回は「先頭1文字は大文字か小文字のアルファベット、それに大文字アルファベット以外が続くもの」を単語と考えることにし、今度は `finditer()` で単語を探しています。  

```python
def _parse_words(string: str) -> Iterator[str]:
    for block in re.split(r"[ _-]+", string):
        for m in re.finditer(r"[A-Za-z][^A-Z]+", block):
            yield m.group(0)
```

これで完成です。

```python
"originallyCamelCase" --> ["originally","Camel","Case"]
"OriginallyPascalCase" --> ["Originally","Pascal","Case"]
"originally_snake_case" --> ["originally","snake","case"]
"originally-kebab-case" --> ["originally","kebab","case"]
"Originally space separated" --> ["Originally","space","separated"]
"Originally Space Separated Upper" --> ["Originally","Space","Separated","Upper"]
"Partially camelCase" --> ["Partially","camel","Case"]
```

なお、 `re.split()` はマッチしなかった残りも返しますが、`re.finditer()` はマッチしない文字を返さないので、「先頭1文字は大文字か小文字のアルファベット、それに大文字アルファベット以外が続くもの」というルールにマッチしないものが文字列に含まれていると、その部分は `_parse_words()` は返さない（読み飛ばしてしまう）点に注意が必要です。

## まとめ

本日の完成品は[こちら](https://gist.github.com/munepi0713/5ed951aaf0f465df7c58bd2c45790a28)に置いてあります。  

https://gist.github.com/munepi0713/5ed951aaf0f465df7c58bd2c45790a28

なお、`"TestID"`、`"XMLParser"` など、 Acronym を含む場合は今回の単語の定義にマッチせず正しく対応できません。今後改善していきたいと思います。