perf(ruby): remove two superlinear costs from the RuboCop pass - #36
Merged
Merged
Conversation
Two methods the Layout cops read through are replaced at boot, the way the syntax_tree patches already are, so the artifact stays stock: - rubocop-ast's ProcessedSource#sorted_tokens sorts on a [begin_pos, index] Array key, which compares through Array#<=> on every step. Any file with a heredoc takes that branch. The key is folded into one Integer, begin_pos * count + index, which orders identically and compares inline: ~150 ms -> ~9 ms on a 49 KB file with 4,738 tokens. - parser's Buffer#line_begins walks the source with String#index(from), which counts characters from the start on a multi-byte string, so the line table costs O(size x lines) and RuboCop rebuilds it every round. A non-ASCII source is walked once with each_line instead: 9.0 s -> 25 ms on a 589 KB file, 52.1 s -> 27.4 s for the whole format. Output is unchanged: the new tests assert each replacement answers exactly as the gem's own expression on sources shaped to reach it, the native RuboCop conformance test gains a sample per patch, and a 206-file corpus hashes identically before and after while going from 120.5 s to 79.2 s under Node.
rubocop-ast's master already carries the token-sort fold, unreleased as of 1.50.0, so that patch retires with the next release. parser's master still walks line_begins with String#index as of 3.3.12.0, so that one has no retirement date and is the fix to propose upstream.
hwkr
approved these changes
Sep 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Two performance patches for the RuboCop pass, applied at boot the way the existing syntax_tree patches are, so the artifact stays stock. Neither changes a byte of output; both change what a gem costs.
The token sort, on every file with a heredoc in it. rubocop-ast's
ProcessedSource#sorted_tokensfalls back to a stable sort keyed on a two-element Array,[begin_pos, index], whenever the token list arrives out of position order — which any heredoc causes.sort_bycompares Integers inline and Arrays through a method call intoArray#<=>: ~150 ms on a 49 KB file with 4,738 tokens. The patch folds the pair into one Integer,begin_pos * count + index, which orders identically and compares as an Integer: ~9 ms. Over a 545 KB profile of 60 real files the sort had been 16.5% of everything the formatter did. (rubocop-astmasterhas since made the same change, unreleased; the patch retires when the pin moves past 1.50.0.)The line table, on every file with a multi-byte character in it. The same bug the 0.6.2 syntax_tree patch fixed, one layer down. parser's
Buffer#line_beginswalks the source withString#index("\n", from), which counts characters from the start on a non-ASCII string, so the table is O(size × lines) to build — and RuboCop rebuilds it on every correction round. On a 589 KB file with 17,401 lines and 729 carrying a multi-byte character it took 9.0 s per round. The patch walks the source once witheach_line; the same table builds in ~25 ms. An ASCII source keeps the gem's own loop.Which packages
packages/ruby
Exactness
Output is unchanged, and three things say so:
src/rubocop-perf-patch.test.tsasserts the patched sort returns the tokens object for object in the order the gem's own expression puts them, on five heredoc shapes that each reach the sort; and that the patched line table matches the gem's own loop entry for entry, sentinel included, on thirteen shapes of source (CRLF endings and multi-byte characters among them). A further test asserts both methods are the patched ones, so a patch that silently stops being applied fails rather than passes.test/rubocop-conformance.test.tsgains one sample per patch, run through the realrubocopbinary on a native Ruby with the pinned gems. It ran here (not skipped) and passes.bun run ruby:bench --only corpusover the 206-file corpus above, snapshotted before and after and--compared: 206 of 206 hashes the same.How it was validated
bun run checkbun run types:checkbun run testbun run test:nodeChangeset
.changeset/quick-heredoc-token-sort.md, apatchbump for@scalar/ruby-fmt.