Skip to content

[AssetMapper] Detect import with a sequence parser #59004

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

Merged
merged 1 commit into from
Jan 25, 2025
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
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\JavascriptSequenceParser;
use Symfony\Component\AssetMapper\Exception\CircularAssetsException;
use Symfony\Component\AssetMapper\Exception\RuntimeException;
use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader;
Expand Down Expand Up @@ -61,15 +62,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 = new JavascriptSequenceParser($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 @@ -146,33 +145,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
<?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
*/
final class JavascriptSequenceParser
{
private const STATE_DEFAULT = 0;
private const STATE_COMMENT = 1;
private const STATE_STRING = 2;

private int $cursor = 0;

private int $contentEnd;

private string $pattern;

private int $currentSequenceType = self::STATE_DEFAULT;

private ?int $currentSequenceEnd = null;

private const COMMENT_SEPARATORS = [
'/*', // Multi-line comment
'//', // Single-line comment
'"', // Double quote
'\'', // Single quote
'`', // Backtick
];

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

$this->pattern ??= '/'.implode('|', array_map(
fn (string $ch): string => preg_quote($ch, '/'),
self::COMMENT_SEPARATORS
)).'/';
}

public function isString(): bool
{
return self::STATE_STRING === $this->currentSequenceType;
}

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

public function isComment(): bool
{
return self::STATE_COMMENT === $this->currentSequenceType;
}

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

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

return;
}

$this->cursor = $this->currentSequenceEnd;
$this->setSequence(self::STATE_DEFAULT, null);
}

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

return;
}

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

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

return;
}

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

return;
}

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

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

return;
}

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

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

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

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

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

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

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

/**
* @param int<self::STATE_*> $type
*/
private function endsWithSequence(int $type, int $cursor): void
{
$this->cursor = $cursor;
$this->currentSequenceType = $type;
$this->currentSequenceEnd = $this->contentEnd;
}

/**
* @param int<self::STATE_*> $type
*/
private function setSequence(int $type, ?int $end = null): void
{
$this->currentSequenceType = $type;
$this->currentSequenceEnd = $end;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -290,15 +290,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
Loading
Loading