Commit a9f673d9 authored by Christian Kuhn's avatar Christian Kuhn
Browse files

[!!!][FEATURE] Implement TypoScript function modifier event

The old TypoScript parser allows implementing own functions
for the := operator using a hook:

  someIdentifier := myCustomFunction(myFunctionArgument)

The new TypoScript parser does not implement this hook
and adds a new event instead.

Resolves: #98016
Related: #97816
Releases: main
Change-Id: I66dd4e393333c239b75a5935aacdbde46b1518e0
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/75280

Tested-by: Stefan Bürk's avatarStefan Bürk <stefan@buerk.tech>
Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Tested-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Stefan Bürk's avatarStefan Bürk <stefan@buerk.tech>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
parent 816476c2
......@@ -291,7 +291,8 @@
"TYPO3\\CMS\\Recycler\\Tests\\": "typo3/sysext/recycler/Tests/",
"TYPO3\\CMS\\T3editor\\Tests\\": "typo3/sysext/t3editor/Tests/",
"TYPO3\\CMS\\Tstemplate\\Tests\\": "typo3/sysext/tstemplate/Tests/",
"TYPO3Tests\\TestLogger\\": "typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_logger/Classes/"
"TYPO3Tests\\TestLogger\\": "typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_logger/Classes/",
"TYPO3Tests\\TestTyposcriptAstFunctionEvent\\": "typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_typoscript_ast_function_event/Classes/"
},
"classmap": [
"typo3/sysext/core/Tests/Unit/Core/Fixtures/test_extension/",
......
......@@ -17,8 +17,10 @@ declare(strict_types=1);
namespace TYPO3\CMS\Core\TypoScript\AST;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Core\TypoScript\AST\CurrentObjectPath\CurrentObjectPath;
use TYPO3\CMS\Core\TypoScript\AST\CurrentObjectPath\CurrentObjectPathStack;
use TYPO3\CMS\Core\TypoScript\AST\Event\EvaluateModifierFunctionEvent;
use TYPO3\CMS\Core\TypoScript\AST\Node\ChildNode;
use TYPO3\CMS\Core\TypoScript\AST\Node\ChildNodeInterface;
use TYPO3\CMS\Core\TypoScript\AST\Node\NodeInterface;
......@@ -54,6 +56,11 @@ final class AstBuilder
*/
private array $flatConstants = [];
public function __construct(
private readonly EventDispatcherInterface $eventDispatcher,
) {
}
/**
* @param array<string, string> $flatConstants
*/
......@@ -237,42 +244,43 @@ final class AstBuilder
* Evaluate operator functions, example TypoScript:
* "page.10.value := appendString(foo)"
*/
private function evaluateValueModifier(Token $functionNameToken, ?Token $functionValueToken, ?string $currentValue): ?string
private function evaluateValueModifier(Token $functionNameToken, ?Token $functionArgumentToken, ?string $originalValue): ?string
{
$functionValue = '';
if ($functionValueToken) {
$functionValue = $functionValueToken->getValue();
$functionName = $functionNameToken->getValue();
$functionArgument = null;
if ($functionArgumentToken) {
$functionArgument = $functionArgumentToken->getValue();
}
switch ($functionNameToken->getValue()) {
switch ($functionName) {
case 'prependString':
return $functionValue . $currentValue;
return $functionArgument . $originalValue;
case 'appendString':
return $currentValue . $functionValue;
return $originalValue . $functionArgument;
case 'removeString':
return str_replace($functionValue, '', $currentValue);
return str_replace((string)$functionArgument, '', $originalValue);
case 'replaceString':
$functionValueArray = explode('|', $functionValue, 2);
$functionValueArray = explode('|', (string)$functionArgument, 2);
$fromStr = $functionValueArray[0] ?? '';
$toStr = $functionValueArray[1] ?? '';
return str_replace($fromStr, $toStr, $currentValue);
return str_replace($fromStr, $toStr, $originalValue);
case 'addToList':
return ($currentValue !== null ? $currentValue . ',' : '') . $functionValue;
return ($originalValue !== null ? $originalValue . ',' : '') . $functionArgument;
case 'removeFromList':
$existingElements = GeneralUtility::trimExplode(',', $currentValue);
$removeElements = GeneralUtility::trimExplode(',', $functionValue);
$existingElements = GeneralUtility::trimExplode(',', $originalValue);
$removeElements = GeneralUtility::trimExplode(',', (string)$functionArgument);
if (!empty($removeElements)) {
return implode(',', array_diff($existingElements, $removeElements));
}
return $currentValue;
return $originalValue;
case 'uniqueList':
$elements = GeneralUtility::trimExplode(',', $currentValue);
$elements = GeneralUtility::trimExplode(',', $originalValue);
return implode(',', array_unique($elements));
case 'reverseList':
$elements = GeneralUtility::trimExplode(',', $currentValue);
$elements = GeneralUtility::trimExplode(',', $originalValue);
return implode(',', array_reverse($elements));
case 'sortList':
$elements = GeneralUtility::trimExplode(',', $currentValue);
$arguments = GeneralUtility::trimExplode(',', $functionValue);
$elements = GeneralUtility::trimExplode(',', $originalValue);
$arguments = GeneralUtility::trimExplode(',', (string)$functionArgument);
$arguments = array_map('strtolower', $arguments);
$sortFlags = SORT_REGULAR;
if (in_array('numeric', $arguments)) {
......@@ -284,7 +292,7 @@ final class AstBuilder
foreach ($elements as $element) {
if (!is_numeric($element)) {
throw new \InvalidArgumentException(
'The list "' . $currentValue . '" should be sorted numerically but contains a non-numeric value',
'The list "' . $originalValue . '" should be sorted numerically but contains a non-numeric value',
1650893781
);
}
......@@ -296,26 +304,13 @@ final class AstBuilder
}
return implode(',', $elements);
case 'getEnv':
$environmentValue = getenv(trim($functionValue));
$environmentValue = getenv(trim((string)$functionArgument));
if ($environmentValue !== false) {
return $environmentValue;
}
return $currentValue;
return $originalValue;
default:
return $currentValue;
// @todo: Implement (and test) hook again or switch to event along the way
/*
if (isset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tsparser.php']['preParseFunc'][$modifierName])) {
$hookMethod = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tsparser.php']['preParseFunc'][$modifierName];
$params = ['currentValue' => $currentValue, 'functionArgument' => $modifierArgument];
$fakeThis = null;
$newValue = GeneralUtility::callUserFunction($hookMethod, $params, $fakeThis);
} else {
self::getLogger()->warning('Missing function definition for {modifier_name} on TypoScript', [
'modifier_name' => $modifierName,
]);
}
*/
return $this->eventDispatcher->dispatch(new EvaluateModifierFunctionEvent($functionName, $functionArgument, $originalValue))->getValue() ?? $originalValue;
}
}
}
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\TypoScript\AST\Event;
/**
* Listeners to this event are able to implement own ":=" TypoScript modifier functions, example:
*
* foo = myOriginalValue
* foo := myNewFunction(myFunctionArgument)
*
* Listeners should take care function names can not overlap with function names
* from other extensions and should thus namespace, example naming: "extNewsSortFunction()"
*/
final class EvaluateModifierFunctionEvent
{
private ?string $value = null;
public function __construct(
private readonly string $functionName,
private readonly ?string $functionArgument,
private readonly ?string $originalValue,
) {
}
/**
* The function name, for example "extNewsSortFunction" when using "foo := extNewsSortFunction()"
*/
public function getFunctionName(): string
{
return $this->functionName;
}
/**
* Optional function argument, for example "myArgument" when using "foo := extNewsSortFunction(myArgument)"
*/
public function getFunctionArgument(): ?string
{
return $this->functionArgument;
}
/**
* Original / current value, for example "fooValue" when using:
* foo = fooValue
* foo := extNewsSortFunction(myArgument)
*/
public function getOriginalValue(): ?string
{
return $this->originalValue;
}
/**
* Set the updated value calculated by a listener.
* Note you can not set to null to "unset", since getValue() falls back to
* originalValue in this case. Set to empty string instead for this edge case.
*/
public function setValue(string $value): void
{
$this->value = $value;
}
/**
* Used by AstBuilder to fetch the updated value, falls back to given original value.
* Can be used by Listeners to see if a previous listener changed the value already
* by comparing with getOriginalValue().
*/
public function getValue(): ?string
{
return $this->value;
}
}
.. include:: /Includes.rst.txt
.. _breaking-98016-1658731955:
================================================
Breaking: #98016 - RemovedTypoScriptFunctionHook
================================================
See :issue:`98016`
Description
===========
With the transition to the :ref:`new TypoScript parser <feature-97816-1656350667>`,
the hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tsparser.php']['preParseFunc']`
is no longer called.
This hook has been used to implement own functions for the TypoScript "function" operator :typoscript:`:=`.
Additional functions can now be implemented using the :php:`\TYPO3\CMS\Core\TypoScript\AST\Event\EvaluateModifierFunctionEvent`
as described in :ref:`this Changelog <feature-98016-1658732423>`
Impact
======
With the continued implementation of the new TypoScript parser in TYPO3 v12,
registered hook implementations are not executed anymore. The extension scanner
will report possible usages.
Affected installations
======================
Extensions registering own TypoScript function implementations like this:
.. code-block:: typoscript
myValue := myCustomFunction(modifierArgument)
Migration
=========
Implement the :ref:`new event <feature-98016-1658732423>`. Extensions that want to keep
compatibility with both TYPO3 v11 and v12 can keep the old hook implementation without
further deprecations.
.. index:: PHP-API, TSConfig, TypoScript, FullyScanned, ext:core
.. include:: /Includes.rst.txt
.. _feature-98016-1658732423:
======================================================
Feature: #98016 - PSR-14 EvaluateModifierFunctionEvent
======================================================
See :issue:`98016`
Description
===========
A new PSR-14 Event :php:`\TYPO3\CMS\Core\TypoScript\AST\Event\EvaluateModifierFunctionEvent`
has been introduced which allows own TypoScript functions using the :typoscript:`:=` operator.
This is a substitution of the old :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tsparser.php']['preParseFunc']`
hook as described in :ref:`this Changelog <breaking-98016-1658731955>`.
Impact
======
The TYPO3 core tests come with test extension
:file:`EXT:core/Tests/Functional/Fixtures/Extensions/test_typoscript_ast_function_event` to functional
test the new event. The extension implements an example listener that can be used as boilerplate.
A simple TypoScript example looks like this:
.. code-block:: typoscript
someIdentifier = originalValue
someIdentifier := myModifierFunction(myFunctionArgument)
To implement :typoscript:`myModifierFunction`, an extension needs to register an event listener
in file :file:`Configuration/Services.yaml`:
.. code-block:: yaml
MyVendor\MyPackage\EventListener\MyTypoScriptModifierFunction:
tags:
- name: event.listener
identifier: 'my-package/typoscript/evaluate-modifier-function'
The corresponding event listener class could look like this:
.. code-block:: php
use TYPO3\CMS\Core\TypoScript\AST\Event\EvaluateModifierFunctionEvent;
final class MyTypoScriptModifierFunction
{
public function __invoke(EvaluateModifierFunctionEvent $event): void
{
if ($event->getFunctionName() === 'myModifierFunction') {
$originalValue = $event->getOriginalValue();
$functionArgument = $event->getFunctionArgument();
// Manipulate values and set new value
$event->setValue($originalValue . ' example ' . $functionArgument);
}
}
}
.. index:: PHP-API, TSConfig, TypoScript, ext:core
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3Tests\TestTyposcriptAstFunctionEvent\EventListener;
use TYPO3\CMS\Core\TypoScript\AST\Event\EvaluateModifierFunctionEvent;
final class TyposcriptTestFunction
{
public function __invoke(EvaluateModifierFunctionEvent $event): void
{
if ($event->getFunctionName() === 'testFunction') {
$event->setValue(($event->getOriginalValue() ?? '') . ' ' . ($event->getFunctionArgument() ?? ''));
}
}
}
services:
_defaults:
autowire: true
autoconfigure: true
public: false
TYPO3Tests\TestTyposcriptAstFunctionEvent\EventListener\TyposcriptTestFunction:
tags:
- name: event.listener
identifier: 'typo3tests-test-typoscript-ast-function-event/typoscript-test-function'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="#FF8700" d="M0 0h64v64H0z"/><path fill="#FFF" d="M42.8 32.8c-3.6 0-8.1-10.1-8.1-15.1 0-2.3.9-2.7 3.2-2.7 5.5 0 11 .9 11 4-.1 6.2-4 13.8-6.1 13.8zM28.5 18.5c0 5 6.4 20.2 10.7 20.2.5 0 .9-.1 1.4-.2-3.8 6.1-8.4 10.6-11.2 10.6-5.9 0-14.3-17.9-14.3-25.7 0-1.2.3-2.2.7-2.8 2-2.5 8.4-4.4 13.7-5-.6.4-1 1-1 2.9z"/></svg>
\ No newline at end of file
<?php
declare(strict_types=1);
$EM_CONF[$_EXTKEY] = [
'title' => 'TypoScript AST function evaluation test',
'description' => 'TypoScript AST function evaluation test',
'category' => 'example',
'version' => '12.0.0',
'state' => 'beta',
'author' => 'Christian Kuhn',
'author_email' => 'lolli@schwarzbu.ch',
'author_company' => '',
'constraints' => [
'depends' => [
'typo3' => '12.0.0',
'workspaces' => '12.0.0',
],
'conflicts' => [],
'suggests' => [],
],
];
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Tests\Functional\TypoScript\AST;
use TYPO3\CMS\Core\TypoScript\AST\AstBuilder;
use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode;
use TYPO3\CMS\Core\TypoScript\Tokenizer\LosslessTokenizer;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
class AstBuilderTest extends FunctionalTestCase
{
protected array $testExtensionsToLoad = [
'typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_typoscript_ast_function_event',
];
/**
* @test
*/
public function notModifiedValueKeepsNullValue(): void
{
$tokens = (new LosslessTokenizer())->tokenize('foo := doesNotExistFunction()');
/** @var AstBuilder $astBuilder */
$astBuilder = $this->get(AstBuilder::class);
$ast = $astBuilder->build($tokens, new RootNode());
self::assertNull($ast->getChildByName('foo')->getValue());
}
/**
* @test
*/
public function notModifiedValueKeepsOriginalValue(): void
{
$tokens = (new LosslessTokenizer())->tokenize(
"foo = originalValue\n" .
'foo := doesNotExistFunction()'
);
/** @var AstBuilder $astBuilder */
$astBuilder = $this->get(AstBuilder::class);
$ast = $astBuilder->build($tokens, new RootNode());
self::assertSame('originalValue', $ast->getChildByName('foo')->getValue());
}
/**
* @test
*/
public function modifiedValueUpdatesOriginalValue(): void
{
$tokens = (new LosslessTokenizer())->tokenize(
"foo = originalValue\n" .
'foo := testFunction(modifierArgument)'
);
/** @var AstBuilder $astBuilder */
$astBuilder = $this->get(AstBuilder::class);
$ast = $astBuilder->build($tokens, new RootNode());
self::assertSame('originalValue modifierArgument', $ast->getChildByName('foo')->getValue());
}
}
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Tests\Unit\Fixtures\EventDispatcher;
use Psr\EventDispatcher\EventDispatcherInterface;
final class NoopEventDispatcher implements EventDispatcherInterface
{
public function dispatch(object $event)
{
return $event;
}
}
......@@ -17,6 +17,7 @@ declare(strict_types=1);
namespace TYPO3\CMS\Core\Tests\Unit\TypoScript\AST;
use TYPO3\CMS\Core\Tests\Unit\Fixtures\EventDispatcher\NoopEventDispatcher;
use TYPO3\CMS\Core\TypoScript\AST\AstBuilder;
use TYPO3\CMS\Core\TypoScript\AST\Node\ChildNode;
use TYPO3\CMS\Core\TypoScript\AST\Node\ReferenceChildNode;
......@@ -1329,8 +1330,9 @@ class AstBuilderTest extends UnitTestCase
*/
public function build(string $source, RootNode $expectedAst): void
{
$noopEventDispatcher = new NoopEventDispatcher();
$tokens = (new LosslessTokenizer())->tokenize($source);
$ast = (new AstBuilder())->build($tokens, new RootNode());
$ast = (new AstBuilder($noopEventDispatcher))->build($tokens, new RootNode());
self::assertEquals($expectedAst, $ast);
}
......@@ -1340,8 +1342,9 @@ class AstBuilderTest extends UnitTestCase
*/
public function buildCompatArray(string $source, RootNode $_, array $expectedArray): void
{
$noopEventDispatcher = new NoopEventDispatcher();
$tokens = (new LosslessTokenizer())->tokenize($source);
$ast = (new AstBuilder())->build($tokens, new RootNode());
$ast = (new AstBuilder($noopEventDispatcher))->build($tokens, new RootNode());
self::assertEquals($expectedArray, $ast->toArray());
}
......@@ -1490,8 +1493,9 @@ class AstBuilderTest extends UnitTestCase
*/
public function buildReference(string $source, RootNode $expectedAst): void
{
$noopEventDispatcher = new NoopEventDispatcher();
$tokens = (new LosslessTokenizer())->tokenize($source);
$ast = (new AstBuilder())->build($tokens, new RootNode());
$ast = (new AstBuilder($noopEventDispatcher))->build($tokens, new RootNode());
self::assertEquals($expectedAst, $ast);
}
......@@ -1501,8 +1505,9 @@ class AstBuilderTest extends UnitTestCase
*/
public function buildReferenceArray(string $source, RootNode $_, array $expectedArray): void
{
$noopEventDispatcher = new NoopEventDispatcher();
$tokens = (new LosslessTokenizer())->tokenize($source);
$ast = (new AstBuilder())->build($tokens, new RootNode());
$ast = (new AstBuilder($noopEventDispatcher))->build($tokens, new RootNode());
self::assertEquals($expectedArray, $ast->toArray());
}
......@@ -1633,8 +1638,9 @@ class AstBuilderTest extends UnitTestCase
*/
public function buildConstant(string $source, array $constants, RootNode $expectedAst): void
{
$noopEventDispatcher = new NoopEventDispatcher();
$tokens = (new LosslessTokenizer())->tokenize($source);
$ast = (new AstBuilder())->build($tokens, new RootNode(), $constants);
$ast = (new AstBuilder($noopEventDispatcher))->build($tokens, new RootNode(), $constants);
self::assertEquals($expectedAst, $ast);
}
......@@ -1644,8 +1650,9 @@ class AstBuilderTest extends UnitTestCase
*/
public function buildConstantCompatArray(string $source, array $constants, RootNode $_, array $expectedArray): void
{
$noopEventDispatcher = new NoopEventDispatcher();
$tokens = (new LosslessTokenizer())->tokenize($source);
$ast = (new AstBuilder())->build($tokens, new RootNode(), $constants);
$ast = (new AstBuilder($noopEventDispatcher))->build($tokens, new RootNode(), $constants);
self::assertEquals($expectedArray, $ast->toArray());
}
......@@ -1672,8 +1679,9 @@ class AstBuilderTest extends UnitTestCase