• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

PHPCSStandards / PHPCSExtra / 7143965899

08 Dec 2023 04:45PM UTC coverage: 99.817% (+0.03%) from 99.784%
7143965899

Pull #301

github

web-flow
Merge 4abbe7594 into 78b2cae1e
Pull Request #301: Release PHPCSExtra 1.2.1

3276 of 3282 relevant lines covered (99.82%)

3.68 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.8
/Universal/Sniffs/WhiteSpace/PrecisionAlignmentSniff.php
1
<?php
2
/**
3
 * PHPCSExtra, a collection of sniffs and standards for use with PHP_CodeSniffer.
4
 *
5
 * @package   PHPCSExtra
6
 * @copyright 2020 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\WhiteSpace;
12

13
use PHP_CodeSniffer\Files\File;
14
use PHP_CodeSniffer\Sniffs\Sniff;
15
use PHP_CodeSniffer\Util\Tokens;
16
use PHPCSUtils\BackCompat\Helper;
17
use PHPCSUtils\Tokens\Collections;
18

19
/**
20
 * Detects when the indentation is not a multiple of a tab-width, i.e. when precision alignment is used.
21
 *
22
 * In rare cases, spaces for precision alignment can be intentional and acceptable,
23
 * but more often than not, precision alignment is a typo.
24
 *
25
 * Notes:
26
 * - When using this sniff with tab-based standards, please ensure that the `tab-width` is set
27
 *   and either don't set the `$indent` property or set it to the tab-width.
28
 * - Precision alignment *within* text strings (multi-line text strings, heredocs, nowdocs)
29
 *   will NOT be flagged by this sniff.
30
 * - The fixer works based on "best guess" and may not always result in the desired indentation.
31
 * - This fixer will use tabs or spaces based on whether tabs were present in the original indent.
32
 *   Use the PHPCS native `Generic.WhiteSpace.DisallowTabIndent` or the
33
 *   `Generic.WhiteSpace.DisallowSpaceIndent` sniff to clean up the results if so desired.
34
 *
35
 * @since 1.0.0
36
 */
37
final class PrecisionAlignmentSniff implements Sniff
38
{
39

40
    /**
41
     * A list of tokenizers this sniff supports.
42
     *
43
     * @since 1.0.0
44
     *
45
     * @var string[]
46
     */
47
    public $supportedTokenizers = [
48
        'PHP',
49
        'JS',
50
        'CSS',
51
    ];
52

53
    /**
54
     * The indent used for the codebase.
55
     *
56
     * This property is used to determine whether something is indentation or precision alignment.
57
     * If this property is not set, the sniff will look to the `--tab-width` CLI value.
58
     * If that also isn't set, the default tab-width of 4 will be used.
59
     *
60
     * @since 1.0.0
61
     *
62
     * @var int|null
63
     */
64
    public $indent = null;
65

66
    /**
67
     * Allow for providing a list of tokens for which (preceding) precision alignment should be ignored.
68
     *
69
     * By default, precision alignment will always be flagged.
70
     *
71
     * Example usage:
72
     * ```xml
73
     * <rule ref="Universal.WhiteSpace.PrecisionAlignment">
74
     *    <properties>
75
     *        <property name="ignoreAlignmentBefore" type="array">
76
     *            <!-- Ignore precision alignment in inline HTML -->
77
     *            <element value="T_INLINE_HTML"/>
78
     *            <!-- Ignore precision alignment in multiline chained method calls. -->
79
     *            <element value="T_OBJECT_OPERATOR"/>
80
     *        </property>
81
     *    </properties>
82
     * </rule>
83
     * ```
84
     *
85
     * @since 1.0.0
86
     *
87
     * @var string[]
88
     */
89
    public $ignoreAlignmentBefore = [];
90

91
    /**
92
     * Whether or not potential trailing whitespace on otherwise blank lines should be examined or ignored.
93
     *
94
     * Defaults to `true`, i.e. ignore blank lines.
95
     *
96
     * It is recommended to only set this to `false` if the standard including this sniff does not
97
     * include the `Squiz.WhiteSpace.SuperfluousWhitespace` sniff (which is included in most standards).
98
     *
99
     * @since 1.0.0
100
     *
101
     * @var bool
102
     */
103
    public $ignoreBlankLines = true;
104

105
    /**
106
     * The --tab-width CLI value that is being used.
107
     *
108
     * @since 1.0.0
109
     *
110
     * @var int
111
     */
112
    private $tabWidth;
113

114
    /**
115
     * Whitespace tokens and tokens which can contain leading whitespace.
116
     *
117
     * A few additional tokens will be added to this list in the register() method.
118
     *
119
     * @since 1.0.0
120
     *
121
     * @var array<int|string, int|string>
122
     */
123
    private $tokensToCheck = [
124
        \T_WHITESPACE             => \T_WHITESPACE,
125
        \T_INLINE_HTML            => \T_INLINE_HTML,
126
        \T_DOC_COMMENT_WHITESPACE => \T_DOC_COMMENT_WHITESPACE,
127
        \T_COMMENT                => \T_COMMENT,
128
        \T_END_HEREDOC            => \T_END_HEREDOC,
129
        \T_END_NOWDOC             => \T_END_NOWDOC,
130
    ];
131

132
    /**
133
     * Returns an array of tokens this test wants to listen for.
134
     *
135
     * @since 1.0.0
136
     *
137
     * @return array<int|string>
138
     */
139
    public function register()
4✔
140
    {
141
        // Add the ignore annotation tokens to the list of tokens to check.
142
        $this->tokensToCheck += Tokens::$phpcsCommentTokens;
4✔
143

144
        return Collections::phpOpenTags();
4✔
145
    }
146

147
    /**
148
     * Processes this test, when one of its tokens is encountered.
149
     *
150
     * @since 1.0.0
151
     *
152
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
153
     * @param int                         $stackPtr  The position of the current token
154
     *                                               in the stack passed in $tokens.
155
     *
156
     * @return int Integer stack pointer to skip the rest of the file.
157
     */
158
    public function process(File $phpcsFile, $stackPtr)
4✔
159
    {
160
        /*
161
         * Handle the properties.
162
         */
163
        if (isset($this->tabWidth) === false || \defined('PHP_CODESNIFFER_IN_TESTS') === true) {
4✔
164
            $this->tabWidth = Helper::getTabWidth($phpcsFile);
4✔
165
        }
2✔
166

167
        if (isset($this->indent) === true) {
4✔
168
            $indent = (int) $this->indent;
4✔
169
        } else {
2✔
170
            $indent = $this->tabWidth;
4✔
171
        }
172

173
        $ignoreTokens = (array) $this->ignoreAlignmentBefore;
4✔
174
        if (empty($ignoreTokens) === false) {
4✔
175
            $ignoreTokens = \array_flip($ignoreTokens);
4✔
176
        }
2✔
177

178
        /*
179
         * Check the whole file in one go.
180
         */
181
        $tokens = $phpcsFile->getTokens();
4✔
182

183
        for ($i = 0; $i < $phpcsFile->numTokens; $i++) {
4✔
184
            if ($tokens[$i]['column'] !== 1) {
4✔
185
                // Only interested in the first token on each line.
186
                continue;
4✔
187
            }
188

189
            if (isset($this->tokensToCheck[$tokens[$i]['code']]) === false) {
4✔
190
                // Not one of the target tokens.
191
                continue;
4✔
192
            }
193

194
            if ($tokens[$i]['content'] === $phpcsFile->eolChar) {
4✔
195
                // Skip completely blank lines.
196
                continue;
4✔
197
            }
198

199
            if (isset($ignoreTokens[$tokens[$i]['type']]) === true
4✔
200
                || (isset($tokens[($i + 1)]) && isset($ignoreTokens[$tokens[($i + 1)]['type']]))
4✔
201
            ) {
2✔
202
                // This is one of the tokens being ignored.
203
                continue;
4✔
204
            }
205

206
            $origContent = null;
4✔
207
            if (isset($tokens[$i]['orig_content']) === true) {
4✔
208
                $origContent = $tokens[$i]['orig_content'];
4✔
209
            }
2✔
210

211
            $spaces  = 0;
4✔
212
            $length  = 0;
4✔
213
            $content = '';
4✔
214
            $closer  = '';
4✔
215

216
            switch ($tokens[$i]['code']) {
4✔
217
                case \T_WHITESPACE:
2✔
218
                    if ($this->ignoreBlankLines === true
4✔
219
                        && isset($tokens[($i + 1)])
4✔
220
                        && $tokens[$i]['line'] !== $tokens[($i + 1)]['line']
4✔
221
                    ) {
2✔
222
                        // Skip blank lines which only contain trailing whitespace.
223
                        continue 2;
4✔
224
                    }
225

226
                    $spaces = ($tokens[$i]['length'] % $indent);
4✔
227
                    break;
4✔
228

229
                case \T_DOC_COMMENT_WHITESPACE:
2✔
230
                    /*
231
                     * Blank lines with trailing whitespace in docblocks are tokenized as
232
                     * two T_DOC_COMMENT_WHITESPACE tokens: one for the trailing whitespace,
233
                     * one for the new line character.
234
                     */
235
                    if ($this->ignoreBlankLines === true
4✔
236
                        && isset($tokens[($i + 1)])
4✔
237
                        && $tokens[($i + 1)]['content'] === $phpcsFile->eolChar
4✔
238
                        && isset($tokens[($i + 2)])
4✔
239
                        && $tokens[$i]['line'] !== $tokens[($i + 2)]['line']
4✔
240
                    ) {
2✔
241
                        // Skip blank lines which only contain trailing whitespace.
242
                        continue 2;
4✔
243
                    }
244

245
                    $spaces = ($tokens[$i]['length'] % $indent);
4✔
246

247
                    if (isset($tokens[($i + 1)]) === true
4✔
248
                        && ($tokens[($i + 1)]['code'] === \T_DOC_COMMENT_STAR
4✔
249
                            || $tokens[($i + 1)]['code'] === \T_DOC_COMMENT_CLOSE_TAG)
4✔
250
                        && $spaces !== 0
4✔
251
                    ) {
2✔
252
                        // One alignment space expected before the *.
253
                        --$spaces;
4✔
254
                    }
2✔
255
                    break;
4✔
256

257
                case \T_COMMENT:
4✔
258
                case \T_INLINE_HTML:
4✔
259
                    if ($this->ignoreBlankLines === true
4✔
260
                        && \trim($tokens[$i]['content']) === ''
4✔
261
                        && isset($tokens[($i + 1)])
4✔
262
                        && $tokens[$i]['line'] !== $tokens[($i + 1)]['line']
4✔
263
                    ) {
2✔
264
                        // Skip blank lines which only contain trailing whitespace.
265
                        continue 2;
4✔
266
                    }
267

268
                    // Deliberate fall-through.
269

270
                case \T_PHPCS_ENABLE:
4✔
271
                case \T_PHPCS_DISABLE:
4✔
272
                case \T_PHPCS_SET:
4✔
273
                case \T_PHPCS_IGNORE:
4✔
274
                case \T_PHPCS_IGNORE_FILE:
4✔
275
                    /*
276
                     * Indentation is included in the contents of the token for:
277
                     * - inline HTML
278
                     * - PHP 7.3+ flexible heredoc/nowdoc closer identifiers (see below);
279
                     * - subsequent lines of multi-line comments;
280
                     * - PHPCS native annotations when part of a multi-line comment.
281
                     */
282
                    $content    = \ltrim($tokens[$i]['content']);
4✔
283
                    $whitespace = \str_replace($content, '', $tokens[$i]['content']);
4✔
284

285
                    /*
286
                     * If there is no content, this is a blank line in a comment or in inline HTML.
287
                     * In that case, use the predetermined length as otherwise the new line character
288
                     * at the end of the whitespace will throw the count off.
289
                     */
290
                    $length = ($content === '') ? $tokens[$i]['length'] : \strlen($whitespace);
4✔
291
                    $spaces = ($length % $indent);
4✔
292

293
                    /*
294
                     * For multi-line star-comments, which use (aligned) stars on subsequent
295
                     * lines, we don't want to trigger on the one extra space before the star.
296
                     *
297
                     * While not 100% correct, don't exclude inline HTML from this check as
298
                     * otherwise the sniff would trigger on multi-line /*-style inline javascript comments.
299
                     * This may cause false negatives as there is no check for being in a
300
                     * <script> tag, but that will be rare.
301
                     */
302
                    if (isset($content[0]) === true && $content[0] === '*' && $spaces !== 0) {
4✔
303
                        --$spaces;
4✔
304
                    }
2✔
305
                    break;
4✔
306

307
                case \T_END_HEREDOC:
4✔
308
                case \T_END_NOWDOC:
4✔
309
                    /*
310
                     * PHPCS does not execute tab replacement in heredoc/nowdoc closer
311
                     * tokens prior to PHPCS 3.7.2, so handle this ourselves.
312
                     */
313
                    $content = $tokens[$i]['content'];
4✔
314
                    if (\strpos($tokens[$i]['content'], "\t") !== false) {
4✔
315
                        $origContent = $content;
×
316
                        $content     = \str_replace("\t", \str_repeat(' ', $this->tabWidth), $content);
×
317
                    }
318

319
                    $closer     = \ltrim($content);
4✔
320
                    $whitespace = \str_replace($closer, '', $content);
4✔
321
                    $length     = \strlen($whitespace);
4✔
322
                    $spaces     = ($length % $indent);
4✔
323
                    break;
4✔
324
            }
2✔
325

326
            if ($spaces === 0) {
4✔
327
                continue;
4✔
328
            }
329

330
            $fix = $phpcsFile->addFixableWarning(
4✔
331
                'Found precision alignment of %s spaces.',
4✔
332
                $i,
4✔
333
                'Found',
4✔
334
                [$spaces]
4✔
335
            );
4✔
336

337
            if ($fix === true) {
4✔
338
                if ($tokens[$i]['code'] === \T_END_HEREDOC || $tokens[$i]['code'] === \T_END_NOWDOC) {
4✔
339
                    // For heredoc/nowdoc, always round down to prevent introducing parse errors.
340
                    $tabstops = (int) \floor($spaces / $indent);
2✔
341
                } else {
342
                    // For everything else, use "best guess".
343
                    $tabstops = (int) \round($spaces / $indent, 0);
4✔
344
                }
345

346
                switch ($tokens[$i]['code']) {
4✔
347
                    case \T_WHITESPACE:
2✔
348
                        /*
349
                         * More complex than you'd think as "length" doesn't include new lines,
350
                         * but we don't want to remove new lines either.
351
                         */
352
                        $replaceLength = (((int) ($tokens[$i]['length'] / $indent) + $tabstops) * $indent);
4✔
353
                        $replace       = $this->getReplacement($replaceLength, $origContent);
4✔
354
                        $newContent    = \substr_replace($tokens[$i]['content'], $replace, 0, $tokens[$i]['length']);
4✔
355

356
                        $phpcsFile->fixer->replaceToken($i, $newContent);
4✔
357
                        break;
4✔
358

359
                    case \T_DOC_COMMENT_WHITESPACE:
2✔
360
                        $replaceLength = (((int) ($tokens[$i]['length'] / $indent) + $tabstops) * $indent);
4✔
361
                        $replace       = $this->getReplacement($replaceLength, $origContent);
4✔
362

363
                        if (isset($tokens[($i + 1)]) === true
4✔
364
                            && ($tokens[($i + 1)]['code'] === \T_DOC_COMMENT_STAR
4✔
365
                                || $tokens[($i + 1)]['code'] === \T_DOC_COMMENT_CLOSE_TAG)
4✔
366
                            && $tabstops === 0
4✔
367
                        ) {
2✔
368
                            // Maintain the extra space before the star.
369
                            $replace .= ' ';
4✔
370
                        }
2✔
371

372
                        $newContent = \substr_replace($tokens[$i]['content'], $replace, 0, $tokens[$i]['length']);
4✔
373

374
                        $phpcsFile->fixer->replaceToken($i, $newContent);
4✔
375
                        break;
4✔
376

377
                    case \T_COMMENT:
4✔
378
                    case \T_INLINE_HTML:
4✔
379
                    case \T_PHPCS_ENABLE:
4✔
380
                    case \T_PHPCS_DISABLE:
4✔
381
                    case \T_PHPCS_SET:
4✔
382
                    case \T_PHPCS_IGNORE:
4✔
383
                    case \T_PHPCS_IGNORE_FILE:
4✔
384
                        $replaceLength = (((int) ($length / $indent) + $tabstops) * $indent);
4✔
385
                        $replace       = $this->getReplacement($replaceLength, $origContent);
4✔
386

387
                        if (isset($content[0]) === true && $content[0] === '*' && $tabstops === 0) {
4✔
388
                            // Maintain the extra space before the star.
389
                            $replace .= ' ';
4✔
390
                        }
2✔
391

392
                        if ($content === '') {
4✔
393
                            // Preserve new lines in blank line comment tokens.
394
                            $newContent = \substr_replace($tokens[$i]['content'], $replace, 0, $length);
4✔
395
                        } else {
2✔
396
                            $newContent = $replace . $content;
4✔
397
                        }
398

399
                        $phpcsFile->fixer->replaceToken($i, $newContent);
4✔
400
                        break;
4✔
401

402
                    case \T_END_HEREDOC:
2✔
403
                    case \T_END_NOWDOC:
2✔
404
                        $replaceLength = (((int) ($length / $indent) + $tabstops) * $indent);
2✔
405
                        $replace       = $this->getReplacement($replaceLength, $origContent);
2✔
406

407
                        $phpcsFile->fixer->replaceToken($i, $replace . $closer);
2✔
408
                        break;
2✔
409
                }
2✔
410
            }
2✔
411
        }
2✔
412

413
        // No need to look at this file again.
414
        return $phpcsFile->numTokens;
4✔
415
    }
416

417
    /**
418
     * Get the whitespace replacement. Respect tabs vs spaces.
419
     *
420
     * @param int         $length      The target length of the replacement.
421
     * @param string|null $origContent The original token content without tabs replaced (if available).
422
     *
423
     * @return string
424
     */
425
    private function getReplacement($length, $origContent)
4✔
426
    {
427
        if ($origContent !== null) {
4✔
428
            // Check whether tabs were part of the indent or inline alignment.
429
            $content    = \ltrim($origContent);
4✔
430
            $whitespace = $origContent;
4✔
431
            if ($content !== '') {
4✔
432
                $whitespace = \str_replace($content, '', $origContent);
4✔
433
            }
2✔
434

435
            if (\strpos($whitespace, "\t") !== false) {
4✔
436
                // Original indent used tabs. Use tabs in replacement too.
437
                $tabs   = (int) ($length / $this->tabWidth);
4✔
438
                $spaces = $length % $this->tabWidth;
4✔
439
                return \str_repeat("\t", $tabs) . \str_repeat(' ', (int) $spaces);
4✔
440
            }
441
        }
442

443
        return \str_repeat(' ', $length);
4✔
444
    }
445
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc