...

понедельник, 5 марта 2018 г.

RegExp Unicode Property Escapes в JavaScript: штрихи к портрету

RegExp Unicode Property Escapes перешли на 4-ю ступень и будут включены в ES2018.

В V8 они доступны без флага начиная с v6.4, так что готовы к использованию во всех текущих каналах Google Chrome от стабильного до Canary.

В Node.js они будут доступны без флага уже в v10 (выходит в апреле). В других версиях требуется флаг --harmony_regexp_property (Node.js v6–v9) или --harmony (Node.js v8-v9). Сейчас без флага их можно испробовать или в ночных сборках, или в ветке v8-canary.

При этом нужно иметь в виду, что сборки Node.js, скомпилированные без поддержки ICU, будут лишены возможности использовать этот класс регулярных выражений (подробнее см. Internationalization Support).

Подробнее о поддержке в других движках и средах см. в известной таблице (после перехода проскрольте чуть выше).

Я не буду повторять описания этой долгожданной возможности, лишь сошлюсь на несколько статей от известных специалистов:


Мне же захотелось рассказать о паре не совсем очевидных мелочей.

Когда я начинал знакомство с этой новой возможностью, то пожалел о двух недостающих удобствах: способе программно получить список всех допустимых вариантов в этом самом обширном теперь классе регулярных выражений и способе получить список подходящих свойств для конкретного символа Юникода.

Если кто-то почувствует такую же нужду, пусть эти заметки сэкономят ему время :)


Список всех доступных свойств для регулярного выражения

На данный момент, авторитетным и исчерпывающим источником, перечисляющим все возможные свойства, служит сама текущая спецификация ECMAScript, в частности таблицы (осторожно, по ссылкам тяжеловесная страница) в разделах Runtime Semantics: UnicodeMatchProperty ( p ) и Runtime Semantics: UnicodeMatchPropertyValue ( p, v ).

Если кому-то неудобно загружать всю спецификацию, можно ограничиться спецификацией предложения с теми же таблицами. И совсем облегчённый вариант: эти таблицы существуют в виде четырёх отдельных файлов в корне репозитория спецификации ECMAScript. Собственно, только они и существуют в виде отдельных файлов, импортируемых в спецификацию, — уже одно это, наверное, может свидетельствовать об их беспрецедентном объёме. Таблицы можно с относительным удобством просмотреть при помощи родного подсервиса.

Я же извлёк эти данные и набросал крохотную библиотечку, содержащую структурированный список всех возможных имён и значений и экспортирующую этот объект в виде уплощённого массива всех возможных членов из данного класса регулярных выражений.

Все подразделы представлены в алфавитном порядке за исключением общих свойств (тут удобнее и привычнее порядок документа из базы Юникода). Список не содержит синонимов, а сокращения используются только для общих свойств, что существенно экономит место в последующих операциях с библиотекой.

При помощи нехитрого скрипта и упомянутой библиотеки можно получить список в формате JSON, содержащий источники для регулярных выражений. Пример такого скрипта и его вывода можно посмотреть там же в комментарии — всего 372 варианта в текущей версии спецификации.


Получение свойств символов

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

Должен оговориться, что ради простоты я не добавлял в скрипты обработку ошибок, так что об этом стоит позаботиться отдельно.


1. Характеристика отдельного символа.

Небольшая утилита получает в качестве параметра командной строки единичный символ или его шестнадцатеричный номер в базе Юникода (code point) и выдаёт список свойств, которые в будущем можно использовать при поиске данного символа или общего ему класса символов.


re-unicode-properties.character-info.js
'use strict';

const reUnicodeProperties = require('./re-unicode-properties.js');

const RADIX = 16;
const PAD_MAX = 4;

const [, , arg] = process.argv;
let character;
let codePoint;

if ([...arg].length === 1) {
  character = arg;
  codePoint = `U+${character.codePointAt(0).toString(RADIX).padStart(PAD_MAX, '0')}`;
} else {
  character = String.fromCodePoint(Number.parseInt(arg, RADIX));
  codePoint = `U+${arg.padStart(PAD_MAX, '0')}`;
}

const characterProperties = reUnicodeProperties
  .filter(re => re.test(character))
  .map(re => re.source)
  .join('\n')
  .replace(/\\p\{|\}/g, '');

console.log(
  `${JSON.stringify(character)} (${codePoint})\n${characterProperties}`,
);

Пример вывода:

$ node re-unicode-properties.character-info.js ё
"ё" (U+0451)
gc=Letter
gc=Cased_Letter
gc=Lowercase_Letter
sc=Cyrillic
scx=Cyrillic
Alphabetic
Any
Assigned
Cased
Changes_When_Casemapped
Changes_When_Titlecased
Changes_When_Uppercased
Grapheme_Base
ID_Continue
ID_Start
Lowercase
XID_Continue
XID_Start

2. Получение списка всех символов Юникода с доступными для них свойствами.

Этот вариант скрипта работает на моей машине 2–3 минуты и отъедает около гигабайта памяти, так что будьте осторожны. Для однократного запуска, дающего нам полную базу, это терпимо, при необходимости же можно настроить постепенный вывод в файл вместо построения всей базы в памяти и вывода в один присест.

Скрипт можно запускать без параметров, тогда он выводит базу в упрощённом текстовом формате, по одному символу со свойствами на строку. Если же добавить параметр json, на выходе мы получим читабельную базу в JSON (кстати, использовать в виде ключей шестнадцатеричные цифры не выходит: сортировка результата перестаёт быть детерминированной порядком создания ключей; поэтому к числовому ключу прибавляется префикс U+ — так и сортировка сохраняется, и искать символ в сети будет удобнее, если понадобится полный набор свойств и подробное описание, а не только подходящий для регулярного выражения список).


re-unicode-properties.code-points.js
'use strict';

const { writeFileSync } = require('fs');
const reUnicodeProperties = require('./re-unicode-properties.js');

const [, , format] = process.argv;

const LAST_CODE_POINT = 0x10FFFF;
const RADIX = 16;
const PAD_MAX = LAST_CODE_POINT.toString(RADIX).length;

const data = {};

let codePoint = 0;

while (codePoint <= LAST_CODE_POINT) {
  const character = String.fromCodePoint(codePoint);
  data[`U+${codePoint.toString(RADIX).padStart(PAD_MAX, '0')}`] = [
    character,
    ...reUnicodeProperties
      .filter(re => re.test(character))
      .map(re => re.source.replace(/\\p\{|\}/g, '')),
  ];
  codePoint++;
}

if (format === 'json') {
  writeFileSync(
    're-unicode-properties.code-points.json',
    `\uFEFF${JSON.stringify(data, null, 2)}\n`,
  );
} else {
  writeFileSync(
    're-unicode-properties.code-points.txt',
    `\uFEFF${
      Object.entries(data)
        .map(([k, v]) => `${k.replace('U+', '')} ${JSON.stringify(v.shift())} ${v.join(' ')}`)
        .join('\n')
    }\n`,
  );
}

Примеры фрагментов в обоих форматах:

000020 " " gc=Separator gc=Space_Separator sc=Common scx=Common ASCII Any Assigned Grapheme_Base Pattern_White_Space White_Space
000021 "!" gc=Punctuation gc=Other_Punctuation sc=Common scx=Common ASCII Any Assigned Grapheme_Base Pattern_Syntax Sentence_Terminal Terminal_Punctuation
000022 "\"" gc=Punctuation gc=Other_Punctuation sc=Common scx=Common ASCII Any Assigned Grapheme_Base Pattern_Syntax Quotation_Mark
000023 "#" gc=Punctuation gc=Other_Punctuation sc=Common scx=Common ASCII Any Assigned Emoji Emoji_Component Grapheme_Base Pattern_Syntax
000024 "$" gc=Symbol gc=Currency_Symbol sc=Common scx=Common ASCII Any Assigned Grapheme_Base Pattern_Syntax
000025 "%" gc=Punctuation gc=Other_Punctuation sc=Common scx=Common ASCII Any Assigned Grapheme_Base Pattern_Syntax
000026 "&" gc=Punctuation gc=Other_Punctuation sc=Common scx=Common ASCII Any Assigned Grapheme_Base Pattern_Syntax
000027 "'" gc=Punctuation gc=Other_Punctuation sc=Common scx=Common ASCII Any Assigned Case_Ignorable Grapheme_Base Pattern_Syntax Quotation_Mark
000028 "(" gc=Punctuation gc=Open_Punctuation sc=Common scx=Common ASCII Any Assigned Bidi_Mirrored Grapheme_Base Pattern_Syntax
000029 ")" gc=Punctuation gc=Close_Punctuation sc=Common scx=Common ASCII Any Assigned Bidi_Mirrored Grapheme_Base Pattern_Syntax
00002a "*" gc=Punctuation gc=Other_Punctuation sc=Common scx=Common ASCII Any Assigned Emoji Emoji_Component Grapheme_Base Pattern_Syntax
00002b "+" gc=Symbol gc=Math_Symbol sc=Common scx=Common ASCII Any Assigned Grapheme_Base Math Pattern_Syntax
00002c "," gc=Punctuation gc=Other_Punctuation sc=Common scx=Common ASCII Any Assigned Grapheme_Base Pattern_Syntax Terminal_Punctuation
00002d "-" gc=Punctuation gc=Dash_Punctuation sc=Common scx=Common ASCII Any Assigned Dash Grapheme_Base Pattern_Syntax
00002e "." gc=Punctuation gc=Other_Punctuation sc=Common scx=Common ASCII Any Assigned Case_Ignorable Grapheme_Base Pattern_Syntax Sentence_Terminal Terminal_Punctuation
00002f "/" gc=Punctuation gc=Other_Punctuation sc=Common scx=Common ASCII Any Assigned Grapheme_Base Pattern_Syntax
[
  "U+000020": [
    " ",
    "gc=Separator",
    "gc=Space_Separator",
    "sc=Common",
    "scx=Common",
    "ASCII",
    "Any",
    "Assigned",
    "Grapheme_Base",
    "Pattern_White_Space",
    "White_Space"
  ],
  "U+000021": [
    "!",
    "gc=Punctuation",
    "gc=Other_Punctuation",
    "sc=Common",
    "scx=Common",
    "ASCII",
    "Any",
    "Assigned",
    "Grapheme_Base",
    "Pattern_Syntax",
    "Sentence_Terminal",
    "Terminal_Punctuation"
  ]
]

Полные базы в архивах можно при желании скачать: .txt (5 MB в архиве, ~60 MB текста) или .json (5.5 MB в архиве, ~112 MB текста). При просмотре не забудьте использовать хорошие шрифты.


3. Список используемых в файле символов с их свойствами.

Это вариант предыдущего скрипта, предоставляющего не полную базу символов, а лишь тот набор, который встречается в заданном файле. Первым параметром скрипта задаётся путь к файлу, вторым необязательным — формат (текстовый используется по умолчанию, также можно задать json). Вывод аналогичный предыдущему, только меньший по объёму. Поскольку файл читается в режиме потока, можно обрабатывать тексты любого разумного размера. У меня гигабайтный файл обрабатывался пять минут, на протяжении всей работы занимал около 60 мегабайт памяти.


re-unicode-properties.file-info.js
'use strict';

const { createReadStream, writeFileSync } = require('fs');
const { basename } = require('path');
const reUnicodeProperties = require('./re-unicode-properties.js');

const [, , filePath, format] = process.argv;

const LAST_CODE_POINT = 0x10FFFF;
const RADIX = 16;
const PAD_MAX = LAST_CODE_POINT.toString(RADIX).length;

const data = {};

(async function main() {
  const fileStream = createReadStream(filePath);
  fileStream.setEncoding('utf8');

  const characters = new Set();
  for await (const chunk of fileStream) {
    [...chunk].forEach((character) => { characters.add(character); });
  }

  [...characters].sort().forEach((character) => {
    data[`U+${character.codePointAt(0).toString(RADIX).padStart(PAD_MAX, '0')}`] = [
      character,
      ...reUnicodeProperties
        .filter(re => re.test(character))
        .map(re => re.source.replace(/\\p\{|\}/g, '')),
    ];
  });

  if (format === 'json') {
    writeFileSync(
      `re-unicode-properties.file-info.${basename(filePath)}.json`,
      `\uFEFF${JSON.stringify(data, null, 2)}\n`,
    );
  } else {
    writeFileSync(
      `re-unicode-properties.file-info.${basename(filePath)}.txt`,
      `\uFEFF${
        Object.entries(data)
          .map(([k, v]) => `${k.replace('U+', '')} ${JSON.stringify(v.shift())} ${v.join(' ')}`)
          .join('\n')
      }\n`,
    );
  }
})();

На этом, пожалуй, всё. Спасибо за уделённое время.

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий