品詞や読みを指定して文字列を置換するコマンドを作った

Clojureで文章を形態素解析して、品詞や読みを指定してマッチした文字列を置換するコマンドを作りました。
この記事では作ったコマンドの使い方と仕組みの話をします。

# ソースコード

ソースコードは以下。

https://github.com/jiro4989/morsed

GitHub Releasesに実行可能ファイルを配置しています。
Linux用とMac用だけ置いてるので、使う方はこちらを使ってください。

# 開発環境

| Key | Value |
| ----| ----- |
| OS | Ubuntu 18.04 |
| Clojure | 1.10.1 |
| Leiningen | 2.9.4 |

# 使い方

以下のように使います。

```bash
$ morsed -p 名詞 -s 寿司 吾輩は猫である。名前はまだない
寿司は寿司である。寿司はまだない

# 標準入力からも入力可能
$ morsed -p 名詞 -s 寿司 < README.adoc
```

複数条件を指定した場合はAND条件となります。

```bash
$ morsed -p 名詞 --part2 代名詞 -s 寿司 彼彼女田中
寿司寿司田中
```

# 仕組み

まず形態素解析には [kuromoji](https://www.atilika.org/) という形態素解析用のJava製のライブラリを使いました。
以下の処理で形態素解析ができます。

```clojure
(ns morsed.core
  (:import [com.atilika.kuromoji.ipadic Token Tokenizer]))

(defn tokens [^String s]
  (.tokenize (Tokenizer.) s))

; (tokens "吾輩は猫である")
```

Clojureは既存のJavaのライブラリも利用可能なので、Javaの膨大な資産を活用できます。
素晴らしいです。

条件のマッチ判定は以下の処理で行っています。

入力文字列を正規表現オブジェクトに変換しています。
変換された正規表現オブジェクトでマッチするかを再帰的にチェックし、一度でもマッチしなければfalseで処理を抜けます。
全部パスした時だけ置換後の文字列で置き換えます。

```clojure
(ns morsed.core
  (:import [com.atilika.kuromoji.ipadic Token Tokenizer])
  (:require [clojure.tools.cli :refer [parse-opts]]
            [clojure.string :as str]
            [cheshire.core :refer [generate-string]])
  (:gen-class))

;; 省略 ;;

(defn re-match-str? [^String ptn
                     ^String text]
  (if (nil? ptn)
    false
    (not (nil? (re-matches (re-pattern ptn) text)))))

(defn token-matched? [^Token token
                      opts
                      k]
  (case k
    :reading (re-match-str? (opts k) (.getReading token))
    :part (re-match-str? (opts k) (.getPartOfSpeechLevel1 token))
    :part2 (re-match-str? (opts k) (.getPartOfSpeechLevel2 token))
    :part3 (re-match-str? (opts k) (.getPartOfSpeechLevel3 token))
    :part4 (re-match-str? (opts k) (.getPartOfSpeechLevel4 token))
    :pronunciation (re-match-str? (opts k) (.getPronunciation token))
    :conjugationform (re-match-str? (opts k) (.getConjugationForm token))
    :conjugationtype (re-match-str? (opts k) (.getConjugationType token))
    :baseform (re-match-str? (opts k) (.getBaseForm token))
    :surface (re-match-str? (opts k) (.getSurface token))
    false))

(defn token-matched-all? [^Token token
                          opts]
  (loop [k (keys opts)]
    (if (empty? k)
      true
      (if-not (token-matched? token opts (first k))
        false
        (recur (rest k))))))
```

opts には置換に指定する文字列をセットしています。
以下のマップです。空文字でないキーのみを判定に使用します。

```clojure
{:surface ""
 :baseform ""
 :conjugationform ""
 :conjugationtype ""
 :part ""
 :part2 ""
 :part3 ""
 :part4 ""
 :pronunciation ""
 :reading ""}
```

詳細はソースコードを見ていただければ。
テストコードを省くと1ファイルで136行しかないので読むのは難しくないと思います。

# 感想

多少コマンドラインオプションを追加してできることを増やした結果現在は136行ほどですが、初期リリース時点では40行ほどで目的を達成できました。

Clojureの表現力の高さと標準ライブラリの豊富さと、Javaの資産を活用できる仕様のおかげで、短期間で目的を達成できました。
ライブラリの作者様にはとても感謝しております。

# 余談 実行可能ファイルの自動リリース

GitHub Releasesに配布している実行可能ファイルは、各プラットフォーム向けに生成されたNativeImageです。
GraalVMを利用すると各プラットフォーム向けの実行可能ファイルを生成できます。

ClojureはJava系言語なので、実行可能JARやクラスファイル指定での実行の場合はVMの起動に時間がかかります。
コマンドラインから利用する場合は、VMの起動時間がネックになってしまいます。
この問題を解消するために、NativeImageを作成することにしました。

今回GitHub Actionsを使用して、CIからNativeImageを作成してGitHub Releasesにアップロードするようにしています。

```release.yml
name: release

on:
  push:
    tags:
      - 'v*.*.*'

env:
  app-name: 'morsed'
  release-files: morsed README.* LICENSE

defaults:
  run:
    shell: bash

jobs:
  build:
    runs-on: ${{ matrix.runs-on }}
    strategy:
      matrix:
        include:
          - runs-on: ubuntu-latest
            os: linux
            cmd: gu
            opts: '--static'
          - runs-on: macOS-latest
            os: darwin
            cmd: gu
            opts: ''
          # - runs-on: windows-latest
          #   os: windows
          #   cmd: gu.cmd
    steps:
      - uses: actions/checkout@v2

      - uses: DeLaGuardo/setup-graalvm@master
        with:
          graalvm-version: '20.2.0.java11'
      - uses: DeLaGuardo/setup-clojure@master
        with:
          # To use Clojure CLI 1.10.1.561 based on tools.deps
          cli: '1.10.1.469'
          # leiningen and boot-cli can be installed as well
          lein: 2.9.4
          # For leiningen and boot you could use 'latest' version
          boot: latest

      - name: Build native image
        run: |
          ${{ matrix.cmd }} install native-image
          lein uberjar
          native-image \
            -jar target/${{ env.app-name }}.jar \
            -H:Name=${{ env.app-name }} \
            -H:+ReportExceptionStackTraces \
            -J-Dclojure.spec.skip-macros=true \
            -J-Dclojure.compiler.direct-linking=true \
            "-H:IncludeResources=command.edn" \
            "-H:IncludeResources=schema.edn" \
            "-H:IncludeResources=config.edn" \
            "-H:IncludeResources=version.txt" \
            "-H:IncludeResources=docs.adoc" \
            '-H:IncludeResources=.*/.*.bin$' \
            --initialize-at-build-time  \
            --report-unsupported-elements-at-runtime \
            -H:Log=registerResource: \
            --verbose \
            --no-fallback \
            --no-server \
            ${{ matrix.opts }} \
            "-J-Xmx3g"

      - name: Run command
        run: |
          ./${{ env.app-name }}
          ./${{ env.app-name }} -p 名詞 -s 寿司 吾輩は猫である。

      - name: Create artifact
        run: |
          assets="${{ env.app-name }}_$(echo "${{ runner.os }}" | tr '[:upper:]' '[:lower:]')"
          echo "$assets"
          mkdir -p "dist/$assets"
          cp -r ${{ env.release-files }} "dist/$assets/"
          (
            cd dist
            if [[ "${{ runner.os }}" == Windows ]]; then
              7z a "$assets.zip" "$assets"
            else
              tar czf "$assets.tar.gz" "$assets"
            fi
            ls -lah *.*
          )
      - uses: actions/upload-artifact@v2
        with:
          name: artifact-${{ matrix.os }}
          path: |
            dist/*.tar.gz
            dist/*.zip

  create-release:
    runs-on: ubuntu-latest
    needs:
      - build
    steps:
      - uses: actions/checkout@v2
        with:
          fetch-depth: 0
      - name: Create Release
        id: create-release
        uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tag_name: ${{ github.ref }}
          release_name: ${{ github.ref }}
          body: |
            ${{ github.ref }}
          draft: false
          prerelease: false

      - name: Write upload_url to file
        run: echo '${{ steps.create-release.outputs.upload_url }}' > upload_url.txt

      - uses: actions/upload-artifact@v2
        with:
          name: create-release
          path: upload_url.txt

  upload-release:
    runs-on: ubuntu-latest
    needs: create-release
    strategy:
      matrix:
        include:
          - os: linux
            asset_name_suffix: linux.tar.gz
            asset_content_type: application/gzip
          - os: darwin
            asset_name_suffix: macos.tar.gz
            asset_content_type: application/gzip
          # - os: windows-latest
          #   asset_name_suffix: windows.zip
          #   asset_content_type: application/zip
    steps:
      - uses: actions/download-artifact@v2
        with:
          name: artifact-${{ matrix.os }}

      - uses: actions/download-artifact@v2
        with:
          name: create-release

      - id: vars
        run: |
          echo "::set-output name=upload_url::$(cat upload_url.txt)"

      - name: Upload Release Asset
        id: upload-release-asset 
        uses: actions/upload-release-asset@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          upload_url: ${{ steps.vars.outputs.upload_url }}
          asset_path: ${{ env.app-name }}_${{ matrix.asset_name_suffix }}
          asset_name: ${{ env.app-name }}_${{ matrix.asset_name_suffix }}
          asset_content_type: ${{ matrix.asset_content_type }}
```

ref: https://github.com/jiro4989/morsed/blob/master/.github/workflows/release.yml

以下のコマンドを実行するだけで、実行可能ファイルの生成が完了します。
快適です。

```bash
git tag v0.1.0
git push origin --tags
```


# まとめ

- Clojureで文章を形態素解析して、品詞や読みを指定してマッチした文字列を置換するコマンドの実装の説明をしました
    - 使っているライブラリ
    - ライブラリの使い方
    - 文字列マッチロジック
- NativeImageを作成するGitHubActionsワークフローについて説明しました

以上です