Home About
Kotlin , state-machines

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

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

というか、そんな大げさな話ではない。 ^#\s を見出し行として、それより上がヘッダ、それより下がボディとしているテキストファイルがあったときに、 ヘッダとボディを別々に分けたい、そのときどうしたらいいかという話。

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

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.

これを ヘッダとボディにわけたい場合、(副作用がある)コードをとりあえず書いてみる。

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.

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

ただ、これはかなり気持ち悪いコード。 副作用を発生させないでこれを記述できないか考えてみた。

それから useLinesReader から読みとった結果を返すことを期待されているわけだから、そのようにコードしたい。

main2.kts

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

enum class NoteContentState { HEADER, BODY }

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

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

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

    val initialValue = listOf<Pair<NoteContentState,String>>()

    val pairs = lineSequences.fold( initialValue, { acc, line->
        val currentState = if( acc.size==0 ){ NoteContentState.HEADER } else { acc[acc.size-1].first }

        val matchResult = titleRegex.find(line)
        if( matchResult!=null ){
            (acc + listOf(Pair(updateState(currentState), line)))
        } else {
            (acc + listOf(Pair(currentState, line)))
        }
    })

    val headerLines = pairs.fold( listOf<String>(), { acc, pair->
        when(pair.first){
            NoteContentState.HEADER -> (acc + listOf(pair.second))
            NoteContentState.BODY   -> acc
        }
    })

    val bodyLines = pairs.fold( listOf<String>(), { acc, pair->
        when(pair.first){
            NoteContentState.HEADER -> acc
            NoteContentState.BODY   -> (acc + listOf(pair.second))
        }
    })

    Pair(headerLines, bodyLines)
}

println( headerLines.joinToString("\n") )
println( bodyLines.joinToString("\n") )

補足: pairs (pairs: List<Pair<NoteContentState, String>>) の部分で行ごとに HEADER か BODY かのメタ情報を追加している。

コードは長くなったが、とりあえず副作用がなくなったし、 useLines は結果を返すようにしたので、 これで(自己)満足することにする。