Skip to content

Commit 720c387

Browse files
smnandrefabpot
authored andcommitted
[AssetMapper] Detect import with a sequence parser
1 parent 5cab1e1 commit 720c387

File tree

4 files changed

+427
-43
lines changed

4 files changed

+427
-43
lines changed

src/Symfony/Component/AssetMapper/Compiler/JavaScriptImportPathCompiler.php

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Psr\Log\LoggerInterface;
1515
use Symfony\Component\AssetMapper\AssetMapperInterface;
16+
use Symfony\Component\AssetMapper\Compiler\Parser\JavascriptSequenceParser;
1617
use Symfony\Component\AssetMapper\Exception\CircularAssetsException;
1718
use Symfony\Component\AssetMapper\Exception\RuntimeException;
1819
use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader;
@@ -61,15 +62,13 @@ public function __construct(
6162

6263
public function compile(string $content, MappedAsset $asset, AssetMapperInterface $assetMapper): string
6364
{
64-
return preg_replace_callback(self::IMPORT_PATTERN, function ($matches) use ($asset, $assetMapper, $content) {
65-
$fullImportString = $matches[0][0];
65+
$jsParser = new JavascriptSequenceParser($content);
6666

67-
// Ignore matches that did not capture import statements
68-
if (!isset($matches[1][0])) {
69-
return $fullImportString;
70-
}
67+
return preg_replace_callback(self::IMPORT_PATTERN, function ($matches) use ($asset, $assetMapper, $jsParser) {
68+
$fullImportString = $matches[0][0];
7169

72-
if ($this->isCommentedOut($matches[0][1], $content)) {
70+
$jsParser->parseUntil($matches[0][1]);
71+
if (!$jsParser->isExecutable()) {
7372
return $fullImportString;
7473
}
7574

@@ -146,33 +145,6 @@ private function handleMissingImport(string $message, ?\Throwable $e = null): vo
146145
};
147146
}
148147

149-
/**
150-
* Simple check for the most common types of comments.
151-
*
152-
* This is not a full parser, but should be good enough for most cases.
153-
*/
154-
private function isCommentedOut(mixed $offsetStart, string $fullContent): bool
155-
{
156-
$lineStart = strrpos($fullContent, "\n", $offsetStart - \strlen($fullContent));
157-
$lineContentBeforeImport = substr($fullContent, $lineStart, $offsetStart - $lineStart);
158-
$firstTwoChars = substr(ltrim($lineContentBeforeImport), 0, 2);
159-
if ('//' === $firstTwoChars) {
160-
return true;
161-
}
162-
163-
if ('/*' === $firstTwoChars) {
164-
$commentEnd = strpos($fullContent, '*/', $lineStart);
165-
// if we can't find the end comment, be cautious: assume this is not a comment
166-
if (false === $commentEnd) {
167-
return false;
168-
}
169-
170-
return $offsetStart < $commentEnd;
171-
}
172-
173-
return false;
174-
}
175-
176148
private function findAssetForBareImport(string $importedModule, AssetMapperInterface $assetMapper): ?MappedAsset
177149
{
178150
if (!$importMapEntry = $this->importMapConfigReader->findRootImportMapEntry($importedModule)) {
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\AssetMapper\Compiler\Parser;
13+
14+
/**
15+
* Parses JavaScript content to identify sequences of strings, comments, etc.
16+
*
17+
* @author Simon André <[email protected]>
18+
*
19+
* @internal
20+
*/
21+
final class JavascriptSequenceParser
22+
{
23+
private const STATE_DEFAULT = 0;
24+
private const STATE_COMMENT = 1;
25+
private const STATE_STRING = 2;
26+
27+
private int $cursor = 0;
28+
29+
private int $contentEnd;
30+
31+
private string $pattern;
32+
33+
private int $currentSequenceType = self::STATE_DEFAULT;
34+
35+
private ?int $currentSequenceEnd = null;
36+
37+
private const COMMENT_SEPARATORS = [
38+
'/*', // Multi-line comment
39+
'//', // Single-line comment
40+
'"', // Double quote
41+
'\'', // Single quote
42+
'`', // Backtick
43+
];
44+
45+
public function __construct(
46+
private readonly string $content,
47+
) {
48+
$this->contentEnd = \strlen($content);
49+
50+
$this->pattern ??= '/'.implode('|', array_map(
51+
fn (string $ch): string => preg_quote($ch, '/'),
52+
self::COMMENT_SEPARATORS
53+
)).'/';
54+
}
55+
56+
public function isString(): bool
57+
{
58+
return self::STATE_STRING === $this->currentSequenceType;
59+
}
60+
61+
public function isExecutable(): bool
62+
{
63+
return self::STATE_DEFAULT === $this->currentSequenceType;
64+
}
65+
66+
public function isComment(): bool
67+
{
68+
return self::STATE_COMMENT === $this->currentSequenceType;
69+
}
70+
71+
public function parseUntil(int $position): void
72+
{
73+
if ($position > $this->contentEnd) {
74+
throw new \RuntimeException('Cannot parse beyond the end of the content.');
75+
}
76+
if ($position < $this->cursor) {
77+
throw new \RuntimeException('Cannot parse backwards.');
78+
}
79+
80+
while ($this->cursor <= $position) {
81+
// Current CodeSequence ?
82+
if (null !== $this->currentSequenceEnd) {
83+
if ($this->currentSequenceEnd > $position) {
84+
$this->cursor = $position;
85+
86+
return;
87+
}
88+
89+
$this->cursor = $this->currentSequenceEnd;
90+
$this->setSequence(self::STATE_DEFAULT, null);
91+
}
92+
93+
preg_match($this->pattern, $this->content, $matches, \PREG_OFFSET_CAPTURE, $this->cursor);
94+
if (!$matches) {
95+
$this->endsWithSequence(self::STATE_DEFAULT, $position);
96+
97+
return;
98+
}
99+
100+
$matchPos = (int) $matches[0][1];
101+
$matchChar = $matches[0][0];
102+
103+
if ($matchPos > $position) {
104+
$this->setSequence(self::STATE_DEFAULT, $matchPos - 1);
105+
$this->cursor = $position;
106+
107+
return;
108+
}
109+
110+
// Multi-line comment
111+
if ('/*' === $matchChar) {
112+
if (false === $endPos = strpos($this->content, '*/', $matchPos + 2)) {
113+
$this->endsWithSequence(self::STATE_COMMENT, $position);
114+
115+
return;
116+
}
117+
118+
$this->cursor = min($endPos + 2, $position);
119+
$this->setSequence(self::STATE_COMMENT, $endPos + 2);
120+
continue;
121+
}
122+
123+
// Single-line comment
124+
if ('//' === $matchChar) {
125+
if (false === $endPos = strpos($this->content, "\n", $matchPos + 2)) {
126+
$this->endsWithSequence(self::STATE_COMMENT, $position);
127+
128+
return;
129+
}
130+
131+
$this->cursor = min($endPos + 1, $position);
132+
$this->setSequence(self::STATE_COMMENT, $endPos + 1);
133+
continue;
134+
}
135+
136+
// Single-line string
137+
if ('"' === $matchChar || "'" === $matchChar) {
138+
if (false === $endPos = strpos($this->content, $matchChar, $matchPos + 1)) {
139+
$this->endsWithSequence(self::STATE_STRING, $position);
140+
141+
return;
142+
}
143+
while (false !== $endPos && '\\' == $this->content[$endPos - 1]) {
144+
$endPos = strpos($this->content, $matchChar, $endPos + 1);
145+
}
146+
147+
$this->cursor = min($endPos + 1, $position);
148+
$this->setSequence(self::STATE_STRING, $endPos + 1);
149+
continue;
150+
}
151+
152+
// Multi-line string
153+
if ('`' === $matchChar) {
154+
if (false === $endPos = strpos($this->content, $matchChar, $matchPos + 1)) {
155+
$this->endsWithSequence(self::STATE_STRING, $position);
156+
157+
return;
158+
}
159+
while (false !== $endPos && '\\' == $this->content[$endPos - 1]) {
160+
$endPos = strpos($this->content, $matchChar, $endPos + 1);
161+
}
162+
163+
$this->cursor = min($endPos + 1, $position);
164+
$this->setSequence(self::STATE_STRING, $endPos + 1);
165+
}
166+
}
167+
}
168+
169+
/**
170+
* @param int<self::STATE_*> $type
171+
*/
172+
private function endsWithSequence(int $type, int $cursor): void
173+
{
174+
$this->cursor = $cursor;
175+
$this->currentSequenceType = $type;
176+
$this->currentSequenceEnd = $this->contentEnd;
177+
}
178+
179+
/**
180+
* @param int<self::STATE_*> $type
181+
*/
182+
private function setSequence(int $type, ?int $end = null): void
183+
{
184+
$this->currentSequenceType = $type;
185+
$this->currentSequenceEnd = $end;
186+
}
187+
}

src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -290,15 +290,6 @@ public static function provideCompileTests(): iterable
290290
'expectedJavaScriptImports' => [],
291291
];
292292

293-
yield 'multi_line_comment_with_no_end_parsed_for_safety' => [
294-
'input' => <<<EOF
295-
const fun;
296-
/* comment import("./other.js");
297-
EOF
298-
,
299-
'expectedJavaScriptImports' => ['/assets/other.js' => ['lazy' => true, 'asset' => 'other.js', 'add' => true]],
300-
];
301-
302293
yield 'multi_line_comment_with_no_end_found_eventually_ignored' => [
303294
'input' => <<<EOF
304295
const fun;

0 commit comments

Comments
 (0)