From de176b553256bb21bfa1991beb2d081113d634e0 Mon Sep 17 00:00:00 2001 From: Hans Lemuet Date: Tue, 28 Feb 2023 01:23:55 +0100 Subject: [PATCH 1/6] V3SlotSetters codemod draft --- .../codemods/v3_slot_setters.rb | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 lib/view_component/codemods/v3_slot_setters.rb 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..880f36b9c --- /dev/null +++ b/lib/view_component/codemods/v3_slot_setters.rb @@ -0,0 +1,183 @@ +# Usage (in rails console): +# +# Make sure all your ViewComponents are loaded before running the codemod. +# +# Zeitwerk::Loader.eager_load_all +# +# 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 + +module ViewComponent + module Codemods + class V3SlotSetters + TEMPLATE_LANGUAGES = %w[erb slim haml].join(",").freeze + RENDER_REGEX = /render[( ](?\w+(?:::\w+)*)\.new[) ]+(do|\{) \|(?\w+)\b/ + + Suggestion = Struct.new(:file, :line, :message) + + def initialize(view_component_path: [], view_path: []) + @view_component_path = view_component_path + @view_path = view_path + 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} view component files 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) do |f| + 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| + suggestions << Suggestion.new(file, f.lineno, "probably replace `#{match}` with `#{match.gsub("#{arg}.", "#{arg}.with_")}`") + end + end + end + end + end + end + end + + def scan_uncertain_matches(file) + [].tap do |suggestions| + File.open(file) do |f| + f.each_line do |line| + if matches = line.scan(/(?#{Regexp.union(all_registered_slot_names)})/) + next if matches.size == 0 + + 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}`") + end + end + end + end + end + end + + def view_components + ViewComponent::Base.descendants + end + + def slottable_components + view_components.select do |comp| + comp.registered_slots.any? + end + end + + def registered_slots + @registered_slots ||= {}.tap do |slots| + puts + puts "Detected slots:" + slottable_components.each do |comp| + puts "- `#{comp}` has slots: #{comp.registered_slots.keys.join(', ')}" + slots[comp] = comp.registered_slots.map do |slot_name, slot| + normalized_slot_name(slot_name, slot) + end + end + end + end + + def all_registered_slot_names + @all_registered_slot_names ||= registered_slots.values.flatten.uniq + end + + def view_component_files + Dir.glob(Rails.root.join(view_component_path_glob, "**", "*.{rb,#{TEMPLATE_LANGUAGES}}")) + end + + def view_files + Dir.glob(Rails.root.join(view_path_glob, "**", "*.{#{TEMPLATE_LANGUAGES}}")) + end + + def all_files + view_component_files + view_files + end + + def view_component_paths + @view_component_paths ||= [ + Rails.application.config.view_component.view_component_path, + @view_component_path, + ].flatten.compact.uniq + end + + def view_component_path_glob + return view_component_paths.first if view_component_paths.size == 1 + + "{#{view_component_paths.join(',')}}" + end + + def view_paths + @view_paths ||= [ + "app/views", + @view_path, + ].flatten.compact.uniq + end + + def view_path_glob + return view_paths.first if view_paths.size == 1 + + "{#{view_paths.join(',')}}" + end + + def normalized_slot_name(slot_name, slot) + slot[:collection] ? ActiveSupport::Inflector.singularize(slot_name) : slot_name.to_s + end + end + end +end From 21a8a26702b388c5b9c348bc61e7a2358e20af7c Mon Sep 17 00:00:00 2001 From: Hans Lemuet Date: Mon, 24 Apr 2023 22:44:36 +0200 Subject: [PATCH 2/6] Update lib/view_component/codemods/v3_slot_setters.rb Co-authored-by: Joel Hawksley --- lib/view_component/codemods/v3_slot_setters.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_component/codemods/v3_slot_setters.rb b/lib/view_component/codemods/v3_slot_setters.rb index 880f36b9c..4930bac9c 100644 --- a/lib/view_component/codemods/v3_slot_setters.rb +++ b/lib/view_component/codemods/v3_slot_setters.rb @@ -31,7 +31,7 @@ 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 "#{slottable_components.size} ViewComponents using Slots found" puts "#{view_component_files.size} view component files found" puts "#{view_files.size} view files found" process_all_files From fb748a6934e0fdde7d9981281069c257707ef5d5 Mon Sep 17 00:00:00 2001 From: Hans Lemuet Date: Mon, 24 Apr 2023 22:44:46 +0200 Subject: [PATCH 3/6] Update lib/view_component/codemods/v3_slot_setters.rb Co-authored-by: Joel Hawksley --- lib/view_component/codemods/v3_slot_setters.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_component/codemods/v3_slot_setters.rb b/lib/view_component/codemods/v3_slot_setters.rb index 4930bac9c..2e56651c5 100644 --- a/lib/view_component/codemods/v3_slot_setters.rb +++ b/lib/view_component/codemods/v3_slot_setters.rb @@ -32,7 +32,7 @@ def call 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} view component files found" + puts "#{view_component_files.size} ViewComponent templates found" puts "#{view_files.size} view files found" process_all_files end From e376b29bdc941af59678407a9e37ed84bf7a009f Mon Sep 17 00:00:00 2001 From: Hans Lemuet Date: Mon, 24 Apr 2023 23:01:11 +0200 Subject: [PATCH 4/6] standardrb --- .../codemods/v3_slot_setters.rb | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/lib/view_component/codemods/v3_slot_setters.rb b/lib/view_component/codemods/v3_slot_setters.rb index 2e56651c5..90ed532f0 100644 --- a/lib/view_component/codemods/v3_slot_setters.rb +++ b/lib/view_component/codemods/v3_slot_setters.rb @@ -18,7 +18,7 @@ module ViewComponent module Codemods class V3SlotSetters TEMPLATE_LANGUAGES = %w[erb slim haml].join(",").freeze - RENDER_REGEX = /render[( ](?\w+(?:::\w+)*)\.new[) ]+(do|\{) \|(?\w+)\b/ + RENDER_REGEX = /render[( ](?\w+(?:::\w+)*)\.new[) ]+(do|\{) \|(?\w+)\b/ # standard:disable Lint/MixedRegexpCaptureTypes Suggestion = Struct.new(:file, :line, :message) @@ -45,7 +45,6 @@ def process_all_files def process_file(file) @suggestions = [] - @suggestions += scan_exact_matches(file) @suggestions += scan_uncertain_matches(file) @@ -65,13 +64,13 @@ def scan_exact_matches(file) rendered_components = [] content = File.read(file) - if render_match = content.match(RENDER_REGEX) + 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 } + rendered_components << {component: component, arg: arg, slots: used_slots_names} end end @@ -81,7 +80,7 @@ def scan_exact_matches(file) arg = rendered_component[:arg] slots = rendered_component[:slots] - if matches = line.scan(/#{arg}\.#{Regexp.union(slots)}/) + 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_")}`") end @@ -96,7 +95,7 @@ def scan_uncertain_matches(file) [].tap do |suggestions| File.open(file) do |f| f.each_line do |line| - if matches = line.scan(/(?#{Regexp.union(all_registered_slot_names)})/) + if (matches = line.scan(/(?#{Regexp.union(all_registered_slot_names)})/)) next if matches.size == 0 matches.flatten.each do |match| @@ -125,7 +124,7 @@ def registered_slots puts puts "Detected slots:" slottable_components.each do |comp| - puts "- `#{comp}` has slots: #{comp.registered_slots.keys.join(', ')}" + puts "- `#{comp}` has slots: #{comp.registered_slots.keys.join(", ")}" slots[comp] = comp.registered_slots.map do |slot_name, slot| normalized_slot_name(slot_name, slot) end @@ -152,27 +151,27 @@ def all_files def view_component_paths @view_component_paths ||= [ Rails.application.config.view_component.view_component_path, - @view_component_path, + @view_component_path ].flatten.compact.uniq end def view_component_path_glob return view_component_paths.first if view_component_paths.size == 1 - "{#{view_component_paths.join(',')}}" + "{#{view_component_paths.join(",")}}" end def view_paths @view_paths ||= [ "app/views", - @view_path, + @view_path ].flatten.compact.uniq end def view_path_glob return view_paths.first if view_paths.size == 1 - "{#{view_paths.join(',')}}" + "{#{view_paths.join(",")}}" end def normalized_slot_name(slot_name, slot) From 547c2020d3d32b6d98a758382e7c0c78cc692e78 Mon Sep 17 00:00:00 2001 From: Hans Lemuet Date: Mon, 24 Apr 2023 23:02:46 +0200 Subject: [PATCH 5/6] Add Zeitwerk eager loading to initializer --- lib/view_component/codemods/v3_slot_setters.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/view_component/codemods/v3_slot_setters.rb b/lib/view_component/codemods/v3_slot_setters.rb index 90ed532f0..7c3a900ad 100644 --- a/lib/view_component/codemods/v3_slot_setters.rb +++ b/lib/view_component/codemods/v3_slot_setters.rb @@ -1,9 +1,5 @@ # Usage (in rails console): # -# Make sure all your ViewComponents are loaded before running the codemod. -# -# Zeitwerk::Loader.eager_load_all -# # Run the codemod: # # ViewComponent::Codemods::V3SlotSetters.new.call @@ -23,6 +19,8 @@ class V3SlotSetters Suggestion = Struct.new(:file, :line, :message) def initialize(view_component_path: [], view_path: []) + Zeitwerk::Loader.eager_load_all + @view_component_path = view_component_path @view_path = view_path end From 324060705eaf27b0de40b23b51ff72965aee8c81 Mon Sep 17 00:00:00 2001 From: Kirill Platonov Date: Thu, 29 Jun 2023 22:53:28 +0200 Subject: [PATCH 6/6] Codemod v3 slots improvements (#1746) * Add rake task for legacy slots detection * Add view component previews to codemod view paths * Autodetect rails view paths * Improve uncertain matches detection * Update lib/view_component/codemods/v3_slot_setters.rb Co-authored-by: Hans Lemuet * Update usage instruction * Add rake task to migrate to new slots * Update changelog * Standardrb * Add tests * Call eager loading via Rails * Update docs/CHANGELOG.md Co-authored-by: Hans Lemuet * Add support for passing view paths via CLI * Fix helper in rake tasks * Fix tests * Add note about codemod to changelog --------- Co-authored-by: Hans Lemuet --- docs/CHANGELOG.md | 9 ++ lib/tasks/view_component.rake | 15 ++++ .../codemods/v3_slot_setters.rb | 86 ++++++++++++++----- test/sandbox/app/helpers/aliases_helper.rb | 7 ++ .../codemods/_v2_slots_setters_alias.html.erb | 5 ++ .../codemods/_v2_slots_setters_exact.html.erb | 14 +++ .../integration_examples/empty_slot.slim | 2 +- .../test/codemods/v3_slot_setters_test.rb | 57 ++++++++++++ 8 files changed, 171 insertions(+), 24 deletions(-) create mode 100644 lib/tasks/view_component.rake create mode 100644 test/sandbox/app/helpers/aliases_helper.rb create mode 100644 test/sandbox/app/views/codemods/_v2_slots_setters_alias.html.erb create mode 100644 test/sandbox/app/views/codemods/_v2_slots_setters_exact.html.erb create mode 100644 test/sandbox/test/codemods/v3_slot_setters_test.rb 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