Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Zaawansowane wyrażenia regularne

Dr Rex stoi przed ścianą monitorów w laboratorium genetycznym Parku Jurajskiego. Na jednym z ekranów przewija się dziennik systemowy: dwadzieścia tysięcy linii z czujników ogrodzeń, inkubatorów i bram sektorowych. Gdzieś w tym strumieniu ukryła się jedna awaria zasilania w siódmym sektorze. Przewijanie tego wzrokiem zajęłoby całą noc.

  • Nie szukamy konkretnego zdania - mówi dr Rex, nie odrywając wzroku od ekranu. - Szukamy kształtu zdania. Kod błędu zawsze wygląda tak samo: trzy wielkie litery, myślnik, trzy cyfry. Nieważne, które to litery.

Dokładnie tym są wyrażenia regularne (RegExp): opisem kształtu tekstu. Tak jak sekwencja DNA opisuje wzór, według którego składa się organizm, tak wyrażenie regularne opisuje wzór, według którego składa się fragment tekstu. Ty podajesz wzór, a silnik JavaScriptu przegląda za ciebie każdy znak w próbce.

Dwa sposoby zapisania wzorca

Zanim zaczniesz czegokolwiek szukać, musisz mieć obiekt wzorca. W JavaScripcie prowadzą do niego dokładnie dwie drogi - i tylko dwie. Pierwsza to literał, czyli wzorzec wpisany wprost do kodu pomiędzy dwa ukośniki. Druga to konstruktor

new RegExp("wzorzec", "flagi")
, który buduje ten sam obiekt ze zwykłych napisów.

Zapamiętaj ten podział, bo wokół niego krąży sporo mitów.

JSON.parse()
i
JSON.stringify()
przekładają dane pomiędzy tekstem a obiektem i z wyszukiwaniem wzorców nie mają nic wspólnego.
document.regex()
oraz
window.match()
w ogóle nie istnieją -
document
to drzewo strony,
window
to obiekt globalny przeglądarki i żaden z nich nie tworzy wyrażeń regularnych. Nie istnieją też
String.regex()
ani
Array.match()
: napis owszem, ma metodę
match()
, ale ona wzorca nie tworzy, tylko go przyjmuje jako argument, a tablice metody
match()
nie mają wcale.

1// Sposob 1: literal - wzorzec wpisany wprost w kod
2const pattern = /ERROR/gi;
3
4console.log(pattern.test("Error: brama sektora 7"));  // true
5
6// Sposob 2: konstruktor - wzorzec zbudowany z napisu
7const species = "Velociraptor";
8const dynamicPattern = new RegExp(species, "i");
9
10console.log(dynamicPattern.test("velociraptor w sektorze B"));  // true
11
12// Konstruktor przydaje sie, gdy wzorzec zalezy od danych
13function findSpecies(logLine, name) {
14  return new RegExp(name, "i").test(logLine);
15}
16
17console.log(findSpecies("ALERT: T-Rex opuscil wybieg", "t-rex"));  // true

Przyjrzyj się pierwszej linii, bo jej kolejność jest sztywna. Najpierw deklarujesz nazwę:

const pattern
. Potem otwierasz literał:
= /
. Dalej wpisujesz treść wzorca:
ERROR
. Na końcu zamykasz go ukośnikiem i doklejasz flagi:
/gi
. Całość czyta się jako
const pattern = /ERROR/gi
. Flagi stoją zawsze za zamykającym ukośnikiem, nigdy przed wzorcem - zapis w rodzaju
/gi ERROR/
nie jest niczym szczególnym, to zwykły wzorzec szukający dosłownego ciągu
gi ERROR
.

Flaga

g
(global) każe silnikowi zebrać wszystkie wystąpienia zamiast zatrzymać się na pierwszym. Flaga
i
(ignore case) wyłącza rozróżnianie wielkości liter, dzięki czemu jeden wzorzec złapie
ERROR
,
Error
i
error
. Literał wybieraj zawsze wtedy, gdy wzorzec jest stały - jest krótszy i czytelniejszy. Po konstruktor sięgaj, gdy wzorzec powstaje dopiero w trakcie działania programu, na przykład z nazwy gatunku wpisanej przez operatora w centrum kontroli.

Co która metoda zwraca

Mając wzorzec, trzeba go do czegoś użyć - i tu zaczyna się najczęstsza pomyłka. Cztery podstawowe metody wyglądają w kodzie podobnie, ale każda oddaje coś zupełnie innego. Dr Rex traktuje je jak cztery różne przyrządy laboratoryjne: pierwszy mówi, czy próbka zawiera szukaną sekwencję, drugi wyciąga, jaką, trzeci podmienia ją na inną, a czwarty podaje, gdzie dokładnie leży.

1const log = "2024-06-15 ALERT: awaria zasilania - ERR-503 - sektor 7";
2
3// test() - zwraca wartosc boolean
4console.log(/ALERT/.test(log));        // true
5console.log(/BRAK DANYCH/.test(log));  // false
6
7// match() - zwraca tablice dopasowan
8console.log(log.match(/ERR-\d{3}/));   // ["ERR-503", index: 37, ...]
9
10// replace() - zwraca NOWY napis, oryginal zostaje nietkniety
11console.log(log.replace(/ALERT/, "OSTRZEZENIE"));
12console.log(log);  // wciaz "... ALERT: ..." - napisy sa niezmienne
13
14// search() - zwraca indeks pierwszego dopasowania (albo -1)
15console.log(log.search(/ERR-/));       // 37
16console.log(log.search(/RAPTOR/));     // -1

Zatrzymaj się przy

test()
, bo to o nią najczęściej pytają.
test()
zwraca wartość boolean:
true
, gdy wzorzec pasuje do tekstu, i
false
, gdy nie pasuje. Nic więcej. Nie zwraca nowego napisu z podmienionymi dopasowaniami - to robota
replace()
. Nie zwraca tablicy dopasowanych fragmentów - tym zajmuje się
match()
. Nie zwraca też indeksu pierwszego dopasowania - od tego jest
search()
, która przy braku dopasowania oddaje
-1
. Dlatego
test()
stawiaj wszędzie tam, gdzie interesuje cię wyłącznie odpowiedź tak albo nie, na przykład w warunku
if
.

Warto znać jeszcze dwie metody.

matchAll()
z flagą
g
zwraca iterator wszystkich dopasowań razem z grupami, a
split()
potrafi rozciąć napis według wzorca -
split(/[\s,]+/)
podzieli tekst po dowolnej liczbie spacji i przecinków. Uważaj przy tym na jedną pułapkę: wzorzec z flagą
g
zapamiętuje pozycję w polu
lastIndex
, więc powtarzane wywołania
test()
na tym samym obiekcie potrafią raz zwrócić
true
, a raz
false
.

Alfabet wzorca - klasy znaków i kwantyfikatory

Wzorzec

/ERROR/
szuka dosłownego napisu i przez to jest bezużyteczny przy kodach błędów, bo każdy z nich ma inne cyfry. Prawdziwa siła wyrażeń regularnych bierze się z klas znaków, czyli symboli opisujących całe kategorie znaków, oraz z kwantyfikatorów mówiących, ile razy dany element ma się powtórzyć.

Trzy klasy zapamiętasz od razu:

\d
to dowolna cyfra,
\w
to znak słowny (litera, cyfra albo podkreślenie), a
\s
to biały znak - spacja, tabulator lub złamanie linii. Ich wielkie odpowiedniki
\D
,
\W
i
\S
znaczą dokładnie odwrotnie. Własną kategorię ułożysz w nawiasach kwadratowych:
[A-Z]
to wielka litera,
[0-9]
cyfra, a
[^0-9]
wszystko oprócz cyfry. Kropka pasuje do dowolnego znaku poza złamaniem linii.

1const record = "DINO_042_REX sektor 7 temperatura 37";
2
3// \d - cyfra, + - jeden lub wiecej powtorzen
4console.log(record.match(/\d+/g));  // ["042", "7", "37"]
5
6// \w - znak slowny (litera, cyfra, podkreslenie)
7console.log(record.match(/\w+/g));  // ["DINO_042_REX", "sektor", "7", "temperatura", "37"]
8
9// [A-Z] - wlasna klasa znakow, {3} - dokladnie trzy powtorzenia
10console.log("ERR-503 oraz ERR-404".match(/[A-Z]{3}-\d{3}/g));  // ["ERR-503", "ERR-404"]
11
12// ? - zero lub jeden raz, * - zero lub wiecej razy
13console.log(/sektor ?7/.test("sektor7"));      // true - spacja nieobowiazkowa
14console.log(/RAPTOR-0*42/.test("RAPTOR-42"));  // true - zera wiodace nieobowiazkowe

Kwantyfikator odnosi się zawsze do elementu stojącego bezpośrednio przed nim.

+
żąda co najmniej jednego powtórzenia,
*
dopuszcza również zero,
?
oznacza element opcjonalny, a klamry podają liczbę wprost:
{3}
to dokładnie trzy razy,
{2,4}
od dwóch do czterech,
{3,}
co najmniej trzy. Dzięki temu
/[A-Z]{3}-\d{3}/
czyta się jak zdanie: trzy wielkie litery, myślnik, trzy cyfry - czyli dokładnie ten kształt kodu błędu, którego szukał dr Rex.

Zwróć uwagę na różnicę między

\d+
a
\d{3}
. Pierwszy zapis złapie ciąg cyfr dowolnej długości, drugi upiera się przy trzech. W dzienniku parku ta drobna różnica decyduje o tym, czy do wyników wpadnie wyłącznie trzycyfrowy kod awarii, czy również numer sektora i numer wersji oprogramowania.

Kotwice - w którym miejscu tekstu

Klasy i kwantyfikatory mówią, co ma pasować. Kotwice mówią, gdzie. Daszek

^
oznacza początek tekstu, dolar
$
jego koniec, a
\b
granicę słowa, czyli miejsce styku znaku słownego z niesłownym. Bez kotwic wzorzec zadowoli się dopasowaniem gdziekolwiek w środku, a przy sprawdzaniu formatu to zwykle za mało: identyfikator okazu ma być całą wartością, a nie jej przypadkowym fragmentem.

1// Bez kotwic wzorzec zadowoli sie fragmentem
2console.log(/[A-Z]{3}-\d{3}/.test("smieci REX-001 smieci"));  // true
3
4// ^ oraz $ wymuszaja dopasowanie calego napisu
5const dinoId = /^[A-Z]{3}-\d{3}$/;
6
7console.log(dinoId.test("REX-001"));                // true
8console.log(dinoId.test("smieci REX-001 smieci"));  // false
9console.log(dinoId.test("REX-01"));                 // false - za malo cyfr
10console.log(dinoId.test("rex-001"));                // false - male litery
11
12// \b - granica slowa
13console.log(/\bRex\b/.test("T-Rex opuscil wybieg"));   // true
14console.log(/\bRex\b/.test("Rexona w laboratorium"));  // false

^[A-Z]{3}-\d{3}$
to gotowy walidator identyfikatora: cała wartość musi składać się z trzech wielkich liter, myślnika i trzech cyfr, bo daszek i dolar nie zostawiają miejsca na nic więcej. Usuń kotwice, a ten sam wzorzec przepuści każdy śmieciowy napis, w którym gdzieś w środku siedzi poprawny kod.

Przy

\b
pamiętaj, że jest to granica, a nie znak - nie zajmuje w tekście żadnego miejsca. Właśnie dlatego
\bRex\b
odnajduje
Rex
w
T-Rex
(myślnik jest znakiem niesłownym), ale odrzuca
Rexona
, gdzie zaraz po
Rex
idzie kolejna litera. Gdy pracujesz z dziennikiem złożonym z wielu linii, dołóż flagę
m
(multiline): wtedy
^
i
$
zaczną oznaczać początek i koniec każdej linii z osobna, a nie całego tekstu.

Grupy - wycinanie fragmentów

Sprawdzenie, czy linia dziennika pasuje do wzorca, to dopiero połowa roboty. Dr Rex potrzebuje z niej danych: osobno daty, osobno kodu błędu, osobno numeru sektora. Służą do tego grupy przechwytujące, czyli fragmenty wzorca ujęte w nawiasy okrągłe. Każda taka grupa trafia do wyniku

match()
jako kolejny element tablicy - pod indeksem zero leży zawsze całe dopasowanie, a od jedynki idą grupy w kolejności otwierających nawiasów.

Nawiasy przydają się też do alternatywy. Pionowa kreska

|
znaczy "albo", więc
(ALERT|ERROR|FATAL)
dopasuje dowolne z trzech słów. Jeżeli nawias jest ci potrzebny wyłącznie do zgrupowania, a nie do zapamiętania wyniku, użyj grupy nieprzechwytującej
(?:...)
- nie zaśmieca ona tablicy wyników.

1const line = "2024-06-15 ERROR ERR-503 sektor 7";
2
3// Grupy przechwytujace - numerowane od 1
4const numbered = /(\d{4}-\d{2}-\d{2}) (?:ALERT|ERROR|FATAL) (ERR-\d{3}) sektor (\d+)/;
5const parts = line.match(numbered);
6
7console.log(parts[0]);  // "2024-06-15 ERROR ERR-503 sektor 7" - cale dopasowanie
8console.log(parts[1]);  // "2024-06-15"
9console.log(parts[2]);  // "ERR-503"
10console.log(parts[3]);  // "7"
11
12// Grupy nazwane - czytelniejsze niz indeksy
13const named = /(?<data>\d{4}-\d{2}-\d{2}) (?:ALERT|ERROR|FATAL) (?<kod>ERR-\d{3}) sektor (?<sektor>\d+)/;
14const fields = line.match(named).groups;
15
16console.log(fields.data);    // "2024-06-15"
17console.log(fields.kod);     // "ERR-503"
18console.log(fields.sektor);  // "7"

Zapis

(?<nazwa>...)
tworzy grupę nazwaną. Zamiast pamiętać, że numer sektora siedzi pod indeksem trzy, sięgasz po
match.groups.sektor
. Gdy wzorzec urośnie do sześciu czy siedmiu grup, ta zmiana ratuje czytelność kodu, bo dorzucenie jednego nawiasu w środku wzorca nie przesuwa już numeracji wszystkich pozostałych.

Zwróć uwagę, że

(?:ALERT|ERROR|FATAL)
mimo nawiasu nie zajął żadnego numeru - dlatego kod błędu wylądował pod indeksem drugim, a nie trzecim. Właśnie po to istnieje grupa nieprzechwytująca: grupuje alternatywę, ale nie udaje danych. Jeżeli chcesz przejść po wszystkich liniach dziennika naraz, dodaj flagę
g
i użyj
matchAll()
- dostaniesz iterator, w którym każdy element ma własną tablicę i własne pole
groups
.

Lookahead - warunek, który nic nie zjada

Czasem chcemy dopasować fragment tekstu pod warunkiem, że dalej stoi (albo właśnie nie stoi) coś konkretnego - ale samego warunku nie chcemy wciągać do wyniku. Do tego służy lookahead, czyli podgląd w przód. Zapis

(?=...)
to lookahead pozytywny: dopasowanie uda się tylko wtedy, gdy zaraz za nim da się dopasować podany wzorzec. Zapis
(?!...)
to lookahead negatywny: dopasowanie uda się tylko wtedy, gdy podanego wzorca w tym miejscu nie ma.

Kluczowa różnica wobec zwykłej grupy jest taka, że lookahead nie konsumuje znaków. Sprawdza teren przed sobą i cofa się na to samo miejsce, więc sprawdzony fragment nie wchodzi do wyniku, a kilka takich warunków można nałożyć jeden po drugim.

1const raport = "SEKTOR-3 OK SEKTOR-7 ALERT SEKTOR-9 OK";
2
3// Lookahead pozytywny - tylko sektory, po ktorych stoi ALERT
4console.log(raport.match(/SEKTOR-\d(?= ALERT)/g));  // ["SEKTOR-7"]
5
6// Lookahead negatywny - wszystkie oprocz tych z ALERT
7console.log(raport.match(/SEKTOR-\d(?! ALERT)/g));  // ["SEKTOR-3", "SEKTOR-9"]
8
9// Kilka warunkow naraz - kod dostepu do laboratorium
10const accessCode = /^(?=.*[A-Z])(?=.*\d).{8,}$/;
11
12console.log(accessCode.test("Raptor42Lab"));  // true
13console.log(accessCode.test("raptor42lab"));  // false - brak wielkiej litery
14console.log(accessCode.test("RaptorLab"));    // false - brak cyfry
15console.log(accessCode.test("Rap42"));        // false - za krotki

W pierwszym przykładzie wynik zawiera wyłącznie

SEKTOR-7
, choć wzorzec sprawdzał również słowo
ALERT
- to właśnie znaczy, że lookahead nie wchodzi do dopasowania. Drugi wzorzec odwraca warunek i zwraca dwa pozostałe sektory.

Najciekawszy jest przykład trzeci.

^(?=.*[A-Z])(?=.*\d).{8,}$
czyta się jako trzy niezależne wymagania nałożone na ten sam napis: gdzieś musi pojawić się wielka litera, gdzieś cyfra, a całość ma mieć co najmniej osiem znaków. Bez lookaheadu trzeba by wypisać wszystkie kolejności, w jakich te elementy mogą wystąpić, a jest ich mnóstwo. Każdy
(?=...)
startuje od tej samej pozycji, sprawdza swój warunek i oddaje sterowanie następnemu; dopiero
.{8,}
faktycznie przechodzi przez tekst i zjada znaki.

Praktyka - parser dziennika parku

Czas złożyć wszystko w jedno narzędzie. Dziennik systemowy parku ma stały format: data, poziom zdarzenia, kod błędu i numer sektora. Zbudujemy funkcję, która z surowego tekstu wyciągnie te cztery informacje i zwróci gotową listę obiektów - dokładnie taką, jaką da się posortować, policzyć albo wyświetlić w centrum kontroli. Wzorzec deklarujemy raz, poza funkcją, i korzystamy z flagi

g
razem z
matchAll()
, żeby przejść przez wszystkie linie za jednym zamachem.

1const LOG_PATTERN = /(?<data>\d{4}-\d{2}-\d{2}) (?<poziom>ALERT|ERROR|INFO) (?<kod>[A-Z]{3}-\d{3}) sektor (?<sektor>\d+)/g;
2
3function parseLog(text) {
4  return [...text.matchAll(LOG_PATTERN)].map(match => ({
5    data: match.groups.data,
6    poziom: match.groups.poziom,
7    kod: match.groups.kod,
8    sektor: Number(match.groups.sektor)
9  }));
10}
11
12const dziennik = "2024-06-15 INFO SYS-100 sektor 3\n2024-06-15 ALERT PWR-503 sektor 7\n2024-06-16 ERROR FNC-404 sektor 7";
13
14const zdarzenia = parseLog(dziennik);
15
16console.log(zdarzenia.length);  // 3
17console.log(zdarzenia[1]);
18// { data: "2024-06-15", poziom: "ALERT", kod: "PWR-503", sektor: 7 }
19
20// Awarie z sektora 7 - juz zwyklymi metodami tablicy
21console.log(zdarzenia.filter(e => e.sektor === 7 && e.poziom !== "INFO").length);  // 2

Wzorzec został tu zapisany jako stała

LOG_PATTERN
poza funkcją i nie jest to przypadek. Wyrażenie regularne trzeba skompilować, więc budowanie go wewnątrz pętli albo przy każdym wywołaniu funkcji to czysta strata pracy - raz utworzony obiekt spokojnie obsłuży cały dziennik.

Zauważ też, jak grupy nazwane zamieniły surowy tekst w strukturę danych. Po

map()
mamy zwykłe obiekty i od tej chwili pracujemy narzędziami, które znasz od dawna:
filter()
,
sort()
,
reduce()
. Wyrażenie regularne zrobiło swoją część, czyli przetłumaczyło nieuporządkowany tekst na dane, i dalej nie jest już potrzebne. To dobra reguła na co dzień: regex ma być mostem do danych, a nie miejscem, w którym mieszka cała logika programu.

Podsumowanie

Dr Rex gasi ekran z dziennikiem. Dwadzieścia tysięcy linii, jedna awaria zasilania w siódmym sektorze, kilka sekund pracy silnika wyrażeń regularnych. Oto, co warto zabrać z tego laboratorium:

  1. Dwa sposoby tworzenia wzorca - literał
    /ERROR/gi
    albo konstruktor
    new RegExp("ERROR", "gi")
    . W literale kolejność jest stała: nazwa,
    = /
    , treść wzorca, zamykający ukośnik z flagami.
  2. Zwracane wartości -
    test()
    oddaje
    true
    albo
    false
    ,
    match()
    tablicę dopasowań,
    replace()
    nowy napis,
    search()
    indeks pierwszego dopasowania.
  3. Budulec wzorca - klasy
    \d
    ,
    \w
    ,
    \s
    i klasy własne
    [A-Z]
    opisują, co ma pasować; kwantyfikatory
    +
    ,
    *
    ,
    ?
    ,
    {n,m}
    mówią, ile razy; kotwice
    ^
    ,
    $
    ,
    \b
    wskazują miejsce.
  4. Grupy -
    (...)
    wycina fragment,
    (?:...)
    tylko grupuje, a
    (?<nazwa>...)
    daje dostęp przez
    match.groups.nazwa
    .
  5. Lookahead -
    (?=...)
    i
    (?!...)
    stawiają warunek na dalszy ciąg tekstu, nie zabierając go do wyniku.

Na koniec rada, którą dr Rex powtarza każdemu praktykantowi: wyrażenia regularne są potężne, ale nie są obowiązkowe. Jeżeli szukasz zwykłego, stałego fragmentu tekstu,

includes()
powie to samo prościej i szybciej. Po regex sięgaj wtedy, gdy naprawdę opisujesz kształt danych, a nie ich treść.

Przejdź do CodeWorlds