Да хватит уже писать эти регулярки
- среда, 9 июня 2021 г. в 00:43:54
Здравствуйте, меня зовут Дмитрий Карловский и раньше я тоже использовал 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
Тут, правда, закралось несколько ошибок. Ну ничего, пофиксим в следующем релизе!
По мере роста, регулярки очень быстро теряют свою понятность. Не зря в интернете есть десятки сервисов для отладки регулярок. Вот лишь некоторые из них:
А с внедрением новых фичей, они теряют и лаконичность:
/(?<слово>(?<буквица>\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:
В общем, всё те же проблемы, что и у нативных регулярок, но втридорога.
Вы скармливаете им грамматику на специальном DSL, а они выдают вам JS код функции парсинга. Для примера возьмём PEG.js:
Это решение более мощное, но со своими косяками. И по воробьям из этой пушки стрелять не будешь.
Для примера возьмём TypeScript библиотеку $mol_regexp:
Это куда более легковесное решение. Давайте попробуем сделать что-то не бесполезное..
Это либо функции-фабрики регулярок, либо сами регулярки.
const {
char_only, latin_only, decimal_only,
begin, tab, line_end, end,
repeat, repeat_greedy, from,
} = $mol_regexp
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
из переданной регулярки, а не использовать какие-то свои захардкоженные.
Ещё из интересного там есть рекурсивное слияние типов групп, но это уже совсем другая история.
Но что бы вы ни выбрали — знайте, что каждый раз, когда вы пишете регулярку вручную, где-то в интернете плачет (от счастья) один верблюд.