Wyrażenia regularne w PHP

Bardzo często zdarza się, że zwykła zamiana tekstu w ciągu znaków przy pomocy funkcji str_replace() nie wystarcza. A sprawdzenie poprawności danych czasem jest niewykonalne korzystając z strstr(). Wtedy z pomocą przychodzą wyrażenia regularne (ang. regular expressions), czyli mechanizm, dzięki któremu można stworzyć pewne wzorce i porównywać, dopasowywać i zmieniać według nich łańcuchy znakowe. Wyrażenia regularne pochodzą z języka Perl i PHP udostępnia kilka funkcji, które zachowują dużą kompatybilność z oryginalnymi perlowymi wyrażeniami regularnymi. Są to funkcje z przedrostkiem preg_.

Krótkie wprowadzenie do wyrażeń regularnych

Podstawowe wzorce, kwantyfikatory powtórzeń

W wyrażeniach regularnych stosuje się wzorce, które zastępują pojedyncze znaki bądź ciągi znaków. Najprostszym wzorcem jest kropka - . - która jest rozumiana jako jeden jakikolwiek znak (wyjątkiem jest znak nowej linii). Czyli pojedyncza litera, cyfra, przecinek, spacja, czy jakikolwiek inny znak będzie pasował do tego wzorca. Jednak dwie litery już nie. Aby to zmienić stosuje się definiowanie ilości wystąpień. Trzy podstawowe kwantyfikatory to *, ? i +, które oznaczają kolejno: zero, jedno lub więcej wystąpień; zero lub jedno wystąpienie; jedno lub więcej wystąpień. Kwantyfikatory powtórzeń wstawia się za wzorcem, czyli .+ oznacza co najmniej jeden dowolny znak, a f? oznacza, że może pojawić się jedna litera 'f'. Istnieją również kwantyfikatory powtórzeń, które pozwalają na elastyczność w definiowaniu powtórzeń. Dzięki nim można zdefiniować minimalną i maksymalną ilość powtórzeń - {min,max}. Zapis .{3,5} oznacza trzy, cztery lub pięć dowolnych znaków. Można również pominąć jedną z wartości. I tak wzorzec .{3,} opisuje minimum trzy znaki, zaś .{,3} to maksimum trzy znaki. Pominięcie przecinka - .{5} - to dokładnie pięć znaków.

Odwrotny ukośnik

We wzorcach można również stosować specjalne litery poprzedzone znakiem odwrotnego ukośnika (ang. backslash), które mają specjalne znaczenie. Na przykład \d oznacza dowolną cyfrę (znak z zakresu od 0 - 9), a \s to spacja. Oczywiście w tym przypadku również można wykorzystać kwantyfikatory powtórzeń. Najprostszy wzorzec (nie uwzględniający znaczenia analizowanych danych) dla jednej z form zapisu numeru identyfikacji podatkowej może wyglądać następująco - \d{3}-\d{2}-\d{2}-\d{3} (trzy cyfry - dwie cyfry - dwie cyfry - trzy cyfry).

Odwrotny ukośnik może również służyć do usunięcia specjalnego znaczenia niektórym znakom.

Zestawy i zakresy

Możliwe jest również definiowanie zestawów dopuszczalnych znaków. Wykorzystuje się do tego nawiasy kwadratowe - [ i ]. Dla przykładu, dla określenia wzorca dla liczby w systemie szesnastkowym (wykorzystującej jako cyfry wszystkie cyfry oraz wielkie litery od A do F) wystarczy użyć: 0x[0123456789ABCDEF]+, dzięki czemu mamy pewność, że jest to liczba zapisana w systemie szesnastkowym, która ma co najmniej jedną cyfrę i jest poprzedzona zwyczajowym przedrostkiem liczb szesnastkowych (0x). Ale wypisywanie wszystkich możliwości jest nieco żmudne. Można skorzystać z zakresów. I tak, ten sam przykład można zapisać jako: 0x[0-9A-F]+, czyli definiujemy zakres, w skład którego wchodzą wszystkie cyfry oraz wielkie litery od A do F.

Negacja, alternatywa, podwyrażenia

W pewnych przypadkach lepiej jest określić znaki, które nie mogą się pojawić niż znaki, które mogą się pojawić, chociażby z powodu ilości pozycji do wpisania. Do zdefiniowania zestawu znaków, które wystąpić nie mogą służy znak ^ (ale tylko gdy rozpoczyna definicję zestawu znaków). Przykładowo, poprawnie zapisanego hiperłącza to: <a\shref="[^\n">]+">[^>]+</a>

Alternatywa to możliwość zdefiniowania kilku wzorców dla tego samego miejsca w analizowanym ciągu znaków. Na przykład słowo cztero lub ośmioliterowe to [a-zA-Z]{4}|[a-zA-Z]{8}. Wykorzystywany jest tutaj znak |.

Podwyrażenia to, jak sama nazwa wskazuje, mniejsze części ogólnego wyrażenia. Cały wzorzec można podzielić na mniejsze części, które będą odpowiedzialne za odpowiednie elementy sprawdzanego ciągu znaków. Obejmuje się je zwykłymi, okrągłymi nawiasami, a przydatne są w szczególności przy operacjach zamiany tekstu, ponieważ to co zostanie dopasowane do podwyrażenia jest dostępne w specjalnej zmiennej - pierwsze podwyrażenie pod \1, drugie pod \2 itd. Cały ciąg znaków, który jest analizowany to \0.

Początek i koniec

Istnieje również możliwość oznaczenia początku i końca linii. Początek linii jest oznaczany znanym już symbolem ^, jednak aby był traktowany jako początek linii, to musi on występować na początku wzorca, a koniec to $ - analogicznie, powinien występować na końcu wzorca.

Zestawienie

Zestawienie wzorców
WzorzecZnaczenie
.Dowolny znak (prócz znaku nowej linii)
\sSpacja (lub znak nowej linii)
\nZnak nowej linii
\dCyfra
\DKażdy znak, który nie jest cyfrą
\wSłowo złożone z liter, cyfr i znaku podkreślenia
\WSłowo złożone z różnych znaków
{x,y}Co najmniej x, ale nie więcej niż y wystąpień poprzedzającego wzorca
{x,}Co najmniej x wystąpień poprzedzającego wzorca
{,y}Co najwyżej y wystąpień poprzedzającego wzorca
{x}Dokładnie x wystąpień poprzedzającego wzorca
?Zero lub jedno wystąpienie poprzedzającego wzorca. Równoważne z {0,1}
+Jedno lub więcej wystąpień poprzedzającego wzorca. Równoważne z {1,}
*Zero, jedno lub więcej wystąpień poprzedzającego wzorca. Równoważne z {0,}
[]Zakres
()Podwyrażenie, grupowanie wzorców
^Użyty na początku zakresu - negator. Użyty na początku wyrażenia - początek linii.
$Koniec linii
|Alternatywa

Zastosowania

Sprawdzanie poprawności adresu e-mail

Pierwszym przykładem zastosowania wyrażeń regularnych będzie sprawdzanie poprawności adresu e-mail. Użytkownik wprowadza go w formularzu, po czym przesyła ten formularz do skryptu. Skrypt analizuje adres i informuje o jego poprawności, bądź nie.

Jak należy stworzyć wzorzec adresu e-mail. To proste. Oczywiście każdy adres ma trzy części: login, znak @, czyli popularną "małpę" oraz domenę. Login może zawierać litery, cyfry, kropkę, myślnik i znak podkreślenia, przy czym musi być co najmniej jeden znak po tej stronie adresu. Domena musi zawierać co najmniej jedną kropkę, a po niej domenę najwyższego poziomu (ang. top level domain - TLD). Domena najwyższego poziomu może być dwuliterowa (pl, de, uk, itd.), trzyliterowa (com, net, itd.) lub czteroliterowa (aero, info, name, itd.). Prawdę mówiąc popełniam tu drobne wykroczenie, ponieważ istnieją domeny najwyższego rzędu, które mają sześć liter (museum i travel), ale dla uproszczenia można je pominąć (dopóki nie będą wystarczająco popularne). Przed domeną najwyższego poziomu musi być domena drugiego poziomu (ang. second level domain - SLD), a przed nią jeszcze domeny niższych poziomów. Nie można wyróżnić tutaj żadnego ograniczenia jeśli chodzi o długość, więc skupimy się tylko do wyszczególnienia znaków, z których może składać się ta domena - litery, cyfry i myślnik (znak podkreślenia w domenach jest nieprawidłowy). Oczywiście do zestawu należy dołączyć kropkę, która może rozdzielać domeny poszczególnych rzędów.

Po takiej analizie można przystąpić do budowy wyrażenia regularnego. Login może wyglądać następująco: [a-zA-Z0-9.\-_]+. Warto zauważyć, że przed myślnikiem pojawił się znak odwrotnego ukośnika. Jak wiemy posiada on możliwość "usunięcia" specjalnego znaczenia znaku, który za nim stoi. W przypadku definiowania zakresów służy od do rozdzielania początku i końca zakresu, a w naszym przypadku chcemy aby myślnik, jako zwykły znak, również został uwzględniony w zestawie. Dlatego myślnik został poprzedzony odwróconym ukośnikiem. Nie zrobiliśmy tego w przypadku kropki, bo kropka objęta nawiasami kwadratowymi nie ma swojego specjalnego znaczenia - nie reprezentuje dowolnego znaku, a tylko samą siebie. Plus na końcu oczywiście oznacza, że musi wystąpić co najmniej jeden znak.

A co z domeną? To jest również proste. Domenę można zapisać następująco: [a-zA-Z0-9\-.]+\.[a-zA-Z]{2,4}. Tutaj również myślnik został poprzedzony odwróconym ukośnikiem. Ale również kropka, która poprzedza domenę najwyższego rzędu została tym ukośnikiem poprzedzona. Bez takiego zabiegu miałaby swoje specjalne znaczenie, a nam bardzo zależy na tym, żeby to była kropka.

Po złożeniu i dodaniu znaków początku i końca linii (aby się upewnić, że tylko adres e-mail jest w analizowanym tekście) wyrażenie wygląda następująco:

^[a-zA-Z0-9.\-_]+@[a-zA-Z0-9\-.]+\.[a-zA-Z]{2,4}$

Należy więc przystąpić do napisania odpowiedniego kodu PHP, który będzie sprawdzał dane. Załóżmy, że dane wprowadzone przez użytkownika znajdują się w zmiennej $dane. Skorzystamy z funkcji preg_match(), która wyszukuje ciągów pasujących do wzorca i zwraca informację o rezultacie. Kod może wyglądać następująco:

$wzorzec = '/^[a-zA-Z0-9.\-_]+@[a-zA-Z0-9\-.]+\.[a-zA-Z]{2,4}$/';
if(preg_match($wzorzec, $dane)) 
	echo("E-mail prawidłowy");
else 
	echo("E-mail nieprawidłowy");

Co od razu warto zauważyć. Wzorzec został ograniczony zwykłymi ukośnikami. Te ukośniki, zwane terminatorami, informują funkcję, gdzie zaczyna się i kończy wyrażenie regularne. Za końcowym terminatorem można wstawić modyfikatory, czyli wpłynąć na opcje działania wyrażeń regularnych. Ale o tym później.

Warto również poruszyć sprawę ograniczania ciągów znakowych w PHP. Można je ograniczyć przy pomocy apostrofów i cudzysłowów. Tutaj użyto apostrofów, dzięki czemu PHP nie analizuje tekstu w poszukiwaniu znaków specjalnych. W przypadku cudzysłowów PHP przeszukałoby tekst w poszukiwaniu znaków specjalnych (poprzedzonych odwrotnym ukośnikiem). Dlatego, korzystając z cudzysłowów, należy zawsze pamiętać o zabezpieczaniu odwrotnych ukośników. Pierwsza linijka naszego kodu, ale zapisana z cudzysłowami wygląda następująco:

$wzorzec = "/^[a-zA-Z0-9.\\-_]+@[a-zA-Z0-9\\-.]+\\.[a-zA-Z]{2,4}$/";

PHP przeanalizuje każde wystąpienie odwróconego ukośnika i nada mu specjalne znaczenie. W naszym przypadku \\ zostanie zastąpione pojedynczym backslashem.

A jak działa ten kod? Wyjątkowo prosto. Funkcja sprawdza, czy zawartość $dane pasuje do wzorca i wyświetla odpowiedni komunikat.

Wyszukiwanie wszystkich adresów e-mail w tekście

Kolejnym przykładem będzie wydostawanie wszystkich adresów e-mail, które znajdują się w tekście. Zadanie jest o tyle prostsze, że mamy już opracowany wzorzec adresu e-mail. Skorzystamy z funkcji preg_match_all(), która od preg_match_all() różni się tym, że wyszukuje wszystkie ciągi pasujące do wzorca i nie poprzestaje na znalezieniu tylko pierwszego. Przy założeniu, że analizowany ciąg jest w zmiennej $tekst wywołanie funkcji może wyglądać następująco.

$wzorzec = '/[a-zA-Z0-9.\-_]+@[a-zA-Z0-9\-.]+\.[a-zA-Z]{2,4}/';
preg_match_all($wzorzec, $tekst, $wyniki);

W tym przypadku pominęliśmy znaki początku i końca linii, ponieważ analizujemy nieco więcej treści niż tylko jeden adres. W zmiennej $wyniki mamy tablicę z wynikami. Dla przykładowej treści, np. To jest zwykły tekst, a to jest adres@email.pl. Tu znowu jest tekst, a tu jest inny adres.email@w.jakiejs.domenie.com. Za to tutaj będzie niepoprawny@adres_.mail., funkcja zwróci tablicę o następującej strukturze:

Array
(
    [0] => Array
        (
            [0] => adres@email.pl
            [1] => adres.email@w.jakiejs.domenie.com
        )
)

Jak widać, pierwszy adres dostępny jest jako $wyniki[0][0], a drugi jako $wyniki[0][1]. Dlaczego jest to tablica dwuwymiarowa? Pierwszy indeks funkcji definiuje, do którego podwyrażenia została dopasowana ta treść. A ponieważ zerowym podwyrażeniem jest całe wyrażenie, to do zerowego podwyrażenia został dopasowany cały adres e-mail. Aby zaobserwować działanie podwyrażeń w tej funkcji, zmodyfikujmy nieco nasz wzorzec:

$wzorzec = '/([a-zA-Z0-9.\-_]+)@([a-zA-Z0-9\-.]+\.[a-zA-Z]{2,4})/';

Jak widać nawiasami zostały objęte obie strony znaku @. Pierwsze podwyrażenie będzie dopasowywać to co jest z lewej strony tego znaku, a drugie to co jest z prawej strony znaku. Dla tego samego przykładowego tekstu tablica z wynikami będzie wyglądała następująco:

Array
(
    [0] => Array
        (
            [0] => adres@email.pl
            [1] => adres.email@w.jakiejs.domenie.com
        )

    [1] => Array
        (
            [0] => adres
            [1] => adres.email
        )

    [2] => Array
        (
            [0] => email.pl
            [1] => w.jakiejs.domenie.com
        )

)

Teraz bardzo dobrze widać, że dzięki podwyrażeniom rozłożyliśmy adresy na ich części składowe.

Zamiana adresów na linki

Innym często spotykanym problemem jest zamiana adresu strony czy pliku na klikalny link. Wykorzystamy tutaj funkcję preg_replace().

Najbardziej skomplikowane w tym przypadku jest stworzenie odpowiedniego wyrażenia, które będzie opisywało adres. Adresy są zapisywane w różny sposób. Najczęściej spotyka się adresy pełne, to znaczy takie, w których będzie opisany zarówno protokół, domena jak i ścieżka do konkretnego pliku. Ale również niektórzy wpisują adresy swoich stron WWW wpisując samą domenę.

Zacznijmy najpierw od pełnego adresu. Musimy opisać protokół. Protokołów jest wiele, ale skupmy się na kilku najpopularniejszych: http, https, ftp czy nntp. Pomińmy tutaj protokoły typu gopher. Warto zauważyć, że wszystkie tutaj wymienione protokoły mają w środku litery t i p. A niektóre z nich mogą zostać rozszerzone o literę s, która definiuje połączenie szyfrowane. Dlatego początek adresu możemy zapisać jako: [a-zA-Z]{1,2}tps?:\/\/. Warto zauważyć, że ukośniki zostały poprzedzone odwrotnymi ukośnikami, dzięki czemu nie zostaną potraktowane jako terminatory wyrażenia.

Następnie domena. Jak wygląda? Wszyscy wiemy. Wykorzystamy to, co wymyśliliśmy przy sprawdzaniu adresu e-mail, ale dopiszemy również możliwość zdefiniowania portu TCP, na którym ma zostać nawiązane połączenie oraz, oczywiście, ukośnika: [a-zA-Z0-9\-.]+\.[a-zA-Z]{2,4}(:[0-9]{1,5})?\/. Wyszczególnienie portu jest objęte w nawiasy, czyli jest zgrupowane (i przy okazji jest podwyrażeniem). Następujący po nawiasie zamykającym pytajnik oczywiście mówi, że ciąg określający port może wystąpić najwyżej raz.

Z adresem pliku na serwerze już nie ma większego problemu. Prawie wszystkie znaki są dozwolone, więc my ograniczymy się do zdefiniowania tego, co nie może się tam pojawić (czyli znaków, które kończą URL) - spacji i znaku nowej linii: [^\n\s]*.

Po złożeniu, całe wyrażenie wygląda następująco:

$wzorzec = '/[a-zA-Z]{1,2}tps?:\/\/[a-zA-Z0-9\-.]+\.[a-zA-Z]{2,4}(:[0-9]{1,5})?\/[^\n\s]*/';

Mamy wzorzec. Pozostaje tylko go wykorzystać.

$zamiana = '<a href="\0">\0</a>';
$wynik = preg_replace($wzorzec, $zamiana, $tresc);

Wzorcem zamiany jest łącze, które wskazuje na \0, czyli ciąg, który został dopasowany do całego wyrażenia. Przykładową treść: To będzie link http://www.antylameriada.net:80/blog/ a to już nie będzie link www.antylameriada.net skrypt zamieni na To będzie link http://www.antylameriada.net:80/blog/ a to już nie będzie link www.antylameriada.net.

Widzimy tutaj potrzebę uzupełnienia skryptu o możliwość zamiany również drugiego sposobu zapisu. Jest to bardzo ułatwione, ponieważ funkcja preg_replace() może przyjmować, jako parametry, również tablice - tablicę wzorców i odpowiadającą im tablicę zamian.

Na pierwszy rzut oka stworzenie odpowiedniego wzorca jest wyjątkowo proste - www kropka subdomeny kropka domena TLD i koniec. Ale w połączeniu z poprzednim wzorcem, wzorcem zamiany pełnych URL-i, może okazać się kłopotliwe, bo wszystkie domeny z www na początku będą pasowały do zarówno pierwszego jak i drugiego wzorca, a co za tym idzie będą podlegać podwójnej zamianie. Musimy się zabezpieczyć, przez wykluczenie tamtych pełnych adresów. Wiemy, że przed domeną, w pełnym adresie występują dwa ukośniki. Wykorzystajmy to! Odpowiednie wyrażenie może wyglądać następująco: ([^\/]{2})(www\.[a-zA-Z0-9\-.]+\.[a-zA-Z]{2,4}). Co widzimy? Widzimy, że przed przedrostkiem www nie mogą wystąpić dwa ukośniki. Są one objęte w nawiasy, ponieważ te dwa "nie ukośniki" są znakami, których nie możemy zgubić przy zamianie. Następnie jest kolejne podwyrażenie, które dopasowuje całą domenę. Złóżmy to wyrażenie, oraz poprzednie w całość:

$wzorzec = array(	'/[a-zA-Z]{1,2}tp[s]?:\/\/[a-zA-Z0-9\-.]+\.[a-zA-Z]{2,4}(:[0-9]*)?\/[^\n\s]*/',
			'/([^\/]{2})(www\.[a-zA-Z0-9\-.]+\.[a-zA-Z]{2,4})/');
$zamiana = array(	'<a href="\0\">\0</a>',
			'\1<a href="http://\2">\2</a>');

Mamy również stworzoną tablicę zamian. Pierwsza pozycja tablicy wzorców zostanie zamieniona pierwszą pozycją tablicy zamian itd. Przyjrzyjmy się drugiej pozycji tablicy zamian. Na początku wstawiamy \1, czyli te dwa znaki, które nie są ukośnikami, a potem analogicznie do pierwszej pozycji, z tym, że domena jest w \2. Oczywiście musimy uzupełnić adres o protokół, którym domyślnie jest HTTP. Z takimi tablicami oba adresy z poprzedniego przykładu zostaną poprawnie zamienione.

Zamiana smileyów na ich graficzne odpowiedniki

Jest to wyjątkowo proste. Wystarczy tylko stworzyć odpowiednie wyrażenia. Proponuję:

$usmiechy = array(	'/:-?\)/',
			'/:-?\(/',
			'/:-?\//',
			'/;-?\)/');

$obrazki = array(	'<img src="usmiech.png" alt="\0" />',
			'<img src="smutek.png" alt="\0" />',
			'<img src="zastanowienie.png" alt="\0" />',
			'<img src="oczko.png" alt="\0" />');

$tresc = preg_replace($usmiechy, $obrazki, $tresc);

Prosto, łatwo i z wykorzystaniem rekurencji.

Sztuczki

Problem z kodowaniem unikodowym i polskimi literami

Obejrzyjmy działanie funkcji preg_match_all() z wyrażeniem [ąćęłńóśżź]{2} (same "polskie litery") i z przykładowym tekstem złożonym tylko z tych literek zakodowanych w UTF-8.

$wzorzec = '/[ąćęłńóśżź]{2}/';
$tekst = 'ąć łóś żółć';
preg_match_all($wzorzec, $tekst, $zwrot);
print_r($zwrot);

Jak wygląda zwrot tej funkcji?

Array
(
    [0] => Array
        (
            [0] => ą
            [1] => ć
            [2] => ł
            [3] => ó
            [4] => ś
            [5] => ż
            [6] => ó
            [7] => ł
            [8] => ć
        )

)

Nie jest to to, czego oczekiwaliśmy. Przecież wyraźnie zaznaczone było w wyrażeniu, że ma wybierać dwuliterowe zbitki, a tymczasem wybrana została każda litera pojedynczo. Okazuje się, że wszystko jest w porządku. W kodowaniu UTF-8 te znaki, które wymieniliśmy, są zapisywane jako dwa bajty (jak większość znaków spoza ASCII), więc wyrażenie regularne powybierało te znaki, ponieważ w rzeczywistości są to dwa znaki. Problem ten można łatwo rozwiązać. Wspominałem już wcześniej o modyfikatorach. Modyfikatory wstawia się za kończącym terminatorem wyrażenia. Modyfikator, który nakazuje "rozumienie" Unikodu to mała litera u. Dlatego, przebudujmy wzorzec:

$wzorzec = '/[ąćęśłńóśżź]{2}/u';

Dzięki czemu, wynik działania poprzedniej funkcji będzie taki:

Array
(
    [0] => Array
        (
            [0] => ąć
            [1] => łó
            [2] => żó
            [3] => łć
        )

)

Tym razem efekt jest taki, jakiego się spodziewaliśmy.

Warto również zauważyć, że litery takie jak ą, czy Ę (czyli te, które nie należą do zestawu znaków ASCII nie zostaną dopasowane do wyrażenia [a-zA-Z]. Jeśli te litery mogą się pojawić, to warto o nie rozszerzyć zestaw: [a-zA-ZąśżźćęńłóĄŚŻŹĆŃŁÓ].

Wrażliwość na wielkość znaków

Domyślnie wyrażenia regularne rozróżniają wielkie i małe litery. Dlatego dotychczas pisaliśmy [a-zA-Z]. Korzystając z modyfikatora - małej litery i - możemy nakazać wyrażeniom regularnym, żeby nie zwracały uwagi na wielkość liter. Wzorzec, który pokazałem wcześniej, ten z wymienionymi wszystkimi polskimi literami, można również zapisać krócej: /[a-ząśżźćęńłó]/ui. Czyli połączenie obsługi Unikodu i niewrażliwości na wielkość liter.