Проходы компиляции
Проходы компиляции предоставляют мощный механизм для анализа и модификации шаблонов Latte после их парсинга в абстрактное синтаксическое дерево (AST) и перед генерацией финального PHP-кода. Это позволяет осуществлять продвинутую манипуляцию шаблонами, оптимизации, проверки безопасности (такие как Песочница) и сбор информации о шаблонах. В этом руководстве мы рассмотрим создание собственных проходов компиляции.
Что такое проход компиляции?
Для понимания роли проходов компиляции ознакомьтесь с процессом компиляции Latte. Как вы можете видеть, проходы компиляции работают на ключевом этапе, позволяя глубоко вмешиваться между начальным парсингом и финальным выводом кода.
По своей сути, проход компиляции — это просто вызываемый PHP-объект
(например, функция, статический метод или метод экземпляра), который
принимает один аргумент: корневой узел AST шаблона, который всегда
является экземпляром Latte\Compiler\Nodes\TemplateNode
.
Основной целью прохода компиляции обычно является одно или оба из следующего:
- Анализ: Проходить по AST и собирать информацию о шаблоне (например, найти все определенные блоки, проверить использование специфических тегов, убедиться в выполнении определенных ограничений безопасности).
- Модификация: Изменять структуру AST или атрибуты узлов (например, автоматически добавлять HTML-атрибуты, оптимизировать определенные комбинации тегов, заменять устаревшие теги новыми, реализовывать правила песочницы).
Регистрация
Проходы компиляции регистрируются с помощью метода расширения getPasses()
. Этот метод
возвращает ассоциативный массив, где ключи — это уникальные имена
проходов (используемые внутри и для сортировки), а значения — это
вызываемые PHP-объекты, реализующие логику прохода.
Проходы, зарегистрированные базовыми расширениями Latte и вашими
собственными расширениями, выполняются последовательно. Порядок
может быть важен, особенно если один проход зависит от результатов или
модификаций другого. Latte предоставляет вспомогательный механизм для
контроля этого порядка, если это необходимо; см. документацию к Extension::getPasses()
для подробностей.
Пример AST
Для лучшего представления об AST, приводим пример. Это исходный шаблон:
А это его представление в виде AST:
Latte\Compiler\Nodes\TemplateNode( Latte\Compiler\Nodes\FragmentNode( - Latte\Essential\Nodes\ForeachNode( expression: Latte\Compiler\Nodes\Php\Expression\MethodCallNode( object: Latte\Compiler\Nodes\Php\Expression\VariableNode('$category') name: Latte\Compiler\Nodes\Php\IdentifierNode('getItems') ) value: Latte\Compiler\Nodes\Php\Expression\VariableNode('$item') content: Latte\Compiler\Nodes\FragmentNode( - Latte\Compiler\Nodes\TextNode(' ') - Latte\Compiler\Nodes\Html\ElementNode('li')( content: Latte\Essential\Nodes\PrintNode( expression: Latte\Compiler\Nodes\Php\Expression\PropertyFetchNode( object: Latte\Compiler\Nodes\Php\Expression\VariableNode('$item') name: Latte\Compiler\Nodes\Php\IdentifierNode('name') ) modifier: Latte\Compiler\Nodes\Php\ModifierNode( filters: - Latte\Compiler\Nodes\Php\FilterNode('upper') ) ) ) ) else: Latte\Compiler\Nodes\FragmentNode( - Latte\Compiler\Nodes\TextNode('no items found') ) ) ) )
Обход AST с помощью NodeTraverser
Написание рекурсивных функций вручную для обхода сложной структуры AST утомительно и подвержено ошибкам. Latte предоставляет специальный инструмент для этой цели: Latte\Compiler\NodeTraverser. Этот класс реализует шаблон проектирования Visitor, благодаря которому обход AST становится систематическим и легко управляемым.
Базовое использование включает создание экземпляра NodeTraverser
и
вызов его метода traverse()
, передавая корневой узел AST и один или два
„посетителя“ (visitor) — вызываемые объекты:
Вы можете предоставить только enter
посетителя, только
leave
посетителя, или обоих, в зависимости от ваших
потребностей.
enter(Node $node)
: Эта функция выполняется для каждого узла
перед тем, как обходчик посетит любые дочерние узлы этого узла. Она
полезна для:
- Сбора информации при обходе дерева сверху вниз.
- Принятия решений перед обработкой дочерних узлов (например, решение пропустить их, см. Оптимизация обхода).
- Потенциального изменения узла перед посещением дочерних узлов (менее часто).
leave(Node $node)
: Эта функция выполняется для каждого узла
после того, как все его дочерние узлы (и их полные поддеревья) были
полностью посещены (как вход, так и выход). Это наиболее частое
место для:
Оба посетителя enter
и leave
могут опционально возвращать
значение для влияния на процесс обхода. Возврат null
(или ничего)
продолжает обход нормально, возврат экземпляра Node
заменяет
текущий узел, а возврат специальных констант, таких как
NodeTraverser::RemoveNode
или NodeTraverser::StopTraversal
, модифицирует поток,
как объяснено в следующих разделах.
Как работает обход
NodeTraverser
внутренне использует метод getIterator()
, который
должен реализовывать каждый класс Node
(как обсуждалось в Создание пользовательских
тегов). Он итерирует по дочерним узлам, полученным с помощью
getIterator()
, рекурсивно вызывает traverse()
для них и гарантирует,
что посетители enter
и leave
вызываются в правильном порядке
(сначала вглубь) для каждого узла в дереве, доступного через итераторы.
Это еще раз подчеркивает, почему правильно реализованный
getIterator()
в ваших собственных узлах тегов абсолютно необходим для
правильной работы проходов компиляции.
Давайте напишем простой проход, который подсчитывает, сколько раз в
шаблоне используется тег {do}
(представленный
Latte\Essential\Nodes\DoNode
).
В этом примере нам нужен был только посетитель enter
для
проверки типа каждого посещенного узла.
Далее мы рассмотрим, как эти посетители фактически модифицируют AST.
Модификация AST
Одной из основных целей проходов компиляции является модификация
абстрактного синтаксического дерева. Это позволяет выполнять мощные
преобразования, оптимизации или применять правила непосредственно к
структуре шаблона перед генерацией PHP-кода. NodeTraverser
предоставляет несколько способов достижения этого в рамках
посетителей enter
и leave
.
Важное примечание: Модификация AST требует осторожности. Неправильные изменения — такие как удаление основных узлов или замена узла несовместимым типом — могут привести к ошибкам во время генерации кода или вызвать неожиданное поведение во время выполнения программы. Всегда тщательно тестируйте ваши модифицирующие проходы.
Изменение свойств узлов
Самый простой способ модифицировать дерево — это прямое изменение публичных свойств узлов, посещенных во время обхода. Все узлы хранят свои распарсенные аргументы, содержимое или атрибуты в публичных свойствах.
Пример: Создадим проход, который находит все статические
текстовые узлы (TextNode
, представляющие обычный HTML или текст вне
тегов Latte) и преобразует их содержимое в верхний регистр прямо в
AST.
В этом примере посетитель enter
проверяет, является ли текущий
$node
типом TextNode
. Если да, мы напрямую обновляем его
публичное свойство $content
с помощью mb_strtoupper()
. Это напрямую
изменяет содержимое статического текста, хранящегося в AST перед
генерацией PHP-кода. Поскольку мы модифицируем объект напрямую, нам не
нужно ничего возвращать из посетителя.
Эффект: Если шаблон содержал
<p>Hello</p>{= $var }<span>World</span>
, после этого прохода AST будет
представлять что-то вроде: <p>HELLO</p>{= $var }<span>WORLD</span>
.
Это НЕ ВЛИЯЕТ на содержимое $var
.
Замена узлов
Более мощной техникой модификации является полная замена узла
другим. Это делается путем возврата нового экземпляра Node
из
посетителя enter
или leave
. NodeTraverser
затем заменяет
исходный узел возвращенным в структуре родительского узла.
Пример: Создадим проход, который находит все использования
константы PHP_VERSION
(представленные ConstantFetchNode
) и заменяет их
непосредственно строковым литералом (StringNode
), содержащим
фактическую версию PHP, обнаруженную во время компиляции. Это
форма оптимизации во время компиляции.
Здесь посетитель leave
идентифицирует специфический
ConstantFetchNode
для PHP_VERSION
. Затем он создает совершенно новый
StringNode
, содержащий значение константы PHP_VERSION
во время
компиляции. Возвращая этот $newNode
, он сообщает обходчику
заменить исходный ConstantFetchNode
в AST.
Эффект: Если шаблон содержал {= PHP_VERSION }
и компиляция
выполняется на PHP 8.2.1, AST после этого прохода будет эффективно
представлять {= '8.2.1' }
.
Выбор enter
vs. leave
для замены:
- Используйте
leave
, если создание нового узла зависит от результатов обработки дочерних узлов старого узла, или если вы просто хотите убедиться, что дочерние узлы были посещены перед заменой (обычная практика). - Используйте
enter
, если вы хотите заменить узел перед тем, как его дочерние узлы вообще будут посещены.
Удаление узлов
Вы можете полностью удалить узел из AST, вернув специальную константу
NodeTraverser::RemoveNode
из посетителя.
Пример: Удалим все комментарии шаблона ({* ... *}
), которые
представлены CommentNode
в AST, генерируемом ядром Latte (хотя обычно они
обрабатываются раньше, это служит примером).
Предупреждение: Используйте RemoveNode
осторожно. Удаление
узла, который содержит основное содержимое или влияет на структуру
(например, удаление узла содержимого цикла), может привести к
поврежденным шаблонам или невалидному сгенерированному коду.
Наиболее безопасно это для узлов, которые действительно необязательны
или автономны (например, комментарии или отладочные теги) или для
пустых структурных узлов (например, пустой FragmentNode
может быть
безопасно удален в некоторых контекстах проходом для очистки).
Эти три метода — изменение свойств, замена узлов и удаление узлов — предоставляют основные инструменты для манипулирования AST в рамках ваших проходов компиляции.
Оптимизация обхода
AST шаблонов может быть довольно большим, потенциально содержащим
тысячи узлов. Обход каждого отдельного узла может быть излишним и
влиять на производительность компиляции, если ваш проход интересуется
только специфическими частями дерева. NodeTraverser
предлагает
способы оптимизации обхода:
Пропуск дочерних узлов
Если вы знаете, что как только вы столкнетесь с определенным типом
узла, ни один из его потомков не может содержать узлы, которые вы ищете,
вы можете сказать обходчику пропустить посещение его дочерних узлов.
Это делается путем возврата константы NodeTraverser::DontTraverseChildren
из
посетителя enter
. Таким образом, вы пропускаете целые ветви
при обходе, что потенциально экономит значительное время, особенно в
шаблонах со сложными PHP-выражениями внутри тегов.
Остановка обхода
Если ваш проход должен найти только первое вхождение чего-либо
(специфический тип узла, выполнение условия), вы можете полностью
остановить весь процесс обхода, как только найдете это. Это
достигается путем возврата константы NodeTraverser::StopTraversal
из
посетителя enter
или leave
. Метод traverse()
перестанет
посещать любые другие узлы. Это очень эффективно, если вам нужно только
первое совпадение в потенциально очень большом дереве.
Полезный помощник NodeHelpers
Хотя NodeTraverser
предлагает тонкий контроль, Latte также
предоставляет удобный вспомогательный класс, Latte\Compiler\NodeHelpers, который
инкапсулирует NodeTraverser
для нескольких распространенных задач
поиска и анализа, часто требующих меньше подготовительного кода.
find(Node $startNode, callable $filter): array
Этот статический метод находит все узлы в поддереве,
начинающемся с $startNode
(включая его), которые удовлетворяют callback'у
$filter
. Возвращает массив соответствующих узлов.
Пример: Найти все узлы переменных (VariableNode
) во всем
шаблоне.
findFirst(Node $startNode, callable $filter): ?Node
Аналогично find
, но останавливает обход немедленно после
нахождения первого узла, который удовлетворяет callback'у $filter
.
Возвращает найденный объект Node
или null
, если не найден ни
один соответствующий узел. Это, по сути, удобная обертка вокруг
NodeTraverser::StopTraversal
.
Пример: Найти узел {parameters}
(то же самое, что и ручной пример
ранее, но короче).
toValue(ExpressionNode $node, bool $constants = false): mixed
Этот статический метод пытается вычислить ExpressionNode
во время
компиляции и вернуть его соответствующее PHP-значение. Работает
надежно только для простых литеральных узлов (StringNode
,
IntegerNode
, FloatNode
, BooleanNode
, NullNode
) и экземпляров
ArrayNode
, содержащих только такие вычисляемые элементы.
Если $constants
установлено в true
, он также будет пытаться
разрешить ConstantFetchNode
и ClassConstantFetchNode
, проверяя defined()
и используя constant()
.
Если узел содержит переменные, вызовы функций или другие
динамические элементы, он не может быть вычислен во время компиляции, и
метод выбросит InvalidArgumentException
.
Пример использования: Получение статического значения аргумента тега во время компиляции для принятия решений во время компиляции.
toText(?Node $node): ?string
Этот статический метод полезен для извлечения простого текстового содержимого из простых узлов. Работает в основном с:
TextNode
: Возвращает его$content
.FragmentNode
: Конкатенирует результатtoText()
для всех его дочерних узлов. Если какой-либо дочерний узел не преобразуется в текст (например, содержитPrintNode
), возвращаетnull
.NopNode
: Возвращает пустую строку.- Другие типы узлов: Возвращает
null
.
Пример использования: Получение статического текстового содержимого значения HTML-атрибута или простого HTML-элемента для анализа во время прохода компиляции.
NodeHelpers
может упростить ваши проходы компиляции, предоставляя
готовые решения для распространенных задач обхода и анализа AST.
Практические примеры
Давайте применим концепции обхода и модификации AST для решения некоторых практических задач. Эти примеры демонстрируют распространенные шаблоны, используемые в проходах компиляции.
Автоматическое добавление
loading="lazy"
к <img>
Современные браузеры поддерживают нативную ленивую загрузку для
изображений с помощью атрибута loading="lazy"
. Создадим проход,
который автоматически добавляет этот атрибут ко всем тегам
<img>
, у которых еще нет атрибута loading
.
Объяснение:
- Посетитель
enter
ищет узлыHtml\ElementNode
с именемimg
. - Итерирует по существующим атрибутам (
$node->attributes->children
) и проверяет, присутствует ли уже атрибутloading
. - Если не найден, создает новый
Html\AttributeNode
, представляющийloading="lazy"
.
Проверка вызовов функций
Проходы компиляции являются основой Песочницы Latte. Хотя настоящая Песочница сложна, мы можем продемонстрировать основной принцип проверки запрещенных вызовов функций.
Цель: Запретить использование потенциально опасной функции
shell_exec
в выражениях шаблона.
Объяснение:
- Определяем список запрещенных имен функций.
- Посетитель
enter
проверяетFunctionCallNode
. - Если имя функции (
$node->name
) является статическимNameNode
, проверяем его строковое представление в нижнем регистре по нашему запрещенному списку. - Если найдена запрещенная функция, выбрасываем
Latte\SecurityViolationException
, которая четко указывает на нарушение правила безопасности и останавливает компиляцию.
Эти примеры показывают, как проходы компиляции с использованием
NodeTraverser
могут быть использованы для анализа, автоматических
модификаций и применения ограничений безопасности путем
взаимодействия непосредственно со структурой AST шаблона.
Лучшие практики
При написании проходов компиляции помните об этих рекомендациях для создания надежных, поддерживаемых и эффективных расширений:
- Порядок важен: Помните о порядке, в котором выполняются проходы.
Если ваш проход зависит от структуры AST, созданной другим проходом
(например, базовые проходы Latte или другой пользовательский проход), или
если другие проходы могут зависеть от ваших модификаций, используйте
механизм сортировки, предоставляемый
Extension::getPasses()
, для определения зависимостей (before
/after
). См. документацию кExtension::getPasses()
для подробностей. - Единая ответственность: Старайтесь создавать проходы, которые выполняют одну четко определенную задачу. Для сложных преобразований рассмотрите возможность разделения логики на несколько проходов — возможно, один для анализа и другой для модификации на основе результатов анализа. Это улучшает читаемость и тестируемость.
- Производительность: Помните, что проходы компиляции добавляют
время компиляции шаблона (хотя это обычно происходит только один раз,
пока шаблон не изменится). Избегайте вычислительно затратных операций
в ваших проходах, если это возможно. Используйте оптимизации обхода,
такие как
NodeTraverser::DontTraverseChildren
иNodeTraverser::StopTraversal
, всякий раз, когда вы знаете, что вам не нужно посещать определенные части AST. - Используйте
NodeHelpers
: Для распространенных задач, таких как поиск специфических узлов или статическое вычисление простых выражений, проверьте, предлагает лиLatte\Compiler\NodeHelpers
подходящий метод, прежде чем писать собственную логикуNodeTraverser
. Это может сэкономить время и уменьшить количество подготовительного кода. - Обработка ошибок: Если ваш проход обнаруживает ошибку или
невалидное состояние в AST шаблона, выбросьте
Latte\CompileException
(илиLatte\SecurityViolationException
для проблем безопасности) с четким сообщением и соответствующим объектомPosition
(обычно$node->position
). Это предоставляет полезную обратную связь разработчику шаблона. - Идемпотентность (если возможно): В идеале, запуск вашего прохода несколько раз на одном и том же AST должен давать тот же результат, что и его однократный запуск. Это не всегда выполнимо, но упрощает отладку и рассуждения о взаимодействиях проходов, если это достигнуто. Например, убедитесь, что ваш модифицирующий проход проверяет, была ли модификация уже применена, прежде чем применять ее снова.
Следуя этим практикам, вы можете эффективно использовать проходы компиляции для расширения возможностей Latte мощным и надежным способом, что способствует более безопасной, оптимизированной или функционально богатой обработке шаблонов.