Skip to content

Commit a8be56a

Browse files
authored
Merge pull request #294 from PHPCSStandards/universal/new-concatposition-sniff
✨ New `Universal.Operators.ConcatPosition` sniff
2 parents 9ca16b2 + a36f204 commit a8be56a

File tree

5 files changed

+540
-0
lines changed

5 files changed

+540
-0
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?xml version="1.0"?>
2+
<documentation xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:noNamespaceSchemaLocation="https://phpcsstandards.github.io/PHPCSDevTools/phpcsdocs.xsd"
4+
title="Concatenation position"
5+
>
6+
<standard>
7+
<![CDATA[
8+
Enforces that the concatenation operator for multi-line concatenations is in a preferred position, either always at the start of the next line or always at the end of the previous line.
9+
10+
The preferred position is configurable and defaults to "start" for _start of the next line_.
11+
12+
Note: mid-line concatenation is still allowed and will not be flagged by this sniff.
13+
]]>
14+
</standard>
15+
<code_comparison>
16+
<code title="Valid: multi-line concatenation with the concatenation operator at the start of each line.">
17+
<![CDATA[
18+
$var = 'text' . $a
19+
<em>.</em> $b . 'text'
20+
<em>.</em> $c;
21+
]]>
22+
</code>
23+
<code title="Invalid: multi-line concatenation with the concatenation operator not consistently at the start of each line.">
24+
<![CDATA[
25+
$var = 'text' . $a <em>.</em>
26+
$b . 'text'
27+
<em>.</em> $c;
28+
]]>
29+
</code>
30+
</code_comparison>
31+
</documentation>
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
<?php
2+
/**
3+
* PHPCSExtra, a collection of sniffs and standards for use with PHP_CodeSniffer.
4+
*
5+
* @package PHPCSExtra
6+
* @copyright 2023 PHPCSExtra Contributors
7+
* @license https://opensource.org/licenses/LGPL-3.0 LGPL3
8+
* @link https://github.com/PHPCSStandards/PHPCSExtra
9+
*/
10+
11+
namespace PHPCSExtra\Universal\Sniffs\Operators;
12+
13+
use PHP_CodeSniffer\Files\File;
14+
use PHP_CodeSniffer\Sniffs\Sniff;
15+
use PHP_CodeSniffer\Util\Tokens;
16+
17+
/**
18+
* Enforces that the concatenation operator in multi-line concatenations is in a preferred position,
19+
* either always at the start of the next line or always at the end of the previous line.
20+
*
21+
* Note: this sniff has no opinion on spacing before/after the concatenation operator.
22+
* It will normalize based on the "one space before/after" PSR-12 industry standard.
23+
* If different spacing is preferred, use the `Squiz.Strings.ConcatenationSpacing` to enforce/correct that.
24+
*
25+
* @since 1.2.0
26+
*/
27+
final class ConcatPositionSniff implements Sniff
28+
{
29+
30+
/**
31+
* The phrase to use for the metric recorded by this sniff.
32+
*
33+
* @since 1.2.0
34+
*
35+
* @var string
36+
*/
37+
const METRIC_NAME = 'Multi-line concatenation operator position';
38+
39+
/**
40+
* Position indication: start of next line.
41+
*
42+
* @since 1.2.0
43+
*
44+
* @var string
45+
*/
46+
const POSITION_START = 'start';
47+
48+
/**
49+
* Position indication: end of previous line.
50+
*
51+
* @since 1.2.0
52+
*
53+
* @var string
54+
*/
55+
const POSITION_END = 'end';
56+
57+
/**
58+
* Position indication: neither start of next line nor end of previous line.
59+
*
60+
* @since 1.2.0
61+
*
62+
* @var string
63+
*/
64+
const POSITION_STANDALONE = 'stand-alone';
65+
66+
/**
67+
* Preferred position for the concatenation operator.
68+
*
69+
* Valid values are: 'start' and 'end'.
70+
* Defaults to 'start'.
71+
*
72+
* @since 1.2.0
73+
*
74+
* @var string
75+
*/
76+
public $allowOnly = self::POSITION_START;
77+
78+
/**
79+
* Returns an array of tokens this test wants to listen for.
80+
*
81+
* @since 1.2.0
82+
*
83+
* @return array<int|string>
84+
*/
85+
public function register()
86+
{
87+
return [\T_STRING_CONCAT];
88+
}
89+
90+
/**
91+
* Processes this test, when one of its tokens is encountered.
92+
*
93+
* @since 1.2.0
94+
*
95+
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
96+
* @param int $stackPtr The position of the current token
97+
* in the stack passed in $tokens.
98+
*
99+
* @return int|void Integer stack pointer to skip forward or void to continue
100+
* normal file processing.
101+
*/
102+
public function process(File $phpcsFile, $stackPtr)
103+
{
104+
/*
105+
* Validate the setting.
106+
*/
107+
if ($this->allowOnly !== self::POSITION_END) {
108+
// Use the default.
109+
$this->allowOnly = self::POSITION_START;
110+
}
111+
112+
$prevNonEmpty = $phpcsFile->findPrevious(Tokens::$emptyTokens, ($stackPtr - 1), null, true);
113+
$nextNonEmpty = $phpcsFile->findNext(Tokens::$emptyTokens, ($stackPtr + 1), null, true);
114+
115+
if ($nextNonEmpty === false) {
116+
// Parse error/live coding.
117+
return;
118+
}
119+
120+
$tokens = $phpcsFile->getTokens();
121+
if ($tokens[$prevNonEmpty]['line'] === $tokens[$nextNonEmpty]['line']) {
122+
// Not multi-line concatenation. Not our target.
123+
return;
124+
}
125+
126+
$position = self::POSITION_STANDALONE;
127+
if ($tokens[$prevNonEmpty]['line'] === $tokens[$stackPtr]['line']) {
128+
$position = self::POSITION_END;
129+
} elseif ($tokens[$nextNonEmpty]['line'] === $tokens[$stackPtr]['line']) {
130+
$position = self::POSITION_START;
131+
}
132+
133+
// Record metric.
134+
$phpcsFile->recordMetric($stackPtr, self::METRIC_NAME, $position);
135+
136+
if ($this->allowOnly === $position) {
137+
// All okay.
138+
return;
139+
}
140+
141+
$fix = $phpcsFile->addFixableError(
142+
'The concatenation operator for multi-line concatenations should always be at the %s of a line.',
143+
$stackPtr,
144+
'Incorrect',
145+
[$this->allowOnly]
146+
);
147+
148+
if ($fix === true) {
149+
if ($this->allowOnly === self::POSITION_END) {
150+
$phpcsFile->fixer->beginChangeset();
151+
152+
// Move the concat operator.
153+
$phpcsFile->fixer->replaceToken($stackPtr, '');
154+
$phpcsFile->fixer->addContent($prevNonEmpty, ' .');
155+
156+
if ($position === self::POSITION_START
157+
&& $tokens[($stackPtr + 1)]['code'] === \T_WHITESPACE
158+
) {
159+
// Remove trailing space.
160+
$phpcsFile->fixer->replaceToken(($stackPtr + 1), '');
161+
} elseif ($position === self::POSITION_STANDALONE) {
162+
// Remove potential indentation space.
163+
if ($tokens[($stackPtr - 1)]['code'] === \T_WHITESPACE) {
164+
$phpcsFile->fixer->replaceToken(($stackPtr - 1), '');
165+
}
166+
167+
// Remove new line.
168+
if ($tokens[($stackPtr + 1)]['code'] === \T_WHITESPACE) {
169+
$phpcsFile->fixer->replaceToken(($stackPtr + 1), '');
170+
}
171+
}
172+
173+
$phpcsFile->fixer->endChangeset();
174+
return;
175+
}
176+
177+
// Fixer for allowOnly === self::POSITION_START.
178+
$phpcsFile->fixer->beginChangeset();
179+
180+
// Move the concat operator.
181+
$phpcsFile->fixer->replaceToken($stackPtr, '');
182+
$phpcsFile->fixer->addContentBefore($nextNonEmpty, '. ');
183+
184+
if ($position === self::POSITION_END
185+
&& $tokens[($stackPtr - 1)]['code'] === \T_WHITESPACE
186+
) {
187+
// Remove trailing space.
188+
$phpcsFile->fixer->replaceToken(($stackPtr - 1), '');
189+
} elseif ($position === self::POSITION_STANDALONE) {
190+
// Remove potential indentation space.
191+
if ($tokens[($stackPtr - 1)]['code'] === \T_WHITESPACE) {
192+
$phpcsFile->fixer->replaceToken(($stackPtr - 1), '');
193+
}
194+
195+
// Remove new line.
196+
if ($tokens[($stackPtr + 1)]['code'] === \T_WHITESPACE) {
197+
$phpcsFile->fixer->replaceToken(($stackPtr + 1), '');
198+
}
199+
}
200+
201+
$phpcsFile->fixer->endChangeset();
202+
}
203+
}
204+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
/*
4+
* Not our targets.
5+
*/
6+
7+
// Same line.
8+
$a = 'text' . 'text' . $b;
9+
10+
/*
11+
* Always prefer start of line (default).
12+
*
13+
* phpcs:set Universal.Operators.ConcatPosition allowOnly start
14+
*/
15+
16+
// OK.
17+
$a = 'start'
18+
. 'start with space'
19+
. $b;
20+
21+
$a = 'start'
22+
.'start no space'
23+
./*comment*/$b;
24+
25+
$a = 'start'
26+
// Comment on own line.
27+
. $b;
28+
29+
// Not OK.
30+
$a = 'mixed' .
31+
32+
33+
'mixed'
34+
. $b;
35+
36+
$a = 'end with space' .
37+
'end with space and comment' . // Comment.
38+
$b;
39+
40+
$a = 'end with space' .
41+
/* comment */ 'end with comment before';
42+
43+
$a = 'end no space'.
44+
'end no space and comment'/*comment*/.//Comment
45+
$b;
46+
47+
$a = 'end with space' .
48+
// Comment on own line.
49+
'end with space with comment above' .
50+
// Comment on own line.
51+
$b;
52+
53+
$a = 'stand-alone'
54+
.
55+
'stand-alone';
56+
57+
/*
58+
* Always prefer end of line (via setting).
59+
*
60+
* phpcs:set Universal.Operators.ConcatPosition allowOnly end
61+
*/
62+
63+
// OK.
64+
$a = 'end with space' .
65+
'end with space and comment' . // Comment.
66+
$b;
67+
68+
$a = 'end no space'.
69+
'end no space and comment'/*comment*/.//Comment
70+
$b;
71+
72+
$a = 'end no space'.
73+
// Comment on own line.
74+
$b;
75+
76+
// Not OK.
77+
$a = 'mixed' .
78+
'mixed'
79+
80+
81+
. $b;
82+
83+
$a = 'start'
84+
. 'start with space'
85+
. $b;
86+
87+
$a = 'start'
88+
.'start no space'
89+
./*comment*/$b;
90+
91+
$a = 'start'
92+
// Comment on own line.
93+
.'start with comment above'
94+
// Comment on own line.
95+
.$b;
96+
97+
$a = 'stand-alone'
98+
.
99+
'stand-alone';
100+
101+
/*
102+
* Invalid setting will use the default (start of line).
103+
*
104+
* phpcs:set Universal.Operators.ConcatPosition allowOnly mixed
105+
*/
106+
107+
// OK.
108+
$a = 'start'
109+
. 'start with space'
110+
. $b;
111+
112+
// Not OK.
113+
$a = 'mixed' .
114+
'mixed'
115+
. $b;
116+
117+
// Reset to the default value.
118+
// phpcs:set Universal.Operators.ConcatPosition allowOnly start
119+
120+
// Intentional parse error/live coding.
121+
// This needs to be the last test in the file.
122+
$a = 'text' .

0 commit comments

Comments
 (0)