Skip to content

Commit 8423411

Browse files
committed
PSR2R.WhiteSpace.ConsistentIndent
1 parent 24a7df4 commit 8423411

File tree

8 files changed

+638
-2
lines changed

8 files changed

+638
-2
lines changed
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
<?php
2+
3+
/**
4+
* MIT License
5+
* For full license information, please view the LICENSE file that was distributed with this source code.
6+
*/
7+
8+
namespace PSR2R\Sniffs\WhiteSpace;
9+
10+
use PHP_CodeSniffer\Files\File;
11+
use PHP_CodeSniffer\Sniffs\Sniff;
12+
13+
/**
14+
* Detects orphaned indentation - lines that are over-indented without a scope change.
15+
* This catches cases where code has extra indentation (e.g., leftover from a deleted block).
16+
*
17+
* @author Mark Scherer
18+
* @license MIT
19+
*/
20+
class ConsistentIndentSniff implements Sniff {
21+
22+
/**
23+
* @inheritDoc
24+
*/
25+
public function register(): array {
26+
return [T_WHITESPACE];
27+
}
28+
29+
/**
30+
* @inheritDoc
31+
*/
32+
public function process(File $phpcsFile, int $stackPtr): void {
33+
$tokens = $phpcsFile->getTokens();
34+
35+
// Only check whitespace at the start of lines (indentation)
36+
if ($stackPtr > 0 && $tokens[$stackPtr - 1]['line'] === $tokens[$stackPtr]['line']) {
37+
return;
38+
}
39+
40+
$line = $tokens[$stackPtr]['line'];
41+
42+
// Skip first line and lines in docblocks
43+
if ($line === 1 || !empty($tokens[$stackPtr]['nested_attributes'])) {
44+
return;
45+
}
46+
47+
// Get the current indentation level
48+
$currentIndent = $this->getIndentLevel($tokens[$stackPtr]);
49+
if ($currentIndent === 0) {
50+
return;
51+
}
52+
53+
// Find the next non-whitespace token on this line
54+
$nextToken = $phpcsFile->findNext(T_WHITESPACE, $stackPtr + 1, null, true);
55+
if ($nextToken === false || $tokens[$nextToken]['line'] !== $line) {
56+
// Empty line or no content
57+
return;
58+
}
59+
60+
// Skip closing braces - they're allowed to be dedented
61+
if ($tokens[$nextToken]['code'] === T_CLOSE_CURLY_BRACKET) {
62+
return;
63+
}
64+
65+
// Skip control flow keywords that often have blank lines before them
66+
$controlFlowTokens = [T_BREAK, T_CONTINUE, T_RETURN, T_THROW, T_CASE, T_DEFAULT];
67+
if (in_array($tokens[$nextToken]['code'], $controlFlowTokens, true)) {
68+
return;
69+
}
70+
71+
// Get the expected indentation based on scope
72+
$expectedIndent = $this->getExpectedIndent($phpcsFile, $nextToken, $tokens);
73+
74+
// Allow continuation lines (lines can be indented more for alignment)
75+
// But check if the previous line suggests this should NOT be indented more
76+
if ($currentIndent > $expectedIndent) {
77+
$prevLine = $this->findPreviousContentLine($phpcsFile, $stackPtr, $tokens);
78+
if ($prevLine !== null && $this->isOrphanedIndent($phpcsFile, $prevLine, $currentIndent, $expectedIndent, $tokens)) {
79+
$error = 'Line indented incorrectly; expected %d tabs, found %d';
80+
$data = [$expectedIndent, $currentIndent];
81+
$fix = $phpcsFile->addFixableError($error, $stackPtr, 'Incorrect', $data);
82+
83+
if ($fix) {
84+
$phpcsFile->fixer->beginChangeset();
85+
$phpcsFile->fixer->replaceToken($stackPtr, str_repeat("\t", $expectedIndent));
86+
$phpcsFile->fixer->endChangeset();
87+
}
88+
}
89+
}
90+
}
91+
92+
/**
93+
* Get the indentation level (number of tabs) for a whitespace token.
94+
*
95+
* @param array $token
96+
*
97+
* @return int
98+
*/
99+
protected function getIndentLevel(array $token): int {
100+
$content = $token['orig_content'] ?? $token['content'];
101+
102+
return substr_count($content, "\t");
103+
}
104+
105+
/**
106+
* Get the expected indentation level based on scope.
107+
*
108+
* @param \PHP_CodeSniffer\Files\File $phpcsFile
109+
* @param int $stackPtr
110+
* @param array $tokens
111+
*
112+
* @return int
113+
*/
114+
protected function getExpectedIndent(File $phpcsFile, int $stackPtr, array $tokens): int {
115+
$conditions = $tokens[$stackPtr]['conditions'];
116+
117+
return count($conditions);
118+
}
119+
120+
/**
121+
* Find the previous line that has actual content (not blank, not comment-only).
122+
*
123+
* @param \PHP_CodeSniffer\Files\File $phpcsFile
124+
* @param int $stackPtr
125+
* @param array $tokens
126+
*
127+
* @return int|null
128+
*/
129+
protected function findPreviousContentLine(File $phpcsFile, int $stackPtr, array $tokens): ?int {
130+
$currentLine = $tokens[$stackPtr]['line'];
131+
132+
for ($i = $stackPtr - 1; $i >= 0; $i--) {
133+
if ($tokens[$i]['line'] >= $currentLine) {
134+
continue;
135+
}
136+
137+
// Skip whitespace and comments
138+
if ($tokens[$i]['code'] === T_WHITESPACE || $tokens[$i]['code'] === T_COMMENT) {
139+
continue;
140+
}
141+
142+
return $i;
143+
}
144+
145+
return null;
146+
}
147+
148+
/**
149+
* Check if this looks like orphaned indentation (not a valid continuation).
150+
*
151+
* @param \PHP_CodeSniffer\Files\File $phpcsFile
152+
* @param int $prevToken Previous content token
153+
* @param int $currentIndent
154+
* @param int $expectedIndent
155+
* @param array $tokens
156+
*
157+
* @return bool
158+
*/
159+
protected function isOrphanedIndent(File $phpcsFile, int $prevToken, int $currentIndent, int $expectedIndent, array $tokens): bool {
160+
// Pattern 1: Previous line was a closing brace
161+
// This catches: } \n orphaned code
162+
if ($tokens[$prevToken]['code'] === T_CLOSE_CURLY_BRACKET) {
163+
return true;
164+
}
165+
166+
// Pattern 2: Previous line ended with }; (closure, array, etc.)
167+
// This catches: }; \n orphaned code
168+
if ($tokens[$prevToken]['code'] === T_SEMICOLON) {
169+
// Check if the token before semicolon is a closing brace
170+
$beforeSemicolon = $phpcsFile->findPrevious(T_WHITESPACE, $prevToken - 1, null, true);
171+
if ($beforeSemicolon !== false && $tokens[$beforeSemicolon]['code'] === T_CLOSE_CURLY_BRACKET) {
172+
return true;
173+
}
174+
175+
// Pattern 3: Previous line is at the same over-indented level
176+
// This catches consecutive orphaned lines: } \n line1; \n line2;
177+
// Find the start of the previous line to check its indentation
178+
$prevLineStart = $prevToken;
179+
while ($prevLineStart > 0 && $tokens[$prevLineStart - 1]['line'] === $tokens[$prevToken]['line']) {
180+
$prevLineStart--;
181+
}
182+
183+
// Check if previous line started with whitespace
184+
if ($tokens[$prevLineStart]['code'] === T_WHITESPACE) {
185+
$prevIndent = $this->getIndentLevel($tokens[$prevLineStart]);
186+
// If previous line was also over-indented at the same level, this is likely orphaned too
187+
if ($prevIndent === $currentIndent && $prevIndent > $expectedIndent) {
188+
return true;
189+
}
190+
}
191+
}
192+
193+
return false;
194+
}
195+
196+
}

docs/sniffs.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# PSR2R Code Sniffer
22

3-
The PSR2R standard contains 209 sniffs
3+
The PSR2R standard contains 210 sniffs
44

55
Generic (24 sniffs)
66
-------------------
@@ -108,7 +108,7 @@ PSR2 (6 sniffs)
108108
- PSR2.Namespaces.NamespaceDeclaration
109109
- PSR2.Namespaces.UseDeclaration
110110

111-
PSR2R (43 sniffs)
111+
PSR2R (44 sniffs)
112112
-----------------
113113
- PSR2R.Classes.BraceOnSameLine
114114
- PSR2R.Classes.InterfaceName
@@ -144,6 +144,7 @@ PSR2R (43 sniffs)
144144
- PSR2R.PHP.PreferStaticOverSelf
145145
- PSR2R.WhiteSpace.ArrayDeclarationSpacing
146146
- PSR2R.WhiteSpace.ArraySpacing
147+
- PSR2R.WhiteSpace.ConsistentIndent
147148
- PSR2R.WhiteSpace.DocBlockAlignment
148149
- PSR2R.WhiteSpace.EmptyEnclosingLine
149150
- PSR2R.WhiteSpace.EmptyLines
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
/**
4+
* MIT License
5+
* For full license information, please view the LICENSE file that was distributed with this source code.
6+
*/
7+
8+
namespace PSR2R\Test\PSR2R\Sniffs\WhiteSpace;
9+
10+
use PSR2R\Sniffs\WhiteSpace\ConsistentIndentSniff;
11+
use PSR2R\Test\TestCase;
12+
13+
/**
14+
* Tests that valid code patterns are NOT flagged as errors (no false positives).
15+
*/
16+
class ConsistentIndentNoErrorsSniffTest extends TestCase {
17+
18+
/**
19+
* @return void
20+
*/
21+
public function testConsistentIndentNoErrors(): void {
22+
$this->assertSnifferFindsErrors(new ConsistentIndentSniff(), 0);
23+
}
24+
25+
/**
26+
* @return void
27+
*/
28+
public function testConsistentIndentNoErrorsFixer(): void {
29+
$this->assertSnifferCanFixErrors(new ConsistentIndentSniff(), 0);
30+
}
31+
32+
/**
33+
* @return string
34+
*/
35+
protected function testFilePath(): string {
36+
return implode(DIRECTORY_SEPARATOR, [
37+
__DIR__,
38+
'..', '..', '..', '_data',
39+
]) . DIRECTORY_SEPARATOR;
40+
}
41+
42+
/**
43+
* @param \PHP_CodeSniffer\Sniffs\Sniff $sniffer
44+
*
45+
* @return string
46+
*/
47+
protected function getDummyFileBefore($sniffer): string {
48+
$className = 'ConsistentIndentNoErrors';
49+
50+
$file = $this->testFilePath() . $className . DIRECTORY_SEPARATOR . static::FILE_BEFORE;
51+
if (!file_exists($file)) {
52+
$this->fail(sprintf('File not found: %s.', $file));
53+
}
54+
55+
return $file;
56+
}
57+
58+
/**
59+
* @param \PHP_CodeSniffer\Sniffs\Sniff $sniffer
60+
*
61+
* @return string
62+
*/
63+
protected function getDummyFileAfter($sniffer): string {
64+
$className = 'ConsistentIndentNoErrors';
65+
66+
$file = $this->testFilePath() . $className . DIRECTORY_SEPARATOR . static::FILE_AFTER;
67+
if (!file_exists($file)) {
68+
$this->fail(sprintf('File not found: %s.', $file));
69+
}
70+
71+
return $file;
72+
}
73+
74+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
/**
4+
* MIT License
5+
* For full license information, please view the LICENSE file that was distributed with this source code.
6+
*/
7+
8+
namespace PSR2R\Test\PSR2R\Sniffs\WhiteSpace;
9+
10+
use PSR2R\Sniffs\WhiteSpace\ConsistentIndentSniff;
11+
use PSR2R\Test\TestCase;
12+
13+
class ConsistentIndentSniffTest extends TestCase {
14+
15+
/**
16+
* @return void
17+
*/
18+
public function testConsistentIndentSniffer(): void {
19+
$this->assertSnifferFindsErrors(new ConsistentIndentSniff(), 3);
20+
}
21+
22+
/**
23+
* @return void
24+
*/
25+
public function testConsistentIndentFixer(): void {
26+
$this->assertSnifferCanFixErrors(new ConsistentIndentSniff());
27+
}
28+
29+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
namespace App\Test;
4+
5+
class ConsistentIndentTest {
6+
7+
public function testMethod() {
8+
if (!isset($this->params['url']['filter'])) {
9+
$filter1 = 'true';
10+
} else {
11+
$filter1 = $this->params['url']['filter'];
12+
}
13+
14+
$orphaned1 = $this->method1(); // WRONG - orphaned after }
15+
$orphaned2 = $this->method2(); // WRONG - consecutive orphaned
16+
if (!$result) {
17+
$correct = true;
18+
}
19+
20+
$callback = function () {
21+
return true;
22+
};
23+
$orphaned3 = 'test'; // WRONG - orphaned after };
24+
25+
// Valid continuation - should NOT be flagged
26+
$continuation = 'some string ' . $var
27+
. ' continuation';
28+
}
29+
30+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
namespace App\Test;
4+
5+
class ConsistentIndentTest {
6+
7+
public function testMethod() {
8+
if (!isset($this->params['url']['filter'])) {
9+
$filter1 = 'true';
10+
} else {
11+
$filter1 = $this->params['url']['filter'];
12+
}
13+
14+
$orphaned1 = $this->method1(); // WRONG - orphaned after }
15+
$orphaned2 = $this->method2(); // WRONG - consecutive orphaned
16+
if (!$result) {
17+
$correct = true;
18+
}
19+
20+
$callback = function () {
21+
return true;
22+
};
23+
$orphaned3 = 'test'; // WRONG - orphaned after };
24+
25+
// Valid continuation - should NOT be flagged
26+
$continuation = 'some string ' . $var
27+
. ' continuation';
28+
}
29+
30+
}

0 commit comments

Comments
 (0)