Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

V3SlotSetters linter/codemod draft #1669

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
9 changes: 9 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
15 changes: 15 additions & 0 deletions lib/tasks/view_component.rake
Original file line number Diff line number Diff line change
@@ -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
220 changes: 220 additions & 0 deletions lib/view_component/codemods/v3_slot_setters.rb
Original file line number Diff line number Diff line change
@@ -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[( ](?<component>\w+(?:::\w+)*)\.new[) ]+(do|\{) \|(?<arg>\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))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this assuming only a single render_match per file?

If I render multiple different components in the same file, would this catch it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After re-reading the code, I guess you're right. I'm a bit surprised I didn't catch this earlier 😅

This part needs to be reworked.

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(/(?<!\s)\.(?<slot>#{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!(/(?<!\s)\.(#{match})\b/, ".with_\\1")
end
end
end
lines << line
end

if @migrate
f.rewind
f.write(lines.join)
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(Pathname.new(File.join(view_component_path_glob, "**", "*.{rb,#{TEMPLATE_LANGUAGES}}")))
end

def view_files
Dir.glob(Pathname.new(File.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 rails_view_paths
ActionController::Base.view_paths.select do |path|
path.to_s.include?(Rails.root.to_s)
end.map(&:to_s)
end

def view_paths
@view_paths ||= [
rails_view_paths,
Rails.application.config.view_component.preview_paths,
@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
7 changes: 7 additions & 0 deletions test/sandbox/app/helpers/aliases_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

module AliasesHelper
def sandbox_slots(*args, **kwargs, &block)
render SlotsComponent.new(*args, **kwargs), &block
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<%= sandbox_slots do |component| %>
<% component.subtitle do %>
<small>This is my subtitle!</small>
<% end %>
<% end %>
14 changes: 14 additions & 0 deletions test/sandbox/app/views/codemods/_v2_slots_setters_exact.html.erb
Original file line number Diff line number Diff line change
@@ -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 %>
<h1>Tab A</h1>
<% end %>
<% component.tab do %>
<h1>Tab B</h1>
<% end %>
<% end %>
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
= render(EmptySlotComponent.new) do |component|
- component.title
- component.with_title
- nil
57 changes: 57 additions & 0 deletions test/sandbox/test/codemods/v3_slot_setters_test.rb
Original file line number Diff line number Diff line change
@@ -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