lib/vercon/commands/generate.rb in vercon-0.0.1 vs lib/vercon/commands/generate.rb in vercon-0.0.2

- old
+ new

@@ -1,31 +1,36 @@ # frozen_string_literal: true -require 'prism' -require 'dry/files' -require 'tty-spinner' -require 'tty-pager' -require 'tty-editor' +require "prism" +require "dry/files" +require "tty-spinner" +require "rouge" +require "tty-editor" module Vercon module Commands class Generate < Dry::CLI::Command - desc 'Generate test file' + AUTOFIXERS = { + standard: "bundle exec standardrb --fix %{file} > /dev/null 2>&1", + rubocop: "bundle exec rubocop -a %{file} > /dev/null 2>&1" + } - argument :path, desc: 'Path to the ruby file' + desc "Generate test file" - option :edit_prompt, type: :boolean, default: false, aliases: ['e'], - desc: 'Edit prompt before submitting to claude' - option :output_path, type: :string, default: nil, aliases: ['o'], - desc: 'Path to save test file' - option :stdout, type: :boolean, default: false, aliases: ['s'], - desc: 'Output test file to stdout instead of writing to test file' - option :force, type: :boolean, default: false, aliases: ['f'], - desc: 'Force overwrite of existing test file' - option :open, type: :boolean, default: false, aliases: ['p'], - desc: 'Open test file in editor after generation' + argument :path, desc: "Path to the ruby file" + option :edit_prompt, type: :boolean, default: false, aliases: ["e"], + desc: "Edit prompt before submitting to claude" + option :output_path, type: :string, default: nil, aliases: ["o"], + desc: "Path to save test file" + option :stdout, type: :boolean, default: false, aliases: ["s"], + desc: "Output test file to stdout instead of writing to test file" + option :force, type: :boolean, default: false, aliases: ["f"], + desc: "Force overwrite of existing test file" + option :open, type: :boolean, default: nil, aliases: ["p"], + desc: "Open test file in editor after generation" + def initialize @config = Vercon::Config.new @stdout = Vercon::Stdout.new @files = Dry::Files.new @@ -39,157 +44,192 @@ return if output_path.nil? current_test = files.exist?(output_path) ? files.read(output_path) : nil result = generate_test_file(path, opts, current_test) + return if result.nil? + result = run_autofixes(result) + if opts[:stdout] - pager = TTY::Pager.new - pager.page(result) + formatter = Rouge::Formatters::Terminal256.new(Rouge::Themes::Base16::Monokai.new) + lexer = Rouge::Lexers::Ruby.new + + stdout.puts(formatter.format(lexer.lex(result))) return end if !opts[:force] && files.exist?(output_path) && stdout.no?("File already exists at \"#{output_path}\". Overwrite?") return end files.write(output_path, result) - run_rubocop(output_path) if include_gem?('rubocop') || include_gem?('standard') - stdout.ok("Test file saved at \"#{output_path}\" 🥳") - return unless opts[:open] - - TTY::Editor.new(raise_on_failure: true).open(output_path) + if opts[:open] == true || (opts[:open].nil? && config.open_by_default?) + TTY::Editor.new(raise_on_failure: true).open(output_path) + end end private attr_reader :config, :stdout, :files def can_generate?(path, _opts) unless config.exists? - stdout.error('Config file does not exist. Run `vercon init` to create a config file.') + stdout.error("Config file does not exist. Run `vercon init` to create a config file.") return false end if path.nil? || path.empty? - stdout.error('Path to ruby file is blank.') + stdout.error("Path to ruby file is blank.") return false end unless files.exist?(path) - stdout.error('Ruby file does not exist.') + stdout.error("Ruby file does not exist.") return false end expanded_path = files.expand_path(path) if Prism.parse_file_failure?(expanded_path) - stdout.error('Looks like the ruby file has syntax errors. Fix them before generating tests.') + stdout.error("Looks like the ruby file has syntax errors. Fix them before generating tests.") return false end - unless include_gem?('rspec') - stdout.error('RSpec is not installed. Vercon requires RSpec to generate test files.') + unless include_gem?("rspec") + stdout.error("RSpec is not installed. Vercon requires RSpec to generate test files.") return false end true end def generate_test_file_path(path, _opts) - system, user, stop_sequence = Vercon::Prompt.for_test_path(path: path) - spinner = TTY::Spinner.new('[:spinner] Preparing spec file path...', format: :flip) + prompt = Vercon::Prompt.for_test_path(path: path) + spinner = TTY::Spinner.new("[:spinner] Preparing spec file path...", format: :flip) spinner.auto_spin - result = Vercon::Claude.new.submit( - model: config.class::LOWEST_CLAUDE_MODEL, - system: system, user: user, - stop_sequences: [stop_sequence] - ) + result = Vercon::Claude.new.submit(**prompt.merge(model: config.class::LOWEST_CLAUDE_MODEL)) spinner.stop stdout.erase(lines: 1) if result.key?(:error) stdout.error("Claude returned error: #{result[:error]}") return end - path = result[:text].match(/RSPEC FILE PATH: "(.+)"/)[1] + path, = result[:text].match(/RSPEC FILE PATH: "(.+)"/).captures if stdout.no?("Corresponding test file path should be \"#{path}\". Correct?") - path = stdout.ask('Enter a relative path of corresponding test:') + path = stdout.ask("Enter a relative path of corresponding test:") end path end def generate_test_file(path, opts, current_test) - factories = Vercon::Factories.new.load if include_gem?('factory_bot') - system, user, stop_sequence = Vercon::Prompt.for_test_generation( + factories = Vercon::Factories.new.load if include_gem?("factory_bot") + prompt = Vercon::Prompt.for_test_generation( path: path, source: files.read(path), factories: factories, current_test: current_test ) - system, user = ask_for_edits(system, user) if opts[:edit_prompt] - return if system.nil? || user.nil? + prompt = ask_for_edits(**prompt) if opts[:edit_prompt] + return if prompt[:system].nil? || prompt[:user].nil? || prompt[:tools].nil? - spinner = TTY::Spinner.new('[:spinner] Generating spec file...', format: :flip) + spinner = TTY::Spinner.new("[:spinner] Generating spec file...", format: :flip) spinner.auto_spin - result = Vercon::Claude.new.submit(system: system, user: user, stop_sequences: [stop_sequence]) + result = Vercon::Claude.new.submit(**prompt) spinner.stop stdout.erase(lines: 1) if result.key?(:error) stdout.error("Claude returned error: #{result[:error]}") return end - result[:text].match(/TEST SOURCE CODE:\n```ruby\n(.+)\n```/m)[1] + tool = result[:tools].find { |tool| tool[:name] == "write_test_file" } + + if tool.nil? + stdout.error('Claude did not return the "write_test_file" tool. Aborting generation.') + return nil + end + + source = tool.dig(:input, "source_code") + source = source.match(/```ruby\n(.+)\n```/m).captures.first if source.include?("```ruby") + + source end - def run_rubocop(path) - spinner = TTY::Spinner.new('[:spinner] Running RuboCop...', format: :flip) - spinner.auto_spin + def run_autofixes(source) + file = Tempfile.new("source_spec.rb") + file.write(source) + file.open - system("bundle exec rubocop -A #{files.expand_path(path)} > /dev/null 2>&1") + AUTOFIXERS.each do |name, command| + next unless include_gem?(name.to_s) - spinner.stop - stdout.erase(lines: 1) + spinner = TTY::Spinner.new("[:spinner] Running #{name}...", format: :flip) + spinner.auto_spin + + system(format(command, file: file.path)) + + spinner.stop + stdout.erase(lines: 1) + end + + file.read + ensure + file.unlink end - def ask_for_edits(system, user) - path = '~/.vercon_prompt.txt' - text = <<~EOF.strip - Please, do not remove magick comments :) + def ask_for_edits(system:, user:, tools: nil) + path = "~/.vercon_prompt.txt" + text = [] + text << <<~EOF.strip + Please, do not remove magick comments like <System prompt> and others :) + #{tools.nil? ? "" : "When chaning tools, make sure to keep the general schema section intact, change descriptions only."} + <System prompt> #{system} + <User prompt> #{user} EOF + text << <<~EOF.strip if tools + <Tools> + #{JSON.pretty_generate(tools)} + EOF - files.write(path, text) + files.write(path, text.join("\n\n")) TTY::Editor.new(raise_on_failure: true).open(path) - TTY::Spinner.new('[:spinner] Waiting for changes...', format: :flip).run { sleep(rand(1..3)) } + TTY::Spinner.new("[:spinner] Waiting for changes...", format: :flip).run { sleep(rand(1..3)) } stdout.erase(lines: 1) - if stdout.no?('Can we proceed?') - stdout.error('Generation aborted!') - return [] + if stdout.no?("Can we proceed?") + stdout.error("Generation aborted!") + return {} end - system, user = files.read(path).match(/<System prompt>\n(.+)\n<User prompt>\n(.+)/m).captures + result = files.read(path) + system, user = result.match(/<System prompt>(.+)\n<User prompt>(.+)/m).captures + if tools + user = user.match(/^(.+)\n\n<Tools>/m).captures.first + tools = result.match(/<Tools>(.+)/m)&.captures&.first + tools = JSON.parse(tools) + end - [system, user] + {system: system, user: user, tools: tools}.reject { |_, v| v.nil? } ensure files.delete(path) end def include_gem?(name) - files.read('Gemfile').include?(name) + files.read("Gemfile").include?(name) end end end end