zz log

zaininnari Blog

openpear/PEGパーサコンビネータを使った簡易CSSパーサ(2)

前回、openpear/PEGパーサコンビネータを使った簡易CSSパーサの続きです。

前回作成したのものを改良して、実践的に使えるものを目指します。

当面の目標は、
Softbank携帯のCSSをパースできるところまで、進めたいと思います。

CSS関連資料

できること

できないこと

  • 「@import」など「@」から始まる文法の解析
    • softbankの技術資料に記載がないため、後回し
  • プロパティと値が実在しない書き方でも、パスします。
    • 「foo {foobar:bar;}」など。「foo {foobar:ああああ;}」もパスする。
  • セレクタのグループ化は未対応
  • 「;」の省略は未対応
div {color:#DDD} /* 本来、最後の「;」は省略できるが、パーサは通らない */

なんちゃってcssパーサ

<?php
require 'PEG.php';

class CSSParser
{
  protected $error = null;
  protected $css = null;

  /**
   * セレクタの解析結果を記録する
   */
  static $logSelector = null;
  /**
   * @var array セレクタ(selector)のチェックリスト
   */
  static $_selectorlist = array(
      '*' => array(
        'null' => true,
        '*' => false,
        ' ' => true,
        't' => false,
        '{' => true,
        '>' => true,
        '#' => true,
        ':' => true,
      ),
      ' ' => array(
        'null' => false,
        '*' => true,
        ' ' => true,
        't' => true,
        '{' => true,
        '>' => true,
        '#' => true,
        ':' => true,
      ),
      't' => array(
        'null' => true,
        '*' => false,
        ' ' => true,
        't' => false,
        '{' => true,
        '>' => true,
        '#' => true,
        ':' => true,
      ),
      '>' => array(
        'null' => false,
        '*' => false,
        ' ' => true,
        't' => true,
        '{' => false,
        '>' => false,
        '#' => true,
        ':' => true,
      ),
      '#' => array(
        'null' => true,
        '*' => false,
        ' ' => true,
        't' => false,
        '{' => true,
        '>' => true,
        '#' => true,
        ':' => true,
      ),
      ':' => array(
        'null' => true,
        '*' => false,
        ' ' => false,
        't' => false,
        '{' => true,
        '>' => false,
        '#' => false,
        ':' => false,
      ),
    );

  /**
   * インスタンスを返す
   *
   * @return object
   */
  function it()
  {
    static $o = null;
    return $o ? $o : $o = new self;
  }

  /**
   * cssパースを開始する
   *
   * @param string $css css
   *
   * @return boolean|array
   */
  function parse($css)
  {
    $this->css = $css;

    // PHP5.3未満
    //配列を連想配列にする array(array('foo', 'boo')) => array(array('foo' => 'boo'))
    $tohash  = create_function(
      'Array $result',
      '$arr = array();
      foreach ($result as $pair) {
        list($key, $value) = $pair;
        $arr[$key] = $value;
      }
      return $arr;'
    );

    // PHP5.3未満
    // 配列を文字列にする array('foo', 'boo') => 'fooboo'
    $toString = create_function(
      'Array $array',
      '
      $result = "";
      foreach ($array as $v) $result .= $v;
      return $result;
      '
    );

    // コメント
    $comment = PEG::seq('/*', PEG::many(PEG::tail(PEG::not('*/'), PEG::anything())), '*/');
    // 空白や改行
    // スペース(0x20)水平タブ(0x09)行送り:LF(0x0A)復帰:CR(0x0D)書式送り:FF(0x0C)
    $space   = PEG::memo(PEG::many1(PEG::char(chr(13).chr(10).chr(9).chr(32).chr(12))));
    // 無視する要素 空白 コメント
    $ignore  = PEG::memo(PEG::many(PEG::choice($space, $comment)));

    // 全称セレクタ
    $universalSelector = PEG::choice(PEG::char('*'), PEG::error('universalSelector'));
    // 子孫セレクタの結合子
    $descendant = PEG::choice(PEG::first(PEG::many1(' ')), PEG::error('descendant'));
    // 子供セレクタの結合子
    $child = PEG::choice('>', PEG::error('child'));
    $selectorChar = PEG::join(PEG::many1(PEG::choice(PEG::alphabet(), PEG::digit(), PEG::char('_-'))));
    // タイプセレクタ
    $typeSelector = PEG::choice($selectorChar, PEG::error('typeSelector'));
    // クラスセレクタ
    $classSelector = PEG::choice(PEG::join(PEG::seq('.', $selectorChar)), PEG::error('classSelector'));
    // IDセレクタ
    $idSelector = PEG::choice(PEG::join(PEG::seq('#', $selectorChar)), PEG::error('idSelector'));
    // リンク擬似セレクタ/ダイナミック擬似セレクタ
    $linkSelector = PEG::choice(PEG::join(PEG::seq(':', $selectorChar)), PEG::error('linkSelector'));
    $selector = PEG::memo(
      PEG::choice(
        $child, $descendant, $universalSelector, $classSelector, $idSelector, $linkSelector,
        $typeSelector, PEG::error('selector')
      )
    );

    $selectors = PEG::many1(
      PEG::choice(
        PEG::hook(array(__CLASS__, syntaxSelector), $selector),
        PEG::error('selectors')
      )
    );

    $charBody = PEG::choice(
      PEG::choice(
        PEG::alphabet(), PEG::digit(), PEG::char('-_'),
        PEG::error('invalid char')
      )
    );

    // 属性
    $property = PEG::memo(
      PEG::choice(
        PEG::hook('trim', PEG::join(PEG::seq(PEG::alphabet(), PEG::many($charBody)))),
        PEG::error('invalid char')
      )
    );

    // 値
    $value = PEG::memo(
      PEG::choice(
        PEG::first(
          PEG::join(PEG::many(PEG::char(';', true))), ';', PEG::drop($ignore)
        ),
        PEG::error('value')
      )
    );

    // 属性:値;
    $declaration = PEG::memo(
      PEG::choice(
        PEG::seq(
          $property, PEG::drop($ignore), PEG::drop(':'), PEG::drop($ignore), $value
        ),
        PEG::drop(PEG::error('declaration'))
      )
    );
    // {}で囲まれた部分 declaration block(宣言ブロック)
    $declarationBlock   = PEG::memo(PEG::hook($tohash, PEG::many($declaration)));

    $style = PEG::memo(
      PEG::hook(
        $tohash,
        PEG::seq(
          PEG::choice(
            PEG::hook(
              $toString,
              PEG::hook(array(__CLASS__, selector), $selectors)
            ),
            PEG::drop(PEG::error('style'))
          ),
          PEG::drop($ignore),
          PEG::drop('{'),
          PEG::drop($ignore), $declarationBlock, PEG::drop($ignore)
        ),
        PEG::drop('}'), PEG::drop($ignore)
      )
    );
    $styles = PEG::memo(PEG::many($style));
    $parser = PEG::second($ignore, $styles, PEG::eos());

    // パーサを実行する
    $res = $parser->parse($context = PEG::context($css));

    if ($res instanceof PEG_Failure) {
      $this->setError($context->lastError());
      return false;
    }

    return $res;
  }

  /**
   * エラーメッセージを記録する
   *
   * @param array $error PEG_IContext が記録したエラー
   *
   * @return ?
   */
  protected function setError(Array $error)
  {
    list($offset,$message) = $error;
    $char = mb_substr($this->css, $offset, 1);
    $this->_error = array(
      'offset'  => $offset,
      'message' => $message,
      'char'    => $char,
    );
  }

  /**
   * エラー情報を取得する
   *
   * @return array
   */
  public function getError()
  {
    return $this->_error;
  }

  /**
   * セレクタの最終チェック
   *
   * @param array $array セレクタ
   *
   * @return array|PEG_Failure
   */
  static function selector(Array $array)
  {
    $count = count($array);
    if ($count === 0) return $array;
    // 前後の空白を省略してよい結合子
    $omissibleCombinators = array('>',chr(32));
    // 前後の空白を削除する
    foreach ($array as $n => $v) {
      if ($n < $count - 1) {
        foreach ($omissibleCombinators as $omissibleCombinator) {
          if ($array[0] === chr(32)) {// 最初の空白を除外
            unset($array[0]);
          } elseif ($array[$count - 1] === chr(32)) {// 最後尾の空白を除外
            unset($array[$count - 1]);
          } elseif ($array[$n] === $omissibleCombinator && $array[$n+1] === chr(32)) {
            unset($array[$n+1]);
          } elseif ($array[$n] === chr(32) && $array[$n+1] === $omissibleCombinator) {
            unset($array[$n]);
          }//var_dump($count !== count($array));
          //変更があれば最初から
          if ($count !== count($array)) {
            $count = count($array);// カウンタを振りなおす
            reset($array);// 内部ポインタを先頭にする
            reset($omissibleCombinators);
          }
        }
      }
    }

    // 添字を振り直す
    $array = array_merge(array_diff($array, array()));

    if ($count > 0) {
      // 初期化。配列の最後尾を登録する
      self::$logSelector = null;
      $result = self::syntaxSelector($array[$count - 1]);
      if($result instanceof PEG_Failure) return PEG::failure();
      // チェック。登録した配列の最後尾と「{」の関係をチェックする
      $result = self::syntaxSelector('{');
      if($result instanceof PEG_Failure) return PEG::failure();
    }

    self::$logSelector = null;

    return $array;
  }

  /**
   * セレクタのチェック。
   * セレクタとして適切であれば、引数をそのまま返す。
   * セレクタとして適切でなければ、PEG_Failureインスタンスを返す。
   * (PEG_Failureインスタンスを返すとパーサは失敗する)
   *
   * @param string $string 解析されたセレクタ
   *
   * @return PEG_Failure|string
   */
  static function syntaxSelector($string)
  {
    // 明らかにセレクタに適当でないものを事前チェックする
    if($string === '' || $string === false) return PEG::failure();
    if(self::$logSelector === null && $string === '{') return PEG::failure();
    if(self::$logSelector === null && $string === '>') return PEG::failure();

    // セレクタを分類する
    $test = $string;
    for (;;) {
      if ($test === '*') break;
      if ($test === ' ') break;
      if ($test === '{') break;
      if ($test === '>') break;
      if ($test === null) {
        $test = 'null';
        break;
      }
      if (mb_substr($test, 0, 1) === '#' || mb_substr($test, 0, 1) === '.') {
        $test = '#';
        break;
      }
      if (mb_substr($test, 0, 1) === ':') {
        $test = ':';
        break;
      }
      if (preg_match('/[\w\-]+/', $test)) {
        $test = 't';
        break;
      }
      throw new InvalidArgumentException('Invalid argument');
    }

    //初回は結果を記録するだけに済ます
    if (self::$logSelector === null) {
      self::$logSelector = $test;
      return $string;
    }

    // 前回と今回の記録を使って、有効かどうかチェックする
    if (!self::$_selectorlist[self::$logSelector][$test]) {
      self::$logSelector = null;
      return PEG::failure();
    }
    // 今回の結果を記録する
    self::$logSelector = $test;

    return $string;
  }


}// end of class

使い方

<?php
$css1 = '
/* コメント */
div {/* コメント */
	/* コメント */font-size:12px;color/* コメント */:/* コメント */#DDD/* コメント */;/* コメント */
}
div a {
	text-align:right;
}
div * #id.class a:link {
	color:#DDD;
}
';
$result1 = CSSParser::it()->parse($css1);
var_dump($css1,$result1);

/*
string '
/* コメント */
div {/* コメント */
	/* コメント */font-size:12px;color/* コメント */:/* コメント */#DDD/* コメント */;/* コメント */
}
div a {
	text-align:right;
}
div * #id.class a:link {
	color:#DDD;
}
' (length=233)

array
  0 =>
    array
      'div' =>
        array
          'font-size' => string '12px' (length=4)
          'color' => string '#DDD/* コメント */' (length=22) //値の解析は未実装
  1 =>
    array
      'div a' =>
        array
          'text-align' => string 'right' (length=5)
  2 =>
    array
      'div * #id.class a:link' =>
        array
          'color' => string '#DDD' (length=4)

*/

$css2 = 'a{font-size!:12px;}';//font-sizeの最後に「!」がついている
$o2 = CSSParser::it();
$result2 = $o2->parse($css2);

var_dump($css2,$result2,$o2->getError());
/*
string 'a{font-size!:12px;}' (length=19)

boolean false

array
  'offset' => int 11
  'message' => string 'invalid char' (length=12)
  'char' => string '!' (length=1)
*/

$css3 = 'div* {font-size!:12px;}';//divの直後に全称セレクタ(*)がついている
$o3 = CSSParser::it();
$result3 = $o3->parse($css3);

var_dump($css3,$result3,$o3->getError());

/*
string 'div* {font-size!:12px;}' (length=23)

boolean false

array
  'offset' => int 3
  'message' => string 'selectors' (length=9)
  'char' => string '*' (length=1)

 */