text = $text; $matcher->findMatches(0, strlen($text)); return $matcher; } public function __clone() { $new = $this; $this->starts = array_map( function ($match) use ($new) { $match->changeMatcher($new); return clone $match; }, $this->starts ); $this->ends = array_map( function ($match) use ($new) { $match->changeMatcher($new); return clone $match; }, $this->ends ); } private function getSubMatcher($start, $end) { $sub = new self(); $sub->level = $this->level + 1; $sub->text = $this->text; $sub->findMatches($start, $end); return $sub; } private function appendSubMatcher($matcher) { foreach ($matcher->starts as $match) { $match->changeMatcher($this); $this->recordMatch($match); } } private function isComplete() { return $this->leftOpen == 0; } public function findMatches($start, $end) { global $prefs; static $passes; if ($this->level === 0) { $passes = 0; } if (++$passes > $prefs['wikiplugin_maximum_passes']) { return; } $this->findNoParseRanges($start, $end); $pos = $start; while (false !== $pos = strpos($this->text, '{', $pos)) { // Shortcut {$var} syntax if (substr($this->text, $pos + 1, 1) === '$') { ++$pos; continue; } if ($pos >= $end) { return; } if (! $this->isParsedLocation($pos)) { ++$pos; continue; } $match = new WikiParser_PluginMatcher_Match($this, $pos); ++$pos; if (! $match->findName($end)) { continue; } if (! $match->findArguments($end)) { continue; } if ($match->getEnd() !== false) { // End already reached $this->recordMatch($match); $pos = $match->getEnd(); } else { ++$this->leftOpen; $bodyStart = $match->getBodyStart(); $lookupStart = $bodyStart; while ($match->findEnd($lookupStart, $end)) { $candidate = $match->getEnd(); $sub = $this->getSubMatcher($bodyStart, $candidate - 1); if ($sub->isComplete()) { $this->recordMatch($match); $this->appendSubMatcher($sub); $pos = $match->getEnd(); --$this->leftOpen; if (empty($this->level)) { $passes = 0; } break; } $lookupStart = $candidate; } } } } public function getText() { return $this->text; } private function recordMatch($match) { $this->starts[$match->getStart()] = $match; $this->ends[$match->getEnd()] = $match; } private function findNoParseRanges($from, $to) { while (false !== $open = $this->findText('~np~', $from, $to)) { if (false !== $close = $this->findText('~/np~', $open, $to)) { $from = $close; $this->ranges[] = [$open, $close]; } else { return; } } } public function isParsedLocation($pos) { foreach ($this->ranges as $range) { list($open, $close ) = $range; if ($pos > $open && $pos < $close) { return false; } } return true; } public function count(): int { return count($this->starts); } /** * @return WikiParser_PluginMatcher_Match */ #[\ReturnTypeWillChange] public function current() { return $this->starts[ $this->scanPosition ]; } /** * @return WikiParser_PluginMatcher_Match */ public function next(): void { foreach ($this->starts as $key => $m) { if ($key > $this->scanPosition) { $this->scanPosition = $key; return; } } $this->scanPosition = -1; } #[\ReturnTypeWillChange] public function key() { return $this->scanPosition; } public function valid(): bool { return isset($this->starts[$this->scanPosition]); } public function rewind(): void { reset($this->starts); $this->scanPosition = key($this->starts); } public function getChunkFrom($pos, $size) { return substr($this->text, $pos, $size); } private function getFirstStart($lower) { foreach ($this->starts as $key => $match) { if ($key >= $lower) { return $key; } } return false; } private function getLastEnd() { $ends = array_keys($this->ends); return end($ends); } public function findText($string, $from, $to) { if ($from >= strlen($this->text)) { return false; } $pos = strpos($this->text, $string, $from); if ($pos === false || $pos + strlen($string) > $to) { return false; } return $pos; } public function performReplace($match, $string) { $start = $match->getStart(); $end = $match->getEnd(); $sizeDiff = - ($end - $start - strlen($string)); $this->text = substr_replace($this->text, $string, $start, $end - $start); $this->removeRanges($start, $end); $this->offsetRanges($end, $sizeDiff); $this->findNoParseRanges($start, $start + strlen($string)); $matches = $this->ends; $toRemove = [$match]; $toAdd = []; foreach ($matches as $key => $m) { if ($m->inside($match)) { $toRemove[] = $m; } elseif ($match->inside($m)) { // Boundaries should not be extended for wrapping plugins } elseif ($key > $end) { unset($this->ends[$m->getEnd()]); unset($this->starts[$m->getStart()]); $m->applyOffset($sizeDiff); $toAdd[] = $m; } } foreach ($toRemove as $m) { unset($this->ends[$m->getEnd()]); unset($this->starts[$m->getStart()]); $m->invalidate(); } foreach ($toAdd as $m) { $this->ends[$m->getEnd()] = $m; $this->starts[$m->getStart()] = $m; } $sub = $this->getSubMatcher($start, $start + strlen($string)); if ($sub->isComplete()) { $this->appendSubMatcher($sub); } ksort($this->ends); ksort($this->starts); if ($this->scanPosition == $start) { $this->scanPosition = $start - 1; } } private function removeRanges($start, $end) { $toRemove = []; foreach ($this->ranges as $key => $range) { if ($start >= $range[0] && $start <= $range[1]) { $toRemove[] = $key; } } foreach ($toRemove as $key) { unset($this->ranges[$key]); } } private function offsetRanges($end, $sizeDiff) { foreach ($this->ranges as & $range) { if ($range[0] >= $end) { $range[0] += $sizeDiff; $range[1] += $sizeDiff; } } } } class WikiParser_PluginMatcher_Match { const LONG = 1; const SHORT = 2; const LEGACY = 3; const NAME_MAX_LENGTH = 50; private $matchType = false; private $nameEnd = false; private $bodyStart = false; private $bodyEnd = false; /** @var WikiParser_PluginMatcher|bool */ private $matcher = false; private $start = false; private $end = false; private $initialstart = false; private $arguments = false; public function __construct($matcher, $start) { $this->matcher = $matcher; $this->start = $start; $this->initialstart = $start; } public function findName($limit) { $candidate = $this->matcher->getChunkFrom($this->start + 1, self::NAME_MAX_LENGTH); $name = strtok($candidate, " (}\n\r,"); if (empty($name) || ! ctype_alnum($name)) { $this->invalidate(); return false; } // Upper case uses long syntax if (strtoupper($name) == $name) { $this->matchType = self::LONG; // Parenthesis required when using long syntax if ($candidate[strlen($name)] != '(') { $this->invalidate(); return false; } } else { $this->matchType = self::SHORT; } $nameEnd = $this->start + 1 + strlen($name); if ($nameEnd > $limit) { $this->invalidate(); return false; } $this->name = strtolower($name); $this->nameEnd = $nameEnd; return true; } public function findArguments($limit) { if ($this->nameEnd === false) { return false; } $pos = $this->matcher->findText('}', $this->nameEnd, $limit); if (false === $pos) { $this->invalidate(); return false; } $unescapedFound = $this->countUnescapedQuotes($this->nameEnd, $pos); while (1 == ($unescapedFound % 2)) { $old = $pos; $pos = $this->matcher->findText('}', $pos + 1, $limit); if (false === $pos) { $this->invalidate(); return false; } $unescapedFound += $this->countUnescapedQuotes($old, $pos); } if ($this->matchType == self::LONG && $this->matcher->findText('/', $pos - 1, $limit) === $pos - 1) { $this->matchType = self::LEGACY; --$pos; } $seek = $pos; while (ctype_space($this->matcher->getChunkFrom($seek - 1, '1'))) { $seek--; } if (in_array($this->matchType, [self::LONG, self::LEGACY]) && $this->matcher->findText(')', $seek - 1, $limit) !== $seek - 1) { $this->invalidate(); return false; } // $arguments = trim($this->matcher->getChunkFrom($this->nameEnd, $pos - $this->nameEnd), '() '); $rawarguments = trim($this->matcher->getChunkFrom($this->nameEnd, $pos - $this->nameEnd), '() '); // arguments can be html encoded. So, decode first $arguments = html_entity_decode($rawarguments); $this->arguments = trim($arguments); if ($this->matchType == self::LEGACY) { ++$pos; } $this->bodyStart = $pos + 1; if ($this->matchType == self::SHORT || $this->matchType == self::LEGACY) { $this->end = $this->bodyStart; $this->bodyStart = false; } return true; } public function findEnd($after, $limit) { if ($this->bodyStart === false) { return false; } $endToken = '{' . strtoupper($this->name) . '}'; do { if (isset($bodyEnd)) { $after = $bodyEnd + 1; } if (false === $bodyEnd = $this->matcher->findText($endToken, $after, $limit)) { $this->invalidate(); return false; } } while (! $this->matcher->isParsedLocation($bodyEnd)); $this->bodyEnd = $bodyEnd; $this->end = $bodyEnd + strlen($endToken); return true; } public function inside($match) { return $this->start > $match->start && $this->end < $match->end; } public function replaceWith($string) { $this->matcher->performReplace($this, $string); } public function replaceWithPlugin($name, $params, $content) { $hasBody = ! empty($content) && ! ctype_space($content); if (is_array($params)) { $parts = []; foreach ($params as $key => $value) { if ($value || $value === '0') { $parts[] = "$key=\"" . str_replace('"', "\\\"", $value) . '"'; } } $params = implode(' ', $parts); } // Replace the content if ($hasBody) { $type = strtoupper($name); $replacement = "{{$type}($params)}$content{{$type}}"; } else { $plugin = strtolower($name); $replacement = "{{$plugin} $params}"; } $this->replaceWith($replacement); } public function getName() { return $this->name; } public function getArguments() { return $this->arguments; } public function getBody() { return $this->matcher->getChunkFrom($this->bodyStart, $this->bodyEnd - $this->bodyStart); } public function getStart() { return $this->start; } public function getEnd() { return $this->end; } public function getInitialStart() { return $this->initialstart; } public function getBodyStart() { return $this->bodyStart; } public function invalidate() { $this->matcher = false; $this->start = false; $this->end = false; } public function applyOffset($offset) { $this->start += $offset; $this->end += $offset; if ($this->nameEnd !== false) { $this->nameEnd += $offset; } if ($this->bodyStart !== false) { $this->bodyStart += $offset; } if ($this->bodyEnd !== false) { $this->bodyEnd += $offset; } } private function countUnescapedQuotes($from, $to) { $string = $this->matcher->getChunkFrom($from, $to - $from); $count = 0; $pos = -1; while (false !== $pos = strpos($string, '"', $pos + 1)) { ++$count; if ($pos > 0 && $string[$pos - 1] == "\\") { --$count; } } return $count; } public function changeMatcher($matcher) { $this->matcher = $matcher; } public function __toString() { return $this->matcher->getChunkFrom($this->start, $this->end - $this->start); } public function debug($level = 'X') { echo "\nMatch [$level] {$this->name} ({$this->arguments}) = {$this->getBody()}\n"; echo "{$this->bodyStart}-{$this->bodyEnd} {$this->nameEnd} ({$this->matchType})\n"; } }