Skip to content

Commit a51db66

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 introduce a new DictionaryProcessingJsExpressionParser. A DispatchingJsExpressionParser allows different parsers to be selected based on what the Javascript expression looks like. Since a naked object expression `{ this: that }` is not valid Javascript, when we see those expressions in templates, we prefix them with `var myObj = ` so that Peast can process them. Bug: T396855
1 parent cc9b51c commit a51db66

14 files changed

+419
-48
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/App.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
use Exception;
99
use WMDE\VueJsTemplating\JsParsing\BasicJsExpressionParser;
1010
use WMDE\VueJsTemplating\JsParsing\CachingExpressionParser;
11+
use WMDE\VueJsTemplating\JsParsing\DictionaryProcessingJsExpressionParser;
12+
use WMDE\VueJsTemplating\JsParsing\DispatchingJsExpressionParser;
1113
use WMDE\VueJsTemplating\JsParsing\JsExpressionParser;
1214

1315
class App {
@@ -29,7 +31,14 @@ class App {
2931
*/
3032
public function __construct( array $methods ) {
3133
$this->htmlParser = new HtmlParser();
32-
$this->expressionParser = new CachingExpressionParser( new BasicJsExpressionParser( $methods ) );
34+
$basicExpressionParser = new BasicJsExpressionParser( $methods );
35+
$this->expressionParser = new CachingExpressionParser(
36+
new DispatchingJsExpressionParser( [
37+
$basicExpressionParser,
38+
new DictionaryProcessingJsExpressionParser( $methods )
39+
]
40+
)
41+
);
3342
}
3443

3544
/**

src/Component.php

Lines changed: 16 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
/**
@@ -150,6 +152,17 @@ private function handleAttributeBinding( DOMElement $node, array $data ) {
150152
if ( $value ) {
151153
$node->setAttribute( $name, $name );
152154
}
155+
} elseif ( is_array( $value ) ) {
156+
$existingParts = [];
157+
if ( $node->getAttribute( $name ) ) {
158+
$existingParts = explode( " ", $node->getAttribute( $name ) );
159+
}
160+
foreach ( $value as $key => $addKey ) {
161+
if ( $addKey ) {
162+
array_unshift( $existingParts, $key );
163+
}
164+
}
165+
$node->setAttribute( $name, implode( " ", $existingParts ) );
153166
} else {
154167
$node->setAttribute( $name, $value );
155168
}
@@ -204,8 +217,10 @@ private function handleFor( DOMNode $node, array $data ) {
204217
if ( $node->hasAttribute( 'v-for' ) ) {
205218
list( $itemName, $listName ) = explode( ' in ', $node->getAttribute( 'v-for' ) );
206219
$node->removeAttribute( 'v-for' );
220+
$node->removeAttribute( ':key' );
221+
$listNameExpression = new VariableAccess( explode( '.', $listName ) );
207222

208-
foreach ( $data[$listName] as $item ) {
223+
foreach ( $listNameExpression->evaluate( $data ) as $item ) {
209224
$newNode = $node->cloneNode( true );
210225
$node->parentNode->insertBefore( $newNode, $node );
211226
$this->handleNode( $newNode, array_merge( $data, [ $itemName => $item ] ) );
Lines changed: 12 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,24 @@
11
<?php
22

3-
namespace WMDE\VueJsTemplating\JsParsing;
3+
declare( strict_types = 1 );
44

5-
use RuntimeException;
5+
namespace WMDE\VueJsTemplating\JsParsing;
66

7-
class BasicJsExpressionParser implements JsExpressionParser {
7+
use Peast\Peast;
88

9-
private $methods;
9+
class BasicJsExpressionParser extends PeastJsExpressionParser implements JsExpressionParser {
1010

11-
public function __construct( array $methods ) {
12-
$this->methods = $methods;
13-
}
14-
15-
/**
16-
* @param string $expression
17-
*
18-
* @return ParsedExpression
19-
*/
11+
/** @inheritDoc */
2012
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-
}
13+
$pexp = Peast::ES2017( $expression )->parse();
14+
$body = $pexp->getBody();
15+
16+
return $this->parseComplexExpression( $body[0]->getExpression() );
3817
}
3918

40-
/**
41-
* @param string $expression
42-
*
43-
* @return string
44-
*/
45-
protected function normalizeExpression( $expression ) {
46-
return trim( $expression );
19+
/** @inheritDoc */
20+
public function canParse( string $expression ): bool {
21+
return preg_match( '/^[\w!\'].*$/', $expression ) === 1;
4722
}
4823

4924
}

src/JsParsing/CachingExpressionParser.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ public function parse( $expression ) {
3434
return $result;
3535
}
3636

37+
/** @inheritDoc */
38+
public function canParse( string $expression ): bool {
39+
return $this->parser->canParse( $expression );
40+
}
41+
3742
/**
3843
* @param string $expression
3944
*
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare( strict_types = 1 );
4+
5+
namespace WMDE\VueJsTemplating\JsParsing;
6+
7+
use Peast\Peast;
8+
9+
class DictionaryProcessingJsExpressionParser extends PeastJsExpressionParser implements JsExpressionParser {
10+
11+
/** @inheritDoc */
12+
public function parse( $expression ) {
13+
$pexp = Peast::ES2017( 'var testDict = ' . $expression )->parse();
14+
$body = $pexp->getBody();
15+
if ( $body === [] ) {
16+
return new JsDictionary( [] );
17+
}
18+
return $this->parseComplexExpression( $body[0]->getDeclarations()[0]->getInit() );
19+
}
20+
21+
/** @inheritDoc */
22+
public function canParse( string $expression ): bool {
23+
return str_starts_with( $expression, '{' ) && str_ends_with( $expression, '}' );
24+
}
25+
26+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
namespace WMDE\VueJsTemplating\JsParsing;
4+
5+
use RuntimeException;
6+
7+
class DispatchingJsExpressionParser implements JsExpressionParser {
8+
9+
private array $expressionParsers;
10+
11+
public function __construct( array $expressionParsers ) {
12+
$this->expressionParsers = $expressionParsers;
13+
}
14+
15+
/** @inheritDoc */
16+
public function parse( $expression ) {
17+
$expression = $this->normalizeExpression( $expression );
18+
foreach ( $this->expressionParsers as $expressionParser ) {
19+
if ( $expressionParser->canParse( $expression ) ) {
20+
return $expressionParser->parse( $expression );
21+
}
22+
}
23+
throw new RuntimeException(
24+
"No registered JS Expression parser can handle this script: '" . $expression . "'"
25+
);
26+
}
27+
28+
/** @inheritDoc */
29+
public function canParse( string $expression ): bool {
30+
foreach ( $this->expressionParsers as $expressionParser ) {
31+
if ( $expressionParser->canParse( $expression ) ) {
32+
return true;
33+
}
34+
}
35+
return false;
36+
}
37+
38+
/**
39+
* @param string $expression
40+
*
41+
* @return string
42+
*/
43+
protected function normalizeExpression( $expression ) {
44+
return trim( $expression );
45+
}
46+
47+
}

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+
}

src/JsParsing/JsExpressionParser.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,14 @@ interface JsExpressionParser {
1111
*/
1212
public function parse( $expression );
1313

14+
/**
15+
* Given a normalised (trimmed) JS expression, return true if we are
16+
* able to parse the expression without error.
17+
*
18+
* @param string $expression the expression to check if we can parse
19+
*
20+
* @return bool whether we can parse the expression
21+
*/
22+
public function canParse( string $expression ): bool;
23+
1424
}
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 PeastJsExpressionParser {
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 parseUnaryExpression( UnaryExpression $expression ) {
27+
if ( $expression->getOperator() !== '!' ) {
28+
throw new RuntimeException( 'Unable to parse unary operator "' . $expression->getOperator() . '"' );
29+
}
30+
return new NegationOperator( $this->parseComplexExpression( $expression->getArgument() ) );
31+
}
32+
33+
protected function parseCallExpression( 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->parseComplexExpression( $exp ), $expression->getArguments() )
43+
);
44+
}
45+
46+
protected function parseMemberExpression( 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 parseObjectExpression( ObjectExpression $expression ) {
66+
$parsedExpressionMap = [];
67+
foreach ( $expression->getProperties() as $property ) {
68+
$parsedExpressionMap[ $this->convertKeyToLiteral( $property->getKey() ) ] =
69+
$this->parseComplexExpression( $property->getValue() );
70+
}
71+
return new JsDictionary( $parsedExpressionMap );
72+
}
73+
74+
protected function parseComplexExpression( Expression $expression ) {
75+
return match( get_class( $expression ) ) {
76+
UnaryExpression::class => $this->parseUnaryExpression( $expression ),
77+
MemberExpression::class => $this->parseMemberExpression( $expression ),
78+
PeastStringLiteral::class => new StringLiteral( $expression->getValue() ),
79+
Identifier::class => new VariableAccess( [ $expression->getName() ] ),
80+
CallExpression::class => $this->parseCallExpression( $expression ),
81+
ObjectExpression::class => $this->parseObjectExpression( $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+
}

0 commit comments

Comments
 (0)