Lewati ke konten

Regular Expression di Rust dengan regex

Regular expression (disingkat regex) adalah bahasa pola mini untuk teks. Alih-alih bilang “cari kata kucing”, kamu bisa bilang “cari empat digit, tanda hubung, dua digit, tanda hubung, dua digit”, lalu menemukan semua tanggal seperti 2024-05-17 sekaligus.

Standard library Rust punya method string yang praktis seperti contains, split, dan find (lihat String). Kalau potongan teks yang tetap tidak cukup, dan kamu butuh sebuah bentuk teks, pakai crate (library Rust) regex. Crate ini dikelola oleh proyek Rust dan sangat cepat.

Menambahkan regex dan pencocokan pertamamu

Section titled “Menambahkan regex dan pencocokan pertamamu”

Di folder proyekmu, jalankan:

Terminal window
cargo add regex

Perintah itu menambahkan regex = "1" di bawah [dependencies] dalam Cargo.toml. (Baru kenal crate? Lihat Cargo dan crate.) Tombol “Coba sendiri” membuka Rust Playground, yang sudah menyediakan regex.

use regex::Regex;
fn main() {
let re = Regex::new(r"\d{4}-\d{2}-\d{2}").unwrap();
println!("{}", re.is_match("Dirilis pada 2024-05-17."));
println!("{}", re.is_match("Dirilis tahun lalu."));
}

Output:

true
false
▶ Coba sendiri

\d artinya “digit apa saja” dan {4} artinya “tepat empat kali dari hal sebelumnya”. is_match menjawab satu pertanyaan: adakah yang cocok di mana saja dalam teks?

Perhatikan huruf r sebelum pola: r"\d{4}". Itu adalah raw string. Di string Rust biasa, \ memulai escape seperti \n (baris baru). Pola regex penuh dengan backslash, jadi raw string menyelamatkanmu dari menulis "\\d{4}". Selalu pakai raw string untuk pola.

Sebuah pola itu seperti program kecil, dan bisa saja ada kesalahan di dalamnya, misalnya kurung yang lupa ditutup. Regex::new memeriksa pola dan mengembalikan Result: Ok(Regex) kalau valid, atau Err dengan pesan yang membantu kalau tidak:

use regex::Regex;
fn main() {
match Regex::new(r"(\d+") {
Ok(_) => println!("pola valid"),
Err(e) => println!("pola salah:\n{e}"),
}
}

Output:

pola salah:
regex parse error:
(\d+
^
error: unclosed group
▶ Coba sendiri

Pesan error dari crate regex tetap berbahasa Inggris: unclosed group artinya ada grup (kurung buka) yang belum ditutup, dan tanda ^ menunjuk posisinya.

Kalau polanya ditulis langsung di kodemu dan kamu yakin benar, .unwrap() tidak masalah: kalau ternyata salah, kamu akan tahu saat program pertama kali dijalankan. Kalau polanya berasal dari pengguna (misalnya dari kotak pencarian), tangani Err-nya, jangan biarkan program crash.

find mengembalikan kecocokan pertama sebagai Option. find_iter memberimu iterator atas semua kecocokan (lihat Iterator):

use regex::Regex;
fn main() {
let re = Regex::new(r"\d+").unwrap();
let text = "3 apel, 12 pir dan 7 plum";
if let Some(m) = re.find(text) {
println!("angka pertama: {} (di byte {}..{})", m.as_str(), m.start(), m.end());
}
let all: Vec<&str> = re.find_iter(text).map(|m| m.as_str()).collect();
println!("semua angka: {:?}", all);
let total: u32 = re.find_iter(text).map(|m| m.as_str().parse::<u32>().unwrap()).sum();
println!("total buah: {total}");
}

Output:

angka pertama: 3 (di byte 0..1)
semua angka: ["3", "12", "7"]
total buah: 22
▶ Coba sendiri

\d+ artinya “satu digit atau lebih”, jadi 12 ditemukan sebagai satu kecocokan, bukan dua. Setiap kecocokan tahu teksnya (as_str()) dan posisinya (start() dan end(), dalam byte).

Sering kali kamu tidak hanya butuh kecocokannya, tapi juga potongan-potongan di dalamnya. Bungkus sebagian pola dengan tanda kurung untuk menangkap (capture) bagian itu. Beri nama dengan (?<nama>...) supaya kodemu lebih mudah dibaca:

use regex::Regex;
fn main() {
let re = Regex::new(r"(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})").unwrap();
let text = "Backup berjalan pada 2024-05-17 dan 2024-06-02.";
if let Some(caps) = re.captures(text) {
println!("seluruh kecocokan: {}", &caps[0]);
println!("tahun: {}", &caps["year"]);
println!("bulan: {}", &caps[2]);
println!("hari: {}", caps.name("day").unwrap().as_str());
}
for caps in re.captures_iter(text) {
println!("{}/{}/{}", &caps["day"], &caps["month"], &caps["year"]);
}
}

Output:

seluruh kecocokan: 2024-05-17
tahun: 2024
bulan: 05
hari: 17
17/05/2024
02/06/2024
▶ Coba sendiri

Ada tiga cara membaca sebuah grup:

  • caps[0] selalu berisi seluruh kecocokan. Grup diberi nomor mulai dari 1, dari kiri ke kanan: caps[2] adalah bulannya.
  • caps["year"] membaca grup berdasarkan namanya. Mengakses dengan [...] akan panic kalau grup itu tidak ikut dalam kecocokan.
  • caps.name("day") mengembalikan Option, pilihan yang aman kalau grupnya opsional.

captures mencari kecocokan pertama. captures_iter menelusuri setiap kecocokan, sama seperti find_iter.

replace mengganti kecocokan pertama, dan replace_all mengganti setiap kecocokan. Di teks pengganti, $nama (atau $1) menyisipkan apa yang ditangkap oleh sebuah grup:

use regex::Regex;
fn main() {
let spaces = Regex::new(r"\s+").unwrap();
println!("{}", spaces.replace_all("terlalu banyak spasi", " "));
let date = Regex::new(r"(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})").unwrap();
let text = "Dari 2024-05-17 sampai 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}"));
}

Output:

terlalu banyak spasi
Dari 17/05/2024 sampai 2024-06-02
Dari 17/05/2024 sampai 02/06/2024
Dari 2024 sampai 2024
Dari 05_2024 sampai 06_2024
▶ Coba sendiri

Lihat baris keempat. Hasilnya Dari 2024 sampai 2024, bukan 05_2024. Kenapa? Nama grup boleh mengandung _, jadi $m_ dibaca sebagai grup bernama m_. Grup itu tidak ada, jadi hasilnya teks kosong. Taruh namanya di dalam kurung kurawal, ${m}_, untuk menandai dengan jelas di mana nama itu berakhir. Kalau ragu, pakai kurung kurawal.

\s+ artinya “satu karakter whitespace (spasi, tab, baris baru) atau lebih”, jadi menggantinya dengan satu spasi adalah cara klasik untuk merapikan input yang berantakan.

split memotong string di setiap tempat yang cocok dengan pola. Mirip str::split, tapi pemisahnya bisa berupa pola:

use regex::Regex;
fn main() {
let sep = Regex::new(r"\s*[,;]\s*").unwrap();
let parts: Vec<&str> = sep.split("apel, pisang;ceri , kurma").collect();
println!("{:?}", parts);
let two: Vec<&str> = sep.splitn("a, b, c, d", 2).collect();
println!("{:?}", two);
}

Output:

["apel", "pisang", "ceri", "kurma"]
["a", "b, c, d"]
▶ Coba sendiri

[,;] artinya “koma atau titik koma”, dan \s* di kedua sisinya menelan spasi apa pun di sekitarnya. splitn(text, 2) berhenti setelah dua potongan.

Membuat Regex adalah bagian yang lambat: crate ini mengubah polamu menjadi mesin pencocok yang efisien. Sedangkan memakainya cepat. Jadi jangan pernah memanggil Regex::new di dalam perulangan (loop) atau fungsi yang sering dijalankan. Buat sekali, lalu pakai berulang kali.

Untuk pola yang dipakai di seluruh programmu, simpan dalam static dengan std::sync::LazyLock. Pola itu dibuat saat pertama kali dipakai, lalu dibagikan:

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));
}
}

Output:

not-an-email -> false
x@y -> false
▶ Coba sendiri

(Input contohnya sengaja dibiarkan apa adanya, karena {input:22} meratakan kolom berdasarkan panjang teks.)

Bagian baru dalam pola ini:

  • (?i) menyalakan pencocokan yang tidak membedakan huruf besar dan kecil, jadi [a-z] juga cocok dengan B dan M.
  • ^ dan $ artinya “awal teks” dan “akhir teks”. Tanpa keduanya, is_match akan menjawab ya untuk "halo [email protected]!!", karena alamat email itu muncul di suatu tempat di dalamnya.
  • \. adalah titik sungguhan. Titik biasa . artinya “karakter apa saja”.

Ini pengecekan “kelihatannya seperti email”, cocok untuk menangkap salah ketik. Memvalidasi semua alamat email yang sah dengan regex terkenal sangat sulit, jadi satu-satunya tes yang benar-benar pasti adalah mengirim email ke alamat itu.

Ayo gabungkan semuanya. Kita membaca sebuah log, melewati baris yang formatnya tidak sesuai, dan untuk setiap baris ERROR kita cetak tanggal, jam, dan semua alamat email yang ada di pesannya:

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 user [email protected] berhasil login
2024-05-17 09:15:44 ERROR pembayaran gagal untuk [email protected]
baris ini rusak
2024-05-18 11:02:10 WARN disk 91% penuh
2024-05-18 11:05:00 ERROR gagal mengirim email ke [email protected] atau [email protected]";
let mut errors = 0;
for line in log.lines() {
let Some(caps) = LOG_LINE.captures(line) else {
println!("dilewati: {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} error ditemukan");
}

Output:

2024-05-17 09:15:44 -> ["[email protected]"]
dilewati: "baris ini rusak"
2024-05-18 11:05:00 -> ["[email protected]", "[email protected]"]
2 error ditemukan
▶ Coba sendiri

Beberapa catatan:

  • "\ di awal log adalah trik Rust: backslash di akhir baris di dalam string akan melewati karakter baris baru, jadi baris log pertama langsung dimulai.
  • let Some(caps) = ... else { ... } adalah let-else: kalau barisnya tidak cocok, kita cetak lalu continue ke baris berikutnya.
  • (?<level>[A-Z]+) + mencocokkan level lalu satu spasi atau lebih, karena INFO dan WARN diberi satu spasi tambahan supaya sejajar dengan ERROR.
  • Di program sungguhan, kamu akan membaca log dari file, baris demi baris. Lihat File I/O.

Kalau kamu pernah memakai regex di JavaScript, Python, atau Perl, mungkin kamu ingin memakai look-around ((?=...), (?<=...)) atau backreference (\1, “teks yang sama dengan grup 1”). Crate regex tidak mendukung keduanya, dan memberitahumu begini:

regex parse error:
foo(?=bar)
^^^
error: look-around, including look-ahead and look-behind, is not supported

Artinya: look-around, termasuk look-ahead dan look-behind, tidak didukung. Ini pertukaran yang disengaja. Fitur-fitur itu bisa membuat sebagian pola butuh waktu eksponensial pada input tertentu, jadi pola pendek ditambah string yang cukup panjang bisa membuat program macet (ini disebut catastrophic backtracking, dan pernah membuat situs web sungguhan down). Tanpa fitur itu, crate regex bisa menjamin waktu pencocokan hanya tumbuh sebanding dengan panjang teks (dikali ukuran pola). Teks dua kali lebih panjang berarti kira-kira dua kali kerja, tidak pernah meledak. Karena itu crate ini aman, bahkan untuk pola atau teks yang berasal dari pengguna.

Biasanya kamu bisa mendapat hasil yang sama dengan cara lain: pakai capture group dan baca hanya bagian yang kamu mau, atau lakukan pengecekan sederhana kedua dengan kode Rust biasa. Kalau kamu benar-benar butuh fitur itu, crate fancy-regex menambahkannya, tapi tanpa jaminan waktu tadi.

Pola Cocok dengan
. karakter apa saja kecuali baris baru
\d \w \s sebuah digit, karakter “kata” (huruf, digit, _), whitespace
\D \W \S kebalikannya: bukan digit, bukan karakter kata, bukan whitespace
[abc] [a-z] [^0-9] salah satu dari a, b, c; huruf kecil apa saja; apa saja selain digit
* + ? nol atau lebih, satu atau lebih, nol atau satu dari item sebelumnya
{3} {2,5} {2,} tepat 3, dari 2 sampai 5, minimal 2
^ $ awal dan akhir teks
\b batas kata (antara karakter kata dan karakter bukan kata)
(...) (?<nama>...) grup bernomor, grup bernama
(?i) tidak membedakan huruf besar/kecil mulai dari sini

Satu lagi: garis tegak berarti “atau”. Contohnya, kucing|anjing cocok dengan salah satu kata itu, dan gr(a|e)y cocok dengan gray dan grey. Untuk mencocokkan karakter khusus apa adanya, taruh backslash di depannya: \., \(, \$.

Lupa memakai raw string. Di string biasa, \d bukan escape yang valid, jadi Rust sudah berhenti sebelum crate regex sempat melihat polamu:

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();
| +

Compiler bilang \d adalah escape karakter yang tidak dikenal, dan bahkan menyarankan solusinya: tambahkan r di depan.

Membuat regex di dalam loop. Kode ini bisa di-compile dan berjalan, tapi lambat, karena polanya di-compile ulang untuk setiap baris:

for line in lines {
let re = Regex::new(r"\d+").unwrap(); // di-compile lagi dan lagi
if re.is_match(line) { /* ... */ }
}

Pindahkan Regex::new ke atas loop, atau ke dalam static LazyLock seperti yang ditunjukkan sebelumnya.

Lupa ^ dan $ saat memvalidasi. is_match mencari pola di mana saja. Untuk memastikan seluruh input punya bentuk tertentu, kunci polanya di kedua ujung.

  • Tambahkan crate-nya dengan cargo add regex, dan tulis pola sebagai raw string: r"\d+".
  • Regex::new mengembalikan Result, karena sebuah pola bisa saja tidak valid.
  • is_match menjawab ya atau tidak; find/find_iter memberi teks yang cocok; captures/captures_iter memberi grup berdasarkan nomor atau (?<nama>...).
  • replace dan replace_all bisa menyisipkan grup dengan $nama; pakai ${nama} kalau setelahnya ada huruf atau _.
  • Compile pola sekali lalu pakai berulang kali, misalnya dalam static LazyLock.
  • Tidak ada look-around atau backreference, dan itu disengaja: sebagai gantinya, pencocokan selalu berjalan dalam waktu linear.

Kuis singkat

1. Kenapa pola regex di Rust biasanya diawali r, seperti r"\d+"?

2. Dengan pola (?<y>\d{4})-(?<m>\d{2}), apa hasil caps["m"] untuk teks 2024-05?

3. Kenapa crate regex tidak mendukung look-around dan backreference?

4. Di mana tempat terbaik memanggil Regex::new untuk pola yang kamu pakai di setiap baris file besar?

Skor: 0 / 4