Home About Contact
Kotlin , State Machine , Text Processing

特定ルールでテキストのヘッダとボディとを分離する

kotlin でステートマシンを使って行ごとの状態を把握したい。

まあ、そんな大げさな話ではない。 テキストファイルの先頭から行ごとに調べて、見出し行が出現する直前までをヘッダとし、それ以後はボディとして扱いたい。そのためのコードをどう書くかの話。

例として、次のようなテキストファイルを考える。

example.txt

date: 2023-04-01
id: c5c7237d-5850-47b8-9dac-a96bd8b934b9

# Lorem Ipsum

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

ヘッダとボディの区切りは、以下の正規表現で判定する。

val titleRegex = "^#\\s".toRegex()

つまり、行の先頭が # + 半角スペース ではじまったら、その行を含むそれ以降をボディと考えることにする。

main.kts

import java.io.File
import java.io.FileInputStream

enum class NoteContentState { HEADER, BODY }

val titleRegex = "^#\\s".toRegex()

val headerLines = mutableListOf<String>()
val bodyLines = mutableListOf<String>()

FileInputStream(File("example.txt"))
    .bufferedReader(Charsets.UTF_8)
    .useLines { lineSequences: Sequence<String> ->

    var currentState = NoteContentState.HEADER

    lineSequences.forEach { line->
        val matchResult = titleRegex.find(line)
        if( matchResult!=null ){
            currentState = NoteContentState.BODY
        }

        when(currentState){
            NoteContentState.HEADER -> headerLines.add(line)
            NoteContentState.BODY   -> bodyLines.add(line)
        }
    }
}

println("--- header ---")
println( headerLines.joinToString("\n") )
println("--- body ---")
println( bodyLines.joinToString("\n") )

初期状態では NoteContentState.HEADER にしておいて、先頭行から調べつつ、タイトル行を見つけたら、NoteContentState.BODY 状態に遷移する。 あとはその状態に応じて headerLines か bodyLines に行の値を蓄積させるだけのコード。

ファイル構成の確認:

.
├── example.txt
└── main.kts

実行する。

$ kotlinc -script main.kts
--- header ---
date: 2023-04-01
id: c5c7237d-5850-47b8-9dac-a96bd8b934b9

--- body ---
# Lorem Ipsum

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

ヘッダとボディをわけたい、という目的を達成させればよいのであれば、機能している。

このコードをリファクタリングする。

main2.kts

import java.io.File
import java.io.FileInputStream

enum class NoteContentState { HEADER, BODY }

typealias Line = Pair<NoteContentState,String>
typealias Header = List<String>
typealias Body   = List<String>

val updateState: (NoteContentState)->NoteContentState = { currentState->
    when(currentState){
        NoteContentState.HEADER-> NoteContentState.BODY
        NoteContentState.BODY-> NoteContentState.BODY
    }
}

val titleRegex = "^#\\s".toRegex()

val toHeaderAndBody: (File)-> Pair<Header, Body> = { file->
    FileInputStream(file)
    .bufferedReader(Charsets.UTF_8)
    .useLines { lineSequences: Sequence<String> ->
        val initialLines = listOf<Line>()
    
        val lines: List<Line> = lineSequences.fold( initialLines, { acc, line->
            val currentState = if( acc.size==0 ){
                NoteContentState.HEADER
            } else {
                acc[acc.size-1].first
            }
    
            val m = titleRegex.find(line)
            if( m!=null ){
                (acc + listOf(Pair(updateState(currentState), line)))
            } else {
                (acc + listOf(Pair(currentState, line)))
            }
        })
    
        Pair(
            lines.filter { it.first==NoteContentState.HEADER } .map {it.second},
            lines.filter { it.first==NoteContentState.BODY   } .map {it.second} )
    }
}

val (headerLines, bodyLines) = toHeaderAndBody( File("example.txt") )

println("--- header ---")
println( headerLines.joinToString("\n") )
println("--- body ---")
println( bodyLines.joinToString("\n") )

headerLines, bodyLines の MutableList を使うのをやめ、 useLinesReader から読みとった結果を返すことを期待されているので、そのようにコードした。

このコードが何をしているコードなのかを表現しているのは、以下の部分になる。

enum class NoteContentState { HEADER, BODY }

typealias Line = Pair<NoteContentState,String>
typealias Header = List<String>
typealias Body   = List<String>

val toHeaderAndBody: (File)-> Pair<Header, Body> = { file-> ...

enum や typealias 宣言はいいとして、関数 toHeaderAndBody についてもシグニチャーだけ切り離して(コードの先頭で)宣言できる書き方があればいいのに。

以下のように typealias で書いてもいいのかもしれない。

typealias ToHeaderAndBody = (File)-> Pair<Header, Body>

この場合関数の実装部分は以下のようになる。

val toHeaderAndBody: ToHeaderAndBody = { file-> ...

くどい。

tyepalias していれば、以下のように簡単に関数実装を書いても意味はわかるが・・・

val f: ToHeaderAndBody = { file-> ...

この関数を使うときに f(file) のように書くことになり、この部分では可読性が著しく落ちる。