diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 248fe125c..0cfc3eac6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,15 @@ nav_order: 5 *Joseph Carpenter* +* 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 index 7c3a900ad..05d7e1c84 100644 --- a/lib/view_component/codemods/v3_slot_setters.rb +++ b/lib/view_component/codemods/v3_slot_setters.rb @@ -1,16 +1,19 @@ -# Usage (in rails console): -# -# Run the codemod: -# -# ViewComponent::Codemods::V3SlotSetters.new.call -# -# If your app uses custom paths for views, you can pass them in: -# -# ViewComponent::Codemods::V3SlotSetters.new( -# view_path: "../app/views", -# ).call +# 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 @@ -18,11 +21,12 @@ class V3SlotSetters Suggestion = Struct.new(:file, :line, :message) - def initialize(view_component_path: [], view_path: []) - Zeitwerk::Loader.eager_load_all + 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 @@ -72,7 +76,8 @@ def scan_exact_matches(file) end end - File.open(file) do |f| + File.open(file, "r+") do |f| + lines = [] f.each_line do |line| rendered_components.each do |rendered_component| arg = rendered_component[:arg] @@ -80,10 +85,25 @@ def scan_exact_matches(file) if (matches = line.scan(/#{arg}\.#{Regexp.union(slots)}/)) matches.each do |match| - suggestions << Suggestion.new(file, f.lineno, "probably replace `#{match}` with `#{match.gsub("#{arg}.", "#{arg}.with_")}`") + 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 @@ -91,17 +111,30 @@ def scan_exact_matches(file) def scan_uncertain_matches(file) [].tap do |suggestions| - File.open(file) do |f| + File.open(file, "r+") do |f| + lines = [] f.each_line do |line| - if (matches = line.scan(/(?#{Regexp.union(all_registered_slot_names)})/)) - next if matches.size == 0 - + 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 } - suggestions << Suggestion.new(file, f.lineno, "maybe replace `.#{match}` with `.with_#{match}`") + 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