From 0cbd210c3a3a9f2ed603f704d915ffc8dd51bc7f Mon Sep 17 00:00:00 2001 From: Earlopain <14981592+Earlopain@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:16:56 +0200 Subject: [PATCH] [ruby/prism] Optimize ripper bounds Basically a port of https://github.com/ruby/ruby/commit/c45f781771314a71856c9b348c640ba532f54349 into ruby It's quite effective at ~97% hit rate for me. Speeds it up from ~6.77x slower to only 4.07x slower. For the lexer `on_sp` it also gives a bit of an improvement: 1.04x slower to 1.10x faster I guess the class may be universally useful but for now I just made it nodoc. https://github.com/ruby/prism/commit/3ad9db38fe --- lib/prism/lex_compat.rb | 11 +++++- lib/prism/parse_result.rb | 4 +- lib/prism/translation/ripper.rb | 69 ++++++++++++++++++++++++++++++--- 3 files changed, 74 insertions(+), 10 deletions(-) diff --git a/lib/prism/lex_compat.rb b/lib/prism/lex_compat.rb index e1b04fc6ce..7aacec037d 100644 --- a/lib/prism/lex_compat.rb +++ b/lib/prism/lex_compat.rb @@ -23,6 +23,12 @@ module Prism # def self.[]: (Integer value) -> State # end # end + # + # class LineAndColumnCache + # def initialize: (Source source) -> void + # + # def line_and_column: (Integer byte_offset) -> [Integer, Integer] + # end # end # end @@ -837,6 +843,8 @@ module Prism prev_token_state = Translation::Ripper::Lexer::State[Translation::Ripper::EXPR_BEG] prev_token_end = bom ? 3 : 0 + cache = Translation::Ripper::LineAndColumnCache.new(source) + tokens.each do |token| # Skip missing heredoc ends. next if token[1] == :on_heredoc_end && token[2] == "" @@ -851,8 +859,7 @@ module Prism if start_offset > prev_token_end sp_value = source.slice(prev_token_end, start_offset - prev_token_end) - sp_line = source.line(prev_token_end) - sp_column = source.column(prev_token_end) + sp_line, sp_column = cache.line_and_column(prev_token_end) # Ripper reports columns on line 1 without counting the BOM sp_column -= 3 if sp_line == 1 && bom continuation_index = sp_value.byteindex("\\") diff --git a/lib/prism/parse_result.rb b/lib/prism/parse_result.rb index 7cf6630f44..93d3c006b7 100644 --- a/lib/prism/parse_result.rb +++ b/lib/prism/parse_result.rb @@ -225,9 +225,7 @@ module Prism freeze end - private - - # Binary search through the offsets to find the line number for the given + # Binary search through the offsets to find the index for the given # byte offset. #-- #: (Integer byte_offset) -> Integer diff --git a/lib/prism/translation/ripper.rb b/lib/prism/translation/ripper.rb index 77ee2c337d..cbbc0b5db8 100644 --- a/lib/prism/translation/ripper.rb +++ b/lib/prism/translation/ripper.rb @@ -445,6 +445,64 @@ module Prism autoload :SexpBuilder, "prism/translation/ripper/sexp" autoload :SexpBuilderPP, "prism/translation/ripper/sexp" + # Provides optimized access to line and column information. + # Ripper bounds are mostly accessed in a linear fashion, so + # we can try a linear scan first and fall back to binary search. + class LineAndColumnCache # :nodoc: + # How many should it look ahead/behind before falling back to binary searching. + WINDOW = 8 + private_constant :WINDOW + + #: (Source source) -> void + def initialize(source) + @source = source + @offsets = source.offsets + @hint = 0 + end + + #: (Integer byte_offset) -> [Integer, Integer] + def line_and_column(byte_offset) + @hint = new_hint(byte_offset) || @source.find_line(byte_offset) + return [@hint + @source.start_line, byte_offset - @offsets[@hint]] + end + + private + + def new_hint(byte_offset) + if @offsets[@hint] <= byte_offset + # Same line? + if (@hint + 1 >= @offsets.size || @offsets[@hint + 1] > byte_offset) + return @hint + end + + # Scan forwards + limit = [@hint + WINDOW + 1, @offsets.size].min + idx = @hint + 1 + while idx < limit + if @offsets[idx] > byte_offset + return idx - 1 + end + if @offsets[idx] == byte_offset + return idx + end + idx += 1 + end + else + # Scan backwards + limit = @hint > WINDOW ? @hint - WINDOW : 0 + idx = @hint + while idx >= limit + 1 + if @offsets[idx - 1] <= byte_offset + return idx - 1 + end + idx -= 1 + end + end + + nil + end + end + # :stopdoc: # This is not part of the public API but used by some gems. @@ -488,6 +546,7 @@ module Prism @lineno = lineno @column = 0 @result = nil + @line_and_column_cache = nil end ########################################################################## @@ -4033,6 +4092,10 @@ module Prism @result ||= Prism.parse(source, partial_script: true, version: "current") end + def line_and_column_cache + @line_and_column_cache ||= LineAndColumnCache.new(result.source) + end + ########################################################################## # Helpers ########################################################################## @@ -4133,12 +4196,8 @@ module Prism # This method is responsible for updating lineno and column information # to reflect the current node. - # - # This method could be drastically improved with some caching on the start - # of every line, but for now it's good enough. def bounds(location) - @lineno = location.start_line - @column = location.start_column + @lineno, @column = line_and_column_cache.line_and_column(location.start_offset) end # :startdoc: