PHP で CSS をパースしてみよう

f:id:aiyoneda:20191202145228p:plain

こんにちは!基盤グループのめもりー (@m3m0r7) です。最近発売された MacBookPro 16 inch を個人的に買ってご満悦です。 この記事は、BASE アドベントカレンダー24日目 の1つ目です。

みなさんは普段プログラミングをするのはどういうときでしょうか。仕事やプライベート、そもそもプログラミングそのものが趣味…などいろんなシーンがあるかなと思います。

私は会社ではプロダクトのためのコードを書いていますが、プライベートでは本来プロダクトで触らない、そう例えば PHP で JVM を実装したり、 PHP そのものを JVM 言語にしていたり、それ以外にも自宅のペットの監視システムを作ったりなどなどしています。

さて、早速ですがこれをご覧ください。

.hello-world {
    property1: value1;
    property2: value2;
}

これを見てあなたはどう感じましたか?「CSS だ!」と感じましたか、それとも「どうやったらパースできるんだろう」と感じましたか?

今日のアドベントカレンダーはプロダクトとは少し離れたお話で、PHP で CSS をパースしてみようと思います。 CSS は Selector Level 3 からいろんな書式が扱えるようになりました。 とはいえ、これをすべてパースするのはとても時間がかかります。簡単なセレクタとプロパティをパースしてプログラム側で扱いやすいようなところまでをゴールとしてやってみようと思います。

この要件としては

  • .hello-world をセレクターとして書き出したい。
  • property1, property2 を .hello-world のプロパティ一覧として扱いたい。

といったところでしょうか。

これをコードで表すには概ね下記のようにする必要があるかと思います。

  • { までをセレクターとして扱う
  • {, } の間の値をプロパティ文字列として扱う、言い換えると { を読み取った位置から } までをプロパティ文字列として扱います。
  • プロパティ文字列から : までをプロパティ名、 ; までを値として扱い、終わるまで繰り返す。

まずは、{}, :, ; までを読み込む、つまり任意のトークンまでの文字列を取得する関数を定義します。

function readTo(string $text, string $delimiterToken, int &$i): string {
    $length = mb_strlen($text);
    $string = '';

    // 指定したトークンまで読みすすめる
    while (($char = mb_substr($text, $i, 1)) && $char !== $delimiterToken && $i < $length) {
        $string .= $char;
        $i++;
    }

    return $string;
}

上記のような形にします。この関数は、 指定したトークンまで読みすすめる、または CSS の終端までの間の文字列を返すためのものです。

例えばこれを

readTo($css, '{', $i);

上記のように呼び出すと、 { までの文字列を取得します。これを使って {} までのそれぞれの文字列を読み込みます。

$selectors = [];
$length = mb_strlen($css);
for ($i = 0; $i < $length; $i++) {
    $statement = [
        'selector' => '',
        'properties' => [],
    ];

    // { まで読みすすめる
    $statement['selector'] = trim(readTo($css, '{', $i));

    // この時点だとまだ { なので、一つすすめる。
    $i++;

    $statement['properties'] = trim(readTo($css, '}', $i));

    $selectors[] = $statement;
}

これを出力すると

array(1) {
  [0]=>
  array(2) {
    ["selector"]=>
    string(12) ".hello-world"
    ["properties"]=>
    string(41) "property1: value1;
    property2: value2;"
  }
}

上記のようになります。次に与えられたプロパティ文字列をパースするために下記の関数を定義します。

function parseProperties(string $propertyString): array {
    $length = mb_strlen($propertyString);

    $properties = [];
    for ($i = 0; $i < $length; $i++) {
        $name = trim(readTo($propertyString, ':', $i));
        $i++;
        $value = trim(readTo($propertyString, ';', $i));

        if ($name === '') {
            continue;
        }

        $properties[$name][] = $value;
    }
    return $properties;
}

これも上記と原理は同じで、 : までと ; までの文字列をそれぞれ取得し、それを配列に入れています。 また、名前がない箇所はプロパティとして考えないようにもしています。

これを $statement['properties'] = trim(readTo($css, '}', $i)); に通してあげて、

$selectors = [];
$length = mb_strlen($css);
for ($i = 0; $i < $length; $i++) {
    $statement = [
        'selector' => '',
        'properties' => [],
    ];

    // { まで読みすすめる
    $statement['selector'] = trim(readTo($css, '{', $i));

    // この時点だとまだ { なので、一つすすめる。
    $i++;

    $statement['properties'] = parseProperties(trim(readTo($css, '}', $i)));

    $selectors[] = $statement;
}

上記のようにして出力すると、下記のように取得できます。

array(1) {
  [0]=>
  array(2) {
    ["selector"]=>
    string(12) ".hello-world"
    ["properties"]=>
    array(2) {
      ["property1"]=>
      array(1) {
        [0]=>
        string(6) "value1"
      }
      ["property2"]=>
      array(1) {
        [0]=>
        string(6) "value2"
      }
    }
  }
}

CSS のパースができました。上のコードをまとめると下記のようになるかと思います。

<?php
$css = <<< EOS
.hello-world {
    property1: value1;
    property2: value2;
}
EOS;

$selectors = [];

function readTo(string $text, string $delimiterToken, int &$i): string {
    $length = mb_strlen($text);
    $string = '';

    // 指定したトークンまで読みすすめる
    while (($char = mb_substr($text, $i, 1)) && $char !== $delimiterToken && $i < $length) {
        $string .= $char;
        $i++;
    }

    return $string;
}

function parseProperties(string $propertyString): array {
    $length = mb_strlen($propertyString);

    $properties = [];
    for ($i = 0; $i < $length; $i++) {
        $name = trim(readTo($propertyString, ':', $i));
        $i++;
        $value = trim(readTo($propertyString, ';', $i));

        if ($name === '') {
            continue;
        }

        $properties[$name][] = $value;
    }
    return $properties;
}

$length = mb_strlen($css);
for ($i = 0; $i < $length; $i++) {
    $statement = [
        'selector' => '',
        'properties' => [],
    ];

    // { まで読みすすめる
    $statement['selector'] = trim(readTo($css, '{', $i));

    // この時点だとまだ { なので、一つすすめる。
    $i++;

    $statement['properties'] = parseProperties(
        readTo($css, '}', $i)
    );

    $selectors[] = $statement;
}


var_dump($selectors);

本来は media クエリのような {, } などのネストにも対応させたり、セレクタの優先度があったり複雑になるのですが、ブログの 1 記事に収められなくなってしまうため、今回は簡単にパースするまでのお話でした。