Эта короткая статья обязана одному интересному тестовому заданию, в котором требовалось реализовать базовый функционал утилиты grep на языке PHP, не используя никаких встроенных функций по работе с регулярными выражениями.

Строка с шаблоном должна была включать поддержку следующих метасимволов:

  • ^ - начало строки

  • $ - конец строки

  • . - любой символ

  • * - 0 или более раз

  • ? - 0 или 1 раз

  • + - 1 или более раз

Так же нужно было поддерживать экранирование метасимволов при помощи '\', чтобы по ним возможно было производить поиск. В результате, получившаяся утилита занимает порядка ста строк кода, из которых 70 - это функции регулярных выражений. 

Для тех кого интересует простая реализация механизма регулярных выражений в сугубо обучающих целях, здесь приведен ее код и краткий разбор.

Конечный результат:

grep.php
#!/bin/env php
<?php

const META_CHAR = 1, LITERAL = 2, META_CHARS = ["*", "?", "+", "."];

function is_metachar(string $re, int $i): bool
{
	return in_array($re[$i], META_CHARS) || ($re[$i] === "^" && $i === 0) || ($re[$i] === "$" && $i === (strlen($re) - 1));
}

function tokenize(string $re, int $i = 0): array
{
	$re_tokens = [];
	$len = strlen($re);
	while ($i < $len) {
		if ($re[$i] === '\\' && $i + 1 < $len && (is_metachar($re, $i + 1) || ($i === 0 && $re[$i + 1] === "^")))
			$re_tokens[] = [LITERAL, $re[++$i]];
		else
			$re_tokens[] = [is_metachar($re, $i) ? META_CHAR : LITERAL, $re[$i]];
		$i++;
	}
	return $re_tokens;
}

function match_char(array $re_char, string $str_char): bool
{
	return ($re_char[0] === LITERAL && $re_char[1] === $str_char) || ($re_char[0] === META_CHAR && $re_char[1] === ".");
}

function match_metachar(array $tokens, int $pos, string $char): bool
{
	return isset($tokens[$pos]) && $tokens[$pos][0] === META_CHAR && $tokens[$pos][1] === $char;
}

function is_end($data, int $pos): bool
{
	return !isset($data[$pos]);
}

function re_match(array $re_tokens, string $str, int $str_pos = 0): bool
{
	if (match_metachar($re_tokens, 0, "^"))
		return match_from_pos($re_tokens, 1, $str, 0);

	$match = false;
	while (!$match && !is_end($str, $str_pos))
		$match = match_from_pos($re_tokens, 0, $str, $str_pos++);
	return $match;
}

function match_from_pos(array $re_tokens, int $re_pos, string $str, int $str_pos): bool
{
	if (is_end($re_tokens, $re_pos))
		return true;

	if (match_metachar($re_tokens, $re_pos, "$"))
		return is_end($str, $str_pos);

	if (match_metachar($re_tokens, $re_pos + 1, "*"))
		return match_from_pos($re_tokens, $re_pos + 2, $str, $str_pos) || (!is_end($str, $str_pos) &&
			match_char($re_tokens[$re_pos], $str[$str_pos]) &&
			match_from_pos($re_tokens, $re_pos, $str, $str_pos + 1));

	if (match_metachar($re_tokens, $re_pos + 1, "+"))
		return match_char($re_tokens[$re_pos], $str[$str_pos]) && (match_from_pos($re_tokens, $re_pos + 2, $str, $str_pos + 1) ||
			match_from_pos($re_tokens, $re_pos, $str, $str_pos + 1));

	if (match_metachar($re_tokens, $re_pos + 1, "?"))
		return match_from_pos($re_tokens, $re_pos + 2, $str, $str_pos) || (match_char($re_tokens[$re_pos], $str[$str_pos]) &&
			match_from_pos($re_tokens, $re_pos + 2, $str, $str_pos + 1));

	return !is_end($str, $str_pos) &&
		match_char($re_tokens[$re_pos], $str[$str_pos]) &&
		match_from_pos($re_tokens, $re_pos + 1, $str, $str_pos + 1);
}

function grep($file, array $re_tokens, string $fname = ""): void
{
	$line = 0;
	while (($str = fgets($file)) !== false && ++$line)
		if (re_match($re_tokens, $str = rtrim($str, "\r\n")))
			echo ($fname ? "{$fname}:" : "") . "{$line}:{$str}\n";
}

if ($argc < 2)
	exit("Usage: {$argv[0]} pattern [file1 file2 ...]\n");

$re_tokens = tokenize($argv[1]);

if ($argc == 2) {
	grep(STDIN, $re_tokens);
} else {
	for ($i = 2; $i < $argc; $i++) {
		if (is_dir($argv[$i])) {
			fwrite(STDERR, "{$argv[0]}: {$argv[$i]} : Is a directory\n");
			continue;
		}
		$f = @fopen($argv[$i], "r");
		if ($f === false) {
			fwrite(STDERR, "Cannot open file: {$argv[$i]}\n");
			continue;
		}
		grep($f, $re_tokens, $argc > 3 ? $argv[$i] : "");
		fclose($f);
	}
}

Пример работы:

./grep.php '^funct+is?.*pos.*$.*bool$' grep.php
30:function match_metachar(array $tokens, int $pos, string $char): bool
51:function match_from_pos(array $re_tokens, int $re_pos, string $str, int $str_pos): bool

В первую очередь нам нужно провести разбор строки с регулярным выражением на метасимволы и литералы. Для этого служит функция tokenize:

<?php

const META_CHAR = 1, LITERAL = 2, META_CHARS = ["*", "?", "+", "."];

function is_metachar(string $re, int $i): bool
{
	return in_array($re[$i], META_CHARS) || ($re[$i] === "^" && $i === 0) || ($re[$i] === "$" && $i === (strlen($re) - 1));
}

function tokenize(string $re, int $i = 0): array
{
	$re_tokens = [];
	$len = strlen($re);
	while ($i < $len) {
		if ($re[$i] === '\\' && $i + 1 < $len && (is_metachar($re, $i + 1) || ($i === 0 && $re[$i + 1] === "^")))
			$re_tokens[] = [LITERAL, $re[++$i]];
		else
			$re_tokens[] = [is_metachar($re, $i) ? META_CHAR : LITERAL, $re[$i]];
		$i++;
	}
	return $re_tokens;
}

Функция принимает строку с регулярным выражением, пробегает по каждому символу и определяет его тип на основе принадлежности к массиву метасимволов. Если метасимвол экранирован (перед ним стоит слеш "\"), то он считается обычным литералом. Так же стоит заметить, что ^ и $ считаются метасимволами только в том случае, если они располагаются в начале и конце шаблона соответственно.

<?php

function match_char(array $re_char, string $str_char): bool
{
	return ($re_char[0] === LITERAL && $re_char[1] === $str_char) || ($re_char[0] === META_CHAR && $re_char[1] === ".");
}

function match_metachar(array $tokens, int $pos, string $char): bool
{
	return isset($tokens[$pos]) && $tokens[$pos][0] === META_CHAR && $tokens[$pos][1] === $char;
}

function is_end($data, int $pos): bool
{
	return !isset($data[$pos]);
}

function re_match(array $re_tokens, string $str, int $str_pos = 0): bool
{
	if (match_metachar($re_tokens, 0, "^"))
		return match_from_pos($re_tokens, 1, $str, 0);

	$match = false;
	while (!$match && !is_end($str, $str_pos))
		$match = match_from_pos($re_tokens, 0, $str, $str_pos++);
	return $match;
}

Функция re_match принимает массив токенов полученный из функции tokenize и строку, в которой производится поиск, а также позицию, с которой начинать поиск в строке (по умолчанию 0). Если регулярное выражение начинается с метасимвола "^", то совпадение оставшегося шаблона проверяется строго с начальной позиции строки. В ином случае производится поиск совпадения в любой позиции (в случае неудачи каждый раз сдвигая позицию строки на один символ, пока не будет достигнут конец).

<?php 

function match_from_pos(array $re_tokens, int $re_pos, string $str, int $str_pos): bool
{
	if (is_end($re_tokens, $re_pos))
		return true;

	if (match_metachar($re_tokens, $re_pos, "$"))
		return is_end($str, $str_pos);

	if (match_metachar($re_tokens, $re_pos + 1, "*"))
		return match_from_pos($re_tokens, $re_pos + 2, $str, $str_pos) || (!is_end($str, $str_pos) &&
			match_char($re_tokens[$re_pos], $str[$str_pos]) &&
			match_from_pos($re_tokens, $re_pos, $str, $str_pos + 1));

	if (match_metachar($re_tokens, $re_pos + 1, "+"))
		return match_char($re_tokens[$re_pos], $str[$str_pos]) && (match_from_pos($re_tokens, $re_pos + 2, $str, $str_pos + 1) ||
			match_from_pos($re_tokens, $re_pos, $str, $str_pos + 1));

	if (match_metachar($re_tokens, $re_pos + 1, "?"))
		return match_from_pos($re_tokens, $re_pos + 2, $str, $str_pos) || (match_char($re_tokens[$re_pos], $str[$str_pos]) &&
			match_from_pos($re_tokens, $re_pos + 2, $str, $str_pos + 1));

	return !is_end($str, $str_pos) &&
		match_char($re_tokens[$re_pos], $str[$str_pos]) &&
		match_from_pos($re_tokens, $re_pos + 1, $str, $str_pos + 1);
}

В функции match_from_pos(ition) находится основной механизм регулярных выражений основанный на применении рекурсии. Логику работы можно описать следующим образом: 

  1. Если достигнут конец регулярного выражения, то возвращается true, так как любая строка соответствует пустому шаблону.

  2. Если текущий метасимвол "$", то проверяется достигнут ли конец строки.

  3. Если за текущей позицией следует метасимвол "*" (0 или более повторений текущего символа), то в начале происходит попытка рекурсивного поиска оставшейся части регулярного выражения начиная с текущей позиции строки (что соответствует варианту с 0 повторений). Если совпадений в оставшейся части не обнаружено, то сверяем символы в текущей позиции и в случае совпадения вызываем рекурсию со следующей позиции в строке (соответствует варианту одного или более совпадений).

  4. Если за текущей позицией следует метасимвол "+" (1 или более повторений текущего символа), то в начале проверяем совпадение символов в текущей позиции, а затем рекурсивно сравниваем оставшуюся часть регулярного выражения со следующей позицией строки или (если этот вариант не совпал) проверяем текущую позицию регулярного выражения со следующей позицией строки (соответствует варианту более одного совпадения).

  5. Если за текущей позицией следует метасимвол "?" (0 или 1 повторение текущего символа), то как и в варианте с "*", в начале обрабатываем случай 0 повторений, а в случае неудачи, сверяем символы в текущей позиции и при их совпадении рекурсивно переходим к проверке следующей позиции.

  6. Последний вариант соответствует простому сравнению символов - проверяем, что конец строки еще не достигнут, сверяем символы в текущих позициях и рекурсивно вызываем проверку со следующей позиции.

Оставшаяся часть кода относится к самой утилите - обычное чтение строк из файлов или потока стандартного ввода и вывод совпавших строк.

<?php 

function grep($file, array $re_tokens, string $fname = ""): void
{
	$line = 0;
	while (($str = fgets($file)) !== false && ++$line)
		if (re_match($re_tokens, $str = rtrim($str, "\r\n")))
			echo ($fname ? "{$fname}:" : "") . "{$line}:{$str}\n";
}

if ($argc < 2)
	exit("Usage: {$argv[0]} pattern [file1 file2 ...]\n");

$re_tokens = tokenize($argv[1]);

if ($argc == 2) {
	grep(STDIN, $re_tokens);
} else {
	for ($i = 2; $i < $argc; $i++) {
		if (is_dir($argv[$i])) {
			fwrite(STDERR, "{$argv[0]}: {$argv[$i]} : Is a directory\n");
			continue;
		}
		$f = @fopen($argv[$i], "r");
		if ($f === false) {
			fwrite(STDERR, "Cannot open file: {$argv[$i]}\n");
			continue;
		}
		grep($f, $re_tokens, $argc > 3 ? $argv[$i] : "");
		fclose($f);
	}
}

Вообщем, эта короткая статья носит сугубо обучающий характер простой реализации механизма регулярных выражений, может быть кому-то пригодится.

Комментарии (14)


  1. Andrey_Solomatin
    04.10.2021 10:56
    +4

    В функции match_from_pos(ition) находится основной механизм регулярных выражений основанный на применении рекурсии.

    Всегда считал, что для этого используются конечные автоматы.
    Код с конечными автоматами получается хорошо структуированным и легко читается.


    1. QtRoS
      04.10.2021 11:38
      +1

      Это так и есть. Тут уже зависит от работодателя - примет ли он решение на самодельном велосипеде или таки откажет из-за того, что не использован годами проверенный инструмент (НКА).


    1. nick1612 Автор
      04.10.2021 12:22
      +2

      Да, конечные автоматы это первый и один из стандартных механизмов реализации регулярных выражений. Первый подобный алгоритм был написам Кеном Томпсоном и строил конечный автомат, а после чего на лету компилировал его напрямую в машинные коды. Есть еще варианты со стековой виртуальной машиной, но они все достоточно громоздкие для этой задачи. На хабре кстати есть перевод хорошей статьи по этому поводу https://habr.com/ru/company/mailru/blog/270507/


  1. poletos
    04.10.2021 12:10
    +1

    В конкретно упомянутой постановке задача допускает гораздо более простое решение. Единственный элемент, который действительно сопоставляется - это точка "." (в настоящих регулярных выражениях есть еще "один из символов" "[a-z]" и "один символ кроме перечисленых" "[^a-z]" например. Поэтому регулярное выражение из символов "^$.*?+" это указание, сколько точек нужено найти, возможно с привязкой к началу/концу строки.
    Конкретнее, регулярное выражение это строка следующего вида:

    1. "^" - начало строки, либо есть, либо нет. Пока пропускаем.

    2. Последовательность из ".", ".*", ".?", ".+". В этой последовательности каждая часть - это просто указание, от скольки до скольки произвольных символов может подойти. Точнее:

      • "." - минимум 1 символ, максимум 1 символ, далее - (1, 1);

      • ".*" - (0, ∞);

      • ".?" - (0, 1);

      • ".+" - (1, ∞);

    3. "$" - конец строки, либо есть либо нет. Пока пропускаем.

    Для пар (минимум, максимум) в регулярном выражении считаем сумму всех пар (допускаем, что ∞ + x = ∞ для любого x). Получаем (min, max).
    В итоге регулярное выражение - это указание, что в строке от min до max символов в строке, возможно с привязкой к началу/концу. Дальше можно разобрать случаи с началом/концом:

    • Есть и "^", и "$". Тогда считаем количество символов в строке, если min <= len <= max, то вся строка и подходит, иначе совпадения нет;

    • Есть только "^". Тогда возвращаем min первых символов, если столько есть, иначе совпадения нет.

    • Есть только "$". Тогда возвращаем min последних символов, если столько есть, иначе совпадения нет.

    • Нет ни "^", ни "$". Возвращаем min любых символов (например первых) если есть, иначе совпадения нет.

    P.S. Заметил, что не используется max, его можно использовать, чтобы прикрутить при желании нахождение всех совпадений.


    1. amishaa
      04.10.2021 12:52

      Ещё сопоставляются все буквы. Например, регулярке a* строка aaa удовлетворяет, а строка bbb (той же длинны) - нет.


    1. nick1612 Автор
      04.10.2021 14:26

      Не могли бы вы привести пример хотя бы псевдокода, если у этой задачи есть гораздо более простое решение, так как я тоже не совсем понял, как это будет работать в связке с обычными символами. Возможно я просто не до конца понял ваш принцип.


      1. poletos
        04.10.2021 14:27

        С обычными символами не будет работать, это я упустил.


  1. a-tk
    04.10.2021 13:25

    А как группы символов и выбор вариантов вида (?:abc|abf|$) ?


    1. nick1612 Автор
      04.10.2021 13:41
      +1

      А разве в статье написано про их реализацию? Мог бы быть заголовок "Пишем полный аналог PCRE в 70 строк кода" :)

      Односимвольный выбор вариантов можно достаточно просто добавть по аналогии с имеющимися метасимволами, а для поддержки групп нужно добавить стек, чтобы сохранять позиции для бэктрекинга


      1. nick1612 Автор
        04.10.2021 13:54

        <?php
        if (match_metachar($re_tokens, $re_pos + 1, "|"))
        		return match_from_pos($re_tokens, $re_pos + 2, $str, $str_pos) || (!is_end($str, $str_pos) &&
        			match_char($re_tokens[$re_pos], $str[$str_pos]) && 
        			match_from_pos($re_tokens, $re_pos + 3, $str, $str_pos + 1));

        ну и добавть "|" в массив META_CHARS


      1. a-tk
        05.10.2021 11:04

        Тогда можно было бы свести задачу к wildcard-символам типа * и _ в именах файлов или % с _ в SQL. Задача та же, но более применима в реальной жизни, чем некоторое ограниченное подмножество регулярок, из которого отрезано всё полезное.


        1. nick1612 Автор
          05.10.2021 11:30

          Не совсем понял, в чем заключается ваше решение, можете привести пример (желательно с примером кода)? Если есть более простое решение, буду рад узнать.


  1. insecto
    04.10.2021 20:01

    О боже, я в красках представил себе производительность.


    1. nick1612 Автор
      04.10.2021 21:03

      По моему я нигде не говорил, что это какое-то производительное production решение, но мне так же не понятно с чего вы взяли, что его производительность настолько ужасна? Для тестовой утилиты командной строки оно работает вполне нормально.