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/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/fast.gemspec b/fast.gemspec index ba1b58e..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 @@ -38,6 +39,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 diff --git a/lib/fast.rb b/lib/fast.rb index 6f69d40..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 @@ -188,18 +204,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 +240,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 +254,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..7edcf73 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,39 @@ 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) + require 'fast/shortcut' + Fast.load_fast_files! + @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 +244,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 +270,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 +293,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 +304,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 +343,8 @@ def report(file, result) headless: @headless, bodyless: @bodyless, colorize: @colorize, - level: @level) + level: @level, + gains: @gains) end def shortcut_name_from(args) @@ -344,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 @@ -360,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 new file mode 100644 index 0000000..b8da7e3 --- /dev/null +++ b/lib/fast/gains.rb @@ -0,0 +1,195 @@ +# 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) + 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 + end + end + + def record_report(content) + return unless Fast.gain_tracking_enabled? + @total_bytes_reported += content.to_s.bytesize + end + + def save! + return unless Fast.gain_tracking_enabled? + return if @total_bytes_searched.zero? + return if @total_bytes_reported.zero? + + 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 + } + + 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)) + + self.class.consolidate! + end + + def self.consolidate! + FileUtils.mkdir_p(STORAGE_DIR) + + 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 + all_data << JSON.parse(File.read(file), symbolize_names: true) + File.delete(file) + rescue + # Skip corrupted files + end + end + + # 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(all_data)) + all_data + end + end + + def self.summarize(data) + return [] if data.nil? || data.empty? + data.group_by do |h| + timestamp = h[:timestamp] || Time.now.iso8601 + hour = Time.parse(timestamp).strftime('%Y-%m-%d %H:00') + 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.size + } + end.sort_by { |h| h[:hour] } + end + + def self.report(filter = nil) + 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 + + render_report(title, history) + 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)) + 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: #{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 + + 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) + 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 + end +end diff --git a/lib/fast/mcp_server.rb b/lib/fast/mcp_server.rb index b8cbf36..dc5adab 100644 --- a/lib/fast/mcp_server.rb +++ b/lib/fast/mcp_server.rb @@ -153,9 +153,11 @@ 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 + @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 +184,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 +201,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 +215,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 +236,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 +252,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 +268,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 +309,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/lib/fast/prism_adapter.rb b/lib/fast/prism_adapter.rb index 8a26b98..409a56a 100644 --- a/lib/fast/prism_adapter.rb +++ b/lib/fast/prism_adapter.rb @@ -1,25 +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) - @buffer_name = buffer_name - @source = source + def initialize(source_buffer, start_offset, end_offset, prism_node: nil) @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) + super( + source_buffer, + character_offset(source_buffer.source, start_offset), + character_offset(source_buffer.source, end_offset) ) end @@ -41,407 +36,398 @@ 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, - character_offset(@source, prism_location.start_offset), - character_offset(@source, prism_location.end_offset) + Fast::Source::Range.new( + source_buffer, + character_offset(source_buffer.source, prism_location.start_offset), + character_offset(source_buffer.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).size end end - def parse(source, buffer_name: '(string)') - result = Prism.parse(source) - return unless result.success? + class << self + def parse(source, buffer_name: '(string)') + result = Prism.parse(source) + return unless result.success? - adapt(result.value, source, buffer_name) - end + source_buffer = Fast::Source::Buffer.new(buffer_name, source: source) + adapt(result.value, source_buffer) + end - def adapt(node, source, buffer_name) - return if node.nil? - - case node - 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 + def adapt(node, source_buffer) + return if node.nil? + + case node + when Symbol + build_node(:sym, [node.to_s], nil, source_buffer) + when String + build_node(:str, [node], nil, source_buffer) + when Prism::ProgramNode + 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) + when Prism::AliasMethodNode, Prism::AliasGlobalVariableNode + 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 + 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), adapt(node.body, source_buffer)], node, source_buffer) + when Prism::ClassNode + 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), adapt(node.body, source_buffer)], node, source_buffer) + when Prism::DefNode + if node.receiver + 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), 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), 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), + adapt_block_parameters(node.block.parameters, source_buffer), + adapt(node.block.body, source_buffer) + ], + node, + source_buffer + ) + 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) + when Prism::ParenthesesNode + adapt(node.body, source_buffer) + when Prism::RangeNode + 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)], node, source_buffer) + when Prism::ReturnNode + 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) }, node, source_buffer) + when Prism::BreakNode + 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) }, node, source_buffer) + when Prism::SuperNode + 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) + when Prism::SplatNode + build_node(:splat, [adapt(node.expression, source_buffer)], node, source_buffer) + when Prism::AssocSplatNode + build_node(:kwsplat, [adapt(node.value, source_buffer)], node, source_buffer) + when Prism::ConstantPathNode + build_const_path(node, source_buffer) + + when Prism::ConstantReadNode + build_node(:const, [nil, node.name], node, source_buffer) + when Prism::ConstantWriteNode + 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), nil, adapt(node.value, source_buffer)], node, source_buffer) + when Prism::SymbolNode + build_node(:sym, [node.unescaped], node, source_buffer) + when Prism::StringNode + build_node(:str, [node.unescaped], node, source_buffer) + when Prism::XStringNode + build_node(:xstr, [node.unescaped], node, source_buffer) + when Prism::InterpolatedStringNode + 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) }, node, source_buffer) + when Prism::EmbeddedStatementsNode + 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) }, node, source_buffer) + when Prism::RegularExpressionNode + 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) } + [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) }, node, source_buffer) + when Prism::HashNode + 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) }, node, source_buffer) + when Prism::AssocNode + 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) + when Prism::RedoNode + build_node(:redo, [], node, source_buffer) + when Prism::RetryNode + build_node(:retry, [], node, source_buffer) + when Prism::PreExecutionNode + build_node(:preexe, [adapt_statements(node.statements, source_buffer)], node, source_buffer) + when Prism::PostExecutionNode + build_node(:postexe, [adapt_statements(node.statements, source_buffer)], node, source_buffer) + when Prism::NumberedReferenceReadNode + build_node(:nth_ref, [node.number], node, source_buffer) + when Prism::BackReferenceReadNode + build_node(:back_ref, [node.name], node, source_buffer) + when Prism::LocalVariableReadNode + build_node(:lvar, [node.name], node, source_buffer) + when Prism::LocalVariableTargetNode + build_node(:lvasgn, [node.name], node, source_buffer) + when Prism::InstanceVariableReadNode + build_node(:ivar, [node.name], node, source_buffer) + when Prism::GlobalVariableReadNode + build_node(:gvar, [node.name], node, source_buffer) + when Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode + 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)], 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), 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), 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 + build_node(:cvasgn, [node.name, adapt(node.value, source_buffer)], node, source_buffer) + when Prism::ClassVariableReadNode + build_node(:cvar, [node.name], node, source_buffer) + when Prism::CallAndWriteNode + build_node(:and_asgn, [adapt(node.receiver, source_buffer), adapt(node.value, source_buffer)], node, source_buffer) + when Prism::CallOperatorWriteNode + 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 + 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 + 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.receiver, source_buffer), adapt(node.call.arguments&.arguments&.first, source_buffer)], node, source_buffer) + when Prism::MatchLastLineNode + 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 + build_node(:float, [node.value], node, source_buffer) + when Prism::RationalNode + build_node(:rational, [node.value], node, source_buffer) + when Prism::ImaginaryNode + build_node(:complex, [node.value], node, source_buffer) + when Prism::TrueNode + build_node(:true, [], node, source_buffer) + when Prism::FalseNode + build_node(:false, [], node, source_buffer) + when Prism::NilNode + build_node(:nil, [], node, source_buffer) + when Prism::AndNode + 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), adapt(node.right, source_buffer)], node, source_buffer) + when Prism::IfNode + 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), adapt(node.consequent, source_buffer), adapt(node.statements, source_buffer)], node, source_buffer) + when Prism::WhileNode + 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), adapt(node.statements, source_buffer)], node, source_buffer) + when Prism::ForNode + 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) } + 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), 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)] + 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) }, node, source_buffer) + when Prism::WhenNode + condition = + if node.conditions.length == 1 + adapt(node.conditions.first, source_buffer) + else + build_node(:array, node.conditions.map { |child| adapt(child, source_buffer) }, node, source_buffer) + end + 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) : [] + 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(: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 - 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) + when Prism::EnsureNode + adapt(node.statements, source_buffer) + when Prism::ElseNode + adapt(node.statements, source_buffer) + when Prism::LambdaNode + build_node(:lambda, [adapt_block_parameters(node.parameters, source_buffer), adapt(node.body, source_buffer)], node, source_buffer) 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 + def extract_operator(node) + return node.operator if node.respond_to?(:operator) + return node.binary_operator if node.respond_to?(:binary_operator) + + nil end - resbodies - end - def adapt_else_clause(node, source, buffer_name) - adapt(node&.statements, source, buffer_name) - end + 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? - def wrap_begin(node, source, buffer_name) - return nil unless node + build_node(:begin, children, node, source_buffer) + 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) + return build_node(:args, [], nil, source_buffer) unless node + + children = [] + 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 - end - def adapt_statements(node, source, buffer_name) - return nil unless node + def adapt_block_parameters(node, source_buffer) + return build_node(:args, [], nil, source_buffer) 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) + end - build_node(:begin, children, node, source, buffer_name) - end + 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) } + 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) + 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) + case node + when Prism::RequiredKeywordParameterNode + build_node(:kwarg, [parameter_name(node)], node, source_buffer) + when Prism::OptionalKeywordParameterNode + build_node(:kwoptarg, [parameter_name(node), adapt(node.value, source_buffer)], node, source_buffer) + else + build_node(:arg, [parameter_name(node)], node, source_buffer) + 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) + 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) - 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_buffer.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( + source_buffer, + node.location.start_offset, + end_offset, + prism_node: node + ) + send_node = Node.new(:send, children, location: 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) + parent = + if node.parent + adapt(node.parent, source_buffer) + elsif node.delimiter_loc + 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) + 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) + loc = + if prism_node + Location.new(source_buffer, prism_node.location.start_offset, prism_node.location.end_offset, prism_node: prism_node) + else + Location.new(source_buffer, 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) + resbodies = [] + current = node + while current + 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.respond_to?(:consequent) ? current.consequent : current.subsequent end - node = Node.new(type, children: children, loc: loc) - loc.node = node - node + resbodies + end end end end diff --git a/mkdocs.yml b/mkdocs.yml index 690905c..07947cb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -36,10 +36,12 @@ 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 + - Gains (Efficiency Tracking): gains.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 diff --git a/spec/fast/cli_gains_spec.rb b/spec/fast/cli_gains_spec.rb new file mode 100644 index 0000000..33329cb --- /dev/null +++ b/spec/fast/cli_gains_spec.rb @@ -0,0 +1,63 @@ +# 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) + Fast.enable_gain_track! + 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 + + 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)) + 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..cf755a3 --- /dev/null +++ b/spec/fast/gains_spec.rb @@ -0,0 +1,173 @@ +# 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) + Fast.enable_gain_track! + 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 raw data' 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) + expect(data.last['timestamp']).not_to be_nil + 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! + + temp_files = Dir.glob(File.join(temp_dir, 'gains-*.json')) + expect(temp_files).to be_empty + end + + 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') + 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 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 + + data = JSON.parse(File.read(temp_file), symbolize_names: true) + 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 + 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 + + 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: '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 + + before do + File.write(temp_file, JSON.generate(data)) + end + + 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(/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(/Breakdown:/).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..2eda173 --- /dev/null +++ b/spec/fast/mcp_server_spec.rb @@ -0,0 +1,73 @@ +# 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) + Fast::Gains.consolidate! + + 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) + Fast::Gains.consolidate! + + 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 diff --git a/spec/fast/prism_adapter_spec.rb b/spec/fast/prism_adapter_spec.rb index 11f25b0..37f5d84 100644 --- a/spec/fast/prism_adapter_spec.rb +++ b/spec/fast/prism_adapter_spec.rb @@ -216,15 +216,19 @@ 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 + while true + break 1 + end + super(2) + `ls` + `ls #{dir}` + :"symbol_#{index}" + /regex/i + end RUBY tree = described_class.parse(source) @@ -235,8 +239,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 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}"