Skip to content
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

fix: no_useless_concat_operator - do not break variable #7827

Merged
97 changes: 60 additions & 37 deletions src/Fixer/Operator/NoUselessConcatOperatorFixer.php
Expand Up @@ -26,6 +26,13 @@
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;

/**
* @phpstan-type _ConcatOperandType array{
* start: int,
* end: int,
* type: self::STR_*,
* }
*/
final class NoUselessConcatOperatorFixer extends AbstractFixer implements ConfigurableFixerInterface
{
private const STR_DOUBLE_QUOTE = 0;
Expand All @@ -47,7 +54,7 @@ public function getDefinition(): FixerDefinitionInterface
* {@inheritdoc}
*
* Must run before DateTimeCreateFromFormatCallFixer, EregToPregFixer, PhpUnitDedicateAssertInternalTypeFixer, RegularCallableCallFixer, SetTypeToCastFixer.
* Must run after NoBinaryStringFixer, SingleQuoteFixer.
* Must run after ExplicitStringVariableFixer, NoBinaryStringFixer, SingleQuoteFixer.
*/
public function getPriority(): int
{
Expand Down Expand Up @@ -105,16 +112,8 @@ protected function createConfigurationDefinition(): FixerConfigurationResolverIn
}

/**
* @param array{
* start: int,
* end: int,
* type: self::STR_*,
* } $firstOperand
* @param array{
* start: int,
* end: int,
* type: self::STR_*,
* } $secondOperand
* @param _ConcatOperandType $firstOperand
* @param _ConcatOperandType $secondOperand
*/
private function fixConcatOperation(Tokens $tokens, array $firstOperand, int $concatIndex, array $secondOperand): void
{
Expand All @@ -130,6 +129,10 @@ private function fixConcatOperation(Tokens $tokens, array $firstOperand, int $co
}

if (self::STR_DOUBLE_QUOTE_VAR === $firstOperand['type'] && self::STR_DOUBLE_QUOTE_VAR === $secondOperand['type']) {
if ($this->operandsCanNotBeMerged($tokens, $firstOperand, $secondOperand)) {
return;
}

$this->mergeConstantEscapedStringVarOperands($tokens, $firstOperand, $concatIndex, $secondOperand);

return;
Expand All @@ -146,6 +149,10 @@ private function fixConcatOperation(Tokens $tokens, array $firstOperand, int $co
[$operand1, $operand2] = $operandPair;

if (self::STR_DOUBLE_QUOTE_VAR === $operand1['type'] && self::STR_DOUBLE_QUOTE === $operand2['type']) {
if ($this->operandsCanNotBeMerged($tokens, $operand1, $operand2)) {
return;
}

$this->mergeConstantEscapedStringVarOperands($tokens, $firstOperand, $concatIndex, $secondOperand);

return;
Expand All @@ -169,6 +176,10 @@ private function fixConcatOperation(Tokens $tokens, array $firstOperand, int $co
$operantContent = $tokens[$operand2['start']]->getContent();

if ($this->isSimpleQuotedStringContent($operantContent)) {
if ($this->operandsCanNotBeMerged($tokens, $operand1, $operand2)) {
return;
}

$this->mergeConstantEscapedStringVarOperands($tokens, $firstOperand, $concatIndex, $secondOperand);
}

Expand All @@ -180,11 +191,7 @@ private function fixConcatOperation(Tokens $tokens, array $firstOperand, int $co
/**
* @param -1|1 $direction
*
* @return null|array{
* start: int,
* end: int,
* type: self::STR_*,
* }
* @return null|_ConcatOperandType
*/
private function getConcatOperandType(Tokens $tokens, int $index, int $direction): ?array
{
Expand Down Expand Up @@ -216,16 +223,8 @@ private function getConcatOperandType(Tokens $tokens, int $index, int $direction
}

/**
* @param array{
* start: int,
* end: int,
* type: self::STR_*,
* } $firstOperand
* @param array{
* start: int,
* end: int,
* type: self::STR_*,
* } $secondOperand
* @param _ConcatOperandType $firstOperand
* @param _ConcatOperandType $secondOperand
*/
private function mergeConstantEscapedStringOperands(
Tokens $tokens,
Expand All @@ -249,24 +248,16 @@ private function mergeConstantEscapedStringOperands(
}

/**
* @param array{
* start: int,
* end: int,
* type: self::STR_*,
* } $firstOperand
* @param array{
* start: int,
* end: int,
* type: self::STR_*,
* } $secondOperand
* @param _ConcatOperandType $firstOperand
* @param _ConcatOperandType $secondOperand
*/
private function mergeConstantEscapedStringVarOperands(
Tokens $tokens,
array $firstOperand,
int $concatOperatorIndex,
array $secondOperand
): void {
// build uo the new content
// build up the new content
$newContent = '';

foreach ([$firstOperand, $secondOperand] as $operant) {
Expand Down Expand Up @@ -336,4 +327,36 @@ private function containsLinebreak(Tokens $tokens, int $startIndex, int $endInde

return false;
}

/**
* @param _ConcatOperandType $firstOperand
* @param _ConcatOperandType $secondOperand
*/
private function operandsCanNotBeMerged(Tokens $tokens, array $firstOperand, array $secondOperand): bool
{
// If the first operand does not end with a variable, no variables would be broken by concatenation.
if (self::STR_DOUBLE_QUOTE_VAR !== $firstOperand['type']) {
return false;
}
if (!$tokens[$firstOperand['end'] - 1]->isGivenKind(T_VARIABLE)) {
return false;
}

$allowedPatternsForSecondOperand = [
'/^\s.*/', // e.g. " foo", ' bar', " $baz"
'/^-(?!\>)/', // e.g. "-foo", '-bar', "-$baz"
];
Comment on lines +345 to +348
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this will allow merging strings in less cases than would technically be possible but I'm guessing this will actually be very rare so good enough for me, we'll improve this later if needed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can always think about making regexes configurable, so people can fine-tune this behaviour on their side 🙂.


// If the first operand ends with a variable, the second operand should match one of the allowed patterns.
// Otherwise, the concatenation can break a variable in the first operand.
foreach ($allowedPatternsForSecondOperand as $allowedPattern) {
$secondOperandInnerContent = substr($tokens->generatePartialCode($secondOperand['start'], $secondOperand['end']), 1, -1);

if (Preg::match($allowedPattern, $secondOperandInnerContent)) {
return false;
}
}

return true;
}
}
3 changes: 2 additions & 1 deletion src/Fixer/StringNotation/ExplicitStringVariableFixer.php
Expand Up @@ -52,11 +52,12 @@ public function getDefinition(): FixerDefinitionInterface
/**
* {@inheritdoc}
*
* Must run before NoUselessConcatOperatorFixer.
* Must run after BacktickToShellExecFixer.
*/
public function getPriority(): int
{
return 0;
return 6;
}

public function isCandidate(Tokens $tokens): bool
Expand Down
3 changes: 3 additions & 0 deletions tests/AutoReview/FixerFactoryTest.php
Expand Up @@ -442,6 +442,9 @@ private static function getFixersPriorityGraph(): array
'heredoc_to_nowdoc',
'single_quote',
],
'explicit_string_variable' => [
'no_useless_concat_operator',
],
'final_class' => [
'protected_to_private',
'self_static_accessor',
Expand Down
47 changes: 47 additions & 0 deletions tests/Fixer/Operator/NoUselessConcatOperatorFixerTest.php
Expand Up @@ -136,6 +136,53 @@ public static function provideFixCases(): iterable
',
];

yield 'do not fix if the execution result would be different' => [
'<?php
echo "abc $d" . "[$e]";
echo "abc $d" . "[3]";
echo "abc $d" . \'[3]\';
echo "abc $d" . "->e";
echo "abc $d" . \'->e\';
echo "abc $d" . "->$e";
echo "abc $d" . "?->e";
echo "abc $d" . "?->$e";
echo "abc $d" . \'?->e\';
',
];

tamiroh marked this conversation as resolved.
Show resolved Hide resolved
yield 'do not fix if variables would be broken' => [
'<?php
echo "abc $d" . "e $f";
julienfalque marked this conversation as resolved.
Show resolved Hide resolved
echo "abc $d" . "e";
echo "abc $d" . \'e\';
julienfalque marked this conversation as resolved.
Show resolved Hide resolved
echo "abc $d" . "😃"; // with emoji
echo "私の名前は$name" . "です"; // multibyte characters (Japanese)
',
];

yield 'fix if variables would not be broken' => [
'<?php
echo "$a b";
echo "$a bcde";
echo "abc ${d}e";
echo "abc $d-efg";
echo "$a bcdef";
echo "abc ${d}ef";
echo "abc $d-efgh";
echo "abc $d-$e";
',
'<?php
echo "$a" . " b";
echo "$a bc" . "de";
echo "abc ${d}" . "e";
echo "abc $d" . "-efg";
echo "$a bc" . \'def\';
echo "abc ${d}" . \'ef\';
echo "abc $d" . \'-efgh\';
echo "abc $d" . "-$e";
',
];

yield 'single quote concat single quote but with line break after' => [
"<?php \$fh = 'x'. // some comment
'y';",
Expand Down
@@ -0,0 +1,13 @@
--TEST--
Integration of fixers: explicit_string_variable,no_useless_concat_operator.
--RULESET--
{"no_useless_concat_operator": true, "explicit_string_variable": true}
--EXPECT--
<?php

$foo = "bar {$baz}qux";

--INPUT--
<?php

$foo = "bar $baz" . "qux";