最近、Rubyで文字列の字句解析を行う機会がありました。
何を使えばよいか考えたときに、最初に思い浮かんだのが Regexp#match
でした。
class String (Ruby 3.3 リファレンスマニュアル)
他にもないか調べたところ、文字列スキャナクラス StringScanner
がありました。
class StringScanner (Ruby 3.3 リファレンスマニュアル)
StringScannerは、Regexpと同じように正規表現を使うのに加え、
- スキャンし終わった位置のインデックスであるスキャンポインタを持つ
bol?
やeos?
でスキャンポインタが先頭・末尾であるかを示せるrest
でスキャンしていない残りの文字列を取得できる
などの機能もあるようでした。
また、以下のスライドのp13を読むことで、StringScannerの動作イメージがわきました。
Improved REXML XML parsing performance using StringScanner - Speaker Deck
そこで今回は、StringScannerを試してみたときのメモを残します。
目次
環境
- Ruby 3.3.6
StringScannerの各メソッドを使ってみる
StringScannerには色々なメソッドが用意されており、どんな挙動になるのかは前述のリファレンスマニュアルに詳しく書かれています。
そこで、まずは Hello, world!
という文字列を対象にMinitestによるテストコードを書いて動作を確認していきます。
最初に StringScanner のインスタンスを生成します。
class StringScannerTest < Minitest::Test def test_hello_world text = 'Hello, world!' scanner = StringScanner.new(text)
続いて、 bol?
メソッドを使い、スキャンポインタが先頭にあることを確認します。また、 eos?
メソッドにてスキャンポインタが末尾にないことも確認します。
assert_equal true, scanner.bol? assert_equal false, scanner.eos?
次に scan
メソッドを使い、Hello
という文字列にマッチすることを確認します。なお、 ここではPOSIX文字クラス [[:word:]]
を使っています。
正規表現 (Ruby 3.3 リファレンスマニュアル)
assert_equal 'Hello', scanner.scan(/[[:word:]]+/)
残りの文字を rest
メソッドで確認します。
assert_equal ', world!', scanner.rest
続く文字は ,
であるかを match?
メソッドを使って確認します。
assert_equal true, scanner.match?(/[[:word:]+]/).nil? assert_equal 1, scanner.match?(/,/)
次に skip_until
で、空白が出てくるところまでスキャンポインタを移動します。
assert_equal 2, scanner.skip_until(/[[:space:]]/)
空白のところへスキャンポインタが移動しているので、残りは world!
だけになります。
assert_equal 'world!', scanner.rest
残りについては skip
メソッドでスキャンポインタを移動します。
assert_equal 6, scanner.skip(/[[:word:]]+!/) assert_equal '', scanner.rest
これで文字列の末尾になりました。
assert_equal true, scanner.eos?
自分のりんごの記録を解析してみる
最近はBlueskyにて食べたりんごを記録しています。
https://bsky.app/profile/thinkami.bsky.social
そこで、過去の投稿を解析してみました。テストはパスしたので良さそうです。
text = '[リンゴ] 今日は `シナノドルチェ` を食べた。パリッとした食感で、果汁があふれ出た。酸味と甘味がしっかりとあっておいしかった。' scanner = StringScanner.new(text) # スキャンポインタを進めつつ、 "[リンゴ]" 始まりかを確認 unless scanner.skip(/\[リンゴ\]/) raise 'リンゴの投稿ではない' end # リンゴ名の前まで移動 unless scanner.skip(/[[:space:]]*[[:word:]]+[[:space:]]`/) raise 'リンゴ名がなさそう' end # リンゴ名を取得 unless scanner.match?(/[[:word:]]+`/) raise 'やっぱりリンゴ名がなさそう' end apple_name = scanner.scan(/[[:word:]]+/) # 感想文の前まで移動 unless scanner.skip(/`[[:space:]]*[[:word:]]+[[:space:]]*。/) raise 'リンゴ名の一文が終わってなさそう' end # 感想を取得 impression = scanner.scan(/[[[:word:]]|[[:punct:]]]+/) assert_equal true, scanner.eos? assert_equal 'シナノドルチェ', apple_name assert_equal 'パリッとした食感で、果汁があふれ出た。酸味と甘味がしっかりとあっておいしかった。', impression
ソースコード
GitHubに上げました。
https://github.com/thinkAmi-sandbox/ruby_string_scanner_example