Skip to content

Commit 3863541

Browse files
feat: add class and file length sniffs
1 parent d415fb3 commit 3863541

10 files changed

Lines changed: 293 additions & 1 deletion

File tree

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ The package is designed for projects that keep domain and application code frame
3333

3434
The SymPress Coding Standards package requires:
3535

36-
- PHP 8.4 or newer to run the standard
36+
- PHP 8.5 or newer to run the standard
3737
- Composer 2
3838
- PHP_CodeSniffer 3.13.5 or newer on the 3.x line
3939
- PHPCompatibility 9.3 or 10
@@ -256,6 +256,22 @@ Example: change the maximum function length:
256256
</rule>
257257
```
258258

259+
Example: change class and file length guardrails:
260+
261+
```xml
262+
<rule ref="SymPress.Classes.ClassLength">
263+
<properties>
264+
<property name="maxLength" type="integer" value="700" />
265+
</properties>
266+
</rule>
267+
268+
<rule ref="SymPress.Files.FileLength">
269+
<properties>
270+
<property name="maxLength" type="integer" value="1200" />
271+
</properties>
272+
</rule>
273+
```
274+
259275
Example: configure PSR-4 checks:
260276

261277
```xml

SymPress-Enterprise-LTS/ruleset.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
<rule ref="SymPress.Classes.AccessorNaming">
1616
<type>warning</type>
1717
</rule>
18+
<rule ref="SymPress.Classes.ClassLength">
19+
<type>warning</type>
20+
</rule>
1821
<rule ref="SymPress.Classes.PropertyLimit">
1922
<type>warning</type>
2023
</rule>
@@ -24,6 +27,9 @@
2427
<rule ref="SymPress.Functions.FunctionLength">
2528
<type>warning</type>
2629
</rule>
30+
<rule ref="SymPress.Files.FileLength">
31+
<type>warning</type>
32+
</rule>
2733
<rule ref="SymPress.Variables.RedundantAssignment">
2834
<type>warning</type>
2935
</rule>

SymPress-Enterprise-Modern/ruleset.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
<rule ref="SymPress.Classes.AccessorNaming">
1616
<type>warning</type>
1717
</rule>
18+
<rule ref="SymPress.Classes.ClassLength">
19+
<type>warning</type>
20+
</rule>
1821
<rule ref="SymPress.Classes.PropertyLimit">
1922
<type>warning</type>
2023
</rule>
@@ -24,6 +27,9 @@
2427
<rule ref="SymPress.Functions.FunctionLength">
2528
<type>warning</type>
2629
</rule>
30+
<rule ref="SymPress.Files.FileLength">
31+
<type>warning</type>
32+
</rule>
2733
<rule ref="SymPress.Variables.RedundantAssignment">
2834
<type>warning</type>
2935
</rule>

SymPress-Pure/ruleset.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
<rule ref="SymPress.Classes.DeprecatedSerializableInterface" />
1717
<rule ref="SymPress.Classes.DeprecatedSerializeMagicMethod" />
1818
<rule ref="SymPress.Classes.AccessorNaming" />
19+
<rule ref="SymPress.Classes.ClassLength" />
1920
<rule ref="SymPress.Classes.PropertyLimit" />
2021
<rule ref="SymPress.Complexity.NestingLevel">
2122
<properties>
@@ -30,6 +31,7 @@
3031
<property name="lineLimit" type="integer" value="120" />
3132
</properties>
3233
</rule>
34+
<rule ref="SymPress.Files.FileLength" />
3335
<rule ref="SymPress.Formatting.AlphabeticalUseStatements" />
3436
<rule ref="SymPress.Formatting.UnnecessaryNamespaceUsage" />
3537
<rule ref="SymPress.Functions.ArgumentTypeDeclaration" />
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SymPressCS\SymPress\Sniffs\Classes;
6+
7+
use PHP_CodeSniffer\Files\File;
8+
use PHP_CodeSniffer\Sniffs\Sniff;
9+
use SymPressCS\SymPress\Helpers\Names;
10+
11+
final class ClassLengthSniff implements Sniff
12+
{
13+
public bool $ignoreBlankLines = true;
14+
public bool $ignoreComments = true;
15+
public bool $ignoreDocBlocks = true;
16+
public int $maxLength = 500;
17+
18+
/** @return list<int|string> */
19+
public function register(): array
20+
{
21+
return [
22+
T_CLASS,
23+
T_ENUM,
24+
T_INTERFACE,
25+
T_TRAIT,
26+
];
27+
}
28+
29+
/**
30+
* @param File $phpcsFile
31+
* @param int $stackPtr
32+
*/
33+
public function process(File $phpcsFile, $stackPtr): void
34+
{
35+
$length = $this->structureLinesCount($phpcsFile, $stackPtr);
36+
if ($length <= $this->maxLength) {
37+
return;
38+
}
39+
40+
$ignored = $this->ignoredLines();
41+
$ignoring = $ignored ? sprintf(' (ignoring: %s)', implode(', ', $ignored)) : '';
42+
$tokenTypeName = Names::tokenTypeName($phpcsFile, $stackPtr);
43+
44+
$phpcsFile->addWarning(
45+
'%s length (%d) exceeds allowed maximum of %d lines%s',
46+
$stackPtr,
47+
'TooLong',
48+
[ucfirst($tokenTypeName), $length, $this->maxLength, $ignoring],
49+
);
50+
}
51+
52+
private function structureLinesCount(File $file, int $position): int
53+
{
54+
/** @var array<int, array<string, mixed>> $tokens */
55+
$tokens = $file->getTokens();
56+
$token = $tokens[$position] ?? [];
57+
58+
if (
59+
!array_key_exists('scope_opener', $token)
60+
|| !array_key_exists('scope_closer', $token)
61+
) {
62+
return 0;
63+
}
64+
65+
$start = (int) $token['scope_opener'];
66+
$end = (int) $token['scope_closer'];
67+
$length = (int) $tokens[$end]['line'] - (int) $tokens[$start]['line'];
68+
69+
if ($length <= $this->maxLength) {
70+
return $length;
71+
}
72+
73+
return $length - $this->collectLinesToExclude($start, $end, $tokens);
74+
}
75+
76+
/**
77+
* @param int $start
78+
* @param int $end
79+
* @param array<int, array<string, mixed>> $tokens
80+
*/
81+
private function collectLinesToExclude(int $start, int $end, array $tokens): int
82+
{
83+
$docBlocks = [];
84+
$linesData = [];
85+
$skipLines = [$tokens[$start + 1]['line'], $tokens[$end]['line']];
86+
87+
for ($i = $start + 1; $i < $end - 1; $i++) {
88+
if (in_array($tokens[$i]['line'], $skipLines, true)) {
89+
continue;
90+
}
91+
92+
$docBlocks = $this->docBlocksData($tokens, $i, $docBlocks);
93+
$linesData = $this->ignoredLinesData($tokens[$i], $linesData);
94+
}
95+
96+
$empty = array_filter(array_column($linesData, 'empty'));
97+
$onlyComment = array_filter(array_column($linesData, 'only-comment'));
98+
$toExcludeCount = (int) array_sum($docBlocks);
99+
100+
if ($this->ignoreBlankLines) {
101+
$toExcludeCount += count($empty);
102+
}
103+
if ($this->ignoreComments) {
104+
$toExcludeCount += count($onlyComment) - count($empty);
105+
}
106+
107+
return $toExcludeCount;
108+
}
109+
110+
/**
111+
* @param array<string, mixed> $token
112+
* @param array<int, array{empty:bool, only-comment:bool}> $lines
113+
* @return array<int, array{empty:bool, only-comment:bool}>
114+
*/
115+
private function ignoredLinesData(array $token, array $lines): array
116+
{
117+
$line = (int) $token['line'];
118+
if (!array_key_exists($line, $lines)) {
119+
$lines[$line] = ['empty' => true, 'only-comment' => true];
120+
}
121+
122+
if (!in_array($token['code'], [T_COMMENT, T_WHITESPACE], true)) {
123+
$lines[$line]['only-comment'] = false;
124+
}
125+
126+
if ($token['code'] !== T_WHITESPACE) {
127+
$lines[$line]['empty'] = false;
128+
}
129+
130+
return $lines;
131+
}
132+
133+
/**
134+
* @param array<int, array<string, mixed>> $tokens
135+
* @param int $position
136+
* @param list<int> $docBlocks
137+
* @return list<int>
138+
*/
139+
private function docBlocksData(array $tokens, int $position, array $docBlocks): array
140+
{
141+
if (
142+
!$this->ignoreDocBlocks
143+
|| $tokens[$position]['code'] !== T_DOC_COMMENT_OPEN_TAG
144+
) {
145+
return $docBlocks;
146+
}
147+
148+
$closer = $tokens[$position]['comment_closer'] ?? null;
149+
150+
$docBlocks[] = is_numeric($closer)
151+
? 1 + (int) $tokens[(int) $closer]['line'] - (int) $tokens[$position]['line']
152+
: 1;
153+
154+
return $docBlocks;
155+
}
156+
157+
/** @return list<string> */
158+
private function ignoredLines(): array
159+
{
160+
$ignored = [];
161+
$flags = [
162+
'ignoreBlankLines' => 'blank lines',
163+
'ignoreComments' => 'inline comments',
164+
'ignoreDocBlocks' => 'doc blocks',
165+
];
166+
167+
foreach ($flags as $flag => $type) {
168+
if (!filter_var($this->{$flag}, FILTER_VALIDATE_BOOLEAN)) {
169+
continue;
170+
}
171+
172+
$ignored[] = $type;
173+
}
174+
175+
return $ignored;
176+
}
177+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SymPressCS\SymPress\Sniffs\Files;
6+
7+
use PHP_CodeSniffer\Files\File;
8+
use PHP_CodeSniffer\Sniffs\Sniff;
9+
10+
final class FileLengthSniff implements Sniff
11+
{
12+
public int $maxLength = 1000;
13+
14+
/** @return list<int|string> */
15+
public function register(): array
16+
{
17+
return [
18+
T_OPEN_TAG,
19+
];
20+
}
21+
22+
/**
23+
* @param File $phpcsFile
24+
* @param int $stackPtr
25+
*/
26+
public function process(File $phpcsFile, $stackPtr): int
27+
{
28+
$length = $this->fileLength($phpcsFile);
29+
if ($length > $this->maxLength) {
30+
$phpcsFile->addWarning(
31+
'File length (%d) exceeds allowed maximum of %d lines',
32+
$stackPtr,
33+
'TooLong',
34+
[$length, $this->maxLength],
35+
);
36+
}
37+
38+
return $phpcsFile->numTokens + 1;
39+
}
40+
41+
private function fileLength(File $file): int
42+
{
43+
$contents = file_get_contents($file->getFilename());
44+
45+
if (!is_string($contents) || $contents === '') {
46+
return 0;
47+
}
48+
49+
return substr_count($contents, "\n") + (str_ends_with($contents, "\n") ? 0 : 1);
50+
}
51+
}

docs/Rules.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,15 @@ profile, not in the shared enterprise default.
4040
| `SymPress.Arrays.ArrayDoubleArrowAlignment` | Formatting | Error, fixable where safe | Normalizes multiline array alignment. |
4141
| `SymPress.Arrays.MultiLineArray` | Formatting | Error, fixable where safe | Keeps multiline arrays stable in diffs. |
4242
| `SymPress.Classes.AccessorNaming` | Architecture | Warning in adoption, strict in Next | Opinionated. Tune or demote when a domain model intentionally avoids getter/setter naming. |
43+
| `SymPress.Classes.ClassLength` | Maintainability | Warning | Signals classes that likely need responsibility review. Default threshold: 500 effective lines. |
4344
| `SymPress.Classes.DeprecatedSerializableInterface` | Risk | Error | Blocks deprecated serialization APIs. |
4445
| `SymPress.Classes.DeprecatedSerializeMagicMethod` | Risk | Error | Blocks deprecated magic serialization APIs. |
4546
| `SymPress.Classes.PropertyLimit` | Architecture | Warning in enterprise profiles | Signals large objects. Treat as design pressure, not proof of a bug. |
4647
| `SymPress.Complexity.NestingLevel` | Maintainability | Warning, then error at higher nesting | Thresholds are configurable. |
4748
| `SymPress.ControlStructures.AlternativeSyntax` | Template formatting | Warning | Applies to template syntax rules. |
4849
| `SymPress.ControlStructures.DisallowElse` | Architecture | Warning in enterprise profiles | Encourages early returns. Exclude when legacy diff churn is too high. |
4950
| `SymPress.Encoding.Utf8EncodingComment` | Formatting | Warning | Keeps source encoding comments consistent. |
51+
| `SymPress.Files.FileLength` | Maintainability | Warning | Signals oversized files. Default threshold: 1000 physical lines. |
5052
| `SymPress.Files.LineLength` | Maintainability | Warning | WordPress i18n functions are allowed in the WordPress layer. |
5153
| `SymPress.Formatting.AlphabeticalUseStatements` | Formatting | Error, fixable | Mechanical import ordering. |
5254
| `SymPress.Formatting.TrailingSemicolon` | Template formatting | Error, fixable | Template shorthand output cleanup. |

docs/Sniffs.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ The package exposes these SymPress custom sniffs:
99
- `SymPress.Classes.AccessorNaming`
1010
- `SymPress.Classes.DeprecatedSerializableInterface`
1111
- `SymPress.Classes.DeprecatedSerializeMagicMethod`
12+
- `SymPress.Classes.ClassLength`
1213
- `SymPress.Classes.PropertyLimit`
1314
- `SymPress.Complexity.NestingLevel`
1415
- `SymPress.ControlStructures.AlternativeSyntax`
1516
- `SymPress.ControlStructures.DisallowElse`
1617
- `SymPress.Encoding.Utf8EncodingComment`
18+
- `SymPress.Files.FileLength`
1719
- `SymPress.Files.LineLength`
1820
- `SymPress.Formatting.AlphabeticalUseStatements`
1921
- `SymPress.Formatting.TrailingSemicolon`

tests/fixtures/class-length.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
// @phpcsSniff SymPress.Classes.ClassLength
3+
// @phpcsSniffPropertiesStart
4+
// $maxLength = 5;
5+
// @phpcsSniffPropertiesEnd
6+
7+
class CompactClass {
8+
public function ok(): void
9+
{
10+
}
11+
}
12+
13+
// @phpcsWarningOnNextLine TooLong
14+
class LongClass {
15+
public function tooLong(): void
16+
{
17+
$first = 1;
18+
$second = 2;
19+
$third = 3;
20+
$fourth = 4;
21+
}
22+
}

tests/fixtures/file-length.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php // @phpcsWarningOnThisLine TooLong
2+
// @phpcsSniff SymPress.Files.FileLength
3+
// @phpcsSniffPropertiesStart
4+
// $maxLength = 5;
5+
// @phpcsSniffPropertiesEnd
6+
7+
$first = 1;
8+
$second = 2;

0 commit comments

Comments
 (0)