Tworzenie compiler passów

Compiler passy to potężny mechanizm pozwalający analizować i modyfikować szablony Latte po sparsowaniu ich do drzewa składniowego (AST) i przed wygenerowaniem końcowego kodu PHP. Umożliwia to zaawansowane manipulacje szablonami, optymalizacje, kontrole bezpieczeństwa (jak Sandbox) i zbieranie informacji o szablonach. Ten przewodnik przeprowadzi Cię przez tworzenie własnych compiler passów.

Czym jest compiler pass?

Aby zrozumieć rolę compiler passów, zobacz proces kompilacji w Latte. Jak widać, compiler passy działają w kluczowym momencie i pozwalają głęboko ingerować między wstępnym parsowaniem a końcowym wynikiem w postaci kodu.

W swojej istocie compiler pass to po prostu callable PHP (funkcja, metoda statyczna albo metoda instancji), który przyjmuje jeden argument: korzeń drzewa AST szablonu, zawsze będący instancją Latte\Compiler\Nodes\TemplateNode.

Głównym celem compiler passa jest zwykle jedno lub oba z poniższych:

  • Analiza: przejście po AST i zebranie informacji o szablonie (np. znalezienie wszystkich zdefiniowanych bloków, sprawdzenie użycia konkretnych tagów, upewnienie się, że spełnione są określone wymogi bezpieczeństwa).
  • Modyfikacja: zmiana struktury AST albo właściwości węzłów (np. automatyczne dodanie atrybutów HTML, optymalizacja pewnych kombinacji tagów, zastąpienie przestarzałych tagów nowymi, wprowadzenie reguł sandboxa).

Rejestracja

Compiler passy rejestruje się metodą getPasses() w rozszerzeniu. Metoda ta zwraca tablicę asocjacyjną, w której kluczami są unikalne nazwy passów (używane wewnętrznie i do ustalania kolejności), a wartościami callable PHP implementujące logikę passa.

use Latte\Compiler\Nodes\TemplateNode;
use Latte\Extension;

class MyExtension extends Extension
{
	public function getPasses(): array
	{
		return [
			'modificationPass' => $this->modifyTemplateAst(...),
			// ... inne passy ...
		];
	}

	public function modifyTemplateAst(TemplateNode $templateNode): void
	{
		// implementacja...
	}
}

Passy zarejestrowane przez rdzenne rozszerzenia Latte i przez Twoje własne rozszerzenia uruchamiają się kolejno. Kolejność może być istotna, zwłaszcza gdy jeden pass opiera się na wynikach lub modyfikacjach innego. Latte udostępnia mechanizm pomocniczy do sterowania tą kolejnością, gdy zajdzie potrzeba; szczegóły w dokumentacji Extension::getPasses().

Przykład AST

Aby lepiej wyobrazić sobie AST, dodajemy próbkę. Oto szablon źródłowy:

{foreach $category->getItems() as $item}
	<li>{$item->name|upper}</li>
	{else}
	no items found
{/foreach}

A oto jego reprezentacja w postaci 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\Compiler\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')
            )
        )
   )
)

Przechodzenie po AST za pomocą NodeTraverser

Ręczne pisanie funkcji rekurencyjnych przechodzących przez złożoną strukturę AST jest żmudne i podatne na błędy. Latte udostępnia do tego dedykowane narzędzie: Latte\Compiler\NodeTraverser. Klasa ta implementuje wzorzec projektowy Visitor, dzięki czemu przechodzenie po AST staje się systematyczne i łatwe do ogarnięcia.

Podstawowe użycie polega na utworzeniu instancji NodeTraverser i wywołaniu jej metody traverse(), przekazując korzeń AST oraz jeden lub dwa “wizytujące” callable:

use Latte\Compiler\Node;
use Latte\Compiler\NodeTraverser;
use Latte\Compiler\Nodes;

(new NodeTraverser)->traverse(
	$templateNode,

	// wizytator 'enter': wywoływany przy wejściu do węzła (przed jego potomkami)
	enter: function (Node $node) {
		echo "Entering node of type: " . $node::class . "\n";
		// tutaj możesz zbadać węzeł
		if ($node instanceof Nodes\TextNode) {
			// echo "Found text: " . $node->content . "\n";
		}
	},

	// wizytator 'leave': wywoływany przy wyjściu z węzła (po jego potomkach)
	leave: function (Node $node) {
		echo "Leaving node of type: " . $node::class . "\n";
		// tutaj możesz wykonać akcje po przetworzeniu potomków
	},
);

Możesz podać tylko wizytator enter, tylko leave albo oba, w zależności od potrzeb.

enter(Node $node): Ta funkcja wykonuje się dla każdego węzła przed odwiedzeniem przez traverser jego potomków. Przydaje się do:

  • zbierania informacji podczas schodzenia w głąb drzewa,
  • podejmowania decyzji przed przetworzeniem potomków (jak decyzja o ich pominięciu, zobacz Optymalizacja przechodzenia),
  • ewentualnej modyfikacji węzła przed odwiedzeniem potomków (rzadsze).

leave(Node $node): Ta funkcja wykonuje się dla każdego węzła po pełnym odwiedzeniu wszystkich jego potomków (i całych ich poddrzew, zarówno wejściu, jak i wyjściu). To najczęstsze miejsce na:

  • zastąpienie węzła po przetworzeniu jego potomków,
  • usuwanie węzłów z AST,
  • agregowanie informacji zebranych z całego poddrzewa.

Zarówno wizytator enter, jak i leave może opcjonalnie zwrócić wartość wpływającą na proces przechodzenia. Zwrócenie null (albo niczego) kontynuuje przechodzenie normalnie, zwrócenie instancji Node zastępuje bieżący węzeł, a zwrócenie specjalnych stałych, takich jak NodeTraverser::RemoveNode czy NodeTraverser::StopTraversal, zmienia przebieg, co wyjaśniają kolejne sekcje.

Jak działa przechodzenie

NodeTraverser wewnętrznie korzysta z metody getIterator(), którą musi implementować każda klasa Node (jak omówiono w Tworzenie własnych tagów). Iteruje po potomkach zwracanych przez getIterator(), rekurencyjnie wywołuje na nich traverse() i zapewnia, że wizytatory enter i leave są wywoływane we właściwej kolejności w głąb dla każdego węzła w drzewie dostępnego przez iteratory. To po raz kolejny pokazuje, dlaczego poprawnie zaimplementowana metoda getIterator() w węzłach Twoich własnych tagów jest absolutnie niezbędna do prawidłowego działania compiler passów.

Napiszmy prosty pass, który zliczy, ile razy w szablonie użyto tagu {do} (reprezentowanego przez Latte\Essential\Nodes\DoNode).

use Latte\Compiler\Node;
use Latte\Compiler\NodeTraverser;
use Latte\Compiler\Nodes\TemplateNode;
use Latte\Essential\Nodes\DoNode;

function countDoTags(TemplateNode $templateNode): void
{
	$count = 0;
	(new NodeTraverser)->traverse(
		$templateNode,
		enter: function (Node $node) use (&$count): void {
			if ($node instanceof DoNode) {
				$count++;
			}
		},
		// wizytator 'leave' nie jest do tego zadania potrzebny
	);

	echo "Found {do} tag $count times.\n";
}

$latte = new Latte\Engine;
$ast = $latte->parse($templateSource);
countDoTags($ast);

W tym przykładzie potrzebowaliśmy tylko wizytatora enter, aby sprawdzić typ każdego napotkanego węzła.

Następnie przyjrzymy się, jak używać tych wizytatorów do faktycznej modyfikacji AST.

Modyfikowanie AST

Jednym z głównych zastosowań compiler passów jest modyfikowanie drzewa składniowego. Pozwala to na potężne transformacje, optymalizacje albo wymuszanie reguł bezpośrednio na strukturze szablonu, zanim wygenerowany zostanie kod PHP. NodeTraverser udostępnia kilka sposobów, aby to osiągnąć wewnątrz wizytatorów enter i leave.

Ważna uwaga: Modyfikowanie AST wymaga ostrożności. Niepoprawne zmiany, jak usunięcie istotnych węzłów albo zastąpienie węzła niekompatybilnym typem, mogą prowadzić do błędów przy generowaniu kodu albo do nieoczekiwanego zachowania w czasie działania. Zawsze dokładnie testuj swoje passy modyfikujące.

Zmiana właściwości węzła

Najprostszym sposobem modyfikacji drzewa jest bezpośrednia zmiana właściwości publicznych węzłów napotkanych podczas przechodzenia. Wszystkie węzły przechowują swoje sparsowane argumenty, treść czy atrybuty we właściwościach publicznych.

Przykład: Utwórzmy pass, który znajdzie wszystkie statyczne węzły tekstowe (TextNode, reprezentujące zwykły HTML albo tekst poza tagami Latte) i zamieni ich treść na wielkie litery bezpośrednio w AST.

use Latte\Compiler\Node;
use Latte\Compiler\NodeTraverser;
use Latte\Compiler\Nodes\TemplateNode;
use Latte\Compiler\Nodes\TextNode;

function uppercaseStaticText(TemplateNode $templateNode): void
{
	(new NodeTraverser)->traverse(
		$templateNode,
		// możemy użyć 'enter', bo TextNode nie ma potomków do wcześniejszego przetworzenia
		enter: function (Node $node) {
			// czy ten węzeł to statyczny blok tekstu?
			if ($node instanceof TextNode) {
				// tak! modyfikujemy bezpośrednio jego publiczną właściwość 'content'
				$node->content = mb_strtoupper(html_entity_decode($node->content));
			}
			// nie trzeba niczego zwracać; modyfikacja dzieje się w miejscu
		},
	);
}

W tym przykładzie wizytator enter sprawdza, czy bieżący $node jest typu TextNode. Jeśli tak, bezpośrednio aktualizujemy jego publiczną właściwość $content za pomocą mb_strtoupper(). Zmienia to bezpośrednio treść tekstu statycznego przechowywaną w AST przed wygenerowaniem kodu PHP. Ponieważ modyfikujemy obiekt bezpośrednio, nie musimy niczego z wizytatora zwracać.

Efekt: Jeśli szablon zawierał <p>Hello</p>{= $var }<span>World</span>, po tym passie AST będzie reprezentować coś w rodzaju: <p>HELLO</p>{= $var }<span>WORLD</span>. NIE wpływa to na zawartość $var.

Zastępowanie węzłów

Potężniejszą techniką modyfikacji jest całkowite zastąpienie węzła innym. Robi się to przez zwrócenie nowej instancji Node z wizytatora enter albo leave. NodeTraverser podstawi wtedy zwrócony węzeł w miejsce pierwotnego w strukturze węzła nadrzędnego.

Przykład: Utwórzmy pass, który znajdzie wszystkie użycia stałej PHP_VERSION (reprezentowanej przez ConstantFetchNode) i zastąpi je bezpośrednio literałem łańcuchowym (StringNode) zawierającym rzeczywistą wersję PHP wykrytą podczas kompilacji. To forma optymalizacji w czasie kompilacji.

use Latte\Compiler\Node;
use Latte\Compiler\NodeTraverser;
use Latte\Compiler\Nodes\TemplateNode;
use Latte\Compiler\Nodes\Php\Expression\ConstantFetchNode;
use Latte\Compiler\Nodes\Php\Scalar\StringNode;

function inlinePhpVersion(TemplateNode $templateNode): void
{
	(new NodeTraverser)->traverse(
		$templateNode,
		// do zastępowania często używa się 'leave', co zapewnia wcześniejsze
		// przetworzenie potomków (jeśli są), choć 'enter' też by tu zadziałał
		leave: function (Node $node) {
			// czy to węzeł dostępu do stałej o nazwie 'PHP_VERSION'?
			if ($node instanceof ConstantFetchNode && (string) $node->name === 'PHP_VERSION') {
				// tworzymy nowy StringNode z bieżącą wersją PHP
				$newNode = new StringNode(PHP_VERSION);

				// opcjonalne, ale dobra praktyka: kopiujemy informacje o pozycji
				$newNode->position = $node->position;

				// zwracamy nowy StringNode; traverser zastąpi nim
				// pierwotny ConstantFetchNode
				return $newNode;
			}
			// jeśli nie zwrócimy węzła, pierwotny $node zostaje zachowany
		},
	);
}

Tutaj wizytator leave rozpoznaje konkretny ConstantFetchNode dla PHP_VERSION. Następnie tworzy zupełnie nowy StringNode zawierający wartość stałej PHP_VERSION w czasie kompilacji. Zwracając ten $newNode, mówi traverserowi, aby zastąpił nim pierwotny ConstantFetchNode w AST.

Efekt: Jeśli szablon zawierał {= PHP_VERSION }, a kompilacja odbywa się na PHP 8.2.1, AST po tym passie będzie faktycznie reprezentować {= '8.2.1' }.

Wybór enter czy leave przy zastępowaniu:

  • Użyj leave, jeśli utworzenie nowego węzła zależy od wyników przetworzenia potomków starego węzła albo jeśli po prostu chcesz mieć pewność, że potomkowie zostaną odwiedzeni przed zastąpieniem (częsta praktyka).
  • Użyj enter, jeśli chcesz zastąpić węzeł zanim jego potomkowie w ogóle zostaną odwiedzeni.

Usuwanie węzłów

Węzeł możesz całkowicie usunąć z AST, zwracając z wizytatora specjalną stałą NodeTraverser::RemoveNode.

Przykład: Usuńmy z wyniku wszystkie komentarze HTML (<!-- ... -->). Komentarzy Latte {* ... *} nie da się w ten sposób namierzyć, bo parser odrzuca ich treść i zastępuje je pustym NopNode zamiast dedykowanym węzłem komentarza, ale komentarze HTML są zachowywane jako węzły Html\CommentNode, więc możemy je tutaj usunąć.

use Latte\Compiler\Node;
use Latte\Compiler\NodeTraverser;
use Latte\Compiler\Nodes\TemplateNode;
use Latte\Compiler\Nodes\Html\CommentNode;

function removeHtmlComments(TemplateNode $templateNode): void
{
	(new NodeTraverser)->traverse(
		$templateNode,
		// 'enter' w zupełności wystarczy, bo do usunięcia komentarza nie potrzebujemy potomków
		enter: function (Node $node) {
			if ($node instanceof CommentNode) {
				// sygnalizujemy traverserowi, aby usunął ten węzeł z AST
				return NodeTraverser::RemoveNode;
			}
		},
	);
}

Uwaga: Używaj RemoveNode ostrożnie. Usunięcie węzła zawierającego istotną treść albo wpływającego na strukturę (jak usunięcie węzła z treścią pętli) może prowadzić do zepsutych szablonów albo nieprawidłowego wygenerowanego kodu. Najbezpieczniejsze jest to przy węzłach naprawdę opcjonalnych lub samodzielnych (jak komentarze czy tagi debugowe) albo przy pustych węzłach strukturalnych (np. pusty FragmentNode może w pewnych kontekstach zostać bezpiecznie usunięty przez pass porządkujący).

Te trzy metody – modyfikowanie właściwości, zastępowanie węzłów i usuwanie węzłów – dają podstawowe narzędzia do manipulowania AST wewnątrz Twoich compiler passów.

Optymalizacja przechodzenia

Drzewa AST szablonów mogą być całkiem duże i zawierać nawet tysiące węzłów. Odwiedzanie każdego z nich bywa zbędne i może odbić się na wydajności kompilacji, jeśli Twój pass interesuje się tylko określonymi częściami drzewa. NodeTraverser oferuje sposoby optymalizacji przechodzenia:

Pomijanie potomków

Jeśli wiesz, że po napotkaniu węzła określonego typu żaden z jego potomków nie może zawierać szukanych węzłów, możesz kazać traverserowi pominąć odwiedzanie jego potomków. Robi się to przez zwrócenie stałej NodeTraverser::DontTraverseChildren z wizytatora enter. Odcinasz w ten sposób całe gałęzie od ścieżki przechodzenia, co może oszczędzić sporo czasu, zwłaszcza w szablonach ze złożonymi wyrażeniami PHP wewnątrz tagów.

Zatrzymanie przechodzenia

Jeśli Twój pass potrzebuje znaleźć tylko pierwsze wystąpienie czegoś (określonego typu węzła, spełnienia warunku), możesz po znalezieniu całkowicie zatrzymać cały proces przechodzenia. Osiąga się to przez zwrócenie stałej NodeTraverser::StopTraversal z wizytatora enter albo leave. Metoda traverse() przestaje wtedy odwiedzać kolejne węzły. Jest to bardzo skuteczne, gdy potrzebujesz tylko pierwszego trafienia w potencjalnie bardzo dużym drzewie.

Przydatna klasa NodeHelpers

NodeTraverser daje precyzyjną kontrolę, ale Latte udostępnia też wygodną klasę pomocniczą Latte\Compiler\NodeHelpers, która opakowuje NodeTraverser dla kilku typowych zadań wyszukiwania i analizy, zwykle wymagając mniej powtarzalnego kodu.

find (Node $startNode, callable $filter)array

Ta statyczna metoda znajduje wszystkie węzły w poddrzewie zaczynającym się od $startNode (włącznie), które spełniają callback $filter. Zwraca tablicę pasujących węzłów.

Przykład: Znajdź w całym szablonie wszystkie węzły zmiennych (VariableNode).

use Latte\Compiler\NodeHelpers;
use Latte\Compiler\Nodes\Php\Expression\VariableNode;
use Latte\Compiler\Nodes\TemplateNode;

function findAllVariables(TemplateNode $templateNode): array
{
	return NodeHelpers::find(
		$templateNode,
		fn($node) => $node instanceof VariableNode,
	);
}

findFirst (Node $startNode, callable $filter)?Node

Podobna do find, ale zatrzymuje przechodzenie natychmiast po znalezieniu pierwszego węzła spełniającego callback $filter. Zwraca znaleziony obiekt Node albo null, jeśli żaden pasujący węzeł się nie znajdzie. To w istocie wygodna nakładka na NodeTraverser::StopTraversal.

Przykład: Znajdź węzeł {parameters}.

use Latte\Compiler\NodeHelpers;
use Latte\Compiler\Nodes\TemplateNode;
use Latte\Essential\Nodes\ParametersNode;

function findParametersNodeHelper(TemplateNode $templateNode): ?ParametersNode
{
	return NodeHelpers::findFirst(
		$templateNode->head, // dla wydajności szukamy tylko w sekcji head
		fn($node) => $node instanceof ParametersNode,
	);
}

clone (Latte\Compiler\Node $node)Node

Ta statyczna metoda tworzy głęboką kopię węzła i całego jego poddrzewa. Przydaje się, gdy potrzebujesz zduplikować gałąź AST, na przykład wstawić zmodyfikowaną kopię węzła, zostawiając oryginał nietknięty.

use Latte\Compiler\NodeHelpers;

$copy = NodeHelpers::clone($node);

toValue (ExpressionNode $node, bool $constants = false)mixed

Ta statyczna metoda próbuje obliczyć ExpressionNode w czasie kompilacji i zwrócić odpowiadającą mu wartość PHP. Działa niezawodnie tylko dla prostych węzłów literałowych (StringNode, IntegerNode, FloatNode, BooleanNode, NullNode) i instancji ArrayNode zawierających wyłącznie takie obliczalne elementy.

Jeśli $constants zostanie ustawione na true, spróbuje też rozwiązać ConstantFetchNode i ClassConstantFetchNode, sprawdzając defined() i używając constant().

Jeśli węzeł zawiera zmienne, wywołania funkcji albo inne elementy dynamiczne, nie da się go obliczyć w czasie kompilacji i metoda zgłosi InvalidArgumentException.

Zastosowanie: Uzyskanie statycznej wartości argumentu tagu podczas kompilacji, aby podjąć decyzje w czasie kompilacji.

use Latte\Compiler\NodeHelpers;
use Latte\Compiler\Nodes\Php\ExpressionNode;

function getStaticStringArgument(ExpressionNode $argumentNode): ?string
{
	try {
		$value = NodeHelpers::toValue($argumentNode);
		return is_string($value) ? $value : null;
	} catch (\InvalidArgumentException $e) {
		// argument nie był statycznym literałem łańcuchowym
		return null;
	}
}

toText (?Node $node): ?string

Ta statyczna metoda przydaje się do wydobycia zwykłego tekstu z prostych węzłów. Działa przede wszystkim z:

  • TextNode: zwraca jego $content.
  • FragmentNode: skleja wynik toText() dla wszystkich swoich potomków. Jeśli któryś potomek nie da się przekształcić w tekst (np. zawiera PrintNode), zwraca null.
  • NopNode: zwraca pusty łańcuch.
  • Pozostałe typy węzłów: zwraca null.

Zastosowanie: Uzyskanie statycznej treści tekstowej wartości atrybutu HTML albo prostego elementu HTML do analizy podczas compiler passa.

use Latte\Compiler\NodeHelpers;
use Latte\Compiler\Nodes\Html\AttributeNode;

function getStaticAttributeValue(AttributeNode $attr): ?string
{
	// $attr->value to zwykle AreaNode (jak FragmentNode albo TextNode)
	return NodeHelpers::toText($attr->value);
}

// przykład użycia w passie:
// if ($node instanceof Html\ElementNode && $node->name === 'meta') {
//     $nameAttrValue = $node->getAttribute('name');
//     if ($nameAttrValue === 'description') { ... }
// }

NodeHelpers może uprościć Twoje compiler passy, dostarczając gotowe rozwiązania typowych zadań przechodzenia po AST i jego analizy.

Praktyczne przykłady

Zastosujmy koncepcje przechodzenia i modyfikowania AST do rozwiązania kilku praktycznych problemów. Te przykłady pokazują typowe wzorce używane w compiler passach.

Automatyczne dodawanie loading="lazy" do <img>

Nowoczesne przeglądarki obsługują natywne leniwe ładowanie obrazków przez atrybut loading="lazy". Utwórzmy pass, który automatycznie doda ten atrybut do wszystkich tagów <img>, które nie mają jeszcze atrybutu loading.

use Latte\Compiler\Node;
use Latte\Compiler\NodeTraverser;
use Latte\Compiler\Nodes;
use Latte\Compiler\Nodes\Html;

function addLazyLoading(Nodes\TemplateNode $templateNode): void
{
	(new NodeTraverser)->traverse(
		$templateNode,
		// możemy użyć 'enter', bo modyfikujemy węzeł bezpośrednio
		// i decyzja nie zależy od potomków
		enter: function (Node $node) {
			// czy to element HTML o nazwie 'img'?
			if ($node instanceof Html\ElementNode && $node->name === 'img') {
				// sprawdzamy, czy atrybut 'loading' już istnieje (bez rozróżniania wielkości liter)
				foreach ($node->attributes->children as $attrNode) {
					if ($attrNode instanceof Html\AttributeNode
						&& $attrNode->name instanceof Nodes\TextNode // statyczna nazwa atrybutu
						&& strtolower($attrNode->name->content) === 'loading'
					) {
						return; // już istnieje, nic nie robimy
					}
				}

				// jeśli atrybuty nie są puste, poprzedzamy spacją
				if ($node->attributes->children) {
					$node->attributes->children[] = new Nodes\TextNode(' ');
				}

				// tworzymy nowy węzeł atrybutu: loading="lazy"
				$node->attributes->children[] = new Html\AttributeNode(
					name: new Nodes\TextNode('loading'),
					value: new Nodes\TextNode('lazy'),
					quote: '"',
				);
				// modyfikacja wykonana w miejscu, nie trzeba niczego zwracać
			}
		},
	);
}

Wyjaśnienie:

  • Wizytator enter szuka węzłów Html\ElementNode o nazwie img.
  • Przechodzi po istniejących atrybutach ($node->attributes->children), aby sprawdzić, czy atrybut loading już tam jest.
  • Jeśli go nie znajdzie, tworzy nowy Html\AttributeNode reprezentujący loading="lazy" i dodaje go (w razie potrzeby poprzedzając spacją).

Kontrola wywołań funkcji

Compiler passy stanowią fundament Sandboxa w Latte. Prawdziwy Sandbox jest wyrafinowany, ale możemy pokazać podstawową zasadę kontroli zabronionych wywołań funkcji.

Cel: Uniemożliwić użycie w wyrażeniach szablonu potencjalnie niebezpiecznej funkcji shell_exec.

use Latte\Compiler\Node;
use Latte\Compiler\NodeTraverser;
use Latte\Compiler\Nodes;
use Latte\Compiler\Nodes\Php;
use Latte\SecurityViolationException;

function checkForbiddenFunctions(Nodes\TemplateNode $templateNode): void
{
	$forbiddenFunctions = ['shell_exec' => true, 'exec' => true]; // prosta lista

	(new NodeTraverser)->traverse(
		$templateNode,
		enter: function (Node $node) use ($forbiddenFunctions) {
			// czy to węzeł bezpośredniego wywołania funkcji?
			if ($node instanceof Php\Expression\FunctionCallNode
				&& $node->name instanceof Php\NameNode
				&& isset($forbiddenFunctions[strtolower((string) $node->name)])
			) {
				throw new SecurityViolationException(
					"Function {$node->name}() is not allowed.",
					$node->position,
				);
			}
		},
	);
}

Wyjaśnienie:

  • Definiujemy listę zabronionych nazw funkcji.
  • Wizytator enter sprawdza FunctionCallNode.
  • Jeśli nazwa funkcji ($node->name) jest statycznym NameNode, porównujemy jej reprezentację łańcuchową pisaną małymi literami z naszą listą zabronionych.
  • Jeśli znajdziemy zabronioną funkcję, zgłaszamy Latte\SecurityViolationException, co jasno wskazuje na naruszenie reguły bezpieczeństwa i zatrzymuje kompilację.

Te przykłady pokazują, jak compiler passy z użyciem NodeTraverser można wykorzystać do analizy, automatycznych modyfikacji i wymuszania ograniczeń bezpieczeństwa przez bezpośrednią pracę ze strukturą AST szablonu.

Dobre praktyki

Pisząc compiler passy, miej na uwadze te wskazówki, aby tworzyć solidne, łatwe w utrzymaniu i wydajne rozszerzenia:

  • Kolejność ma znaczenie: Pamiętaj o kolejności, w jakiej uruchamiają się passy. Jeśli Twój pass opiera się na strukturze AST utworzonej przez inny pass (np. rdzenne passy Latte albo inny własny pass) albo jeśli inne passy mogą zależeć od Twoich modyfikacji, użyj mechanizmu porządkowania udostępnianego przez Extension::getPasses() do zdefiniowania zależności (before/after). Szczegóły w dokumentacji Extension::getPasses().
  • Jedna odpowiedzialność: Dąż do tego, aby pass wykonywał jedno, dobrze określone zadanie. Przy złożonych transformacjach rozważ podzielenie logiki na kilka passów, na przykład jeden do analizy, a drugi do modyfikacji na podstawie jej wyników. Poprawia to przejrzystość i testowalność.
  • Wydajność: Pamiętaj, że compiler passy wydłużają czas kompilacji szablonu (choć zwykle dzieje się to tylko raz, do momentu zmiany szablonu). W miarę możliwości unikaj w passach operacji kosztownych obliczeniowo. Korzystaj z optymalizacji przechodzenia, takich jak NodeTraverser::DontTraverseChildren i NodeTraverser::StopTraversal, gdy tylko wiesz, że nie musisz odwiedzać pewnych części AST.
  • Używaj NodeHelpers: Przy typowych zadaniach, takich jak znajdowanie konkretnych węzłów albo statyczne obliczanie prostych wyrażeń, sprawdź, czy Latte\Compiler\NodeHelpers nie oferuje odpowiedniej metody, zanim napiszesz własną logikę na NodeTraverser. Może to oszczędzić czas i ograniczyć powtarzalny kod.
  • Obsługa błędów: Jeśli Twój pass wykryje błąd albo nieprawidłowy stan w AST szablonu, zgłoś Latte\CompileException (albo Latte\SecurityViolationException przy problemach bezpieczeństwa) z jasnym komunikatem i odpowiednim obiektem Position (zwykle $node->position). Daje to pomocną informację zwrotną autorowi szablonu.
  • Idempotentność (jeśli to możliwe): Idealnie wielokrotne uruchomienie Twojego passa na tym samym AST powinno dawać ten sam wynik co uruchomienie jednorazowe. Nie zawsze da się to osiągnąć, ale gdy się uda, upraszcza to debugowanie i rozumowanie o wzajemnym oddziaływaniu passów. Na przykład zadbaj, aby Twój pass modyfikujący sprawdzał, czy modyfikacja nie została już zastosowana, zanim zastosuje ją ponownie.

Trzymając się tych praktyk, możesz skutecznie wykorzystywać compiler passy do rozszerzania możliwości Latte w potężny i niezawodny sposób, przyczyniając się do bezpieczniejszego, bardziej zoptymalizowanego i bogatszego w funkcje przetwarzania szablonów.

wersja: 3.x