Здравствуйте. Хотел бы поделится своим опытом с сообществом Habrahabr. Речь пойдет о разработке не очень сложного парсера BBCode, который преобразует его в HTML. Парсер, который мы будем писать — это типичный конечный автомат.
Рассмотрим, чем отличаются обработчики BBCode, основанные на регулярных выражениях, от обработчиков, основанных на конечном автомате, а также их плюсы и минусы.
Парсер на регулярках:
+ Прост в написании;
— Может некорректно обрабатывать BBCode;
— Медленно работает (скорость напрямую зависит от объема текста, а также набора поддерживаемых тегов).
Парсер на конечном автомате:
— Трудный в написании;
+ Обработка BBCode идет строго по матрице состояний;
+ Быстро работает (обрабатывает текст в один проход).
Итак, приступим к написанию
Для начала нам нужно рассмотреть, из каких этапов будет состоять наш парсер BBCode, что будет происходить в этих этапах, а также их результаты выполнения.
1. Обработка BBCode:
Заключается в преобразовании BBCode вида:
[b]Hello World![/b] @l;bbcode/@r; [img="hello_world.jpg"/]
В массив:
Array(
[0] => Array([type] => open, [text] => [b], [attrib] => Array(), [tag] => b)
[1] => Array([type] => text, [text] => Hello World!)
[2] => Array([type] => close, [text] => [/b], [attrib] => Array(), [tag] => b)
[3] => Array([type] => text, [text] => @l;bbcode/@r;)
[4] => Array([type] => open/close, [text] => [img="hello_world.jpg"/], [attrib] => Array([img] => hello_world.jpg), [tag] => img)
)
Скорее всего это самый сложный процесс, в котором как раз и будет участвовать наш конечный автомат. Сам наш процесс разделен на 2 функции: getToken и parse.
getToken — эта функция которая разбивает текст на токены, рассмотрим какие типы токенов у нас будут:
1 — [, 2 — ], 3 — ", 4 — ', 5 — =, 6 — /, 7 — пробел, 8 — r, n, , x0B, 9 — имя тега, 0 — Все остальное.
Ниже приведен код функции:
private function getToken() {
$token = '';
$token_type = false;
$char_type = false;
while (true) {
$token_type = $char_type;
if (!isset($this->source[$this->cursor])) {
if ($char_type === false) {
return false;
} else {
break;
}
}
$char = $this->source[$this->cursor];
switch ($char) {
case '[':
$char_type = 1;
break;
case ']':
$char_type = 2;
break;
case '"':
$char_type = 3;
break;
case "'":
$char_type = 4;
break;
case '=':
$char_type = 5;
break;
case '/':
$char_type = 6;
break;
case ' ':
$char_type = 7;
break;
case "n":
$char_type = 8;
break;
case "r":
$char_type = 8;
break;
case "":
$char_type = 8;
break;
case "x0B":
$char_type = 8;
break;
default:
$char_type = 0;
break;
}
if ($token_type === false) {
$token = $char;
} else if ($token_type > 0) {
break;
} else if ($token_type == $char_type) {
$token .= $char;
} else {
break;
}
$this->cursor++;
}
if ($token_type == 0 && isset($this->tags[$token])) {
$token_type = 9;
}
return array($token_type, $token);
}
parse — это собственно наш конечный автомат, вся его логика заключена в массиве состояний: $finite_automaton. Начальное состояние автомата $mode = 0, при обработке токена состояние меняется на $mode = $finite_automaton[$mode][$token] и выполняется соответствующее номеру состояния действие.
Ниже приведен код конечного автомата:
private function parse() {
$this->cursor = 0;
$this->syntax = array();
$finite_automaton = array(
0 => array( 0, 1, 0, 0, 0, 0, 0, 0, 0, 0),
1 => array( 7, 20, 7, 7, 7, 7, 3, 7, 7, 2),
2 => array( 7, 20, 5, 7, 7, 19, 6, 8, 7, 7),
3 => array( 7, 20, 7, 7, 7, 7, 7, 7, 7, 4),
4 => array( 7, 20, 5, 7, 7, 7, 7, 7, 7, 7),
5 => array( 0, 1, 0, 0, 0, 0, 0, 0, 0, 0),
6 => array( 7, 20, 5, 7, 7, 7, 7, 7, 7, 7),
7 => array( 0, 1, 0, 0, 0, 0, 0, 0, 0, 0),
8 => array( 9, 20, 7, 7, 7, 19, 6, 7, 7, 9),
9 => array( 7, 20, 7, 7, 7, 10, 6, 17, 7, 7),
10 => array(11, 20, 7, 12, 15, 7, 7, 18, 7, 11),
11 => array( 7, 20, 5, 7, 7, 7, 6, 8, 7, 7),
12 => array(13, 20, 7, 14, 13, 13, 13, 13, 13, 13),
13 => array(13, 20, 7, 14, 13, 13, 13, 13, 13, 13),
14 => array( 7, 20, 5, 7, 7, 7, 6, 8, 7, 7),
15 => array(16, 20, 7, 16, 14, 16, 16, 16, 16, 16),
16 => array(16, 20, 7, 16, 14, 16, 16, 16, 16, 16),
17 => array( 7, 20, 7, 7, 7, 10, 6, 7, 7, 7),
18 => array(11, 20, 7, 12, 15, 7, 7, 7, 7, 11),
19 => array(11, 20, 7, 12, 15, 7, 7, 18, 7, 11),
20 => array( 7, 20, 7, 7, 7, 7, 3, 7, 7, 2)
);
$mode = 0;
$pointer = -1;
$last_tag = null;
$last_attrib = null;
while ($token = $this->getToken()) {
$mode = $finite_automaton[$mode][$token[0]];
switch ($mode) {
case 0:
if (isset($this->syntax[$pointer]) && $this->syntax[$pointer]['type'] == 'text') {
$this->syntax[$pointer]['text'] .= $token[1];
} else {
$this->syntax[++$pointer] = array('type' => 'text', 'text' => $token[1]);
}
break;
case 1:
$last_tag = array('type' => 'open', 'text' => $token[1], 'attrib' => array());
break;
case 2:
$last_tag['tag'] = $token[1];
$last_tag['text'] .= $token[1];
$last_attrib = $token[1];
break;
case 3:
$last_tag['type'] = 'close';
$last_tag['text'] .= $token[1];
break;
case 4:
$last_tag['tag'] = $token[1];
$last_tag['text'] .= $token[1];
break;
case 5:
$last_tag['text'] .= $token[1];
$pointer++;
$this->syntax[$pointer] = $last_tag;
$last_tag = null;
break;
case 6:
$last_tag['type'] = 'open/close';
$last_tag['text'] .= $token[1];
break;
case 7:
$last_tag['text'] .= $token[1];
if (isset($this->syntax[$pointer]) && $this->syntax[$pointer]['type'] == 'text') {
$this->syntax[$pointer]['text'] .= $last_tag['text'];
} else {
$this->syntax[++$pointer] = array('type' => 'text', 'text' => $last_tag['text']);
}
$last_tag = null;
break;
case 8:
$last_tag['text'] .= $token[1];
break;
case 9:
$last_tag['text'] .= $token[1];
$last_tag['attrib'][$token[1]] = '';
$last_attrib = $token[1];
break;
case 10:
$last_tag['text'] .= $token[1];
break;
case 11:
$last_tag['text'] .= $token[1];
$last_tag['attrib'][$last_attrib] = $token[1];
break;
case 12:
$last_tag['text'] .= $token[1];
break;
case 13:
$last_tag['text'] .= $token[1];
$last_tag['attrib'][$last_attrib] .= $token[1];
break;
case 14:
$last_tag['text'] .= $token[1];
break;
case 15:
$last_tag['text'] .= $token[1];
break;
case 16:
$last_tag['text'] .= $token[1];
$last_tag['attrib'][$last_attrib] .= $token[1];
break;
case 17:
$last_tag['text'] .= $token[1];
break;
case 18:
$last_tag['text'] .= $token[1];
break;
case 19:
$last_tag['text'] .= $token[1];
$last_attrib = $last_tag['tag'];
break;
case 20:
if ($this->syntax[$pointer]['type'] == 'text') {
$this->syntax[$pointer]['text'] .= $last_tag['text'];
} else {
$this->syntax[++$pointer] = array('type' => 'text', 'text' => $last_tag['text']);
}
$last_tag = array('type' => 'open', 'text' => $token[1], 'attrib' => array());
break;
}
}
if (isset($last_tag)) {
if (isset($this->syntax[$pointer]) && $this->syntax[$pointer]['type'] == 'text') {
$this->syntax[$pointer]['text'] .= $last_tag['text'];
} else {
$pointer++;
$this->syntax[$pointer] = array('type' => 'text', 'text' => $last_tag['text']);
}
}
}
На этом наш этап заканчивается.
2. Нормализация
На этом этапе мы преобразуем наш массив с учетом вложенности элементов. Не зря мы храним для каждого тега параметр text — в случае, если тег имеет некорректное расположение, он преобразуется в текст, который, собственно, и хранится у нас в параметре text.
Ниже представлен код:
private function normalize() {
$stack = array();
foreach ($this->syntax as $key => $node) {
switch ($node['type']) {
case 'open':
if (count($stack) == 0) {
$allowed = $this->root_tags;
} else {
$allowed = $this->tags[$stack[count($stack)-1]]['children'];
}
if (array_search($node['tag'], $allowed) !== false) {
if ($this->tags[$node['tag']]['closed']) {
$this->syntax[$key]['type'] = 'open/close';
} else {
array_push($stack, $node['tag']);
}
} else {
$this->syntax[$key] = array('type' => 'text', 'text' => $node['text']);
}
break;
case 'close':
if (count($stack) > 0 && $node['tag'] == $stack[count($stack)-1]){
array_pop($stack);
} else {
$this->syntax[$key] = array('type' => 'text', 'text' => $node['text']);
}
break;
case 'open/close':
if (count($stack) <= 0) {
$allowed = $this->root_tags;
} else {
$allowed = $this->tags[$stack[count($stack)-1]]['children'];
}
if (array_search($node['tag'], $allowed) === false) {
$this->syntax[$key] = array('type' => 'text', 'text' => $node['text']);
}
break;
}
}
}
3. Преобразование в DOM
Самый последний и интересный этап — это преобразование массива в дерево объектов. Тут у нас будет 2 основных класса: класс Tag и класс Text. Tag — это родительский класс для всех тегов, Text — как вы уже догадались, это класс, хранящий Text. Оба эти класса наследуют класс Node, который просит реализовать функцию getHTML.
Ниже приведен код этих 3 классов:
abstract class Node {
abstract public function getHTML();
protected function specialchars($string) {
$chars = array(
'[' => '@l;',
']' => '@r;',
'"' => '@q;',
"'" => '@a;',
'@' => '@at;'
);
return strtr($string, $chars);
}
protected function unspecialchars($string) {
$chars = array(
'@l;' => '[',
'@r;' => ']',
'@q;' => '"',
'@a;' => "'",
'@at;' => '@'
);
return strtr($string, $chars);
}
}
class Tag extends Node {
public $parent = null;
public $children = array();
public $attrib = array();
public function __construct($parent, $attrib) {
$this->parent = $parent;
$this->attrib = (array) $attrib;
}
public function getHTML() {
$html = '';
foreach ($this->children as $child) {
$html .= $child->getHTML();
}
return $html;
}
public function getAttrib($name) {
return $this->unspecialchars($this->attrib[$name]);
}
public function hasAttrib($name) {
return isset($this->attrib[$name]);
}
}
class Text extends Node {
public $parent = null;
public $text = '';
public function __construct($parent, $text) {
$this->parent = $parent;
$this->text = (string) $text;
}
public function getHTML() {
return htmlspecialchars($this->unspecialchars($this->text));
}
}
Кстати, сам класс парсера BBCode наследует класс Tag, так как BBCode у нас — это корневой элемент.
Так, с классами для DOM мы разобрались, теперь давайте же создадим этот DOM. Ниже приведен код создания DOM:
private function createDOM() {
$current = $this;
foreach ($this->syntax as $node) {
switch ($node['type']) {
case 'text':
$child = new Text($current, $node['text']);
array_push($current->children, $child);
break;
case 'open':
$tag_class = $this->tags[$node['tag']]['class'];
$class = (class_exists($tag_class, false)) ? $tag_class : 'Tag';
$child = new $class($current, $node['attrib']);
array_push($current->children, $child);
$current = $child;
break;
case 'close':
$current = $current->parent;
break;
case 'open/close':
$tag_class = $this->tags[$node['tag']]['class'];
$class = (class_exists($tag_class, false)) ? $tag_class : 'Tag';
$child = new $class($current, $node['attrib']);
array_push($current->children, $child);
break;
}
}
}
Вот и все, на этом обработка BBCode полностью завершена.
Теги и расширяемость
Как уже писалось выше, все теги наследуют у нас класс Tag. Вот пример нашего тега:
class Tag_P extends Tag {
public function getHTML() {
return '<p class="bbcode">'.parent::getHTML().'</p>';
}
}
Чуть не забыл про один существенный минус в такой реализации: так как для каждого тега либо текста требуется новый класс — это может существенно нагружать память.
Итоги
У нас получился отличный парсер BBCode, написанный на php. Сам парсер я писал год назад, и вот только сейчас решил рассказать про проделанную работу. Не судите слишком сложно, это мой первый пост. В конце я предоставлю полный исходник парсера BBCode.
Исходный код
Здесь выкладывать не буду, так как весь исходник занимает около 500 строчек кода.
Ссылка на исходный код: pastebin.com/5Xpf3jZ6
Автор: wilcot