Регулярные выражения в Rust с крейтом regex
Регулярное выражение (regex, «регулярка») — это крошечный язык шаблонов для текста. Вместо «найди слово cat» можно сказать «найди четыре цифры, дефис, две цифры, дефис, две цифры» — и разом найти все даты вида 2024-05-17.
В стандартной библиотеке Rust есть удобные методы строк вроде contains, split и find (см. Строки). Когда конкретного кусочка текста мало и нужна форма текста, бери крейт regex (на английском). Его поддерживает команда Rust, и он очень быстрый.
Добавляем regex и находим первое совпадение
Заголовок раздела «Добавляем regex и находим первое совпадение»В папке проекта выполни:
cargo add regexКоманда добавит regex = "1" в раздел [dependencies] файла Cargo.toml. (Крейты пока в новинку? Загляни в урок Cargo и крейты.) Кнопки «Попробуй сам» открывают Rust Playground, где regex уже есть.
use regex::Regex;
fn main() { let re = Regex::new(r"\d{4}-\d{2}-\d{2}").unwrap();
println!("{}", re.is_match("Вышла 2024-05-17.")); println!("{}", re.is_match("Вышла прошлой весной."));}Вывод:
truefalse\d означает «любая цифра», а {4} — «ровно четыре раза то, что стоит перед этим». is_match отвечает на один вопрос: есть ли совпадение где-нибудь в тексте?
Обрати внимание на r перед шаблоном: r"\d{4}". Это сырая строка (raw string). В обычной строке Rust символ \ начинает escape-последовательность вроде \n (перевод строки). В шаблонах регулярок полно обратных слешей, и сырые строки избавляют от необходимости писать "\\d{4}". Всегда пиши шаблоны сырыми строками.
Почему Regex::new возвращает Result
Заголовок раздела «Почему Regex::new возвращает Result»Шаблон — это маленькая программа, и в нём бывают ошибки, например незакрытая скобка. Regex::new проверяет шаблон и возвращает Result: Ok(Regex), если он правильный, или Err с понятным сообщением, если нет:
use regex::Regex;
fn main() { match Regex::new(r"(\d+") { Ok(_) => println!("шаблон правильный"), Err(e) => println!("ошибка в шаблоне:\n{e}"), }}Вывод:
ошибка в шаблоне:regex parse error: (\d+ ^error: unclosed groupСообщение от крейта остаётся на английском: он показывает, где ошибка, и говорит, что группа не закрыта (unclosed group).
Когда шаблон написан прямо в коде и ты знаешь, что он правильный, .unwrap() вполне подходит: если ошибёшься, узнаешь об этом при первом же запуске. Когда шаблон приходит от пользователя (например, из поля поиска), обработай Err, а не роняй программу.
Ищем совпадения: find и find_iter
Заголовок раздела «Ищем совпадения: find и find_iter»find возвращает первое совпадение в виде Option. find_iter даёт итератор по всем совпадениям:
use regex::Regex;
fn main() { let re = Regex::new(r"\d+").unwrap(); let text = "3 яблока, 12 груш и 7 слив";
if let Some(m) = re.find(text) { println!("первое число: {} (байты {}..{})", m.as_str(), m.start(), m.end()); }
let all: Vec<&str> = re.find_iter(text).map(|m| m.as_str()).collect(); println!("все числа: {:?}", all);
let total: u32 = re.find_iter(text).map(|m| m.as_str().parse::<u32>().unwrap()).sum(); println!("всего фруктов: {total}");}Вывод:
первое число: 3 (байты 0..1)все числа: ["3", "12", "7"]всего фруктов: 22\d+ означает «одна или больше цифр», поэтому 12 находится одним совпадением, а не двумя. Каждое совпадение знает свой текст (as_str()) и своё положение (start() и end(), в байтах).
Группы захвата и именованные группы
Заголовок раздела «Группы захвата и именованные группы»Часто нужно не всё совпадение целиком, а его части. Возьми часть шаблона в круглые скобки, чтобы её захватить (capture). Дай ей имя через (?<name>...), чтобы код читался легче:
use regex::Regex;
fn main() { let re = Regex::new(r"(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})").unwrap(); let text = "Резервные копии сделаны 2024-05-17 и 2024-06-02.";
if let Some(caps) = re.captures(text) { println!("всё совпадение: {}", &caps[0]); println!("год: {}", &caps["year"]); println!("месяц: {}", &caps[2]); println!("день: {}", caps.name("day").unwrap().as_str()); }
for caps in re.captures_iter(text) { println!("{}.{}.{}", &caps["day"], &caps["month"], &caps["year"]); }}Вывод:
всё совпадение: 2024-05-17год: 2024месяц: 05день: 1717.05.202402.06.2024Три способа прочитать группу:
caps[0]— это всегда всё совпадение. Группы нумеруются с 1, слева направо:caps[2]— это месяц.caps["year"]читает группу по имени. Обращение через[...]вызывает панику, если эта группа не участвовала в совпадении.caps.name("day")возвращаетOption— это безопасный выбор, когда группа необязательная.
captures находит первое совпадение. captures_iter проходит по всем совпадениям, точно как find_iter.
Заменяем текст
Заголовок раздела «Заменяем текст»replace меняет первое совпадение, а replace_all — все. В тексте замены $name (или $1) вставляет то, что захватила группа:
use regex::Regex;
fn main() { let spaces = Regex::new(r"\s+").unwrap(); println!("{}", spaces.replace_all("слишком много пробелов", " "));
let date = Regex::new(r"(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})").unwrap(); let text = "С 2024-05-17 по 2024-06-02"; println!("{}", date.replace(text, "$d.$m.$y")); println!("{}", date.replace_all(text, "$d.$m.$y")); println!("{}", date.replace_all(text, "$m_$y")); println!("{}", date.replace_all(text, "${m}_${y}"));}Вывод:
слишком много пробеловС 17.05.2024 по 2024-06-02С 17.05.2024 по 02.06.2024С 2024 по 2024С 05_2024 по 06_2024Посмотри на четвёртую строку. Там напечаталось С 2024 по 2024, а не 05_2024. Почему? Имя группы может содержать _, поэтому $m_ прочиталось как группа с именем m_. Такой группы нет, и на её месте оказалась пустая строка. Возьми имя в фигурные скобки, ${m}_, чтобы точно указать, где оно заканчивается. Если сомневаешься — ставь скобки.
\s+ означает «один или больше пробельных символов», так что замена их одним пробелом — классический способ привести в порядок неаккуратный ввод.
Разбиваем текст
Заголовок раздела «Разбиваем текст»split режет строку везде, где совпадает шаблон. Это как str::split, только разделителем может быть шаблон:
use regex::Regex;
fn main() { let sep = Regex::new(r"\s*[,;]\s*").unwrap(); let parts: Vec<&str> = sep.split("яблоко, банан;вишня , финик").collect(); println!("{:?}", parts);
let two: Vec<&str> = sep.splitn("a, b, c, d", 2).collect(); println!("{:?}", two);}Вывод:
["яблоко", "банан", "вишня", "финик"]["a", "b, c, d"][,;] означает «запятая или точка с запятой», а \s* с каждой стороны съедает пробелы вокруг. splitn(text, 2) останавливается после двух кусков.
Компилируй один раз, а не в цикле
Заголовок раздела «Компилируй один раз, а не в цикле»Создание Regex — это медленная часть: крейт превращает шаблон в эффективный механизм поиска. А вот использование — быстрое. Поэтому никогда не вызывай Regex::new внутри цикла или функции, которая выполняется часто. Создай один раз и используй повторно.
Для шаблона, который нужен по всей программе, положи его в static с std::sync::LazyLock. Он создастся при первом использовании, а потом будет общим:
use regex::Regex;use std::sync::LazyLock;
static EMAIL: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"(?i)^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$").unwrap()});
fn is_email(s: &str) -> bool { EMAIL.is_match(s)}
fn main() { println!("{input:22} -> {}", is_email(input)); }}Вывод:
[email protected] -> true[email protected] -> truenot-an-email -> falsex@y -> falseЧто нового в этом шаблоне:
(?i)включает поиск без учёта регистра, поэтому[a-z]совпадает и сB, и сM.^и$означают «начало текста» и «конец текста». Без нихis_matchответил бы «да» и для"hello [email protected]!!", ведь адрес встречается там где-то.\.— это настоящая точка. Обычная.означает «любой символ».
Это проверка «похоже на email» — она хорошо ловит опечатки. А по-настоящему проверить регуляркой любой допустимый адрес почты печально известно как трудно, так что единственная настоящая проверка — отправить на него письмо.
Пример: достаём данные из строк лога
Заголовок раздела «Пример: достаём данные из строк лога»Соберём всё вместе. Мы читаем лог, пропускаем строки, которые не подходят под формат, а для каждой строки ERROR печатаем дату, время и все адреса почты из сообщения:
use regex::Regex;use std::sync::LazyLock;
static LOG_LINE: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"^(?<date>\d{4}-\d{2}-\d{2}) (?<time>\d{2}:\d{2}:\d{2}) (?<level>[A-Z]+) +(?<msg>.*)$").unwrap()});
static EMAIL: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}").unwrap()});
fn main() { let log = "\2024-05-17 09:12:03 INFO пользователь [email protected] вошёл2024-05-17 09:15:44 ERROR не прошёл платёж для [email protected]эта строка — мусор2024-05-18 11:02:10 WARN диск заполнен на 91%
let mut errors = 0; for line in log.lines() { let Some(caps) = LOG_LINE.captures(line) else { println!("пропущено: {line:?}"); continue; }; if &caps["level"] == "ERROR" { errors += 1; let emails: Vec<&str> = EMAIL.find_iter(&caps["msg"]).map(|m| m.as_str()).collect(); println!("{} {} -> {:?}", &caps["date"], &caps["time"], emails); } } println!("найдено ошибок: {errors}");}Вывод:
2024-05-17 09:15:44 -> ["[email protected]"]пропущено: "эта строка — мусор"2024-05-18 11:05:00 -> ["[email protected]", "[email protected]"]найдено ошибок: 2Несколько замечаний:
"\в началеlog— это трюк Rust: обратный слеш в конце строки внутри строкового литерала пропускает перевод строки, так что первая строка лога начинается сразу.let Some(caps) = ... else { ... }— это let-else: если строка не совпала, мы печатаем её и черезcontinueпереходим к следующей.(?<level>[A-Z]+) +совпадает с уровнем, а затем с одним или несколькими пробелами, потому что послеINFOиWARNстоит лишний пробел — для выравнивания сERROR.- В настоящей программе ты читал бы лог из файла, строку за строкой. См. Работа с файлами.
Чего regex не умеет (специально)
Заголовок раздела «Чего regex не умеет (специально)»Если ты пользовался регулярками в JavaScript, Python или Perl, рука может потянуться к просмотру вперёд и назад (look-around: (?=...), (?<=...)) или к обратным ссылкам (backreferences: \1, «тот же текст, что в группе 1»). Крейт regex их не поддерживает и прямо об этом говорит:
regex parse error: foo(?=bar) ^^^error: look-around, including look-ahead and look-behind, is not supportedКрейт сообщает, что просмотр вперёд и назад не поддерживается. Это сознательный компромисс. С этими возможностями некоторые шаблоны на определённых входных данных работают экспоненциально долго, так что короткий шаблон и достаточно длинная строка могут подвесить программу (это называется катастрофическим возвратом, catastrophic backtracking, и он уже ронял настоящие сайты). Без них крейт regex может гарантировать, что время поиска растёт только пропорционально длине текста (умноженной на размер шаблона). Вдвое больше текста — примерно вдвое больше работы, и никакого взрыва. Поэтому он безопасен даже для шаблонов и текста, которые приходят от пользователей.
Обычно того же результата можно добиться по-другому: взять группу захвата и прочитать только нужную часть или сделать вторую простую проверку обычным кодом на Rust. Если эти возможности очень нужны, их добавляет крейт fancy-regex (на английском) — но уже без гарантии по времени.
Шпаргалка
Заголовок раздела «Шпаргалка»| Шаблон | Совпадает с |
|---|---|
. |
любым символом, кроме перевода строки |
\d \w \s |
цифрой, «словесным» символом (буква, цифра, _), пробельным символом |
\D \W \S |
противоположным: не цифрой, не словесным символом, не пробельным |
[abc] [a-z] [^0-9] |
одним из a, b, c; любой строчной латинской буквой; чем угодно, кроме цифры |
* + ? |
ноль или больше, один или больше, ноль или один раз предыдущий элемент |
{3} {2,5} {2,} |
ровно 3, от 2 до 5, не меньше 2 |
^ $ |
началом и концом текста |
\b |
границей слова (между словесным и несловесным символом) |
(...) (?<name>...) |
нумерованной группой, именованной группой |
(?i) |
без учёта регистра, начиная с этого места |
И ещё одно: вертикальная черта означает «или». Например, cat|dog совпадает с любым из двух слов, а gr(a|e)y — с gray и grey. Чтобы найти специальный символ как есть, поставь перед ним обратный слеш: \., \(, \$.
Частые ошибки
Заголовок раздела «Частые ошибки»Забыть про сырую строку. В обычной строке \d — недопустимая escape-последовательность, поэтому Rust останавливается ещё до того, как крейт regex увидит шаблон:
use regex::Regex;
fn main() { let re = Regex::new("\d+").unwrap(); println!("{}", re.is_match("id=22"));}error: unknown character escape: `d` --> src/main.rs:4:27 |4 | let re = Regex::new("\d+").unwrap(); | ^ unknown character escape |help: if you meant to write a literal backslash (perhaps escaping in a regular expression), consider a raw string literal |4 | let re = Regex::new(r"\d+").unwrap(); | +Компилятор говорит, что не знает escape-последовательности \d, и даже подсказывает решение: добавь r в начало.
Создавать регулярку внутри цикла. Это компилируется и работает, но медленно, потому что шаблон компилируется заново для каждой строки:
for line in lines { let re = Regex::new(r"\d+").unwrap(); // компилируется снова и снова if re.is_match(line) { /* ... */ }}Перенеси Regex::new над циклом или в static с LazyLock, как показано выше.
Забыть ^ и $ при проверке. is_match ищет шаблон где угодно. Чтобы убедиться, что весь ввод имеет нужную форму, закрепи шаблон с обоих концов.
- Добавь крейт через
cargo add regexи пиши шаблоны сырыми строками:r"\d+". Regex::newвозвращаетResult, потому что шаблон может быть неправильным.is_matchотвечает «да» или «нет»;find/find_iterдают найденный текст;captures/captures_iterдают группы по номеру или по(?<name>...).replaceиreplace_allвставляют группы через$name; пиши${name}, если за именем идут буквы или_.- Компилируй шаблон один раз и используй повторно, например в
staticсLazyLock. - Никакого look-around и обратных ссылок — так задумано: взамен поиск всегда идёт за линейное время.
Проверь себя
Заголовок раздела «Проверь себя»Проверь себя
1. Почему шаблоны регулярок в Rust обычно начинаются с r, как в r"\d+"?
2. Что даст caps["m"] для текста 2024-05 с шаблоном (?<y>\d{4})-(?<m>\d{2})?
3. Почему крейт regex не поддерживает look-around и обратные ссылки?
4. Где лучше всего вызывать Regex::new для шаблона, который применяется к каждой строке большого файла?
Результат: 0 / 4
Что почитать
Заголовок раздела «Что почитать»- Документация крейта
regex— docs.rs (на английском): официальное руководство с множеством примеров и полным описанием синтаксиса. - API
Regex— docs.rs (на английском): все методы, включаяcaptures_iter,replacenиsplit. LazyLock— документация стандартной библиотеки (на английском): как работают статические значения с ленивой инициализацией.- Regular Expressions — Rust Cookbook (на английском): короткие рецепты, как извлечь хештеги, телефонные номера, адреса почты и многое другое.