Разбор кода и построение синтаксических деревьев с PLY. Основы

от автора

Что такое PLY?

PLY — это аббревиатура из первых букв выражения: Python Lex-Yacc.
Фактически, это порт утилит lex и yacc на python в красивой обертке.
Работать с ply очень просто и порог входа для начала использования практически нулевой.
Написан он на чистом питоне и представляет из себя LALR(1) парсер, но кому это интересно?
Я по натуре практик (как и большинсво из вас) поэтому пошли в бой!

Что будем делать?

На сайте есть пример написания очередного калькулятора, поэтому повторяться не будем. А сделаем что-то навроде парсера очень очень узкого подмножества PHP 🙂
Наша задача в конце статьи построить синтаксическое дерево для такого примера:

<?php $val = 5; $result = substr( "foobar", 2*(7-$val) ); echo "это наш результат: $result"; 

Пример очень маленький и взят с потолка. Но чтобы построить дерево кода нужно много и походу мы задействуем такой механизм PLY как state.

Lex

Lex — это штука, которая разбивает текст на базовые элементы языка. Ну или группирует текст в базовые элементы. Как-то так.

Что мы здесь видим, кроме бесполезного кода? Видим токены (базовые элементы):
PHP_START — ‘<?php’
PHP_VAR — ‘$result’
PHP_EQUAL — ‘=’
PHP_NAME — ‘substr’
PHP_OPEN — ‘(‘
PHP_STRING — «foobar», ‘это наш результат: $result’
PHP_NUM — ‘2’, ‘7’
PHP_CLOSE — ‘)’
PHP_ECHO — «echo»
PHP_COLON — ‘;’
PHP_COMA — ‘,’
PHP_PLUSMINUS — ‘-‘
PHP_DIVMUL — ‘*’

Для разбора текста на токены в PLY имеется ply.lex. И работает он с регулярными выражениями.
А еще он очень придирчивый к тому что мы пишем в коде. Он требует обязательного присутствия массива с именем tokens.
Для каждого элемента этого массива мы обязаны иметь в коде регулярку или функцию с именем t_ЭЛЕМЕНТ.
Почему он такой придирчивый? Потому что он не работает напрямую во время выполнения программы, а выполняется лишь единожды при первом запуске программы и создает файл lextab.py, в котором описаны таблицы переходов и регулярки. При следующем запуске программы он проверяет наличие этого файла и в случае его присутствия уже не пытается строить таблицы заново, а использует сгенерированные.
Назад к коду.
Если бы синтаксис PHP ограничивался тринадцатью выше перечисленными токенами, то мы бы написали следующее:

# coding=utf8 import ply.lex as lex  # без этой штуки ничего не съинтерпретируется, потому что этот массив шарится между лексером и парсером и кроме того используется внутренне библиотекой tokens = (     'PHPSTART', 'PHPVAR', 'PHPEQUAL', 'PHPFUNC',     'PHPSTRING', 'PHPECHO', 'PHPCOLON', 'PHPCOMA',     'PHPOPEN', 'PHPCLOSE', 'PHPNUM', 'PLUSMINUS', 'DIVMUL' )  # определим регулярку для абстрактного идетификатора ident = r'[a-z]\w*'  # для каждого токена из массива мы должны написать его определение вида t_ИМЯТОКЕНА = регулярка t_PHPSTART = r'\<\?php' t_PHPVAR = r'\$'+ident # очень удобно, правда? t_PHPEQUAL = r'\=' t_PHPFUNC = ident t_PHPSTRING = r'"(\\.|[^"])*"' t_PHPECHO = r'echo' t_PHPCOLON = r';' t_PHPCOMA = r',' t_PHPOPEN = r'\(' t_PHPCLOSE = r'\)' t_PHPNUM = r'\d+' t_PLUSMINUS = r'\+|\-' t_DIVMUL = r'/|\*'  # здесь мы игнорируем незначащие символы. Нам ведь все равно, написано $var=$value или $var   =  $value t_ignore = ' \r\n\t\f'  # а здесь мы обрабатываем ошибки. Кстати заметьте формат названия функции def t_error(t):     print "Illegal character '%s'" % t.value[0]     t.lexer.skip(1)  lexer = lex.lex(reflags=re.UNICODE | re.DOTALL)  data = ''' <?php $result = substr("foobar", "bar"); echo $result; '''  lexer.input(data)  while True:     tok = lexer.token() # читаем следующий токен     if not tok: break      # закончились печеньки     print tok 

В комментарих к коду я примерно описал что у нас там творится. Замечу лишь, что вместо регулярок, определяющих токен, можно писать функции в которых будут эти же регулярки, но кроме этого можно будет туда вписать что-то еще свое. Например:

def t_PHPVAR(t):     r'\$[a-zA-Z]\w*'     print 'Урра, мы распарсили переменную ' + t.value # value - это то что нашлось регуляркой     return t 

Кстати вывод примера, что написан выше будет таким:

LexToken(PHPSTART,'<?php',1,1) LexToken(PHPVAR,'$val',1,7) LexToken(PHPEQUAL,'=',1,12) LexToken(PHPNUM,'5',1,14) LexToken(PHPCOLON,';',1,15) LexToken(PHPVAR,'$result',1,17) LexToken(PHPEQUAL,'=',1,25) LexToken(PHPFUNC,'substr',1,27) LexToken(PHPOPEN,'(',1,33) LexToken(PHPSTRING,'"foobar"',1,35) LexToken(PHPCOMA,',',1,43) LexToken(PHPNUM,'2',1,45) LexToken(DIVMUL,'*',1,46) LexToken(PHPOPEN,'(',1,47) LexToken(PHPNUM,'7',1,48) LexToken(PLUSMINUS,'-',1,49) LexToken(PHPVAR,'$val',1,50) LexToken(PHPCLOSE,')',1,54) LexToken(PHPCLOSE,')',1,56) LexToken(PHPCOLON,';',1,57) LexToken(PHPFUNC,'echo',1,59) LexToken(PHPSTRING,'"\xd1\x8d\xd1\x82\xd0\xbe \xd0\xbd\xd0\xb0\xd1\x88 \xd1\x80\xd0\xb5\xd0\xb7\xd1\x83\xd0\xbb\xd1\x8c\xd1\x82\xd0\xb0\xd1\x82: $result"',1,64) LexToken(PHPCOLON,';',1,107) 

Числа в конце — это номер строки и номер символа. Как видите, номер строки не изменяется. Его нужно менять самим. Как? При проходе токена с переходом на новую строку. Для этого уберем символ новой строки из игнорируемых токенов и добавим новую функцию t_newline:

t_ignore = ' \r\t\f'  def t_newline(t):     r'\n+'     t.lexer.lineno += len(t.value) 

Вот теперь у нас все работает:

 LexToken(PHPSTART,'<?php',2,1) LexToken(PHPVAR,'$val',3,7) LexToken(PHPEQUAL,'=',3,12) LexToken(PHPNUM,'5',3,14) LexToken(PHPCOLON,';',3,15) LexToken(PHPVAR,'$result',4,17) LexToken(PHPEQUAL,'=',4,25) LexToken(PHPFUNC,'substr',4,27) LexToken(PHPOPEN,'(',4,33) LexToken(PHPSTRING,'"foobar"',4,35) LexToken(PHPCOMA,',',4,43) LexToken(PHPNUM,'2',4,45) LexToken(DIVMUL,'*',4,46) LexToken(PHPOPEN,'(',4,47) LexToken(PHPNUM,'7',4,48) LexToken(PLUSMINUS,'-',4,49) LexToken(PHPVAR,'$val',4,50) LexToken(PHPCLOSE,')',4,54) LexToken(PHPCLOSE,')',4,56) LexToken(PHPCOLON,';',4,57) LexToken(PHPFUNC,'echo',5,59) LexToken(PHPSTRING,'"\xd1\x8d\xd1\x82\xd0\xbe \xd0\xbd\xd0\xb0\xd1\x88 \xd1\x80\xd0\xb5\xd0\xb7\xd1\x83\xd0\xbb\xd1\x8c\xd1\x82\xd0\xb0\xd1\x82: $result"',5,64) LexToken(PHPCOLON,';',5,107) 

Работает, но не полностью. Наша строка «это наш результат: $result» возвращается лексером как есть. А как парсить её будем? Создавать отдельный лексер? Неа. В PLY (да и в сишном lex) есть поддержка такой штуки как state. State — это переключалка на другой тип токенов. Вот это мы и будем использовать.

State

Итак, для того чтобы создать новый state нам нужно его предварительно объявить:

states = (    ('string','exclusive'), ) 

‘string’ — это название нового состояния, а exclusive — это тип. Вообще, может быть два типа состояний: exclusive и inclusive. Exclusive — общих токенов не видно (общие, это те которые мы писали до этого, они по умолчанию находятся в состоянии INITIAL). Inclusive — видны общие токены и в текущем состоянии мы просто добавляем к ним дополнительные.
В случае нашей строки нам не нужны другие токены, так как внутри у нас там все по другому. Впрочем парочку мы позаимствуем. Нам нужен токен PHPVAR, потому что переменная у нас выглядит одинаково и снаружи и внутри строки, и нужен будет еще один токен, который вы увидите походу ниже.
Чтобы сделать токен общим мы должны присвоить ему префикс ANY_, ну а чтобы сделать токен видимым только для какого-то определенного состояния — <состояние>_. Ниже приведены измененные токены.

# для каждого токена из массива мы должны написать его определение вида t_ИМЯТОКЕНА = регулярка t_PHPSTART = r'\<\?php' t_ANY_PHPVAR = r'\$'+ident # очень удобно, правда? t_PHPEQUAL = r'\=' t_PHPFUNC = ident t_PHPECHO = r'echo' t_PHPCOLON = r';' t_PHPCOMA = r',' t_PHPOPEN = r'\(' t_PHPCLOSE = r'\)' t_PHPNUM = r'\d+' t_PLUSMINUS = r'\+|\-' t_DIVMUL = r'/|\*'  # изменили токен PHPSTRING и сделали из него функцию, добавили его во все состояния def t_ANY_PHPSTRING(t): # нужен в обоих состояниях, потому что двойные кавычки матчатся и там и там.     r'"'     if t.lexer.current_state() == 'string':         t.lexer.begin('INITIAL') # переходим в начальное состояние     else:         t.lexer.begin('string') # парсим строку     return t  t_string_STR = r'(\\.|[^$"])+' # парсим пока не дойдем до переменной или до кавычки, попутно игнорируем экранки  # говорим что ничего не будем игнорировать t_string_ignore = '' # это кстати обязательная переменная, без неё нельзя создать новый state  # ну и куда же мы без обработки ошибок def t_string_error(t):     print "Illegal character '%s'" % t.value[0]     t.lexer.skip(1) 

Кстати, заметили это: LexToken(PHPFUNC,’echo’,5,59)?
PLY перед построением таблицы сортирует регулярные выражения по возрастанию и в процессе парсинга побеждает длиннейшее. Вот поэтому echo и не парсится как PHPECHO. Как это обойти? Легко. Просто изменим тип возвращаемого токена в функции.
Вот так:

@TOKEN(ident) def t_PHPFUNC(t):     if t.value.lower() == 'echo':         t.type = 'PHPECHO'     return t 

Теперь echo возвращается как нужно. Кстати о декораторе TOKEN: вместо того, чтобы писать регулярку в начале тела функции, можно просто поместить её в переменную и применить к функции как декоратор. Что мы и сделали.

Вот. Теперь вроде все. Да не все.
Неплохо бы игнорировать комментарии.
Что же, добавим еще одну маленькую функцию в лексер:

def t_comment(t):     r'(/\*(.|\n)*?\*/)|(//.*)'     pass 
Полный исходный код лексера (lexer.py)

# coding=utf8 import ply.lex as lex from ply.lex import TOKEN import re  states = (    ('string','exclusive'), )  # без этой штуки ничего не съинтерпретируется, потому что этот массив шарится между лексером и парсером и кроме того используется внутренне библиотекой tokens = (     'PHPSTART', 'PHPVAR', 'PHPEQUAL', 'PHPFUNC',     'PHPSTRING', 'PHPECHO', 'PHPCOLON', 'PHPCOMA',     'PHPOPEN', 'PHPCLOSE', 'PHPNUM', 'PLUSMINUS', 'DIVMUL',     'STR' )  # определим регулярку для абстрактного идетификатора ident = r'[a-z]\w*'  # для каждого токена из массива мы должны написать его определение вида t_ИМЯТОКЕНА = регулярка t_PHPSTART = r'\<\?php' t_ANY_PHPVAR = r'\$'+ident # очень удобно, правда? t_PHPEQUAL = r'\=' t_PHPCOLON = r';' t_PHPCOMA = r',' t_PHPOPEN = r'\(' t_PHPCLOSE = r'\)' t_PHPNUM = r'\d+' t_PLUSMINUS = r'\+|\-' t_DIVMUL = r'/|\*'  @TOKEN(ident) def t_PHPFUNC(t):     if t.value.lower() == 'echo':         t.type = 'PHPECHO'     return t  # игнорируем комментарии def t_comment(t):     r'(/\*(.|\n)*?\*/)|(//.*)'     pass  # изменили токен PHPSTRING и сделали из него функцию, добавили его во все состояния def t_ANY_PHPSTRING(t): # нужен в обоих состояниях, потому что двойные кавычки матчатся и там и там.     r'"'     if t.lexer.current_state() == 'string':         t.lexer.begin('INITIAL') # переходим в начальное состояние     else:         t.lexer.begin('string') # парсим строку     return t  t_string_STR = r'(\\.|[^$"])+' # парсим пока не дойдем до переменной или до кавычки, попутно игнорируем экранки  # говорим что ничего не будем игнорировать t_string_ignore = '' # это кстати обязательная переменная, без неё нельзя создать новый state  # ну и куда же мы без обработки ошибок def t_string_error(t):     print "Illegal character '%s'" % t.value[0]     t.lexer.skip(1)  # здесь мы игнорируем незначащие символы. Нам ведь все равно, написано $var=$value или $var   =  $value t_ignore = ' \r\t\f'  def t_newline(t):     r'\n+'     t.lexer.lineno += len(t.value)  # а здесь мы обрабатываем ошибки. Кстати заметьте формат названия функции def t_error(t):     print "Illegal character '%s'" % t.value[0]     t.lexer.skip(1)  lexer = lex.lex(reflags=re.UNICODE | re.DOTALL | re.IGNORECASE)  if __name__=="__main__":     data = ''' <?php $val = 5; $result = substr( "foobar", 2*(7-$val) ); echo "это наш результат: $result";     '''      lexer.input(data)      while True:         tok = lexer.token() # читаем следующий токен         if not tok: break      # закончились печеньки         print tok 

Вот теперь переходим к парсеру (parser.py).

Yacc

Yacc — это штука (парсер), в которую мы передаем токены и описываем правила их соединения (грамматику). Походу работы этой программы мы можем создать абстрактное дерево или же сразу выполнять программу (как это сделано в примере с калькулятором на сайте).
В PLY для описания грамматики существует класс ply.yacc.
Описывать граматику в ply очень просто и это доставляет даже некоторое наслаждение. Для описания у нас снова существуют специальные функции p_имяфункции, где в doc-строках мы и описываем эту самую грамматику.
Давайте попробуем написать очень простую грамматику для нашего абстрактного примера с php:

 php -> [PHPSTART phpbody]? phpbody -> [phpline phpcolons]* phpcolons -> [ PHPCOLON ]+ phpline -> assign | func | [PHPECHO args] assign -> PHPVAR PHPEQUAL expr expr -> [fact | expr PLUSMINUS fact] fact -> [term | fact DIVMUL term] term -> [arg | PHPOPEN expr PHPCLOSE] func -> PHPFUNC PHPOPEN args PHPCLOSE args -> [ expr [PHPCOMA expr]* ]? arg -> string | phpvar | PHPNUM | func string -> PHPSTRING str PHPSTRING str -> [STR | str phpvar]? phpvar -> PHPVAR 

Текста написано уже много, и рассказывать еще можно очень долго. Но для понимания основ ply.yacc достаточно разобрать один пример. А дальше я уже выложу исходник парсера.

Итак, выдранный кусок из парсера:

def p_str(p):     '''str :            | STR            | str phpvar'''     if len(p) == 1:         p[0] = Node('str', [''])     elif len(p) == 2:         p[0] = Node('str', [p[1]])     else:         p[0] = p[1].add_parts([p[2]]) 

По порядку. Парсинг начинается с первой сверху функции с шаблоном p_<функция>. Т.е. у меня например первой в исходнике стоит p_php, поэтому парсер начнет работу именно с неё (сверху вниз). Парсер работает с правилами в doc-строках, и после их матчинга возвращает управление в функцию с правилами и при этом передает переменную p. В переменной p хранится результат парсинга. Причем после обработки мы должны засунуть наши результаты в p[0]. Элементы, начиная с p[1], это правая часть наших правил — отматченные токены и правила.

    '''str :            | STR            | str phpvar''' 

По сути правило выше — это скомбинированное (слитое) правило. ‘|’ как несложно догадаться — это ИЛИ, ну или различные варианты токена.
Между двоеточием и левой/правой частью обязателен пробел. Так любит ply. Если правая часть может быть пустой, то после двоеточия ничего не пишем. Токены должны быть написаны большими буквами, а правила маленькими, без префикса p_. Т.е. в примере выше использовались правила p_str и p_phpvar.

Например, если мы имеем "" (пустую строку), то в p у нас будет только одно значение — p[0], причем оно не определно и мы должны его заполнить.
Если у нас строка «привет $vasya пупкин», то эта функция выполнится три раза. Первый раз для значения STR, и мы вернем это значение обратно в p[0], второй раз для $vasya, и добавим это к предыдущему значению (p[1] будет содержать ноду, которую мы вернули в p[0] на прошлой итерации), а третий раз добавим на выход «пупкин». Наверное я сумбурно описал, но это надо просто потрогать. Открыть среду исполнения и поиграться 🙂

Кстати, иногда более удобен вариант с разделенными вариантами (тафтология, простите):

def p_str_empty(p):     '''str :'''     p[0] = Node('str', [''])   def p_str_raw(p):     '''str : STR'''     p[0] = Node('str', [p[1]])  def p_str_var(p):     '''str : str phpvar'''     p[0] = p[1].add_parts([p[2]]) 

Он абсолютно идентичен предыдущему варианту кода. Просто фломастеры другие. Функции имеют разные названия (потому что разделять их надо в питоне), но префиксы идентичные (str), что и заставляет ply группировать их вместе как варианты одного правила.

Для удобства построения дерева я использовал такой простенький и эффективный класс:

class Node:     def parts_str(self):         st = []         for part in self.parts:             st.append( str( part ) )         return "\n".join(st)      def __repr__(self):         return self.type + ":\n\t" + self.parts_str().replace("\n", "\n\t")      def add_parts(self, parts):         self.parts += parts         return self      def __init__(self, type, parts):         self.type = type         self.parts = parts 

Он одновременно и хранит все нужное, и структурирует вывод, что чень удобно при отладке.

Полный код парсера

# coding=utf8  from lexer import tokens import ply.yacc as yacc  class Node:     def parts_str(self):         st = []         for part in self.parts:             st.append( str( part ) )         return "\n".join(st)      def __repr__(self):         return self.type + ":\n\t" + self.parts_str().replace("\n", "\n\t")      def add_parts(self, parts):         self.parts += parts         return self      def __init__(self, type, parts):         self.type = type         self.parts = parts  def p_php(p):     '''php :            | PHPSTART phpbody'''     if len(p) == 1:         p[0] = None     else:         p[0] = p[2]  def p_phpbody(p):     '''phpbody :                | phpbody phpline phpcolons'''     if len(p) > 1:         if p[1] is None:             p[1] = Node('body', [])         p[0] = p[1].add_parts([p[2]])     else:         p[0] = Node('body', [])  def p_phpcolons(p):     '''phpcolons : PHPCOLON                  | phpcolons PHPCOLON'''  def p_phpline(p):     '''phpline : assign                | func                | PHPECHO args'''     if len(p) == 2:         p[0] = p[1]     else:         p[0] = Node('echo', [p[2]])  def p_assign(p):     '''assign : PHPVAR PHPEQUAL expr'''     p[0] = Node('assign', [p[1], p[3]])  def p_expr(p):     '''expr : fact             | expr PLUSMINUS fact'''     if len(p) == 2:         p[0] = p[1]     else:         p[0] = Node(p[2], [p[1], p[3]])  def p_fact(p):     '''fact : term             | fact DIVMUL term'''     if len(p) == 2:         p[0] = p[1]     else:         p[0] = Node(p[2], [p[1], p[3]])  def p_term(p):     '''term : arg             | PHPOPEN expr PHPCLOSE'''     if len(p) == 2:         p[0] = p[1]     else:         p[0] = p[2]  def p_func(p):     '''func : PHPFUNC PHPOPEN args PHPCLOSE'''     p[0] = Node('func', [p[1], p[3]])  def p_args(p):     '''args :             | expr             | args PHPCOMA expr'''     if len(p) == 1:         p[0] = Node('args', [])     elif len(p) == 2:         p[0] = Node('args', [p[1]])     else:         p[0] = p[1].add_parts([p[3]])  def p_arg(p):     '''arg : string            | phpvar            | PHPNUM            | func'''     p[0] = Node('arg', [p[1]])  def p_phpvar(p):     '''phpvar : PHPVAR'''     p[0] = Node('var', [p[1]])  def p_string(p):     '''string : PHPSTRING str PHPSTRING'''     p[0] = p[2]  def p_str(p):     '''str :            | STR            | str phpvar'''     if len(p) == 1:         p[0] = Node('str', [''])     elif len(p) == 2:         p[0] = Node('str', [p[1]])     else:         p[0] = p[1].add_parts([p[2]])  def p_error(p):     print 'Unexpected token:', p  parser = yacc.yacc()  def build_tree(code):     return parser.parse(code) 

Основной файлик, откуда вызываем все

# coding=utf8  from parser import build_tree  data = ''' <?php $val = 5; $result = substr( "foobar", 2*(7-$val) ); /* comment */ echo "это наш результат: ", $result; '''  result = build_tree(data) print result 

Вывод синтаксического дерева

 line: 	assign: 		$val 		arg: 			5 	assign: 		$result 		arg: 			func: 				substr 				args: 					arg: 						str: 							foobar 					*: 						arg: 							2 						-: 							arg: 								7 							arg: 								var: 									$val 	echo: 		args: 			arg: 				str: 					это наш результат:  			arg: 				var: 					$result 

Заключение

Если есть какие-то замечания или пожелания, с удовольствием допишу/изменю пост.

Код статьи здесь

Ну и если интересно с чего я вдруг про ply решил написать — pyfox. Делаю потихонечьку обработку css на python. Вернее парсер css написан, но вот прогулка по dom еще полностью не реализована (не все псевдоселекторы работают). Сейчас конкретно проблема с nth-child. Не на уровне css а на уровне эффективного матчинга — в dom нету такого свойства как номер среди соседей, а считать предыдущих соседей не хочется. Видимо придется HTMLParser кастомизировать. Может кто решит присоединитсья ко мне? Всех welcome 🙂

ссылка на оригинал статьи http://habrahabr.ru/post/191252/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *