rubocop-view_component 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ ViewComponent/ComponentSuffix:
2
+ Description: 'Enforce -Component suffix for ViewComponent classes.'
3
+ Enabled: true
4
+ VersionAdded: '0.1'
5
+ Severity: warning
6
+ StyleGuide: 'https://viewcomponent.org/best_practices.html'
7
+
8
+ ViewComponent/NoGlobalState:
9
+ Description: 'Avoid accessing global state (params, request, session, cookies, flash) directly.'
10
+ Enabled: true
11
+ VersionAdded: '0.1'
12
+ Severity: warning
13
+ StyleGuide: 'https://viewcomponent.org/best_practices.html'
14
+
15
+ ViewComponent/PreferPrivateMethods:
16
+ Description: 'Suggest making helper methods private.'
17
+ Enabled: true
18
+ VersionAdded: '0.1'
19
+ Severity: convention
20
+ StyleGuide: 'https://viewcomponent.org/best_practices.html'
21
+ AllowedPublicMethods:
22
+ - initialize
23
+ - call
24
+ - before_render
25
+ - before_render_check
26
+ - render?
27
+
28
+ ViewComponent/PreferSlots:
29
+ Description: 'Prefer slots over HTML string parameters.'
30
+ Enabled: true
31
+ VersionAdded: '0.1'
32
+ Severity: warning
33
+ StyleGuide: 'https://viewcomponent.org/best_practices.html'
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module ViewComponent
6
+ # Shared helper methods for ViewComponent cops
7
+ module Base
8
+ # Check if a class node inherits from ViewComponent::Base or ApplicationComponent
9
+ def view_component_class?(node)
10
+ return false unless node&.class_type?
11
+
12
+ parent_class = node.parent_class
13
+ return false unless parent_class
14
+
15
+ view_component_parent?(parent_class)
16
+ end
17
+
18
+ # Check if node represents ViewComponent::Base or ApplicationComponent
19
+ def view_component_parent?(node)
20
+ return false unless node.const_type?
21
+
22
+ source = node.source
23
+ source == "ViewComponent::Base" || source == "ApplicationComponent"
24
+ end
25
+
26
+ # Find the enclosing class node
27
+ def enclosing_class(node)
28
+ node.each_ancestor(:class).first
29
+ end
30
+
31
+ # Check if node is within a ViewComponent class
32
+ def inside_view_component?(node)
33
+ klass = enclosing_class(node)
34
+ return false unless klass
35
+
36
+ view_component_class?(klass)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module ViewComponent
6
+ # Enforces that ViewComponent classes end with the `Component` suffix.
7
+ #
8
+ # @example
9
+ # # bad
10
+ # class FooBar < ViewComponent::Base
11
+ # end
12
+ #
13
+ # # good
14
+ # class FooBarComponent < ViewComponent::Base
15
+ # end
16
+ #
17
+ class ComponentSuffix < RuboCop::Cop::Base
18
+ include ViewComponent::Base
19
+
20
+ MSG = "ViewComponent class names should end with `Component`."
21
+
22
+ def on_class(node)
23
+ return unless view_component_class?(node)
24
+
25
+ class_name = node.identifier.source
26
+ return if class_name.end_with?("Component")
27
+
28
+ add_offense(node.identifier)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module ViewComponent
6
+ # Prevents direct access to global state within ViewComponent classes.
7
+ #
8
+ # @example
9
+ # # bad
10
+ # class UserComponent < ViewComponent::Base
11
+ # def admin?
12
+ # params[:admin]
13
+ # end
14
+ # end
15
+ #
16
+ # # good
17
+ # class UserComponent < ViewComponent::Base
18
+ # def initialize(admin:)
19
+ # @admin = admin
20
+ # end
21
+ #
22
+ # def admin?
23
+ # @admin
24
+ # end
25
+ # end
26
+ #
27
+ class NoGlobalState < RuboCop::Cop::Base
28
+ include ViewComponent::Base
29
+
30
+ MSG = "Avoid accessing `%<method>s` directly in ViewComponents. " \
31
+ "Pass necessary data through the constructor instead."
32
+
33
+ GLOBAL_STATE_METHODS = %i[
34
+ params
35
+ request
36
+ session
37
+ cookies
38
+ flash
39
+ ].freeze
40
+
41
+ RESTRICT_ON_SEND = GLOBAL_STATE_METHODS
42
+
43
+ def_node_matcher :global_state_access?, <<~PATTERN
44
+ (send nil? ${:params :request :session :cookies :flash} ...)
45
+ PATTERN
46
+
47
+ def on_send(node)
48
+ return unless inside_view_component?(node)
49
+
50
+ method_name = global_state_access?(node)
51
+ return unless method_name
52
+
53
+ add_offense(node, message: format(MSG, method: method_name))
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module ViewComponent
6
+ # Suggests making helper methods private in ViewComponents.
7
+ #
8
+ # @example
9
+ # # bad
10
+ # class CardComponent < ViewComponent::Base
11
+ # def formatted_title
12
+ # @title.upcase
13
+ # end
14
+ # end
15
+ #
16
+ # # good
17
+ # class CardComponent < ViewComponent::Base
18
+ # private
19
+ #
20
+ # def formatted_title
21
+ # @title.upcase
22
+ # end
23
+ # end
24
+ #
25
+ class PreferPrivateMethods < RuboCop::Cop::Base
26
+ include ViewComponent::Base
27
+
28
+ MSG = "Consider making this method private. " \
29
+ "Only ViewComponent interface methods should be public."
30
+
31
+ ALLOWED_PUBLIC_METHODS = %i[
32
+ initialize
33
+ call
34
+ before_render
35
+ before_render_check
36
+ render?
37
+ ].freeze
38
+
39
+ def on_class(node)
40
+ return unless view_component_class?(node)
41
+
42
+ check_public_methods(node)
43
+ end
44
+
45
+ private
46
+
47
+ def check_public_methods(class_node)
48
+ current_visibility = :public
49
+
50
+ class_node.body&.each_child_node do |child|
51
+ if visibility_modifier?(child)
52
+ current_visibility = child.method_name
53
+ next
54
+ end
55
+
56
+ next unless child.def_type?
57
+ next unless current_visibility == :public
58
+ next if ALLOWED_PUBLIC_METHODS.include?(child.method_name)
59
+
60
+ add_offense(child)
61
+ end
62
+ end
63
+
64
+ def visibility_modifier?(node)
65
+ return false unless node.send_type?
66
+ return false unless node.receiver.nil?
67
+
68
+ %i[private protected public].include?(node.method_name)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module ViewComponent
6
+ # Detects parameters that accept HTML content and suggests using slots.
7
+ #
8
+ # @example
9
+ # # bad
10
+ # class ModalComponent < ViewComponent::Base
11
+ # def initialize(title:, body_html:)
12
+ # @title = title
13
+ # @body_html = body_html
14
+ # end
15
+ # end
16
+ #
17
+ # # good
18
+ # class ModalComponent < ViewComponent::Base
19
+ # renders_one :body
20
+ #
21
+ # def initialize(title:)
22
+ # @title = title
23
+ # end
24
+ # end
25
+ #
26
+ class PreferSlots < RuboCop::Cop::Base
27
+ include ViewComponent::Base
28
+
29
+ MSG = "Consider using `%<slot_method>s` instead of passing HTML " \
30
+ "as a parameter. This maintains Rails' automatic HTML escaping."
31
+
32
+ HTML_PARAM_PATTERNS = [
33
+ /_html$/,
34
+ /_content$/,
35
+ /^html_/,
36
+ /^content$/
37
+ ].freeze
38
+
39
+ # Exclude common non-HTML parameters
40
+ EXCLUDED_PARAMS = %i[
41
+ html_class
42
+ html_classes
43
+ html_id
44
+ html_tag
45
+ ].freeze
46
+
47
+ def_node_search :html_safe_call?, "(send _ :html_safe)"
48
+
49
+ def on_class(node)
50
+ return unless view_component_class?(node)
51
+
52
+ initialize_method = find_initialize(node)
53
+ return unless initialize_method
54
+
55
+ check_initialize_params(initialize_method)
56
+ end
57
+
58
+ private
59
+
60
+ def find_initialize(class_node)
61
+ class_node.each_descendant(:def).find do |def_node|
62
+ def_node.method_name == :initialize
63
+ end
64
+ end
65
+
66
+ def check_initialize_params(initialize_node)
67
+ initialize_node.arguments.each do |arg|
68
+ next unless arg.kwoptarg_type? || arg.kwarg_type?
69
+
70
+ param_name = arg.children[0]
71
+
72
+ # Skip excluded parameters
73
+ next if EXCLUDED_PARAMS.include?(param_name)
74
+
75
+ # Check parameter name patterns
76
+ if html_param_name?(param_name)
77
+ suggested_slot = suggest_slot_name(param_name)
78
+ add_offense(arg, message: format(MSG, slot_method: suggested_slot))
79
+ next
80
+ end
81
+
82
+ # Check for html_safe in default value
83
+ if arg.kwoptarg_type? && html_safe_call?(arg)
84
+ suggested_slot = suggest_slot_name(param_name)
85
+ add_offense(arg, message: format(MSG, slot_method: suggested_slot))
86
+ end
87
+ end
88
+ end
89
+
90
+ def html_param_name?(name)
91
+ HTML_PARAM_PATTERNS.any? { |pattern| pattern.match?(name.to_s) }
92
+ end
93
+
94
+ def suggest_slot_name(param_name)
95
+ clean_name = param_name.to_s
96
+ .sub(/_html$/, "")
97
+ .sub(/_content$/, "")
98
+ .sub(/^html_/, "")
99
+
100
+ "renders_one :#{clean_name}"
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "view_component/base"
4
+ require_relative "view_component/component_suffix"
5
+ require_relative "view_component/no_global_state"
6
+ require_relative "view_component/prefer_private_methods"
7
+ require_relative "view_component/prefer_slots"
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lint_roller"
4
+
5
+ module RuboCop
6
+ module ViewComponent
7
+ # A plugin that integrates rubocop-view_component with RuboCop's plugin system.
8
+ class Plugin < LintRoller::Plugin
9
+ def about
10
+ LintRoller::About.new(
11
+ name: "rubocop-view_component",
12
+ version: VERSION,
13
+ homepage: "TODO: Put your plugin's homepage URL here.",
14
+ description: "TODO: Put your plugin's description here."
15
+ )
16
+ end
17
+
18
+ def supported?(context)
19
+ context.engine == :rubocop
20
+ end
21
+
22
+ def rules(_context)
23
+ LintRoller::Rules.new(
24
+ type: :path,
25
+ config_format: :rubocop,
26
+ value: Pathname.new(__dir__).join("../../../config/default.yml")
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module ViewComponent
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "view_component/version"
4
+
5
+ module RuboCop
6
+ module ViewComponent
7
+ class Error < StandardError; end
8
+ # Your code goes here...
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ require_relative "rubocop/view_component"
6
+ require_relative "rubocop/view_component/version"
7
+ require_relative "rubocop/view_component/plugin"
8
+
9
+ require_relative "rubocop/cop/view_component_cops"
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe RuboCop::Cop::ViewComponent::ComponentSuffix, :config do
4
+ let(:config) { RuboCop::Config.new }
5
+
6
+ context "when class inherits from ViewComponent::Base" do
7
+ it "registers an offense when class name does not end with Component" do
8
+ expect_offense(<<~RUBY)
9
+ class FooBar < ViewComponent::Base
10
+ ^^^^^^ ViewComponent/ComponentSuffix: ViewComponent class names should end with `Component`.
11
+ end
12
+ RUBY
13
+ end
14
+
15
+ it "does not register offense when class name ends with Component" do
16
+ expect_no_offenses(<<~RUBY)
17
+ class FooBarComponent < ViewComponent::Base
18
+ end
19
+ RUBY
20
+ end
21
+ end
22
+
23
+ context "when class inherits from ApplicationComponent" do
24
+ it "registers an offense when class name does not end with Component" do
25
+ expect_offense(<<~RUBY)
26
+ class UserCard < ApplicationComponent
27
+ ^^^^^^^^ ViewComponent/ComponentSuffix: ViewComponent class names should end with `Component`.
28
+ end
29
+ RUBY
30
+ end
31
+
32
+ it "does not register offense when class name ends with Component" do
33
+ expect_no_offenses(<<~RUBY)
34
+ class UserCardComponent < ApplicationComponent
35
+ end
36
+ RUBY
37
+ end
38
+ end
39
+
40
+ context "when class does not inherit from ViewComponent" do
41
+ it "does not register offense for regular classes" do
42
+ expect_no_offenses(<<~RUBY)
43
+ class FooBar < SomeOtherBase
44
+ end
45
+ RUBY
46
+ end
47
+
48
+ it "does not register offense for plain classes" do
49
+ expect_no_offenses(<<~RUBY)
50
+ class FooBar
51
+ end
52
+ RUBY
53
+ end
54
+ end
55
+
56
+ context "with namespaced components" do
57
+ it "checks the final component name" do
58
+ expect_offense(<<~RUBY)
59
+ module Admin
60
+ class UserCard < ViewComponent::Base
61
+ ^^^^^^^^ ViewComponent/ComponentSuffix: ViewComponent class names should end with `Component`.
62
+ end
63
+ end
64
+ RUBY
65
+ end
66
+
67
+ it "allows namespaced component with Component suffix" do
68
+ expect_no_offenses(<<~RUBY)
69
+ module Admin
70
+ class UserCardComponent < ViewComponent::Base
71
+ end
72
+ end
73
+ RUBY
74
+ end
75
+ end
76
+
77
+ context "with compact nested class syntax" do
78
+ it "registers offense for compact syntax" do
79
+ expect_offense(<<~RUBY)
80
+ class Admin::UserCard < ViewComponent::Base
81
+ ^^^^^^^^^^^^^^^ ViewComponent/ComponentSuffix: ViewComponent class names should end with `Component`.
82
+ end
83
+ RUBY
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe RuboCop::Cop::ViewComponent::NoGlobalState, :config do
4
+ let(:config) { RuboCop::Config.new }
5
+
6
+ context "when accessing params" do
7
+ it "registers an offense" do
8
+ expect_offense(<<~RUBY)
9
+ class UserComponent < ViewComponent::Base
10
+ def admin?
11
+ params[:admin]
12
+ ^^^^^^ ViewComponent/NoGlobalState: Avoid accessing `params` directly in ViewComponents. Pass necessary data through the constructor instead.
13
+ end
14
+ end
15
+ RUBY
16
+ end
17
+
18
+ it "registers offense for params method call" do
19
+ expect_offense(<<~RUBY)
20
+ class UserComponent < ViewComponent::Base
21
+ def admin?
22
+ params.fetch(:admin)
23
+ ^^^^^^ ViewComponent/NoGlobalState: Avoid accessing `params` directly in ViewComponents. Pass necessary data through the constructor instead.
24
+ end
25
+ end
26
+ RUBY
27
+ end
28
+ end
29
+
30
+ context "when accessing request" do
31
+ it "registers an offense" do
32
+ expect_offense(<<~RUBY)
33
+ class UserComponent < ViewComponent::Base
34
+ def user_agent
35
+ request.user_agent
36
+ ^^^^^^^ ViewComponent/NoGlobalState: Avoid accessing `request` directly in ViewComponents. Pass necessary data through the constructor instead.
37
+ end
38
+ end
39
+ RUBY
40
+ end
41
+ end
42
+
43
+ context "when accessing session" do
44
+ it "registers an offense" do
45
+ expect_offense(<<~RUBY)
46
+ class UserComponent < ViewComponent::Base
47
+ def current_user_id
48
+ session[:user_id]
49
+ ^^^^^^^ ViewComponent/NoGlobalState: Avoid accessing `session` directly in ViewComponents. Pass necessary data through the constructor instead.
50
+ end
51
+ end
52
+ RUBY
53
+ end
54
+ end
55
+
56
+ context "when accessing cookies" do
57
+ it "registers an offense" do
58
+ expect_offense(<<~RUBY)
59
+ class UserComponent < ViewComponent::Base
60
+ def preference
61
+ cookies[:theme]
62
+ ^^^^^^^ ViewComponent/NoGlobalState: Avoid accessing `cookies` directly in ViewComponents. Pass necessary data through the constructor instead.
63
+ end
64
+ end
65
+ RUBY
66
+ end
67
+ end
68
+
69
+ context "when accessing flash" do
70
+ it "registers an offense" do
71
+ expect_offense(<<~RUBY)
72
+ class UserComponent < ViewComponent::Base
73
+ def notice
74
+ flash[:notice]
75
+ ^^^^^ ViewComponent/NoGlobalState: Avoid accessing `flash` directly in ViewComponents. Pass necessary data through the constructor instead.
76
+ end
77
+ end
78
+ RUBY
79
+ end
80
+ end
81
+
82
+ context "when not accessing global state" do
83
+ it "does not register offense for instance variables" do
84
+ expect_no_offenses(<<~RUBY)
85
+ class UserComponent < ViewComponent::Base
86
+ def initialize(admin:)
87
+ @admin = admin
88
+ end
89
+
90
+ def admin?
91
+ @admin
92
+ end
93
+ end
94
+ RUBY
95
+ end
96
+
97
+ it "does not register offense for method arguments" do
98
+ expect_no_offenses(<<~RUBY)
99
+ class UserComponent < ViewComponent::Base
100
+ def format_params(params)
101
+ params[:admin]
102
+ end
103
+ end
104
+ RUBY
105
+ end
106
+ end
107
+
108
+ context "when not in a ViewComponent" do
109
+ it "does not register offense in regular classes" do
110
+ expect_no_offenses(<<~RUBY)
111
+ class RegularClass
112
+ def admin?
113
+ params[:admin]
114
+ end
115
+ end
116
+ RUBY
117
+ end
118
+ end
119
+ end