4 сентября 2023 г.

Опережающие и ретроспективные проверки

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

Для этого в регулярных выражениях есть специальный синтаксис: опережающая (lookahead) и ретроспективная (lookbehind) проверка.

В качестве первого примера найдём стоимость из строки 1 индейка стоит 30€. То есть, найдём число, после которого есть знак валюты .

Опережающая проверка

Синтаксис опережающей проверки: X(?=Y).

Он означает: найди X при условии, что за ним следует Y. Вместо X и Y здесь может быть любой шаблон.

Для целого числа, за которым идёт знак , шаблон регулярного выражения будет \d+(?=€):

let str = "1 индейка стоит 30€";

alert( str.match(/\d+(?=€)/) ); // 30, число 1 проигнорировано, так как за ним НЕ следует €

Обратим внимание, что проверка – это именно проверка, содержимое скобок (?=...) не включается в результат 30.

При поиске X(?=Y) движок регулярных выражений, найдя X, проверяет есть ли после него Y. Если это не так, то игнорирует совпадение и продолжает поиск дальше.

Возможны и более сложные проверки, например X(?=Y)(?=Z) означает:

  1. Найти X.
  2. Проверить, идёт ли Y сразу после X (если нет – не подходит).
  3. Проверить, идёт ли Z сразу после X (если нет – не подходит).
  4. Если обе проверки прошли – совпадение найдено.

То есть этот шаблон означает, что мы ищем X при условии, что за ним идёт и Y и Z.

Такое возможно только при условии, что шаблоны Y и Z не являются взаимно исключающими.

Например, \d+(?=\s)(?=.*30) ищет \d+ при условии, что за ним идёт пробел, и где-то впереди есть 30:

let str = "1 индейка стоит 30€";

alert( str.match(/\d+(?=\s)(?=.*30)/) ); // 1

В нашей строке это как раз число 1.

Негативная опережающая проверка

Допустим, нам нужно узнать из этой же строки количество индеек, то есть число \d+, за которым НЕ следует знак .

Для этой задачи мы можем применить негативную опережающую проверку.

Синтаксис: X(?!Y)

Он означает: найди такой X, за которым НЕ следует Y.

let str = "2 индейки стоят 60€";

alert( str.match(/\d+(?!€)/) ); // 2 (в этот раз проигнорирована цена)

Ретроспективная проверка

Браузерная совместимость с ретроспективной проверкой

Обратите внимание: Lookbehind не поддерживается в браузерах построенных не на движке V8, таких как Safari и Internet Explorer.

Опережающие проверки позволяют задавать условия на то, что «идёт после».

Ретроспективная проверка выполняет такую же функцию, но с просмотром назад. Другими словами, она находит соответствие шаблону, только если перед ним есть что-то заранее определённое.

Синтаксис:

  • Позитивная ретроспективная проверка: (?<=Y)X, ищет совпадение с X при условии, что перед ним ЕСТЬ Y.
  • Негативная ретроспективная проверка: (?<!Y)X, ищет совпадение с X при условии, что перед ним НЕТ Y.

Чтобы протестировать ретроспективную проверку, давайте поменяем валюту на доллары США. Знак доллара обычно ставится перед суммой денег, поэтому для того чтобы найти $30, мы используем (?<=\$)\d+ – число, перед которым идёт $:

let str = "1 индейка стоит $30";

// знак доллара экранируем \$, так как это специальный символ
alert( str.match(/(?<=\$)\d+/) ); // 30, одинокое число игнорируется

Если нам необходимо найти количество индеек – число, перед которым не идёт $, мы можем использовать негативную ретроспективную проверку (?<!\$)\d+:

let str = "2 индейки стоят $60";

alert( str.match(/(?<!\$)\d+/) ); // 2 (проигнорировалась цена)

Скобочные группы

Как правило, то что находится внутри скобок, задающих опережающую и ретроспективную проверку, не включается в результат совпадения.

Например, в шаблоне \d+(?=€) знак не будет включён в результат. Это логично, ведь мы ищем число \d+, а (?=€) – это всего лишь проверка, что за ним идёт знак .

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

В следующем примере знак валюты (€|kr) будет включён в результат вместе с суммой:

let str = "1 индейка стоит 30€";
let regexp = /\d+(?=(€|kr))/; // добавлены дополнительные скобки вокруг €|kr

alert( str.match(regexp) ); // 30, €

То же самое можно применить к ретроспективной проверке:

let str = "1 индейка стоит $30";
let regexp = /(?<=(\$|£))\d+/;

alert( str.match(regexp) ); // 30, $

Итого

Опережающая и ретроспективная проверки удобны, когда мы хотим искать шаблон по дополнительному условию на контекст, в котором он находится.

Для простых регулярных выражений мы можем сделать похожую вещь «вручную». То есть, найти все совпадения, независимо от контекста, а затем в цикле отфильтровать подходящие.

Как мы помним, regexp.match (без флага g) и str.matchAll (всегда) возвращают совпадения со свойством index, которое содержит позицию совпадения в строке, так что мы можем посмотреть на контекст.

Но обычно регулярные выражения удобнее.

Виды проверок:

Шаблон Тип Совпадение
X(?=Y) Позитивная опережающая X, если за ним следует Y
X(?!Y) Негативная опережающая X, если за ним НЕ следует Y
(?<=Y)X Позитивная ретроспективная X, если следует за Y
(?<!Y)X Негативная ретроспективная X, если НЕ следует за Y

Задачи

Есть строка с целыми числами.

Создайте регулярное выражение, которое ищет только неотрицательные числа. Ноль разрешён.

Пример использования:

let regexp = /ваше регулярное выражение/g;

let str = "0 12 -5 123 -18";

alert( str.match(regexp) ); // 0, 12, 123

Регэксп для целого числа: \d+.

Мы можем исключить отрицательные добавлением негативной ретроспективной проверки: (?<!-)\d+.

Однако, если попробуем применить такой регэксп, то увидим лишний результат:

let regexp = /(?<!-)\d+/g;

let str = "0 12 -5 123 -18";

console.log( str.match(regexp) ); // 0, 12, 123, 8

Как видите, оно находит 8 из -18. То есть, берёт только цифру из числа -18, так как это формально подходит под регулярное выражение.

Чтобы исключить такой вариант, надо убедиться, что регэксп не будет искать число с середины другого (неподходящего) числа.

Мы можем сделать это добавлением ещё одной проверки: (?<!-)(?<!\d)\d+. Теперь (?<!\d) гарантирует, что поиск не начнётся после цифры.

Можем объединить проверки в одну:

let regexp = /(?<![-\d])\d+/g;

let str = "0 12 -5 123 -18";

alert( str.match(regexp) ); // 0, 12, 123

У нас есть строка с HTML-документом.

Напишите регулярное выражение которое вставляет <h1>Hello</h1> сразу же после тега <body>. У тега могут быть атрибуты.

Например:

let regexp = /ваше регулярное выражение/;

let str = `
<html>
  <body style="height: 200px">
  ...
  </body>
</html>
`;

str = str.replace(regexp, `<h1>Hello</h1>`);

После этого значение str:

<html>
  <body style="height: 200px"><h1>Hello</h1>
  ...
  </body>
</html>

Для того, чтобы вставить после тега <body>, нужно вначале его найти. Мы можем использовать регулярное выражение <body.*?> для этого.

В этом задании нам не нужно изменять <body> тег. Нам нужно только добавить текст после него.

Вот как мы можем это сделать:

let str = '...<body style="...">...';
str = str.replace(/<body.*?>/, '$&<h1>Hello</h1>');

alert(str); // ...<body style="..."><h1>Hello</h1>...

В строке замены $& означает само совпадение, то есть мы заменяем ту часть текста которая соответствует <body.*?>. Совпадение заменяется на самого себя плюс <h1>Hello</h1>.

Альтернативный вариант – использовать ретроспективную проверку:

let str = '...<body style="...">...';
str = str.replace(/(?<=<body.*?>)/, `<h1>Hello</h1>`);

alert(str); // ...<body style="..."><h1>Hello</h1>...

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

Это работает вот так:

  • В каждой позиции текста.
  • Проверяет не предшествует ли ему <body.*?>.
  • Если это так, то мы встретили совпадение.

Тег <body.*?> не вернётся. Результатом этого регулярного выражения является буквально пустая строка, но она совпадает только в позициях, которым предшествует <body.*?>.

Происходит замена «пустой строки», которой предшествует <body.*?> на <h1>Hello</h1>. Что, как раз, и есть вставка этой строки после <body>.

P.S. Флаги регулярных выражений, такие как s и i, также могут быть полезны: /<body.*?>/si. Флаг s создает точечный . соответствует символу новой строки, а флаг i делает <body> также соответствующим <BODY> без учета регистра.

Карта учебника