Skip to content

Commit

Permalink
feat: Introduce multiline_string_to_heredoc fixer (PHP-CS-Fixer#7665)
Browse files Browse the repository at this point in the history
  • Loading branch information
mvorisek authored and danog committed Feb 2, 2024
1 parent 9ee9fb1 commit 2d4438b
Show file tree
Hide file tree
Showing 9 changed files with 632 additions and 2 deletions.
3 changes: 3 additions & 0 deletions doc/rules/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,9 @@ String Notation
- `heredoc_to_nowdoc <./string_notation/heredoc_to_nowdoc.rst>`_

Convert ``heredoc`` to ``nowdoc`` where possible.
- `multiline_string_to_heredoc <./string_notation/multiline_string_to_heredoc.rst>`_

Convert multiline string to ``heredoc`` or ``nowdoc``.
- `no_binary_string <./string_notation/no_binary_string.rst>`_

There should not be a binary flag before strings.
Expand Down
45 changes: 45 additions & 0 deletions doc/rules/string_notation/multiline_string_to_heredoc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
====================================
Rule ``multiline_string_to_heredoc``
====================================

Convert multiline string to ``heredoc`` or ``nowdoc``.

Examples
--------

Example #1
~~~~~~~~~~

.. code-block:: diff
--- Original
+++ New
<?php
-$a = 'line1
-line2';
+$a = <<<'EOD'
+line1
+line2
+EOD;
Example #2
~~~~~~~~~~

.. code-block:: diff
--- Original
+++ New
<?php
-$a = "line1
-{$obj->getName()}";
+$a = <<<EOD
+line1
+{$obj->getName()}
+EOD;
References
----------

- Fixer class: `PhpCsFixer\\Fixer\\StringNotation\\MultilineStringToHeredocFixer <./../../../src/Fixer/StringNotation/MultilineStringToHeredocFixer.php>`_
- Test class: `PhpCsFixer\\Tests\\Fixer\\StringNotation\\MultilineStringToHeredocFixerTest <./../../../tests/Fixer/StringNotation/MultilineStringToHeredocFixerTest.php>`_

The test class defines officially supported behaviour. Each test case is a part of our backward compatibility promise.
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ public function isCandidate(Tokens $tokens): bool
* {@inheritdoc}
*
* Must run before HeredocToNowdocFixer, SingleQuoteFixer.
* Must run after BacktickToShellExecFixer.
* Must run after BacktickToShellExecFixer, MultilineStringToHeredocFixer.
*/
public function getPriority(): int
{
Expand Down
166 changes: 166 additions & 0 deletions src/Fixer/StringNotation/MultilineStringToHeredocFixer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<?php

declare(strict_types=1);

/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/

namespace PhpCsFixer\Fixer\StringNotation;

use PhpCsFixer\AbstractFixer;
use PhpCsFixer\FixerDefinition\CodeSample;
use PhpCsFixer\FixerDefinition\FixerDefinition;
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
use PhpCsFixer\Preg;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;

/**
* @author Michael Vorisek <https://github.com/mvorisek>
*/
final class MultilineStringToHeredocFixer extends AbstractFixer
{
public function getDefinition(): FixerDefinitionInterface
{
return new FixerDefinition(
'Convert multiline string to `heredoc` or `nowdoc`.',
[
new CodeSample(
<<<'EOD'
<?php
$a = 'line1
line2';
EOD."\n"
),
new CodeSample(
<<<'EOD'
<?php
$a = "line1
{$obj->getName()}";
EOD."\n"
),
]
);
}

public function isCandidate(Tokens $tokens): bool
{
return $tokens->isAnyTokenKindsFound([T_CONSTANT_ENCAPSED_STRING, T_ENCAPSED_AND_WHITESPACE]);
}

/**
* {@inheritdoc}
*
* Must run before EscapeImplicitBackslashesFixer, HeredocIndentationFixer.
*/
public function getPriority(): int
{
return 16;
}

protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
{
$inHeredoc = false;
$complexStringStartIndex = null;
foreach ($tokens as $index => $token) {
if ($token->isGivenKind([T_START_HEREDOC, T_END_HEREDOC])) {
$inHeredoc = $token->isGivenKind(T_START_HEREDOC) || !$token->isGivenKind(T_END_HEREDOC);

continue;
}

if (null === $complexStringStartIndex) {
if ($token->isGivenKind(T_CONSTANT_ENCAPSED_STRING)) {
$this->convertStringToHeredoc($tokens, $index, $index);

// skip next 2 added tokens if replaced
if ($tokens[$index]->isGivenKind(T_START_HEREDOC)) {
$inHeredoc = true;
}
} elseif ($token->equalsAny(['"', 'b"', 'B"'])) {
$complexStringStartIndex = $index;
}
} elseif ($token->equals('"')) {
$this->convertStringToHeredoc($tokens, $complexStringStartIndex, $index);

$complexStringStartIndex = null;
}
}
}

private function convertStringToHeredoc(Tokens $tokens, int $stringStartIndex, int $stringEndIndex): void
{
$closingMarker = 'EOD';

if ($tokens[$stringStartIndex]->isGivenKind(T_CONSTANT_ENCAPSED_STRING)) {
$content = $tokens[$stringStartIndex]->getContent();
if ('b' === strtolower(substr($content, 0, 1))) {
$content = substr($content, 1);
}
$isSingleQuoted = str_starts_with($content, '\'');
$content = substr($content, 1, -1);

if ($isSingleQuoted) {
$content = Preg::replace('~\\\\([\\\\\'])~', '$1', $content);
} else {
$content = Preg::replace('~(\\\\\\\\)|\\\\(")~', '$1$2', $content);
}

$constantStringToken = new Token([T_ENCAPSED_AND_WHITESPACE, $content."\n"]);
} else {
$content = $tokens->generatePartialCode($stringStartIndex + 1, $stringEndIndex - 1);
$isSingleQuoted = false;
$constantStringToken = null;
}

if (!str_contains($content, "\n") && !str_contains($content, "\r")) {
return;
}

while (Preg::match('~(^|[\r\n])\s*'.preg_quote($closingMarker, '~').'(?!\w)~', $content)) {
$closingMarker .= '_';
}

$quoting = $isSingleQuoted ? '\'' : '';
$heredocStartToken = new Token([T_START_HEREDOC, '<<<'.$quoting.$closingMarker.$quoting."\n"]);
$heredocEndToken = new Token([T_END_HEREDOC, $closingMarker]);

if (null !== $constantStringToken) {
$tokens->overrideRange($stringStartIndex, $stringEndIndex, [
$heredocStartToken,
$constantStringToken,
$heredocEndToken,
]);
} else {
for ($i = $stringStartIndex + 1; $i < $stringEndIndex; ++$i) {
if ($tokens[$i]->isGivenKind(T_ENCAPSED_AND_WHITESPACE)) {
$tokens[$i] = new Token([
$tokens[$i]->getId(),
Preg::replace('~(\\\\\\\\)|\\\\(")~', '$1$2', $tokens[$i]->getContent()),
]);
}
}

$tokens[$stringStartIndex] = $heredocStartToken;
$tokens[$stringEndIndex] = $heredocEndToken;
if ($tokens[$stringEndIndex - 1]->isGivenKind(T_ENCAPSED_AND_WHITESPACE)) {
$tokens[$stringEndIndex - 1] = new Token([
$tokens[$stringEndIndex - 1]->getId(),
$tokens[$stringEndIndex - 1]->getContent()."\n",
]);
} else {
$tokens->insertAt($stringEndIndex, new Token([
T_ENCAPSED_AND_WHITESPACE,
"\n",
]));
}
}
}
}
2 changes: 1 addition & 1 deletion src/Fixer/Whitespace/HeredocIndentationFixer.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public function getDefinition(): FixerDefinitionInterface
/**
* {@inheritdoc}
*
* Must run after BracesFixer, StatementIndentationFixer.
* Must run after BracesFixer, MultilineStringToHeredocFixer, StatementIndentationFixer.
*/
public function getPriority(): int
{
Expand Down
4 changes: 4 additions & 0 deletions tests/AutoReview/FixerFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,10 @@ private static function getFixersPriorityGraph(): array
'modernize_types_casting' => [
'no_unneeded_control_parentheses',
],
'multiline_string_to_heredoc' => [
'escape_implicit_backslashes',
'heredoc_indentation',
],
'multiline_whitespace_before_semicolons' => [
'space_after_semicolon',
],
Expand Down

0 comments on commit 2d4438b

Please sign in to comment.