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 - Bar chart: phpcs WordPress-Extra vs mago + mago-wordpress lint time on the top 10 WordPress.org plugins by active installs and WordPress core, from WooCommerce (33.40 s vs 3.32 s) down to Akismet (0.52 s vs 0.26 s) + Bar chart: phpcs WordPress vs mago + mago-wordpress lint time on the top 10 WordPress.org plugins by active installs and WordPress core, from WooCommerce (26.33 s vs 12.64 s) down to Akismet (0.70 s vs 0.57 s) -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 @@ 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.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"]