thunderer/Shortcode

View on GitHub
src/Processor/Processor.php

Summary

Maintainability
A
1 hr
Test Coverage
<?php
namespace Thunder\Shortcode\Processor;

use Thunder\Shortcode\Event\ReplaceShortcodesEvent;
use Thunder\Shortcode\Event\FilterShortcodesEvent;
use Thunder\Shortcode\EventContainer\EventContainerInterface;
use Thunder\Shortcode\Events;
use Thunder\Shortcode\HandlerContainer\HandlerContainerInterface as Handlers;
use Thunder\Shortcode\Parser\ParserInterface;
use Thunder\Shortcode\Shortcode\ReplacedShortcode;
use Thunder\Shortcode\Shortcode\ParsedShortcodeInterface;
use Thunder\Shortcode\Shortcode\ProcessedShortcode;
use Thunder\Shortcode\Shortcode\ShortcodeInterface;

/**
 * @author Tomasz Kowalczyk <tomasz@kowalczyk.cc>
 */
final class Processor implements ProcessorInterface
{
    /** @var Handlers */
    private $handlers;
    /** @var ParserInterface */
    private $parser;
    /** @var EventContainerInterface|null */
    private $eventContainer;

    /** @var int|null */
    private $recursionDepth; // infinite recursion
    /** @var int|null */
    private $maxIterations = 1; // one iteration
    /** @var bool */
    private $autoProcessContent = true; // automatically process shortcode content

    public function __construct(ParserInterface $parser, Handlers $handlers)
    {
        $this->parser = $parser;
        $this->handlers = $handlers;
    }

    /**
     * Entry point for shortcode processing. Implements iterative algorithm for
     * both limited and unlimited number of iterations.
     *
     * @param string $text Text to process
     *
     * @return string
     */
    public function process($text)
    {
        $iterations = $this->maxIterations === null ? 1 : $this->maxIterations;
        $context = new ProcessorContext();
        $context->processor = $this;

        while ($iterations--) {
            $context->iterationNumber++;
            $newText = $this->processIteration($text, $context, null);
            if ($newText === $text) {
                break;
            }
            $text = $newText;
            $iterations += $this->maxIterations === null ? 1 : 0;
        }

        return $text;
    }

    /**
     * @param string $name
     * @param object $event
     *
     * @return object
     */
    private function dispatchEvent($name, $event)
    {
        if(null === $this->eventContainer) {
            return $event;
        }

        $handlers = $this->eventContainer->getListeners($name);
        foreach($handlers as $handler) {
            $handler($event);
        }

        return $event;
    }

    /**
     * @param string $text
     *
     * @return string
     */
    private function processIteration($text, ProcessorContext $context, ProcessedShortcode $parent = null)
    {
        if (null !== $this->recursionDepth && $context->recursionLevel > $this->recursionDepth) {
            return $text;
        }

        $context->parent = $parent;
        $context->text = $text;
        $filterEvent = new FilterShortcodesEvent($this->parser->parse($text), $parent);
        $this->dispatchEvent(Events::FILTER_SHORTCODES, $filterEvent);
        $shortcodes = $filterEvent->getShortcodes();
        $replaces = array();
        $baseOffset = $parent && $shortcodes
            ? (int)mb_strpos($parent->getShortcodeText(), $shortcodes[0]->getText(), 0, 'utf-8') - $shortcodes[0]->getOffset() + $parent->getOffset()
            : 0;
        foreach ($shortcodes as $shortcode) {
            $name = $shortcode->getName();
            $hasNamePosition = array_key_exists($name, $context->namePosition);

            $context->baseOffset = $baseOffset + $shortcode->getOffset();
            $context->position++;
            $context->namePosition[$name] = $hasNamePosition ? $context->namePosition[$name] + 1 : 1;
            $context->shortcodeText = $shortcode->getText();
            $context->offset = $shortcode->getOffset();
            $context->shortcode = $shortcode;
            $context->textContent = (string)$shortcode->getContent();

            $handler = $this->handlers->get($name);
            $replace = $this->processHandler($shortcode, $context, $handler);

            $replaces[] = new ReplacedShortcode($shortcode, $replace);
        }

        $applyEvent = new ReplaceShortcodesEvent($text, $replaces, $parent);
        $this->dispatchEvent(Events::REPLACE_SHORTCODES, $applyEvent);

        return $applyEvent->hasResult() ? (string)$applyEvent->getResult() : $this->applyReplaces($text, $replaces);
    }

    /**
     * @param string $text
     * @param ReplacedShortcode[] $replaces
     *
     * @return string
     */
    private function applyReplaces($text, array $replaces)
    {
        foreach(array_reverse($replaces) as $s) {
            $offset = $s->getOffset();
            $length = mb_strlen($s->getText(), 'utf-8');
            $textLength = mb_strlen($text, 'utf-8');

            $text = mb_substr($text, 0, $offset, 'utf-8').$s->getReplacement().mb_substr($text, $offset + $length, $textLength, 'utf-8');
        }

        return $text;
    }

    /**
     * @psalm-param (callable(ShortcodeInterface):string)|null $handler
     * @return string
     */
    private function processHandler(ParsedShortcodeInterface $parsed, ProcessorContext $context, $handler)
    {
        $processed = ProcessedShortcode::createFromContext(clone $context);
        $content = $this->processRecursion($processed, $context);
        $processed = $processed->withContent($content);

        if($handler) {
            return $handler($processed);
        }

        $state = $parsed->getText();
        /** @psalm-suppress RedundantCast */
        $length = (int)mb_strlen($processed->getTextContent(), 'utf-8');
        $offset = (int)mb_strrpos($state, $processed->getTextContent(), 0, 'utf-8');

        return mb_substr($state, 0, $offset, 'utf-8').(string)$processed->getContent().mb_substr($state, $offset + $length, mb_strlen($state, 'utf-8'), 'utf-8');
    }

    /** @return string|null */
    private function processRecursion(ProcessedShortcode $shortcode, ProcessorContext $context)
    {
        $content = $shortcode->getContent();
        if ($this->autoProcessContent && null !== $content) {
            $context->recursionLevel++;
            // this is safe from using max iterations value because it's manipulated in process() method
            $content = $this->processIteration($content, clone $context, $shortcode);
            $context->recursionLevel--;

            return $content;
        }

        return $content;
    }

    /**
     * Container for event handlers used in this processor.
     *
     * @param EventContainerInterface $eventContainer
     *
     * @return self
     */
    public function withEventContainer(EventContainerInterface $eventContainer)
    {
        $self = clone $this;
        $self->eventContainer = $eventContainer;

        return $self;
    }

    /**
     * Recursion depth level, null means infinite, any integer greater than or
     * equal to zero sets value (number of recursion levels). Zero disables
     * recursion. Defaults to null.
     *
     * @param int|null $depth
     *
     * @return self
     */
    public function withRecursionDepth($depth)
    {
        /** @psalm-suppress DocblockTypeContradiction */
        if (null !== $depth && !(is_int($depth) && $depth >= 0)) {
            $msg = 'Recursion depth must be null (infinite) or integer >= 0!';
            throw new \InvalidArgumentException($msg);
        }

        $self = clone $this;
        $self->recursionDepth = $depth;

        return $self;
    }

    /**
     * Maximum number of iterations, null means infinite, any integer greater
     * than zero sets value. Zero is invalid because there must be at least one
     * iteration. Defaults to 1. Loop breaks if result of two consequent
     * iterations shows no change in processed text.
     *
     * @param int|null $iterations
     *
     * @return self
     */
    public function withMaxIterations($iterations)
    {
        /** @psalm-suppress DocblockTypeContradiction */
        if (null !== $iterations && !(is_int($iterations) && $iterations > 0)) {
            $msg = 'Maximum number of iterations must be null (infinite) or integer > 0!';
            throw new \InvalidArgumentException($msg);
        }

        $self = clone $this;
        $self->maxIterations = $iterations;

        return $self;
    }

    /**
     * Whether shortcode content will be automatically processed and handler
     * already receives shortcode with processed content. If false, every
     * shortcode handler needs to process content on its own. Default true.
     *
     * @param bool $flag True if enabled (default), false otherwise
     *
     * @return self
     */
    public function withAutoProcessContent($flag)
    {
        /** @psalm-suppress DocblockTypeContradiction */
        if (!is_bool($flag)) {
            $msg = 'Auto processing flag must be a boolean value!';
            throw new \InvalidArgumentException($msg);
        }

        $self = clone $this;
        $self->autoProcessContent = $flag;

        return $self;
    }
}