Перейти к содержимому

Регулярные выражения в Rust с крейтом regex

Регулярное выражение (regex, «регулярка») — это крошечный язык шаблонов для текста. Вместо «найди слово cat» можно сказать «найди четыре цифры, дефис, две цифры, дефис, две цифры» — и разом найти все даты вида 2024-05-17.

В стандартной библиотеке Rust есть удобные методы строк вроде contains, split и find (см. Строки). Когда конкретного кусочка текста мало и нужна форма текста, бери крейт regex (на английском). Его поддерживает команда Rust, и он очень быстрый.

Добавляем regex и находим первое совпадение

Заголовок раздела «Добавляем regex и находим первое совпадение»

В папке проекта выполни:

Terminal window
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("Вышла прошлой весной."));
}

Вывод:

true
false
▶ Попробуй сам

\d означает «любая цифра», а {4} — «ровно четыре раза то, что стоит перед этим». is_match отвечает на один вопрос: есть ли совпадение где-нибудь в тексте?

Обрати внимание на r перед шаблоном: r"\d{4}". Это сырая строка (raw string). В обычной строке Rust символ \ начинает escape-последовательность вроде \n (перевод строки). В шаблонах регулярок полно обратных слешей, и сырые строки избавляют от необходимости писать "\\d{4}". Всегда пиши шаблоны сырыми строками.

Шаблон — это маленькая программа, и в нём бывают ошибки, например незакрытая скобка. 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 возвращает первое совпадение в виде 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
день: 17
17.05.2024
02.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() {
for input in ["[email protected]", "[email protected]", "not-an-email", "x@y"] {
println!("{input:22} -> {}", is_email(input));
}
}

Вывод:

not-an-email -> false
x@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%
2024-05-18 11:05:00 ERROR не удалось отправить письмо [email protected] и [email protected]";
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.
  • В настоящей программе ты читал бы лог из файла, строку за строкой. См. Работа с файлами.

Если ты пользовался регулярками в 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