pug-php/ci-pug

View on GitHub
Jade/Parser.php

Summary

Maintainability
F
3 days
Test Coverage
<?php

namespace Jade;

class Parser
{
    public $basepath;
    public $extension;
    public $allowMixedIndent;
    public $textOnly = array('script', 'style');
    public static $includeNotFound = ".alert.alert-danger.\n\tPage not found.";

    protected $options = array();
    protected $input;
    protected $lexer;
    protected $filename;
    protected $extending;
    protected $blocks = array();
    protected $mixins = array();
    protected $contexts = array();

    public function __construct($input, $filename = null, array $options = array())
    {
        $defaultOptions = array(
            'allowMixedIndent' => true,
            'extension'        => array('.pug', '.jade'),
        );
        foreach ($defaultOptions as $key => $default) {
            $this->$key = isset($options[$key]) ? $options[$key] : $default;
            $this->options[$key] = $this->$key;
        }

        $this->setInput($filename, $input);

        if ($this->input && $this->input[0] === "\xef" && $this->input[1] === "\xbb" && $this->input[2] === "\xbf") {
            $this->input = substr($this->input, 3);
        }

        $this->lexer = new Lexer($this->input, $this->options);
        array_push($this->contexts, $this);
    }

    protected function hasValidTemplateExtension($path)
    {
        $extensions = is_string($this->extension)
            ? array($this->extension)
            : $this->extension;
        foreach (array_unique($extensions) as $extension) {
            if (substr($path, -strlen($extension)) === $extension) {
                return true;
            }
        }

        return false;
    }

    protected function getTemplatePath($path)
    {
        $extensions = is_string($this->extension)
            ? array($this->extension)
            : $this->extension;
        $extensions[] = '';
        foreach (array_unique($extensions) as $extension) {
            if (file_exists($path.$extension)) {
                return $path.$extension;
            }
        }

        throw new \InvalidArgumentException("The included file '$path' does not exists.");
    }

    protected function getTemplateContents($path)
    {
        try {
            return file_get_contents($this->getTemplatePath($path));
        } catch (\InvalidArgumentException $e) {
            if (static::$includeNotFound === false) {
                throw $e;
            }
        }

        return static::$includeNotFound;
    }

    protected function setInput($filename, $input)
    {
        if ($filename === null && file_exists($input)) {
            $filename = $input;
            $input = file_get_contents($input);
        }

        $this->input = preg_replace('`\r\n|\r`', "\n", $input);
        $this->filename = $filename;
    }

    /**
     * get a parser with the same settings.
     *
     * @return Parser
     */
    public function subParser($input)
    {
        return new static($input, $this->filename, $this->options);
    }

    public function context($parser = null)
    {
        if ($parser === null) {
            return array_pop($this->contexts);
        }
        array_push($this->contexts, $parser);
    }

    public function advance()
    {
        return $this->lexer->advance();
    }

    public function skip($n)
    {
        while ($n--) {
            $this->advance();
        }
    }

    public function peek()
    {
        return $this->lookahead(1);
    }

    public function line()
    {
        return $this->lexer->lineno;
    }

    public function lookahead($n = 1)
    {
        return $this->lexer->lookahead($n);
    }

    public function parse()
    {
        $block = new Nodes\Block();
        $block->line = $this->line();

        while ($this->peek()->type !== 'eos') {
            if ($this->peek()->type === 'newline') {
                $this->advance();
                continue;
            }
            $block->push($this->parseExpression());
        }

        if ($parser = $this->extending) {
            $this->context($parser);
            // $parser->blocks = $this->blocks;
            try {
                $ast = $parser->parse();
            } catch (\Exception $e) {
                throw new \Exception($parser->filename.' ('.$block->line.') : '.$e->getMessage(), 1, $e);
            }
            $this->context();

            foreach ($this->mixins as $name => $v) {
                $ast->unshift($this->mixins[$name]);
            }

            return $ast;
        }

        return $block;
    }

    protected function expect($type)
    {
        if ($this->peek()->type === $type) {
            return $this->lexer->advance();
        }

        $lineNumber = $this->line();
        $lines = explode("\n", $this->input);
        $lineString = isset($lines[$lineNumber]) ? $lines[$lineNumber] : '';

        throw new \Exception("\n".sprintf('Expected %s, but got %s in %dth line : %s', $type, $this->peek()->type, $lineNumber, $lineString)."\n");
    }

    protected function accept($type)
    {
        if ($this->peek()->type === $type) {
            return $this->advance();
        }
    }

    protected function parseExpression()
    {
        $_types = array('tag', 'mixin', 'block', 'case', 'when', 'default', 'extends', 'include', 'doctype', 'filter', 'comment', 'text', 'each', 'code', 'call', 'interpolation');

        if (in_array($this->peek()->type, $_types)) {
            $_method = 'parse'.ucfirst($this->peek()->type);

            return $this->$_method();
        }

        switch ($this->peek()->type) {
            case 'yield':
                $this->advance();
                $block = new Nodes\Block();
                $block->yield = true;

                return $block;

            case 'id':
            case 'class':
                $token = $this->advance();
                $this->lexer->defer($this->lexer->token('tag', 'div'));
                $this->lexer->defer($token);

                return $this->parseExpression();

            default:
                throw new \Exception($this->filename.' ('.$this->line().') : Unexpected token "'.$this->peek()->type.'"');
        }
    }

    protected function parseText()
    {
        $token = $this->expect('text');
        if (preg_match('/^(.*?)#\[([^\]\n]+)\]/', $token->value)) {
            $block = new Nodes\Block();
            $this->parseInlineTags($block, $token->value);

            return $block;
        }
        $node = new Nodes\Text($token->value);
        $node->line = $this->line();

        return $node;
    }

    protected function parseBlockExpansion()
    {
        if (':' == $this->peek()->type) {
            $this->advance();

            return new Nodes\Block($this->parseExpression());
        }

        return $this->block();
    }

    protected function parseCase()
    {
        $value = $this->expect('case')->value;
        $node = new Nodes\CaseNode($value);
        $node->line = $this->line();
        $node->block = $this->block();

        return $node;
    }

    protected function parseWhen()
    {
        $value = $this->expect('when')->value;

        return new Nodes\When($value, $this->parseBlockExpansion());
    }

    protected function parseDefault()
    {
        $this->expect('default');

        return new Nodes\When('default', $this->parseBlockExpansion());
    }

    protected function parseCode()
    {
        $token = $this->expect('code');
        $buffer = isset($token->buffer) ? $token->buffer : false;
        $escape = isset($token->escape) ? $token->escape : true;
        $node = new Nodes\Code($token->value, $buffer, $escape);
        $node->line = $this->line();

        $i = 1;
        while ($this->lookahead($i)->type === 'newline') {
            $i++;
        }

        if ($this->lookahead($i)->type === 'indent') {
            $this->skip($i - 1);
            $node->block = $this->block();
        }

        return $node;
    }

    protected function parseComment()
    {
        $token = $this->expect('comment');
        $node = new Nodes\Comment($token->value, $token->buffer);
        $node->line = $this->line();

        return $node;
    }

    protected function parseDoctype()
    {
        $token = $this->expect('doctype');
        $node = new Nodes\Doctype($token->value);
        $node->line = $this->line();

        return $node;
    }

    protected function parseFilter()
    {
        $token = $this->expect('filter');
        $attributes = $this->accept('attributes');

        $this->lexer->pipeless = true;
        $block = $this->parseTextBlock();
        $this->lexer->pipeless = false;

        $node = new Nodes\Filter($token->value, $block, $attributes);
        $node->line = $this->line();

        return $node;
    }

    protected function parseEach()
    {
        $token = $this->expect('each');
        $node = new Nodes\Each($token->code, $token->value, $token->key);
        $node->line = $this->line();
        $node->block = $this->block();
        if ($this->peek()->type === 'code' && $this->peek()->value === 'else') {
            $this->advance();
            $node->alternative = $this->block();
        }

        return $node;
    }

    protected function parseExtends()
    {
        $file = $this->expect('extends')->value;
        $dir = realpath(dirname($this->filename));
        $path = $this->getTemplatePath($dir.DIRECTORY_SEPARATOR.$file);

        $string = $this->getTemplateContents($path);
        $parser = new static($string, $path);
        // need to be a reference, or be seted after the parse loop
        $parser->blocks = &$this->blocks;
        $parser->contexts = $this->contexts;
        $this->extending = $parser;

        return new Nodes\Literal('');
    }

    protected function parseBlock()
    {
        $block = $this->expect('block');
        $mode = $block->mode;
        $name = trim($block->value);

        $block = 'indent' == $this->peek()->type
            ? $this->block()
            : new Nodes\Block(empty($name)
                ? new Nodes\MixinBlock()
                : new Nodes\Literal('')
            );

        if (isset($this->blocks[$name])) {
            $prev = &$this->blocks[$name];

            switch ($prev->mode) {
                case 'append':
                    $block->nodes = array_merge($block->nodes, $prev->nodes);
                    $prev = $block;
                    break;

                case 'prepend':
                    $block->nodes = array_merge($prev->nodes, $block->nodes);
                    $prev = $block;
                    break;

                case 'replace':
                default:
                    break;
            }

            return $this->blocks[$name];
        }

        $block->mode = $mode;
        $this->blocks[$name] = $block;

        return $block;
    }

    protected function parseInclude()
    {
        $token = $this->expect('include');
        $file = trim($token->value);
        $dir = realpath(dirname($this->filename));
        $path = $dir.DIRECTORY_SEPARATOR.$file;

        if (strpos(basename($file), '.') !== false && !$this->hasValidTemplateExtension($path)) {
            if (!file_exists($path)) {
                throw new \Exception($file.' not found at '.$this->filename.' (line '.$token->line.')');
            }

            return new Nodes\Literal(file_get_contents($path));
        }

        $string = $this->getTemplateContents($dir.DIRECTORY_SEPARATOR.$file);

        $parser = new static($string, $path);
        $parser->blocks = $this->blocks;
        $parser->mixins = $this->mixins;

        $this->context($parser);

        try {
            $ast = $parser->parse();
        } catch (\Exception $e) {
            throw new \Exception($path.' ('.$parser->lexer->lineno.') : '.$e->getMessage());
        }
        $this->context();
        $ast->filename = $path;

        if ('indent' === $this->peek()->type && method_exists($ast, 'includeBlock')) {
            $block = $ast->includeBlock();
            if (is_object($block)) {
                $handler = count($block->nodes) === 1
                    ? $block->nodes[0]->block
                    : $block;
                $handler->push($this->block());
            }
        }

        return $ast;
    }

    protected function parseCall()
    {
        $token = $this->expect('call');
        $name = $token->value;

        $arguments = isset($token->arguments)
            ? $token->arguments
            : null;

        $mixin = new Nodes\Mixin($name, $arguments, new Nodes\Block(), true);

        $this->tag($mixin);

        if ($mixin->block->isEmpty()) {
            $mixin->block = null;
        }

        return $mixin;
    }

    protected function parseMixin()
    {
        $token = $this->expect('mixin');
        $name = $token->value;
        $arguments = $token->arguments;

        // definition
        if ('indent' === $this->peek()->type) {
            $mixin = new Nodes\Mixin($name, $arguments, $this->block(), false);
            $this->mixins[$name] = $mixin;

            return $mixin;
        }

        // call
        return new Nodes\Mixin($name, $arguments, null, true);
    }

    protected function parseTextBlock()
    {
        $block = new Nodes\Block();
        $block->line = $this->line();
        $spaces = $this->expect('indent')->value;

        if (!isset($this->_spaces)) {
            $this->_spaces = $spaces;
        }

        $indent = str_repeat(' ', $spaces - $this->_spaces + 1);

        while ($this->peek()->type != 'outdent') {
            switch ($this->peek()->type) {
                case 'newline':
                    $this->lexer->advance();
                    break;

                case 'indent':
                    foreach ($this->parseTextBlock()->nodes as $n) {
                        $block->push($n);
                    }
                    break;

                default:
                    $this->parseInlineTags($block, $indent.$this->advance()->value);
            }
        }

        if (isset($this->_spaces) && $spaces == $this->_spaces) {
            unset($this->_spaces);
        }

        $this->expect('outdent');

        return $block;
    }

    protected function block()
    {
        $block = new Nodes\Block();
        $block->line = $this->line();
        $this->expect('indent');

        while ($this->peek()->type !== 'outdent') {
            if ($this->peek()->type === 'newline') {
                $this->lexer->advance();
            } else {
                $block->push($this->parseExpression());
            }
        }

        $this->expect('outdent');

        return $block;
    }

    protected function parseInterpolation()
    {
        $token = $this->advance();
        $tag = new Nodes\Tag($token->value);
        $tag->buffer = true;

        return $this->tag($tag);
    }

    protected function parseASTFilter()
    {
        $token = $this->expect('tag');
        $attributes = $this->accept('attributes');
        $this->expect(':');
        $block = $this->block();
        $node = new Nodes\Filter($token->value, $block, $attributes);
        $node->line = $this->line();

        return $node;
    }

    protected function parseTag()
    {
        $i = 2;

        if ('attributes' === $this->lookahead($i)->type) {
            $i++;
        }

        if (':' === $this->lookahead($i)->type) {
            $i++;

            if ('indent' === $this->lookahead($i)->type) {
                return $this->parseASTFilter();
            }
        }

        $token = $this->advance();
        $tag = new Nodes\Tag($token->value);

        $tag->selfClosing = isset($token->selfClosing)
            ? $token->selfClosing
            : false;

        return $this->tag($tag);
    }

    public function parseInlineTags($block, $str)
    {
        while (preg_match('/^(.*?)#\[([^\]\n]+)\]/', $str, $matches)) {
            if (!empty($matches[1])) {
                $text = new Nodes\Text($matches[1]);
                $text->line = $this->line();
                $block->push($text);
            }
            $parser = $this->subParser($matches[2]);
            $tag = $parser->parse();
            $tag->line = $this->line();
            $block->push($tag);
            $str = mb_substr($str, mb_strlen($matches[0]));
        }
        $text = new Nodes\Text($str);
        $text->line = $this->line();
        $block->push($text);
    }

    protected function tag($tag)
    {
        $tag->line = $this->line();

        while (true) {
            switch ($type = $this->peek()->type) {
                case 'id':
                    $token = $this->advance();
                    $peek = $this->peek();
                    $escaped = isset($peek->escaped, $peek->escaped[$type]) && $peek->escaped[$type];
                    $value = $escaped || !isset($peek->attributes, $peek->attributes[$type])
                        ? "'".$token->value."'"
                        : $peek->attributes[$type];
                    $tag->setAttribute($token->type, $value, $escaped);
                    unset($peek->attributes[$type]);
                    continue;

                case 'class':
                    $token = $this->advance();
                    $tag->setAttribute($token->type, "'".$token->value."'");
                    continue;

                case 'attributes':
                    $token = $this->advance();
                    $obj = $token->attributes;
                    $escaped = $token->escaped;
                    $nameList = array_keys($obj);

                    if ($token->selfClosing) {
                        $tag->selfClosing = true;
                    }

                    foreach ($nameList as $name) {
                        $value = $obj[$name];
                        $tag->setAttribute($name, $value, $escaped[$name]);
                    }
                    continue;

                case '&attributes':
                    $token = $this->advance();
                    $tag->setAttribute('&attributes', $token->value);
                    continue;

                default:
                    break 2;
            }
        }

        $dot = false;
        $tag->textOnly = false;
        if ('.' == $this->peek()->value) {
            $dot = $tag->textOnly = true;
            $this->advance();
        }

        switch ($this->peek()->type) {
            case 'text':
                $this->parseInlineTags($tag->block, $this->expect('text')->value);
                break;

            case 'code':
                $tag->code = $this->parseCode();
                break;

            case ':':
                $this->advance();
                $tag->block = new Nodes\Block();
                $tag->block->push($this->parseExpression());
                break;
        }

        while ('newline' == $this->peek()->type) {
            $this->advance();
        }

        if (in_array($tag->name, $this->textOnly)) {
            $tag->textOnly = true;
        }

        if ('script' == $tag->name) {
            $type = $tag->getAttribute('type');

            if ($type !== null) {
                $type = preg_replace('/^[\'\"]|[\'\"]$/', '', $type);

                if (!$dot && 'text/javascript' != $type['value']) {
                    $tag->textOnly = false;
                }
            }
        }

        if ('indent' == $this->peek()->type) {
            if ($tag->textOnly) {
                $this->lexer->pipeless = true;
                $tag->block = $this->parseTextBlock();
                $this->lexer->pipeless = false;

                return $tag;
            }

            $block = $this->block();

            if ($tag->block && !$tag->block->isEmpty()) {
                foreach ($block->nodes as $n) {
                    $tag->block->push($n);
                }

                return $tag;
            }

            $tag->block = $block;
        }

        return $tag;
    }
}