Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"require": {
"ext-dom": "*",
"ext-libxml": "*",
"php": ">=8.1"
"php": ">=8.1",
"mck89/peast": "^1.17"
},
"autoload": {
"psr-4": {
Expand Down
42 changes: 9 additions & 33 deletions src/JsParsing/BasicJsExpressionParser.php
Original file line number Diff line number Diff line change
@@ -1,49 +1,25 @@
<?php

declare( strict_types = 1 );

namespace WMDE\VueJsTemplating\JsParsing;

use RuntimeException;
use Peast\Peast;

class BasicJsExpressionParser implements JsExpressionParser {

private $methods;
private PeastExpressionConverter $expressionConverter;

public function __construct( array $methods ) {
$this->methods = $methods;
$this->expressionConverter = new PeastExpressionConverter( $methods );
}

/**
* @param string $expression
*
* @return ParsedExpression
*/
/** @inheritDoc */
public function parse( $expression ) {
$expression = $this->normalizeExpression( $expression );
if ( str_starts_with( $expression, '!' ) ) {
return new NegationOperator( $this->parse( substr( $expression, 1 ) ) );
} elseif ( str_starts_with( $expression, "'" ) && str_ends_with( $expression, "'" ) ) {
return new StringLiteral( substr( $expression, 1, -1 ) );
} elseif ( preg_match( '/^(\w+)\((.*)\)$/', $expression, $matches ) ) {
$methodName = $matches[1];
if ( !array_key_exists( $methodName, $this->methods ) ) {
throw new RuntimeException( "Method '{$methodName}' is undefined" );
}
$method = $this->methods[$methodName];
$args = [ $this->parse( $matches[2] ) ];
return new MethodCall( $method, $args );
} else {
$parts = explode( '.', $expression );
return new VariableAccess( $parts );
}
}
$pexp = Peast::ES2017( "($expression)" )->parse();
$body = $pexp->getBody();

/**
* @param string $expression
*
* @return string
*/
protected function normalizeExpression( $expression ) {
return trim( $expression );
return $this->expressionConverter->convertExpression( $body[0]->getExpression()->getExpression() );
}

}
28 changes: 28 additions & 0 deletions src/JsParsing/JsDictionary.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare( strict_types = 1 );

namespace WMDE\VueJsTemplating\JsParsing;

class JsDictionary implements ParsedExpression {

private array $parsedExpressionMap;

public function __construct( $data ) {
$this->parsedExpressionMap = $data;
}

/**
* @param array $data the data to be passed into the expression evaluations
*
* @return array the dictionary with expressions replaced with their evaluated values
*/
public function evaluate( array $data ) {
$result = [];
foreach ( $this->parsedExpressionMap as $key => $value ) {
$result[ $key ] = $value->evaluate( $data );
}
return $result;
}

}
98 changes: 98 additions & 0 deletions src/JsParsing/PeastExpressionConverter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare( strict_types = 1 );

namespace WMDE\VueJsTemplating\JsParsing;

use Peast\Syntax\Node\CallExpression;
use Peast\Syntax\Node\Expression;
use Peast\Syntax\Node\Identifier;
use Peast\Syntax\Node\MemberExpression;
use Peast\Syntax\Node\ObjectExpression;
use Peast\Syntax\Node\StringLiteral as PeastStringLiteral;
use Peast\Syntax\Node\UnaryExpression;

use RuntimeException;

class PeastExpressionConverter {

/** @var array a map of method names to their implementations in PHP */
protected array $methods;

public function __construct( array $methods ) {
$this->methods = $methods;
}

protected function convertUnaryExpression( UnaryExpression $expression ) {
if ( $expression->getOperator() !== '!' ) {
throw new RuntimeException( 'Unable to parse unary operator "' . $expression->getOperator() . '"' );
}
return new NegationOperator( $this->convertExpression( $expression->getArgument() ) );
}

protected function convertCallExpression( CallExpression $expression ) {
$methodName = $expression->getCallee()->getName();
if ( !array_key_exists( $methodName, $this->methods ) ) {
throw new RuntimeException( "Method '{$methodName}' is undefined" );
}
$method = $this->methods[$methodName];

return new MethodCall(
$method,
array_map( fn ( $exp ) => $this->convertExpression( $exp ), $expression->getArguments() )
);
}

protected function convertMemberExpression( MemberExpression $expression ) {
$parts = [];
while ( $expression !== null ) {
if ( get_class( $expression ) === MemberExpression::class ) {
$property = $expression->getProperty()->getName();
array_unshift( $parts, $property );
$expression = $expression->getObject();
} elseif ( get_class( $expression ) === Identifier::class ) {
array_unshift( $parts, $expression->getName() );
$expression = null;
} else {
throw new RuntimeException(
'Unable to parse member expression with nodes of type ' . get_class( $expression )
);
}
}
return new VariableAccess( $parts );
}

protected function convertObjectExpression( ObjectExpression $expression ) {
$parsedExpressionMap = [];
foreach ( $expression->getProperties() as $property ) {
$parsedExpressionMap[ $this->convertKeyToLiteral( $property->getKey() ) ] =
$this->convertExpression( $property->getValue() );
}
return new JsDictionary( $parsedExpressionMap );
}

public function convertExpression( Expression $expression ) {
return match( get_class( $expression ) ) {
UnaryExpression::class => $this->convertUnaryExpression( $expression ),
MemberExpression::class => $this->convertMemberExpression( $expression ),
PeastStringLiteral::class => new StringLiteral( $expression->getValue() ),
Identifier::class => new VariableAccess( [ $expression->getName() ] ),
CallExpression::class => $this->convertCallExpression( $expression ),
ObjectExpression::class => $this->convertObjectExpression( $expression ),
default => throw new RuntimeException(
'Unable to parse complex expression of type ' . get_class( $expression )
)
};
}

protected function convertKeyToLiteral( $key ) {
return match( get_class( $key ) ) {
PeastStringLiteral::class => $key->getValue(),
Identifier::class => $key->getName(),
default => throw new RuntimeException(
'Unable to extract name from dictionary key of type ' . get_class( $key )
)
};
}

}
40 changes: 36 additions & 4 deletions tests/php/JsParsing/BasicJsExpressionParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ public function testCanParseMethodCall_builtin_variable() {
'strtoupper' => 'strtoupper',
] );

$parsedExpression = $jsExpressionEvaluator->parse( 'strtoupper(var)' );
$result = $parsedExpression->evaluate( [ 'var' => 'abc' ] );
$parsedExpression = $jsExpressionEvaluator->parse( 'strtoupper(somevar)' );
$result = $parsedExpression->evaluate( [ 'somevar' => 'abc' ] );

$this->assertSame( 'ABC', $result );
}
Expand All @@ -68,9 +68,9 @@ public function testCanParseMethodCall_whitespace_nested() {
] );

$parsedExpression = $jsExpressionEvaluator->parse(
' strrev( strtoupper( var ) ) '
' strrev( strtoupper( somevar ) ) '
);
$result = $parsedExpression->evaluate( [ 'var' => 'abc' ] );
$result = $parsedExpression->evaluate( [ 'somevar' => 'abc' ] );

$this->assertSame( 'CBA', $result );
}
Expand All @@ -84,4 +84,36 @@ public function testIgnoresTrailingAndLeadingSpaces() {
$this->assertEquals( 'some string', $result );
}

public function testCanParse_simple_dictionary(): void {
$jsExpressionEvaluator = new BasicJsExpressionParser( [] );

$parsedExpression = $jsExpressionEvaluator->parse( "{ key: testProperty }" );
$result = $parsedExpression->evaluate( [ 'testProperty' => 1 ] );

$this->assertSame( [ "key" => 1 ], $result );
}

public function testCanParse_nested_values(): void {
$jsExpressionEvaluator = new BasicJsExpressionParser( [] );

$parsedExpression = $jsExpressionEvaluator->parse( "{ key: testObject.testDelegate.testProperty }" );
$result = $parsedExpression->evaluate( [ 'testObject' => [ 'testDelegate' => [ 'testProperty' => 1 ] ] ] );

$this->assertSame( [ "key" => 1 ], $result );
}

public function testCanParse_dictionary_with_string_keys(): void {
$jsExpressionEvaluator = new BasicJsExpressionParser( [] );

$parsedExpression = $jsExpressionEvaluator->parse(
"{ 'wikibase-mex-icon-expand-x-small': !showReferences.P321, " .
"'wikibase-mex-icon-collapse-x-small': showReferences.P321 }"
);
$result = $parsedExpression->evaluate( [ 'showReferences' => [ 'P321' => false ] ] );

$this->assertSame( [
"wikibase-mex-icon-expand-x-small" => true,
"wikibase-mex-icon-collapse-x-small" => false
], $result );
}
}
8 changes: 4 additions & 4 deletions tests/php/TemplatingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -253,17 +253,17 @@ public function testTemplateWithAttributeBinding_ConditionIsString_AttributeIsRe

public function testTemplateWithPropertyAccessInMustache_CorrectValueIsRendered() {
$result = $this->createAndRender(
'<p>{{var.property}}</p>',
[ 'var' => [ 'property' => 'value' ] ]
'<p>{{variable.property}}</p>',
[ 'variable' => [ 'property' => 'value' ] ]
);

$this->assertSame( '<p>value</p>', $result );
}

public function testTemplateWithMethodAccessInAttributeBinding_CorrectValueIsRendered() {
$result = $this->createAndRender(
'<p :attr1="strtoupper(var.property)"></p>',
[ 'var' => [ 'property' => 'value' ] ],
'<p :attr1="strtoupper(variable.property)"></p>',
[ 'variable' => [ 'property' => 'value' ] ],
[ 'strtoupper' => 'strtoupper' ]
);

Expand Down