Tokenizer: Токенизация строк

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

(Для более опытных программистов)

Установка:

composer require nette/tokenizer

Токенизация строк

Давайте создадим простой токенизатор, который разделяет строки на числа, пробелы и буквы.

$tokenizer = new Nette\Tokenizer\Tokenizer([
	T_DNUMBER => '\d+',
	T_WHITESPACE => '\s+',
	T_STRING => '\w+',
]);

Подсказка: Если вам интересно, откуда взялись константы T_, то это внутренний тип, используемый для разбора кода. Они охватывают большинство распространенных имен токенов, которые нам обычно нужны. Помните, что их значение не гарантировано, поэтому не используйте цифры для сравнения.

Теперь, когда мы передадим ему строку, она вернет поток из токенов.

$stream = $tokenizer->tokenize("say \n123");

Результирующий массив лексем $stream->tokens будет выглядеть следующим образом:

[
	new Token('say', T_STRING, 0),
	new Token(" \n", T_WHITESPACE, 3),
	new Token('123', T_DNUMBER, 5),
]

Также вы можете получить доступ к отдельным свойствам токена:

$firstToken = $stream->tokens[0];
$firstToken->value; // значение
$firstToken->type; // значение T_STRING
$firstToken->offset; // позиция в строке: 0

Просто, не так ли?

Обработка токенов

Теперь мы знаем, как создавать токены из строки. Давайте эффективно обработаем их, используя поток. В нем есть множество действительно потрясающих методов, если вам нужно перемещать токены!

Давайте попробуем разобрать простую аннотацию из PHPDoc и создать из нее объект. Какие регулярные выражения нам нужны для лексем? Все аннотации начинаются с @, затем идет имя, пробелы и значение.

  • @ для начала аннотации
  • \s+ для пробелов
  • \w+ для строк

Никогда не используйте захватывающие подшаблоны в регулярных выражениях Tokenizer, например '(ab)+c', используйте только не захватывающие '(?:ab)+c'.

Это должно работать с простыми аннотациями, верно? Теперь покажем входную строку, которую мы попытаемся разобрать.

$input = '
	@author David Grudl
	@package Nette
';

Давайте создадим класс Parser, который будет принимать строку и возвращать массив пар [имя, значение]. Это будет очень просто.

use Nette\Tokenizer\Tokenizer;
use Nette\Tokenizer\Stream;

class Parser
{
	const T_AT = 1;
	const T_WHITESPACE = 2;
	const T_STRING = 3;

	private Tokenizer $tokenizer;
	private Stream $stream;

	public function __construct()
	{
		$this->tokenizer = new Tokenizer([
			self::T_AT => '@',
			self::T_WHITESPACE => '\s+',
			self::T_STRING => '\w+',
		]);
	}

	public function parse(string $input): array
	{
		$this->stream = $this->tokenizer->tokenize($input);

		$result = [];
		while ($this->stream->nextToken()) {
			if ($this->stream->isCurrent(self::T_AT)) {
				$result[] = $this->parseAnnotation();
			}
		}

		return $result;
	}

	private function parseAnnotation(): array
	{
		$name = $this->stream->joinUntil(self::T_WHITESPACE);
		$this->stream->nextUntil(self::T_STRING);
		$content = $this->stream->joinUntil(self::T_AT);

		return [$name, trim($content)];
	}
}
$parser = new Parser;
$annotations = $parser->parse($input);

Итак, что же делает метод parse()? Он перебирает лексемы и ищет символ @, с которого начинаются аннотации. Вызов nextToken() перемещает курсор на следующий токен. Метод isCurrent() проверяет, является ли текущий токен в курсоре заданным типом. Затем, если @ найден, метод parse() вызывает parseAnnotation(), который ожидает, что аннотации будут в очень специфическом формате.

Сначала, используя метод joinUntil(), поток продолжает перемещать курсор и добавлять значения лексем в буфер, пока не найдет лексему нужного типа, затем останавливается и возвращает вывод буфера. Поскольку в данной позиции находится только одна лексема типа T_STRING и это 'name', в переменной $name будет значение 'name'.

Метод nextUntil() похож на joinUntil(), но у него нет буфера. Он перемещает курсор только до тех пор, пока не найдет маркер. Поэтому этот вызов просто пропускает все пробелы после имени аннотации.

И затем, есть ещё один joinUntil(), который ищет следующий @. Этот конкретный вызов вернет `David Grudl\n`.

И вот, мы разобрали целую аннотацию! Вероятно, $content заканчивается пробелами, поэтому мы должны обрезать его. Теперь мы можем вернуть эту конкретную аннотацию в виде пары [$name, $content].

Попробуйте скопировать код и запустить его. Если вы сбросите переменную $annotations, она должна выдать похожий результат.

array (2)
   0 => array (2)
   |  0 => 'author'
   |  1 => 'David Grudl'
   1 => array (2)
   |  0 => 'package'
   |  1 => 'Nette'

Потоковые методы

Поток может вернуть текущий токен с помощью метода currentToken() или только его значение с помощью currentValue().

nextToken() перемещает курсор и возвращает токен. Если вы не даете ему никаких аргументов, он просто возвращает следующий токен.

nextValue() подобно nextToken(), но возвращает только значение токена.

Большинство методов также принимают несколько аргументов, поэтому вы можете искать несколько типов одновременно.

// итерация до тех пор, пока не будет найдена строка или пробел, затем возвращает следующий токен
$token = $stream->nextToken(T_STRING, T_WHITESPACE);

// получить следующий токен
$token = $stream->nextToken();

Можно также выполнять поиск по значению токена.

// перемещайте курсор, пока не найдете лексему, содержащую только '@', затем остановитесь и верните её обратно
$token = $stream->nextToken('@');

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

joinUntil() аналогичен nextUntil(), но конкатенирует все пройденные токены и возвращает строку.

joinAll() просто конкатенирует все оставшиеся значения маркеров и возвращает их. Он перемещает курсор в конец потока токенов

nextAll() как и joinAll(), но возвращает массив токенов.

isCurrent() проверяет, равен ли текущий токен или значение текущего токена одному из заданных аргументов.

// является ли текущий поток токеном '@' или типом T_AT?
$stream->isCurrent(T_AT, '@');

isNext() как и isCurrent(), но проверяет следующий токен.

isPrev() подобно isCurrent(), но проверяет предыдущий токен.

И последний метод reset() сбрасывает курсор, чтобы вы могли снова итерировать поток токенов.