-
Notifications
You must be signed in to change notification settings - Fork 444
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
Changes from 8 commits
de176b5
c413492
21a8a26
fb748a6
d075cde
e376b29
547c202
2542ba5
b805fd7
3240607
cf8a038
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,180 @@ | ||
# 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 | ||
|
||
module ViewComponent | ||
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: []) | ||
Zeitwerk::Loader.eager_load_all | ||
|
||
@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} 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)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) 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(/(?<!\s)\.(?<slot>#{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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we load these from the rails config instead of requiring them to be passed in?