1. C чего всё началось
Недавно у меня возникла необходимость написать очередную утилиту, обрабатывающую текстовый файл в формате, похожем на упрощённый BBCode, а именно в формате исходников для словарей ABBYY Lingvo — DSL (Dictionary Specification Language). (Не путать с другим DSL (Domain-specific language) — интересный случай, когда гипоним является омонимом к гиперониму).
Достаточно сказать, что в языке используются теги в квадратных скобках и что квадратные скобки можно экранировать обратной косой чертой, если нужно использовать их как часть обычного текста.
Одной из задач утилиты было как раз нахождение этих тегов с исключением экранированных сочетаний.
Поскольку в регулярных выражениях JavaScript с недавнего времени можно пользоваться lookbehind assertions (в личных целях), я подумал, нельзя ли реализовать поиск при помощи этого средства, — тем более что в данной разновидности lookbehind можно использовать выражения переменной длины.
2. Предварительные замечания
Чтобы оценить дальнейший эксперимент, необходимо знакомство с некоторыми новыми возможностями JavaScript.
1. Template literals — давно ожидавшаяся возможность создания строк с интерполяцией переменных.
2. String.raw(). Думаю, возможности этой функции можно сравнить с одинарными кавычками в Perl и префиксом r''
в Python: все они помогают создавать строки с буквальной интерпретацией спецсимвола экранирования.
3. Lookbehind assertions (в том числе см. способы активизации их в Google Chrome и Node.js).
3. Реализация
Код скрипта с пробной (наивной) реализацией поиска и проверки:
/******************************************************************************/
'use strict';
/******************************************************************************/
const r = String.raw;
const startOfString = '^';
const notEscapeSymbol = r`[^x5c]`;
const escapedEscapeSymbols = r`(?:${startOfString}|${notEscapeSymbol})(?:x5c{2})+`;
const tag = r`x5b[^x5d]+x5d`;
const tagRE = new RegExp(
`(?<=${startOfString}|${notEscapeSymbol}|${escapedEscapeSymbols})${tag}`, 'g'
);
console.log(r`[tag]text[/tag]`.match(tagRE));
console.log(r`\[tag]text\\[/tag]`.match(tagRE));
console.log(r`[tag]text\[/tag]`.match(tagRE));
/******************************************************************************/
Сперва мы создаём синоним для String.raw
, чтобы можно было использовать короткую форму, подобно префиксу r''
в Python.
Затем мы создаём составные части будущего регулярного выражения.
Я исходил из предположения, что правильному тегу может предшествовать один из трёх вариантов: начало строки, любой символ кроме обратной косой черты и экранированная обратная косая черта (то есть сочетание двух обратных косых черт). При этом нужно следить за тем, чтобы экранирующий косую черту символ сам не подвергся экранированию: иными словами, предшествовать тегу может лишь чётное количество обратных косых черт, перед которыми, в свою очередь, может быть или начало строки, или любой отличный от них символ.
Таким образом, нам нужно четыре ключевых элемента сложного регулярного выражения: сам тег и три его допустимых предшественника — начало строки, любой символ за исключением обратной косой черты и экранированная косая черта или её повторение любое количество раз. Третий предшественник тега может быть представлен как сочетание одного из первых двух предшественников и пары обратных косых черт в любом количестве.
Чтобы не рябило в глазах, я все буквальные символы обратных косых черт и квадратных скобок заменил на шестнадцатеричные литералы ([ — x5b, — x5c, ] — x5d
).
Эквивалентом скомпилированного из частей регулярного выражения будет следующее сочетание (его можно использовать вместо всей первой части, присвоив его переменной tagRE
напрямую):
/(?<=^|[^x5c]|(?:^|[^x5c])(?:x5c{2})+)x5b[^x5d]+x5d/g
В конце скрипта полученное выражение тестируется на минимальном наборе правильных и экранированных тегов. Первая строка содержит тег после начала строки и после символа, отличного от обратной косой черты. Вторая строка содержит теги после экранированной обратной косой черты, которую (или которых) предваряет или начало строки, или символ, отличный от них самих. Третья строка содержит экранированные теги.
В консоль выводится следующий результат:
[ '[i]', '[/i]' ]
[ '[i]', '[/i]' ]
null
При оценке решения следует иметь в виду две оговорки:
1. Это реализация для домашнего использования, а не для выпуска в массовую продукцию (пока lookbehind assertions не выйдут из-под флага в Node.js и Google Chrome и не будут реализованы в других браузерах).
2. Данное выражение не призвано проверять правильность содержимого самих тегов, только отличить их от экранированных сочетаний.
Буду благодарен за указания не незамеченные риски и за советы по оптимизации.
Автор: vmb