RubyのStringScannerを使って、文字列をスキャンしてみた

最近、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を試してみたときのメモを残します。

 
目次

 

環境

 

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