PHP
正規表現

sprintfのフォーマット文字列の変数にマッチする正規表現

sprintfのformatの変換指定の数をカウントしなければならなくなったので、正規表現書いたメモです。

とりあえず、phpのsprintfのドキュメントに載ってる例にはすべてマッチします。
https://regex101.com/r/EM27Au/1

結論

//簡易(位置指定子と型s,dのみ)
(?:^|[^%])(?:%%)*(%(?:\d+\$)?[sd])
//完全版(たぶん)
(?:^|[^%])(?:%%)*(%(?:\d+\$)?(?:\+)?(?:0|'.)?(?:\-)?(?:\d+)?(?:\.\d+)?[bcdeEufFgGosxX])
//php上で使うときは/と$をエスケープ

//簡易(位置指定子と型s,dのみ)
$reg1 = "/(?:^|[^%])(?:%%)*(%(?:\\d+\\\$)?[sd])/u";

//完全版(たぶん)
$reg2 = "/(?:^|[^%])(?:%%)*(%(?:\\d+\\\$)?(?:\\+)?(?:0|'.)?(?:\\-)?(?:\\d+)?(?:\\.\\d+)?[bcdeEufFgGosxX])/u";

正規表現を組み立てた手順

正規表現そのものだけだと合ってるんだか自分でも判別つきがたく、
あとで修正入れようにもよくわからなくなるので、組み立てた手順を書きます。
間違いなどあれば突っ込んでいただければ喜びます。

1. %s にマッチさせる

まず単純に%sにマッチすることを目指します。
正規表現を使わなくても大丈夫に見えますが、エスケープに注意しなければいけません。
"%"+"s"ではなくエスケープされてない% + "s"です。

エスケープされてない: %s 、 %%%s 、 %%%%%s
すべてエスケープ済み: %%s%%%%s

こう分けてみるとエスケープされてない%というのは、奇数個連続した%の末尾と言い換えることができそうです。

奇数=偶数+1 であり、
偶数個の%は正規表現で、(?:%%)*と書くことが出来そうです。

(?:)はグループ化の意味です。
%%のグループが0個以上ある = 偶数個の%

しかし実行してみると(?:%%)*は奇数個の%にもマッチしてしまいます。
なぜなら、%%%などの最後の二文字にマッチしてしまうから。
(?:%%)*には「 文の始めである or %以外の文字から連続する」という条件が必要なようです。

「 文の始め or %以外の文字 」 は(?:^|[^%]) で表現できます。

^ => 行の始め
| => or
[^%] => %以外の文字

合わせると、エスケープされていない%は、(?:^|[^%])(?:%%)*% ということになります。
よって、%sにマッチする正規表現 は

(?:^|[^%])(?:%%)*(%s)

2. %dにもマッチさせる

%sだけでなく%dも必要です。これは簡単で、s[sd]に変えるだけです。

(?:^|[^%])(?:%%)*(%[sd])

3. 位置指定子に対応させる。

sprintfに複数の変換指定を使うとき、%1$s%2$sのように位置指定子を使って順序の指定をします。

位置指定子は数値+"$"ですので、(?:\d+\$)と表現できます。

位置指定子は必ず%の直後と決まっているので、次のようになります。

(?:^|[^%])(?:%%)*(%(?:\d+\$)[sd])

ただし%sなど位置指定子を使わない場合も対応しなければなりません。

よって最終的には、

(?:^|[^%])(?:%%)*(%(?:\d+\$)?[sd])

[sd]の直前に?が増えています。
(?:パターン)?のように末尾に?を付けると在っても無くてもよいという意味になります。

3.すべての変換指定子に対応する

sprintfのformatは次のようになっています。
%位置指定子符号指定子padding指定子align指定子幅指定子精度指定子型指定子
この順序は固定なので、それぞれの指定子のパターンを前述の位置指定子の場合と同じように並べていけば良さそうです。

名称 正規表現 内容
位置指定子 \d+\$ 数値(ゼロパディング可)と$
符号指定子 \+ +
padding指定子 0|'.] 0 または `と任意の1文字
align指定子 \- -
幅指定子 \d+ 数値(ゼロパディング可)
精度指定子 \.\d+ ピリオドと数値(ゼロパディング可)
型指定子 [bcdeEufFgGosxX] 特定のアルファベット

一覧にしてみると思ったよりシンプルですね。
これを合わせてみたら完成です。

(?:^|[^%])(?:%%)*(%(?:\d+\$)?(?:\+)?(?:0|'.)?(?:\-)?(?:\d+)?(?:\.\d+)?[bcdeEufFgGosxX])

疑問点

PHPマニュアル/sprintfのページに次のような説明があります。

オプションの精度指定子 (ピリオド (.) に続けてオプションで桁数指定文字列を書いたもの)。 これは、浮動小数点数に対して数字を何桁まで表示するかを指定します。 文字列に対して使用した場合は、これは切り捨て位置として働きます。 この文字数を超える文字を切り捨てられます。 さらに、数値の桁埋めに使う文字を指定することもできます。桁埋め文字は、ピリオドと数値の間に指定します。

ピリオドの後に桁埋め文字が書けるというので、試してみたのですが、結果は次のようなものでした。

echo sprintf("[%'.9d]" , 123); // → [......123]
echo sprintf("[%'.09d]", 123); // → [000000123]
echo sprintf("[%'.#9d]", 123); // → [9d]

他の言語のprintfのリファレンス見てもピリオドの後に桁埋め文字というのは見当たらないので、マニュアルの間違いなんじゃないかと思うんですが……。