Skip to content

Instantly share code, notes, and snippets.

@Charl13
Created March 25, 2025 13:17
Show Gist options
  • Save Charl13/eae8ea8dd130b7527d9b4a6b78c69ec6 to your computer and use it in GitHub Desktop.
Save Charl13/eae8ea8dd130b7527d9b4a6b78c69ec6 to your computer and use it in GitHub Desktop.
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <[email protected]>
* Dariusz Rumiński <[email protected]>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Fixer\FunctionNotation;
use PhpCsFixer\AbstractFixer;
use PhpCsFixer\Fixer\ConfigurableFixerInterface;
use PhpCsFixer\Fixer\ConfigurableFixerTrait;
use PhpCsFixer\Fixer\WhitespacesAwareFixerInterface;
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface;
use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
use PhpCsFixer\FixerDefinition\CodeSample;
use PhpCsFixer\FixerDefinition\FixerDefinition;
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
use PhpCsFixer\Preg;
use PhpCsFixer\Tokenizer\CT;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;
/**
* @author Kuanhung Chen <[email protected]>
*
* @implements ConfigurableFixerInterface<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration>
*
* @phpstan-type _AutogeneratedInputConfiguration array{
* after_heredoc?: bool,
* attribute_placement?: 'ignore'|'same_line'|'standalone',
* keep_multiple_spaces_after_comma?: bool,
* on_multiline?: 'ensure_fully_multiline'|'ensure_single_line'|'ignore',
* }
* @phpstan-type _AutogeneratedComputedConfiguration array{
* after_heredoc: bool,
* attribute_placement: 'ignore'|'same_line'|'standalone',
* keep_multiple_spaces_after_comma: bool,
* on_multiline: 'ensure_fully_multiline'|'ensure_single_line'|'ignore',
* }
*/
final class MethodArgumentSpaceFixer extends AbstractFixer implements ConfigurableFixerInterface, WhitespacesAwareFixerInterface
{
/** @use ConfigurableFixerTrait<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration> */
use ConfigurableFixerTrait;
public function getDefinition(): FixerDefinitionInterface
{
return new FixerDefinition(
'In method arguments and method call, there MUST NOT be a space before each comma and there MUST be one space after each comma. Argument lists MAY be split across multiple lines, where each subsequent line is indented once. When doing so, the first item in the list MUST be on the next line, and there MUST be only one argument per line.',
[
new CodeSample(
"<?php\nfunction sample(\$a=10,\$b=20,\$c=30) {}\nsample(1, 2);\n",
null
),
new CodeSample(
"<?php\nfunction sample(\$a=10,\$b=20,\$c=30) {}\nsample(1, 2);\n",
['keep_multiple_spaces_after_comma' => false]
),
new CodeSample(
"<?php\nfunction sample(\$a=10,\$b=20,\$c=30) {}\nsample(1, 2);\n",
['keep_multiple_spaces_after_comma' => true]
),
new CodeSample(
"<?php\nfunction sample(\$a=10,\n \$b=20,\$c=30) {}\nsample(1,\n 2);\n",
['on_multiline' => 'ensure_fully_multiline']
),
new CodeSample(
"<?php\nfunction sample(\n \$a=10,\n \$b=20,\n \$c=30\n) {}\nsample(\n 1,\n 2\n);\n",
['on_multiline' => 'ensure_single_line']
),
new CodeSample(
"<?php\nfunction sample(\$a=10,\n \$b=20,\$c=30) {}\nsample(1, \n 2);\nsample('foo', 'foobarbaz', 'baz');\nsample('foobar', 'bar', 'baz');\n",
[
'on_multiline' => 'ensure_fully_multiline',
'keep_multiple_spaces_after_comma' => true,
]
),
new CodeSample(
"<?php\nfunction sample(\$a=10,\n \$b=20,\$c=30) {}\nsample(1, \n 2);\nsample('foo', 'foobarbaz', 'baz');\nsample('foobar', 'bar', 'baz');\n",
[
'on_multiline' => 'ensure_fully_multiline',
'keep_multiple_spaces_after_comma' => false,
]
),
new CodeSample(
"<?php\nfunction sample(#[Foo] #[Bar] \$a=10,\n \$b=20,\$c=30) {}\nsample(1, 2);\n",
[
'on_multiline' => 'ensure_fully_multiline',
'attribute_placement' => 'ignore',
]
),
new CodeSample(
"<?php\nfunction sample(#[Foo]\n #[Bar]\n \$a=10,\n \$b=20,\$c=30) {}\nsample(1, 2);\n",
[
'on_multiline' => 'ensure_fully_multiline',
'attribute_placement' => 'same_line',
]
),
new CodeSample(
"<?php\nfunction sample(#[Foo] #[Bar] \$a=10,\n \$b=20,\$c=30) {}\nsample(1, 2);\n",
[
'on_multiline' => 'ensure_fully_multiline',
'attribute_placement' => 'standalone',
]
),
new CodeSample(
<<<'SAMPLE'
<?php
sample(
<<<EOD
foo
EOD
,
'bar'
);
SAMPLE
,
['after_heredoc' => true]
),
],
'This fixer covers rules defined in PSR2 ¶4.4, ¶4.6.'
);
}
public function isCandidate(Tokens $tokens): bool
{
return $tokens->isTokenKindFound('(');
}
/**
* {@inheritdoc}
*
* Must run before ArrayIndentationFixer, StatementIndentationFixer.
* Must run after CombineNestedDirnameFixer, FunctionDeclarationFixer, ImplodeCallFixer, LambdaNotUsedImportFixer, NoMultilineWhitespaceAroundDoubleArrowFixer, NoUselessSprintfFixer, PowToExponentiationFixer, StrictParamFixer.
*/
public function getPriority(): int
{
return 30;
}
protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
{
$expectedTokens = [T_LIST, T_FUNCTION, CT::T_USE_LAMBDA, T_FN, T_CLASS];
$tokenCount = $tokens->count();
for ($index = 1; $index < $tokenCount; ++$index) {
$token = $tokens[$index];
if (!$token->equals('(')) {
continue;
}
$meaningfulTokenBeforeParenthesis = $tokens[$tokens->getPrevMeaningfulToken($index)];
if (
$meaningfulTokenBeforeParenthesis->isKeyword()
&& !$meaningfulTokenBeforeParenthesis->isGivenKind($expectedTokens)
) {
continue;
}
$isMultiline = $this->fixFunction($tokens, $index);
if (
$isMultiline
&& 'ensure_fully_multiline' === $this->configuration['on_multiline']
&& !$meaningfulTokenBeforeParenthesis->isGivenKind(T_LIST)
) {
$this->ensureFunctionFullyMultiline($tokens, $index);
}
}
}
protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
{
return new FixerConfigurationResolver([
(new FixerOptionBuilder('keep_multiple_spaces_after_comma', 'Whether keep multiple spaces after comma.'))
->setAllowedTypes(['bool'])
->setDefault(false)
->getOption(),
(new FixerOptionBuilder(
'on_multiline',
'Defines how to handle function arguments lists that contain newlines.'
))
->setAllowedValues(['ignore', 'ensure_single_line', 'ensure_fully_multiline'])
->setDefault('ensure_fully_multiline')
->getOption(),
(new FixerOptionBuilder('after_heredoc', 'Whether the whitespace between heredoc end and comma should be removed.'))
->setAllowedTypes(['bool'])
->setDefault(false)
->getOption(),
(new FixerOptionBuilder(
'attribute_placement',
'Defines how to handle argument attributes when function definition is multiline.'
))
->setAllowedValues(['ignore', 'same_line', 'standalone'])
->setDefault('standalone')
->getOption(),
]);
}
/**
* Fix arguments spacing for given function.
*
* @param Tokens $tokens Tokens to handle
* @param int $startFunctionIndex Start parenthesis position
*
* @return bool whether the function is multiline
*/
private function fixFunction(Tokens $tokens, int $startFunctionIndex): bool
{
$isMultiline = false;
$endFunctionIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $startFunctionIndex);
$firstWhitespaceIndex = $this->findWhitespaceIndexAfterParenthesis($tokens, $startFunctionIndex, $endFunctionIndex);
$lastWhitespaceIndex = $this->findWhitespaceIndexAfterParenthesis($tokens, $endFunctionIndex, $startFunctionIndex);
foreach ([$firstWhitespaceIndex, $lastWhitespaceIndex] as $index) {
if (null === $index || !Preg::match('/\R/', $tokens[$index]->getContent())) {
continue;
}
if ('ensure_single_line' !== $this->configuration['on_multiline']) {
$isMultiline = true;
continue;
}
$newLinesRemoved = $this->ensureSingleLine($tokens, $index);
if (!$newLinesRemoved) {
$isMultiline = true;
}
}
for ($index = $endFunctionIndex - 1; $index > $startFunctionIndex; --$index) {
$token = $tokens[$index];
if ($token->equals(')')) {
$index = $tokens->findBlockStart(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $index);
continue;
}
if ($token->isGivenKind(CT::T_ARRAY_SQUARE_BRACE_CLOSE)) {
$index = $tokens->findBlockStart(Tokens::BLOCK_TYPE_ARRAY_SQUARE_BRACE, $index);
continue;
}
if ($token->equals('}')) {
$index = $tokens->findBlockStart(Tokens::BLOCK_TYPE_CURLY_BRACE, $index);
continue;
}
if ($token->equals(',')) {
$this->fixSpace($tokens, $index);
if (!$isMultiline && $this->isNewline($tokens[$index + 1])) {
$isMultiline = true;
}
}
}
return $isMultiline;
}
private function findWhitespaceIndexAfterParenthesis(Tokens $tokens, int $startParenthesisIndex, int $endParenthesisIndex): ?int
{
$direction = $endParenthesisIndex > $startParenthesisIndex ? 1 : -1;
$startIndex = $startParenthesisIndex + $direction;
$endIndex = $endParenthesisIndex - $direction;
for ($index = $startIndex; $index !== $endIndex; $index += $direction) {
$token = $tokens[$index];
if ($token->isWhitespace()) {
return $index;
}
if (!$token->isComment()) {
break;
}
}
return null;
}
/**
* @return bool Whether newlines were removed from the whitespace token
*/
private function ensureSingleLine(Tokens $tokens, int $index): bool
{
$previousToken = $tokens[$index - 1];
if ($previousToken->isComment() && !str_starts_with($previousToken->getContent(), '/*')) {
return false;
}
$content = Preg::replace('/\R\h*/', '', $tokens[$index]->getContent());
$tokens->ensureWhitespaceAtIndex($index, 0, $content);
return true;
}
private function ensureFunctionFullyMultiline(Tokens $tokens, int $startFunctionIndex): void
{
// find out what the indentation is
$searchIndex = $startFunctionIndex;
do {
$prevWhitespaceTokenIndex = $tokens->getPrevTokenOfKind(
$searchIndex,
[[T_ENCAPSED_AND_WHITESPACE], [T_INLINE_HTML], [T_WHITESPACE]],
);
$searchIndex = $prevWhitespaceTokenIndex;
} while (null !== $prevWhitespaceTokenIndex
&& !str_contains($tokens[$prevWhitespaceTokenIndex]->getContent(), "\n")
);
if (null === $prevWhitespaceTokenIndex) {
$existingIndentation = '';
} elseif (!$tokens[$prevWhitespaceTokenIndex]->isGivenKind(T_WHITESPACE)) {
return;
} else {
$existingIndentation = $tokens[$prevWhitespaceTokenIndex]->getContent();
$lastLineIndex = strrpos($existingIndentation, "\n");
$existingIndentation = false === $lastLineIndex
? $existingIndentation
: substr($existingIndentation, $lastLineIndex + 1);
}
$indentation = $existingIndentation.$this->whitespacesConfig->getIndent();
$endFunctionIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $startFunctionIndex);
$wasWhitespaceBeforeEndFunctionAddedAsNewToken = $tokens->ensureWhitespaceAtIndex(
$tokens[$endFunctionIndex - 1]->isWhitespace() ? $endFunctionIndex - 1 : $endFunctionIndex,
0,
$this->whitespacesConfig->getLineEnding().$existingIndentation
);
if ($wasWhitespaceBeforeEndFunctionAddedAsNewToken) {
++$endFunctionIndex;
}
for ($index = $endFunctionIndex - 1; $index > $startFunctionIndex; --$index) {
$token = $tokens[$index];
// skip nested method calls and arrays
if ($token->equals(')')) {
$index = $tokens->findBlockStart(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $index);
continue;
}
// skip nested arrays
if ($token->isGivenKind(CT::T_ARRAY_SQUARE_BRACE_CLOSE)) {
$index = $tokens->findBlockStart(Tokens::BLOCK_TYPE_ARRAY_SQUARE_BRACE, $index);
continue;
}
if ($token->equals('}')) {
$index = $tokens->findBlockStart(Tokens::BLOCK_TYPE_CURLY_BRACE, $index);
continue;
}
if ($tokens[$tokens->getNextMeaningfulToken($index)]->equals(')')) {
continue;
}
if ($token->isGivenKind(CT::T_ATTRIBUTE_CLOSE)) {
if ('standalone' === $this->configuration['attribute_placement']) {
$this->fixNewline($tokens, $index, $indentation);
} elseif ('same_line' === $this->configuration['attribute_placement']) {
$this->ensureSingleLine($tokens, $index + 1);
$tokens->ensureWhitespaceAtIndex($index + 1, 0, ' ');
}
$index = $tokens->findBlockStart(Tokens::BLOCK_TYPE_ATTRIBUTE, $index);
continue;
}
if ($token->equals(',')) {
$this->fixNewline($tokens, $index, $indentation);
}
}
$this->fixNewline($tokens, $startFunctionIndex, $indentation, false);
}
/**
* Method to insert newline after comma, attribute or opening parenthesis.
*
* @param int $index index of a comma
* @param string $indentation the indentation that should be used
* @param bool $override whether to override the existing character or not
*/
private function fixNewline(Tokens $tokens, int $index, string $indentation, bool $override = true): void
{
if ($tokens[$index + 1]->isComment()) {
return;
}
if ($tokens[$index + 2]->isComment()) {
$nextMeaningfulTokenIndex = $tokens->getNextMeaningfulToken($index + 2);
if (!$this->isNewline($tokens[$nextMeaningfulTokenIndex - 1])) {
if ($tokens[$nextMeaningfulTokenIndex - 1]->isWhitespace()) {
$tokens->clearAt($nextMeaningfulTokenIndex - 1);
}
$tokens->ensureWhitespaceAtIndex($nextMeaningfulTokenIndex, 0, $this->whitespacesConfig->getLineEnding().$indentation);
}
return;
}
$nextMeaningfulTokenIndex = $tokens->getNextMeaningfulToken($index);
if ($tokens[$nextMeaningfulTokenIndex]->equals(')')) {
return;
}
$tokens->ensureWhitespaceAtIndex($index + 1, 0, $this->whitespacesConfig->getLineEnding().$indentation);
}
/**
* Method to insert space after comma and remove space before comma.
*/
private function fixSpace(Tokens $tokens, int $index): void
{
// remove space before comma if exist
if ($tokens[$index - 1]->isWhitespace()) {
$prevIndex = $tokens->getPrevNonWhitespace($index - 1);
if (
!$tokens[$prevIndex]->equals(',') && !$tokens[$prevIndex]->isComment()
&& (true === $this->configuration['after_heredoc'] || !$tokens[$prevIndex]->isGivenKind(T_END_HEREDOC))
) {
$tokens->clearAt($index - 1);
}
}
$nextIndex = $index + 1;
$nextToken = $tokens[$nextIndex];
// Two cases for fix space after comma (exclude multiline comments)
// 1) multiple spaces after comma
// 2) no space after comma
if ($nextToken->isWhitespace()) {
$newContent = $nextToken->getContent();
if ('ensure_single_line' === $this->configuration['on_multiline']) {
$newContent = Preg::replace('/\R/', '', $newContent);
}
if (
(false === $this->configuration['keep_multiple_spaces_after_comma'] || Preg::match('/\R/', $newContent))
&& !$this->isCommentLastLineToken($tokens, $index + 2)
) {
$newContent = ltrim($newContent, " \t");
}
$tokens[$nextIndex] = new Token([T_WHITESPACE, '' === $newContent ? ' ' : $newContent]);
return;
}
if (!$this->isCommentLastLineToken($tokens, $index + 1)) {
$tokens->insertAt($index + 1, new Token([T_WHITESPACE, ' ']));
}
}
/**
* Check if last item of current line is a comment.
*
* @param Tokens $tokens tokens to handle
* @param int $index index of token
*/
private function isCommentLastLineToken(Tokens $tokens, int $index): bool
{
if (!$tokens[$index]->isComment() || !$tokens[$index + 1]->isWhitespace()) {
return false;
}
$content = $tokens[$index + 1]->getContent();
return $content !== ltrim($content, "\r\n");
}
/**
* Checks if token is new line.
*/
private function isNewline(Token $token): bool
{
return $token->isWhitespace() && str_contains($token->getContent(), "\n");
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment