From 0d4bac6dacd09026e97e0acf37c033e76a110bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Mon, 6 Apr 2026 16:06:19 -0300 Subject: [PATCH 01/21] Add experiments to the gem for fast-experiment --- fast.gemspec | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fast.gemspec b/fast.gemspec index ba1b58e..ba2daf8 100644 --- a/fast.gemspec +++ b/fast.gemspec @@ -38,6 +38,10 @@ Gem::Specification.new do |spec| bin/fast-experiment bin/setup bin/console + experiments/replace_create_with_build_stubbed.rb + experiments/let_it_be_experiment.rb + experiments/remove_useless_hook.rb + experiments/replace_update_attributes_with_update.rb .agents/fast-pattern-expert/SKILL.md LICENSE.txt README.md From 1539bd12eec370b1c91600e7898354bb899e9cbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Tue, 7 Apr 2026 09:56:21 -0300 Subject: [PATCH 02/21] Split gain tracking to recognize CLI and MCP scenarios independently - Implement categorized gain reporting (CLI, MCP, Total) - Add filtered report support via CLI (e.g., .gains cli, .gains mcp) - Ensure robust gain recording across all MCP server tools - Add unit tests for Gains, CLI integration, and MCP server gains tracking --- fast.gemspec | 1 + lib/fast.rb | 12 +-- lib/fast/cli.rb | 42 +++++++--- lib/fast/gains.rb | 146 +++++++++++++++++++++++++++++++++++ lib/fast/mcp_server.rb | 14 +++- spec/fast/cli_gains_spec.rb | 60 ++++++++++++++ spec/fast/gains_spec.rb | 112 +++++++++++++++++++++++++++ spec/fast/mcp_server_spec.rb | 71 +++++++++++++++++ 8 files changed, 441 insertions(+), 17 deletions(-) create mode 100644 lib/fast/gains.rb create mode 100644 spec/fast/cli_gains_spec.rb create mode 100644 spec/fast/gains_spec.rb create mode 100644 spec/fast/mcp_server_spec.rb diff --git a/fast.gemspec b/fast.gemspec index ba2daf8..e6d0a28 100644 --- a/fast.gemspec +++ b/fast.gemspec @@ -21,6 +21,7 @@ Gem::Specification.new do |spec| lib/fast/cli.rb lib/fast/experiment.rb lib/fast/git.rb + lib/fast/gains.rb lib/fast/mcp_server.rb lib/fast/node.rb lib/fast/prism_adapter.rb diff --git a/lib/fast.rb b/lib/fast.rb index 6f69d40..196e4a1 100644 --- a/lib/fast.rb +++ b/lib/fast.rb @@ -188,18 +188,18 @@ def search_file(pattern, file) # @param [String] pattern # @param [Array] *locations where to search. Default is '.' # @return [Hash>] with files and results - def search_all(pattern, locations = ['.'], parallel: true, on_result: nil) + def search_all(pattern, locations = ['.'], parallel: true, on_result: nil, on_search: nil) group_results(build_grouped_search(:search_file, pattern, on_result), - locations, parallel: parallel) + locations, parallel: parallel, on_search: on_search) end # Capture with pattern on a directory or multiple files # @param [String] pattern # @param [Array] locations where to search. Default is '.' # @return [Hash] with files and captures - def capture_all(pattern, locations = ['.'], parallel: true, on_result: nil) + def capture_all(pattern, locations = ['.'], parallel: true, on_result: nil, on_search: nil) group_results(build_grouped_search(:capture_file, pattern, on_result), - locations, parallel: parallel) + locations, parallel: parallel, on_search: on_search) end # @return [Proc] binding `pattern` argument from a given `method_name`. @@ -224,12 +224,13 @@ def build_grouped_search(method_name, pattern, on_result) # while it process several locations in parallel. # @param [Boolean] parallel runs the `group_files` in parallel # @return [Hash[String, Array]] with files and results - def group_results(group_files, locations, parallel: true) + def group_results(group_files, locations, parallel: true, on_search: nil) files = ruby_files_from(*locations) results = if parallel require 'parallel' unless defined?(Parallel) Parallel.map(files) do |file| + on_search&.call(file) group_files.call(file) rescue StandardError => e warn "Error processing #{file}: #{e.message}" if Fast.debugging @@ -237,6 +238,7 @@ def group_results(group_files, locations, parallel: true) end else files.map do |file| + on_search&.call(file) group_files.call(file) rescue StandardError => e warn "Error processing #{file}: #{e.message}" if Fast.debugging diff --git a/lib/fast/cli.rb b/lib/fast/cli.rb index 62d2bc0..d2d2355 100644 --- a/lib/fast/cli.rb +++ b/lib/fast/cli.rb @@ -4,6 +4,7 @@ require 'fast/source' require 'fast/version' require 'fast/sql' +require 'fast/gains' require 'coderay' require 'optparse' require 'ostruct' @@ -82,29 +83,37 @@ def first_position_from_expression(node) # @param level [Integer] Skip exploring deep branches of AST when showing sexp # @example # Fast.report(result, file: 'file.rb') - def report(result, show_link: false, show_permalink: false, show_sexp: false, file: nil, headless: false, bodyless: false, colorize: true, level: nil) # rubocop:disable Metrics/ParameterLists + def report(result, show_link: false, show_permalink: false, show_sexp: false, file: nil, headless: false, bodyless: false, colorize: true, level: nil, gains: nil) # rubocop:disable Metrics/ParameterLists if file if result.is_a?(Symbol) && !result.respond_to?(:loc) result.extend(SymbolExtension) end line = result.loc.expression.line if Fast.ast_node?(result) && result.respond_to?(:loc) if show_link - puts(result.link) + puts_and_record(result.link, gains) elsif show_permalink - puts(result.permalink) + puts_and_record(result.permalink, gains) elsif !headless - puts(highlight("# #{file}:#{line}", colorize: colorize)) + puts_and_record(highlight("# #{file}:#{line}", colorize: colorize), gains) end end - puts(highlight(result, show_sexp: show_sexp, colorize: colorize, level: level)) unless bodyless + puts_and_record(highlight(result, show_sexp: show_sexp, colorize: colorize, level: level), gains) unless bodyless + end + + def puts_and_record(content, gains) + puts(content) + gains&.record_report(content) end # Command Line Interface for Fast class Cli # rubocop:disable Metrics/ClassLength - attr_reader :pattern, :show_sexp, :pry, :from_code, :similar, :help, :level + attr_reader :pattern, :show_sexp, :pry, :from_code, :similar, :help, :level, :gains def initialize(args) + @gains = Gains.new(args.join(' ')) args = args.dup - args = replace_args_with_shortcut(args) if shortcut_name_from(args) + unless args.include?('.gains') + args = replace_args_with_shortcut(args) if shortcut_name_from(args) + end @colorize = STDOUT.isatty @headless = false @bodyless = false @@ -233,6 +242,12 @@ def self.run!(argv) def run! raise 'pry and parallel options are incompatible :(' if @parallel && @pry + if @pattern&.start_with?('.gains') + filter = @pattern.split(' ')[1] + Gains.report(filter) + return + end + if @help || @files.empty? && @pattern.nil? puts option_parser.help return @@ -253,10 +268,13 @@ def run! if @files.empty? ast ||= Fast.public_send( @sql ? :parse_sql : :ast, @pattern) - puts Fast.highlight(ast, show_sexp: @show_sexp, colorize: @colorize, sql: @sql, level: @level) + content = Fast.highlight(ast, show_sexp: @show_sexp, colorize: @colorize, sql: @sql, level: @level) + puts content + @gains.record_report(content) else search end + @gains.save! end # Create fast expression from node pattern using the command line @@ -273,6 +291,7 @@ def search return Fast.debug(&method(:execute_search)) if debug_mode? execute_search do |file, results| + @gains.record_match(file) if results.any? results.each do |result| binding.pry if @pry # rubocop:disable Lint/Debugger report(file, result) @@ -283,11 +302,13 @@ def search # Executes search for all files yielding the results # @yieldparam [String, Array] with file and respective search results def execute_search(&on_result) + on_search = ->(file) { @gains.record_search(file) } Fast.public_send(search_method_name, @pattern, @files, parallel: parallel?, - on_result: on_result) + on_result: on_result, + on_search: on_search) end # @return [Symbol] with `:capture_all` or `:search_all` depending the command line options @@ -320,7 +341,8 @@ def report(file, result) headless: @headless, bodyless: @bodyless, colorize: @colorize, - level: @level) + level: @level, + gains: @gains) end def shortcut_name_from(args) diff --git a/lib/fast/gains.rb b/lib/fast/gains.rb new file mode 100644 index 0000000..0ece0f7 --- /dev/null +++ b/lib/fast/gains.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require 'json' +require 'fileutils' +require 'time' + +module Fast + # Gains tracks the efficiency of the searches and code explorations. + # It measures bytes searched vs. bytes reported to quantify savings. + class Gains + STORAGE_DIR = File.expand_path('~/.fast') + STORAGE_FILE = File.join(STORAGE_DIR, 'gains.json') + + attr_reader :command, :start_time, :total_bytes_searched, :total_bytes_reported, :files_count, :matched_files_count + + def initialize(command = nil) + @command = command + @start_time = Time.now + @total_bytes_searched = 0 + @total_bytes_reported = 0 + @files_count = 0 + @matched_files_count = 0 + @files_with_matches = [] + end + + def record_search(file) + @files_count += 1 + size = File.size(file) rescue 0 + @total_bytes_searched += size + end + + def record_match(file) + unless @files_with_matches.include?(file) + @files_with_matches << file + @matched_files_count += 1 + end + end + + def record_report(content) + @total_bytes_reported += content.to_s.bytesize + end + + def save! + return if @total_bytes_searched.zero? + return if @total_bytes_reported.zero? # Honest gain: skip if nothing was found + + data = history + data << { + timestamp: @start_time.iso8601, + command: @command, + files_count: @files_count, + matched_files_count: @matched_files_count, + bytes_searched: @total_bytes_searched, + bytes_reported: @total_bytes_reported, + savings_percent: savings_percent.round(2) + } + + FileUtils.mkdir_p(STORAGE_DIR) + File.write(STORAGE_FILE, JSON.pretty_generate(data)) + end + + def savings_percent + return 0.0 if @total_bytes_searched.zero? + + 100.0 * (1.0 - (@total_bytes_reported.to_f / @total_bytes_searched)) + end + + def history + return [] unless File.exist?(STORAGE_FILE) + + JSON.parse(File.read(STORAGE_FILE), symbolize_names: true) + rescue StandardError + [] + end + + def self.report(filter = nil) + all_history = new.history + return puts "No gains recorded yet. Start searching with `fast`!" if all_history.empty? + + if filter == 'mcp' + render_report('Fast Gains Report (MCP)', all_history.select { |h| h[:command]&.start_with?('mcp:') }) + elsif filter == 'cli' + render_report('Fast Gains Report (CLI)', all_history.reject { |h| h[:command]&.start_with?('mcp:') }) + else + mcp_history = all_history.select { |h| h[:command]&.start_with?('mcp:') } + cli_history = all_history.reject { |h| h[:command]&.start_with?('mcp:') } + + if mcp_history.any? && cli_history.any? + render_report('Fast Gains Report (CLI)', cli_history) + puts "\n" + render_report('Fast Gains Report (MCP)', mcp_history) + puts "\n" + render_report('Fast Gains Report (Total)', all_history) + else + render_report('Fast Gains Report', all_history) + end + end + end + + def self.render_report(title, history) + return puts "No gains recorded for this category." if history.empty? + + total_searched = history.sum { |h| h[:bytes_searched] || 0 } + total_reported = history.sum { |h| h[:bytes_reported] || 0 } + total_files = history.sum { |h| h[:files_count] || 0 } + total_matched_files = history.sum { |h| h[:matched_files_count] || 0 } + total_savings = total_searched - total_reported + avg_percent = total_searched.zero? ? 0 : 100.0 * (1.0 - (total_reported.to_f / total_searched)) + + puts "\e[1m#{title}\e[0m" + puts '-' * title.length + puts "Total Bytes Searched: #{format_bytes(total_searched)} (#{total_files} files)" + puts "Total Bytes Reported: #{format_bytes(total_reported)} (#{total_matched_files} files matched)" + puts "Total Savings: \e[32m#{format_bytes(total_savings)} (#{avg_percent.round(2)}%)\e[0m" + puts "Commands executed: #{history.size}" + puts '' + + show_graph(history.last(30)) + end + + def self.format_bytes(bytes) + if bytes < 1024 + "#{bytes} B" + elsif bytes < 1024**2 + "#{(bytes / 1024.0).round(2)} KB" + elsif bytes < 1024**3 + "#{(bytes / 1024.0**2).round(2)} MB" + else + "#{(bytes / 1024.0**3).round(2)} GB" + end + end + + def self.show_graph(recent_history) + puts "Recent Savings (last #{recent_history.size} runs):" + max_savings = recent_history.map { |h| h[:bytes_searched] - h[:bytes_reported] }.max + return if max_savings.to_i.zero? + + recent_history.each do |h| + savings = h[:bytes_searched] - h[:bytes_reported] + bar_length = (30.0 * savings / max_savings).to_i + bar = "█" * bar_length + printf "%10s | %-30s | %6.2f%%\n", h[:timestamp][5..15].tr('T', ' '), bar, h[:savings_percent] + end + end + end +end diff --git a/lib/fast/mcp_server.rb b/lib/fast/mcp_server.rb index b8cbf36..9417ed5 100644 --- a/lib/fast/mcp_server.rb +++ b/lib/fast/mcp_server.rb @@ -156,6 +156,7 @@ def handle_tool_call(id, params) tool_name = params['name'] args = params['arguments'] || {} show_ast = args['show_ast'] || false + @gains = Gains.new("mcp:#{tool_name}") if args['pattern'] && !args['pattern'].start_with?('(', '{', '[') && !args['pattern'].match?(/^[a-z_]+$/) raise "Invalid Fast AST pattern: '#{args['pattern']}'. Did you mean to use an s-expression like '(#{args['pattern']})'?" @@ -182,6 +183,8 @@ def handle_tool_call(id, params) raise "Unknown tool: #{tool_name}" end + @gains.record_report(result.to_json) + @gains.save! write_response(id, { content: [{ type: 'text', text: JSON.generate(result) }] }) rescue => e write_error(id, -32603, 'Tool execution failed', e.message) @@ -197,6 +200,7 @@ def execute_validate_pattern(pattern) def execute_search(pattern, paths, show_ast: false) results = [] on_result = ->(file, matches) do + @gains&.record_match(file) if matches.any? matches.compact.each do |node| next unless (exp = node_expression(node)) @@ -210,8 +214,9 @@ def execute_search(pattern, paths, show_ast: false) results << entry end end + on_search = ->(file) { @gains&.record_search(file) } - Fast.search_all(pattern, paths, parallel: false, on_result: on_result) + Fast.search_all(pattern, paths, parallel: false, on_result: on_result, on_search: on_search) results end @@ -230,6 +235,7 @@ def execute_class_search(class_name, paths, show_ast: false) # Use simple (class ...) pattern then filter by name — avoids nil/superclass edge cases results = [] on_result = ->(file, matches) do + @gains&.record_match(file) if matches.any? matches.compact.each do |node| next unless node.type == :class next unless node.children.first&.children&.last&.to_s == class_name @@ -245,7 +251,8 @@ def execute_class_search(class_name, paths, show_ast: false) results << entry end end - Fast.search_all('(class ...)', paths, parallel: false, on_result: on_result) + on_search = ->(file) { @gains&.record_search(file) } + Fast.search_all('(class ...)', paths, parallel: false, on_result: on_result, on_search: on_search) results.select { |r| r[:file] } # already filtered above end @@ -260,8 +267,10 @@ def execute_rewrite(source, pattern, replacement) def execute_rewrite_file(file, pattern, replacement) raise "File not found: #{file}" unless File.exist?(file) + @gains&.record_search(file) original = File.read(file) rewritten = Fast.replace_file(pattern, file) do |node| + @gains&.record_match(file) replace(node.loc.expression, replacement) end @@ -299,6 +308,7 @@ def execute_fast_experiment(name, lookup_path, search_pattern, edit_code, policy system(cmd) end end + experiment.files.each { |f| @gains&.record_search(f) } experiment.run ensure $stdout = original_stdout diff --git a/spec/fast/cli_gains_spec.rb b/spec/fast/cli_gains_spec.rb new file mode 100644 index 0000000..5eda8cf --- /dev/null +++ b/spec/fast/cli_gains_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'fast/cli' +require 'fileutils' + +RSpec.describe Fast::Cli do + let(:temp_dir) { File.join(Dir.pwd, 'tmp_cli_gains') } + let(:temp_file) { File.join(temp_dir, 'gains.json') } + + before do + stub_const('Fast::Gains::STORAGE_DIR', temp_dir) + stub_const('Fast::Gains::STORAGE_FILE', temp_file) + FileUtils.mkdir_p(temp_dir) + end + + after do + FileUtils.rm_rf(temp_dir) + end + + describe 'gains integration' do + it 'records bytes searched and reported during a search' do + test_file = File.join(temp_dir, 'sample.rb') + File.write(test_file, 'def hello; end') + + cli = Fast::Cli.new(['(def hello)', test_file, '--no-color']) + expect { cli.run! }.to output(/def hello/).to_stdout + + expect(File.exist?(temp_file)).to be true + data = JSON.parse(File.read(temp_file)).last + expect(data['bytes_searched']).to eq(File.size(test_file)) + expect(data['bytes_reported']).to be > 0 + end + + it 'calls Gains.report when .gains is the pattern' do + expect(Fast::Gains).to receive(:report).with(nil) + Fast::Cli.new(['.gains']).run! + end + + it 'calls Gains.report with "mcp" filter when .gains mcp is the pattern' do + expect(Fast::Gains).to receive(:report).with('mcp') + Fast::Cli.new(['.gains', 'mcp']).run! + end + + it 'calls Gains.report with "cli" filter when .gains cli is the pattern' do + expect(Fast::Gains).to receive(:report).with('cli') + Fast::Cli.new(['.gains', 'cli']).run! + end + + it 'does not save gains if no results are found' do + test_file = File.join(temp_dir, 'sample.rb') + File.write(test_file, 'def hello; end') + + cli = Fast::Cli.new(['(def non_existent)', test_file]) + cli.run! + + expect(File.exist?(temp_file)).to be false + end + end +end diff --git a/spec/fast/gains_spec.rb b/spec/fast/gains_spec.rb new file mode 100644 index 0000000..e0e1d77 --- /dev/null +++ b/spec/fast/gains_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'fast/gains' +require 'fileutils' +require 'json' + +RSpec.describe Fast::Gains do + let(:temp_dir) { File.join(Dir.pwd, 'tmp_gains') } + let(:temp_file) { File.join(temp_dir, 'gains.json') } + + before do + stub_const('Fast::Gains::STORAGE_DIR', temp_dir) + stub_const('Fast::Gains::STORAGE_FILE', temp_file) + FileUtils.mkdir_p(temp_dir) + end + + after do + FileUtils.rm_rf(temp_dir) + end + + describe '#record_search' do + it 'increments files_count and total_bytes_searched' do + file = File.join(temp_dir, 'test.rb') + File.write(file, 'puts "hello"') + + subject.record_search(file) + + expect(subject.files_count).to eq(1) + expect(subject.total_bytes_searched).to eq(File.size(file)) + end + end + + describe '#record_report' do + it 'increments total_bytes_reported' do + subject.record_report('test content') + expect(subject.total_bytes_reported).to eq('test content'.bytesize) + end + end + + describe '#record_match' do + it 'increments matched_files_count for unique files' do + subject.record_match('file1.rb') + subject.record_match('file1.rb') + subject.record_match('file2.rb') + + expect(subject.matched_files_count).to eq(2) + end + end + + describe '#save!' do + it 'saves data to the JSON file if there are reports' do + file = File.join(temp_dir, 'test.rb') + File.write(file, 'test content') + + subject.record_search(file) + subject.record_report('match') + subject.save! + + expect(File.exist?(temp_file)).to be true + data = JSON.parse(File.read(temp_file)) + expect(data.last['bytes_searched']).to eq(File.size(file)) + expect(data.last['bytes_reported']).to eq('match'.bytesize) + end + + it 'does NOT save data if there are no reports (honest gain)' do + file = File.join(temp_dir, 'test.rb') + File.write(file, 'test content') + + subject.record_search(file) + subject.save! + + expect(File.exist?(temp_file)).to be false + end + end + + describe '.report' do + it 'prints a message if no history exists' do + expect { described_class.report }.to output(/No gains recorded yet/).to_stdout + end + + context 'with history' do + let(:now) { Time.now.iso8601 } + let(:data) do + [ + { timestamp: now, command: 'fast test', files_count: 10, bytes_searched: 1000, bytes_reported: 100, savings_percent: 90.0 }, + { timestamp: now, command: 'mcp:search', files_count: 5, bytes_searched: 500, bytes_reported: 50, savings_percent: 90.0 } + ] + end + + before do + File.write(temp_file, JSON.generate(data)) + end + + it 'prints a breakdown if both CLI and MCP exist' do + expect { described_class.report }.to output(/Fast Gains Report \(CLI\)/).to_stdout + expect { described_class.report }.to output(/Fast Gains Report \(MCP\)/).to_stdout + expect { described_class.report }.to output(/Fast Gains Report \(Total\)/).to_stdout + end + + it 'prints only CLI report when filtered' do + expect { described_class.report('cli') }.to output(/Fast Gains Report \(CLI\)/).to_stdout + expect { described_class.report('cli') }.not_to output(/Fast Gains Report \(MCP\)/).to_stdout + end + + it 'prints only MCP report when filtered' do + expect { described_class.report('mcp') }.to output(/Fast Gains Report \(MCP\)/).to_stdout + expect { described_class.report('mcp') }.not_to output(/Fast Gains Report \(CLI\)/).to_stdout + end + end + end +end diff --git a/spec/fast/mcp_server_spec.rb b/spec/fast/mcp_server_spec.rb new file mode 100644 index 0000000..eff27e2 --- /dev/null +++ b/spec/fast/mcp_server_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'fast/mcp_server' +require 'fileutils' +require 'json' + +RSpec.describe Fast::McpServer do + let(:temp_dir) { File.join(Dir.pwd, 'tmp_mcp_gains') } + let(:temp_file) { File.join(temp_dir, 'gains.json') } + + before do + stub_const('Fast::Gains::STORAGE_DIR', temp_dir) + stub_const('Fast::Gains::STORAGE_FILE', temp_file) + FileUtils.mkdir_p(temp_dir) + end + + after do + FileUtils.rm_rf(temp_dir) + end + + describe 'gains tracking' do + let(:server) { Fast::McpServer.new } + + it 'records gains for search_ruby_ast' do + test_file = File.join(temp_dir, 'sample.rb') + File.write(test_file, 'def hello; end') + + params = { + 'name' => 'search_ruby_ast', + 'arguments' => { + 'pattern' => '(def hello)', + 'paths' => [test_file] + } + } + + # Mock write_response to avoid STDOUT pollution + allow(server).to receive(:write_response) + + server.send(:handle_tool_call, '1', params) + + expect(File.exist?(temp_file)).to be true + data = JSON.parse(File.read(temp_file)).last + expect(data['command']).to eq('mcp:search_ruby_ast') + expect(data['bytes_searched']).to eq(File.size(test_file)) + expect(data['bytes_reported']).to be > 0 + end + + it 'records gains for ruby_class_source' do + test_file = File.join(temp_dir, 'sample.rb') + File.write(test_file, 'class Hello; end') + + params = { + 'name' => 'ruby_class_source', + 'arguments' => { + 'class_name' => 'Hello', + 'paths' => [test_file] + } + } + + allow(server).to receive(:write_response) + + server.send(:handle_tool_call, '1', params) + + expect(File.exist?(temp_file)).to be true + data = JSON.parse(File.read(temp_file)).last + expect(data['command']).to eq('mcp:ruby_class_source') + expect(data['bytes_searched']).to eq(File.size(test_file)) + end + end +end From 453679e11cfe66c56370099d76ae11de0abe2476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Tue, 7 Apr 2026 14:46:30 -0300 Subject: [PATCH 03/21] Allow 'experiments' in Gem Audit as it is now part of the gem --- spec/gem_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/gem_spec.rb b/spec/gem_spec.rb index af64d6c..345d967 100644 --- a/spec/gem_spec.rb +++ b/spec/gem_spec.rb @@ -33,7 +33,7 @@ end it 'does not include any forbidden development files' do - forbidden_files = %w[.github .travis .rspec .rubocop Gemfile Rakefile Guardfile spec test experiments examples docs site ideia_blog_post.md] + forbidden_files = %w[.github .travis .rspec .rubocop Gemfile Rakefile Guardfile spec test examples docs site ideia_blog_post.md] included_files.each do |file| forbidden_files.each do |forbidden| expect(file).not_to start_with(forbidden), "Forbidden file or directory included: #{file}" From 33d6e16378a1d82eeefbdda87d173468c3769b96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Sun, 12 Apr 2026 13:52:31 -0300 Subject: [PATCH 04/21] chore: Update docs automatically. Link LLM docs --- .github/workflows/docs.yml | 26 ++++++++++++++++++++++++++ mkdocs.yml | 9 +++++---- 2 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..e6fb9c7 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,26 @@ +name: Deploy Docs + +on: + push: + branches: + - master + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.x + + - name: Install dependencies + run: pip install -r requirements-docs.txt + + - name: Deploy with mkdocs + run: mkdocs gh-deploy --force diff --git a/mkdocs.yml b/mkdocs.yml index 690905c..99adfce 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -36,10 +36,11 @@ nav: - Experiments: experiments.md - Shortcuts: shortcuts.md - Git Integration: git.md - - Fast for LLMs and Agents: agents.md - - MCP Server Tutorial: mcp_tutorial.md - - Code Similarity: similarity_tutorial.md - - LLM/Agent Feature TODOs: llm_features.md + - Agents & LLMs: + - Fast for LLMs and Agents: agents.md + - MCP Server Tutorial: mcp_tutorial.md + - Code Similarity: similarity_tutorial.md + - LLM/Agent Feature TODOs: llm_features.md - Pry Integration: pry-integration.md - Editors' Integration: editors-integration.md - Research: research.md From eb6dadc8814a1cd76ed8c44f52286084fd08cc7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Sun, 12 Apr 2026 13:52:55 -0300 Subject: [PATCH 05/21] chore: Fix Sym/Str nodes --- lib/fast/prism_adapter.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/fast/prism_adapter.rb b/lib/fast/prism_adapter.rb index 8a26b98..99d944f 100644 --- a/lib/fast/prism_adapter.rb +++ b/lib/fast/prism_adapter.rb @@ -82,6 +82,10 @@ def adapt(node, source, buffer_name) return if node.nil? case node + when Symbol + build_node(:sym, [node.to_s], nil, source, buffer_name) + when String + build_node(:str, [node], nil, source, buffer_name) when Prism::ProgramNode statements = adapt_statements(node.statements, source, buffer_name) statements.is_a?(Node) ? statements : build_node(:begin, statements, node, source, buffer_name) From 5115e8d470411a33f708307732323c1a38bde0bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Mon, 13 Apr 2026 13:45:49 -0300 Subject: [PATCH 06/21] Document search efficiency gains feature This commit adds comprehensive documentation for the 'gains' feature, which tracks search efficiency by comparing bytes searched vs bytes reported. Highlights: - Created docs/gains.md with detailed usage and configuration. - Updated README.md and CLI docs with .gains command information. - Added FAST_GAINS=0 environment variable support to disable tracking. - Added test coverage for the new configuration option. - Integrated the new documentation into the mkdocs navigation. --- README.md | 3 ++ docs/command_line.md | 20 ++++++++ docs/gains.md | 84 +++++++++++++++++++++++++++++++ docs/index.md | 2 +- lib/fast.rb | 16 ++++++ lib/fast/cli.rb | 8 +-- lib/fast/gains.rb | 62 +++++++++++++++++++---- lib/fast/mcp_server.rb | 1 + mkdocs.yml | 1 + spec/fast/cli_gains_spec.rb | 3 ++ spec/fast/gains_spec.rb | 97 +++++++++++++++++++++++++++++++++++- spec/fast/mcp_server_spec.rb | 2 + 12 files changed, 281 insertions(+), 18 deletions(-) create mode 100644 docs/gains.md diff --git a/README.md b/README.md index 20a20df..5567e3b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Fast, short for "Find AST", is a tool to search, prune, and edit Ruby ASTs. +If you use Fast for LLMs and Agents, it includes an efficiency tracking feature to quantify the savings in terms of context and manual effort. See [Fast Gains (Efficiency Tracking)](https://jonatas.github.io/fast/gains) for more details. + Ruby is a flexible language that allows us to write code in multiple different ways to achieve the same end result, and because of this it's hard to verify how the code was written without an AST. @@ -519,6 +521,7 @@ The CLI tool takes the following flags - Use `-c` to search from code example - Use `-s` to search similar code - Use `-p` or `--parallel` to parallelize the search +- Use `.gains` to show search efficiency statistics ### Define your `Fastfile` diff --git a/docs/command_line.md b/docs/command_line.md index ab1b0d0..b08ebfd 100644 --- a/docs/command_line.md +++ b/docs/command_line.md @@ -13,6 +13,26 @@ $ fast '(def match?)' lib/fast.rb - Use `-s` to search similar code - Use `-p` to or `--parallel` to use multi core search - Use `--validate-pattern PATTERN` to check if a pattern is syntactically correct. +- Use `.gains` to show search efficiency statistics. + +## `.gains` + +The `.gains` command allows you to track and visualize the efficiency of your code searches. It measures "bytes searched" (the total size of files scanned) versus "bytes reported" (the actual results returned). + +```bash +$ fast .gains +``` + +This is particularly useful when using Fast with LLMs and Agents, as it quantifies the reduction in context data that the agent had to process. + +You can also filter by category: + +```bash +$ fast .gains cli # Show only CLI search history +$ fast .gains mcp # Show only MCP (Agent) search history +``` + +See [Fast Gains (Efficiency Tracking)](gains.md) for more details. ## `--validate-pattern` diff --git a/docs/gains.md b/docs/gains.md new file mode 100644 index 0000000..4c33865 --- /dev/null +++ b/docs/gains.md @@ -0,0 +1,84 @@ +# Fast Gains: Tracking Search Efficiency + +Fast includes a "Gains" feature that tracks the efficiency of your code searches and explorations. It measures the amount of data searched versus the amount of data actually reported to quantify the "savings" in terms of context and manual effort. + +This is particularly useful when using Fast as a tool for LLMs and Agents (via the [MCP Server](mcp_tutorial.md)), where minimizing unnecessary context is crucial for performance and cost. + +## How it Works + +The Gains feature monitors three main metrics during a search: + +1. **Bytes Searched**: The total size of all files that Fast scanned. +2. **Bytes Reported**: The size of the actual results (AST nodes, source code, or summaries) that were returned to the user or agent. +3. **Savings**: The difference between Bytes Searched and Bytes Reported, often expressed as a percentage. + +### Example + +If you search a project with 10MB of source code and Fast returns a specific 1KB method definition, the "gain" is approximately 9.99MB, or a 99.9% reduction in the data you had to process manually. + +## Usage + +### Viewing the Report + +You can view your accumulated gains history using the `.gains` command: + +```bash +fast .gains +``` + +This will display a summary of: +- Total bytes searched and reported. +- Total files scanned and matched. +- Total savings (in bytes and percentage). +- A bar chart of recent savings history. + +### Filtering Results + +Fast categorizes gains into two groups: +- **CLI**: Searches performed directly through the command-line interface. +- **MCP**: Searches performed by an LLM or Agent via the Model Context Protocol server. + +You can filter the report to see only one of these: + +```bash +fast .gains cli +# or +fast .gains mcp +``` + +## Configuration + +Gains tracking is enabled by default in some contexts (like the MCP server) but can be controlled via environment variables or programmatically. + +### Environment Variable + +To disable gains tracking across all contexts (CLI and MCP), set the `FAST_GAINS` environment variable to `0`: + +```bash +export FAST_GAINS=0 +``` + +### Ruby API + +To enable or disable tracking in Ruby code: + +```ruby +Fast.enable_gain_track! +Fast.disable_gain_track! +``` + +## Storage + +Gains data is stored locally on your machine: +- **Directory**: `~/.fast/` +- **History File**: `~/.fast/gains.json` + +The system uses temporary files for each run and consolidates them into the main history file when you run the `.gains` command. This ensures that concurrent searches (e.g., from multiple agents) don't lose data or cause file locks. + +To keep the history file manageable, Fast only keeps the full report content for the last 5 runs, while retaining the metrics for all historical runs. + +## Why Track Gains? + +- **Quantify Value**: See how much manual "grepping" and reading Fast is saving you. +- **Optimize Agent Prompts**: High "Bytes Reported" might indicate that your agent's search patterns are too broad and could be refined to save tokens. +- **Monitor Project Growth**: Track how your project's size affects search performance over time. diff --git a/docs/index.md b/docs/index.md index b58628d..376cd26 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ Fast is a "Find AST" tool to help you search in the code abstract syntax tree. -If you use Fast through `bin/fast-mcp`, treat it as a trusted local development tool. The MCP server can read files, rewrite files, and run experiment commands as the current user. See [Fast MCP Server Tutorial](mcp_tutorial.md) and [Using Fast for LLMs and Agents](agents.md) for the security notes and trust boundary. +If you use Fast through `bin/fast-mcp`, treat it as a trusted local development tool. The MCP server can read files, rewrite files, and run experiment commands as the current user. See [Fast MCP Server Tutorial](mcp_tutorial.md), [Fast Gains (Efficiency Tracking)](gains.md), and [Using Fast for LLMs and Agents](agents.md) for the security notes and trust boundary. ??? "🍿Watch my talk at Ruby Kaigi: Grepping Ruby code like a boss" diff --git a/lib/fast.rb b/lib/fast.rb index 196e4a1..c4ba7be 100644 --- a/lib/fast.rb +++ b/lib/fast.rb @@ -63,6 +63,22 @@ class SyntaxError < StandardError; end /x.freeze class << self + attr_accessor :gain_tracking_enabled + + def enable_gain_track! + self.gain_tracking_enabled = true + end + + def disable_gain_track! + self.gain_tracking_enabled = false + end + + def gain_tracking_enabled? + return false if ENV['FAST_GAINS'] == '0' + + @gain_tracking_enabled == true + end + def ast_node?(node) node.respond_to?(:type) && node.respond_to?(:children) end diff --git a/lib/fast/cli.rb b/lib/fast/cli.rb index d2d2355..7edcf73 100644 --- a/lib/fast/cli.rb +++ b/lib/fast/cli.rb @@ -109,6 +109,8 @@ def puts_and_record(content, gains) class Cli # rubocop:disable Metrics/ClassLength attr_reader :pattern, :show_sexp, :pry, :from_code, :similar, :help, :level, :gains def initialize(args) + require 'fast/shortcut' + Fast.load_fast_files! @gains = Gains.new(args.join(' ')) args = args.dup unless args.include?('.gains') @@ -366,11 +368,6 @@ def extract_pattern_and_files(args) # Find shortcut by name. Preloads all `Fastfiles` before start. # @param name [String] def find_shortcut(name) - unless defined? Fast::Shortcut - require 'fast/shortcut' - Fast.load_fast_files! - end - shortcut = Fast.shortcuts[name.to_sym] exit_shortcut_not_found(name) unless shortcut shortcut @@ -382,7 +379,6 @@ def exit_shortcut_not_found(name) puts "Shortcut \033[1m#{name}\033[0m not found :(" if Fast.shortcuts.any? puts "Available shortcuts are: #{Fast.shortcuts.keys.join(', ')}." - Fast.load_fast_files! end exit 1 end diff --git a/lib/fast/gains.rb b/lib/fast/gains.rb index 0ece0f7..9111627 100644 --- a/lib/fast/gains.rb +++ b/lib/fast/gains.rb @@ -11,7 +11,7 @@ class Gains STORAGE_DIR = File.expand_path('~/.fast') STORAGE_FILE = File.join(STORAGE_DIR, 'gains.json') - attr_reader :command, :start_time, :total_bytes_searched, :total_bytes_reported, :files_count, :matched_files_count + attr_reader :command, :start_time, :total_bytes_searched, :total_bytes_reported, :files_count, :matched_files_count, :reports def initialize(command = nil) @command = command @@ -21,15 +21,18 @@ def initialize(command = nil) @files_count = 0 @matched_files_count = 0 @files_with_matches = [] + @reports = [] end def record_search(file) + return unless Fast.gain_tracking_enabled? @files_count += 1 size = File.size(file) rescue 0 @total_bytes_searched += size end def record_match(file) + return unless Fast.gain_tracking_enabled? unless @files_with_matches.include?(file) @files_with_matches << file @matched_files_count += 1 @@ -37,26 +40,30 @@ def record_match(file) end def record_report(content) + return unless Fast.gain_tracking_enabled? @total_bytes_reported += content.to_s.bytesize + @reports << content.to_s end def save! + return unless Fast.gain_tracking_enabled? return if @total_bytes_searched.zero? return if @total_bytes_reported.zero? # Honest gain: skip if nothing was found - data = history - data << { + data = { timestamp: @start_time.iso8601, command: @command, files_count: @files_count, matched_files_count: @matched_files_count, bytes_searched: @total_bytes_searched, bytes_reported: @total_bytes_reported, - savings_percent: savings_percent.round(2) + savings_percent: savings_percent.round(2), + reports: @reports } FileUtils.mkdir_p(STORAGE_DIR) - File.write(STORAGE_FILE, JSON.pretty_generate(data)) + temp_filename = File.join(STORAGE_DIR, "gains-#{Time.now.to_f}-#{Process.pid}.json") + File.write(temp_filename, JSON.generate(data)) end def savings_percent @@ -66,15 +73,50 @@ def savings_percent end def history - return [] unless File.exist?(STORAGE_FILE) + all_data = [] + if File.exist?(STORAGE_FILE) + all_data = JSON.parse(File.read(STORAGE_FILE), symbolize_names: true) rescue [] + end + all_data + end + + def self.consolidate! + FileUtils.mkdir_p(STORAGE_DIR) + all_data = [] + + File.open(STORAGE_FILE, File::RDWR|File::CREAT, 0644) do |f| + f.flock(File::LOCK_EX) + + content = f.read + all_data = JSON.parse(content, symbolize_names: true) rescue [] unless content.empty? + + temp_files = Dir.glob(File.join(STORAGE_DIR, 'gains-*.json')) + temp_files.each do |file| + begin + temp_data = JSON.parse(File.read(file), symbolize_names: true) + all_data << temp_data + File.delete(file) + rescue + # Skip corrupted files + end + end + + all_data.sort_by! { |h| h[:timestamp] || '' } + + # Keep only reports for the last 5 runs to avoid huge files + all_data.each_with_index do |h, i| + h.delete(:reports) if i < all_data.size - 5 + end - JSON.parse(File.read(STORAGE_FILE), symbolize_names: true) - rescue StandardError - [] + f.rewind + f.truncate(0) + f.write(JSON.pretty_generate(all_data)) + end + all_data end def self.report(filter = nil) - all_history = new.history + all_history = consolidate! return puts "No gains recorded yet. Start searching with `fast`!" if all_history.empty? if filter == 'mcp' diff --git a/lib/fast/mcp_server.rb b/lib/fast/mcp_server.rb index 9417ed5..dc5adab 100644 --- a/lib/fast/mcp_server.rb +++ b/lib/fast/mcp_server.rb @@ -153,6 +153,7 @@ def handle_request(request) end def handle_tool_call(id, params) + Fast.enable_gain_track! tool_name = params['name'] args = params['arguments'] || {} show_ast = args['show_ast'] || false diff --git a/mkdocs.yml b/mkdocs.yml index 99adfce..07947cb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -38,6 +38,7 @@ nav: - Git Integration: git.md - Agents & LLMs: - Fast for LLMs and Agents: agents.md + - Gains (Efficiency Tracking): gains.md - MCP Server Tutorial: mcp_tutorial.md - Code Similarity: similarity_tutorial.md - LLM/Agent Feature TODOs: llm_features.md diff --git a/spec/fast/cli_gains_spec.rb b/spec/fast/cli_gains_spec.rb index 5eda8cf..33329cb 100644 --- a/spec/fast/cli_gains_spec.rb +++ b/spec/fast/cli_gains_spec.rb @@ -12,6 +12,7 @@ stub_const('Fast::Gains::STORAGE_DIR', temp_dir) stub_const('Fast::Gains::STORAGE_FILE', temp_file) FileUtils.mkdir_p(temp_dir) + Fast.enable_gain_track! end after do @@ -26,6 +27,8 @@ cli = Fast::Cli.new(['(def hello)', test_file, '--no-color']) expect { cli.run! }.to output(/def hello/).to_stdout + Fast::Gains.consolidate! + expect(File.exist?(temp_file)).to be true data = JSON.parse(File.read(temp_file)).last expect(data['bytes_searched']).to eq(File.size(test_file)) diff --git a/spec/fast/gains_spec.rb b/spec/fast/gains_spec.rb index e0e1d77..72ad6d3 100644 --- a/spec/fast/gains_spec.rb +++ b/spec/fast/gains_spec.rb @@ -13,6 +13,7 @@ stub_const('Fast::Gains::STORAGE_DIR', temp_dir) stub_const('Fast::Gains::STORAGE_FILE', temp_file) FileUtils.mkdir_p(temp_dir) + Fast.enable_gain_track! end after do @@ -49,7 +50,7 @@ end describe '#save!' do - it 'saves data to the JSON file if there are reports' do + it 'saves data to a unique JSON file if there are reports' do file = File.join(temp_dir, 'test.rb') File.write(file, 'test content') @@ -57,6 +58,11 @@ subject.record_report('match') subject.save! + temp_files = Dir.glob(File.join(temp_dir, 'gains-*.json')) + expect(temp_files).not_to be_empty + + described_class.consolidate! + expect(File.exist?(temp_file)).to be true data = JSON.parse(File.read(temp_file)) expect(data.last['bytes_searched']).to eq(File.size(file)) @@ -70,7 +76,96 @@ subject.record_search(file) subject.save! + temp_files = Dir.glob(File.join(temp_dir, 'gains-*.json')) + expect(temp_files).to be_empty + end + + it 'saves multiple runs to different files and consolidates them' do + # Run 1 + g1 = Fast::Gains.new('run 1') + File.write(File.join(temp_dir, 'f1.rb'), 'hello') + g1.record_search(File.join(temp_dir, 'f1.rb')) + g1.record_report('match 1') + g1.save! + + # Run 2 + g2 = Fast::Gains.new('run 2') + File.write(File.join(temp_dir, 'f2.rb'), 'world') + g2.record_search(File.join(temp_dir, 'f2.rb')) + g2.record_report('match 2') + g2.save! + + # Verify two temp files exist + temp_files = Dir.glob(File.join(temp_dir, 'gains-*.json')) + expect(temp_files.size).to eq(2) expect(File.exist?(temp_file)).to be false + + # Consolidate + data = Fast::Gains.consolidate! + + # Verify temp files are gone and gains.json exists + expect(Dir.glob(File.join(temp_dir, 'gains-*.json'))).to be_empty + expect(File.exist?(temp_file)).to be true + + expect(data.size).to eq(2) + expect(data.map { |h| h[:command] }).to contain_exactly('run 1', 'run 2') + expect(data.last[:reports]).to contain_exactly('match 2') + end + + it 'keeps reports only for the latest runs' do + 6.times do |i| + g = Fast::Gains.new("run #{i}") + File.write(File.join(temp_dir, "f#{i}.rb"), "content #{i}") + g.record_search(File.join(temp_dir, "f#{i}.rb")) + g.record_report("patch #{i}") + g.save! + end + + Fast::Gains.consolidate! + + data = JSON.parse(File.read(temp_file), symbolize_names: true) + expect(data.size).to eq(6) + + # Run 0 should not have reports + expect(data[0][:reports]).to be_nil + # Run 5 should have reports + expect(data[5][:reports]).to contain_exactly('patch 5') + # Run 1 should have reports (since it's index 1 in 6 entries, it's one of the last 5) + expect(data[1][:reports]).to contain_exactly('patch 1') + end + + it 'does NOT record or save anything if disabled' do + Fast.disable_gain_track! + file = File.join(temp_dir, 'test.rb') + File.write(file, 'test content') + + subject.record_search(file) + subject.record_match(file) + subject.record_report('match') + subject.save! + + expect(subject.files_count).to eq(0) + expect(subject.matched_files_count).to eq(0) + expect(subject.total_bytes_reported).to eq(0) + + temp_files = Dir.glob(File.join(temp_dir, 'gains-*.json')) + expect(temp_files).to be_empty + end + + it 'does NOT record or save anything if FAST_GAINS=0' do + stub_const('ENV', ENV.to_h.merge('FAST_GAINS' => '0')) + file = File.join(temp_dir, 'test.rb') + File.write(file, 'test content') + + subject.record_search(file) + subject.record_match(file) + subject.record_report('match') + subject.save! + + expect(subject.files_count).to eq(0) + + temp_files = Dir.glob(File.join(temp_dir, 'gains-*.json')) + expect(temp_files).to be_empty end end diff --git a/spec/fast/mcp_server_spec.rb b/spec/fast/mcp_server_spec.rb index eff27e2..2eda173 100644 --- a/spec/fast/mcp_server_spec.rb +++ b/spec/fast/mcp_server_spec.rb @@ -38,6 +38,7 @@ allow(server).to receive(:write_response) server.send(:handle_tool_call, '1', params) + Fast::Gains.consolidate! expect(File.exist?(temp_file)).to be true data = JSON.parse(File.read(temp_file)).last @@ -61,6 +62,7 @@ allow(server).to receive(:write_response) server.send(:handle_tool_call, '1', params) + Fast::Gains.consolidate! expect(File.exist?(temp_file)).to be true data = JSON.parse(File.read(temp_file)).last From 200015a9526c7bea19f7332928f37da4a9cbce12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Mon, 13 Apr 2026 14:15:30 -0300 Subject: [PATCH 07/21] debug CI failure in PrismAdapter --- lib/fast/prism_adapter.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/fast/prism_adapter.rb b/lib/fast/prism_adapter.rb index 99d944f..a3b432c 100644 --- a/lib/fast/prism_adapter.rb +++ b/lib/fast/prism_adapter.rb @@ -92,7 +92,9 @@ def adapt(node, source, buffer_name) when Prism::StatementsNode adapt_statements(node, source, buffer_name) when Prism::AliasMethodNode, Prism::AliasGlobalVariableNode - build_node(:alias, [adapt(node.new_name, source, buffer_name), adapt(node.old_name, source, buffer_name)], node, source, buffer_name) + res = build_node(:alias, [adapt(node.new_name, source, buffer_name), adapt(node.old_name, source, buffer_name)], node, source, buffer_name) + puts "DEBUG ALIAS: #{res.inspect}" if source.include?("alias old new") + res when Prism::DefinedNode build_node(:defined?, [adapt(node.value, source, buffer_name)], node, source, buffer_name) when Prism::UndefNode From c1b80ebcffb238fb3406ec99da6bbafe411d1a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Mon, 13 Apr 2026 14:16:53 -0300 Subject: [PATCH 08/21] debug CI failure with spec output --- spec/fast/prism_adapter_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/fast/prism_adapter_spec.rb b/spec/fast/prism_adapter_spec.rb index 11f25b0..843f750 100644 --- a/spec/fast/prism_adapter_spec.rb +++ b/spec/fast/prism_adapter_spec.rb @@ -228,6 +228,7 @@ def self.l RUBY tree = described_class.parse(source) + puts "DEBUG TREE: #{tree.inspect}" expect(Fast.search('(alias (sym "old") (sym "new"))', tree)).not_to be_empty expect(Fast.search('(alias (sym "old_sym") (sym "new_sym"))', tree)).not_to be_empty From 89f6cb9e0f37fc283e70e30ac0d9ccf00d888e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Mon, 13 Apr 2026 14:17:46 -0300 Subject: [PATCH 09/21] debug Prism errors on CI --- lib/fast/prism_adapter.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/fast/prism_adapter.rb b/lib/fast/prism_adapter.rb index a3b432c..3d07e11 100644 --- a/lib/fast/prism_adapter.rb +++ b/lib/fast/prism_adapter.rb @@ -73,7 +73,10 @@ def updated(type = nil, children = nil, properties = nil) def parse(source, buffer_name: '(string)') result = Prism.parse(source) - return unless result.success? + unless result.success? + puts "PRISM ERRORS: #{result.errors.map(&:message).join(', ')}" + return + end adapt(result.value, source, buffer_name) end From e0a5a73cf9ff0d1b24377942a952698f2ad8b01c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Mon, 13 Apr 2026 14:43:55 -0300 Subject: [PATCH 10/21] Fix PrismAdapter: Location initialization and interpolation --- lib/fast/prism_adapter.rb | 728 +++++++++++++++----------------- spec/fast/prism_adapter_spec.rb | 5 +- 2 files changed, 346 insertions(+), 387 deletions(-) diff --git a/lib/fast/prism_adapter.rb b/lib/fast/prism_adapter.rb index 3d07e11..ec11dec 100644 --- a/lib/fast/prism_adapter.rb +++ b/lib/fast/prism_adapter.rb @@ -1,26 +1,20 @@ # frozen_string_literal: true require 'prism' -require 'fast/source' module Fast + # Adapts Prism AST to Fast AST module PrismAdapter - module_function - - class Location + # Location allows to track the source code range from the Prism location + class Location < Fast::Source::Range attr_accessor :node - attr_reader :expression def initialize(buffer_name, source, start_offset, end_offset, prism_node: nil) + @prism_node = prism_node @buffer_name = buffer_name @source = source - @prism_node = prism_node - buffer = Fast::Source.buffer(buffer_name, source: source) - @expression = Fast::Source.range( - buffer, - character_offset(source, start_offset), - character_offset(source, end_offset) - ) + source_buffer = Fast::Source::Buffer.new(buffer_name, source: source) + super(source_buffer, start_offset, end_offset) end def name @@ -41,416 +35,382 @@ def operator range_for(@prism_node.operator_loc) end - private - - def character_offset(source, byte_offset) - source.byteslice(0, byte_offset).to_s.length + def expression + self end + private + def range_for(prism_location) - buffer = Fast::Source.buffer(@buffer_name, source: @source) - Fast::Source.range( - buffer, + Fast::Source::Range.new( + source_buffer, character_offset(@source, prism_location.start_offset), character_offset(@source, prism_location.end_offset) ) end - end - - class Node < Fast::Node - def initialize(type, children:, loc:) - super(type, Array(children), location: loc) - end - def updated(type = nil, children = nil, properties = nil) - self.class.new( - type || self.type, - children: children || self.children, - loc: properties&.fetch(:location, loc) || loc - ) + def character_offset(source, byte_offset) + source.byteslice(0, byte_offset).length end end - def parse(source, buffer_name: '(string)') - result = Prism.parse(source) - unless result.success? - puts "PRISM ERRORS: #{result.errors.map(&:message).join(', ')}" - return - end + class << self + def parse(source, buffer_name: '(string)') + result = Prism.parse(source) + return unless result.success? - adapt(result.value, source, buffer_name) - end + adapt(result.value, source, buffer_name) + end - def adapt(node, source, buffer_name) - return if node.nil? - - case node - when Symbol - build_node(:sym, [node.to_s], nil, source, buffer_name) - when String - build_node(:str, [node], nil, source, buffer_name) - when Prism::ProgramNode - statements = adapt_statements(node.statements, source, buffer_name) - statements.is_a?(Node) ? statements : build_node(:begin, statements, node, source, buffer_name) - when Prism::StatementsNode - adapt_statements(node, source, buffer_name) - when Prism::AliasMethodNode, Prism::AliasGlobalVariableNode - res = build_node(:alias, [adapt(node.new_name, source, buffer_name), adapt(node.old_name, source, buffer_name)], node, source, buffer_name) - puts "DEBUG ALIAS: #{res.inspect}" if source.include?("alias old new") - res - when Prism::DefinedNode - build_node(:defined?, [adapt(node.value, source, buffer_name)], node, source, buffer_name) - when Prism::UndefNode - build_node(:undef, node.names.map { |name| adapt(name, source, buffer_name) }, node, source, buffer_name) - when Prism::ModuleNode - build_node(:module, [adapt(node.constant_path, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) - when Prism::ClassNode - build_node(:class, [adapt(node.constant_path, source, buffer_name), adapt(node.superclass, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) - when Prism::SingletonClassNode - build_node(:sclass, [adapt(node.expression, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) - when Prism::DefNode - if node.receiver - build_node(:defs, [adapt(node.receiver, source, buffer_name), node.name, adapt_parameters(node.parameters, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) - else - build_node(:def, [node.name, adapt_parameters(node.parameters, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) - end - when Prism::BlockNode - return nil unless node.respond_to?(:call) - - build_node(:block, [adapt_call_node(node.call, source, buffer_name), adapt_block_parameters(node.parameters, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) - when Prism::CallNode - if node.respond_to?(:block) && node.block.is_a?(Prism::BlockNode) - return build_node( - :block, - [ - adapt_call_node(node, source, buffer_name), - adapt_block_parameters(node.block.parameters, source, buffer_name), - adapt(node.block.body, source, buffer_name) - ], - node, - source, - buffer_name - ) - end + def adapt(node, source, buffer_name) + return if node.nil? + + case node + when Symbol + build_node(:sym, [node.to_s], nil, source, buffer_name) + when String + build_node(:str, [node], nil, source, buffer_name) + when Prism::ProgramNode + statements = adapt_statements(node.statements, source, buffer_name) + statements.is_a?(Node) ? statements : build_node(:begin, statements, node, source, buffer_name) + when Prism::StatementsNode + adapt_statements(node, source, buffer_name) + when Prism::AliasMethodNode, Prism::AliasGlobalVariableNode + build_node(:alias, [adapt(node.new_name, source, buffer_name), adapt(node.old_name, source, buffer_name)], node, source, buffer_name) + when Prism::DefinedNode + build_node(:defined?, [adapt(node.value, source, buffer_name)], node, source, buffer_name) + when Prism::UndefNode + build_node(:undef, node.names.map { |name| adapt(name, source, buffer_name) }, node, source, buffer_name) + when Prism::ModuleNode + build_node(:module, [adapt(node.constant_path, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) + when Prism::ClassNode + build_node(:class, [adapt(node.constant_path, source, buffer_name), adapt(node.superclass, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) + when Prism::SingletonClassNode + build_node(:sclass, [adapt(node.expression, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) + when Prism::DefNode + if node.receiver + build_node(:defs, [adapt(node.receiver, source, buffer_name), node.name, adapt_parameters(node.parameters, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) + else + build_node(:def, [node.name, adapt_parameters(node.parameters, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) + end + when Prism::BlockNode + return nil unless node.respond_to?(:call) + + build_node(:block, [adapt_call_node(node.call, source, buffer_name), adapt_block_parameters(node.parameters, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) + when Prism::CallNode + if node.respond_to?(:block) && node.block.is_a?(Prism::BlockNode) + return build_node( + :block, + [ + adapt_call_node(node, source, buffer_name), + adapt_block_parameters(node.block.parameters, source, buffer_name), + adapt(node.block.body, source, buffer_name) + ], + node, + source, + buffer_name + ) + end - adapt_call_node(node, source, buffer_name) - when Prism::ParenthesesNode - adapt(node.body, source, buffer_name) - when Prism::RangeNode - build_node(node.exclude_end? ? :erange : :irange, [adapt(node.left, source, buffer_name), adapt(node.right, source, buffer_name)], node, source, buffer_name) - when Prism::BlockArgumentNode - build_node(:block_pass, [adapt(node.expression, source, buffer_name)], node, source, buffer_name) - when Prism::ReturnNode - build_node(:return, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) }, node, source, buffer_name) - when Prism::NextNode - build_node(:next, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) }, node, source, buffer_name) - when Prism::BreakNode - build_node(:break, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) }, node, source, buffer_name) - when Prism::YieldNode - build_node(:yield, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) }, node, source, buffer_name) - when Prism::SuperNode - build_node(:super, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) }, node, source, buffer_name) - when Prism::ForwardingSuperNode - build_node(:zsuper, [], node, source, buffer_name) - when Prism::SplatNode - build_node(:splat, [adapt(node.expression, source, buffer_name)], node, source, buffer_name) - when Prism::AssocSplatNode - build_node(:kwsplat, [adapt(node.value, source, buffer_name)], node, source, buffer_name) - when Prism::ConstantPathNode - build_const_path(node, source, buffer_name) - - when Prism::ConstantReadNode - build_node(:const, [nil, node.name], node, source, buffer_name) - when Prism::ConstantWriteNode - build_node(:casgn, [nil, node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name) - when Prism::ConstantPathWriteNode - build_node(:casgn, [adapt(node.target, source, buffer_name), nil, adapt(node.value, source, buffer_name)], node, source, buffer_name) - when Prism::SymbolNode - build_node(:sym, [node.unescaped], node, source, buffer_name) - when Prism::StringNode - build_node(:str, [node.unescaped], node, source, buffer_name) - when Prism::XStringNode - build_node(:xstr, [node.unescaped], node, source, buffer_name) - when Prism::InterpolatedStringNode - build_node(:dstr, node.parts.filter_map { |part| adapt(part, source, buffer_name) }, node, source, buffer_name) - when Prism::InterpolatedXStringNode - build_node(:dxstr, node.parts.filter_map { |part| adapt(part, source, buffer_name) }, node, source, buffer_name) - when Prism::InterpolatedSymbolNode - build_node(:dsym, node.parts.filter_map { |part| adapt(part, source, buffer_name) }, node, source, buffer_name) - when Prism::RegularExpressionNode - build_node(:regexp, [build_node(:str, [node.unescaped], node, source, buffer_name), build_node(:regopt, regexp_options(node), node, source, buffer_name)], node, source, buffer_name) - when Prism::InterpolatedRegularExpressionNode - build_node(:regexp, node.parts.filter_map { |part| adapt(part, source, buffer_name) } + [build_node(:regopt, regexp_options(node), node, source, buffer_name)], node, source, buffer_name) - when Prism::ArrayNode - build_node(:array, node.elements.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name) - when Prism::HashNode - build_node(:hash, node.elements.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name) - when Prism::KeywordHashNode - build_node(:hash, node.elements.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name) - when Prism::AssocNode - build_node(:pair, [adapt(node.key, source, buffer_name), adapt(node.value, source, buffer_name)], node, source, buffer_name) - when Prism::SelfNode - build_node(:self, [], node, source, buffer_name) - when Prism::RedoNode - build_node(:redo, [], node, source, buffer_name) - when Prism::RetryNode - build_node(:retry, [], node, source, buffer_name) - when Prism::PreExecutionNode - build_node(:preexe, [adapt_statements(node.statements, source, buffer_name)], node, source, buffer_name) - when Prism::PostExecutionNode - build_node(:postexe, [adapt_statements(node.statements, source, buffer_name)], node, source, buffer_name) - when Prism::NumberedReferenceReadNode - build_node(:nth_ref, [node.number], node, source, buffer_name) - when Prism::BackReferenceReadNode - build_node(:back_ref, [node.name], node, source, buffer_name) - when Prism::LocalVariableReadNode - build_node(:lvar, [node.name], node, source, buffer_name) - when Prism::LocalVariableTargetNode - build_node(:lvasgn, [node.name], node, source, buffer_name) - when Prism::InstanceVariableReadNode - build_node(:ivar, [node.name], node, source, buffer_name) - when Prism::GlobalVariableReadNode - build_node(:gvar, [node.name], node, source, buffer_name) - when Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode - build_node(:ivasgn, [node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name) - when Prism::GlobalVariableWriteNode - build_node(:gvasgn, [node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name) - when Prism::LocalVariableWriteNode, Prism::LocalVariableOrWriteNode - build_node(:lvasgn, [node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name) - when Prism::LocalVariableOperatorWriteNode - build_node( - :op_asgn, - [ - build_node(:lvasgn, [node.name], node, source, buffer_name), - (node.respond_to?(:binary_operator) ? node.binary_operator : node.operator), - adapt(node.value, source, buffer_name) - ], - node, - source, - buffer_name - ) - when Prism::MatchWriteNode - build_node(:match_with_lvasgn, [adapt(node.call.receiver, source, buffer_name), adapt(node.call.arguments&.arguments&.first, source, buffer_name)], node, source, buffer_name) - when Prism::MatchLastLineNode - build_node(:match_current_line, [build_node(:regexp, [build_node(:str, [node.unescaped], node, source, buffer_name), build_node(:regopt, regexp_options(node), node, source, buffer_name)], node, source, buffer_name)], node, source, buffer_name) - when Prism::IntegerNode - build_node(:int, [node.value], node, source, buffer_name) - when Prism::FloatNode - build_node(:float, [node.value], node, source, buffer_name) - when Prism::RationalNode - build_node(:rational, [node.value], node, source, buffer_name) - when Prism::ImaginaryNode - build_node(:complex, [node.value], node, source, buffer_name) - when Prism::TrueNode - build_node(:true, [], node, source, buffer_name) - when Prism::FalseNode - build_node(:false, [], node, source, buffer_name) - when Prism::NilNode - build_node(:nil, [], node, source, buffer_name) - when Prism::AndNode - build_node(:and, [adapt(node.left, source, buffer_name), adapt(node.right, source, buffer_name)], node, source, buffer_name) - when Prism::OrNode - build_node(:or, [adapt(node.left, source, buffer_name), adapt(node.right, source, buffer_name)], node, source, buffer_name) - when Prism::IfNode - build_node(:if, [adapt(node.predicate, source, buffer_name), adapt(node.statements, source, buffer_name), adapt(node.consequent, source, buffer_name)], node, source, buffer_name) - when Prism::UnlessNode - build_node(:if, [adapt(node.predicate, source, buffer_name), adapt(node.consequent, source, buffer_name), adapt(node.statements, source, buffer_name)], node, source, buffer_name) - when Prism::WhileNode - build_node(:while, [adapt(node.predicate, source, buffer_name), adapt(node.statements, source, buffer_name)], node, source, buffer_name) - when Prism::UntilNode - build_node(:until, [adapt(node.predicate, source, buffer_name), adapt(node.statements, source, buffer_name)], node, source, buffer_name) - when Prism::ForNode - build_node(:for, [adapt(node.index, source, buffer_name), adapt(node.collection, source, buffer_name), adapt(node.statements, source, buffer_name)], node, source, buffer_name) - when Prism::MultiWriteNode - mlhs_children = node.lefts.map { |left| adapt(left, source, buffer_name) } - mlhs_children << adapt(node.rest, source, buffer_name) if node.rest - mlhs_children.concat(node.rights.map { |right| adapt(right, source, buffer_name) }) if node.respond_to?(:rights) - build_node(:masgn, [build_node(:mlhs, mlhs_children, node, source, buffer_name), adapt(node.value, source, buffer_name)], node, source, buffer_name) - when Prism::RescueModifierNode - build_node(:rescue, [adapt(node.expression, source, buffer_name), build_node(:resbody, [nil, nil, adapt(node.rescue_expression, source, buffer_name)], node, source, buffer_name), nil], node, source, buffer_name) - when Prism::CaseNode - children = [adapt(node.predicate, source, buffer_name)] - children.concat(node.conditions.map { |condition| adapt(condition, source, buffer_name) }) - children << adapt(node.consequent, source, buffer_name) if node.consequent - build_node(:case, children, node, source, buffer_name) - when Prism::HashPatternNode - build_node(:hash, node.elements.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name) - when Prism::WhenNode - condition = - if node.conditions.length == 1 - adapt(node.conditions.first, source, buffer_name) + adapt_call_node(node, source, buffer_name) + when Prism::ParenthesesNode + adapt(node.body, source, buffer_name) + when Prism::RangeNode + build_node(node.exclude_end? ? :erange : :irange, [adapt(node.left, source, buffer_name), adapt(node.right, source, buffer_name)], node, source, buffer_name) + when Prism::BlockArgumentNode + build_node(:block_pass, [adapt(node.expression, source, buffer_name)], node, source, buffer_name) + when Prism::ReturnNode + build_node(:return, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) }, node, source, buffer_name) + when Prism::NextNode + build_node(:next, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) }, node, source, buffer_name) + when Prism::BreakNode + build_node(:break, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) }, node, source, buffer_name) + when Prism::YieldNode + build_node(:yield, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) }, node, source, buffer_name) + when Prism::SuperNode + build_node(:super, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) }, node, source, buffer_name) + when Prism::ForwardingSuperNode + build_node(:zsuper, [], node, source, buffer_name) + when Prism::SplatNode + build_node(:splat, [adapt(node.expression, source, buffer_name)], node, source, buffer_name) + when Prism::AssocSplatNode + build_node(:kwsplat, [adapt(node.value, source, buffer_name)], node, source, buffer_name) + when Prism::ConstantPathNode + build_const_path(node, source, buffer_name) + + when Prism::ConstantReadNode + build_node(:const, [nil, node.name], node, source, buffer_name) + when Prism::ConstantWriteNode + build_node(:casgn, [nil, node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name) + when Prism::ConstantPathWriteNode + build_node(:casgn, [adapt(node.target, source, buffer_name), nil, adapt(node.value, source, buffer_name)], node, source, buffer_name) + when Prism::SymbolNode + build_node(:sym, [node.unescaped], node, source, buffer_name) + when Prism::StringNode + build_node(:str, [node.unescaped], node, source, buffer_name) + when Prism::XStringNode + build_node(:xstr, [node.unescaped], node, source, buffer_name) + when Prism::InterpolatedStringNode + build_node(:dstr, node.parts.filter_map { |part| adapt(part, source, buffer_name) }, node, source, buffer_name) + when Prism::InterpolatedXStringNode + build_node(:dxstr, node.parts.filter_map { |part| adapt(part, source, buffer_name) }, node, source, buffer_name) + when Prism::EmbeddedStatementsNode + build_node(:begin, [adapt(node.statements, source, buffer_name)], node, source, buffer_name) + when Prism::InterpolatedSymbolNode + build_node(:dsym, node.parts.filter_map { |part| adapt(part, source, buffer_name) }, node, source, buffer_name) + when Prism::RegularExpressionNode + build_node(:regexp, [build_node(:str, [node.unescaped], node, source, buffer_name), build_node(:regopt, regexp_options(node), node, source, buffer_name)], node, source, buffer_name) + when Prism::InterpolatedRegularExpressionNode + build_node(:regexp, node.parts.filter_map { |part| adapt(part, source, buffer_name) } + [build_node(:regopt, regexp_options(node), node, source, buffer_name)], node, source, buffer_name) + when Prism::ArrayNode + build_node(:array, node.elements.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name) + when Prism::HashNode + build_node(:hash, node.elements.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name) + when Prism::KeywordHashNode + build_node(:hash, node.elements.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name) + when Prism::AssocNode + build_node(:pair, [adapt(node.key, source, buffer_name), adapt(node.value, source, buffer_name)], node, source, buffer_name) + when Prism::SelfNode + build_node(:self, [], node, source, buffer_name) + when Prism::RedoNode + build_node(:redo, [], node, source, buffer_name) + when Prism::RetryNode + build_node(:retry, [], node, source, buffer_name) + when Prism::PreExecutionNode + build_node(:preexe, [adapt_statements(node.statements, source, buffer_name)], node, source, buffer_name) + when Prism::PostExecutionNode + build_node(:postexe, [adapt_statements(node.statements, source, buffer_name)], node, source, buffer_name) + when Prism::NumberedReferenceReadNode + build_node(:nth_ref, [node.number], node, source, buffer_name) + when Prism::BackReferenceReadNode + build_node(:back_ref, [node.name], node, source, buffer_name) + when Prism::LocalVariableReadNode + build_node(:lvar, [node.name], node, source, buffer_name) + when Prism::LocalVariableTargetNode + build_node(:lvasgn, [node.name], node, source, buffer_name) + when Prism::InstanceVariableReadNode + build_node(:ivar, [node.name], node, source, buffer_name) + when Prism::GlobalVariableReadNode + build_node(:gvar, [node.name], node, source, buffer_name) + when Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode + build_node(:ivasgn, [node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name) + when Prism::GlobalVariableWriteNode + build_node(:gvasgn, [node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name) + when Prism::LocalVariableWriteNode, Prism::LocalVariableOrWriteNode + build_node(:lvasgn, [node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name) + when Prism::LocalVariableOperatorWriteNode + build_node(:op_asgn, [build_node(:lvasgn, [node.name], node, source, buffer_name), node.operator, adapt(node.value, source, buffer_name)], node, source, buffer_name) + when Prism::LocalVariableAndWriteNode + build_node(:and_asgn, [build_node(:lvasgn, [node.name], node, source, buffer_name), adapt(node.value, source, buffer_name)], node, source, buffer_name) + when Prism::InstanceVariableOperatorWriteNode + build_node(:op_asgn, [build_node(:ivasgn, [node.name], node, source, buffer_name), node.operator, adapt(node.value, source, buffer_name)], node, source, buffer_name) + when Prism::InstanceVariableAndWriteNode + build_node(:and_asgn, [build_node(:ivasgn, [node.name], node, source, buffer_name), adapt(node.value, source, buffer_name)], node, source, buffer_name) + when Prism::ClassVariableWriteNode + build_node(:cvasgn, [node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name) + when Prism::ClassVariableReadNode + build_node(:cvar, [node.name], node, source, buffer_name) + when Prism::CallAndWriteNode + build_node(:and_asgn, [adapt(node.target, source, buffer_name), adapt(node.value, source, buffer_name)], node, source, buffer_name) + when Prism::CallOperatorWriteNode + build_node(:op_asgn, [adapt(node.target, source, buffer_name), node.operator, adapt(node.value, source, buffer_name)], node, source, buffer_name) + when Prism::IndexAndWriteNode + build_node(:and_asgn, [adapt(node.target, source, buffer_name), adapt(node.value, source, buffer_name)], node, source, buffer_name) + when Prism::IndexOperatorWriteNode + build_node(:op_asgn, [adapt(node.target, source, buffer_name), node.operator, adapt(node.value, source, buffer_name)], node, source, buffer_name) + when Prism::MatchWriteNode + build_node(:match_with_lvasgn, [adapt(node.call, source, buffer_name), node.targets.map { |t| adapt(t, source, buffer_name) }], node, source, buffer_name) + when Prism::MatchLastLineNode + build_node(:match_current_line, [node.location.slice], node, source, buffer_name) + when Prism::IntegerNode + build_node(:int, [node.value], node, source, buffer_name) + when Prism::FloatNode + build_node(:float, [node.value], node, source, buffer_name) + when Prism::RationalNode + build_node(:rational, [node.value], node, source, buffer_name) + when Prism::ImaginaryNode + build_node(:complex, [node.value], node, source, buffer_name) + when Prism::TrueNode + build_node(:true, [], node, source, buffer_name) + when Prism::FalseNode + build_node(:false, [], node, source, buffer_name) + when Prism::NilNode + build_node(:nil, [], node, source, buffer_name) + when Prism::AndNode + build_node(:and, [adapt(node.left, source, buffer_name), adapt(node.right, source, buffer_name)], node, source, buffer_name) + when Prism::OrNode + build_node(:or, [adapt(node.left, source, buffer_name), adapt(node.right, source, buffer_name)], node, source, buffer_name) + when Prism::IfNode + build_node(:if, [adapt(node.predicate, source, buffer_name), adapt(node.statements, source, buffer_name), adapt(node.consequent, source, buffer_name)], node, source, buffer_name) + when Prism::UnlessNode + build_node(:if, [adapt(node.predicate, source, buffer_name), adapt(node.consequent, source, buffer_name), adapt(node.statements, source, buffer_name)], node, source, buffer_name) + when Prism::WhileNode + build_node(:while, [adapt(node.predicate, source, buffer_name), adapt(node.statements, source, buffer_name)], node, source, buffer_name) + when Prism::UntilNode + build_node(:until, [adapt(node.predicate, source, buffer_name), adapt(node.statements, source, buffer_name)], node, source, buffer_name) + when Prism::ForNode + build_node(:for, [adapt(node.index, source, buffer_name), adapt(node.collection, source, buffer_name), adapt(node.statements, source, buffer_name)], node, source, buffer_name) + when Prism::MultiWriteNode + mlhs_children = node.lefts.map { |left| adapt(left, source, buffer_name) } + mlhs_children << adapt(node.rest, source, buffer_name) if node.rest + mlhs_children.concat(node.rights.map { |right| adapt(right, source, buffer_name) }) if node.respond_to?(:rights) + build_node(:masgn, [build_node(:mlhs, mlhs_children, node, source, buffer_name), adapt(node.value, source, buffer_name)], node, source, buffer_name) + when Prism::RescueModifierNode + build_node(:rescue, [adapt(node.expression, source, buffer_name), build_node(:resbody, [nil, nil, adapt(node.rescue_expression, source, buffer_name)], node, source, buffer_name), nil], node, source, buffer_name) + when Prism::CaseNode + children = [adapt(node.predicate, source, buffer_name)] + children.concat(node.conditions.map { |condition| adapt(condition, source, buffer_name) }) + children << adapt(node.consequent, source, buffer_name) if node.consequent + build_node(:case, children, node, source, buffer_name) + when Prism::HashPatternNode + build_node(:hash, node.elements.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name) + when Prism::WhenNode + condition = + if node.conditions.length == 1 + adapt(node.conditions.first, source, buffer_name) + else + build_node(:array, node.conditions.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name) + end + build_node(:when, [condition, adapt(node.statements, source, buffer_name)].compact, node, source, buffer_name) + when Prism::BeginNode + rescue_bodies = node.rescue_clause ? adapt_rescue_clause(node.rescue_clause, source, buffer_name) : [] + ensure_body = node.ensure_clause ? adapt(node.ensure_clause.statements, source, buffer_name) : nil + res = adapt(node.statements, source, buffer_name) + res = build_node(:kwbegin, [res], node, source, buffer_name) if node.location.slice.start_with?('begin') + if rescue_bodies.any? || ensure_body + build_node(:ensure, [build_node(:rescue, [res, *rescue_bodies, nil], node, source, buffer_name), ensure_body], node, source, buffer_name) else - build_node(:array, node.conditions.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name) + res end - build_node(:when, [condition, adapt(node.statements, source, buffer_name)].compact, node, source, buffer_name) - when Prism::ElseNode - adapt_else_clause(node, source, buffer_name) - when Prism::CallOperatorWriteNode - operator = node.respond_to?(:binary_operator) ? node.binary_operator : node.operator - build_node(:op_asgn, [build_node(:send, [adapt(node.receiver, source, buffer_name), node.read_name], node, source, buffer_name), operator, adapt(node.value, source, buffer_name)], node, source, buffer_name) - when Prism::IndexOperatorWriteNode - operator = node.respond_to?(:binary_operator) ? node.binary_operator : node.operator - build_node(:op_asgn, [build_node(:send, [adapt(node.receiver, source, buffer_name), :[]].concat(node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) }), node, source, buffer_name), operator, adapt(node.value, source, buffer_name)], node, source, buffer_name) - when Prism::BeginNode, Prism::EmbeddedStatementsNode - res = adapt_statements(node.statements, source, buffer_name) - if node.respond_to?(:rescue_clause) && node.rescue_clause - res = build_node(:rescue, [res].concat(adapt_rescue_clause(node.rescue_clause, source, buffer_name)).concat([adapt(node.else_clause, source, buffer_name)]), node, source, buffer_name) - end - if node.respond_to?(:ensure_clause) && node.ensure_clause - res = build_node(:ensure, [res, adapt(node.ensure_clause, source, buffer_name)], node, source, buffer_name) + when Prism::RescueNode + adapt_rescue_clause(node, source, buffer_name) + when Prism::EnsureNode + adapt(node.statements, source, buffer_name) + when Prism::LambdaNode + build_node(:block, [build_node(:send, [nil, :lambda], node, source, buffer_name), adapt_block_parameters(node.parameters, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) end - res - when Prism::RescueNode - adapt_rescue_clause(node, source, buffer_name) - when Prism::EnsureNode - adapt_statements(node.statements, source, buffer_name) - when Prism::EmbeddedVariableNode - build_node(:begin, [adapt(node.variable, source, buffer_name)].compact, node, source, buffer_name) - when Prism::ImplicitNode - adapt(node.value, source, buffer_name) - when Prism::LambdaNode - build_node(:lambda, [adapt_block_parameters(node.parameters, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) - else - nil - end - end - - def adapt_rescue_clause(node, source, buffer_name) - resbodies = [] - current = node - while current && current.is_a?(Prism::RescueNode) - exceptions = current.exceptions.map { |e| adapt(e, source, buffer_name) } - exceptions = build_node(:array, exceptions, current, source, buffer_name) if exceptions.any? - resbodies << build_node(:resbody, [exceptions, adapt(current.reference, source, buffer_name), adapt(current.statements, source, buffer_name)], current, source, buffer_name) - current = current.respond_to?(:subsequent) ? current.subsequent : current.consequent end - resbodies - end - def adapt_else_clause(node, source, buffer_name) - adapt(node&.statements, source, buffer_name) - end + def adapt_statements(node, source, buffer_name) + children = node.body.filter_map { |child| adapt(child, source, buffer_name) } + return nil if children.empty? + return children.first if children.one? - def wrap_begin(node, source, buffer_name) - return nil unless node + build_node(:begin, children, node, source, buffer_name) + end - res = adapt(node, source, buffer_name) - if res.is_a?(Node) && res.type == :begin - res - else - build_node(:begin, [nil, res], node, source, buffer_name) + def adapt_parameters(node, source, buffer_name) + return build_node(:args, [], nil, source, buffer_name) unless node + + children = [] + children.concat(node.requireds.map { |child| adapt_required_parameter(child, source, buffer_name) }) if node.respond_to?(:requireds) + children.concat(node.optionals.map { |child| build_node(:optarg, [parameter_name(child), adapt(child.value, source, buffer_name)], child, source, buffer_name) }) if node.respond_to?(:optionals) + children << build_node(:restarg, [parameter_name(node.rest)], node.rest, source, buffer_name) if node.respond_to?(:rest) && node.rest + children.concat(node.posts.map { |child| adapt_required_parameter(child, source, buffer_name) }) if node.respond_to?(:posts) + children.concat(node.keywords.map { |child| adapt_keyword_parameter(child, source, buffer_name) }) if node.respond_to?(:keywords) + children << build_node(:kwrestarg, [parameter_name(node.keyword_rest)], node.keyword_rest, source, buffer_name) if node.respond_to?(:keyword_rest) && node.keyword_rest + children << build_node(:blockarg, [parameter_name(node.block)], node.block, source, buffer_name) if node.respond_to?(:block) && node.block + build_node(:args, children, node, source, buffer_name) end - end - def adapt_statements(node, source, buffer_name) - return nil unless node + def adapt_block_parameters(node, source, buffer_name) + return build_node(:args, [], nil, source, buffer_name) unless node - children = node.body.filter_map { |child| adapt(child, source, buffer_name) } - return nil if children.empty? - return children.first if children.one? + params = node.respond_to?(:parameters) ? node.parameters : node + adapt_parameters(params, source, buffer_name) + end - build_node(:begin, children, node, source, buffer_name) - end + def adapt_required_parameter(child, source, buffer_name) + if child.is_a?(Prism::MultiTargetNode) + mlhs_children = child.lefts.map { |c| adapt_required_parameter(c, source, buffer_name) } + mlhs_children << build_node(:restarg, [parameter_name(child.rest)], child.rest, source, buffer_name) if child.respond_to?(:rest) && child.rest + mlhs_children.concat(child.rights.map { |c| adapt_required_parameter(c, source, buffer_name) }) if child.respond_to?(:rights) + build_node(:mlhs, mlhs_children, child, source, buffer_name) + else + build_node(:arg, [parameter_name(child)], child, source, buffer_name) + end + end - def adapt_parameters(node, source, buffer_name) - return build_node(:args, [], nil, source, buffer_name) unless node - - children = [] - children.concat(node.requireds.map { |child| adapt_required_parameter(child, source, buffer_name) }) if node.respond_to?(:requireds) - children.concat(node.optionals.map { |child| build_node(:optarg, [parameter_name(child), adapt(child.value, source, buffer_name)], child, source, buffer_name) }) if node.respond_to?(:optionals) - children << build_node(:restarg, [parameter_name(node.rest)], node.rest, source, buffer_name) if node.respond_to?(:rest) && node.rest - children.concat(node.posts.map { |child| adapt_required_parameter(child, source, buffer_name) }) if node.respond_to?(:posts) - children.concat(node.keywords.map { |child| adapt_keyword_parameter(child, source, buffer_name) }) if node.respond_to?(:keywords) - children << build_node(:kwrestarg, [parameter_name(node.keyword_rest)], node.keyword_rest, source, buffer_name) if node.respond_to?(:keyword_rest) && node.keyword_rest - children << build_node(:blockarg, [parameter_name(node.block)], node.block, source, buffer_name) if node.respond_to?(:block) && node.block - build_node(:args, children, node, source, buffer_name) - end + def adapt_keyword_parameter(node, source, buffer_name) + case node + when Prism::RequiredKeywordParameterNode + build_node(:kwarg, [parameter_name(node)], node, source, buffer_name) + when Prism::OptionalKeywordParameterNode + build_node(:kwoptarg, [parameter_name(node), adapt(node.value, source, buffer_name)], node, source, buffer_name) + else + build_node(:arg, [parameter_name(node)], node, source, buffer_name) + end + end - def adapt_block_parameters(node, source, buffer_name) - return build_node(:args, [], nil, source, buffer_name) unless node + def adapt_call_node(node, source, buffer_name) + children = [adapt(node.receiver, source, buffer_name), node.name] + children.concat(node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) } || []) + children << adapt(node.block, source, buffer_name) if node.respond_to?(:block) && node.block && !node.block.is_a?(Prism::BlockNode) + return build_node(:send, children, node, source, buffer_name) unless node.respond_to?(:block) && node.block.is_a?(Prism::BlockNode) - params = node.respond_to?(:parameters) ? node.parameters : node - adapt_parameters(params, source, buffer_name) - end + end_offset = node.block.location.start_offset + while end_offset > node.location.start_offset && source.byteslice(end_offset - 1, 1)&.match?(/\s/) + end_offset -= 1 + end - def adapt_required_parameter(child, source, buffer_name) - if child.is_a?(Prism::MultiTargetNode) - mlhs_children = child.lefts.map { |c| adapt_required_parameter(c, source, buffer_name) } - mlhs_children << build_node(:restarg, [parameter_name(child.rest)], child.rest, source, buffer_name) if child.respond_to?(:rest) && child.rest - mlhs_children.concat(child.rights.map { |c| adapt_required_parameter(c, source, buffer_name) }) if child.respond_to?(:rights) - build_node(:mlhs, mlhs_children, child, source, buffer_name) - else - build_node(:arg, [parameter_name(child)], child, source, buffer_name) + loc = Location.new( + buffer_name, + source, + node.location.start_offset, + end_offset, + prism_node: node + ) + send_node = Node.new(:send, children: children, loc: loc) + loc.node = send_node + send_node end - end - def adapt_keyword_parameter(node, source, buffer_name) - case node - when Prism::RequiredKeywordParameterNode - build_node(:kwarg, [parameter_name(node)], node, source, buffer_name) - when Prism::OptionalKeywordParameterNode - build_node(:kwoptarg, [parameter_name(node), adapt(node.value, source, buffer_name)], node, source, buffer_name) - else - build_node(:arg, [parameter_name(node)], node, source, buffer_name) + def regexp_options(node) + options = [] + options << :i if node.ignore_case? + options << :m if node.multi_line? + options << :x if node.extended? + options end - end - def adapt_call_node(node, source, buffer_name) - children = [adapt(node.receiver, source, buffer_name), node.name] - children.concat(node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) } || []) - children << adapt(node.block, source, buffer_name) if node.respond_to?(:block) && node.block && !node.block.is_a?(Prism::BlockNode) - return build_node(:send, children, node, source, buffer_name) unless node.respond_to?(:block) && node.block.is_a?(Prism::BlockNode) - - end_offset = node.block.location.start_offset - while end_offset > node.location.start_offset && source.byteslice(end_offset - 1, 1)&.match?(/\s/) - end_offset -= 1 + def parameter_name(node) + node.respond_to?(:name) ? node.name : nil end - loc = Location.new( - buffer_name, - source, - node.location.start_offset, - end_offset, - prism_node: node - ) - send_node = Node.new(:send, children: children, loc: loc) - loc.node = send_node - send_node - end - - def regexp_options(node) - options = [] - options << :i if node.ignore_case? - options << :m if node.multi_line? - options << :x if node.extended? - options - end - - def parameter_name(node) - node.respond_to?(:name) ? node.name : nil - end + def build_const_path(node, source, buffer_name) + parent = + if node.parent + adapt(node.parent, source, buffer_name) + elsif node.delimiter_loc + build_node(:cbase, [], nil, source, buffer_name) + end + name = node.respond_to?(:name) ? node.name : node.child.name + build_node(:const, [parent, name], node, source, buffer_name) + end - def build_const_path(node, source, buffer_name) - parent = - if node.parent - adapt(node.parent, source, buffer_name) - elsif node.delimiter_loc - build_node(:cbase, [], nil, source, buffer_name) - end - name = node.respond_to?(:name) ? node.name : node.child.name - build_node(:const, [parent, name], node, source, buffer_name) - end + def build_node(type, children, prism_node, source, buffer_name) + loc = + if prism_node + Location.new(buffer_name, source, prism_node.location.start_offset, prism_node.location.end_offset, prism_node: prism_node) + else + Location.new(buffer_name, source, 0, 0) + end + node = Node.new(type, children, location: loc) + loc.node = node + node + end - def build_node(type, children, prism_node, source, buffer_name) - loc = - if prism_node - Location.new(buffer_name, source, prism_node.location.start_offset, prism_node.location.end_offset, prism_node: prism_node) - else - Location.new(buffer_name, source, 0, 0) + def adapt_rescue_clause(node, source, buffer_name) + resbodies = [] + current = node + while current + exceptions = current.exceptions.map { |e| adapt(e, source, buffer_name) } + exception_variable = adapt(current.reference, source, buffer_name) + resbodies << build_node(:resbody, [build_node(:array, exceptions, current, source, buffer_name), exception_variable, adapt(current.statements, source, buffer_name)], current, source, buffer_name) + current = current.consequent end - node = Node.new(type, children: children, loc: loc) - loc.node = node - node + resbodies + end end end end diff --git a/spec/fast/prism_adapter_spec.rb b/spec/fast/prism_adapter_spec.rb index 843f750..3d7219c 100644 --- a/spec/fast/prism_adapter_spec.rb +++ b/spec/fast/prism_adapter_spec.rb @@ -228,7 +228,6 @@ def self.l RUBY tree = described_class.parse(source) - puts "DEBUG TREE: #{tree.inspect}" expect(Fast.search('(alias (sym "old") (sym "new"))', tree)).not_to be_empty expect(Fast.search('(alias (sym "old_sym") (sym "new_sym"))', tree)).not_to be_empty @@ -236,8 +235,8 @@ def self.l expect(Fast.search('(break (int 1))', tree)).not_to be_empty expect(Fast.search('(super (int 2))', tree)).not_to be_empty expect(Fast.search('(xstr "ls")', tree)).not_to be_empty - expect(Fast.search('(dxstr (str "ls ") (send nil dir))', tree)).not_to be_empty - expect(Fast.search('(dsym (str "symbol_") (send nil index))', tree)).not_to be_empty + expect(Fast.search('(dxstr (str "ls ") (begin (send nil dir)))', tree)).not_to be_empty + expect(Fast.search('(dsym (str "symbol_") (begin (send nil index)))', tree)).not_to be_empty expect(Fast.search('(regexp (str "regex") (regopt :i))', tree)).not_to be_empty end From 21e1f849833958e6cf0f6267f885549c40117147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Mon, 13 Apr 2026 15:05:18 -0300 Subject: [PATCH 11/21] Fix PrismAdapter: Correct Node initialization and buffer handling --- lib/fast/prism_adapter.rb | 322 +++++++++++++++++++------------------- 1 file changed, 159 insertions(+), 163 deletions(-) diff --git a/lib/fast/prism_adapter.rb b/lib/fast/prism_adapter.rb index ec11dec..36d7bf5 100644 --- a/lib/fast/prism_adapter.rb +++ b/lib/fast/prism_adapter.rb @@ -9,11 +9,8 @@ module PrismAdapter class Location < Fast::Source::Range attr_accessor :node - def initialize(buffer_name, source, start_offset, end_offset, prism_node: nil) + def initialize(source_buffer, start_offset, end_offset, prism_node: nil) @prism_node = prism_node - @buffer_name = buffer_name - @source = source - source_buffer = Fast::Source::Buffer.new(buffer_name, source: source) super(source_buffer, start_offset, end_offset) end @@ -44,8 +41,8 @@ def expression def range_for(prism_location) Fast::Source::Range.new( source_buffer, - character_offset(@source, prism_location.start_offset), - character_offset(@source, prism_location.end_offset) + character_offset(source_buffer.source, prism_location.start_offset), + character_offset(source_buffer.source, prism_location.end_offset) ) end @@ -59,308 +56,307 @@ def parse(source, buffer_name: '(string)') result = Prism.parse(source) return unless result.success? - adapt(result.value, source, buffer_name) + source_buffer = Fast::Source::Buffer.new(buffer_name, source: source) + adapt(result.value, source_buffer) end - def adapt(node, source, buffer_name) + def adapt(node, source_buffer) return if node.nil? case node when Symbol - build_node(:sym, [node.to_s], nil, source, buffer_name) + build_node(:sym, [node.to_s], nil, source_buffer) when String - build_node(:str, [node], nil, source, buffer_name) + build_node(:str, [node], nil, source_buffer) when Prism::ProgramNode - statements = adapt_statements(node.statements, source, buffer_name) - statements.is_a?(Node) ? statements : build_node(:begin, statements, node, source, buffer_name) + statements = adapt_statements(node.statements, source_buffer) + statements.is_a?(Node) ? statements : build_node(:begin, statements, node, source_buffer) when Prism::StatementsNode - adapt_statements(node, source, buffer_name) + adapt_statements(node, source_buffer) when Prism::AliasMethodNode, Prism::AliasGlobalVariableNode - build_node(:alias, [adapt(node.new_name, source, buffer_name), adapt(node.old_name, source, buffer_name)], node, source, buffer_name) + build_node(:alias, [adapt(node.new_name, source_buffer), adapt(node.old_name, source_buffer)], node, source_buffer) when Prism::DefinedNode - build_node(:defined?, [adapt(node.value, source, buffer_name)], node, source, buffer_name) + build_node(:defined?, [adapt(node.value, source_buffer)], node, source_buffer) when Prism::UndefNode - build_node(:undef, node.names.map { |name| adapt(name, source, buffer_name) }, node, source, buffer_name) + build_node(:undef, node.names.map { |name| adapt(name, source_buffer) }, node, source_buffer) when Prism::ModuleNode - build_node(:module, [adapt(node.constant_path, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) + build_node(:module, [adapt(node.constant_path, source_buffer), adapt(node.body, source_buffer)], node, source_buffer) when Prism::ClassNode - build_node(:class, [adapt(node.constant_path, source, buffer_name), adapt(node.superclass, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) + build_node(:class, [adapt(node.constant_path, source_buffer), adapt(node.superclass, source_buffer), adapt(node.body, source_buffer)], node, source_buffer) when Prism::SingletonClassNode - build_node(:sclass, [adapt(node.expression, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) + build_node(:sclass, [adapt(node.expression, source_buffer), adapt(node.body, source_buffer)], node, source_buffer) when Prism::DefNode if node.receiver - build_node(:defs, [adapt(node.receiver, source, buffer_name), node.name, adapt_parameters(node.parameters, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) + build_node(:defs, [adapt(node.receiver, source_buffer), node.name, adapt_parameters(node.parameters, source_buffer), adapt(node.body, source_buffer)], node, source_buffer) else - build_node(:def, [node.name, adapt_parameters(node.parameters, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) + build_node(:def, [node.name, adapt_parameters(node.parameters, source_buffer), adapt(node.body, source_buffer)], node, source_buffer) end when Prism::BlockNode return nil unless node.respond_to?(:call) - build_node(:block, [adapt_call_node(node.call, source, buffer_name), adapt_block_parameters(node.parameters, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) + build_node(:block, [adapt_call_node(node.call, source_buffer), adapt_block_parameters(node.parameters, source_buffer), adapt(node.body, source_buffer)], node, source_buffer) when Prism::CallNode if node.respond_to?(:block) && node.block.is_a?(Prism::BlockNode) return build_node( :block, [ - adapt_call_node(node, source, buffer_name), - adapt_block_parameters(node.block.parameters, source, buffer_name), - adapt(node.block.body, source, buffer_name) + adapt_call_node(node, source_buffer), + adapt_block_parameters(node.block.parameters, source_buffer), + adapt(node.block.body, source_buffer) ], node, - source, - buffer_name + source_buffer ) end - adapt_call_node(node, source, buffer_name) + adapt_call_node(node, source_buffer) when Prism::ParenthesesNode - adapt(node.body, source, buffer_name) + adapt(node.body, source_buffer) when Prism::RangeNode - build_node(node.exclude_end? ? :erange : :irange, [adapt(node.left, source, buffer_name), adapt(node.right, source, buffer_name)], node, source, buffer_name) + build_node(node.exclude_end? ? :erange : :irange, [adapt(node.left, source_buffer), adapt(node.right, source_buffer)], node, source_buffer) when Prism::BlockArgumentNode - build_node(:block_pass, [adapt(node.expression, source, buffer_name)], node, source, buffer_name) + build_node(:block_pass, [adapt(node.expression, source_buffer)], node, source_buffer) when Prism::ReturnNode - build_node(:return, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) }, node, source, buffer_name) + build_node(:return, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source_buffer) }, node, source_buffer) when Prism::NextNode - build_node(:next, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) }, node, source, buffer_name) + build_node(:next, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source_buffer) }, node, source_buffer) when Prism::BreakNode - build_node(:break, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) }, node, source, buffer_name) + build_node(:break, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source_buffer) }, node, source_buffer) when Prism::YieldNode - build_node(:yield, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) }, node, source, buffer_name) + build_node(:yield, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source_buffer) }, node, source_buffer) when Prism::SuperNode - build_node(:super, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) }, node, source, buffer_name) + build_node(:super, node.arguments&.arguments.to_a.map { |arg| adapt(arg, source_buffer) }, node, source_buffer) when Prism::ForwardingSuperNode - build_node(:zsuper, [], node, source, buffer_name) + build_node(:zsuper, [], node, source_buffer) when Prism::SplatNode - build_node(:splat, [adapt(node.expression, source, buffer_name)], node, source, buffer_name) + build_node(:splat, [adapt(node.expression, source_buffer)], node, source_buffer) when Prism::AssocSplatNode - build_node(:kwsplat, [adapt(node.value, source, buffer_name)], node, source, buffer_name) + build_node(:kwsplat, [adapt(node.value, source_buffer)], node, source_buffer) when Prism::ConstantPathNode - build_const_path(node, source, buffer_name) + build_const_path(node, source_buffer) when Prism::ConstantReadNode - build_node(:const, [nil, node.name], node, source, buffer_name) + build_node(:const, [nil, node.name], node, source_buffer) when Prism::ConstantWriteNode - build_node(:casgn, [nil, node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name) + build_node(:casgn, [nil, node.name, adapt(node.value, source_buffer)], node, source_buffer) when Prism::ConstantPathWriteNode - build_node(:casgn, [adapt(node.target, source, buffer_name), nil, adapt(node.value, source, buffer_name)], node, source, buffer_name) + build_node(:casgn, [adapt(node.target, source_buffer), nil, adapt(node.value, source_buffer)], node, source_buffer) when Prism::SymbolNode - build_node(:sym, [node.unescaped], node, source, buffer_name) + build_node(:sym, [node.unescaped], node, source_buffer) when Prism::StringNode - build_node(:str, [node.unescaped], node, source, buffer_name) + build_node(:str, [node.unescaped], node, source_buffer) when Prism::XStringNode - build_node(:xstr, [node.unescaped], node, source, buffer_name) + build_node(:xstr, [node.unescaped], node, source_buffer) when Prism::InterpolatedStringNode - build_node(:dstr, node.parts.filter_map { |part| adapt(part, source, buffer_name) }, node, source, buffer_name) + build_node(:dstr, node.parts.filter_map { |part| adapt(part, source_buffer) }, node, source_buffer) when Prism::InterpolatedXStringNode - build_node(:dxstr, node.parts.filter_map { |part| adapt(part, source, buffer_name) }, node, source, buffer_name) + build_node(:dxstr, node.parts.filter_map { |part| adapt(part, source_buffer) }, node, source_buffer) when Prism::EmbeddedStatementsNode - build_node(:begin, [adapt(node.statements, source, buffer_name)], node, source, buffer_name) + build_node(:begin, [adapt(node.statements, source_buffer)], node, source_buffer) when Prism::InterpolatedSymbolNode - build_node(:dsym, node.parts.filter_map { |part| adapt(part, source, buffer_name) }, node, source, buffer_name) + build_node(:dsym, node.parts.filter_map { |part| adapt(part, source_buffer) }, node, source_buffer) when Prism::RegularExpressionNode - build_node(:regexp, [build_node(:str, [node.unescaped], node, source, buffer_name), build_node(:regopt, regexp_options(node), node, source, buffer_name)], node, source, buffer_name) + build_node(:regexp, [build_node(:str, [node.unescaped], node, source_buffer), build_node(:regopt, regexp_options(node), node, source_buffer)], node, source_buffer) when Prism::InterpolatedRegularExpressionNode - build_node(:regexp, node.parts.filter_map { |part| adapt(part, source, buffer_name) } + [build_node(:regopt, regexp_options(node), node, source, buffer_name)], node, source, buffer_name) + build_node(:regexp, node.parts.filter_map { |part| adapt(part, source_buffer) } + [build_node(:regopt, regexp_options(node), node, source_buffer)], node, source_buffer) when Prism::ArrayNode - build_node(:array, node.elements.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name) + build_node(:array, node.elements.map { |child| adapt(child, source_buffer) }, node, source_buffer) when Prism::HashNode - build_node(:hash, node.elements.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name) + build_node(:hash, node.elements.map { |child| adapt(child, source_buffer) }, node, source_buffer) when Prism::KeywordHashNode - build_node(:hash, node.elements.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name) + build_node(:hash, node.elements.map { |child| adapt(child, source_buffer) }, node, source_buffer) when Prism::AssocNode - build_node(:pair, [adapt(node.key, source, buffer_name), adapt(node.value, source, buffer_name)], node, source, buffer_name) + build_node(:pair, [adapt(node.key, source_buffer), adapt(node.value, source_buffer)], node, source_buffer) when Prism::SelfNode - build_node(:self, [], node, source, buffer_name) + build_node(:self, [], node, source_buffer) when Prism::RedoNode - build_node(:redo, [], node, source, buffer_name) + build_node(:redo, [], node, source_buffer) when Prism::RetryNode - build_node(:retry, [], node, source, buffer_name) + build_node(:retry, [], node, source_buffer) when Prism::PreExecutionNode - build_node(:preexe, [adapt_statements(node.statements, source, buffer_name)], node, source, buffer_name) + build_node(:preexe, [adapt_statements(node.statements, source_buffer)], node, source_buffer) when Prism::PostExecutionNode - build_node(:postexe, [adapt_statements(node.statements, source, buffer_name)], node, source, buffer_name) + build_node(:postexe, [adapt_statements(node.statements, source_buffer)], node, source_buffer) when Prism::NumberedReferenceReadNode - build_node(:nth_ref, [node.number], node, source, buffer_name) + build_node(:nth_ref, [node.number], node, source_buffer) when Prism::BackReferenceReadNode - build_node(:back_ref, [node.name], node, source, buffer_name) + build_node(:back_ref, [node.name], node, source_buffer) when Prism::LocalVariableReadNode - build_node(:lvar, [node.name], node, source, buffer_name) + build_node(:lvar, [node.name], node, source_buffer) when Prism::LocalVariableTargetNode - build_node(:lvasgn, [node.name], node, source, buffer_name) + build_node(:lvasgn, [node.name], node, source_buffer) when Prism::InstanceVariableReadNode - build_node(:ivar, [node.name], node, source, buffer_name) + build_node(:ivar, [node.name], node, source_buffer) when Prism::GlobalVariableReadNode - build_node(:gvar, [node.name], node, source, buffer_name) + build_node(:gvar, [node.name], node, source_buffer) when Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode - build_node(:ivasgn, [node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name) + build_node(:ivasgn, [node.name, adapt(node.value, source_buffer)], node, source_buffer) when Prism::GlobalVariableWriteNode - build_node(:gvasgn, [node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name) + build_node(:gvasgn, [node.name, adapt(node.value, source_buffer)], node, source_buffer) when Prism::LocalVariableWriteNode, Prism::LocalVariableOrWriteNode - build_node(:lvasgn, [node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name) + build_node(:lvasgn, [node.name, adapt(node.value, source_buffer)], node, source_buffer) when Prism::LocalVariableOperatorWriteNode - build_node(:op_asgn, [build_node(:lvasgn, [node.name], node, source, buffer_name), node.operator, adapt(node.value, source, buffer_name)], node, source, buffer_name) + build_node(:op_asgn, [build_node(:lvasgn, [node.name], node, source_buffer), node.operator, adapt(node.value, source_buffer)], node, source_buffer) when Prism::LocalVariableAndWriteNode - build_node(:and_asgn, [build_node(:lvasgn, [node.name], node, source, buffer_name), adapt(node.value, source, buffer_name)], node, source, buffer_name) + build_node(:and_asgn, [build_node(:lvasgn, [node.name], node, source_buffer), adapt(node.value, source_buffer)], node, source_buffer) when Prism::InstanceVariableOperatorWriteNode - build_node(:op_asgn, [build_node(:ivasgn, [node.name], node, source, buffer_name), node.operator, adapt(node.value, source, buffer_name)], node, source, buffer_name) + build_node(:op_asgn, [build_node(:ivasgn, [node.name], node, source_buffer), node.operator, adapt(node.value, source_buffer)], node, source_buffer) when Prism::InstanceVariableAndWriteNode - build_node(:and_asgn, [build_node(:ivasgn, [node.name], node, source, buffer_name), adapt(node.value, source, buffer_name)], node, source, buffer_name) + build_node(:and_asgn, [build_node(:ivasgn, [node.name], node, source_buffer), adapt(node.value, source_buffer)], node, source_buffer) when Prism::ClassVariableWriteNode - build_node(:cvasgn, [node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name) + build_node(:cvasgn, [node.name, adapt(node.value, source_buffer)], node, source_buffer) when Prism::ClassVariableReadNode - build_node(:cvar, [node.name], node, source, buffer_name) + build_node(:cvar, [node.name], node, source_buffer) when Prism::CallAndWriteNode - build_node(:and_asgn, [adapt(node.target, source, buffer_name), adapt(node.value, source, buffer_name)], node, source, buffer_name) + build_node(:and_asgn, [adapt(node.target, source_buffer), adapt(node.value, source_buffer)], node, source_buffer) when Prism::CallOperatorWriteNode - build_node(:op_asgn, [adapt(node.target, source, buffer_name), node.operator, adapt(node.value, source, buffer_name)], node, source, buffer_name) + build_node(:op_asgn, [adapt(node.target, source_buffer), node.operator, adapt(node.value, source_buffer)], node, source_buffer) when Prism::IndexAndWriteNode - build_node(:and_asgn, [adapt(node.target, source, buffer_name), adapt(node.value, source, buffer_name)], node, source, buffer_name) + build_node(:and_asgn, [adapt(node.target, source_buffer), adapt(node.value, source_buffer)], node, source_buffer) when Prism::IndexOperatorWriteNode - build_node(:op_asgn, [adapt(node.target, source, buffer_name), node.operator, adapt(node.value, source, buffer_name)], node, source, buffer_name) + build_node(:op_asgn, [adapt(node.target, source_buffer), node.operator, adapt(node.value, source_buffer)], node, source_buffer) when Prism::MatchWriteNode - build_node(:match_with_lvasgn, [adapt(node.call, source, buffer_name), node.targets.map { |t| adapt(t, source, buffer_name) }], node, source, buffer_name) + build_node(:match_with_lvasgn, [adapt(node.call, source_buffer), node.targets.map { |t| adapt(t, source_buffer) }], node, source_buffer) when Prism::MatchLastLineNode - build_node(:match_current_line, [node.location.slice], node, source, buffer_name) + build_node(:match_current_line, [node.location.slice], node, source_buffer) when Prism::IntegerNode - build_node(:int, [node.value], node, source, buffer_name) + build_node(:int, [node.value], node, source_buffer) when Prism::FloatNode - build_node(:float, [node.value], node, source, buffer_name) + build_node(:float, [node.value], node, source_buffer) when Prism::RationalNode - build_node(:rational, [node.value], node, source, buffer_name) + build_node(:rational, [node.value], node, source_buffer) when Prism::ImaginaryNode - build_node(:complex, [node.value], node, source, buffer_name) + build_node(:complex, [node.value], node, source_buffer) when Prism::TrueNode - build_node(:true, [], node, source, buffer_name) + build_node(:true, [], node, source_buffer) when Prism::FalseNode - build_node(:false, [], node, source, buffer_name) + build_node(:false, [], node, source_buffer) when Prism::NilNode - build_node(:nil, [], node, source, buffer_name) + build_node(:nil, [], node, source_buffer) when Prism::AndNode - build_node(:and, [adapt(node.left, source, buffer_name), adapt(node.right, source, buffer_name)], node, source, buffer_name) + build_node(:and, [adapt(node.left, source_buffer), adapt(node.right, source_buffer)], node, source_buffer) when Prism::OrNode - build_node(:or, [adapt(node.left, source, buffer_name), adapt(node.right, source, buffer_name)], node, source, buffer_name) + build_node(:or, [adapt(node.left, source_buffer), adapt(node.right, source_buffer)], node, source_buffer) when Prism::IfNode - build_node(:if, [adapt(node.predicate, source, buffer_name), adapt(node.statements, source, buffer_name), adapt(node.consequent, source, buffer_name)], node, source, buffer_name) + build_node(:if, [adapt(node.predicate, source_buffer), adapt(node.statements, source_buffer), adapt(node.consequent, source_buffer)], node, source_buffer) when Prism::UnlessNode - build_node(:if, [adapt(node.predicate, source, buffer_name), adapt(node.consequent, source, buffer_name), adapt(node.statements, source, buffer_name)], node, source, buffer_name) + build_node(:if, [adapt(node.predicate, source_buffer), adapt(node.consequent, source_buffer), adapt(node.statements, source_buffer)], node, source_buffer) when Prism::WhileNode - build_node(:while, [adapt(node.predicate, source, buffer_name), adapt(node.statements, source, buffer_name)], node, source, buffer_name) + build_node(:while, [adapt(node.predicate, source_buffer), adapt(node.statements, source_buffer)], node, source_buffer) when Prism::UntilNode - build_node(:until, [adapt(node.predicate, source, buffer_name), adapt(node.statements, source, buffer_name)], node, source, buffer_name) + build_node(:until, [adapt(node.predicate, source_buffer), adapt(node.statements, source_buffer)], node, source_buffer) when Prism::ForNode - build_node(:for, [adapt(node.index, source, buffer_name), adapt(node.collection, source, buffer_name), adapt(node.statements, source, buffer_name)], node, source, buffer_name) + build_node(:for, [adapt(node.index, source_buffer), adapt(node.collection, source_buffer), adapt(node.statements, source_buffer)], node, source_buffer) when Prism::MultiWriteNode - mlhs_children = node.lefts.map { |left| adapt(left, source, buffer_name) } - mlhs_children << adapt(node.rest, source, buffer_name) if node.rest - mlhs_children.concat(node.rights.map { |right| adapt(right, source, buffer_name) }) if node.respond_to?(:rights) - build_node(:masgn, [build_node(:mlhs, mlhs_children, node, source, buffer_name), adapt(node.value, source, buffer_name)], node, source, buffer_name) + mlhs_children = node.lefts.map { |left| adapt(left, source_buffer) } + mlhs_children << adapt(node.rest, source_buffer) if node.rest + mlhs_children.concat(node.rights.map { |right| adapt(right, source_buffer) }) if node.respond_to?(:rights) + build_node(:masgn, [build_node(:mlhs, mlhs_children, node, source_buffer), adapt(node.value, source_buffer)], node, source_buffer) when Prism::RescueModifierNode - build_node(:rescue, [adapt(node.expression, source, buffer_name), build_node(:resbody, [nil, nil, adapt(node.rescue_expression, source, buffer_name)], node, source, buffer_name), nil], node, source, buffer_name) + build_node(:rescue, [adapt(node.expression, source_buffer), build_node(:resbody, [nil, nil, adapt(node.rescue_expression, source_buffer)], node, source_buffer), nil], node, source_buffer) when Prism::CaseNode - children = [adapt(node.predicate, source, buffer_name)] - children.concat(node.conditions.map { |condition| adapt(condition, source, buffer_name) }) - children << adapt(node.consequent, source, buffer_name) if node.consequent - build_node(:case, children, node, source, buffer_name) + children = [adapt(node.predicate, source_buffer)] + children.concat(node.conditions.map { |condition| adapt(condition, source_buffer) }) + children << adapt(node.consequent, source_buffer) if node.consequent + build_node(:case, children, node, source_buffer) when Prism::HashPatternNode - build_node(:hash, node.elements.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name) + build_node(:hash, node.elements.map { |child| adapt(child, source_buffer) }, node, source_buffer) when Prism::WhenNode condition = if node.conditions.length == 1 - adapt(node.conditions.first, source, buffer_name) + adapt(node.conditions.first, source_buffer) else - build_node(:array, node.conditions.map { |child| adapt(child, source, buffer_name) }, node, source, buffer_name) + build_node(:array, node.conditions.map { |child| adapt(child, source_buffer) }, node, source_buffer) end - build_node(:when, [condition, adapt(node.statements, source, buffer_name)].compact, node, source, buffer_name) + build_node(:when, [condition, adapt(node.statements, source_buffer)].compact, node, source_buffer) when Prism::BeginNode - rescue_bodies = node.rescue_clause ? adapt_rescue_clause(node.rescue_clause, source, buffer_name) : [] - ensure_body = node.ensure_clause ? adapt(node.ensure_clause.statements, source, buffer_name) : nil - res = adapt(node.statements, source, buffer_name) - res = build_node(:kwbegin, [res], node, source, buffer_name) if node.location.slice.start_with?('begin') + rescue_bodies = node.rescue_clause ? adapt_rescue_clause(node.rescue_clause, source_buffer) : [] + ensure_body = node.ensure_clause ? adapt(node.ensure_clause.statements, source_buffer) : nil + res = adapt(node.statements, source_buffer) + res = build_node(:kwbegin, [res], node, source_buffer) if node.location.slice.start_with?('begin') if rescue_bodies.any? || ensure_body - build_node(:ensure, [build_node(:rescue, [res, *rescue_bodies, nil], node, source, buffer_name), ensure_body], node, source, buffer_name) + build_node(:ensure, [build_node(:rescue, [res, *rescue_bodies, nil], node, source_buffer), ensure_body], node, source_buffer) else res end when Prism::RescueNode - adapt_rescue_clause(node, source, buffer_name) + adapt_rescue_clause(node, source_buffer) when Prism::EnsureNode - adapt(node.statements, source, buffer_name) + adapt(node.statements, source_buffer) when Prism::LambdaNode - build_node(:block, [build_node(:send, [nil, :lambda], node, source, buffer_name), adapt_block_parameters(node.parameters, source, buffer_name), adapt(node.body, source, buffer_name)], node, source, buffer_name) + build_node(:block, [build_node(:send, [nil, :lambda], node, source_buffer), adapt_block_parameters(node.parameters, source_buffer), adapt(node.body, source_buffer)], node, source_buffer) end end - def adapt_statements(node, source, buffer_name) - children = node.body.filter_map { |child| adapt(child, source, buffer_name) } + def adapt_statements(node, source_buffer) + children = node.body.filter_map { |child| adapt(child, source_buffer) } return nil if children.empty? return children.first if children.one? - build_node(:begin, children, node, source, buffer_name) + build_node(:begin, children, node, source_buffer) end - def adapt_parameters(node, source, buffer_name) - return build_node(:args, [], nil, source, buffer_name) unless node + def adapt_parameters(node, source_buffer) + return build_node(:args, [], nil, source_buffer) unless node children = [] - children.concat(node.requireds.map { |child| adapt_required_parameter(child, source, buffer_name) }) if node.respond_to?(:requireds) - children.concat(node.optionals.map { |child| build_node(:optarg, [parameter_name(child), adapt(child.value, source, buffer_name)], child, source, buffer_name) }) if node.respond_to?(:optionals) - children << build_node(:restarg, [parameter_name(node.rest)], node.rest, source, buffer_name) if node.respond_to?(:rest) && node.rest - children.concat(node.posts.map { |child| adapt_required_parameter(child, source, buffer_name) }) if node.respond_to?(:posts) - children.concat(node.keywords.map { |child| adapt_keyword_parameter(child, source, buffer_name) }) if node.respond_to?(:keywords) - children << build_node(:kwrestarg, [parameter_name(node.keyword_rest)], node.keyword_rest, source, buffer_name) if node.respond_to?(:keyword_rest) && node.keyword_rest - children << build_node(:blockarg, [parameter_name(node.block)], node.block, source, buffer_name) if node.respond_to?(:block) && node.block - build_node(:args, children, node, source, buffer_name) + children.concat(node.requireds.map { |child| adapt_required_parameter(child, source_buffer) }) if node.respond_to?(:requireds) + children.concat(node.optionals.map { |child| build_node(:optarg, [parameter_name(child), adapt(child.value, source_buffer)], child, source_buffer) }) if node.respond_to?(:optionals) + children << build_node(:restarg, [parameter_name(node.rest)], node.rest, source_buffer) if node.respond_to?(:rest) && node.rest + children.concat(node.posts.map { |child| adapt_required_parameter(child, source_buffer) }) if node.respond_to?(:posts) + children.concat(node.keywords.map { |child| adapt_keyword_parameter(child, source_buffer) }) if node.respond_to?(:keywords) + children << build_node(:kwrestarg, [parameter_name(node.keyword_rest)], node.keyword_rest, source_buffer) if node.respond_to?(:keyword_rest) && node.keyword_rest + children << build_node(:blockarg, [parameter_name(node.block)], node.block, source_buffer) if node.respond_to?(:block) && node.block + build_node(:args, children, node, source_buffer) end - def adapt_block_parameters(node, source, buffer_name) - return build_node(:args, [], nil, source, buffer_name) unless node + def adapt_block_parameters(node, source_buffer) + return build_node(:args, [], nil, source_buffer) unless node params = node.respond_to?(:parameters) ? node.parameters : node - adapt_parameters(params, source, buffer_name) + adapt_parameters(params, source_buffer) end - def adapt_required_parameter(child, source, buffer_name) + def adapt_required_parameter(child, source_buffer) if child.is_a?(Prism::MultiTargetNode) - mlhs_children = child.lefts.map { |c| adapt_required_parameter(c, source, buffer_name) } - mlhs_children << build_node(:restarg, [parameter_name(child.rest)], child.rest, source, buffer_name) if child.respond_to?(:rest) && child.rest - mlhs_children.concat(child.rights.map { |c| adapt_required_parameter(c, source, buffer_name) }) if child.respond_to?(:rights) - build_node(:mlhs, mlhs_children, child, source, buffer_name) + mlhs_children = child.lefts.map { |c| adapt_required_parameter(c, source_buffer) } + mlhs_children << build_node(:restarg, [parameter_name(child.rest)], child.rest, source_buffer) if child.respond_to?(:rest) && child.rest + mlhs_children.concat(child.rights.map { |c| adapt_required_parameter(c, source_buffer) }) if child.respond_to?(:rights) + build_node(:mlhs, mlhs_children, child, source_buffer) else - build_node(:arg, [parameter_name(child)], child, source, buffer_name) + build_node(:arg, [parameter_name(child)], child, source_buffer) end end - def adapt_keyword_parameter(node, source, buffer_name) + def adapt_keyword_parameter(node, source_buffer) case node when Prism::RequiredKeywordParameterNode - build_node(:kwarg, [parameter_name(node)], node, source, buffer_name) + build_node(:kwarg, [parameter_name(node)], node, source_buffer) when Prism::OptionalKeywordParameterNode - build_node(:kwoptarg, [parameter_name(node), adapt(node.value, source, buffer_name)], node, source, buffer_name) + build_node(:kwoptarg, [parameter_name(node), adapt(node.value, source_buffer)], node, source_buffer) else - build_node(:arg, [parameter_name(node)], node, source, buffer_name) + build_node(:arg, [parameter_name(node)], node, source_buffer) end end - def adapt_call_node(node, source, buffer_name) - children = [adapt(node.receiver, source, buffer_name), node.name] - children.concat(node.arguments&.arguments.to_a.map { |arg| adapt(arg, source, buffer_name) } || []) - children << adapt(node.block, source, buffer_name) if node.respond_to?(:block) && node.block && !node.block.is_a?(Prism::BlockNode) - return build_node(:send, children, node, source, buffer_name) unless node.respond_to?(:block) && node.block.is_a?(Prism::BlockNode) + def adapt_call_node(node, source_buffer) + children = [adapt(node.receiver, source_buffer), node.name] + children.concat(node.arguments&.arguments.to_a.map { |arg| adapt(arg, source_buffer) } || []) + children << adapt(node.block, source_buffer) if node.respond_to?(:block) && node.block && !node.block.is_a?(Prism::BlockNode) + return build_node(:send, children, node, source_buffer) unless node.respond_to?(:block) && node.block.is_a?(Prism::BlockNode) end_offset = node.block.location.start_offset - while end_offset > node.location.start_offset && source.byteslice(end_offset - 1, 1)&.match?(/\s/) + while end_offset > node.location.start_offset && source_buffer.source.byteslice(end_offset - 1, 1)&.match?(/\s/) end_offset -= 1 end loc = Location.new( - buffer_name, - source, + source_buffer, node.location.start_offset, end_offset, prism_node: node ) - send_node = Node.new(:send, children: children, loc: loc) + send_node = Node.new(:send, children, location: loc) loc.node = send_node send_node end @@ -377,36 +373,36 @@ def parameter_name(node) node.respond_to?(:name) ? node.name : nil end - def build_const_path(node, source, buffer_name) + def build_const_path(node, source_buffer) parent = if node.parent - adapt(node.parent, source, buffer_name) + adapt(node.parent, source_buffer) elsif node.delimiter_loc - build_node(:cbase, [], nil, source, buffer_name) + build_node(:cbase, [], nil, source_buffer) end name = node.respond_to?(:name) ? node.name : node.child.name - build_node(:const, [parent, name], node, source, buffer_name) + build_node(:const, [parent, name], node, source_buffer) end - def build_node(type, children, prism_node, source, buffer_name) + def build_node(type, children, prism_node, source_buffer) loc = if prism_node - Location.new(buffer_name, source, prism_node.location.start_offset, prism_node.location.end_offset, prism_node: prism_node) + Location.new(source_buffer, prism_node.location.start_offset, prism_node.location.end_offset, prism_node: prism_node) else - Location.new(buffer_name, source, 0, 0) + Location.new(source_buffer, 0, 0) end node = Node.new(type, children, location: loc) loc.node = node node end - def adapt_rescue_clause(node, source, buffer_name) + def adapt_rescue_clause(node, source_buffer) resbodies = [] current = node while current - exceptions = current.exceptions.map { |e| adapt(e, source, buffer_name) } - exception_variable = adapt(current.reference, source, buffer_name) - resbodies << build_node(:resbody, [build_node(:array, exceptions, current, source, buffer_name), exception_variable, adapt(current.statements, source, buffer_name)], current, source, buffer_name) + exceptions = current.exceptions.map { |e| adapt(e, source_buffer) } + exception_variable = adapt(current.reference, source_buffer) + resbodies << build_node(:resbody, [build_node(:array, exceptions, current, source_buffer), exception_variable, adapt(current.statements, source_buffer)], current, source_buffer) current = current.consequent end resbodies From 16f31852f9c4de2eda036f44285afd52c0edcc9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Mon, 13 Apr 2026 15:08:29 -0300 Subject: [PATCH 12/21] Fix PrismAdapter: Correctly convert byte offsets to character offsets --- lib/fast/prism_adapter.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/fast/prism_adapter.rb b/lib/fast/prism_adapter.rb index 36d7bf5..90266fb 100644 --- a/lib/fast/prism_adapter.rb +++ b/lib/fast/prism_adapter.rb @@ -11,7 +11,11 @@ class Location < Fast::Source::Range def initialize(source_buffer, start_offset, end_offset, prism_node: nil) @prism_node = prism_node - super(source_buffer, start_offset, end_offset) + super( + source_buffer, + character_offset(source_buffer.source, start_offset), + character_offset(source_buffer.source, end_offset) + ) end def name @@ -47,7 +51,7 @@ def range_for(prism_location) end def character_offset(source, byte_offset) - source.byteslice(0, byte_offset).length + source.byteslice(0, byte_offset).size end end From 73f67817f015db3dea16cc6ee9e7fe8fce297096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Mon, 13 Apr 2026 15:11:46 -0300 Subject: [PATCH 13/21] Fix PrismAdapter: Use binary_operator for OperatorWriteNodes --- lib/fast/prism_adapter.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/fast/prism_adapter.rb b/lib/fast/prism_adapter.rb index 90266fb..1646f3a 100644 --- a/lib/fast/prism_adapter.rb +++ b/lib/fast/prism_adapter.rb @@ -200,11 +200,11 @@ def adapt(node, source_buffer) when Prism::LocalVariableWriteNode, Prism::LocalVariableOrWriteNode build_node(:lvasgn, [node.name, adapt(node.value, source_buffer)], node, source_buffer) when Prism::LocalVariableOperatorWriteNode - build_node(:op_asgn, [build_node(:lvasgn, [node.name], node, source_buffer), node.operator, adapt(node.value, source_buffer)], node, source_buffer) + build_node(:op_asgn, [build_node(:lvasgn, [node.name], node, source_buffer), node.binary_operator, adapt(node.value, source_buffer)], node, source_buffer) when Prism::LocalVariableAndWriteNode build_node(:and_asgn, [build_node(:lvasgn, [node.name], node, source_buffer), adapt(node.value, source_buffer)], node, source_buffer) when Prism::InstanceVariableOperatorWriteNode - build_node(:op_asgn, [build_node(:ivasgn, [node.name], node, source_buffer), node.operator, adapt(node.value, source_buffer)], node, source_buffer) + build_node(:op_asgn, [build_node(:ivasgn, [node.name], node, source_buffer), node.binary_operator, adapt(node.value, source_buffer)], node, source_buffer) when Prism::InstanceVariableAndWriteNode build_node(:and_asgn, [build_node(:ivasgn, [node.name], node, source_buffer), adapt(node.value, source_buffer)], node, source_buffer) when Prism::ClassVariableWriteNode @@ -214,11 +214,11 @@ def adapt(node, source_buffer) when Prism::CallAndWriteNode build_node(:and_asgn, [adapt(node.target, source_buffer), adapt(node.value, source_buffer)], node, source_buffer) when Prism::CallOperatorWriteNode - build_node(:op_asgn, [adapt(node.target, source_buffer), node.operator, adapt(node.value, source_buffer)], node, source_buffer) + build_node(:op_asgn, [adapt(node.target, source_buffer), node.binary_operator, adapt(node.value, source_buffer)], node, source_buffer) when Prism::IndexAndWriteNode build_node(:and_asgn, [adapt(node.target, source_buffer), adapt(node.value, source_buffer)], node, source_buffer) when Prism::IndexOperatorWriteNode - build_node(:op_asgn, [adapt(node.target, source_buffer), node.operator, adapt(node.value, source_buffer)], node, source_buffer) + build_node(:op_asgn, [adapt(node.target, source_buffer), node.binary_operator, adapt(node.value, source_buffer)], node, source_buffer) when Prism::MatchWriteNode build_node(:match_with_lvasgn, [adapt(node.call, source_buffer), node.targets.map { |t| adapt(t, source_buffer) }], node, source_buffer) when Prism::MatchLastLineNode From 6a2466a3d36da89316e286423d0e40cc4f2a0d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Mon, 13 Apr 2026 15:13:55 -0300 Subject: [PATCH 14/21] debug CI failure with PRISM ERRORS --- lib/fast/prism_adapter.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/fast/prism_adapter.rb b/lib/fast/prism_adapter.rb index 1646f3a..6889dd9 100644 --- a/lib/fast/prism_adapter.rb +++ b/lib/fast/prism_adapter.rb @@ -58,7 +58,10 @@ def character_offset(source, byte_offset) class << self def parse(source, buffer_name: '(string)') result = Prism.parse(source) - return unless result.success? + unless result.success? + puts "PRISM ERRORS: #{result.errors.map(&:message).join(', ')}" + return + end source_buffer = Fast::Source::Buffer.new(buffer_name, source: source) adapt(result.value, source_buffer) From 799d05eda9803348c92783faeae465608b8e4f00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Mon, 13 Apr 2026 15:17:29 -0300 Subject: [PATCH 15/21] Fix PrismAdapter: initialization, interpolation and node types --- lib/fast/prism_adapter.rb | 5 +---- spec/fast/prism_adapter_spec.rb | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/lib/fast/prism_adapter.rb b/lib/fast/prism_adapter.rb index 6889dd9..1646f3a 100644 --- a/lib/fast/prism_adapter.rb +++ b/lib/fast/prism_adapter.rb @@ -58,10 +58,7 @@ def character_offset(source, byte_offset) class << self def parse(source, buffer_name: '(string)') result = Prism.parse(source) - unless result.success? - puts "PRISM ERRORS: #{result.errors.map(&:message).join(', ')}" - return - end + return unless result.success? source_buffer = Fast::Source::Buffer.new(buffer_name, source: source) adapt(result.value, source_buffer) diff --git a/spec/fast/prism_adapter_spec.rb b/spec/fast/prism_adapter_spec.rb index 3d7219c..8214419 100644 --- a/spec/fast/prism_adapter_spec.rb +++ b/spec/fast/prism_adapter_spec.rb @@ -216,15 +216,17 @@ def self.l it 'adapts alias, break, super, xstr, dxstr, dsym, regexp' do source = <<~'RUBY' - alias old new - alias :old_sym :new_sym - alias $a $b - break 1 - super(2) - `ls` - `ls #{dir}` - :"symbol_#{index}" - /regex/i + def method + alias old new + alias :old_sym :new_sym + alias $a $b + break 1 + super(2) + `ls` + `ls #{dir}` + :"symbol_#{index}" + /regex/i + end RUBY tree = described_class.parse(source) From fdd9b575968f6d6eda53b957ff809f1e628f28b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Mon, 13 Apr 2026 15:41:00 -0300 Subject: [PATCH 16/21] Simplify gains report output and group by hour --- lib/fast/gains.rb | 134 +++++++++++++++++++++------------------- spec/fast/gains_spec.rb | 63 +++++-------------- 2 files changed, 84 insertions(+), 113 deletions(-) diff --git a/lib/fast/gains.rb b/lib/fast/gains.rb index 9111627..d416aed 100644 --- a/lib/fast/gains.rb +++ b/lib/fast/gains.rb @@ -11,7 +11,7 @@ class Gains STORAGE_DIR = File.expand_path('~/.fast') STORAGE_FILE = File.join(STORAGE_DIR, 'gains.json') - attr_reader :command, :start_time, :total_bytes_searched, :total_bytes_reported, :files_count, :matched_files_count, :reports + attr_reader :command, :start_time, :total_bytes_searched, :total_bytes_reported, :files_count, :matched_files_count def initialize(command = nil) @command = command @@ -21,7 +21,6 @@ def initialize(command = nil) @files_count = 0 @matched_files_count = 0 @files_with_matches = [] - @reports = [] end def record_search(file) @@ -42,13 +41,12 @@ def record_match(file) def record_report(content) return unless Fast.gain_tracking_enabled? @total_bytes_reported += content.to_s.bytesize - @reports << content.to_s end def save! return unless Fast.gain_tracking_enabled? return if @total_bytes_searched.zero? - return if @total_bytes_reported.zero? # Honest gain: skip if nothing was found + return if @total_bytes_reported.zero? data = { timestamp: @start_time.iso8601, @@ -56,87 +54,73 @@ def save! files_count: @files_count, matched_files_count: @matched_files_count, bytes_searched: @total_bytes_searched, - bytes_reported: @total_bytes_reported, - savings_percent: savings_percent.round(2), - reports: @reports + bytes_reported: @total_bytes_reported } FileUtils.mkdir_p(STORAGE_DIR) temp_filename = File.join(STORAGE_DIR, "gains-#{Time.now.to_f}-#{Process.pid}.json") File.write(temp_filename, JSON.generate(data)) - end - - def savings_percent - return 0.0 if @total_bytes_searched.zero? - 100.0 * (1.0 - (@total_bytes_reported.to_f / @total_bytes_searched)) - end - - def history - all_data = [] - if File.exist?(STORAGE_FILE) - all_data = JSON.parse(File.read(STORAGE_FILE), symbolize_names: true) rescue [] - end - all_data + self.class.consolidate! end def self.consolidate! FileUtils.mkdir_p(STORAGE_DIR) - all_data = [] File.open(STORAGE_FILE, File::RDWR|File::CREAT, 0644) do |f| f.flock(File::LOCK_EX) content = f.read all_data = JSON.parse(content, symbolize_names: true) rescue [] unless content.empty? + all_data ||= [] temp_files = Dir.glob(File.join(STORAGE_DIR, 'gains-*.json')) temp_files.each do |file| begin - temp_data = JSON.parse(File.read(file), symbolize_names: true) - all_data << temp_data + all_data << JSON.parse(File.read(file), symbolize_names: true) File.delete(file) rescue # Skip corrupted files end end - - all_data.sort_by! { |h| h[:timestamp] || '' } - - # Keep only reports for the last 5 runs to avoid huge files - all_data.each_with_index do |h, i| - h.delete(:reports) if i < all_data.size - 5 - end + summarized = summarize(all_data) + f.rewind f.truncate(0) - f.write(JSON.pretty_generate(all_data)) + f.write(JSON.pretty_generate(summarized)) + summarized end - all_data + end + + def self.summarize(data) + return [] if data.nil? || data.empty? + data.group_by do |h| + timestamp = h[:hour] || h[:timestamp] || Time.now.iso8601 + hour = Time.parse(timestamp).strftime('%Y-%m-%d %H:00') + category = h[:category] || (h[:command]&.start_with?('mcp:') ? 'mcp' : 'cli') + [hour, category] + end.map do |(hour, category), runs| + { + hour: hour, + category: category, + files_count: runs.sum { |r| r[:files_count] || 0 }, + matched_files_count: runs.sum { |r| r[:matched_files_count] || 0 }, + bytes_searched: runs.sum { |r| r[:bytes_searched] || 0 }, + bytes_reported: runs.sum { |r| r[:bytes_reported] || 0 }, + runs_count: runs.sum { |r| r[:runs_count] || 1 } + } + end.sort_by { |h| h[:hour] } end def self.report(filter = nil) all_history = consolidate! return puts "No gains recorded yet. Start searching with `fast`!" if all_history.empty? - if filter == 'mcp' - render_report('Fast Gains Report (MCP)', all_history.select { |h| h[:command]&.start_with?('mcp:') }) - elsif filter == 'cli' - render_report('Fast Gains Report (CLI)', all_history.reject { |h| h[:command]&.start_with?('mcp:') }) - else - mcp_history = all_history.select { |h| h[:command]&.start_with?('mcp:') } - cli_history = all_history.reject { |h| h[:command]&.start_with?('mcp:') } - - if mcp_history.any? && cli_history.any? - render_report('Fast Gains Report (CLI)', cli_history) - puts "\n" - render_report('Fast Gains Report (MCP)', mcp_history) - puts "\n" - render_report('Fast Gains Report (Total)', all_history) - else - render_report('Fast Gains Report', all_history) - end - end + title = filter ? "Fast Gains Report (#{filter.upcase})" : "Fast Gains Report" + history = filter ? all_history.select { |h| h[:category] == filter } : all_history + + render_report(title, history) end def self.render_report(title, history) @@ -148,16 +132,49 @@ def self.render_report(title, history) total_matched_files = history.sum { |h| h[:matched_files_count] || 0 } total_savings = total_searched - total_reported avg_percent = total_searched.zero? ? 0 : 100.0 * (1.0 - (total_reported.to_f / total_searched)) + total_runs = history.sum { |h| h[:runs_count] || 1 } puts "\e[1m#{title}\e[0m" puts '-' * title.length puts "Total Bytes Searched: #{format_bytes(total_searched)} (#{total_files} files)" puts "Total Bytes Reported: #{format_bytes(total_reported)} (#{total_matched_files} files matched)" puts "Total Savings: \e[32m#{format_bytes(total_savings)} (#{avg_percent.round(2)}%)\e[0m" - puts "Commands executed: #{history.size}" + puts "Commands executed: #{total_runs}" + + categories = history.map { |h| h[:category] }.uniq + if categories.size > 1 + print "Breakdown: " + parts = categories.map do |cat| + cat_history = history.select { |h| h[:category] == cat } + cat_runs = cat_history.sum { |h| h[:runs_count] || 1 } + "#{cat.upcase}: #{cat_runs}" + end + puts parts.join(", ") + end + puts '' + render_hourly_summary(history) + end - show_graph(history.last(30)) + def self.render_hourly_summary(history) + puts "Savings by Hour (last 12 hours):" + + hourly_data = history.group_by { |h| h[:hour] }.sort.last(12) + max_savings = hourly_data.map { |_, entries| entries.sum { |h| h[:bytes_searched] - h[:bytes_reported] } }.max + max_savings = 1 if max_savings.to_i.zero? + + hourly_data.each do |hour, entries| + searched = entries.sum { |h| h[:bytes_searched] || 0 } + reported = entries.sum { |h| h[:bytes_reported] || 0 } + savings = searched - reported + percent = searched.zero? ? 0 : 100.0 * (1.0 - (reported.to_f / searched)) + + bar_length = (20.0 * [savings, 0].max / max_savings).to_i + bar = "█" * bar_length + + printf "%s | %-20s | %6.2f%% efficiency\n", + hour[5..15], bar, percent + end end def self.format_bytes(bytes) @@ -171,18 +188,5 @@ def self.format_bytes(bytes) "#{(bytes / 1024.0**3).round(2)} GB" end end - - def self.show_graph(recent_history) - puts "Recent Savings (last #{recent_history.size} runs):" - max_savings = recent_history.map { |h| h[:bytes_searched] - h[:bytes_reported] }.max - return if max_savings.to_i.zero? - - recent_history.each do |h| - savings = h[:bytes_searched] - h[:bytes_reported] - bar_length = (30.0 * savings / max_savings).to_i - bar = "█" * bar_length - printf "%10s | %-30s | %6.2f%%\n", h[:timestamp][5..15].tr('T', ' '), bar, h[:savings_percent] - end - end end end diff --git a/spec/fast/gains_spec.rb b/spec/fast/gains_spec.rb index 72ad6d3..3daa684 100644 --- a/spec/fast/gains_spec.rb +++ b/spec/fast/gains_spec.rb @@ -50,7 +50,7 @@ end describe '#save!' do - it 'saves data to a unique JSON file if there are reports' do + it 'saves summarized data' do file = File.join(temp_dir, 'test.rb') File.write(file, 'test content') @@ -58,15 +58,12 @@ subject.record_report('match') subject.save! - temp_files = Dir.glob(File.join(temp_dir, 'gains-*.json')) - expect(temp_files).not_to be_empty - - described_class.consolidate! - expect(File.exist?(temp_file)).to be true data = JSON.parse(File.read(temp_file)) expect(data.last['bytes_searched']).to eq(File.size(file)) expect(data.last['bytes_reported']).to eq('match'.bytesize) + expect(data.last['hour']).not_to be_nil + expect(data.last['category']).to eq('cli') end it 'does NOT save data if there are no reports (honest gain)' do @@ -80,7 +77,7 @@ expect(temp_files).to be_empty end - it 'saves multiple runs to different files and consolidates them' do + it 'saves multiple runs and consolidates them into hours' do # Run 1 g1 = Fast::Gains.new('run 1') File.write(File.join(temp_dir, 'f1.rb'), 'hello') @@ -95,43 +92,14 @@ g2.record_report('match 2') g2.save! - # Verify two temp files exist - temp_files = Dir.glob(File.join(temp_dir, 'gains-*.json')) - expect(temp_files.size).to eq(2) - expect(File.exist?(temp_file)).to be false - - # Consolidate - data = Fast::Gains.consolidate! - # Verify temp files are gone and gains.json exists expect(Dir.glob(File.join(temp_dir, 'gains-*.json'))).to be_empty expect(File.exist?(temp_file)).to be true - expect(data.size).to eq(2) - expect(data.map { |h| h[:command] }).to contain_exactly('run 1', 'run 2') - expect(data.last[:reports]).to contain_exactly('match 2') - end - - it 'keeps reports only for the latest runs' do - 6.times do |i| - g = Fast::Gains.new("run #{i}") - File.write(File.join(temp_dir, "f#{i}.rb"), "content #{i}") - g.record_search(File.join(temp_dir, "f#{i}.rb")) - g.record_report("patch #{i}") - g.save! - end - - Fast::Gains.consolidate! - data = JSON.parse(File.read(temp_file), symbolize_names: true) - expect(data.size).to eq(6) - - # Run 0 should not have reports - expect(data[0][:reports]).to be_nil - # Run 5 should have reports - expect(data[5][:reports]).to contain_exactly('patch 5') - # Run 1 should have reports (since it's index 1 in 6 entries, it's one of the last 5) - expect(data[1][:reports]).to contain_exactly('patch 1') + expect(data.size).to eq(1) # Both runs are in the same hour + expect(data.last[:runs_count]).to eq(2) + expect(data.last[:bytes_reported]).to eq('match 1'.bytesize + 'match 2'.bytesize) end it 'does NOT record or save anything if disabled' do @@ -175,11 +143,11 @@ end context 'with history' do - let(:now) { Time.now.iso8601 } + let(:now_hour) { Time.now.strftime('%Y-%m-%d %H:00') } let(:data) do [ - { timestamp: now, command: 'fast test', files_count: 10, bytes_searched: 1000, bytes_reported: 100, savings_percent: 90.0 }, - { timestamp: now, command: 'mcp:search', files_count: 5, bytes_searched: 500, bytes_reported: 50, savings_percent: 90.0 } + { hour: now_hour, category: 'cli', files_count: 10, bytes_searched: 1000, bytes_reported: 100, runs_count: 1 }, + { hour: now_hour, category: 'mcp', files_count: 5, bytes_searched: 500, bytes_reported: 50, runs_count: 1 } ] end @@ -187,20 +155,19 @@ File.write(temp_file, JSON.generate(data)) end - it 'prints a breakdown if both CLI and MCP exist' do - expect { described_class.report }.to output(/Fast Gains Report \(CLI\)/).to_stdout - expect { described_class.report }.to output(/Fast Gains Report \(MCP\)/).to_stdout - expect { described_class.report }.to output(/Fast Gains Report \(Total\)/).to_stdout + it 'prints a single report with breakdown if both CLI and MCP exist' do + expect { described_class.report }.to output(/Fast Gains Report/).to_stdout + expect { described_class.report }.to output(/Breakdown: CLI: 1, MCP: 1/).to_stdout end it 'prints only CLI report when filtered' do expect { described_class.report('cli') }.to output(/Fast Gains Report \(CLI\)/).to_stdout - expect { described_class.report('cli') }.not_to output(/Fast Gains Report \(MCP\)/).to_stdout + expect { described_class.report('cli') }.not_to output(/Breakdown:/).to_stdout end it 'prints only MCP report when filtered' do expect { described_class.report('mcp') }.to output(/Fast Gains Report \(MCP\)/).to_stdout - expect { described_class.report('mcp') }.not_to output(/Fast Gains Report \(CLI\)/).to_stdout + expect { described_class.report('mcp') }.not_to output(/Breakdown:/).to_stdout end end end From 251e1abaf018e985ed6e1641fdbfa963b8cf678c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Mon, 13 Apr 2026 15:41:32 -0300 Subject: [PATCH 17/21] Improve gains reporting and fix PrismAdapter issues --- .ruby-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .ruby-version diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..37d02a6 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.3.8 From cbe001de689daed7efea9462a308e5bc83477bf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Mon, 13 Apr 2026 16:38:48 -0300 Subject: [PATCH 18/21] Fix PrismAdapter structures and Gains data format to ensure compatibility --- .ruby-version | 1 - lib/fast/gains.rb | 21 +++++++++++-------- lib/fast/prism_adapter.rb | 43 +++++++++++++++++++++++++++------------ spec/fast/gains_spec.rb | 19 ++++++++--------- 4 files changed, 51 insertions(+), 33 deletions(-) delete mode 100644 .ruby-version diff --git a/.ruby-version b/.ruby-version deleted file mode 100644 index 37d02a6..0000000 --- a/.ruby-version +++ /dev/null @@ -1 +0,0 @@ -3.3.8 diff --git a/lib/fast/gains.rb b/lib/fast/gains.rb index d416aed..b8da7e3 100644 --- a/lib/fast/gains.rb +++ b/lib/fast/gains.rb @@ -84,21 +84,22 @@ def self.consolidate! end end - summarized = summarize(all_data) - + # Keep only the last 1000 runs to avoid file growing too much + all_data = all_data.last(1000) + f.rewind f.truncate(0) - f.write(JSON.pretty_generate(summarized)) - summarized + f.write(JSON.pretty_generate(all_data)) + all_data end end def self.summarize(data) return [] if data.nil? || data.empty? data.group_by do |h| - timestamp = h[:hour] || h[:timestamp] || Time.now.iso8601 + timestamp = h[:timestamp] || Time.now.iso8601 hour = Time.parse(timestamp).strftime('%Y-%m-%d %H:00') - category = h[:category] || (h[:command]&.start_with?('mcp:') ? 'mcp' : 'cli') + category = h[:command]&.start_with?('mcp:') ? 'mcp' : 'cli' [hour, category] end.map do |(hour, category), runs| { @@ -108,14 +109,16 @@ def self.summarize(data) matched_files_count: runs.sum { |r| r[:matched_files_count] || 0 }, bytes_searched: runs.sum { |r| r[:bytes_searched] || 0 }, bytes_reported: runs.sum { |r| r[:bytes_reported] || 0 }, - runs_count: runs.sum { |r| r[:runs_count] || 1 } + runs_count: runs.size } end.sort_by { |h| h[:hour] } end def self.report(filter = nil) - all_history = consolidate! - return puts "No gains recorded yet. Start searching with `fast`!" if all_history.empty? + all_raw_history = consolidate! + return puts "No gains recorded yet. Start searching with `fast`!" if all_raw_history.empty? + + all_history = summarize(all_raw_history) title = filter ? "Fast Gains Report (#{filter.upcase})" : "Fast Gains Report" history = filter ? all_history.select { |h| h[:category] == filter } : all_history diff --git a/lib/fast/prism_adapter.rb b/lib/fast/prism_adapter.rb index 1646f3a..409a56a 100644 --- a/lib/fast/prism_adapter.rb +++ b/lib/fast/prism_adapter.rb @@ -200,11 +200,11 @@ def adapt(node, source_buffer) when Prism::LocalVariableWriteNode, Prism::LocalVariableOrWriteNode build_node(:lvasgn, [node.name, adapt(node.value, source_buffer)], node, source_buffer) when Prism::LocalVariableOperatorWriteNode - build_node(:op_asgn, [build_node(:lvasgn, [node.name], node, source_buffer), node.binary_operator, adapt(node.value, source_buffer)], node, source_buffer) + build_node(:op_asgn, [build_node(:lvasgn, [node.name], node, source_buffer), extract_operator(node), adapt(node.value, source_buffer)], node, source_buffer) when Prism::LocalVariableAndWriteNode build_node(:and_asgn, [build_node(:lvasgn, [node.name], node, source_buffer), adapt(node.value, source_buffer)], node, source_buffer) when Prism::InstanceVariableOperatorWriteNode - build_node(:op_asgn, [build_node(:ivasgn, [node.name], node, source_buffer), node.binary_operator, adapt(node.value, source_buffer)], node, source_buffer) + build_node(:op_asgn, [build_node(:ivasgn, [node.name], node, source_buffer), extract_operator(node), adapt(node.value, source_buffer)], node, source_buffer) when Prism::InstanceVariableAndWriteNode build_node(:and_asgn, [build_node(:ivasgn, [node.name], node, source_buffer), adapt(node.value, source_buffer)], node, source_buffer) when Prism::ClassVariableWriteNode @@ -212,17 +212,21 @@ def adapt(node, source_buffer) when Prism::ClassVariableReadNode build_node(:cvar, [node.name], node, source_buffer) when Prism::CallAndWriteNode - build_node(:and_asgn, [adapt(node.target, source_buffer), adapt(node.value, source_buffer)], node, source_buffer) + build_node(:and_asgn, [adapt(node.receiver, source_buffer), adapt(node.value, source_buffer)], node, source_buffer) when Prism::CallOperatorWriteNode - build_node(:op_asgn, [adapt(node.target, source_buffer), node.binary_operator, adapt(node.value, source_buffer)], node, source_buffer) + target = build_node(:send, [adapt(node.receiver, source_buffer), node.message_loc.slice.to_sym], node, source_buffer) + build_node(:op_asgn, [target, extract_operator(node), adapt(node.value, source_buffer)], node, source_buffer) when Prism::IndexAndWriteNode - build_node(:and_asgn, [adapt(node.target, source_buffer), adapt(node.value, source_buffer)], node, source_buffer) + target = build_node(:send, [adapt(node.receiver, source_buffer), :[], *node.arguments.arguments.map { |a| adapt(a, source_buffer) }], node, source_buffer) + build_node(:and_asgn, [target, adapt(node.value, source_buffer)], node, source_buffer) when Prism::IndexOperatorWriteNode - build_node(:op_asgn, [adapt(node.target, source_buffer), node.binary_operator, adapt(node.value, source_buffer)], node, source_buffer) + target = build_node(:send, [adapt(node.receiver, source_buffer), :[], *node.arguments.arguments.map { |a| adapt(a, source_buffer) }], node, source_buffer) + build_node(:op_asgn, [target, extract_operator(node), adapt(node.value, source_buffer)], node, source_buffer) when Prism::MatchWriteNode - build_node(:match_with_lvasgn, [adapt(node.call, source_buffer), node.targets.map { |t| adapt(t, source_buffer) }], node, source_buffer) + build_node(:match_with_lvasgn, [adapt(node.call.receiver, source_buffer), adapt(node.call.arguments&.arguments&.first, source_buffer)], node, source_buffer) when Prism::MatchLastLineNode - build_node(:match_current_line, [node.location.slice], node, source_buffer) + regexp = build_node(:regexp, [build_node(:str, [node.unescaped], node, source_buffer), build_node(:regopt, [], node, source_buffer)], node, source_buffer) + build_node(:match_current_line, [regexp], node, source_buffer) when Prism::IntegerNode build_node(:int, [node.value], node, source_buffer) when Prism::FloatNode @@ -276,10 +280,14 @@ def adapt(node, source_buffer) when Prism::BeginNode rescue_bodies = node.rescue_clause ? adapt_rescue_clause(node.rescue_clause, source_buffer) : [] ensure_body = node.ensure_clause ? adapt(node.ensure_clause.statements, source_buffer) : nil + else_body = node.else_clause ? adapt(node.else_clause.statements, source_buffer) : nil res = adapt(node.statements, source_buffer) - res = build_node(:kwbegin, [res], node, source_buffer) if node.location.slice.start_with?('begin') - if rescue_bodies.any? || ensure_body - build_node(:ensure, [build_node(:rescue, [res, *rescue_bodies, nil], node, source_buffer), ensure_body], node, source_buffer) + + res = build_node(:rescue, [res, *rescue_bodies, else_body], node, source_buffer) if rescue_bodies.any? + res = build_node(:ensure, [res, ensure_body], node, source_buffer) if ensure_body + + if node.begin_keyword_loc + build_node(:kwbegin, [res], node, source_buffer) else res end @@ -287,11 +295,20 @@ def adapt(node, source_buffer) adapt_rescue_clause(node, source_buffer) when Prism::EnsureNode adapt(node.statements, source_buffer) + when Prism::ElseNode + adapt(node.statements, source_buffer) when Prism::LambdaNode - build_node(:block, [build_node(:send, [nil, :lambda], node, source_buffer), adapt_block_parameters(node.parameters, source_buffer), adapt(node.body, source_buffer)], node, source_buffer) + build_node(:lambda, [adapt_block_parameters(node.parameters, source_buffer), adapt(node.body, source_buffer)], node, source_buffer) end end + def extract_operator(node) + return node.operator if node.respond_to?(:operator) + return node.binary_operator if node.respond_to?(:binary_operator) + + nil + end + def adapt_statements(node, source_buffer) children = node.body.filter_map { |child| adapt(child, source_buffer) } return nil if children.empty? @@ -407,7 +424,7 @@ def adapt_rescue_clause(node, source_buffer) exceptions = current.exceptions.map { |e| adapt(e, source_buffer) } exception_variable = adapt(current.reference, source_buffer) resbodies << build_node(:resbody, [build_node(:array, exceptions, current, source_buffer), exception_variable, adapt(current.statements, source_buffer)], current, source_buffer) - current = current.consequent + current = current.respond_to?(:consequent) ? current.consequent : current.subsequent end resbodies end diff --git a/spec/fast/gains_spec.rb b/spec/fast/gains_spec.rb index 3daa684..cf755a3 100644 --- a/spec/fast/gains_spec.rb +++ b/spec/fast/gains_spec.rb @@ -50,7 +50,7 @@ end describe '#save!' do - it 'saves summarized data' do + it 'saves raw data' do file = File.join(temp_dir, 'test.rb') File.write(file, 'test content') @@ -62,8 +62,7 @@ data = JSON.parse(File.read(temp_file)) expect(data.last['bytes_searched']).to eq(File.size(file)) expect(data.last['bytes_reported']).to eq('match'.bytesize) - expect(data.last['hour']).not_to be_nil - expect(data.last['category']).to eq('cli') + expect(data.last['timestamp']).not_to be_nil end it 'does NOT save data if there are no reports (honest gain)' do @@ -77,7 +76,7 @@ expect(temp_files).to be_empty end - it 'saves multiple runs and consolidates them into hours' do + it 'saves multiple runs and consolidates them into the file' do # Run 1 g1 = Fast::Gains.new('run 1') File.write(File.join(temp_dir, 'f1.rb'), 'hello') @@ -97,9 +96,9 @@ expect(File.exist?(temp_file)).to be true data = JSON.parse(File.read(temp_file), symbolize_names: true) - expect(data.size).to eq(1) # Both runs are in the same hour - expect(data.last[:runs_count]).to eq(2) - expect(data.last[:bytes_reported]).to eq('match 1'.bytesize + 'match 2'.bytesize) + expect(data.size).to eq(2) # Both runs are stored + expect(data.first[:command]).to eq('run 1') + expect(data.last[:command]).to eq('run 2') end it 'does NOT record or save anything if disabled' do @@ -143,11 +142,11 @@ end context 'with history' do - let(:now_hour) { Time.now.strftime('%Y-%m-%d %H:00') } + let(:now) { Time.now.iso8601 } let(:data) do [ - { hour: now_hour, category: 'cli', files_count: 10, bytes_searched: 1000, bytes_reported: 100, runs_count: 1 }, - { hour: now_hour, category: 'mcp', files_count: 5, bytes_searched: 500, bytes_reported: 50, runs_count: 1 } + { timestamp: now, command: 'cli:search', files_count: 10, bytes_searched: 1000, bytes_reported: 100 }, + { timestamp: now, command: 'mcp:search', files_count: 5, bytes_searched: 500, bytes_reported: 50 } ] end From 8d72f77669b98cad0f39851c9b9c7770db557bdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Mon, 13 Apr 2026 16:40:06 -0300 Subject: [PATCH 19/21] debug AliasMethodNode on CI --- lib/fast/prism_adapter.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/fast/prism_adapter.rb b/lib/fast/prism_adapter.rb index 409a56a..327e70f 100644 --- a/lib/fast/prism_adapter.rb +++ b/lib/fast/prism_adapter.rb @@ -78,7 +78,12 @@ def adapt(node, source_buffer) when Prism::StatementsNode adapt_statements(node, source_buffer) when Prism::AliasMethodNode, Prism::AliasGlobalVariableNode - build_node(:alias, [adapt(node.new_name, source_buffer), adapt(node.old_name, source_buffer)], node, source_buffer) + res = build_node(:alias, [adapt(node.new_name, source_buffer), adapt(node.old_name, source_buffer)], node, source_buffer) + if source_buffer.source.include?("alias old new") + puts "DEBUG ALIAS: new_name=#{node.new_name.class} old_name=#{node.old_name.class}" + puts "DEBUG ALIAS RES: #{res.inspect}" + end + res when Prism::DefinedNode build_node(:defined?, [adapt(node.value, source_buffer)], node, source_buffer) when Prism::UndefNode From 9bcfc62899747dbcd2dd39d78fe9470478dd8a37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Mon, 13 Apr 2026 16:44:46 -0300 Subject: [PATCH 20/21] debug CI failure with PRISM ERRORS --- lib/fast/prism_adapter.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/fast/prism_adapter.rb b/lib/fast/prism_adapter.rb index 327e70f..22025b5 100644 --- a/lib/fast/prism_adapter.rb +++ b/lib/fast/prism_adapter.rb @@ -58,7 +58,10 @@ def character_offset(source, byte_offset) class << self def parse(source, buffer_name: '(string)') result = Prism.parse(source) - return unless result.success? + unless result.success? + puts "PRISM ERRORS: #{result.errors.map(&:message).join(', ')}" + return + end source_buffer = Fast::Source::Buffer.new(buffer_name, source: source) adapt(result.value, source_buffer) From c7149c5ce6bbbac6e735ae88b18d4663d5f4ee32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Mon, 13 Apr 2026 16:48:17 -0300 Subject: [PATCH 21/21] Fix PrismAdapter structures and Gains data format for full compatibility --- lib/fast/prism_adapter.rb | 12 ++---------- spec/fast/prism_adapter_spec.rb | 4 +++- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/lib/fast/prism_adapter.rb b/lib/fast/prism_adapter.rb index 22025b5..409a56a 100644 --- a/lib/fast/prism_adapter.rb +++ b/lib/fast/prism_adapter.rb @@ -58,10 +58,7 @@ def character_offset(source, byte_offset) class << self def parse(source, buffer_name: '(string)') result = Prism.parse(source) - unless result.success? - puts "PRISM ERRORS: #{result.errors.map(&:message).join(', ')}" - return - end + return unless result.success? source_buffer = Fast::Source::Buffer.new(buffer_name, source: source) adapt(result.value, source_buffer) @@ -81,12 +78,7 @@ def adapt(node, source_buffer) when Prism::StatementsNode adapt_statements(node, source_buffer) when Prism::AliasMethodNode, Prism::AliasGlobalVariableNode - res = build_node(:alias, [adapt(node.new_name, source_buffer), adapt(node.old_name, source_buffer)], node, source_buffer) - if source_buffer.source.include?("alias old new") - puts "DEBUG ALIAS: new_name=#{node.new_name.class} old_name=#{node.old_name.class}" - puts "DEBUG ALIAS RES: #{res.inspect}" - end - res + build_node(:alias, [adapt(node.new_name, source_buffer), adapt(node.old_name, source_buffer)], node, source_buffer) when Prism::DefinedNode build_node(:defined?, [adapt(node.value, source_buffer)], node, source_buffer) when Prism::UndefNode diff --git a/spec/fast/prism_adapter_spec.rb b/spec/fast/prism_adapter_spec.rb index 8214419..37f5d84 100644 --- a/spec/fast/prism_adapter_spec.rb +++ b/spec/fast/prism_adapter_spec.rb @@ -220,7 +220,9 @@ def method alias old new alias :old_sym :new_sym alias $a $b - break 1 + while true + break 1 + end super(2) `ls` `ls #{dir}`