Reguläre Ausdrücke in Rust mit regex
Ein regulärer Ausdruck (kurz Regex) ist eine winzige Mustersprache für Text. Statt „Finde das Wort Katze“ kannst du sagen: „Finde vier Ziffern, einen Bindestrich, zwei Ziffern, einen Bindestrich, zwei Ziffern“ – und damit jedes Datum wie 2024-05-17 auf einmal erwischen.
Rusts Standardbibliothek hat praktische String-Methoden wie contains, split und find (siehe Strings). Wenn ein fester Text nicht reicht und du eine bestimmte Form von Text suchst, nimm das Crate regex. Es wird vom Rust-Projekt gepflegt und ist sehr schnell.
regex hinzufügen und der erste Treffer
Abschnitt betitelt „regex hinzufügen und der erste Treffer“Führe in deinem Projektordner aus:
cargo add regexDamit landet regex = "1" unter [dependencies] in deiner Cargo.toml. (Crates sind neu für dich? Lies Cargo und Crates.) Die „Selbst ausprobieren“-Buttons öffnen den Rust Playground, und der hat regex schon dabei.
use regex::Regex;
fn main() { let re = Regex::new(r"\d{4}-\d{2}-\d{2}").unwrap();
println!("{}", re.is_match("Veröffentlicht am 2024-05-17.")); println!("{}", re.is_match("Veröffentlicht im letzten Frühling."));}Ausgabe:
truefalse\d bedeutet „irgendeine Ziffer“, und {4} bedeutet „genau viermal das Vorherige“. is_match beantwortet eine einzige Frage: Gibt es irgendwo im Text einen Treffer?
Achte auf das r vor dem Muster: r"\d{4}". Das ist ein Raw String (roher String). In einem normalen Rust-String leitet \ eine Escape-Sequenz ein, etwa \n (Zeilenumbruch). Regex-Muster sind voller Backslashes, und mit Raw Strings musst du nicht "\\d{4}" schreiben. Benutze für Muster immer Raw Strings.
Warum Regex::new ein Result zurückgibt
Abschnitt betitelt „Warum Regex::new ein Result zurückgibt“Ein Muster ist ein kleines Programm, und es kann Fehler enthalten – zum Beispiel eine Klammer, die du vergessen hast zu schließen. Regex::new prüft das Muster und gibt ein Result zurück: Ok(Regex), wenn es gültig ist, und Err mit einer hilfreichen Meldung, wenn nicht:
use regex::Regex;
fn main() { match Regex::new(r"(\d+") { Ok(_) => println!("gültiges Muster"), Err(e) => println!("ungültiges Muster:\n{e}"), }}Ausgabe:
ungültiges Muster:regex parse error: (\d+ ^error: unclosed groupDie Meldung selbst kommt vom Crate regex und ist deshalb auf Englisch: Das Muster konnte nicht gelesen werden, weil eine Gruppe nicht geschlossen ist („unclosed group“). Das ^ zeigt auf die Stelle, an der die Gruppe beginnt.
Wenn das Muster in deinem Code steht und du weißt, dass es stimmt, ist .unwrap() in Ordnung: Ist es falsch, merkst du es beim ersten Programmstart. Wenn das Muster vom Benutzer kommt (etwa aus einem Suchfeld), behandle das Err, statt abzustürzen.
Treffer finden: find und find_iter
Abschnitt betitelt „Treffer finden: find und find_iter“find gibt den ersten Treffer als Option zurück. find_iter liefert dir einen Iterator über alle Treffer:
use regex::Regex;
fn main() { let re = Regex::new(r"\d+").unwrap(); let text = "3 Äpfel, 12 Birnen und 7 Pflaumen";
if let Some(m) = re.find(text) { println!("erste Zahl: {} (bei Bytes {}..{})", m.as_str(), m.start(), m.end()); }
let all: Vec<&str> = re.find_iter(text).map(|m| m.as_str()).collect(); println!("alle Zahlen: {:?}", all);
let total: u32 = re.find_iter(text).map(|m| m.as_str().parse::<u32>().unwrap()).sum(); println!("Obst insgesamt: {total}");}Ausgabe:
erste Zahl: 3 (bei Bytes 0..1)alle Zahlen: ["3", "12", "7"]Obst insgesamt: 22\d+ bedeutet „eine oder mehrere Ziffern“, deshalb wird 12 als ein Treffer gefunden, nicht als zwei. Jeder Treffer kennt seinen Text (as_str()) und seine Position (start() und end(), in Bytes). Vorsicht: Weil Umlaute wie Ä in UTF-8 zwei Bytes belegen, sind diese Byte-Positionen nicht immer dasselbe wie die Zeichenposition.
Gruppen und benannte Gruppen
Abschnitt betitelt „Gruppen und benannte Gruppen“Oft willst du nicht nur den Treffer, sondern Teile davon. Setz einen Teil des Musters in Klammern, um ihn einzufangen (Capture Group). Mit (?<name>...) gibst du der Gruppe einen Namen, damit dein Code leichter zu lesen ist:
use regex::Regex;
fn main() { let re = Regex::new(r"(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})").unwrap(); let text = "Das Backup lief am 2024-05-17 und am 2024-06-02.";
if let Some(caps) = re.captures(text) { println!("ganzer Treffer: {}", &caps[0]); println!("Jahr: {}", &caps["year"]); println!("Monat: {}", &caps[2]); println!("Tag: {}", caps.name("day").unwrap().as_str()); }
for caps in re.captures_iter(text) { println!("{}.{}.{}", &caps["day"], &caps["month"], &caps["year"]); }}Ausgabe:
ganzer Treffer: 2024-05-17Jahr: 2024Monat: 05Tag: 1717.05.202402.06.2024Es gibt drei Wege, eine Gruppe zu lesen:
caps[0]ist immer der ganze Treffer. Die Gruppen sind ab 1 durchnummeriert, von links nach rechts:caps[2]ist der Monat.caps["year"]liest eine Gruppe über ihren Namen. Der Zugriff mit[...]löst eine Panic aus, wenn diese Gruppe am Treffer nicht beteiligt war.caps.name("day")gibt eineOptionzurück. Das ist die sichere Wahl, wenn eine Gruppe optional ist.
captures findet den ersten Treffer. captures_iter geht alle Treffer durch, genau wie find_iter.
Text ersetzen
Abschnitt betitelt „Text ersetzen“replace ändert den ersten Treffer, replace_all ändert jeden Treffer. Im Ersatztext fügt $name (oder $1) ein, was eine Gruppe eingefangen hat:
use regex::Regex;
fn main() { let spaces = Regex::new(r"\s+").unwrap(); println!("{}", spaces.replace_all("zu viele Leerzeichen", " "));
let date = Regex::new(r"(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})").unwrap(); let text = "Vom 2024-05-17 bis 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}"));}Ausgabe:
zu viele LeerzeichenVom 17.05.2024 bis 2024-06-02Vom 17.05.2024 bis 02.06.2024Vom 2024 bis 2024Vom 05_2024 bis 06_2024Schau dir die vierte Zeile an. Sie hat Vom 2024 bis 2024 ausgegeben, nicht 05_2024. Warum? Ein Gruppenname darf _ enthalten, also wurde $m_ als Gruppe namens m_ gelesen. So eine Gruppe gibt es nicht, deshalb wurde daraus leerer Text. Setz den Namen in geschweifte Klammern, ${m}_, um genau zu sagen, wo er endet. Im Zweifel nimm immer die Klammern. (Ein Punkt wie in $d.$m kann dagegen nicht Teil eines Namens sein, dort brauchst du keine Klammern.)
\s+ bedeutet „ein oder mehrere Leerraumzeichen“. Sie durch ein einziges Leerzeichen zu ersetzen, ist ein klassischer Weg, unordentliche Eingaben aufzuräumen.
Text aufteilen
Abschnitt betitelt „Text aufteilen“split schneidet einen String überall dort auf, wo das Muster passt. Das ist wie str::split, nur dass das Trennzeichen ein Muster sein darf:
use regex::Regex;
fn main() { let sep = Regex::new(r"\s*[,;]\s*").unwrap(); let parts: Vec<&str> = sep.split("Apfel, Banane;Kirsche , Dattel").collect(); println!("{:?}", parts);
let two: Vec<&str> = sep.splitn("a, b, c, d", 2).collect(); println!("{:?}", two);}Ausgabe:
["Apfel", "Banane", "Kirsche", "Dattel"]["a", "b, c, d"][,;] bedeutet „ein Komma oder ein Semikolon“, und das \s* auf beiden Seiten schluckt alle Leerzeichen drumherum. splitn(text, 2) hört nach zwei Teilen auf.
Einmal kompilieren, nicht in der Schleife
Abschnitt betitelt „Einmal kompilieren, nicht in der Schleife“Einen Regex zu bauen, ist der langsame Teil: Das Crate verwandelt dein Muster in einen effizienten Matcher. Ihn zu benutzen, geht schnell. Ruf Regex::new deshalb nie in einer Schleife oder in einer Funktion auf, die oft läuft. Bau ihn einmal und verwende ihn wieder.
Für ein Muster, das du im ganzen Programm brauchst, leg es mit std::sync::LazyLock in ein static. Es wird beim ersten Benutzen gebaut und danach gemeinsam verwendet:
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)); }}Ausgabe:
[email protected] -> true[email protected] -> truenot-an-email -> falsex@y -> falseDie Eingaben bleiben hier auf Englisch, weil {input:22} die Spalten auffüllt – und das zählt Zeichen, die sauber untereinanderstehen sollen.
Neu in diesem Muster:
(?i)schaltet die Unterscheidung von Groß- und Kleinschreibung ab, sodass[a-z]auch aufBundMpasst.^und$bedeuten „Anfang des Textes“ und „Ende des Textes“. Ohne sie würdeis_matchauch bei"hallo [email protected]!!"Ja sagen, weil die E-Mail-Adresse irgendwo darin vorkommt.\.ist ein echter Punkt. Ein einfacher.bedeutet „irgendein Zeichen“.
Das ist eine Prüfung nach dem Motto „sieht aus wie eine E-Mail-Adresse“ – gut, um Tippfehler abzufangen. Jede gültige E-Mail-Adresse mit einer Regex wirklich zu validieren, ist bekanntermaßen schwer. Der einzig echte Test ist, eine E-Mail dorthin zu schicken.
Beispiel: Daten aus Logzeilen herausziehen
Abschnitt betitelt „Beispiel: Daten aus Logzeilen herausziehen“Jetzt kombinieren wir alles. Wir lesen ein Log, überspringen Zeilen, die nicht zum Format passen, und geben für jede ERROR-Zeile das Datum, die Uhrzeit und alle E-Mail-Adressen in der Nachricht aus:
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 Benutzer [email protected] angemeldet2024-05-17 09:15:44 ERROR Zahlung fehlgeschlagen für [email protected]diese Zeile ist Müll2024-05-18 11:02:10 WARN Festplatte zu 91 % voll
let mut errors = 0; for line in log.lines() { let Some(caps) = LOG_LINE.captures(line) else { println!("übersprungen: {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} Fehler gefunden");}Ausgabe:
2024-05-17 09:15:44 -> ["[email protected]"]übersprungen: "diese Zeile ist Müll"2024-05-18 11:05:00 -> ["[email protected]", "[email protected]"]2 Fehler gefundenEin paar Hinweise:
- Das
"\am Anfang vonlogist ein Rust-Trick: Ein Backslash am Zeilenende innerhalb eines Strings überspringt den Zeilenumbruch, sodass die erste Logzeile sofort beginnt. let Some(caps) = ... else { ... }ist let-else: Wenn die Zeile nicht passt, geben wir sie aus und springen mitcontinuezur nächsten.(?<level>[A-Z]+) +passt auf das Level und danach auf ein oder mehrere Leerzeichen, weilINFOundWARNmit einem zusätzlichen Leerzeichen aufgefüllt sind, damit sie mitERRORbündig stehen.- In einem echten Programm würdest du das Log Zeile für Zeile aus einer Datei lesen. Siehe Dateien lesen und schreiben.
Was regex (absichtlich) nicht kann
Abschnitt betitelt „Was regex (absichtlich) nicht kann“Wenn du reguläre Ausdrücke aus JavaScript, Python oder Perl kennst, greifst du vielleicht zu Look-around ((?=...), (?<=...)) oder Rückverweisen (Backreferences, \1, „derselbe Text wie in Gruppe 1“). Das Crate regex unterstützt sie nicht und sagt dir das auch:
regex parse error: foo(?=bar) ^^^error: look-around, including look-ahead and look-behind, is not supportedDie Meldung heißt: Look-around, also auch Look-ahead und Look-behind, wird nicht unterstützt. Das ist ein bewusster Tausch. Diese Funktionen können manche Muster bei bestimmten Eingaben exponentiell viel Zeit kosten lassen. Ein kurzes Muster und ein ausreichend langer String können dann ein Programm einfrieren (das nennt man Catastrophic Backtracking, und es hat schon echte Websites lahmgelegt). Ohne diese Funktionen kann das Crate regex garantieren, dass die Suchzeit nur proportional zur Länge des Textes wächst (mal der Größe des Musters). Doppelt so viel Text bedeutet ungefähr doppelt so viel Arbeit – nie eine Explosion. Deshalb ist es sogar sicher, wenn Muster oder Text von Benutzern stammen.
Meist erreichst du dasselbe Ergebnis auf anderem Weg: Nimm eine Gruppe und lies nur den Teil, den du brauchst, oder mach eine zweite, einfache Prüfung in normalem Rust-Code. Wenn du diese Funktionen wirklich brauchst, rüstet das Crate fancy-regex sie nach – allerdings ohne die Zeitgarantie.
Spickzettel
Abschnitt betitelt „Spickzettel“| Muster | Passt auf |
|---|---|
. |
jedes Zeichen außer einem Zeilenumbruch |
\d \w \s |
eine Ziffer, ein „Wortzeichen“ (Buchstabe, Ziffer, _), Leerraum |
\D \W \S |
das Gegenteil: keine Ziffer, kein Wortzeichen, kein Leerraum |
[abc] [a-z] [^0-9] |
eines von a, b, c; ein beliebiger Kleinbuchstabe; alles außer einer Ziffer |
* + ? |
null oder mehr, eins oder mehr, null oder eins vom vorherigen Element |
{3} {2,5} {2,} |
genau 3, von 2 bis 5, mindestens 2 |
^ $ |
Anfang und Ende des Textes |
\b |
eine Wortgrenze (zwischen einem Wortzeichen und einem Nicht-Wortzeichen) |
(...) (?<name>...) |
eine nummerierte Gruppe, eine benannte Gruppe |
(?i) |
ab hier ohne Unterscheidung von Groß- und Kleinschreibung |
Noch eins: Ein senkrechter Strich bedeutet „oder“. Zum Beispiel passt Katze|Hund auf beide Wörter, und gr(a|e)y passt auf gray und grey. Um ein Sonderzeichen wörtlich zu suchen, stell einen Backslash davor: \., \(, \$.
Häufige Fehler
Abschnitt betitelt „Häufige Fehler“Den Raw String vergessen. In einem normalen String ist \d keine gültige Escape-Sequenz, deshalb bricht Rust ab, bevor das Crate regex dein Muster überhaupt zu sehen bekommt:
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(); | +Der Compiler meldet eine unbekannte Escape-Sequenz \d und schlägt sogar die Lösung vor: Wenn du einen echten Backslash meinst (etwa in einem regulären Ausdruck), schreib ein r davor.
Die Regex in der Schleife bauen. Das kompiliert und funktioniert, ist aber langsam, weil das Muster für jede Zeile neu kompiliert wird:
for line in lines { let re = Regex::new(r"\d+").unwrap(); // wird wieder und wieder kompiliert if re.is_match(line) { /* ... */ }}Zieh Regex::new vor die Schleife oder in ein LazyLock-Static, wie oben gezeigt.
^ und $ beim Validieren vergessen. is_match sucht das Muster irgendwo. Wenn du prüfen willst, ob die ganze Eingabe eine bestimmte Form hat, verankere das Muster an beiden Enden.
Zusammenfassung
Abschnitt betitelt „Zusammenfassung“- Füge das Crate mit
cargo add regexhinzu und schreib Muster als Raw Strings:r"\d+". Regex::newgibt einResultzurück, weil ein Muster ungültig sein kann.is_matchsagt Ja oder Nein;find/find_iterliefern den gefundenen Text;captures/captures_iterliefern Gruppen nach Nummer oder nach(?<name>...).replaceundreplace_allkönnen mit$nameGruppen einfügen; nimm${name}, wenn Buchstaben oder_folgen.- Kompiliere ein Muster einmal und verwende es wieder, zum Beispiel in einem
staticmitLazyLock. - Kein Look-around und keine Rückverweise – mit Absicht: Dafür läuft die Suche immer in linearer Zeit.
Kurzer Check
Abschnitt betitelt „Kurzer Check“Kurzer Check
1. Warum beginnen Regex-Muster in Rust meist mit r, wie in r"\d+"?
2. Was liefert caps["m"] mit dem Muster (?<y>\d{4})-(?<m>\d{2}) für den Text 2024-05?
3. Warum unterstützt das Crate regex kein Look-around und keine Rückverweise?
4. Wo rufst du Regex::new am besten auf, wenn du ein Muster auf jede Zeile einer großen Datei anwendest?
Punkte: 0 / 4
Weiterlesen
Abschnitt betitelt „Weiterlesen“- Dokumentation des Crates
regex– docs.rs (auf Englisch): der offizielle Leitfaden, mit vielen Beispielen und der vollständigen Syntax-Referenz. Regex-API – docs.rs (auf Englisch): jede Methode, einschließlichcaptures_iter,replacenundsplit.LazyLock– Dokumentation der Standardbibliothek (auf Englisch): wie verzögert initialisierte Statics funktionieren.- Regular Expressions – Rust Cookbook (auf Englisch): kurze Rezepte, um Hashtags, Telefonnummern, E-Mail-Adressen und mehr herauszuziehen.