Skip to content
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.build/
.idea
.php-cs-fixer.cache
.phpunit.result.cache
composer.lock
vendor
2 changes: 2 additions & 0 deletions config.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ function config(Finder $finder, array $ruleOverrides = []): Config
'less_and_greater' => false,
],

Fixer\LineBreakBeforeThrowExpressionFixer::NAME => true,
Fixer\PhpdocSimplifyArrayKeyFixer::NAME => true,

PhpCsFixerCustomFixers\Fixer\ConstructorEmptyBracesFixer::name() => true,
Expand All @@ -97,6 +98,7 @@ function config(Finder $finder, array $ruleOverrides = []): Config
return (new Config())
->registerCustomFixers(new PhpCsFixerCustomFixers\Fixers())
->registerCustomFixers([
new Fixer\LineBreakBeforeThrowExpressionFixer(),
new Fixer\PhpdocSimplifyArrayKeyFixer(),
])
->setFinder($finder)
Expand Down
4 changes: 4 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
<testsuites>
<testsuite name="Tests">
<directory>tests</directory>
<exclude>tests/Fixer/Php80</exclude>
</testsuite>
<testsuite name="Php80">
<directory phpVersion="8.0" phpVersionOperator="&gt;=">tests/Fixer/Php80</directory>
</testsuite>
</testsuites>
</phpunit>
209 changes: 209 additions & 0 deletions src/Fixer/LineBreakBeforeThrowExpressionFixer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
<?php declare(strict_types=1);

namespace MLL\PhpCsFixerConfig\Fixer;

use PhpCsFixer\AbstractFixer;
use PhpCsFixer\Fixer\WhitespacesAwareFixerInterface;
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;

/**
* Forces line breaks before `?? throw` and `?: throw` patterns.
*/
final class LineBreakBeforeThrowExpressionFixer extends AbstractFixer implements WhitespacesAwareFixerInterface
{
public const NAME = 'MLL/line_break_before_throw_expression';

public function getName(): string
{
return self::NAME;
}

public function getDefinition(): FixerDefinitionInterface
{
return new FixerDefinition(
'Coalesce or elvis throw expressions (`?? throw`, `?: throw`) must be on their own line.',
[
new CodeSample(
<<<'PHP'
<?php
$result = $this->fetchNullable() ?? throw new \RuntimeException('message');

PHP
),
]
);
}

public function isCandidate(Tokens $tokens): bool
{
return $tokens->isTokenKindFound(T_THROW);
}

public function getPriority(): int
{
// Run before operator_linebreak (priority 0)
return 1;
}

protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
{
for ($index = $tokens->count() - 1; $index > 0; --$index) {
if (! $tokens[$index]->isGivenKind(T_THROW)) {
continue;
}

$operatorIndex = $this->findPrecedingThrowOperator($tokens, $index);
if ($operatorIndex === null) {
continue;
}

$this->ensureLineBreakBeforeOperator($tokens, $operatorIndex);
}
}

/**
* Find `??` or `?:` operator immediately before the throw token.
*
* @return int|null The index of the operator (T_COALESCE or `?` for elvis), or null
*/
private function findPrecedingThrowOperator(Tokens $tokens, int $throwIndex): ?int
{
$prevIndex = $tokens->getPrevMeaningfulToken($throwIndex);
if ($prevIndex === null) {
return null;
}

// Check for ?? (T_COALESCE)
if ($tokens[$prevIndex]->isGivenKind(T_COALESCE)) {
return $prevIndex;
}

// Check for ?: (elvis operator - two separate tokens)
if ($tokens[$prevIndex]->equals(':')) {
$questionIndex = $tokens->getPrevMeaningfulToken($prevIndex);
if ($questionIndex !== null && $tokens[$questionIndex]->equals('?')) {
return $questionIndex;
}
}

return null;
}

private function ensureLineBreakBeforeOperator(Tokens $tokens, int $operatorIndex): void
{
$prevMeaningfulIndex = $tokens->getPrevMeaningfulToken($operatorIndex);
if ($prevMeaningfulIndex === null) {
return;
}

// If the preceding expression is a multi-line block, don't add another line break
$blockType = $this->getBlockType($tokens[$prevMeaningfulIndex]);
if ($blockType !== null) {
$openIndex = $tokens->findBlockStart($blockType, $prevMeaningfulIndex);
if ($this->hasNewlineBetween($tokens, $openIndex, $prevMeaningfulIndex)) {
return;
}
}

$statementStart = $this->findStatementStart($tokens, $operatorIndex);
$expressionAlreadyMultiline = $this->hasNewlineBetween($tokens, $statementStart, $prevMeaningfulIndex);

if ($expressionAlreadyMultiline) {
// Use the existing indentation level from the multiline expression
$indentation = $this->getIndentAt($tokens, $operatorIndex);
} else {
// Add one indent level from the statement start
$indentation = $this->getIndentAt($tokens, $statementStart) . $this->whitespacesConfig->getIndent();
}

$newWhitespace = $this->whitespacesConfig->getLineEnding() . $indentation;

$whitespaceIndex = $operatorIndex - 1;
if ($tokens[$whitespaceIndex]->isWhitespace()) {
if ($tokens[$whitespaceIndex]->getContent() === $newWhitespace) {
return;
}

$tokens[$whitespaceIndex] = new Token([T_WHITESPACE, $newWhitespace]);
} else {
$tokens->insertAt($operatorIndex, new Token([T_WHITESPACE, $newWhitespace]));
}
}

private function hasNewlineBetween(Tokens $tokens, int $startIndex, int $endIndex): bool
{
for ($i = $startIndex + 1; $i < $endIndex; ++$i) {
$token = $tokens[$i];
if ($token->isWhitespace() && str_contains($token->getContent(), "\n")) {
return true;
}
}

return false;
}

/** Get the block type for closing braces, or null if not a closing brace. */
private function getBlockType(Token $token): ?int
{
if ($token->equals(')')) {
return Tokens::BLOCK_TYPE_PARENTHESIS_BRACE;
}

if ($token->equals('}')) {
return Tokens::BLOCK_TYPE_CURLY_BRACE;
}

return null;
}

// TODO: Replace with IndentationTrait once we require php-cs-fixer ^3.87.0
private function getIndentAt(Tokens $tokens, int $index): string
{
for ($i = $index - 1; $i >= 0; --$i) {
$token = $tokens[$i];
$content = $token->getContent();

if (Preg::match('/\R(\h*)$/', $content, $matches)) {
return $matches[1];
}
}

return '';
}

private function findStatementStart(Tokens $tokens, int $index): int
{
$nestingLevel = 0;

for ($i = $index - 1; $i >= 0; --$i) {
$token = $tokens[$i];

// Track nesting to ignore commas/arrows inside parentheses, brackets, or braces
// CT::T_ARRAY_SQUARE_BRACE_* are custom tokens for array literal brackets
if ($token->equalsAny([')', ']']) || $token->isGivenKind(CT::T_ARRAY_SQUARE_BRACE_CLOSE)) {
++$nestingLevel;
} elseif ($token->equalsAny(['(', '[']) || $token->isGivenKind(CT::T_ARRAY_SQUARE_BRACE_OPEN)) {
--$nestingLevel;
} elseif ($token->equals('}')) {
if ($nestingLevel === 0) {
return $tokens->getNextMeaningfulToken($i) ?? $index;
}
++$nestingLevel;
} elseif ($token->equals('{')) {
return $tokens->getNextMeaningfulToken($i) ?? $index;
} elseif ($nestingLevel === 0) {
if ($token->equalsAny([';', ',']) || $token->isGivenKind([T_OPEN_TAG, T_DOUBLE_ARROW])) {
return $tokens->getNextMeaningfulToken($i) ?? $index;
}
}
}

return 0;
}
}
Loading