Здравствуйте, меня зовут Дмитрий Карловский и раньше я тоже использовал Perl для разработки фронтенда. Только гляньте, каким лаконичным кодом можно распарсить, например, имейл:
/^(?:((?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}(?:\.(?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}){0,})|("(?:((?:(?:([\u{1}-\u{8}\u{b}\u{c}\u{e}-\u{1f}\u{21}\u{23}-\u{5b}\u{5d}-\u{7f}])|(\\[\u{1}-\u{9}\u{b}\u{c}\u{e}-\u{7f}]))){0,}))"))@(?:((?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}(?:\.(?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}){0,}))$/gsu
Тут, правда, закралось несколько ошибок. Ну ничего, пофиксим в следующем релизе!
Шутки в сторону

По мере роста, регулярки очень быстро теряют свою понятность. Не зря в интернете есть десятки сервисов для отладки регулярок. Вот лишь некоторые из них:
- https://regex101.com/
- https://regexr.com/
- https://www.debuggex.com/
- https://extendsclass.com/regex-tester.html
А с внедрением новых фичей, они теряют и лаконичность:
/(?<слово>(?<буквица>\p{Script=Cyrillic})\p{Script=Cyrillic}+)/gimsu
У регулярок довольно развесистый синтаксис, который то и дело выветривается из памяти, что требует постоянного подсматривания в шпаргалку. Чего только стоят 5 разных способов экранирования:
/\t/ /\ci/ /\x09/ /\u0009/ /\u{9}/u
В JS у нас есть интерполяция строк, но как быть с регулярками?
const text = 'lol;)' // SyntaxError: Invalid regular expression: /^(lol;)){2}$/: Unmatched ')' const regexp = new RegExp( `^(${ text }){2}$` )
Ну, или у нас есть несколько простых регулярок, и мы хотим собрать из них одну сложную:
const VISA = /(?<type>4)\d{12}(?:\d{3})?/ const MasterCard = /(?<type>5)[12345]\d{14}/ // Invalid regular expression: /(?<type>4)\d{12}(?:\d{3})?|(?<type>5)[12345]\d{14}/: Duplicate capture group name const CardNumber = new RegExp( VISA.source + '|' + MasterCard.source )
Короче, писать их сложно, читать невозможно, а рефакторить вообще адски! Какие есть альтернативы?
Свои регулярки с распутным синтаксисом
Полностью своя реализация регулярок на JS. Для примера возьмём XRegExp:
- API совместимо с нативным.
- Можно форматировать пробелами.
- Можно оставлять комментарии.
- Можно расширять своими плагинами.
- Нет статической типизации.
- Отсутствует поддержка IDE.
В общем, всё те же проблемы, что и у нативных регулярок, но втридорога.
Генераторы парсеров
Вы скармливаете им грамматику на специальном DSL, а они выдают вам JS код функции парсинга. Для примера возьмём PEG.js:
- Наглядный синтаксис.
- Каждая грамматика — вещь в себе и не компонуется с другими.
- Нет статической типизации генерируемого парсера.
- Отсутствует поддержка IDE.
- Минимум 2 кб в ужатопережатом виде на каждую грамматику.
Это решение более мощное, но со своими косяками. И по воробьям из этой пушки стрелять не будешь.
Билдеры нативных регулярок
Для примера возьмём TypeScript библиотеку $mol_regexp:
- Строгая статическая типизация.
- Хорошая интеграция с IDE.
- Композиция регулярок с именованными группами захвата.
- Поддержка генерации строки, которая матчится на регулярку.
Это куда более легковесное решение. Давайте попробуем сделать что-то не бесполезное..
Номера банковских карт
Импортируем компоненты билдера
Это либо функции-фабрики регулярок, либо сами регулярки.
const { char_only, latin_only, decimal_only, begin, tab, line_end, end, repeat, repeat_greedy, from, } = $mol_regexp
Ну или так, если вы ещё используете NPM
import { $mol_regexp: { char_only, decimal_only, begin, tab, line_end, repeat, from, } } from 'mol_regexp'
Пишем регулярки для разных типов карт
// /4(?:\d){12,}?(?:(?:\d){3,}?){0,1}/gsu const VISA = from([ '4', repeat( decimal_only, 12 ), [ repeat( decimal_only, 3 ) ], ]) // /5[12345](?:\d){14,}?/gsu const MasterCard = from([ '5', char_only( '12345' ), repeat( decimal_only, 14 ), ])
В фабрику можно передавать:
- Строку и тогда она будет заэкранирована.
- Число и оно будет интерпретировано как юникод кодепоинт.
- Другую регулярку и она будет вставлена как есть.
- Массив и он будет трактован как последовательность выражений. Вложенный массив уже используется для указания на опциональность вложенной последовательности.
- Объект означающий захват одного из вариантов с именем соответствующим полю объекта (далее будет пример).
Компонуем в одну регулярку
// /(?:(4(?:\d){12,}?(?:(?:\d){3,}?){0,1})|(5[12345](?:\d){14,}?))/gsu const CardNumber = from({ VISA, MasterCard })
Строка списка карт
// /^(?:\t){0,}?(?:((?:(4(?:\d){12,}?(?:(?:\d){3,}?){0,1})|(5[12345](?:\d){14,}?))))(?:((?:\r){0,1}\n)|(\r))/gmsu const CardRow = from( [ begin, repeat( tab ), {CardNumber}, line_end ], { multiline: true }, )
Сам список карточек
const cards = ` 3123456789012 4123456789012 551234567890123 5512345678901234 `
Парсим текст регуляркой
for( const token of cards.matchAll( CardRow ) ) { if( !token.groups ) { if( !token[0].trim() ) continue console.log( 'Ошибка номера', token[0].trim() ) continue } const type = '' || token.groups.VISA && 'Карта VISA' || token.groups.MasterCard && 'MasterCard' console.log( type, token.groups.CardNumber ) }
Тут, правда, есть небольшое отличие от нативного поведения. matchAll с нативными регулярками выдаёт токен лишь для совпавших подстрок, игнорируя весь текст между ними. $mol_regexp же для текста между совпавшими подстроками выдаёт специальный токен. Отличить его можно по отсутствию поля groups. Эта вольность позволяет не просто искать подстроки, а полноценно разбивать весь текст на токены, как во взрослых парсерах.
Результат парсинга
Ошибка номера 3123456789012 Карта VISA 4123456789012 Ошибка номера 551234567890123 MasterCard 5512345678901234
Регулярку из начала статьи можно собрать так:
const { begin, end, char_only, char_range, latin_only, slash_back, repeat_greedy, from, } = $mol_regexp // Логин в виде пути разделённом точками const atom_char = char_only( latin_only, "!#$%&'*+/=?^`{|}~-" ) const atom = repeat_greedy( atom_char, 1 ) const dot_atom = from([ atom, repeat_greedy([ '.', atom ]) ]) // Допустимые символы в закавыченном имени сендбокса const name_letter = char_only( char_range( 0x01, 0x08 ), 0x0b, 0x0c, char_range( 0x0e, 0x1f ), 0x21, char_range( 0x23, 0x5b ), char_range( 0x5d, 0x7f ), ) // Экранированные последовательности в имени сендбокса const quoted_pair = from([ slash_back, char_only( char_range( 0x01, 0x09 ), 0x0b, 0x0c, char_range( 0x0e, 0x7f ), ) ]) // Закавыченное имя сендборкса const name = repeat_greedy({ name_letter, quoted_pair }) const quoted_name = from([ '"', {name}, '"' ]) // Основные части имейла: доменная и локальная const local_part = from({ dot_atom, quoted_name }) const domain = dot_atom // Матчится, если вся строка является имейлом const mail = from([ begin, local_part, '@', {domain}, end ])
Но просто распарсить имейл — эка невидаль. Давайте сгенерируем имейл!
// SyntaxError: Wrong param: dot_atom=foo..bar mail.generate({ dot_atom: 'foo..bar', domain: 'example.org', })
Упс, ерунду сморозил… Поправить можно так:
// foo.bar@example.org mail.generate({ dot_atom: 'foo.bar', domain: 'example.org', })
Или так:
// "foo..bar"@example.org mail.generate({ name: 'foo..bar', domain: 'example.org', })
Роуты
Представим, что сеошник поймал вас в тёмном переулке и заставил сделать ему "человекопонятные" урлы вида /snjat-dvushku/s-remontom/v-vihino. Не делайте резких движений, а медленно соберите ему регулярку:
const translit = char_only( latin_only, '-' ) const place = repeat_greedy( translit ) const action = from({ rent: 'snjat', buy: 'kupit' }) const repaired = from( 's-remontom' ) const rooms = from({ one_room: 'odnushku', two_room: 'dvushku', any_room: 'kvartiru', }) const route = from([ begin, '/', {action}, '-', {rooms}, [ '/', {repaired} ], [ '/v-', {place} ], end, ])
Теперь подсуньте в неё урл и получите структурированную информацию:
// `/snjat-dvushku/v-vihino`.matchAll(route).next().value.groups { action: "snjat", rent: "snjat", buy: "", rooms: "dvushku", one_room: "", two_room: "dvushku", any_room: "", repaired: "", place: "vihino", }
А когда потребуется сгенерировать новый урл, то просто задайте группам нужные значения:
// /kupit-kvartiru/v-moskve route.generate({ buy: true, any_room: true, repaired: false, place: 'moskve', })
Если задать true, то значение будет взято из самой регулярки. А если false, то будет скипнуто вместе со всем опциональным блоком.
И пока сеошник радостно потирает руки предвкушая первое место в выдаче, незаметно достаньте телефон, вызовите полицию, а сами скройтесь в песочнице.
Как это работает?
Нативные именованные группы, как мы выяснили ранее, не компонуются. Попадётся вам 2 регулярки с одинаковыми именами групп и всё, поехали за костылями. Поэтому при генерации регулярки используются анонимные группы. Но в каждую регулярку просовывается массив groups со списком имён:
// time.source == "((\d{2}):(\d{2}))" // time.groups == [ 'time', 'hours', 'minutes' ] const time = from({ time: [ { hours: repeat( decimal_only, 2 ) }, ':', { minutes: repeat( decimal_only, 2 ) }, ], )
Наследуемся, переопределям exec и добавляем пост-процессинг результата с формированием в нём объекта groups вида:
{ time: '12:34', hours: '12, minutes: '34', }
И всё бы хорошо, да только если скомпоновать с нативной регуляркой, содержащей анонимные группы, но не содержащей имён групп, то всё поедет:
// time.source == "((\d{2}):(\d{2}))" // time.groups == [ 'time', 'minutes' ] const time = wrong_from({ time: [ /(\d{2})/, ':', { minutes: repeat( decimal_only, 2 ) }, ], )
{ time: '12:34', hours: '34, minutes: undefined, }
Чтобы такого не происходило, при композиции с обычной нативной регуляркой, нужно "замерить" сколько в ней объявлено групп и дать им искусственные имена "0", "1" и тд. Сделать это не сложно — достаточно поправить регулярку, чтобы она точно совпала с пустой строкой, и посчитать число возвращённых групп:
new RegExp( '|' + regexp.source ).exec('').length - 1
И всё бы хорошо, да только String..match и String..matchAll клали шуруп на наш чудесный exec. Однако, их можно научить уму разуму, переопределив для регулярки методы Symbol.match и Symbol.matchAll. Например:
*[Symbol.matchAll] (str:string) { const index = this.lastIndex this.lastIndex = 0 while ( this.lastIndex < str.length ) { const found = this.exec(str) if( !found ) break yield found } this.lastIndex = index }
И всё бы хорошо, да только тайпскрипт всё равно не поймёт, какие в регулярке есть именованные группы:
interface RegExpMatchArray { groups?: { [key: string]: string } }
Что ж, активируем режим обезьянки и поправим это недоразумение:
interface String { match< RE extends RegExp >( regexp: RE ): ReturnType< RE[ typeof Symbol.match ] > matchAll< RE extends RegExp >( regexp: RE ): ReturnType< RE[ typeof Symbol.matchAll ] > }
Теперь TypeScript будет брать типы для groups из переданной регулярки, а не использовать какие-то свои захардкоженные.
Ещё из интересного там есть рекурсивное слияние типов групп, но это уже совсем другая история.
Напутствие
- Подробности в документация по $mol_regexp.
- Пример из дикой природы — токенайзеры MarkedText: $hyoo_marked.
- Другие микро библиотеки из экосистемы MAM доступные в NPM.
- Если у вас аллергия на $mol, то в этой статье можете найти ещё несколько популярных библиотек для парсинга.
Но что бы вы ни выбрали — знайте, что каждый раз, когда вы пишете регулярку вручную, где-то в интернете плачет (от счастья) один верблюд.

ссылка на оригинал статьи https://habr.com/ru/post/561704/
Добавить комментарий