diff --git a/README.md b/README.md
index 9ba7308d..67e8b4bc 100644
--- a/README.md
+++ b/README.md
@@ -11,14 +11,14 @@ WordPress Coding Standards for [Mago](https://github.com/carthage-software/mago)
ports the WPCS lint sniffs to Mago's linter, so a plugin or theme is checked in seconds, not
minutes.
-## 2–12× faster than phpcs
+## About 2× faster than phpcs on large plugins
-
+
-8.1× faster overall on the top 10 WordPress.org plugins plus WordPress core. Method and numbers:
+1.8× faster overall on the top 10 WordPress.org plugins plus WordPress core: about 2–2.7× on the large plugins, about even on the small ones. Method and numbers:
[Benchmarks](docs/benchmarks.md).
## Install
diff --git a/bench/bakeoff.sh b/bench/bakeoff.sh
index 6e33d9e5..b2b4e784 100755
--- a/bench/bakeoff.sh
+++ b/bench/bakeoff.sh
@@ -51,13 +51,13 @@ fi
first=$(ls "$out"/*.out | head -1)
echo "# Bake-off $(basename "$out"): top 10 WordPress.org plugins + WordPress core"
echo
-echo "\`bench/bakeoff.sh\` (phpcs WordPress-Extra vs mago + mago-wordpress), mean of 3 runs after a warm-up."
+echo "\`bench/bakeoff.sh\` (phpcs WordPress vs mago + mago-wordpress), mean of 3 runs after a warm-up."
[[ -f "$out/versions.txt" ]] || { echo "error: $out/versions.txt is missing; run the benchmarks first" >&2; exit 1; }
echo "Versions: $(grep -m1 "^mago " "$first"), $(cat "$out/versions.txt")."
echo
echo "## Results"
echo
-echo "| Codebase | PHP files | phpcs \`WordPress-Extra\` | \`mago lint\` + extension | Speed-up |"
+echo "| Codebase | PHP files | phpcs \`WordPress\` | \`mago lint\` + extension | Speed-up |"
echo "|:---|---:|---:|---:|---:|"
tf=0; tp=0; tm=0
for entry in "${codebases[@]}"; do
diff --git a/bench/lib.sh b/bench/lib.sh
index 29994d47..80833285 100644
--- a/bench/lib.sh
+++ b/bench/lib.sh
@@ -19,7 +19,7 @@ php-version = "8.2"
paths = [{$json($project)}]
excludes = ["**/vendor/**", "**/vendor_prefixed/**", "**/node_modules/**", "**/tests/**"]
[extension-hosts.wordpress]
-command = ["php", {$json($worker)}]
+command = ["php", "-d", "opcache.enable_cli=1", {$json($worker)}]
TOML);
file_put_contents("$work/composer.json", json_encode(
@@ -38,3 +38,6 @@ foreach (json_decode(stream_get_contents(STDIN), true)["extensions"][0]["linter-
if ($r["default-enabled"]) { echo $r["code"], ","; }
}' | sed 's/,$//')
[[ -n $codes ]] || { echo "could not list the extension rules" >&2; exit 1; }
+# Mago's own missing-docs rule covers the WordPress-Docs sniffs the extension leaves to it; the presets
+# enable it, so the timed run does too.
+codes="$codes,missing-docs"
diff --git a/bench/results/2026-10-20-bakeoff.md b/bench/results/2026-10-20-bakeoff.md
new file mode 100644
index 00000000..4e51d586
--- /dev/null
+++ b/bench/results/2026-10-20-bakeoff.md
@@ -0,0 +1,735 @@
+# Bake-off 2026-10-20: top 10 WordPress.org plugins + WordPress core
+
+`bench/bakeoff.sh` (phpcs WordPress vs mago + mago-wordpress), mean of 3 runs after a warm-up.
+Versions: mago 1.57.0 / PHP_CodeSniffer version 3.13.6 (stable) by Squiz and PHPCSStandards, PHP 8.4.24, this package at perf/worker-speedups (b31b57e).
+
+## Results
+
+| Codebase | PHP files | phpcs `WordPress` | `mago lint` + extension | Speed-up |
+|:---|---:|---:|---:|---:|
+| WooCommerce | 3528 | 26.33 s | 12.64 s | 2.1× |
+| WordPress core | 1868 | 18.14 s | 14.63 s | 1.2× |
+| Elementor | 1460 | 10.61 s | 4.96 s | 2.1× |
+| Google Site Kit | 1869 | 12.47 s | 5.40 s | 2.3× |
+| Yoast SEO | 1511 | 9.72 s | 3.59 s | 2.7× |
+| WPForms Lite | 963 | 7.97 s | 5.32 s | 1.5× |
+| LiteSpeed Cache | 212 | 2.19 s | 2.04 s | 1.1× |
+| WP Mail SMTP | 185 | 1.79 s | 1.68 s | 1.1× |
+| All-in-One WP Migration | 147 | 1.56 s | 0.74 s | 2.1× |
+| Contact Form 7 | 111 | 1.15 s | 0.89 s | 1.3× |
+| Akismet | 29 | 0.70 s | 0.57 s | 1.2× |
+| **Total** | **11883** | **92.63 s** | **52.46 s** | **1.8×** |
+
+## mago issue counts by rule, per codebase
+
+### WooCommerce
+
+```
+error[generic/disallow-space-indent]: 47092
+error[wordpress/valid-variable-name]: 13190
+error[wordpress/function-call-signature]: 12319
+warning[wordpress/array-alignment]: 6954
+error[wordpress/control-structure-spacing]: 5959
+error[wordpress/file-name]: 4536
+error[generic/function-comment]: 4230
+error[generic/disallow-short-array-syntax]: 4103
+error[generic/function-declaration-argument-spacing]: 3687
+error[generic/file-comment]: 2499
+error[wordpress/array-brace-spacing]: 2469
+error[generic/opening-function-brace-kernighan-ritchie]: 2337
+error[generic/doc-comment]: 1977
+error[wordpress/comma-after-last]: 1930
+warning[wordpress/direct-database-query]: 1756
+help[missing-docs]: 1362
+warning[wordpress/statement-alignment]: 1327
+error[wordpress/escape-output]: 1155
+error[wordpress/valid-function-name]: 859
+error[wordpress/operator-spacing]: 801
+error[wordpress/array-key-spacing-restrictions]: 774
+error[wordpress/yoda-conditions]: 750
+error[generic/inline-comment]: 583
+error[generic/arbitrary-parentheses-spacing]: 564
+error[generic/opening-brace-same-line]: 446
+error[wordpress/validated-sanitized-input]: 434
+error[wordpress/array-declaration-spacing]: 404
+warning[wordpress/nonce-verification-warning]: 363
+error[generic/concatenation-spacing]: 234
+warning[generic/no-reserved-keyword-parameter-names]: 229
+error[wordpress/prepared-sql]: 226
+warning[wordpress/capabilities-warning]: 189
+warning[generic/strict-comparisons]: 163
+warning[generic/unused-function-parameter]: 158
+error[generic/block-comment]: 135
+error[generic/function-comment-throw-tag]: 98
+error[wordpress/nonce-verification]: 98
+error[generic/double-quote-usage]: 93
+error[generic/multi-line-function-declaration]: 90
+warning[wordpress/strict-in-array]: 89
+warning[wordpress/alternative-functions]: 76
+error[generic/disallow-multiple-assignments]: 74
+error[generic/return-type-declaration]: 74
+warning[generic/disallow-standalone-post-increment-decrement]: 71
+warning[wordpress/slow-db-query]: 61
+error[generic/class-declaration]: 60
+error[wordpress/wp-date-time]: 52
+error[generic/variable-comment]: 46
+warning[wordpress/no-silenced-errors]: 43
+error[generic/dirname]: 39
+warning[generic/method-declaration-warning]: 39
+warning[wordpress/discouraged-wp-functions]: 39
+warning[generic/assignment-in-condition]: 37
+error[generic/increment-decrement-spacing]: 30
+error[generic/superfluous-whitespace]: 29
+error[wordpress/array-indentation]: 28
+error[wordpress/wp-deprecated-parameters]: 28
+error[generic/disallow-short-ternary]: 25
+error[generic/space-after-cast]: 25
+error[generic/disallow-lonely-if]: 22
+warning[generic/commented-out-code]: 19
+error[wordpress/cast-structure-spacing]: 19
+error[wordpress/wp-i18n]: 19
+error[generic/no-leading-backslash]: 18
+warning[generic/precision-alignment]: 18
+error[wordpress/global-variables-override]: 17
+error[generic/function-closing-brace]: 16
+error[generic/valid-logical-operators]: 15
+error[generic/method-scope]: 14
+warning[wordpress/safe-redirect]: 14
+error[generic/self-member-reference]: 12
+warning[generic/useless-overriding-method]: 12
+error[generic/disallow-inline-tabs]: 11
+error[generic/require-explicit-boolean-operator-precedence]: 11
+error[generic/end-file-newline]: 10
+error[wordpress/prefix-all-globals]: 10
+error[generic/scope-indent]: 8
+error[generic/valid-class-name]: 7
+warning[generic/else-if-declaration]: 6
+warning[generic/property-declaration-warning]: 6
+warning[wordpress/prepared-sql-placeholders-warning]: 6
+error[generic/no-echo-sprintf]: 5
+error[generic/embedded-php]: 4
+warning[generic/non-executable-code]: 4
+error[generic/semicolon-spacing]: 4
+error[generic/class-instantiation]: 3
+error[generic/forbidden-functions]: 3
+error[generic/one-object-structure-per-file]: 3
+error[generic/disallow-size-functions-in-loops]: 2
+error[generic/doc-comment-alignment]: 2
+error[generic/duplicate-array-key]: 2
+error[generic/including-file]: 2
+warning[wordpress/enqueued-resource-parameters]: 2
+warning[wordpress/get-meta-single]: 2
+error[wordpress/ini-set]: 2
+warning[wordpress/preg-quote-delimiter]: 2
+error[generic/closing-tag]: 1
+error[generic/deprecated-functions]: 1
+warning[generic/for-loop-with-test-function-call]: 1
+error[generic/function-call-argument-spacing]: 1
+error[generic/no-double-negative]: 1
+error[generic/upper-case-constant-name]: 1
+warning[wordpress/assignment-in-ternary-condition]: 1
+warning[wordpress/cron-interval]: 1
+warning[wordpress/ini-set-warning]: 1
+warning[wordpress/prepared-sql-unquoted-complex-placeholder]: 1
+warning[wordpress/valid-hook-name-warning]: 1
+error[wordpress/wp-deprecated-functions]: 1
+```
+
+### WordPress core
+
+```
+error[generic/disallow-space-indent]: 79731
+error[wordpress/function-call-signature]: 49673
+error[wordpress/control-structure-spacing]: 18051
+error[wordpress/valid-variable-name]: 10073
+error[generic/arbitrary-parentheses-spacing]: 8783
+error[generic/inline-comment]: 6842
+error[generic/scope-indent]: 6796
+error[wordpress/escape-output]: 6604
+warning[wordpress/array-alignment]: 6337
+warning[wordpress/statement-alignment]: 6242
+error[generic/function-comment]: 6015
+error[wordpress/operator-spacing]: 5964
+error[wordpress/array-brace-spacing]: 5532
+error[generic/function-declaration-argument-spacing]: 4567
+error[wordpress/array-key-spacing-restrictions]: 4000
+error[wordpress/wp-i18n]: 3291
+warning[generic/precision-alignment]: 3243
+error[generic/doc-comment]: 2947
+error[wordpress/comma-after-last]: 2361
+error[wordpress/validated-sanitized-input]: 2222
+error[generic/opening-function-brace-kernighan-ritchie]: 2211
+error[generic/disallow-inline-tabs]: 2025
+error[generic/concatenation-spacing]: 1899
+error[wordpress/yoda-conditions]: 1829
+error[wordpress/array-declaration-spacing]: 1611
+warning[generic/strict-comparisons]: 1341
+error[wordpress/global-variables-override]: 1293
+error[generic/function-call-argument-spacing]: 1284
+error[generic/disallow-short-array-syntax]: 1223
+warning[wordpress/direct-database-query]: 989
+warning[wordpress/alternative-functions]: 946
+error[generic/double-quote-usage]: 933
+warning[wordpress/nonce-verification-warning]: 837
+error[wordpress/file-name]: 813
+warning[generic/commented-out-code]: 807
+help[missing-docs]: 767
+error[wordpress/valid-function-name]: 707
+error[wordpress/array-indentation]: 569
+error[generic/file-comment]: 568
+error[generic/disallow-multiple-assignments]: 548
+warning[generic/assignment-in-condition]: 506
+error[generic/inline-control-structure]: 499
+error[generic/property-declaration]: 440
+warning[wordpress/discouraged-wp-functions]: 401
+error[generic/comma-spacing]: 397
+warning[wordpress/no-silenced-errors]: 390
+error[generic/opening-brace-same-line]: 366
+error[wordpress/nonce-verification]: 354
+error[generic/method-scope]: 329
+error[generic/control-signature]: 294
+error[generic/block-comment]: 272
+error[generic/function-comment-throw-tag]: 269
+warning[generic/unused-function-parameter]: 265
+error[generic/superfluous-whitespace]: 250
+error[wordpress/prepared-sql]: 237
+error[generic/self-member-reference]: 225
+error[wordpress/cast-structure-spacing]: 210
+error[generic/lowercase-constant]: 208
+warning[wordpress/safe-redirect]: 195
+warning[generic/no-reserved-keyword-parameter-names]: 192
+error[generic/multi-line-function-declaration]: 183
+warning[generic/method-declaration-warning]: 175
+warning[generic/disallow-standalone-post-increment-decrement]: 167
+error[generic/require-explicit-boolean-operator-precedence]: 146
+error[generic/disallow-lonely-if]: 118
+warning[generic/property-declaration-warning]: 110
+warning[generic/non-executable-code]: 107
+warning[wordpress/strict-in-array]: 103
+error[generic/increment-decrement-usage]: 96
+error[generic/empty-statement]: 88
+error[generic/disallow-size-functions-in-loops]: 81
+warning[wordpress/slow-db-query]: 79
+error[wordpress/wp-deprecated-functions]: 79
+error[generic/upper-case-constant-name]: 74
+error[generic/disallow-multiple-statements]: 66
+error[generic/valid-class-name]: 65
+error[generic/space-after-cast]: 62
+warning[generic/for-loop-with-test-function-call]: 57
+error[generic/valid-logical-operators]: 56
+warning[generic/else-if-declaration]: 54
+error[generic/doc-comment-alignment]: 53
+warning[wordpress/enqueued-resource-parameters]: 38
+warning[wordpress/discouraged-constants]: 36
+error[generic/one-object-structure-per-file]: 34
+warning[generic/unconditional-if-statement]: 34
+error[generic/class-declaration]: 32
+error[generic/dirname]: 31
+error[wordpress/db-restricted-functions]: 30
+error[generic/static-in-final-class]: 29
+error[generic/forbidden-functions]: 24
+error[generic/unnecessary-string-concat]: 22
+error[generic/switch-declaration]: 18
+warning[wordpress/get-meta-single]: 16
+error[wordpress/valid-post-type-slug]: 16
+error[generic/end-file-newline]: 14
+error[generic/disallow-short-ternary]: 13
+error[wordpress/enqueued-resources]: 12
+warning[wordpress/preg-quote-delimiter]: 12
+error[wordpress/ini-set]: 11
+error[generic/deprecated-functions]: 10
+error[generic/separate-functions-from-oo]: 10
+error[generic/variable-comment]: 10
+error[wordpress/prefix-all-globals]: 10
+error[generic/including-file]: 9
+warning[wordpress/valid-hook-name-warning]: 9
+error[generic/semicolon-spacing]: 8
+error[generic/trait-use-declaration]: 7
+error[wordpress/wp-date-time]: 7
+error[wordpress/wp-deprecated-classes]: 7
+error[wordpress/wp-deprecated-parameters]: 7
+error[generic/function-closing-brace]: 6
+error[wordpress/language-construct-spacing]: 6
+warning[generic/unnecessary-heredoc]: 5
+error[generic/class-comment]: 4
+error[generic/closing-tag]: 4
+error[generic/no-echo-sprintf]: 4
+error[generic/class-instantiation]: 3
+warning[generic/useless-overriding-method]: 3
+warning[wordpress/ini-set-warning]: 3
+error[wordpress/wp-deprecated-parameter-values]: 3
+error[wordpress/db-restricted-classes]: 2
+warning[wordpress/posts-per-page]: 2
+warning[wordpress/prepared-sql-placeholders-warning]: 2
+warning[generic/including-file-warning]: 1
+error[generic/increment-decrement-spacing]: 1
+error[generic/require-anon-class-parentheses]: 1
+warning[wordpress/assignment-in-ternary-condition]: 1
+error[wordpress/dont-extract]: 1
+error[wordpress/valid-hook-name]: 1
+```
+
+### Elementor
+
+```
+warning[wordpress/array-alignment]: 16235
+error[generic/disallow-short-array-syntax]: 14793
+help[missing-docs]: 8569
+error[wordpress/function-call-signature]: 3768
+warning[wordpress/statement-alignment]: 3397
+error[generic/function-comment]: 1836
+error[generic/file-comment]: 1456
+error[wordpress/file-name]: 1377
+error[generic/doc-comment]: 1119
+error[generic/inline-comment]: 407
+warning[wordpress/valid-hook-name-warning]: 385
+error[generic/multi-line-function-declaration]: 177
+error[generic/embedded-php]: 165
+error[wordpress/prefix-all-globals]: 151
+warning[generic/unused-function-parameter]: 77
+warning[wordpress/nonce-verification-warning]: 60
+warning[wordpress/slow-db-query]: 58
+warning[wordpress/direct-database-query]: 57
+warning[wordpress/alternative-functions]: 53
+warning[wordpress/strict-in-array]: 51
+warning[generic/method-declaration-warning]: 46
+warning[wordpress/discouraged-wp-functions]: 34
+error[wordpress/valid-variable-name]: 32
+warning[generic/commented-out-code]: 27
+warning[generic/strict-comparisons]: 19
+warning[generic/disallow-standalone-post-increment-decrement]: 16
+warning[generic/property-declaration-warning]: 16
+warning[wordpress/no-silenced-errors]: 16
+error[wordpress/wp-date-time]: 13
+warning[generic/no-reserved-keyword-parameter-names]: 11
+error[generic/superfluous-whitespace]: 10
+error[wordpress/array-brace-spacing]: 8
+error[wordpress/yoda-conditions]: 8
+error[generic/double-quote-usage]: 5
+warning[generic/else-if-declaration]: 5
+warning[generic/assignment-in-condition]: 3
+error[generic/one-object-structure-per-file]: 3
+warning[wordpress/capital-p-dangit]: 3
+warning[wordpress/enqueued-resource-parameters]: 3
+error[wordpress/valid-function-name]: 3
+warning[generic/for-loop-with-test-function-call]: 2
+warning[wordpress/capabilities-warning]: 2
+warning[wordpress/safe-redirect]: 2
+warning[generic/constructor-destructor-return-warning]: 1
+error[generic/deprecated-functions]: 1
+error[generic/disallow-lonely-if]: 1
+error[generic/empty-statement]: 1
+error[generic/inline-control-structure]: 1
+warning[generic/non-executable-code]: 1
+error[generic/separate-functions-from-oo]: 1
+warning[wordpress/cron-interval]: 1
+error[wordpress/dont-extract]: 1
+warning[wordpress/get-meta-single]: 1
+```
+
+### Google Site Kit
+
+```
+error[generic/disallow-space-indent]: 135765
+error[wordpress/function-call-signature]: 33183
+error[wordpress/valid-variable-name]: 16567
+error[wordpress/control-structure-spacing]: 12124
+error[generic/function-declaration-argument-spacing]: 10326
+error[wordpress/array-brace-spacing]: 8192
+error[generic/function-comment]: 8063
+error[generic/opening-function-brace-kernighan-ritchie]: 7963
+error[generic/disallow-short-array-syntax]: 5183
+warning[wordpress/statement-alignment]: 5142
+error[wordpress/file-name]: 3481
+error[generic/doc-comment]: 3465
+help[missing-docs]: 3111
+error[wordpress/operator-spacing]: 2991
+error[wordpress/array-key-spacing-restrictions]: 2285
+error[wordpress/array-declaration-spacing]: 2186
+error[generic/file-comment]: 2052
+error[wordpress/prefix-all-globals]: 2006
+error[generic/inline-comment]: 1608
+error[generic/opening-brace-same-line]: 1360
+error[generic/arbitrary-parentheses-spacing]: 1226
+error[wordpress/yoda-conditions]: 1103
+error[generic/block-comment]: 937
+error[wordpress/valid-function-name]: 823
+warning[generic/strict-comparisons]: 766
+error[generic/scope-indent]: 495
+warning[wordpress/array-alignment]: 472
+error[wordpress/escape-output]: 411
+error[generic/function-comment-throw-tag]: 402
+error[generic/double-quote-usage]: 324
+warning[generic/precision-alignment]: 243
+error[generic/unnecessary-string-concat]: 237
+warning[generic/no-reserved-keyword-parameter-names]: 235
+error[generic/disallow-multiple-assignments]: 231
+warning[wordpress/alternative-functions]: 207
+error[generic/upper-case-constant-name]: 202
+error[generic/disallow-short-ternary]: 169
+warning[generic/commented-out-code]: 150
+warning[generic/assignment-in-condition]: 130
+error[generic/superfluous-whitespace]: 107
+warning[wordpress/discouraged-wp-functions]: 91
+error[wordpress/cast-structure-spacing]: 90
+error[generic/disallow-size-functions-in-loops]: 86
+error[generic/valid-class-name]: 76
+warning[wordpress/strict-in-array]: 75
+warning[generic/disallow-standalone-post-increment-decrement]: 64
+warning[generic/for-loop-with-test-function-call]: 62
+warning[generic/unused-function-parameter]: 58
+error[generic/multi-line-function-declaration]: 48
+error[generic/trait-use-declaration]: 44
+error[wordpress/validated-sanitized-input]: 41
+error[generic/empty-statement]: 39
+warning[wordpress/no-silenced-errors]: 38
+error[generic/variable-comment]: 35
+error[generic/self-member-reference]: 31
+warning[generic/method-declaration-warning]: 25
+error[wordpress/wp-i18n]: 24
+error[generic/one-object-structure-per-file]: 22
+error[generic/require-explicit-boolean-operator-precedence]: 17
+error[generic/increment-decrement-usage]: 16
+error[generic/empty-catch-comment]: 14
+error[wordpress/array-indentation]: 12
+error[wordpress/comma-after-last]: 12
+warning[generic/else-if-declaration]: 10
+error[generic/comma-spacing]: 8
+error[generic/disallow-namespace-curly-brace-syntax]: 6
+error[generic/one-namespace-declaration-per-file]: 5
+error[wordpress/wp-date-time]: 5
+warning[generic/non-executable-code]: 4
+error[generic/static-in-final-class]: 4
+warning[generic/useless-overriding-method]: 4
+error[wordpress/nonce-verification]: 4
+warning[wordpress/nonce-verification-warning]: 4
+error[generic/dirname]: 2
+error[generic/disallow-namespace-without-name]: 2
+error[generic/doc-comment-alignment]: 2
+error[generic/file-header]: 2
+error[generic/switch-declaration]: 2
+warning[generic/unnecessary-heredoc]: 2
+warning[wordpress/ini-set-warning]: 2
+error[generic/deprecated-functions]: 1
+error[generic/namespace-declaration]: 1
+warning[generic/property-declaration-warning]: 1
+warning[generic/unconditional-if-statement]: 1
+warning[wordpress/preg-quote-delimiter]: 1
+```
+
+### Yoast SEO
+
+```
+error[generic/disallow-space-indent]: 7009
+error[generic/disallow-short-array-syntax]: 4497
+error[generic/arbitrary-parentheses-spacing]: 3910
+error[wordpress/function-call-signature]: 1852
+error[wordpress/yoda-conditions]: 1578
+error[wordpress/prefix-all-globals]: 1512
+error[wordpress/file-name]: 1304
+error[generic/file-comment]: 1274
+error[generic/disallow-multiple-assignments]: 1149
+warning[wordpress/array-alignment]: 645
+error[generic/opening-function-brace-kernighan-ritchie]: 594
+error[wordpress/array-brace-spacing]: 370
+error[generic/control-signature]: 337
+warning[wordpress/direct-database-query]: 96
+error[wordpress/array-declaration-spacing]: 94
+error[wordpress/control-structure-spacing]: 76
+error[wordpress/escape-output]: 65
+error[wordpress/prepared-sql]: 54
+warning[wordpress/slow-db-query]: 22
+warning[wordpress/alternative-functions]: 20
+warning[wordpress/valid-hook-name-warning]: 19
+error[wordpress/valid-hook-name]: 18
+warning[wordpress/capabilities-warning]: 14
+warning[wordpress/nonce-verification-warning]: 14
+warning[wordpress/discouraged-wp-functions]: 9
+error[wordpress/validated-sanitized-input]: 8
+warning[generic/unused-function-parameter]: 6
+help[missing-docs]: 5
+warning[wordpress/prepared-sql-placeholders-warning]: 5
+warning[generic/commented-out-code]: 3
+warning[generic/no-reserved-keyword-parameter-names]: 3
+error[wordpress/wp-i18n]: 3
+error[generic/doc-comment]: 2
+error[generic/function-comment]: 2
+error[wordpress/dont-extract]: 2
+error[wordpress/global-variables-override]: 2
+warning[generic/assignment-in-condition]: 1
+error[generic/block-comment]: 1
+error[generic/function-comment-throw-tag]: 1
+error[generic/opening-brace-same-line]: 1
+warning[generic/useless-overriding-method]: 1
+error[wordpress/prepared-sql-placeholders]: 1
+warning[wordpress/statement-alignment]: 1
+error[wordpress/valid-variable-name]: 1
+error[wordpress/wp-date-time]: 1
+```
+
+### WPForms Lite
+
+```
+error[generic/disallow-short-array-syntax]: 10556
+error[wordpress/yoda-conditions]: 1623
+error[wordpress/file-name]: 1411
+error[generic/file-comment]: 967
+error[wordpress/prefix-all-globals]: 382
+error[generic/scope-indent]: 160
+error[generic/disallow-space-indent]: 104
+error[wordpress/escape-output]: 96
+error[generic/function-call-argument-spacing]: 38
+warning[generic/unused-function-parameter]: 38
+warning[generic/no-reserved-keyword-parameter-names]: 26
+error[generic/doc-comment]: 22
+warning[wordpress/array-alignment]: 22
+error[generic/inline-comment]: 20
+warning[generic/precision-alignment]: 11
+error[generic/function-comment]: 10
+error[generic/multi-line-function-declaration]: 10
+error[generic/class-declaration]: 7
+error[generic/no-leading-backslash]: 7
+warning[generic/commented-out-code]: 6
+error[generic/increment-decrement-spacing]: 4
+error[generic/switch-declaration]: 4
+error[generic/disallow-lonely-if]: 3
+error[generic/function-comment-throw-tag]: 2
+error[generic/no-echo-sprintf]: 2
+error[generic/class-instantiation]: 1
+error[generic/control-signature]: 1
+warning[generic/disallow-standalone-post-increment-decrement]: 1
+error[generic/empty-statement]: 1
+error[generic/function-closing-brace]: 1
+warning[generic/property-declaration-warning]: 1
+error[generic/separate-functions-from-oo]: 1
+error[generic/superfluous-whitespace]: 1
+help[missing-docs]: 1
+error[wordpress/function-call-signature]: 1
+error[wordpress/global-variables-override]: 1
+error[wordpress/operator-spacing]: 1
+warning[wordpress/strict-in-array]: 1
+error[wordpress/valid-function-name]: 1
+```
+
+### LiteSpeed Cache
+
+```
+error[wordpress/function-call-signature]: 1349
+error[generic/disallow-short-array-syntax]: 919
+error[wordpress/prefix-all-globals]: 738
+warning[wordpress/array-alignment]: 548
+error[generic/inline-comment]: 547
+error[generic/disallow-space-indent]: 435
+error[wordpress/control-structure-spacing]: 376
+error[wordpress/wp-i18n]: 278
+warning[generic/method-declaration-warning]: 272
+error[wordpress/file-name]: 272
+error[wordpress/operator-spacing]: 160
+warning[generic/property-declaration-warning]: 123
+error[generic/scope-indent]: 110
+help[missing-docs]: 85
+error[wordpress/array-key-spacing-restrictions]: 78
+warning[wordpress/direct-database-query]: 78
+warning[generic/commented-out-code]: 49
+error[wordpress/array-declaration-spacing]: 13
+error[generic/superfluous-whitespace]: 11
+error[generic/arbitrary-parentheses-spacing]: 10
+error[wordpress/cast-structure-spacing]: 4
+error[generic/file-comment]: 1
+error[generic/multi-line-function-declaration]: 1
+```
+
+### WP Mail SMTP
+
+```
+error[generic/disallow-short-array-syntax]: 2179
+error[generic/inline-comment]: 358
+error[wordpress/file-name]: 352
+error[wordpress/yoda-conditions]: 311
+error[wordpress/prefix-all-globals]: 223
+error[generic/file-comment]: 185
+warning[generic/precision-alignment]: 102
+error[generic/doc-comment]: 65
+error[generic/function-comment]: 55
+warning[wordpress/direct-database-query]: 37
+error[generic/disallow-space-indent]: 33
+error[wordpress/nonce-verification]: 18
+error[generic/disallow-lonely-if]: 17
+warning[wordpress/array-alignment]: 10
+warning[generic/no-reserved-keyword-parameter-names]: 9
+error[generic/multi-line-function-declaration]: 8
+warning[wordpress/alternative-functions]: 8
+warning[generic/unused-function-parameter]: 7
+error[wordpress/wp-i18n]: 6
+warning[generic/disallow-standalone-post-increment-decrement]: 5
+error[generic/block-comment]: 4
+error[generic/increment-decrement-spacing]: 4
+warning[wordpress/discouraged-wp-functions]: 4
+error[wordpress/escape-output]: 4
+error[wordpress/valid-variable-name]: 4
+error[wordpress/array-declaration-spacing]: 3
+error[wordpress/comma-after-last]: 3
+error[wordpress/control-structure-spacing]: 3
+warning[wordpress/nonce-verification-warning]: 3
+error[generic/class-declaration]: 2
+error[generic/dirname]: 2
+error[generic/end-file-newline]: 2
+error[generic/function-call-argument-spacing]: 2
+error[generic/scope-indent]: 2
+error[wordpress/array-brace-spacing]: 2
+warning[wordpress/capabilities-warning]: 2
+warning[wordpress/capital-p-dangit]: 2
+warning[wordpress/enqueued-resource-parameters]: 2
+error[wordpress/function-call-signature]: 2
+warning[wordpress/statement-alignment]: 2
+warning[generic/commented-out-code]: 1
+error[generic/empty-catch-comment]: 1
+error[generic/empty-statement]: 1
+error[generic/function-closing-brace]: 1
+error[generic/no-echo-sprintf]: 1
+warning[generic/strict-comparisons]: 1
+error[generic/superfluous-whitespace]: 1
+error[generic/switch-declaration]: 1
+warning[wordpress/no-silenced-errors]: 1
+```
+
+### All-in-One WP Migration
+
+```
+error[generic/inline-comment]: 1237
+error[generic/function-comment]: 377
+help[missing-docs]: 174
+error[generic/file-comment]: 147
+error[generic/disallow-multiple-assignments]: 138
+warning[generic/assignment-in-condition]: 136
+warning[wordpress/strict-in-array]: 94
+warning[wordpress/alternative-functions]: 78
+warning[wordpress/discouraged-wp-functions]: 57
+warning[wordpress/no-silenced-errors]: 39
+error[wordpress/yoda-conditions]: 35
+error[wordpress/array-declaration-spacing]: 31
+error[generic/one-object-structure-per-file]: 25
+error[wordpress/nonce-verification]: 21
+warning[wordpress/capabilities-warning]: 15
+error[wordpress/validated-sanitized-input]: 12
+warning[wordpress/nonce-verification-warning]: 11
+error[wordpress/prefix-all-globals]: 11
+error[wordpress/wp-i18n]: 9
+error[generic/disallow-size-functions-in-loops]: 8
+error[generic/doc-comment]: 8
+error[generic/empty-statement]: 8
+warning[generic/for-loop-with-test-function-call]: 8
+warning[generic/unused-function-parameter]: 8
+error[generic/function-comment-throw-tag]: 7
+warning[generic/disallow-standalone-post-increment-decrement]: 6
+error[generic/empty-catch-comment]: 5
+error[generic/self-member-reference]: 4
+warning[wordpress/direct-database-query]: 4
+error[generic/disallow-lonely-if]: 2
+warning[generic/no-reserved-keyword-parameter-names]: 2
+error[generic/require-explicit-boolean-operator-precedence]: 2
+error[wordpress/escape-output]: 2
+error[wordpress/file-name]: 2
+warning[wordpress/ini-set-warning]: 2
+warning[generic/useless-overriding-method]: 1
+error[wordpress/prepared-sql]: 1
+```
+
+### Contact Form 7
+
+```
+error[wordpress/function-call-signature]: 1740
+warning[wordpress/array-alignment]: 1003
+help[missing-docs]: 512
+warning[wordpress/statement-alignment]: 337
+error[generic/function-comment]: 302
+error[wordpress/array-declaration-spacing]: 258
+error[generic/valid-logical-operators]: 240
+error[wordpress/array-key-spacing-restrictions]: 174
+error[generic/file-comment]: 117
+error[generic/disallow-multiple-assignments]: 88
+warning[generic/assignment-in-condition]: 79
+error[generic/doc-comment-alignment]: 68
+error[generic/scope-indent]: 63
+error[wordpress/file-name]: 60
+error[generic/class-declaration]: 54
+error[generic/inline-comment]: 53
+error[generic/upper-case-constant-name]: 49
+error[wordpress/prefix-all-globals]: 34
+warning[wordpress/capabilities-warning]: 33
+warning[generic/unused-function-parameter]: 31
+error[generic/embedded-php]: 20
+error[generic/no-echo-sprintf]: 15
+error[generic/one-object-structure-per-file]: 14
+warning[generic/no-reserved-keyword-parameter-names]: 13
+error[generic/double-quote-usage]: 11
+error[generic/increment-decrement-usage]: 9
+error[generic/separate-functions-from-oo]: 9
+warning[wordpress/direct-database-query]: 9
+error[wordpress/nonce-verification]: 9
+error[generic/require-explicit-boolean-operator-precedence]: 8
+warning[wordpress/discouraged-wp-functions]: 8
+error[generic/disallow-inline-tabs]: 7
+error[wordpress/control-structure-spacing]: 6
+warning[wordpress/no-silenced-errors]: 6
+warning[generic/commented-out-code]: 3
+error[generic/disallow-lonely-if]: 3
+error[wordpress/comma-after-last]: 3
+error[wordpress/validated-sanitized-input]: 3
+error[wordpress/array-brace-spacing]: 2
+warning[wordpress/enqueued-resource-parameters]: 2
+warning[wordpress/nonce-verification-warning]: 2
+warning[wordpress/slow-db-query]: 2
+error[wordpress/valid-variable-name]: 2
+error[generic/class-comment]: 1
+error[generic/disallow-short-ternary]: 1
+error[generic/disallow-size-functions-in-loops]: 1
+error[generic/doc-comment]: 1
+error[generic/empty-statement]: 1
+warning[generic/including-file-warning]: 1
+warning[generic/precision-alignment]: 1
+warning[generic/strict-comparisons]: 1
+error[wordpress/array-indentation]: 1
+warning[wordpress/get-meta-single]: 1
+warning[wordpress/preg-quote-delimiter]: 1
+warning[wordpress/safe-redirect]: 1
+warning[wordpress/strict-in-array]: 1
+error[wordpress/yoda-conditions]: 1
+```
+
+### Akismet
+
+```
+help[missing-docs]: 156
+error[wordpress/yoda-conditions]: 96
+warning[generic/strict-comparisons]: 88
+error[generic/inline-comment]: 79
+error[generic/function-comment]: 55
+warning[wordpress/direct-database-query]: 32
+error[wordpress/prefix-all-globals]: 32
+error[generic/file-comment]: 24
+warning[generic/unused-function-parameter]: 21
+warning[wordpress/strict-in-array]: 21
+warning[wordpress/nonce-verification-warning]: 19
+warning[generic/assignment-in-condition]: 10
+error[wordpress/file-name]: 10
+warning[wordpress/discouraged-wp-functions]: 8
+warning[generic/commented-out-code]: 4
+error[generic/disallow-multiple-assignments]: 4
+error[generic/doc-comment]: 4
+warning[wordpress/alternative-functions]: 4
+error[wordpress/prepared-sql]: 4
+error[generic/block-comment]: 3
+warning[wordpress/slow-db-query]: 3
+warning[generic/method-declaration-warning]: 2
+error[generic/no-double-negative]: 2
+warning[wordpress/enqueued-resource-parameters]: 2
+error[wordpress/nonce-verification]: 2
+warning[wordpress/prepared-sql-placeholders-warning]: 2
+error[generic/empty-statement]: 1
+error[generic/method-scope]: 1
+error[generic/separate-functions-from-oo]: 1
+error[wordpress/escape-output]: 1
+warning[wordpress/get-meta-single]: 1
+warning[wordpress/safe-redirect]: 1
+```
diff --git a/bench/run.sh b/bench/run.sh
index a29ef8f6..11ac635f 100755
--- a/bench/run.sh
+++ b/bench/run.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-# Benchmarks phpcs (WordPress-Extra) against mago + this extension on a WordPress codebase.
+# Benchmarks phpcs (WordPress) against mago + this extension on a WordPress codebase.
#
# bench/run.sh [] []
#
@@ -7,8 +7,11 @@
# in $PHPCS (defaults to vendor/bin/phpcs of this package if installed). Results print as
# a markdown table; paste them into docs/benchmarks.md.
#
-# Not an apples-to-apples rule comparison: phpcs runs the full WordPress-Extra ruleset
-# (WPCS 3.4.1), while mago only runs this extension's rules.
+# Rule coverage: phpcs runs the full WordPress standard (WPCS 3.4.1, 186 sniffs), and the mago side
+# runs the extension's default rules, which port 183 of those sniffs. The three left out are
+# Generic.PHP.Syntax (php -l), Generic.Classes.DuplicateClassName and WordPress.Utils.I18nTextDomainFixer.
+# Both run on PHP with opcache.enable_cli=1 (the worker command and phpcs alike), so neither tool
+# pays for compiling its scripts on every run.
set -euo pipefail
[[ $# -ge 1 ]] || { echo "usage: bench/run.sh [] []" >&2; exit 1; }
@@ -37,7 +40,7 @@ $xml = fn (string $value): string => htmlspecialchars($value, ENT_QUOTES | ENT_X
file_put_contents("$work/phpcs.xml", <<
-
+
@@ -48,7 +51,7 @@ XML);
# The worker discovers composer.json via getcwd(), so mago must run from $work.
run_mago() { (cd "$work" && "$mago" --config "$work/mago.toml" lint --only "$codes" --reporting-format code-count); }
run_phpcs() {
- "$phpcs" --standard="$work/phpcs.xml" --extensions=php \
+ php -d opcache.enable_cli=1 "$phpcs" --standard="$work/phpcs.xml" --extensions=php \
--ignore='*/vendor/*,*/vendor_prefixed/*,*/node_modules/*,*/tests/*' \
--parallel=8 -d memory_limit=2G --report=summary "$project"
}
@@ -109,5 +112,5 @@ timed() { # label, tool
}
echo "| Tool | Mean of 3 runs |"
echo "|:---|---:|"
-timed "phpcs WordPress-Extra (WPCS 3.4.1, --parallel=8)" phpcs
+timed "phpcs WordPress (WPCS 3.4.1, --parallel=8)" phpcs
timed "mago + mago-wordpress" mago
diff --git a/bin/generate-presets.php b/bin/generate-presets.php
index acb73399..fd1588d8 100644
--- a/bin/generate-presets.php
+++ b/bin/generate-presets.php
@@ -166,7 +166,7 @@
TOML . $disabled($off) . <<
Lint time, seconds (lower is better)
-phpcs WordPress-Extra
+phpcs WordPress
mago + mago-wordpress
0
-
-10
+
+5
+
+10
-20
-
-30
+15
+
+20
+
+25
-40
+30
WooCommerce
-
-33.40 s
-
-3.32 s (10× faster)
+
+26.33 s
+
+12.64 s (2.1× faster)
WordPress core
-
-25.89 s
-
-3.78 s (7× faster)
+
+18.14 s
+
+14.63 s (1.2× faster)
Elementor
-
-13.02 s
-
-1.35 s (10× faster)
+
+10.61 s
+
+4.96 s (2.1× faster)
Google Site Kit
-
-15.92 s
-
-1.33 s (12× faster)
+
+12.47 s
+
+5.40 s (2.3× faster)
Yoast SEO
-
-12.32 s
-
-1.25 s (10× faster)
+
+9.72 s
+
+3.59 s (2.7× faster)
WPForms Lite
-
-11.11 s
-
-1.68 s (7× faster)
+
+7.97 s
+
+5.32 s (1.5× faster)
LiteSpeed Cache
-
-2.53 s
-
-0.62 s (4× faster)
+
+2.19 s
+
+2.04 s (1.1× faster)
WP Mail SMTP
-
-2.35 s
-
-0.56 s (4× faster)
+
+1.79 s
+
+1.68 s (1.1× faster)
All-in-One WP Migration
-
-1.67 s
-
-0.35 s (5× faster)
+
+1.56 s
+
+0.74 s (2.1× faster)
Contact Form 7
-
-1.40 s
-
-0.34 s (4× faster)
+
+1.15 s
+
+0.89 s (1.3× faster)
Akismet
-
-0.52 s
-
-0.26 s (2× faster)
-
+
+0.70 s
+
+0.57 s (1.2× faster)
diff --git a/docs/benchmarks-light.svg b/docs/benchmarks-light.svg
index 5bf1e5a9..073a653b 100644
--- a/docs/benchmarks-light.svg
+++ b/docs/benchmarks-light.svg
@@ -1,72 +1,75 @@
diff --git a/docs/benchmarks.md b/docs/benchmarks.md
index a2765497..57c21061 100644
--- a/docs/benchmarks.md
+++ b/docs/benchmarks.md
@@ -1,42 +1,49 @@
# Benchmarks
-`bench/run.sh ` times phpcs (`WordPress-Extra`, WPCS 3.4.1,
-`--parallel=8`) against `mago lint` running only this extension's rules. Each figure is the mean of
-three runs after a warm-up, on the same machine (Apple M4 MacBook Air, PHP 8.4.24, Mago 1.51.0).
+`bench/run.sh ` times phpcs (`WordPress`, WPCS 3.4.1,
+`--parallel=8`) against `mago lint` running this extension's default rules and Mago's `missing-docs` (the WordPress-Docs sniffs). Each figure is the mean of
+three runs after a warm-up, on the same machine (Apple M4 MacBook Air, PHP 8.4.24, Mago 1.57.0). Both
+tools run on PHP with `opcache.enable_cli=1`.
-The comparison isn't apples to apples. `WordPress-Extra` also runs WPCS's formatting and generic
-sniffs, while the mago side runs this extension's rules only.
+The rule sets match closely. The extension ports 183 of the 186 sniffs in the `WordPress` standard.
+The three left out are `Generic.PHP.Syntax` (which runs `php -l`), `Generic.Classes.DuplicateClassName`
+and `WordPress.Utils.I18nTextDomainFixer`. Mago also parses each file itself, which phpcs does not.
The bake-off covers the top 10 plugins on WordPress.org by active installs, plus WordPress core
(`src/` from [wordpress-develop](https://github.com/WordPress/wordpress-develop), text domain
`default`, prefix `wp`). WPCS ships a narrower `WordPress-Core` ruleset for core, but the table runs
-the same `WordPress-Extra` comparison throughout, for consistency. `classic-editor`, next in
+the same `WordPress` comparison throughout, for consistency. `classic-editor`, next in
active-install rank, was skipped (1 PHP file after excludes) in favour of `wp-mail-smtp`, the next
plugin down the list.
-| Codebase | Version | Active installs | PHP files | phpcs `WordPress-Extra` | `mago lint` + this extension | Speed-up |
+| Codebase | Version | Active installs | PHP files | phpcs `WordPress` | `mago lint` + this extension | Speed-up |
|:---|:---|---:|---:|---:|---:|---:|
-| WooCommerce | 11.1.2 | 7,000,000+ | 3,528 | 33.40 s | 3.32 s | 10.1× |
-| WordPress core | trunk | n/a¹ | 1,868 | 25.89 s | 3.78 s | 6.8× |
-| Elementor | 4.3.2 | 10,000,000+ | 1,460 | 13.02 s | 1.35 s | 9.6× |
-| Google Site Kit | 1.188.0 | 5,000,000+ | 1,869 | 15.92 s | 1.33 s | 12.0× |
-| Yoast SEO | 28.5 | 10,000,000+ | 1,511 | 12.32 s | 1.25 s | 9.9× |
-| WPForms Lite | 2.0.2.1 | 5,000,000+ | 963 | 11.11 s | 1.68 s | 6.6× |
-| LiteSpeed Cache | 7.9.1 | 7,000,000+ | 212 | 2.53 s | 0.62 s | 4.1× |
-| WP Mail SMTP | 4.9.0 | 4,000,000+ | 185 | 2.35 s | 0.56 s | 4.2× |
-| All-in-One WP Migration | 7.111 | 5,000,000+ | 147 | 1.67 s | 0.35 s | 4.8× |
-| Contact Form 7 | 6.1.7 | 10,000,000+ | 111 | 1.40 s | 0.34 s | 4.1× |
-| Akismet | 5.7.2 | 5,000,000+ | 29 | 0.52 s | 0.26 s | 2.0× |
-| **Total** | | | **11,883** | **120.13 s** | **14.84 s** | **8.1×** |
+| WooCommerce | 11.1.2 | 7,000,000+ | 3,528 | 26.33 s | 12.64 s | 2.1× |
+| WordPress core | trunk | n/a¹ | 1,868 | 18.14 s | 14.63 s | 1.2× |
+| Elementor | 4.3.2 | 10,000,000+ | 1,460 | 10.61 s | 4.96 s | 2.1× |
+| Google Site Kit | 1.188.0 | 5,000,000+ | 1,869 | 12.47 s | 5.40 s | 2.3× |
+| Yoast SEO | 28.5 | 10,000,000+ | 1,511 | 9.72 s | 3.59 s | 2.7× |
+| WPForms Lite | 2.0.2.1 | 5,000,000+ | 963 | 7.97 s | 5.32 s | 1.5× |
+| LiteSpeed Cache | 7.9.1 | 7,000,000+ | 212 | 2.19 s | 2.04 s | 1.1× |
+| WP Mail SMTP | 4.9.0 | 4,000,000+ | 185 | 1.79 s | 1.68 s | 1.1× |
+| All-in-One WP Migration | 7.111 | 5,000,000+ | 147 | 1.56 s | 0.74 s | 2.1× |
+| Contact Form 7 | 6.1.7 | 10,000,000+ | 111 | 1.15 s | 0.89 s | 1.3× |
+| Akismet | 5.7.2 | 5,000,000+ | 29 | 0.70 s | 0.57 s | 1.2× |
+| **Total** | | | **11,883** | **92.63 s** | **52.46 s** | **1.8×** |
¹ WordPress.org publishes active-install counts for plugins only; core has no equivalent figure.
-Measured 2026-10-01 on the plugins' release zips (vendor and tests excluded) and a wordpress-develop
-checkout, with `mago` at 1.51.0, this package at 1.2.0 and the rules that are on by default. phpcs
-suppression comments are honoured. Load average was 5–9 from background services, so expect some
-noise. The mago column includes starting the PHP worker. mago never lost a single-codebase comparison.
+Measured 2026-10-20 on the plugins' release zips (vendor and tests excluded) and a wordpress-develop
+checkout, with `mago` at 1.57.0, this package at 1.6.0 plus the speed-ups in this release and the rules
+that are on by default. phpcs suppression comments are honoured. Load average was 4–8 from background
+services, so expect some noise. The mago column includes starting the PHP worker.
+
+The speed-up is 2.1-2.7× on the large plugins (WordPress core, 1.2×, is the exception) and about even
+on the small ones, where starting the worker processes is a large share of the time. The extension has
+about four times as many rules as the 46 that the first bake-off (2026-10-01, 8.1×) measured, so those
+figures are not comparable with these.
Full output, per-codebase mago issue counts by rule and exact reproduction commands are in
-[`bench/results/2026-10-01-bakeoff.md`](../bench/results/2026-10-01-bakeoff.md). Every rule PR
+[`bench/results/2026-10-20-bakeoff.md`](../bench/results/2026-10-20-bakeoff.md). Every rule PR
re-runs the bake-off (`bench/bakeoff.sh`) and commits the per-rule counts next to it; the newest
`*-bakeoff.md` in [`bench/results/`](../bench/results/) has the current counts.
diff --git a/src/Internal/BlankLinesAfterComment.php b/src/Internal/BlankLinesAfterComment.php
index f44c398c..3a926fda 100644
--- a/src/Internal/BlankLinesAfterComment.php
+++ b/src/Internal/BlankLinesAfterComment.php
@@ -39,7 +39,7 @@ public static function range(string $text, int $end, string $eol, array $closers
return null;
}
- $lineStart = strrpos(substr($text, 0, $next), "\n");
+ $lineStart = Strings::lastNewlineBefore($text, $next);
$from = $end + (str_starts_with(substr($text, $end, 2), $eol) ? strlen($eol) : 0);
$to = $lineStart === false ? 0 : $lineStart + 1;
diff --git a/src/Internal/Calls.php b/src/Internal/Calls.php
index 91d7f465..f06a4fba 100644
--- a/src/Internal/Calls.php
+++ b/src/Internal/Calls.php
@@ -261,7 +261,19 @@ public static function argument(
}
}
- return self::positionalArguments($file, $call)[$index] ?? null;
+ // The $index-th positional argument, unwrapped alone rather than every argument of the call.
+ $position = 0;
+ foreach ($call->arguments as $argument) {
+ if ($argument->unpacked || $argument->name !== null) {
+ continue;
+ }
+
+ if ($position++ === $index) {
+ return Values::unwrap($file, $argument->value);
+ }
+ }
+
+ return null;
}
/**
diff --git a/src/Internal/DocBlockScan.php b/src/Internal/DocBlockScan.php
index 962d30be..a078f2b2 100644
--- a/src/Internal/DocBlockScan.php
+++ b/src/Internal/DocBlockScan.php
@@ -67,7 +67,7 @@ public static function previous(array $t, int $from, int $to, array $codes, bool
/** @param array{code: string, text: string, offset: int, line: int} $token The column (1-based, a tab counting as one) of a token. */
public static function column(array $token, string $source): int
{
- $lineStart = strrpos(substr($source, 0, $token['offset']), "\n");
+ $lineStart = Strings::lastNewlineBefore($source, $token['offset']);
return $token['offset'] - ($lineStart === false ? -1 : $lineStart);
}
@@ -79,7 +79,7 @@ public static function column(array $token, string $source): int
*/
public static function visualColumn(array $token, string $source): int
{
- $lineStart = strrpos(substr($source, 0, $token['offset']), "\n");
+ $lineStart = Strings::lastNewlineBefore($source, $token['offset']);
$prefix = substr(
$source,
$lineStart === false ? 0 : $lineStart + 1,
diff --git a/src/Internal/FileTokens.php b/src/Internal/FileTokens.php
new file mode 100644
index 00000000..8fe65dab
--- /dev/null
+++ b/src/Internal/FileTokens.php
@@ -0,0 +1,25 @@
+ */
+ public static function raw(SourceFile $file): array
+ {
+ return FileCache::remember($file, 'php-tokens', static fn(): array => PhpToken::tokenize($file->contents));
+ }
+}
diff --git a/src/Internal/ItemShift.php b/src/Internal/ItemShift.php
index 7be661ef..db072311 100644
--- a/src/Internal/ItemShift.php
+++ b/src/Internal/ItemShift.php
@@ -55,7 +55,7 @@ public static function edits(PhpcsTokens $tokens, string $text, int $start, int
/** What the new item indent adds to the indent of the line the item starts on. */
private static function delta(string $text, int $start, string $indent): string
{
- $newline = strrpos(substr($text, 0, $start), "\n");
+ $newline = Strings::lastNewlineBefore($text, $start);
$lineStart = $newline === false ? 0 : $newline + 1;
$lead = substr($text, $lineStart, $start - $lineStart);
if (trim($lead) !== '') {
diff --git a/src/Internal/JoinedStatement.php b/src/Internal/JoinedStatement.php
index b434e0d1..a1c7f6ae 100644
--- a/src/Internal/JoinedStatement.php
+++ b/src/Internal/JoinedStatement.php
@@ -80,7 +80,7 @@ private static function strip(PhpcsTokens $tokens, string $text, int $start, int
/** The whitespace at the start of the line that holds $offset. */
private static function indentOf(string $text, int $offset): string
{
- $newline = strrpos(substr($text, 0, $offset), "\n");
+ $newline = Strings::lastNewlineBefore($text, $offset);
$start = $newline === false ? 0 : $newline + 1;
$line = substr($text, $start, $offset - $start);
diff --git a/src/Internal/LeadingComment.php b/src/Internal/LeadingComment.php
index 5b643c7b..c10e065c 100644
--- a/src/Internal/LeadingComment.php
+++ b/src/Internal/LeadingComment.php
@@ -9,6 +9,8 @@
use Mago\Sdk\Syntax\Trivia;
use Mago\Sdk\Syntax\TriviaKind;
+use function strlen;
+use function strpbrk;
use function strrpos;
use function substr;
use function trim;
@@ -33,29 +35,18 @@ public static function before(SourceFile $file, int $start): ?array
{
$text = $file->contents;
$end = $start;
- while ($end > 0 && trim($text[$end - 1]) === '') {
+ while ($end > 0 && strpbrk($text[$end - 1], " \t\n\r\v\0") !== false) {
--$end;
}
- $found = null;
- foreach (self::trivia($file) as $trivia) {
- if ($trivia->span->end === $end) {
- $found = $trivia;
-
- break;
- }
-
- if ($trivia->span->end > $end) {
- break;
- }
- }
+ $found = self::trivia($file)[$end] ?? null;
if ($found === null) {
return null;
}
if ($found->kind !== TriviaKind::DocBlockComment) {
- $lineStart = strrpos(substr($text, 0, $found->span->start), "\n");
+ $lineStart = Strings::lastNewlineBefore($text, $found->span->start);
$from = $lineStart === false ? 0 : $lineStart + 1;
if (trim(substr($text, $from, $found->span->start - $from)) !== '') {
return null;
@@ -65,14 +56,22 @@ public static function before(SourceFile $file, int $start): ?array
return ['kind' => $found->kind, 'span' => $found->span];
}
- /** @return list */
+ /**
+ * The file's comments by the offset they end at.
+ *
+ * @return array
+ */
private static function trivia(SourceFile $file): array
{
return FileCache::remember($file, 'leading-comment-trivia', static function () use ($file): array {
$trivia = $file->getTrivia();
usort($trivia, static fn($a, $b): int => $a->span->start <=> $b->span->start);
+ $byEnd = [];
+ foreach ($trivia as $piece) {
+ $byEnd[$piece->span->end] ??= $piece;
+ }
- return $trivia;
+ return $byEnd;
});
}
}
diff --git a/src/Internal/LineIndents.php b/src/Internal/LineIndents.php
index c882c4eb..e4f061d0 100644
--- a/src/Internal/LineIndents.php
+++ b/src/Internal/LineIndents.php
@@ -50,7 +50,7 @@ final class LineIndents
*/
public static function of(SourceFile $file): array
{
- return FileCache::remember($file, 'line-indents', static fn(): array => self::build($file->contents));
+ return FileCache::remember($file, 'line-indents', static fn(): array => self::build($file));
}
/** The display width of $whitespace starting at column 0, tabs going to the next tab stop. */
@@ -67,10 +67,11 @@ public static function width(string $whitespace): int
/**
* @return list
*/
- private static function build(string $text): array
+ private static function build(SourceFile $file): array
{
+ $text = $file->contents;
$found = [];
- foreach (PhpToken::tokenize($text) as $token) {
+ foreach (FileTokens::raw($file) as $token) {
$id = $token->id;
if ($id === T_WHITESPACE) {
self::lines($found, $text, $token, self::WHITESPACE, firstLineToo: true);
diff --git a/src/Internal/PhpText.php b/src/Internal/PhpText.php
index 35ac1b42..53752ad0 100644
--- a/src/Internal/PhpText.php
+++ b/src/Internal/PhpText.php
@@ -8,7 +8,6 @@
use function strlen;
use function strpos;
use function substr;
-use function substr_count;
/**
* Scanning over PHP source text without tokenizing it.
@@ -35,12 +34,6 @@ public static function skipAttributes(string $text, int $offset): int
return PhpAttributes::skip($text, $offset);
}
- /** The zero-based line of an offset. */
- public static function line(string $text, int $offset): int
- {
- return substr_count($text, "\n", 0, $offset);
- }
-
/**
* Where the docblock opening at $start ends: the offset of its closing tag (which takes the
* stars in front of the slash, as in `**\/`) and the offset after it. NULL when it never closes.
diff --git a/src/Internal/PhpcsComments.php b/src/Internal/PhpcsComments.php
index 761de485..dabae429 100644
--- a/src/Internal/PhpcsComments.php
+++ b/src/Internal/PhpcsComments.php
@@ -15,7 +15,6 @@
use function strlen;
use function strtolower;
use function substr;
-use function substr_count;
use function trim;
use function usort;
@@ -38,6 +37,12 @@ private function __construct() {}
* @return list
*/
public static function tokens(SourceFile $file): array
+ {
+ return FileCache::remember($file, 'phpcs-comments', static fn(): array => self::build($file));
+ }
+
+ /** @return list */
+ private static function build(SourceFile $file): array
{
$trivia = $file->getTrivia();
usort($trivia, static fn($a, $b): int => $a->span->start <=> $b->span->start);
@@ -47,7 +52,7 @@ public static function tokens(SourceFile $file): array
$start = $comment->span->start;
$adjacent = $end !== null && trim(substr($file->contents, $end, $start - $end)) === '';
$end = $comment->span->end;
- $line = substr_count($file->contents, "\n", 0, $start) + 1;
+ $line = LineIndex::of($file, $start);
$text = $file->getText($comment->span);
if ($comment->kind === TriviaKind::DocBlockComment) {
$tokens[] = [
diff --git a/src/Internal/ScopeColon.php b/src/Internal/ScopeColon.php
index 012b5689..e14ca65d 100644
--- a/src/Internal/ScopeColon.php
+++ b/src/Internal/ScopeColon.php
@@ -26,7 +26,7 @@ public static function at(string $text, int $colon): bool
return false;
}
- $lineStart = strrpos(substr($text, 0, $colon), "\n");
+ $lineStart = Strings::lastNewlineBefore($text, $colon);
$line = substr(
$text,
$lineStart === false ? 0 : $lineStart + 1,
diff --git a/src/Internal/Strings.php b/src/Internal/Strings.php
index 0308e527..94f6509b 100644
--- a/src/Internal/Strings.php
+++ b/src/Internal/Strings.php
@@ -23,6 +23,7 @@
use function str_ends_with;
use function str_starts_with;
use function strlen;
+use function strrpos;
use function strtolower;
use function strtr;
use function substr;
@@ -226,4 +227,10 @@ private static function codepointToUtf8(int $codepoint): string
. chr(0x80 | ($codepoint & 0x3F))
);
}
+
+ /** The offset of the last "\n" before $offset, as `strrpos(substr($text, 0, $offset), "\n")` finds it, without copying the text. */
+ public static function lastNewlineBefore(string $text, int $offset): int|false
+ {
+ return $offset === 0 ? false : strrpos($text, "\n", $offset - strlen($text) - 1);
+ }
}
diff --git a/src/Linter/Rules/ArrayAlignmentRule.php b/src/Linter/Rules/ArrayAlignmentRule.php
index 5cede176..8d35246f 100644
--- a/src/Linter/Rules/ArrayAlignmentRule.php
+++ b/src/Linter/Rules/ArrayAlignmentRule.php
@@ -14,6 +14,7 @@
use Mago\Sdk\Syntax\Node;
use Mago\Sdk\Syntax\NodeKind;
use Mago\Sdk\Syntax\SourceFile;
+use Rlorenzo\MagoWordPress\Internal\LineIndex;
use Rlorenzo\MagoWordPress\Internal\Report;
use function max;
@@ -24,7 +25,6 @@
use function strpos;
use function strrpos;
use function substr;
-use function substr_count;
use function trim;
/**
@@ -50,6 +50,13 @@ final class ArrayAlignmentRule implements Rule
private const TAB_WIDTH = 4;
+ /**
+ * The last answer of width(): the file, the offset and the width there.
+ *
+ * @var array{SourceFile, int, int}|null
+ */
+ private static ?array $widthMemo = null;
+
public function __construct(
private readonly Report $report,
) {}
@@ -222,27 +229,44 @@ private function fix(LintContext $context, array $item, int $spaces, string $cod
);
}
- /** The display width of the line's text up to $offset, tabs counted to the next multiple of 4. */
+ /**
+ * The display width of the line's text up to $offset, tabs counted to the next multiple of 4.
+ *
+ * Items on one very long line ask for increasing offsets, so the last answer is kept and the
+ * next one continues from it instead of rescanning the line.
+ */
private static function width(SourceFile $file, int $offset): int
{
- // A negative offset searches backwards from that many bytes before the end: no copy of the text before it.
- $newline = $offset === 0
- ? false
- : strrpos($file->contents, needle: "\n", offset: $offset - strlen($file->contents) - 1);
- $start = $newline === false ? 0 : $newline + 1;
- $width = 0;
- for ($i = $start; $i < $offset; ++$i) {
+ $memo = self::$widthMemo;
+ if (
+ $memo !== null
+ && $memo[0] === $file
+ && $memo[1] <= $offset
+ && !str_contains(substr($file->contents, $memo[1], $offset - $memo[1]), "\n")
+ ) {
+ [, $i, $width] = $memo;
+ } else {
+ // A negative offset searches backwards from that many bytes before the end: no copy of the text before it.
+ $newline = $offset === 0
+ ? false
+ : strrpos($file->contents, needle: "\n", offset: $offset - strlen($file->contents) - 1);
+ $i = $newline === false ? 0 : $newline + 1;
+ $width = 0;
+ }
+
+ for (; $i < $offset; ++$i) {
// Characters, not bytes, as phpcs counts (a UTF-8 continuation byte is no column).
$width += $file->contents[$i] === "\t"
? self::TAB_WIDTH - ($width % self::TAB_WIDTH)
: (int) ((ord($file->contents[$i]) & 0xC0) !== 0x80);
}
+ self::$widthMemo = [$file, $offset, $width];
return $width;
}
private static function line(SourceFile $file, int $offset): int
{
- return substr_count($file->contents, needle: "\n", offset: 0, length: $offset);
+ return LineIndex::of($file, $offset);
}
}
diff --git a/src/Linter/Rules/ClassCommentRule.php b/src/Linter/Rules/ClassCommentRule.php
index 784ce3cc..efea7820 100644
--- a/src/Linter/Rules/ClassCommentRule.php
+++ b/src/Linter/Rules/ClassCommentRule.php
@@ -13,6 +13,7 @@
use Mago\Sdk\Syntax\NodeKind;
use Mago\Sdk\Syntax\TriviaKind;
use Rlorenzo\MagoWordPress\Internal\LeadingComment;
+use Rlorenzo\MagoWordPress\Internal\LineIndex;
use Rlorenzo\MagoWordPress\Internal\PhpText;
use Rlorenzo\MagoWordPress\Internal\Report;
@@ -67,7 +68,7 @@ public function lint(LintContext $context): void
}
}
- if (PhpText::line($text, self::tokenAbove($text, $keyword) - 1) === (PhpText::line($text, $keyword) - 1)) {
+ if (LineIndex::of($file, self::tokenAbove($text, $keyword) - 1) === (LineIndex::of($file, $keyword) - 1)) {
return;
}
diff --git a/src/Linter/Rules/ClassInstantiationRule.php b/src/Linter/Rules/ClassInstantiationRule.php
index dd28c14b..d881f25f 100644
--- a/src/Linter/Rules/ClassInstantiationRule.php
+++ b/src/Linter/Rules/ClassInstantiationRule.php
@@ -13,6 +13,7 @@
use Mago\Sdk\Span;
use Mago\Sdk\Syntax\NodeKind;
use PhpToken;
+use Rlorenzo\MagoWordPress\Internal\FileTokens;
use Rlorenzo\MagoWordPress\Internal\Report;
use function count;
@@ -92,7 +93,7 @@ public function lint(LintContext $context): void
}
/** @var list $tokens */
- $tokens = PhpToken::tokenize($contents);
+ $tokens = FileTokens::raw($context->file);
$count = count($tokens);
foreach ($tokens as $index => $token) {
if ($token->id !== T_NEW || $this->isIdentifier($tokens, $index)) {
diff --git a/src/Linter/Rules/DisallowInlineTabsRule.php b/src/Linter/Rules/DisallowInlineTabsRule.php
index b906a069..c126c7ef 100644
--- a/src/Linter/Rules/DisallowInlineTabsRule.php
+++ b/src/Linter/Rules/DisallowInlineTabsRule.php
@@ -13,6 +13,7 @@
use Mago\Sdk\Span;
use Mago\Sdk\Syntax\NodeKind;
use PhpToken;
+use Rlorenzo\MagoWordPress\Internal\FileTokens;
use Rlorenzo\MagoWordPress\Internal\Report;
use function explode;
@@ -75,7 +76,7 @@ public function lint(LintContext $context): void
return;
}
- foreach (PhpToken::tokenize($text) as $token) {
+ foreach (FileTokens::raw($context->file) as $token) {
if (!str_contains($token->text, "\t") || $token->id === T_INLINE_HTML) {
continue;
}
diff --git a/src/Linter/Rules/DisallowLonelyIfRule.php b/src/Linter/Rules/DisallowLonelyIfRule.php
index 365c87b4..55658a65 100644
--- a/src/Linter/Rules/DisallowLonelyIfRule.php
+++ b/src/Linter/Rules/DisallowLonelyIfRule.php
@@ -15,6 +15,7 @@
use Mago\Sdk\Syntax\NodeKind;
use Mago\Sdk\Syntax\SourceFile;
use Rlorenzo\MagoWordPress\Internal\Report;
+use Rlorenzo\MagoWordPress\Internal\Strings;
use function array_slice;
use function count;
@@ -153,7 +154,7 @@ private static function fix(SourceFile $file, Node $else, Node $block, Node $inn
$condition = rtrim($between($ifKeyword->span->end, $innerOpen->span->start));
// The inner `if` line goes whole when it has a line of its own.
- $newlineBefore = strrpos(substr($contents, offset: 0, length: $inner->span->start), needle: "\n");
+ $newlineBefore = Strings::lastNewlineBefore($contents, $inner->span->start);
$lineStart = $newlineBefore === false ? 0 : $newlineBefore + 1;
$from = $lineStart > $block->span->start ? $lineStart : $inner->span->start;
$to = $innerOpen->span->start + 1;
diff --git a/src/Linter/Rules/DisallowMultipleStatementsRule.php b/src/Linter/Rules/DisallowMultipleStatementsRule.php
index d2bac514..749fb3ab 100644
--- a/src/Linter/Rules/DisallowMultipleStatementsRule.php
+++ b/src/Linter/Rules/DisallowMultipleStatementsRule.php
@@ -13,6 +13,7 @@
use Mago\Sdk\Span;
use Mago\Sdk\Syntax\NodeKind;
use PhpToken;
+use Rlorenzo\MagoWordPress\Internal\FileTokens;
use Rlorenzo\MagoWordPress\Internal\Layout;
use Rlorenzo\MagoWordPress\Internal\Report;
@@ -61,7 +62,7 @@ public function lint(LintContext $context): void
{
$contents = $context->file->contents;
/** @var list $tokens */
- $tokens = PhpToken::tokenize($contents);
+ $tokens = FileTokens::raw($context->file);
$newline = strpos($contents, "\n");
$eol = $newline !== false && $newline > 0 && $contents[$newline - 1] === "\r" ? "\r\n" : "\n";
$count = count($tokens);
diff --git a/src/Linter/Rules/EmptyCatchCommentRule.php b/src/Linter/Rules/EmptyCatchCommentRule.php
index ae67911f..5aa0f2d7 100644
--- a/src/Linter/Rules/EmptyCatchCommentRule.php
+++ b/src/Linter/Rules/EmptyCatchCommentRule.php
@@ -11,6 +11,7 @@
use Mago\Sdk\Reporting\Level;
use Mago\Sdk\Span;
use Mago\Sdk\Syntax\NodeKind;
+use Rlorenzo\MagoWordPress\Internal\FileGate;
use Rlorenzo\MagoWordPress\Internal\PhpcsTokens;
use Rlorenzo\MagoWordPress\Internal\Report;
@@ -26,9 +27,13 @@ final class EmptyCatchCommentRule implements Rule
{
private const SNIFF = 'Squiz.Commenting.EmptyCatchComment';
+ private readonly FileGate $gate;
+
public function __construct(
private readonly Report $report,
- ) {}
+ ) {
+ $this->gate = FileGate::forWords(['catch']);
+ }
public function getDefinition(): RuleDefinition
{
@@ -44,6 +49,10 @@ public function getDefinition(): RuleDefinition
public function lint(LintContext $context): void
{
+ if (!$this->gate->passes($context->file)) {
+ return;
+ }
+
$tokens = PhpcsTokens::of($context->file);
$text = $context->file->contents;
foreach ($tokens->codes as $index => $code) {
diff --git a/src/Linter/Rules/EndFileNewlineRule.php b/src/Linter/Rules/EndFileNewlineRule.php
index 73314c26..b283b5bb 100644
--- a/src/Linter/Rules/EndFileNewlineRule.php
+++ b/src/Linter/Rules/EndFileNewlineRule.php
@@ -12,7 +12,9 @@
use Mago\Sdk\Reporting\TextEdit;
use Mago\Sdk\Span;
use Mago\Sdk\Syntax\NodeKind;
+use Mago\Sdk\Syntax\SourceFile;
use PhpToken;
+use Rlorenzo\MagoWordPress\Internal\FileTokens;
use Rlorenzo\MagoWordPress\Internal\Report;
use function rtrim;
@@ -66,7 +68,7 @@ public function lint(LintContext $context): void
return;
}
- if (str_contains($text, '?>') && self::hasHtmlAfterOpenTag($text)) {
+ if (str_contains($text, '?>') && self::hasHtmlAfterOpenTag($context->file)) {
return;
}
@@ -105,10 +107,10 @@ private static function isBlank(string $char): bool
return $char === ' ' || $char === "\t" || $char === "\r" || $char === "\n";
}
- private static function hasHtmlAfterOpenTag(string $text): bool
+ private static function hasHtmlAfterOpenTag(SourceFile $file): bool
{
$open = false;
- foreach (PhpToken::tokenize($text) as $token) {
+ foreach (FileTokens::raw($file) as $token) {
if ($token->id === T_OPEN_TAG || $token->id === T_OPEN_TAG_WITH_ECHO) {
$open = true;
} elseif ($open && $token->id === T_INLINE_HTML) {
diff --git a/src/Linter/Rules/EscapeOutputRule.php b/src/Linter/Rules/EscapeOutputRule.php
index 92fe5dc9..b7ecf237 100644
--- a/src/Linter/Rules/EscapeOutputRule.php
+++ b/src/Linter/Rules/EscapeOutputRule.php
@@ -15,6 +15,7 @@
use Rlorenzo\MagoWordPress\Internal\PhpcsToken;
use Rlorenzo\MagoWordPress\Internal\PhpcsTokenStream;
use Rlorenzo\MagoWordPress\Internal\Report;
+use Rlorenzo\MagoWordPress\Internal\Spacing;
use Rlorenzo\MagoWordPress\Internal\Strings;
use Rlorenzo\MagoWordPress\Internal\WordPress\Lists;
use Rlorenzo\MagoWordPress\Settings;
@@ -212,7 +213,7 @@ public function lint(LintContext $context): void
}
$this->context = $context;
- $this->tokens = PhpcsTokenStream::fromSource($context->file->contents);
+ $this->tokens = Spacing::tokens($context);
$count = count($this->tokens);
$ignoreTo = null;
for ($ptr = 0; $ptr < $count; $ptr++) {
diff --git a/src/Linter/Rules/FileCommentRule.php b/src/Linter/Rules/FileCommentRule.php
index 4beae68d..6add5d5f 100644
--- a/src/Linter/Rules/FileCommentRule.php
+++ b/src/Linter/Rules/FileCommentRule.php
@@ -13,6 +13,7 @@
use Mago\Sdk\Syntax\NodeKind;
use Rlorenzo\MagoWordPress\Internal\DocComment;
use Rlorenzo\MagoWordPress\Internal\FileCommentTags;
+use Rlorenzo\MagoWordPress\Internal\LineIndex;
use Rlorenzo\MagoWordPress\Internal\NodeIndex;
use Rlorenzo\MagoWordPress\Internal\PhpText;
use Rlorenzo\MagoWordPress\Internal\Report;
@@ -113,7 +114,7 @@ private function check(LintContext $context, Span $open): void
return;
}
- $line = static fn(int $offset): int => PhpText::line($text, $offset);
+ $line = static fn(int $offset): int => LineIndex::of($context->file, $offset);
if ($line($start) > ($line($open->start) + 1)) {
$this->add($context, 'There must be no blank lines before the file comment', $open, 'SpacingAfterOpen');
}
diff --git a/src/Linter/Rules/FileHeaderRule.php b/src/Linter/Rules/FileHeaderRule.php
index f83c57d1..013a4a73 100644
--- a/src/Linter/Rules/FileHeaderRule.php
+++ b/src/Linter/Rules/FileHeaderRule.php
@@ -14,6 +14,7 @@
use Mago\Sdk\Syntax\NodeKind;
use Mago\Sdk\Syntax\SourceFile;
use Mago\Sdk\Syntax\TriviaKind;
+use Rlorenzo\MagoWordPress\Internal\LineIndex;
use Rlorenzo\MagoWordPress\Internal\NodeIndex;
use Rlorenzo\MagoWordPress\Internal\Report;
@@ -25,7 +26,6 @@
use function preg_match;
use function stripos;
use function strtolower;
-use function substr_count;
use function usort;
/**
@@ -292,6 +292,6 @@ private static function following(SourceFile $file, array $statements, int $offs
private static function line(SourceFile $file, int $offset): int
{
- return substr_count($file->contents, needle: "\n", offset: 0, length: $offset) + 1;
+ return LineIndex::of($file, $offset);
}
}
diff --git a/src/Linter/Rules/FunctionCallSignatureRule.php b/src/Linter/Rules/FunctionCallSignatureRule.php
index 4732465c..8a60526e 100644
--- a/src/Linter/Rules/FunctionCallSignatureRule.php
+++ b/src/Linter/Rules/FunctionCallSignatureRule.php
@@ -15,6 +15,7 @@
use Mago\Sdk\Syntax\SourceFile;
use PhpToken;
use Rlorenzo\MagoWordPress\Internal\FileCache;
+use Rlorenzo\MagoWordPress\Internal\FileTokens;
use Rlorenzo\MagoWordPress\Internal\FunctionTokens;
use Rlorenzo\MagoWordPress\Internal\PhpcsToken;
use Rlorenzo\MagoWordPress\Internal\PhpcsTokenStream;
@@ -830,11 +831,7 @@ private static function bracket(PhpToken $token): int
/** @return list */
private static function tokens(SourceFile $file): array
{
- return FileCache::remember(
- $file,
- 'call-signature-tokens',
- static fn(): array => PhpToken::tokenize($file->contents),
- );
+ return FileTokens::raw($file);
}
/** @param list $tokens */
diff --git a/src/Linter/Rules/FunctionCommentRule.php b/src/Linter/Rules/FunctionCommentRule.php
index 07a86953..e633d4bc 100644
--- a/src/Linter/Rules/FunctionCommentRule.php
+++ b/src/Linter/Rules/FunctionCommentRule.php
@@ -18,6 +18,7 @@
use Rlorenzo\MagoWordPress\Internal\DocComment;
use Rlorenzo\MagoWordPress\Internal\LeadingComment;
use Rlorenzo\MagoWordPress\Internal\Report;
+use Rlorenzo\MagoWordPress\Internal\Strings;
use function count;
use function explode;
@@ -168,7 +169,7 @@ private function spacingAfter(LintContext $context, Span $comment, Node $functio
return;
}
- $lineStart = strrpos(substr($text, 0, $function->span->start), needle: "\n");
+ $lineStart = Strings::lastNewlineBefore($text, $function->span->start);
$declaration = $lineStart === false ? 0 : $lineStart + 1;
$this->add(
$context,
diff --git a/src/Linter/Rules/InlineControlStructureRule.php b/src/Linter/Rules/InlineControlStructureRule.php
index 61a2cc16..79f73184 100644
--- a/src/Linter/Rules/InlineControlStructureRule.php
+++ b/src/Linter/Rules/InlineControlStructureRule.php
@@ -15,6 +15,7 @@
use Mago\Sdk\Syntax\SourceFile;
use PhpToken;
use Rlorenzo\MagoWordPress\Internal\Report;
+use Rlorenzo\MagoWordPress\Internal\Strings;
use function array_filter;
use function count;
@@ -173,7 +174,7 @@ private static function fix(SourceFile $file, Node $node, Node $keyword, Node $b
/** The keyword line's indentation, or null when the line opens with inline HTML or an open tag. */
private static function indent(string $contents, int $offset): ?string
{
- $lineStart = strrpos(substr($contents, offset: 0, length: $offset), needle: "\n");
+ $lineStart = Strings::lastNewlineBefore($contents, $offset);
$lineStart = $lineStart === false ? 0 : $lineStart + 1;
$before = substr($contents, $lineStart, $offset - $lineStart);
$indent = substr($before, offset: 0, length: strspn($before, characters: " \t"));
diff --git a/src/Linter/Rules/NonExecutableCodeRule.php b/src/Linter/Rules/NonExecutableCodeRule.php
index d6dbeb27..d866a761 100644
--- a/src/Linter/Rules/NonExecutableCodeRule.php
+++ b/src/Linter/Rules/NonExecutableCodeRule.php
@@ -15,6 +15,8 @@
use Mago\Sdk\Syntax\SourceFile;
use PhpToken;
use Rlorenzo\MagoWordPress\Internal\FileCache;
+use Rlorenzo\MagoWordPress\Internal\FileTokens;
+use Rlorenzo\MagoWordPress\Internal\LineIndex;
use Rlorenzo\MagoWordPress\Internal\NodeIndex;
use Rlorenzo\MagoWordPress\Internal\Report;
@@ -29,7 +31,6 @@
use function rtrim;
use function strlen;
use function strtolower;
-use function substr_count;
use function trim;
/**
@@ -259,11 +260,7 @@ private function unreachable(LintContext $context, Node $terminal, Node $stateme
*/
private static function tokens(SourceFile $file): array
{
- return FileCache::remember(
- $file,
- 'non-executable-code-tokens',
- static fn(): array => PhpToken::tokenize($file->contents),
- );
+ return FileTokens::raw($file);
}
/**
@@ -328,6 +325,6 @@ private static function type(Node $terminal): string
private static function line(SourceFile $file, int $offset): int
{
- return substr_count($file->contents, needle: "\n", offset: 0, length: $offset) + 1;
+ return LineIndex::of($file, $offset);
}
}
diff --git a/src/Linter/Rules/SeparateFunctionsFromOORule.php b/src/Linter/Rules/SeparateFunctionsFromOORule.php
index 0e9f7111..f5065f16 100644
--- a/src/Linter/Rules/SeparateFunctionsFromOORule.php
+++ b/src/Linter/Rules/SeparateFunctionsFromOORule.php
@@ -12,12 +12,12 @@
use Mago\Sdk\Syntax\Node;
use Mago\Sdk\Syntax\NodeKind;
use Mago\Sdk\Syntax\SourceFile;
+use Rlorenzo\MagoWordPress\Internal\LineIndex;
use Rlorenzo\MagoWordPress\Internal\NodeIndex;
use Rlorenzo\MagoWordPress\Internal\Report;
use function count;
use function in_array;
-use function substr_count;
/**
* Ports `Universal.Files.SeparateFunctionsFromOO`: a file that declares both functions and
@@ -128,6 +128,6 @@ private static function keyword(SourceFile $file, Node $node): Node
private static function line(SourceFile $file, Node $node): int
{
- return substr_count($file->contents, needle: "\n", offset: 0, length: $node->span->start) + 1;
+ return LineIndex::of($file, $node->span->start);
}
}
diff --git a/src/Linter/Rules/SuperfluousWhitespaceRule.php b/src/Linter/Rules/SuperfluousWhitespaceRule.php
index ba7bcfcd..6791e316 100644
--- a/src/Linter/Rules/SuperfluousWhitespaceRule.php
+++ b/src/Linter/Rules/SuperfluousWhitespaceRule.php
@@ -13,11 +13,14 @@
use Mago\Sdk\Span;
use Mago\Sdk\Syntax\NodeKind;
use PhpToken;
+use Rlorenzo\MagoWordPress\Internal\FileTokens;
use Rlorenzo\MagoWordPress\Internal\Report;
use function array_pop;
use function count;
+use function ctype_space;
use function explode;
+use function ord;
use function preg_match;
use function sprintf;
use function strlen;
@@ -77,12 +80,16 @@ public function getDefinition(): RuleDefinition
public function lint(LintContext $context): void
{
$text = $context->file->contents;
- $tokens = PhpToken::tokenize($text);
+ // Empty lines are only looked for in a file with an empty line followed by a blank one.
+ $checkEmptyLines = preg_match('/\n\r?\n[ \t]*\r?\n/', $text) === 1;
+ if (!$checkEmptyLines && !self::mayHaveWhitespaceFindings($text)) {
+ return;
+ }
+
+ $tokens = FileTokens::raw($context->file);
$count = count($tokens);
$this->fileEdges($context, $tokens, $count);
- // Empty lines are only looked for in a file with an empty line followed by a blank one.
- $checkEmptyLines = preg_match('/\n\r?\n[ \t]*\r?\n/', $text) === 1;
$stack = [];
$pending = null;
$depth = 0;
@@ -145,6 +152,26 @@ public function lint(LintContext $context): void
}
}
+ /**
+ * Whether the text can have a finding other than empty lines: whitespace at either end of the file
+ * (a byte of 0x80 or more could start a Unicode space) or a space or tab before a line break or the
+ * end of the file. A file without any of them has nothing to tokenize for.
+ */
+ private static function mayHaveWhitespaceFindings(string $text): bool
+ {
+ if ($text === '') {
+ return false;
+ }
+
+ foreach ([$text[0], $text[strlen($text) - 1]] as $edge) {
+ if (ord($edge) >= 0x80 || ctype_space($edge)) {
+ return true;
+ }
+ }
+
+ return preg_match('/[ \t](?:\r?\n|\z)/', $text) === 1;
+ }
+
/**
* @param list $tokens
*/
diff --git a/src/WordPressExtension.php b/src/WordPressExtension.php
index c84e01f0..585c3ff4 100644
--- a/src/WordPressExtension.php
+++ b/src/WordPressExtension.php
@@ -201,7 +201,7 @@
*/
final class WordPressExtension
{
- private const VERSION = '1.4.0';
+ private const VERSION = '1.6.1';
private function __construct() {}
@@ -253,6 +253,8 @@ public static function create(?Settings $settings = null): Extension
new NullableTypeDeclarationRule($report),
new ScopeKeywordSpacingRule($report),
new SpreadOperatorSpacingAfterRule($report),
+ // Reads the shared token stream too, so it runs before DisallowMultipleAssignmentsRule frees it.
+ new EscapeOutputRule($report, $settings),
new DisallowMultipleAssignmentsRule($report),
new GlobalVariablesOverrideRule($report, $settings),
new EnqueuedResourceParametersRule($report),
@@ -266,7 +268,6 @@ public static function create(?Settings $settings = null): Extension
),
new PreparedSqlUnquotedComplexPlaceholderRule($report),
new SafeRedirectRule($report),
- new EscapeOutputRule($report, $settings),
...SplitRule::pair(
new ValidHookNameRule($report, $settings),
$report,
diff --git a/tests/Unit/WordPressExtensionTest.php b/tests/Unit/WordPressExtensionTest.php
index 7cc00985..22b7d247 100644
--- a/tests/Unit/WordPressExtensionTest.php
+++ b/tests/Unit/WordPressExtensionTest.php
@@ -44,7 +44,7 @@ public function testFactoryOwnsStableRegistration(): void
self::assertSame('rlorenzo/mago-wordpress', $extension->identifier);
self::assertSame('WordPress', $extension->name);
- self::assertSame('1.4.0', $extension->version);
+ self::assertSame('1.6.1', $extension->version);
self::assertCount(0, $extension->analyzerPlugins);
self::assertNull($extension->workerReducer);
}
diff --git a/wordpress-core.mago.toml b/wordpress-core.mago.toml
index d08a24f3..6086a600 100644
--- a/wordpress-core.mago.toml
+++ b/wordpress-core.mago.toml
@@ -15,4 +15,4 @@ extends = "wordpress.mago.toml"
missing-docs = { enabled = false }
[extension-hosts.wordpress]
-command = ["php", "vendor/rlorenzo/mago-wordpress/resources/worker.php", "--standard=WordPress-Core"]
+command = ["php", "-d", "opcache.enable_cli=1", "vendor/rlorenzo/mago-wordpress/resources/worker.php", "--standard=WordPress-Core"]
diff --git a/wordpress-extra.mago.toml b/wordpress-extra.mago.toml
index b12f30b0..69139bd6 100644
--- a/wordpress-extra.mago.toml
+++ b/wordpress-extra.mago.toml
@@ -15,4 +15,4 @@ extends = "wordpress.mago.toml"
missing-docs = { enabled = false }
[extension-hosts.wordpress]
-command = ["php", "vendor/rlorenzo/mago-wordpress/resources/worker.php", "--standard=WordPress-Extra"]
+command = ["php", "-d", "opcache.enable_cli=1", "vendor/rlorenzo/mago-wordpress/resources/worker.php", "--standard=WordPress-Extra"]
diff --git a/wordpress.mago.toml b/wordpress.mago.toml
index 6bbce59f..ad6ed065 100644
--- a/wordpress.mago.toml
+++ b/wordpress.mago.toml
@@ -159,4 +159,4 @@ preserve-redundant-logical-binary-expression-parentheses = true
# trailing-comma stays on (Mago's default): WordPress-Core requires one after a multi-line array's last item.
[extension-hosts.wordpress]
-command = ["php", "vendor/rlorenzo/mago-wordpress/resources/worker.php"]
+command = ["php", "-d", "opcache.enable_cli=1", "vendor/rlorenzo/mago-wordpress/resources/worker.php"]