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

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


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


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


    Напутствие



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


    Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

    Чем вы парсите строки?

    • 40,0%Лапками6
    • 20,0%Регулярками3
    • 13,3%Генераторами парсеров2
    • 6,7%Билдерами регулярок1
    • 20,0%Не царское это дело3

    Комментарии 0

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое