Флаг `v` в регулярных выражениях с использованием нотации множеств и свойств строк
JavaScript поддерживает регулярные выражения с ECMAScript 3 (1999). Спустя шестнадцать лет в ES2015 были введены режим Unicode (флаг u), режим липкости (флаг y) и геттер RegExp.prototype.flags. Ещё через три года в ES2018 появились режим dotAll (флаг s), обратные проверки, именованные группы захвата и экранирование свойств символов Unicode. А в ES2020 String.prototype.matchAll упростил работу с регулярными выражениями. Регулярные выражения в JavaScript прошли долгий путь и продолжают совершенствоваться.
Последним достижением является новый режим unicodeSets, включаемый с помощью флага v. Этот новый режим предоставляет поддержку расширенных символьных классов, включая следующие функции:
- Свойства строк Unicode
- Нотация множеств + синтаксис строковых литералов
- Улучшенное нечёткое сопоставление
Эта статья подробно рассматривает каждую из этих функций. Но сначала — вот как использовать новый флаг:
const re = /…/v;
Флаг v можно комбинировать с уже существующими флагами регулярных выражений, за исключением одного случая. Флаг v включает все хорошие стороны флага u, но с дополнительными функциями и улучшениями — некоторые из которых несовместимы с флагом u. Важно отметить, что v является полностью отдельным режимом от u, а не дополняющим его. Поэтому флаги v и u не могут быть объединены — попытка использовать оба флага в одном регулярном выражении приводит к ошибке. Единственные возможные варианты: либо использовать u, либо использовать v, либо не использовать ни u, ни v. Но поскольку v является наиболее полнофункциональной опцией, выбор очевиден…
Давайте углубимся в новую функциональность!
Свойства строк Unicode
Стандарт Unicode назначает различные свойства и значения свойств каждому символу. Например, чтобы получить набор символов, используемых в греческом письме, выполните поиск в базе данных Unicode символов, значение свойства Script_Extensions которых включает Greek.
Экранирование свойств символов Unicode в ES2018 позволяет получить доступ к этим свойствам символов Unicode непосредственно в регулярных выражениях ECMAScript. Например, шаблон \p{Script_Extensions=Greek} соответствует каждому символу, который используется в греческом письме:
const regexGreekSymbol = /\p{Script_Extensions=Greek}/u;
regexGreekSymbol.test('π');
// → true
Согласно определению, свойства символов Unicode расширяются до множества кодовых точек, и, следовательно, их можно интерпретировать как символьный класс, содержащий кодовые точки, которые они индивидуально соответствуют. Например, \p{ASCII_Hex_Digit} эквивалентен [0-9A-Fa-f]: он всегда соответствует только одному символу Unicode/кодовой точке за раз. В некоторых ситуациях этого недостаточно:
// Unicode определяет свойство символа под названием «Emoji».
const re = /^\p{Emoji}$/u;
// Соответствие эмодзи, состоящему из всего 1 кодовой точки:
re.test('⚽'); // '\u26BD'
// → true ✅
// Соответствие эмодзи, состоящему из нескольких кодовых точек:
re.test('👨🏾⚕️'); // '\u{1F468}\u{1F3FE}\u200D\u2695\uFE0F'
// → false ❌
В приведённом выше примере регулярное выражение не соответствует эмодзи 👨🏾⚕️, потому что он состоит из нескольких кодовых точек, а Emoji является свойством символа Unicode.
К счастью, стандарт Unicode также определяет несколько свойств строк. Эти свойства расширяются до набора строк, каждая из которых содержит одну или несколько кодовых точек. В регулярных выражениях свойства строк преобразуются в набор альтернатив. Для иллюстрации представьте, что существует свойство Unicode, которое применимо к строкам 'a', 'b', 'c', 'W', 'xy' и 'xyz'. Это свойство переводится в один из следующих шаблонов регулярного выражения (с использованием чередования): xyz|xy|a|b|c|W или xyz|xy|[a-cW]. (Сначала длинные строки, чтобы префикс, такой как 'xy', не скрывал более длинную строку, такую как 'xyz'.) В отличие от существующих Unicode-экрапов свойств, этот шаблон может совпадать с многосимвольными строками. Вот пример использования свойства строк:
const re = /^\p{RGI_Emoji}$/v;
// Совпадение эмодзи, состоящего всего из одной кодовой точки:
re.test('⚽'); // '\u26BD'
// → true ✅
// Совпадение эмодзи, состоящего из нескольких кодовых точек:
re.test('👨🏾⚕️'); // '\u{1F468}\u{1F3FE}\u200D\u2695\uFE0F'
// → true ✅
Этот фрагмент кода относится к свойству строк RGI_Emoji, которое Unicode определяет как «подмножество всех допустимых эмодзи (символов и последовательностей), рекомендованных для общего обмена». С этим теперь мы можем сопоставлять эмодзи независимо от того, сколько кодовых точек они содержат!
Флаг v включает поддержку следующих Unicode свойств строк изначально:
Basic_EmojiEmoji_Keycap_SequenceRGI_Emoji_Modifier_SequenceRGI_Emoji_Flag_SequenceRGI_Emoji_Tag_SequenceRGI_Emoji_ZWJ_SequenceRGI_Emoji
Этот список поддерживаемых свойств может расшириться в будущем, если стандарт Unicode определит дополнительные свойства строк. Хотя все текущие свойства строк связаны с эмодзи, будущие свойства могут служить совершенно другим целям.
Примечание: Хотя свойства строк в настоящее время ограничены новым флагом v, мы планируем в конечном итоге сделать их доступными и в режиме u.
Нотация множества + синтаксис строковых литералов
При работе с экрапами \p{…} (будь то свойства символов или новые свойства строк) может быть полезно выполнять разность/вычитание или пересечение. С флагом v теперь можно вкладывать классы символов, и эти операции с множествами можно выполнять внутри них, а не с использованием смежных утверждений предвосхищения или ретроспективы или длинных классов символов, выражающих вычисленные диапазоны.
Разность/вычитание с помощью --
Синтаксис A--B можно использовать для сопоставления строк в A, но не в B, иначе говоря разность/вычитание.
Например, что если вы хотите сопоставить все греческие символы, кроме буквы π? Используя нотацию множества, решить это просто:
/[\p{Script_Extensions=Greek}--π]/v.test('π'); // → false
Используя -- для разности/вычитания, движок регулярных выражений выполняет сложную работу за вас, сохраняя ваш код читаемым и поддерживаемым.
Что если, вместо одного символа, мы хотим вычесть набор символов α, β и γ? Нет проблем — мы можем использовать вложенный класс символов и вычесть его содержимое:
/[\p{Script_Extensions=Greek}--[αβγ]]/v.test('α'); // → false
/[\p{Script_Extensions=Greek}--[α-γ]]/v.test('β'); // → false
Другой пример — сопоставление не-ASCII цифр, например, для их преобразования в ASCII цифры позже:
/[\p{Decimal_Number}--[0-9]]/v.test('𑜹'); // → true
/[\p{Decimal_Number}--[0-9]]/v.test('4'); // → false
Нотация множества также может быть использована с новыми свойствами строк:
// Примечание: 🏴 состоит из 7 кодовых точек.
/^\p{RGI_Emoji_Tag_Sequence}$/v.test('🏴'); // → true
/^[\p{RGI_Emoji_Tag_Sequence}--\q{🏴}]$/v.test('🏴'); // → false
Этот пример сопоставляет любую последовательность тегов эмодзи RGI, кроме флага Шотландии. Обратите внимание на использование \q{…}, который является новым синтаксисом для строковых литералов внутри классов символов. Например, \q{a|bc|def} сопоставляет строки a, bc, и def. Без \q{…} нельзя было бы вычесть жестко закодированные многосимвольные строки.
Пересечение с &&
Синтаксис A&&B сопоставляет строки, которые есть и в A, и в B, иначе говоря пересечение. Это позволяет делать, например, сопоставление греческих букв:
const re = /[\p{Script_Extensions=Greek}&&\p{Letter}]/v;
// U+03C0 МАЛАЯ ГРЕЧЕСКАЯ БУКВА ПИ
re.test('π'); // → true
// U+1018A ГРЕЧЕСКИЙ НУЛЕВОЙ ЗНАК
re.test('𐆊'); // → false
Сопоставление всех пробелов ASCII:
const re = /[\p{White_Space}&&\p{ASCII}]/v;
re.test('\n'); // → true
re.test('\u2028'); // → false
Или сопоставление всех монгольских чисел:
const re = /[\p{Script_Extensions=Mongolian}&&\p{Number}]/v;
// U+1817 МОНОЛЬСКАЯ ЦИФРА СЕМЬ
re.test('᠗'); // → true
// U+1834 БУКВА МОНОЛЬСКОЙ ЧА
re.test('ᠴ'); // → false
Объединение
Сопоставление строк, которые лежат в A или в B, уже было возможно для одиночных символов строк, используя класс символов, например [\p{Letter}\p{Number}]. С флагом v эта функциональность становится более мощной, поскольку теперь она может быть комбинирована с свойствами строк или строковыми литералами:
const re = /^[\p{Emoji_Keycap_Sequence}\p{ASCII}\q{🇧🇪|abc}xyz0-9]$/v;
re.test('4️⃣'); // → true
re.test('_'); // → true
re.test('🇧🇪'); // → true
re.test('abc'); // → true
re.test('x'); // → true
re.test('4'); // → true
Класс символов в этом шаблоне объединяет:
- свойство строки (
\p{Emoji_Keycap_Sequence}) - свойство символа (
\p{ASCII}) - синтаксис строкового литерала для многокодовых точек строк
🇧🇪иabc - классический синтаксис класса символов для одиночных символов
x,yиz - классический синтаксис класса символов для диапазона символов от
0до9
Другой пример — это сопоставление всех часто используемых флажков-эмодзи, независимо от того, закодированы ли они как двухбуквенный код ISO (RGI_Emoji_Flag_Sequence) или как последовательность тегов со специальным случаем (RGI_Emoji_Tag_Sequence):
const reFlag = /[\p{RGI_Emoji_Flag_Sequence}\p{RGI_Emoji_Tag_Sequence}]/v;
// Последовательность флага, состоящая из 2 кодовых точек (флаг Бельгии):
reFlag.test('🇧🇪'); // → true
// Последовательность тегов, состоящая из 7 кодовых точек (флаг Англии):
reFlag.test('🏴'); // → true
// Последовательность флага, состоящая из 2 кодовых точек (флаг Швейцарии):
reFlag.test('🇨🇭'); // → true
// Последовательность тегов, состоящая из 7 кодовых точек (флаг Уэльса):
reFlag.test('🏴'); // → true
Улучшенное регистронезависимое сопоставление
Флаг u из ES2015 страдает от запутанного поведения регистронезависимого сопоставления. Рассмотрим следующие два регулярных выражения:
const re1 = /\p{Lowercase_Letter}/giu;
const re2 = /[^\P{Lowercase_Letter}]/giu;
Первый шаблон соответствует всем строчным буквам. Второй шаблон использует \P вместо \p, чтобы соответствовать всем символам, кроме строчных букв, но затем обернут в отрицательный класс символов ([^…]). Оба регулярных выражения становятся регистронезависимыми благодаря установке флага i (ignoreCase).
Интуитивно можно ожидать, что оба регулярных выражения будут работать одинаково. На практике их поведение сильно отличается:
const re1 = /\p{Lowercase_Letter}/giu;
const re2 = /[^\P{Lowercase_Letter}]/giu;
const string = 'aAbBcC4#';
string.replaceAll(re1, 'X');
// → 'XXXXXX4#'
string.replaceAll(re2, 'X');
// → 'aAbBcC4#''
Новый флаг v имеет менее удивительное поведение. С использованием флага v вместо флага u оба шаблона работают одинаково:
const re1 = /\p{Lowercase_Letter}/giv;
const re2 = /[^\P{Lowercase_Letter}]/giv;
const string = 'aAbBcC4#';
string.replaceAll(re1, 'X');
// → 'XXXXXX4#'
string.replaceAll(re2, 'X');
// → 'XXXXXX4#'
Более общо, флаг v делает [^\p{X}] ≍ [\P{X}] ≍ \P{X} и [^\P{X}] ≍ [\p{X}] ≍ \p{X}, независимо от того, установлен ли флаг i.
Дополнительное чтение
Репозиторий предложения содержит больше деталей и фона вокруг этих функций и их проектных решений.
Как часть нашей работы над этими функциями JavaScript, мы пошли дальше простого предложения изменений спецификации ECMAScript. Мы передали определение «свойств строк» в Unicode UTS#18, чтобы другие языки программирования могли реализовать аналогичный функционал унифицированным образом. Мы также предлагаем изменение стандарта HTML с целью включения этих новых функций в атрибут pattern.
Поддержка флага RegExp v
V8 v11.0 (Chrome 110) предлагает экспериментальную поддержку этой новой функциональности через флаг --harmony-regexp-unicode-sets. V8 v12.0 (Chrome 112) включает новые функции по умолчанию. Babel также поддерживает транспиляцию флага v — попробуйте примеры из этой статьи в Babel REPL! Таблица поддержки ниже содержит ссылки на отслеживание проблем, на которые можно подписаться для получения обновлений.