<?php
|
|
|
|
// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project
|
|
//
|
|
// All Rights Reserved. See copyright.txt for details and a complete list of authors.
|
|
// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details.
|
|
// $Id$
|
|
|
|
class WikiParser_PluginMatcher implements Iterator, Countable
|
|
{
|
|
private $starts = [];
|
|
private $ends = [];
|
|
private $level = 0;
|
|
|
|
private $ranges = [];
|
|
|
|
private $text;
|
|
|
|
private $scanPosition = -1;
|
|
|
|
private $leftOpen = 0;
|
|
|
|
/**
|
|
* @param $text
|
|
* @return WikiParser_PluginMatcher
|
|
*/
|
|
public static function match($text)
|
|
{
|
|
$matcher = new self();
|
|
$matcher->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";
|
|
}
|
|
}
|