Onigmo の \G アンカーによる字句走査

ruby の組み込み正規表現オブジェクト Regexp には match メソッドが一つあるだけです。 このメソッドの第 1 引数に文字列を、 第 2 引数には検索開始位置を文字単位で指定します。 第 2 引数は省略可能でそのときはゼロを指定したことになります。 match メソッドは、 onig_search 関数を使って、 文字列の検索開始位置以降にある正規表現パターンにマッチする箇所を探します。

    Regexp#match(string, pos = 0): (String, Integer) -> MatchData | nil

python正規表現オブジェクトには search メソッドと match メソッドの 2 つがあります。 search メソッドは ruby の match メソッドと同じで、 検索開始位置以降からマッチする箇所を探します。 一方、 python の match メソッドは検索開始位置でマッチするかどうかを調べます。 Javascriptpython と同じで、 2 種類のメソッドをもっています。

python の match メソッドは、 字句解析に使うと便利です。 ruby では、 strscan ライブラリで、 python の match メソッドの利用パターンをカプセル化していました。 strscan の scan メソッドも内部では python の match メソッドに相当する onig_match 関数を使って実装してあります。 さらに、 ruby 1.9 以降では、 \G アンカーの有無で、 python の search と match のように、 Regexp オブジェクトの match メソッドを使い分けられるようになっています。

  python:
  import re
  re.compile(r'\d+').search('   1234   ').group(0)    #=> '1234'
  re.compile(r'\d+').match('   1234   ')              #=> None
  re.compile(r'\d+').match('   1234   ', 3).group(0)  #=> '1234'

  ruby1.9 以降:
  %r/\d+/.match('   1234   ').to_a[0]                 #=> '1234'
  %r/\G\d+/.match('   1234   ').to_a[0]               #=> nil
  %r/\G\d+/.match('   1234   ', 3).to_a[0]            #=> '1234'

StringScanner にカプセル化してあるとはいっても、 バイト単位で文字列を扱うため、 UTF-8 エンコーディングでの使い勝手が悪いのが欠点です。 さらに、 戻り読みも利用できません。 リファレンス・マニュアルに \G アンカーによる match の挙動の差が記載されないので、 これまでは StringScanner を我慢して使ってきましたが、 ruby 1.9 で組み込まれてから ruby 2.6 を迎えようとしています。 その間、 利用可能なまま過ぎています。 そろそろ使っても平気だろうと判断しました。

先日の CommonMark の強調文字マーク (星印限定) 判別は、 \G と match メソッドを使って次のように、 戻り読みで記述することができます。

def flanking(str)
  pos = str.index('*') or return nil
  # pos = %r/[*]/.match(str)&.begin(0) or return nil
  left = right = nil
  if %r/\G[*]++[^\p{Space}\p{Punct}]/.match(str, pos)
    left = :left
  elsif %r/\G(?:^|(?<=[\p{Space}\p{Punct}]))[*]++\p{Punct}/.match(str, pos)
    left = :left
  end
  if %r/\G(?<=[^\p{Space}\p{Punct}])[*]/.match(str, pos)
    right = :right
  elsif %r/\G(?<=\p{Punct})[*]++(?:$|[\p{Space}\p{Punct}])/.match(str, pos)
    right = :right
  end
  (left || right) ? [left, %r/\G[*]+/.match(str, pos)[0], right] : nil
end

StringScanner の代わりにするには、 文字列と検索開始位置を組にして、 マッチしたら検索開始位置を進めていく小さなクラスを使うことにします。

class MiniStrScanner
  attr_accessor :string, :pos, :last_matched

  def initialize(string, pos=0)
    @string, @pos, @last_matched = string, pos, nil
  end

  def eos?() @pos >= @string.size end
  def bol?() 0 == @pos || "\n" == @string[@pos - 1] end
  def match?(regexp) @last_matched = regexp.match(@string, @pos) end

  def [](i) @last_matched&.[](i) end
  def begin(i) @last_matched&.begin(i) end
  def end(i) @last_matched&.end(i) end

  def branch() self.class.new(@string, @pos) end
  def merge(scanner) @pos = scanner.pos; self end

  def scan(regexp)
    @last_matched = regexp.match(@string, @pos) or return nil
    @pos = @last_matched.end(0)
    self
  end
end