HabHub icon indicating copy to clipboard operation
HabHub copied to clipboard

Да хватит уже писать эти регулярки!

Open nin-jin opened this issue 3 years ago • 6 comments

https://page.hyoo.ru/#!=7elk22_ykxz0c

Здравствуйте, меня зовут Дмитрий Карловский и раньше я тоже использовал 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

Заценить в песочнице.

E-Mail

Регулярку из начала статьи можно собрать так:

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',
})

Упс, ерунду сморозил... Поправить можно так:

// [email protected]
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 из переданной регулярки, а не использовать какие-то свои захардкоженные.

Ещё из интересного там есть рекурсивное слияние типов групп, но это уже совсем другая история.

Напутствие

Но что бы вы ни выбрали - знайте, что каждый раз, когда вы пишете регулярку вручную, где-то в интернете плачет (от счастья) один верблюд.

nin-jin avatar Jun 07 '21 20:06 nin-jin

между импортом и регулярками кард, мб что-нибудь попроще вставить?

как вариант, показать что из себя представляют импортируемые штуки

static decimal_only = $mol_regexp.from( /\d/gsu )
static end = $mol_regexp.from( /$/gsu )

Нужно делать усилия что бы интерпретировать from(...), конечно не такие, что бы понять что в простой регулярке написанно. Рядом с примерами карт можно на регулярках тоже самое показать.

regexpVISA = /4\d{12,12}(\d\d\d)?/
// написал без шпаргалки)

const VISA = from([
    '4',
    repeat( decimal_only, 12 ),
    [ repeat( decimal_only, 3 ) ],
])

Пример с имейлом, немного легче было бы понять с объяснениями какую часть имейла данный токен парсит

Статья, кажется, больше обзорная. Было бы интересно почитать про как оно внутри работает, с какими проблемами борется и че-нибудь типа гайда как пользоваться. Хотя как пользоваться и тут достаточно описано, все что можно импортнуть можно в исходниках увидеть

PavelZubkov avatar Jun 08 '21 06:06 PavelZubkov

Спасибо, добавил дампы генерируемых регулярок, пояснения по имейлу и дополнительный раздел с описанием внутренних трудностей.

nin-jin avatar Jun 08 '21 09:06 nin-jin

Да, то что регулярки не композиционируемые это давно известная проблема. Люди кто их проектировал похоже не читали про монады и ФП. В перле для этого придумали Regexp::Grammars, очень удобная вещь.

Насчет реализации из статьи, все вроде бы классно. Особенно нравится возможно сгенерить строку соответствующую регулярке.

Единственное, я так понимаю она позиционируется как часть "эко-системы МАМ", что, думаю, отразится на популярности, поскольку изучать новую эко-систему для решения одной частной задачи мало кто будет. Но это уже на усмотрение автора.

А так стоящая вещь, достойная релиза, доки https://github.com/hyoo-ru/mam_mol/tree/master/regexp конечно тоже можно улучшить

canonic-epicure avatar Jun 08 '21 10:06 canonic-epicure

В доках я бы сделал упор на слово composable:

$mol_regexp
Build composable regular expressions, which compiles to native RegExps. Generate a sample matching string for them.

canonic-epicure avatar Jun 08 '21 10:06 canonic-epicure

Да оно как-то не особо привязано к MAM экосистеме. Можно использовать и из NPM.

nin-jin avatar Jun 08 '21 12:06 nin-jin

Спасибо за советы, опубликовал на Хабре.

nin-jin avatar Jun 08 '21 13:06 nin-jin