Skip to content

Commit 9fb9cb6

Browse files
committed
Add complex expression handling with Peast
Until now the Javascript expression handling has been limited to simple expressions and has used regexps to process the strings of Javascript code. As we move to support more advanced Vue features, we need to be able to process more complex Javascript expressions and need to move away from regexps. Peast is a library that is already used in Mediawiki that converts Javascript expressions into an abstract syntax tree representation. Using Peast we can more reliably parse Javascript and implement more complex expression handling. Refactor the existing BasicJsExpressionParser to use Peast, and add handling of Object expressions (`{ test: 'value' }`). Bug: T396855
1 parent cc9b51c commit 9fb9cb6

File tree

7 files changed

+182
-43
lines changed

7 files changed

+182
-43
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"require": {
1212
"ext-dom": "*",
1313
"ext-libxml": "*",
14-
"php": ">=8.1"
14+
"php": ">=8.1",
15+
"mck89/peast": "^1.17"
1516
},
1617
"autoload": {
1718
"psr-4": {

src/Component.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
use DOMNodeList;
1010
use DOMText;
1111

12+
use WMDE\VueJsTemplating\JsParsing\VariableAccess;
13+
1214
class Component {
1315

1416
/**
@@ -204,8 +206,10 @@ private function handleFor( DOMNode $node, array $data ) {
204206
if ( $node->hasAttribute( 'v-for' ) ) {
205207
list( $itemName, $listName ) = explode( ' in ', $node->getAttribute( 'v-for' ) );
206208
$node->removeAttribute( 'v-for' );
209+
$node->removeAttribute( ':key' );
210+
$listNameExpression = new VariableAccess( explode( '.', $listName ) );
207211

208-
foreach ( $data[$listName] as $item ) {
212+
foreach ( $listNameExpression->evaluate( $data ) as $item ) {
209213
$newNode = $node->cloneNode( true );
210214
$node->parentNode->insertBefore( $newNode, $node );
211215
$this->handleNode( $newNode, array_merge( $data, [ $itemName => $item ] ) );
Lines changed: 9 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,25 @@
11
<?php
22

3+
declare( strict_types = 1 );
4+
35
namespace WMDE\VueJsTemplating\JsParsing;
46

5-
use RuntimeException;
7+
use Peast\Peast;
68

79
class BasicJsExpressionParser implements JsExpressionParser {
810

9-
private $methods;
11+
private PeastExpressionConverter $expressionConverter;
1012

1113
public function __construct( array $methods ) {
12-
$this->methods = $methods;
14+
$this->expressionConverter = new PeastExpressionConverter( $methods );
1315
}
1416

15-
/**
16-
* @param string $expression
17-
*
18-
* @return ParsedExpression
19-
*/
17+
/** @inheritDoc */
2018
public function parse( $expression ) {
21-
$expression = $this->normalizeExpression( $expression );
22-
if ( str_starts_with( $expression, '!' ) ) {
23-
return new NegationOperator( $this->parse( substr( $expression, 1 ) ) );
24-
} elseif ( str_starts_with( $expression, "'" ) && str_ends_with( $expression, "'" ) ) {
25-
return new StringLiteral( substr( $expression, 1, -1 ) );
26-
} elseif ( preg_match( '/^(\w+)\((.*)\)$/', $expression, $matches ) ) {
27-
$methodName = $matches[1];
28-
if ( !array_key_exists( $methodName, $this->methods ) ) {
29-
throw new RuntimeException( "Method '{$methodName}' is undefined" );
30-
}
31-
$method = $this->methods[$methodName];
32-
$args = [ $this->parse( $matches[2] ) ];
33-
return new MethodCall( $method, $args );
34-
} else {
35-
$parts = explode( '.', $expression );
36-
return new VariableAccess( $parts );
37-
}
38-
}
19+
$pexp = Peast::ES2017( "($expression)" )->parse();
20+
$body = $pexp->getBody();
3921

40-
/**
41-
* @param string $expression
42-
*
43-
* @return string
44-
*/
45-
protected function normalizeExpression( $expression ) {
46-
return trim( $expression );
22+
return $this->expressionConverter->convertExpression( $body[0]->getExpression()->getExpression() );
4723
}
4824

4925
}

src/JsParsing/JsDictionary.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare( strict_types = 1 );
4+
5+
namespace WMDE\VueJsTemplating\JsParsing;
6+
7+
class JsDictionary implements ParsedExpression {
8+
9+
private array $parsedExpressionMap;
10+
11+
public function __construct( $data ) {
12+
$this->parsedExpressionMap = $data;
13+
}
14+
15+
/**
16+
* @param array $data the data to be passed into the expression evaluations
17+
*
18+
* @return array the dictionary with expressions replaced with their evaluated values
19+
*/
20+
public function evaluate( array $data ) {
21+
$result = [];
22+
foreach ( $this->parsedExpressionMap as $key => $value ) {
23+
$result[ $key ] = $value->evaluate( $data );
24+
}
25+
return $result;
26+
}
27+
28+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
declare( strict_types = 1 );
4+
5+
namespace WMDE\VueJsTemplating\JsParsing;
6+
7+
use Peast\Syntax\Node\CallExpression;
8+
use Peast\Syntax\Node\Expression;
9+
use Peast\Syntax\Node\Identifier;
10+
use Peast\Syntax\Node\MemberExpression;
11+
use Peast\Syntax\Node\ObjectExpression;
12+
use Peast\Syntax\Node\StringLiteral as PeastStringLiteral;
13+
use Peast\Syntax\Node\UnaryExpression;
14+
15+
use RuntimeException;
16+
17+
class PeastExpressionConverter {
18+
19+
/** @var array a map of method names to their implementations in PHP */
20+
protected array $methods;
21+
22+
public function __construct( array $methods ) {
23+
$this->methods = $methods;
24+
}
25+
26+
protected function convertUnaryExpression( UnaryExpression $expression ) {
27+
if ( $expression->getOperator() !== '!' ) {
28+
throw new RuntimeException( 'Unable to parse unary operator "' . $expression->getOperator() . '"' );
29+
}
30+
return new NegationOperator( $this->convertExpression( $expression->getArgument() ) );
31+
}
32+
33+
protected function convertCallExpression( CallExpression $expression ) {
34+
$methodName = $expression->getCallee()->getName();
35+
if ( !array_key_exists( $methodName, $this->methods ) ) {
36+
throw new RuntimeException( "Method '{$methodName}' is undefined" );
37+
}
38+
$method = $this->methods[$methodName];
39+
40+
return new MethodCall(
41+
$method,
42+
array_map( fn ( $exp ) => $this->convertExpression( $exp ), $expression->getArguments() )
43+
);
44+
}
45+
46+
protected function convertMemberExpression( MemberExpression $expression ) {
47+
$parts = [];
48+
while ( $expression !== null ) {
49+
if ( get_class( $expression ) === MemberExpression::class ) {
50+
$property = $expression->getProperty()->getName();
51+
array_unshift( $parts, $property );
52+
$expression = $expression->getObject();
53+
} elseif ( get_class( $expression ) === Identifier::class ) {
54+
array_unshift( $parts, $expression->getName() );
55+
$expression = null;
56+
} else {
57+
throw new RuntimeException(
58+
'Unable to parse member expression with nodes of type ' . get_class( $expression )
59+
);
60+
}
61+
}
62+
return new VariableAccess( $parts );
63+
}
64+
65+
protected function convertObjectExpression( ObjectExpression $expression ) {
66+
$parsedExpressionMap = [];
67+
foreach ( $expression->getProperties() as $property ) {
68+
$parsedExpressionMap[ $this->convertKeyToLiteral( $property->getKey() ) ] =
69+
$this->convertExpression( $property->getValue() );
70+
}
71+
return new JsDictionary( $parsedExpressionMap );
72+
}
73+
74+
public function convertExpression( Expression $expression ) {
75+
return match( get_class( $expression ) ) {
76+
UnaryExpression::class => $this->convertUnaryExpression( $expression ),
77+
MemberExpression::class => $this->convertMemberExpression( $expression ),
78+
PeastStringLiteral::class => new StringLiteral( $expression->getValue() ),
79+
Identifier::class => new VariableAccess( [ $expression->getName() ] ),
80+
CallExpression::class => $this->convertCallExpression( $expression ),
81+
ObjectExpression::class => $this->convertObjectExpression( $expression ),
82+
default => throw new RuntimeException(
83+
'Unable to parse complex expression of type ' . get_class( $expression )
84+
)
85+
};
86+
}
87+
88+
protected function convertKeyToLiteral( $key ) {
89+
return match( get_class( $key ) ) {
90+
PeastStringLiteral::class => $key->getValue(),
91+
Identifier::class => $key->getName(),
92+
default => throw new RuntimeException(
93+
'Unable to extract name from dictionary key of type ' . get_class( $key )
94+
)
95+
};
96+
}
97+
98+
}

tests/php/JsParsing/BasicJsExpressionParserTest.php

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ public function testCanParseMethodCall_builtin_variable() {
4242
'strtoupper' => 'strtoupper',
4343
] );
4444

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

4848
$this->assertSame( 'ABC', $result );
4949
}
@@ -68,9 +68,9 @@ public function testCanParseMethodCall_whitespace_nested() {
6868
] );
6969

7070
$parsedExpression = $jsExpressionEvaluator->parse(
71-
' strrev( strtoupper( var ) ) '
71+
' strrev( strtoupper( somevar ) ) '
7272
);
73-
$result = $parsedExpression->evaluate( [ 'var' => 'abc' ] );
73+
$result = $parsedExpression->evaluate( [ 'somevar' => 'abc' ] );
7474

7575
$this->assertSame( 'CBA', $result );
7676
}
@@ -84,4 +84,36 @@ public function testIgnoresTrailingAndLeadingSpaces() {
8484
$this->assertEquals( 'some string', $result );
8585
}
8686

87+
public function testCanParse_simple_dictionary(): void {
88+
$jsExpressionEvaluator = new BasicJsExpressionParser( [] );
89+
90+
$parsedExpression = $jsExpressionEvaluator->parse( "{ key: testProperty }" );
91+
$result = $parsedExpression->evaluate( [ 'testProperty' => 1 ] );
92+
93+
$this->assertSame( [ "key" => 1 ], $result );
94+
}
95+
96+
public function testCanParse_nested_values(): void {
97+
$jsExpressionEvaluator = new BasicJsExpressionParser( [] );
98+
99+
$parsedExpression = $jsExpressionEvaluator->parse( "{ key: testObject.testDelegate.testProperty }" );
100+
$result = $parsedExpression->evaluate( [ 'testObject' => [ 'testDelegate' => [ 'testProperty' => 1 ] ] ] );
101+
102+
$this->assertSame( [ "key" => 1 ], $result );
103+
}
104+
105+
public function testCanParse_dictionary_with_string_keys(): void {
106+
$jsExpressionEvaluator = new BasicJsExpressionParser( [] );
107+
108+
$parsedExpression = $jsExpressionEvaluator->parse(
109+
"{ 'wikibase-mex-icon-expand-x-small': !showReferences.P321, " .
110+
"'wikibase-mex-icon-collapse-x-small': showReferences.P321 }"
111+
);
112+
$result = $parsedExpression->evaluate( [ 'showReferences' => [ 'P321' => false ] ] );
113+
114+
$this->assertSame( [
115+
"wikibase-mex-icon-expand-x-small" => true,
116+
"wikibase-mex-icon-collapse-x-small" => false
117+
], $result );
118+
}
87119
}

tests/php/TemplatingTest.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -253,17 +253,17 @@ public function testTemplateWithAttributeBinding_ConditionIsString_AttributeIsRe
253253

254254
public function testTemplateWithPropertyAccessInMustache_CorrectValueIsRendered() {
255255
$result = $this->createAndRender(
256-
'<p>{{var.property}}</p>',
257-
[ 'var' => [ 'property' => 'value' ] ]
256+
'<p>{{variable.property}}</p>',
257+
[ 'variable' => [ 'property' => 'value' ] ]
258258
);
259259

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

263263
public function testTemplateWithMethodAccessInAttributeBinding_CorrectValueIsRendered() {
264264
$result = $this->createAndRender(
265-
'<p :attr1="strtoupper(var.property)"></p>',
266-
[ 'var' => [ 'property' => 'value' ] ],
265+
'<p :attr1="strtoupper(variable.property)"></p>',
266+
[ 'variable' => [ 'property' => 'value' ] ],
267267
[ 'strtoupper' => 'strtoupper' ]
268268
);
269269

0 commit comments

Comments
 (0)