Skip to content

[AssetMapper] Improve import statements extraction #54134

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use Psr\Log\LoggerInterface;
use Symfony\Component\AssetMapper\AssetMapperInterface;
use Symfony\Component\AssetMapper\Compiler\Parser\JavascriptParser;
use Symfony\Component\AssetMapper\Exception\CircularAssetsException;
use Symfony\Component\AssetMapper\Exception\RuntimeException;
use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader;
Expand All @@ -30,16 +31,7 @@ final class JavaScriptImportPathCompiler implements AssetCompilerInterface
/**
* @see https://regex101.com/r/1iBAIb/1
*/
private const IMPORT_PATTERN = '/
^
(?:\/\/.*) # Lines that start with comments
|
(?:
\'(?:[^\'\\\\\n]|\\\\.)*\' # Strings enclosed in single quotes
|
"(?:[^"\\\\\n]|\\\\.)*" # Strings enclosed in double quotes
)
|
private const PREVIOUS_IMPORT_PATTERN = '/
(?: # Import statements (script captured)
import\s*
(?:
Expand All @@ -49,10 +41,11 @@ final class JavaScriptImportPathCompiler implements AssetCompilerInterface
|
\bimport\(
)
\s*[\'"`](\.\/[^\'"`\n]+|(\.\.\/)*[^\'"`\n]+)[\'"`]\s*[;\)]
?
\s*[\'"`](\.\/[^\'"`\n]+|(\.\.\/)*[^\'"`\n]+)[\'"`]\s*[)]
/mx';

private const IMPORT_PATTERN = '/import(?:{[^{}]+}|.*?)\s*(?:from)?\s*[\'"`](.*?)[\'"`]|import\(.*?\)/m';

public function __construct(
private readonly ImportMapConfigReader $importMapConfigReader,
private readonly string $missingImportMode = self::MISSING_IMPORT_WARN,
Expand All @@ -62,15 +55,13 @@ public function __construct(

public function compile(string $content, MappedAsset $asset, AssetMapperInterface $assetMapper): string
{
return preg_replace_callback(self::IMPORT_PATTERN, function ($matches) use ($asset, $assetMapper, $content) {
$fullImportString = $matches[0][0];
$jsParser = JavascriptParser::create($content);

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

if ($this->isCommentedOut($matches[0][1], $content)) {
$jsParser->parseUntil($matches[0][1]);
if (!$jsParser->isExecutable()) {
return $fullImportString;
}

Expand Down Expand Up @@ -142,33 +133,6 @@ private function handleMissingImport(string $message, ?\Throwable $e = null): vo
};
}

/**
* Simple check for the most common types of comments.
*
* This is not a full parser, but should be good enough for most cases.
*/
private function isCommentedOut(mixed $offsetStart, string $fullContent): bool
{
$lineStart = strrpos($fullContent, "\n", $offsetStart - \strlen($fullContent));
$lineContentBeforeImport = substr($fullContent, $lineStart, $offsetStart - $lineStart);
$firstTwoChars = substr(ltrim($lineContentBeforeImport), 0, 2);
if ('//' === $firstTwoChars) {
return true;
}

if ('/*' === $firstTwoChars) {
$commentEnd = strpos($fullContent, '*/', $lineStart);
// if we can't find the end comment, be cautious: assume this is not a comment
if (false === $commentEnd) {
return false;
}

return $offsetStart < $commentEnd;
}

return false;
}

private function findAssetForBareImport(string $importedModule, AssetMapperInterface $assetMapper): ?MappedAsset
{
if (!$importMapEntry = $this->importMapConfigReader->findRootImportMapEntry($importedModule)) {
Expand Down
49 changes: 49 additions & 0 deletions src/Symfony/Component/AssetMapper/Compiler/Parser/CodeSequence.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\AssetMapper\Compiler\Parser;

/**
* Represents a sequence of code (e.g. a string, a comment, a block of code).
*
* @author Simon André <smn.andre@gmail.com>
*
* @internal
*/
class CodeSequence implements \Stringable
{
public function __construct(
private readonly string $type,
private readonly int $start,
private readonly int $end,
) {
}

public function getType(): string
{
return $this->type;
}

public function getStart(): int
{
return $this->start;
}

public function getEnd(): int
{
return $this->end;
}

public function __toString(): string
{
return sprintf('%s [%d:%d]', $this->type, $this->start, $this->end);
}
}
186 changes: 186 additions & 0 deletions src/Symfony/Component/AssetMapper/Compiler/Parser/JavascriptParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\AssetMapper\Compiler\Parser;

/**
* Parses JavaScript content to identify sequences of strings, comments, etc.
*
* @author Simon André <smn.andre@gmail.com>
*
* @internal
*/
class JavascriptParser
{
private const STATE_DEFAULT = 'DEFAULT';
private const STATE_COMMENT = 'COMMENT';
private const STATE_STRING = 'STRING';

private int $cursor = 0;

private int $contentEnd;

private string $pattern;

private ?CodeSequence $currentSequence = null;

/**
* @var CodeSequence[]
*/
private array $sequences = [];

private function __construct(
private string $content,
) {
$this->contentEnd = \strlen($content);

$chars = [
'/*', // Multi-line comment
'//', // Single-line comment
'"', // Double quote
'\'', // Single quote
'`', // Backtick
];
$this->pattern = '/'.implode('|', array_map(fn ($ch) => preg_quote($ch, '/'), $chars)).'/';
}

public static function create(string $content): self
{
return new self($content);
}

/**
* @return CodeSequence[]
*/
public function getSequences(): array
{
return $this->sequences;
}

public function getCurrentSequence(): ?CodeSequence
{
return $this->currentSequence;
}

public function isExecutable(): bool
{
return self::STATE_DEFAULT === $this->currentSequence?->getType();
}

public function parseUntil(int $position): void
{
if ($position > $this->contentEnd) {
throw new \InvalidArgumentException('Cannot parse beyond the end of the content.');
}
if ($position < $this->cursor) {
throw new \InvalidArgumentException('Cannot parse backwards.');
}

while ($this->cursor <= $position) {
// Current CodeSequence ?
if (null !== $this->currentSequence) {
if ($this->currentSequence->getEnd() > $position) {
$this->cursor = $position;

return;
}

$this->cursor = $this->currentSequence->getEnd();
$this->currentSequence = null;
}

preg_match($this->pattern, $this->content, $matches, \PREG_OFFSET_CAPTURE, $this->cursor);
if (!$matches) {
$this->cursor = $position;
$this->pushSequence(self::STATE_DEFAULT, $this->cursor, $this->contentEnd);

return;
}

$matchPos = (int) $matches[0][1];
$matchChar = $matches[0][0];

if ($matchPos > $position) {
$this->pushSequence(self::STATE_DEFAULT, $this->cursor, $matchPos - 1);
$this->cursor = $position;

return;
}

// Multi-line comment
if ('/*' === $matchChar) {
if (false === $endPos = strpos($this->content, '*/', $matchPos + 2)) {
$this->cursor = $position;
$this->pushSequence(self::STATE_COMMENT, $matchPos, $this->contentEnd);

return;
}

$this->cursor = min($endPos + 2, $position);
$this->pushSequence(self::STATE_COMMENT, $matchPos, $endPos + 2);
continue;
}

// Single-line comment
if ('//' === $matchChar) {
if (false === $endPos = strpos($this->content, "\n", $matchPos + 2)) {
$this->cursor = $position;
$this->pushSequence(self::STATE_COMMENT, $matchPos, $this->contentEnd);

return;
}

$this->cursor = min($endPos + 1, $position);
$this->pushSequence(self::STATE_COMMENT, $matchPos, $endPos + 1);
continue;
}

// Single-line string
if ('"' === $matchChar || "'" === $matchChar) {
if (false === $endPos = strpos($this->content, $matchChar, $matchPos + 1)) {
$this->cursor = $position;
$this->pushSequence(self::STATE_STRING, $matchPos, $this->contentEnd);

return;
}
while (false !== $endPos && '\\' == $this->content[$endPos - 1]) {
$endPos = strpos($this->content, $matchChar, $endPos + 1);
}

$this->cursor = min($endPos + 1, $position);
$this->pushSequence(self::STATE_STRING, $matchPos, $endPos + 1);
continue;
}

// Multi-line string
if ('`' === $matchChar) {
if (false === $endPos = strpos($this->content, $matchChar, $matchPos + 1)) {
$this->cursor = $position;
$this->pushSequence(self::STATE_STRING, $matchPos, $this->contentEnd);

return;
}
while (false !== $endPos && '\\' == $this->content[$endPos - 1]) {
$endPos = strpos($this->content, $matchChar, $endPos + 1);
}

$this->cursor = min($endPos + 1, $position);
$this->pushSequence(self::STATE_STRING, $matchPos, $endPos + 1);
continue;
}
}
}

private function pushSequence(string $type, int $start, int $end): void
{
$this->sequences[] = $this->currentSequence = new CodeSequence($type, $start, $end);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -274,15 +274,6 @@ public static function provideCompileTests(): iterable
'expectedJavaScriptImports' => [],
];

yield 'multi_line_comment_with_no_end_parsed_for_safety' => [
'input' => <<<EOF
const fun;
/* comment import("./other.js");
EOF
,
'expectedJavaScriptImports' => ['/assets/other.js' => ['lazy' => true, 'asset' => 'other.js', 'add' => true]],
];

yield 'multi_line_comment_with_no_end_found_eventually_ignored' => [
'input' => <<<EOF
const fun;
Expand Down