diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 77521fe91..cbc2c8035 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -76,6 +76,15 @@ nav_order: 5 *Reegan Viljoen* +* Add codemod to detect and migrate deprecated Slots setters to new `with_*` prefix introduced in v3.x. Note: This codemod is non-deterministic and works on a best-effort basis. + + ```bash + bin/rails view_component:detect_legacy_slots + bin/rails view_component:migrate_legacy_slots + ``` + + *Hans Lemuet, Kirill Platonov* + ### v3.0.0 1,000+ days and 100+ releases later, the 200+ contributors to ViewComponent are proud to ship v3.0.0! diff --git a/lib/tasks/view_component.rake b/lib/tasks/view_component.rake new file mode 100644 index 000000000..9b0bcd45e --- /dev/null +++ b/lib/tasks/view_component.rake @@ -0,0 +1,15 @@ +require "view_component/codemods/v3_slot_setters" + +namespace :view_component do + task detect_legacy_slots: :environment do + ARGV.each { |a| task a.to_sym {} } + custom_paths = ARGV.compact.map { |path| Rails.root.join(path) } + ViewComponent::Codemods::V3SlotSetters.new(view_path: custom_paths).call + end + + task migrate_legacy_slots: :environment do + ARGV.each { |a| task a.to_sym {} } + custom_paths = ARGV.compact.map { |path| Rails.root.join(path) } + ViewComponent::Codemods::V3SlotSetters.new(view_path: custom_paths, migrate: true).call + end +end diff --git a/lib/view_component/codemods/v3_slot_setters.rb b/lib/view_component/codemods/v3_slot_setters.rb new file mode 100644 index 000000000..05d7e1c84 --- /dev/null +++ b/lib/view_component/codemods/v3_slot_setters.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +module ViewComponent + # Usage: + # + # Run via rake task: + # + # bin/rails view_component:detect_legacy_slots + # bin/rails view_component:migrate_legacy_slots + # bin/rails view_component:migrate_legacy_slots app/views + # + # Or run via rails console if you need to pass custom paths: + # + # ViewComponent::Codemods::V3SlotSetters.new( + # view_path: Rails.root.join("app/views"), + # ).call + module Codemods + class V3SlotSetters + TEMPLATE_LANGUAGES = %w[erb slim haml].join(",").freeze + RENDER_REGEX = /render[( ](?\w+(?:::\w+)*)\.new[) ]+(do|\{) \|(?\w+)\b/ # standard:disable Lint/MixedRegexpCaptureTypes + + Suggestion = Struct.new(:file, :line, :message) + + def initialize(view_component_path: [], view_path: [], migrate: false) + Rails.application.eager_load! + + @view_component_path = view_component_path + @view_path = view_path + @migrate = migrate + end + + def call + puts "Using ViewComponent path: #{view_component_paths.join(", ")}" + puts "Using Views path: #{view_paths.join(", ")}" + puts "#{view_components.size} ViewComponents found" + puts "#{slottable_components.size} ViewComponents using Slots found" + puts "#{view_component_files.size} ViewComponent templates found" + puts "#{view_files.size} view files found" + process_all_files + end + + def process_all_files + all_files.each do |file| + process_file(file) + end + end + + def process_file(file) + @suggestions = [] + @suggestions += scan_exact_matches(file) + @suggestions += scan_uncertain_matches(file) + + if @suggestions.any? + puts + puts "File: #{file}" + @suggestions.each do |s| + puts "=> line #{s.line}: #{s.message}" + end + end + end + + private + + def scan_exact_matches(file) + [].tap do |suggestions| + rendered_components = [] + content = File.read(file) + + if (render_match = content.match(RENDER_REGEX)) + component = render_match[:component] + arg = render_match[:arg] + + if registered_slots.key?(component.constantize) + used_slots_names = registered_slots[component.constantize] + rendered_components << {component: component, arg: arg, slots: used_slots_names} + end + end + + File.open(file, "r+") do |f| + lines = [] + f.each_line do |line| + rendered_components.each do |rendered_component| + arg = rendered_component[:arg] + slots = rendered_component[:slots] + + if (matches = line.scan(/#{arg}\.#{Regexp.union(slots)}/)) + matches.each do |match| + new_value = match.gsub("#{arg}.", "#{arg}.with_") + message = if @migrate + "replaced `#{match}` with `#{new_value}`" + else + "probably replace `#{match}` with `#{new_value}`" + end + suggestions << Suggestion.new(file, f.lineno, message) + if @migrate + line.gsub!("#{arg}.", "#{arg}.with_") + end + end + end + end + lines << line + end + + if @migrate + f.rewind + f.write(lines.join) + end + end + end + end + + def scan_uncertain_matches(file) + [].tap do |suggestions| + File.open(file, "r+") do |f| + lines = [] + f.each_line do |line| + if (matches = line.scan(/(?#{Regexp.union(all_registered_slot_names)})\b/)) + matches.flatten.each do |match| + next if @suggestions.find { |s| s.file == file && s.line == f.lineno } + + message = if @migrate + "replaced `#{match}` with `with_#{match}`" + else + "maybe replace `#{match}` with `with_#{match}`" + end + suggestions << Suggestion.new(file, f.lineno, message) + if @migrate + line.gsub!(/(? + <% component.subtitle do %> + This is my subtitle! + <% end %> +<% end %> diff --git a/test/sandbox/app/views/codemods/_v2_slots_setters_exact.html.erb b/test/sandbox/app/views/codemods/_v2_slots_setters_exact.html.erb new file mode 100644 index 000000000..404869e8f --- /dev/null +++ b/test/sandbox/app/views/codemods/_v2_slots_setters_exact.html.erb @@ -0,0 +1,14 @@ +<%= render SlotsComponent.new do |component| %> + <% component.title do %> + This is my title! + <% end %> +<% end %> + +<%= render SlotsComponent.new do |component| %> + <% component.tab do %> +

Tab A

+ <% end %> + <% component.tab do %> +

Tab B

+ <% end %> +<% end %> diff --git a/test/sandbox/app/views/integration_examples/empty_slot.slim b/test/sandbox/app/views/integration_examples/empty_slot.slim index b802753b1..af6a928bc 100644 --- a/test/sandbox/app/views/integration_examples/empty_slot.slim +++ b/test/sandbox/app/views/integration_examples/empty_slot.slim @@ -1,3 +1,3 @@ = render(EmptySlotComponent.new) do |component| - - component.title + - component.with_title - nil diff --git a/test/sandbox/test/codemods/v3_slot_setters_test.rb b/test/sandbox/test/codemods/v3_slot_setters_test.rb new file mode 100644 index 000000000..b568eb37b --- /dev/null +++ b/test/sandbox/test/codemods/v3_slot_setters_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "test_helper" +require "view_component/codemods/v3_slot_setters" + +class V3SlotSettersTest < Minitest::Test + def teardown + restore_legacy_slots + end + + def test_detects_legacy_slots + output = capture_output do + ViewComponent::Codemods::V3SlotSetters.new.call + end + + assert_match "_v2_slots_setters_exact.html.erb\n=> line 2: probably replace `component.title` with `component.with_title`", output + assert_match "line 8: probably replace `component.tab` with `component.with_tab`", output + assert_match "line 11: probably replace `component.tab` with `component.with_tab`", output + assert_match "_v2_slots_setters_alias.html.erb\n=> line 2: maybe replace `subtitle` with `with_subtitle`", output + end + + def test_migrate_legacy_slots + ViewComponent::Codemods::V3SlotSetters.new(migrate: true).call + + output = capture_output do + ViewComponent::Codemods::V3SlotSetters.new.call + end + + refute_match "_v2_slots_setters_exact.html.erb\n=> line 2: probably replace `component.title` with `component.with_title`", output + refute_match "line 6: probably replace `component.tab` with `component.with_tab`", output + refute_match "line 9: probably replace `component.tab` with `component.with_tab`", output + refute_match "_v2_slots_setters_alias.html.erb\n=> line 2: maybe replace `subtitle` with `with_subtitle`", output + end + + private + + def capture_output + original_stdout = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original_stdout + end + + def restore_legacy_slots + test_views = [ + Rails.root.join("app/views/codemods/_v2_slots_setters_alias.html.erb"), + Rails.root.join("app/views/codemods/_v2_slots_setters_exact.html.erb") + ] + test_views.each do |file| + content = File.read(file) + content.gsub!("with_", "") + File.write(file, content) + end + end +end