Регулярное выражение (regex) — это не язык программирования и не «магическая строка», а способ описать шаблон текста и потом проверить, подходит ли под него данная строка, найти все такие места или заменить их. Один раз научившись читать шаблоны, вы перестанете гуглить «как проверить email» каждый раз и начнете видеть regex повсюду: в фильтре логов, в правиле nginx, в валидации формы на PHP и в поиске редактора. Разберём regex по делу: из чего он состоит, как его собрать под конкретную задачу, где он ломается и почему «работает в тесте» не значит «работает на юникоде».
Часть I. Анатомия одной регулярки
Начнём с того, что regex читается вслух почти как предложение. Возьмём шаблон /^[A-Z][a-z]+$/ и разберём слева направо:
| Символ | Что значит |
|---|---|
^ | «начало строки» — до этого символа ничего не должно быть |
[A-Z] | ровно один символ из диапазона A…Z (заглавная) |
[a-z]+ | одна или много строчных букв подряд |
$ | «конец строки» — после этого ничего |
Вместе: «слово из одной заглавной и набора строчных» — то, что подходит под Codestack, но не под codestack или Codestack2024. Никакой логики, никаких условий — просто описание формы.
Три базовых строительных блока
1. Литералы — обычные символы, которые ищут самих себя: abc матчится к «abc». Специальные символы (. * + ? ( ) [ ] { } \ | ^ $) приходится экранировать слэшем, если вы хотите найти их буквально: \. — точка как знак препинания, а не «любой символ».
2. Классы символов — «один символ из набора»:
.— любой символ, кроме перевода строки (не «любая буква» — именно любой, включая пробел).\d— цифра,\D— не цифра;\w— буква/цифра/подчёркивание,\W— наоборот;\s— пробельный,\S— непробельный.[abc]— одна из трёх букв;[a-z]— диапазон;[^0-9]— «всё, кроме цифры» (карет внутри скобок = отрицание).
3. Квантификаторы — «сколько раз повторить предыдущий атом»:
*— 0 или больше;+— 1 или больше;?— 0 или 1.{n}— ровно n;{n,}— не меньше n;{n,m}— от n до m.
a{2,4} ловит aa, aaa, aaaa, но не a и не aaaaa.
Жадность: почему <.+> захватил весь файл
По умолчанию квантификаторы жадные: берут максимально длинное совпадение. Шаблон <.+>, применённый к строке <b>текст</b> <i>ещё</i>, захватит всё от первого < до последнего > — то есть и теги, и текст между ними. Причина: .+ тянет до конца строки, потом откатывается назад ровно до ближайшего >.
Лечение — ленивый (non-greedy) режим: .+?. Тогда движок берёт минимум и останавливается на первом подходящем >. А ещё точнее — не «что угодно между тегов», а «символы, не являющиеся >»: <[^>]+>. Это дешевле вычислительно и не перескакивает через закрытие тега.
Чек-лист части
- Умею вслух прочитать простой шаблон символ за символом (`^`, класс, `+`, `$`).
- Знаю, что `.` — это «любой символ», а не «точка», и что спец-символы экранируются `\.`.
- Помню, что `*`/`+`/`?` жадные по умолчанию, а `?` после них и `[^>]` — способ это сдержать.
- Могу отличить `\d` (цифра) от `[0-9]` (то же самое) и понять, когда удобнее класс-отрицание `[^…]`.
Часть II. Группы, границы и альтернативы — рабочий уровень
Одного «ищи такой шаблон» мало: обычно нужно ещё и вытащить часть совпадения, сопоставить варианты или привязаться к краю слова.
Группировка и захват
(...) делает две вещи: группирует (чтобы повесить квантификатор на кусок) и захватывает — сохраняет найденное в подгруппу, доступную потом как $1, \1 или .group(1).
(\d{3})-(\d{2})-(\d{2})
На 070-12-34 это даст группу 1 = 070, группу 2 = 12, группу 3 = 34. Так извлекают телефон, дату, ID из строки.
Если группировать нужно, но не захватывать (просто повесить ? или | на кусок) — берём non-capturing (?:…):
(?:https?|ftp)://
Это матчится к http://, https://, ftp://, но ничего не кладёт в $1 — экономит и память, и путаницу с номерами групп.
Именованные группы
(?<имя>…) даёт подгруппе имя вместо номера — код читается сам:
// PHP (PCRE): имя группы
$pattern = '/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u';
if (preg_match($pattern, $date, $m)) {
echo $m['year']; // 2026 — по имени, а не по $m[1]
}// JavaScript: именованные группы
const re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u;
const m = '2026-10-20'.match(re);
console.log(m.groups.year); // 2026Границы строки и слова
^/$— начало / конец строки (или с флагомm— начало/конец каждой строки внутри многострочного текста).\b— граница слова (переход «буква ↔ не-буква»)./cat\b/матчится кcat,cats,the cat., но не кcaterpillar— потому что послеcatидётe, а не граница. Это главный способ найти целое слово, а не его кусок внутри другого.
Альтернатива |
| означает «или», и работает на всю ширину группировки вокруг. /red|blue/ ловит red и blue. Но ^red|blue$ — это не «строка из red или blue», а «начинается с red» ИЛИ «заканчивается на blue». Чтобы ограничить выбор, группируют: ^(?:red|blue)$.
Разбор реальной валидации email по кусочкам
Часто просят «правильное regex для email». Честный ответ: полностью соответствующий RFC 5322 шаблон огромен и неподдерживаем, поэтому в прикладном коде берут практичный шаблон, покрывающий 99% реальных адресов:
^[\w.+-]+@[\w-]+\.[\w.-]+$
Читаем вслух:
^— с начала строки;[\w.+-]+— локальная часть: буква/цифра/_, точка,+,-(именно поэтомуa.b+c@…пройдёт);@— обязательная собака;[\w-]+— имя домена первого уровня;\.— буквальная точка (экранирована!);[\w.-]+$— доменная зона и её поддомены, до конца строки.
Он не ловит user@localhost (нет точки в домене) и не проверяет, что домен существует. Для формы регистрации этого достаточно; для «доставляем ли мы письмо» — только отправка подтверждением.
Чек-лист части
- Знаю разницу `(…)`, `(?:…)` и `(?<name>…)` и не забываю не-захватывающую группу, когда она не нужна.
- Использую `\b`, чтобы не найти `cat` внутри `category`.
- Группирую альтернативу, если она должна быть в начале или в конце: `^(?:a|b)$`, а не `^a|b$`.
- Экранирую спец-символы в литералах: `\.` для точки, `\/` для слэша внутри `/…/`.
Часть III. Где regex живёт в вашем стеке
Regex не «учебная тема». Он уже в вашем проекте — в четырёх местах.
PHP: preg_* с модификаторами
// Найти все совпадения
preg_match_all('/#(\w+)/u', $post, $m); // $m[1] — массив хэштегов без решётки
// Заменить
$safe = preg_replace('/[^a-z0-9-]/u', '', strtolower($title)); // слаг из заголовка
preg_match возвращает 1/0, а не «истину» — и важно отличать false (ошибка, например битый шаблон) от 0 (просто нет совпадения). Модификатор /u включает режим UTF-8 — без него \w и классы работают по байтам, и кириллица ведёт себя неверно (об этом в Части IV). /i — регистронезависимо, /m — ^/$ построчно, /s — точка захватывает и перевод строки.
JavaScript: RegExp и флаги
// Флаг g — найти все, иначе только первое совпадение
const tags = (text.match(/#(\w+)/g) || []);
// matchAll + именованные группы (ES2020+)
for (const m of '2026-10-20'.matchAll(/(?<y>\d{4})-(?<mo>\d{2})/gu)) {
console.log(m.groups.y);
}
Важно: regex с флагом g имеет состояние (lastIndex) — у метода .test()/.exec() на одном и том же объекте с g второй вызов начнёт не с нуля, а с продолжения. Частый баг «валидация то проходит, то нет». Для проверки «соответствует ли строка целиком» создавайте regex заново или сбрасывайте lastIndex.
Bash: grep -P, sed, поиск файлов
# найти строки, похожие на IPv4 (быстрый черновой фильтр)
grep -Po '\b(?:\d{1,3}\.){3}\d{1,3}\b' access.log
-E — расширенный синтаксис (+/?/| без слэшей), -P — PCRE (то самое движок-«взрослый»). Именно этот фильтр вы уже писали в статье про разбор логов и диагностику.
Nginx: location ~ — regex уже в проде
# обслуживать статические файлы по расширению — это regex-сопоставление
location ~* \.(?:css|js|png|jpg|jpeg|webp|svg|woff2)$ {
expires 30d;
access_log off;
}
~ — регистр важен, ~* — нет. Это тот же класс движка, что и в PHP (PCRE). Прочитать и починить такой конфиг целиком — задача отдельного инструмента-аудитора, который мы разбирали в статье про nginx. Регулярка — «секретный ингредиент», который вы уже трогали руками.
Чек-лист части
- Помню, что в PHP без `/u` классы работают по байтам, а в JS regex с `g` имеет скрытое состояние.
- Могу написать grep-фильтр логов через `-E`/`-P`.
- Узнаю regex в `location ~` nginx и понимаю `~` против `~*`.
- Знаю имена функций: `preg_match`/`preg_match_all`/`preg_replace`, `match`/`matchAll`.
Часть IV. Грабли: почему «работает в тесте» и ломается в проде
Повторяемые беды regex предсказуемы. Каждая со своим рецептом.
1. Катастрофический backtracking (самая дорогая)
Когда шаблон содержит вложенные квантификаторы над перекрывающимися символами — (a+)+, (\s*)*, (?:[\w.]+)+ — движок на несовпадающей длинной строке перебирает экспоненциальное число вариантов. Результат: CPU 100%, запрос висит, сайт лежит.
// Плохо: может улететь в экспоненциальный перебор на specially crafted вводе
$re = '/^(\w+\s?)*$/';
// Лучше: атомарная/обладательная скобка или простой безвложенный шаблон
$re = '/^[\w]+(?:[\s]+[\w]+)*$/u';
Защита: не вкладывать квантификатор в квантификатор над тем же классом символов; ограничивать длину входа до матчинга (регулярка не должна запускаться на megabyte-строку из формы); в PHP ставить pcre.backtrack_limit; для недоверенного ввода вообще не брать паттерны с вложенными звёздочками.
2. Юникод без /u — «успешно» ломает кириллицу
/\w+/ без /u матчится по байтам: буква й в UTF-8 — это два байта, и \w их не возьмёт. Кириллический текст «не совпадает», хотя глазами совпадает; . съедает байт и строка рвётся.
Решение: модификатор /u в PHP и флаг u в JS — всегда, когда данные UTF-8. Классы вроде [а-яё] пишите диапазоном, а не буквами поштучно, и помните, что ё часто забывают — [а-яё], а не [а-я].
3. Точка и слэш: два экранирования сразу
user.name@ — шаблон user.name матчится и к userXname, потому что . = «любой символ». Буквальную точку надо писать \..
А если ваш паттерн содержит / (URL-пути), то внутриDelimiter /…/ слэш надо экранировать \/ (или выбрать другой разделитель). Это не про синтаксис regex — про то, что в одном и том же паттерне иногда нужно и «экранировать спец-символ», и «не сломать разделитель».
4. Подмена: $1 против \1
Разные движки по-разному ссылается на группы при замене:
- PHP
preg_replace(': '$1'и'\\1'работают, но$1— предпочтительно. - JS
replace/replaceAll:$1,$<name>для именованных. - sed:
\1.
Ошибка «хочу вставить группу, а получила литерал $1» случается, когда $ ещё и спец-символ в контексте (bash-строка в двойных кавычках). Пишитеreplacement в одинарных кавычках или экранируйте.
5. Многострочность: ^/$ не там, где думали
По умолчанию ^ = начало всей строки, а . не матчится к \n. Проверяете многострочное поле «каждая строка — это email» и получаете «совпало всё разом» — забыли /m (в JS m), а для «точка = любой символ, включая перевод» нужен /s (в JS — класс [\s\S], флага s мало в старых средах).
6. «Работает в тесте» ≠ «работает на всех данных»
Тест обычно содержит три аккуратные строки. Прод содержит null, пустую строку, строку из пробелов, эмодзи (surrogate pair в JS!), \r\n с Windows и однострочный SQL-инъекционный мусор. Валидный шаблон обязан проходить не только «что должно», но и «что не должно» — для этого и нужныпозитивные и негативные примеры из Части V.
Чек-лист граблей этой части
- Никаких `(x+)+`, `(?:[…]+)+` над недоверенным вводом — защита от backtracking.
- UTF-8 данные → `/u` (PHP) / `u`-флаг (JS) всегда.
- Буквальные `.` и `/` — экранированы; `ё` включена в кириллический диапазон.
- Помню про `/m` (построчные `^$`) и `/s` (точка через `\n`).
- Ссылки на группы в заменях — в правильном синтаксисе движка (`$1`/`\1`).
Часть V. Конструирование по шагам
Хорошая регулярка почти никогда не пишется с одного удара. Она собирается итерацией, и вот как это делать быстро.
Метод четырёх вопросов
Задача: «найти в тексте все хэштеги вида #кодстек — слова, начинающиеся с решётки».
- Что такое «атом»? Одно слово после решётки: буквы/цифры/подчёркивание, в UTF-8 —
[\p{L}\p{N}_]+(буквы/цифры любой письменности +_). - Что перед ним и за ним? Перед —
#, после — пробел, конец или пунктуация → граница\bили просто «не слово» справа. - Сколько раз? Ищем все → глобальный поиск, не якорим
^…$. - Что НЕ должно ловиться? Одиночная
#,#(пробел),123без решётки.
Черновик: /(?<tag>#[\p{L}\p{N}_]+)/u. Прогоняем позитивы и негативы (см. ниже), при необходимости добавляем ограничение «в слове есть хотя бы одна не-цифра» и т.д.
3 позитива и 3 негатива — обязательный минимум
Для любого шаблона проверяют не только «что должно совпасть», но и «что обязано НЕ совпасть». Для валидатора email из Части II:
| Должно совпасть (позитив) | Не должно совпасть (негатив) |
|---|---|
anna@mail.ru | anna@ (нет домена) |
a.b+c@sub.example.org | @mail.ru (пустая локальная часть) |
ivan_2024@codestack.test | a b@mail.ru (пробел) |
Если негатив пролез — шаблон слишком широкий; если позитив не поймал — слишком узкий. Эта таблица из трёх+трёх строк ловит большинство ошибок валидации до того, какregex уехал в прод.
Инструмент, а не магия
Когда намерение уже сформулировано словами («слаг из заголовка», «только строки с IPv4», «email без поддомена») — это ровно то, что можно отдать генератору: он вернёт шаблон под нужный диалект (PCRE/JS/Python), а вам останется прогнать свои позитивы и негативы из таблицы выше. Главное — не принимать regex на веру без проверки: генератор снимает рутину «вспомнить синтаксис», но не снимает ответственность «понять, что он нашёл».
Чек-лист части
- Иду по четырём вопросам: атом → окружение → сколько раз → что отсекаем.
- Проверяю минимум 3 позитива и 3 негатива до того, как вставлю шаблон в код.
- Формулирую намерение словами **до** того, как писать или генерировать regex.
Часть VI. Практика под WordPress
Свяжем всё с реальной задачей на сайте — три паттерна, которые встречаются в плагинах и темах.
1. Валидация и нормализация user-ввода
Slug из заголовка, который пишет пользователь:
function cs_slugify(string $title): string {
$title = mb_strtolower($title, 'UTF-8'); // кириллица тоже
$title = str_replace('ё', 'е', $title); // ё → е
// оставляем буквы/цифры/пробелы/дефис — остальное выкидываем
$title = preg_replace('/[^\p{L}\p{N}\s-]+/u', '', $title);
$title = preg_replace('/[\s-]+/u', '-', $title); // пробелы/дефисы в один дефис
return trim($title, '-');
}
\p{L} — категория «любая буква» из Unicode — единственно правильный способ для UTF-8 (кириллица, греческий, эмодзи-модификаторы). Без /u и без \p{…} кириллический заголовок схлопнется в пустую строку.
2. Вытащить данные из лога (смычка к диагностике)
В статье про мониторинг уровня ОС и диагностику мы читали journalctl. Regex — то, чем вы потом фильтруете этот вывод:
# ошибки PHP-FPM: строки с "[ERROR] … pid ..." → вытащить pid и сообщение
journalctl -u php8.2-fpm --no-pager \
| grep -Po '\[ERROR\].*?(pid \d+)'
.*? здесь — ленивый, чтобы не захватить через несколько сообщений на одной длинной строке; \d+ — цифры pid. Тот же приём, что в Части I про <.+>, только наоборот полезный.
3. Разрешить/запретить путь в правиле nginx
# закрыть служебные файлы WordPress от внешнего доступа
location ~ /\. (?:ht|git|env) {
deny all;
}
~ — регистрозависимый regex, \. — буквальная точка, (?:ht|git|env) — не-захватывающая альтернатива. Обратите внимание: вы пишете то же самое, что в PHP и в grep — просто в другом файле. Поняв regex один раз, вы переносите навык между всеми четырьмя стеками из Части III.
Чек-лист части
- Для user-ввода нормализую через `preg_replace` с `\p{L}\p{N}` и `/u`, а не через `str_replace` букв поштучно.
- Фильтрую логи через `grep -E`/`-P` и умею извлечь поля группами.
- Узнаю regex в nginx-правиле и понимаю, почему там `\.` и `(?:…)`.
Что дальше
Регулярки дают вам один суперскилл: надежно сопоставлять и извлекать текст — от слага в WordPress до фильтра логов и правила в nginx. Это мост сразу к двум темам серии. Первая — юнит-тесты: теперь, когда вы проверяете шаблон на 3 позитивах и 3 негативах (Часть V), следующий логичный шаг — оформить такие проверки как тесты и гонять их автоматически при каждом изменении кода. Вторая — разбор диффов и кодревью:regex часто чинят не «на коленке», а ревьюя чужую регулярку в PR — там свои блокеры (backtracking, молчаливые расхождения движков).
Материал написан простым языком; любой англоязычный термин (regex, движок, матч, литерал, класс, квантификатор, жадность/backtracking, подгруппа, якорь/граница, диалект/flavor, PCRE, модификатор/флаг) объясняется при первом употреблении. Примеры — для PHP 8 (PCRE), современных браузерах (ECMAScript 2020+), GNU grep и nginx. Имена юнитов (
php8.2-fpmпротивphp8.3-fpm), домены и пути (example.test,/var/www/example.test) условные — подставляйте свои. Полная email-валидация по RFC 5322 сознательно не приводится: практичный шаблон Части II покрывает реальные адреса, а не весь стандарт. Тонкие механизмы (lookahead/lookbehind, атомарные и possessive-скобки,\K, backreferences в substitution) упомянуты на уровне «существуют» — это следующий слой, когда базовые шаблоны уже собираются уверенно.
Инструменты под задачу
Сформулировали намерение словами («найти все IPv4», « slug из заголовка», «только строки с email») — дайте его генератору: он соберёт regex под нужный диалект (PCRE / JavaScript / Python) и объяснит каждую группу. А если чужая регулярка непонятна или «почему-то не матчит» — вставьте её в режим «Объяснить», и движок разберёт её вслух и покажет, где именно ломается.
Генератор не заменяет понимание из Части IV: catastrophic backtracking и «юникод без /u» он тоже ловит, но вы отвечаете за то, чтобы прогнать 3 позитива и 3 негатива из Части V, прежде чем вставлять шаблон в прод.
👤 Зарегистрируйтесь, чтобы добавить материал в закладки.
