rubocop-view_component 0.8.0 → 0.9.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab88d6999ec2a99b6f9e459d22a14b9d727b62edd967088fa22aef709681dbdb
4
- data.tar.gz: b06b97cebdb888ba95176af4d9d664c9f75bf75865eacb148bc327bba761f77f
3
+ metadata.gz: 7ae5d788e931ed620494da128b9d5d971b8b6278841b11037a9594cf9da7cadf
4
+ data.tar.gz: e4202f7c094efe88eb925802022d6a7abebede5886ae5d381d2ae8eb44cd4c49
5
5
  SHA512:
6
- metadata.gz: 8b6aa5e13ce63d13205335bb2d900a29d57e80ce1b9d1906c92e3548d85941875b18235a36775849e35752fed347c260e842289fb8e805ad0ee7670c252c89b1
7
- data.tar.gz: b65833f97c6211d366b5c0cdeba807e501f6ef10a8a878f63e0379de23be3c2a4842542e50e43171254886204990ae5224a4d3e3abbe90f72be1e05f9cfb0f08
6
+ metadata.gz: 5d4a8f3314b13a36463adaa1545e747b120c430cfddba098e79af01083d906d126bea6714674084109a11a2c577e6a835c3bd7f6ca1ddd5c4b4a3289bedf430c
7
+ data.tar.gz: b2f22476fcda3172a5a33326ae2e09246df2377b2baaf5071433a5e7ef15c09285151a98b1093c294ea281b5b034d92e321fc8956443275a9d3642416649281e
data/AGENTS.md CHANGED
@@ -47,10 +47,10 @@ All cops inherit from `RuboCop::Cop::Base` and are located in `lib/rubocop/cop/v
47
47
  - Provides `inside_view_component?(node)` - Checks if code is within a ViewComponent
48
48
 
49
49
  **`TemplateAnalyzer`** (`lib/rubocop/cop/view_component/template_analyzer.rb`)
50
- - Used by PreferPrivateMethods cop to analyze ERB templates
50
+ - Used by PreferPrivateMethods cop to analyze ERB, Haml, and Slim templates
51
51
  - Extracts method calls from templates to avoid flagging template-used methods as private candidates
52
- - Handles both sibling templates (`component.html.erb`) and sidecar templates (`component/component.html.erb`)
53
- - Uses the `herb` gem to parse ERB and extract Ruby code
52
+ - Handles both sibling templates (`component.html.erb`) and sidecar templates (`component/component.html.erb`), including format-less templates (`component.erb`)
53
+ - Uses the `herb` gem to parse ERB and the `haml` (6+) and `slim` engines to compile Haml and Slim templates to Ruby
54
54
 
55
55
  ### Verification System
56
56
 
@@ -59,6 +59,6 @@ The `script/verify` script downloads real component libraries, runs all ViewComp
59
59
  ## Implementation Notes
60
60
 
61
61
  - When adding a new cop, use `rake new_cop[ViewComponent/CopName]` to generate the boilerplate
62
- - Template analysis is performance-sensitive - `PreferPrivateMethods` uses `herb` gem for efficient ERB parsing
62
+ - Template analysis is performance-sensitive - `PreferPrivateMethods` uses `herb` gem for efficient ERB parsing, while Haml and Slim rely on their template engines to compile to Ruby
63
63
  - Cops must handle graceful degradation when templates can't be parsed
64
64
  - All cops should include `ViewComponent::Base` module to get detection helpers
data/Appraisals CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  appraise "rubocop-minimum" do
4
+ gem "haml", ">= 6"
4
5
  gem "rubocop", "= 1.89.0"
6
+ gem "slim", ">= 4"
5
7
  end
data/README.md CHANGED
@@ -33,7 +33,7 @@ This gem provides several cops to enforce ViewComponent best practices:
33
33
 
34
34
  - **ViewComponent/ComponentSuffix** - Enforce `-Component` suffix for ViewComponent classes
35
35
  - **ViewComponent/NoGlobalState** - Prevent direct access to `params`, `request`, `session`, etc.
36
- - **ViewComponent/PreferPrivateMethods** - Suggest making helper methods private (analyzes ERB templates to avoid flagging methods used in views)
36
+ - **ViewComponent/PreferPrivateMethods** - Suggest making helper methods private (analyzes ERB, Haml, and Slim templates to avoid flagging methods used in views)
37
37
  - **ViewComponent/PreferSlots** - Detect HTML parameters that should be slots
38
38
  - **ViewComponent/PreferComposition** - Avoid inheriting one ViewComponent from another (prefer composition). Abstract bases without their own template, such as `ApplicationComponent`, are allowed
39
39
  - **ViewComponent/TestRenderedOutput** - Encourage testing rendered output over private methods. Supports Minitest and RSpec styles, including components built via `described_class.new` or a `let`/`subject` helper
@@ -3,11 +3,13 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "appraisal"
6
+ gem "haml", ">= 6"
6
7
  gem "irb"
7
8
  gem "rake", "~> 13.4"
8
9
  gem "rspec"
9
10
  gem "rubocop", "= 1.89.0"
10
11
  gem "rubocop-rake"
11
12
  gem "rubocop-rspec"
13
+ gem "slim", ">= 4"
12
14
 
13
15
  gemspec path: "../"
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- rubocop-view_component (0.6.0)
4
+ rubocop-view_component (0.8.0)
5
5
  activesupport
6
6
  herb
7
7
  lint_roller (~> 1.1)
@@ -37,6 +37,11 @@ GEM
37
37
  diff-lcs (1.6.2)
38
38
  drb (2.2.3)
39
39
  erb (6.0.7)
40
+ haml (7.5.1)
41
+ prism (>= 1.1.0)
42
+ temple (>= 0.8.2)
43
+ thor
44
+ tilt
40
45
  herb (0.10.3)
41
46
  herb (0.10.3-aarch64-linux-gnu)
42
47
  herb (0.10.3-aarch64-linux-musl)
@@ -126,7 +131,12 @@ GEM
126
131
  rubydex (0.3.0-x86_64-darwin)
127
132
  rubydex (0.3.0-x86_64-linux)
128
133
  securerandom (0.4.1)
134
+ slim (5.2.2)
135
+ temple (~> 0.10.0)
136
+ tilt (>= 2.1.0)
137
+ temple (0.10.7)
129
138
  thor (1.5.0)
139
+ tilt (2.9.0)
130
140
  tsort (0.2.0)
131
141
  tzinfo (2.0.6)
132
142
  concurrent-ruby (~> 1.0)
@@ -150,6 +160,7 @@ PLATFORMS
150
160
 
151
161
  DEPENDENCIES
152
162
  appraisal
163
+ haml (>= 6)
153
164
  irb
154
165
  rake (~> 13.4)
155
166
  rspec
@@ -157,6 +168,7 @@ DEPENDENCIES
157
168
  rubocop-rake
158
169
  rubocop-rspec
159
170
  rubocop-view_component!
171
+ slim (>= 4)
160
172
 
161
173
  BUNDLED WITH
162
174
  2.7.2
@@ -5,8 +5,13 @@ require "herb"
5
5
  module RuboCop
6
6
  module Cop
7
7
  module ViewComponent
8
- # Helper module for analyzing ViewComponent ERB templates
8
+ # Helper module for analyzing ViewComponent templates.
9
+ #
10
+ # Supports ERB, Haml (6+) and Slim templates.
9
11
  module TemplateAnalyzer
12
+ TEMPLATE_EXTENSIONS = %w[erb haml slim].freeze
13
+ TEMPLATE_GLOB = "{#{TEMPLATE_EXTENSIONS.join(",")}}".freeze
14
+
10
15
  # Find template file paths for a component
11
16
  # @param component_path [String] Path to the component Ruby file
12
17
  # @return [Array<String>] Array of template file paths
@@ -18,38 +23,28 @@ module RuboCop
18
23
  component_name = File.basename(component_path, ".rb")
19
24
  sidecar_dir = File.join(component_dir, component_name)
20
25
 
21
- paths = sibling_templates(base_path) + sidecar_templates(sidecar_dir, component_name)
22
- paths.uniq
26
+ (sibling_templates(base_path) + sidecar_templates(sidecar_dir, component_name)).uniq
23
27
  end
24
28
 
25
29
  def sibling_templates(base_path)
26
- paths = []
27
- sibling = "#{base_path}.html.erb"
28
- paths << sibling if File.exist?(sibling)
29
- paths.concat(Dir.glob("#{base_path}.*.html.erb"))
30
- paths
30
+ template_paths(base_path)
31
31
  end
32
32
 
33
33
  def sidecar_templates(sidecar_dir, component_name)
34
- paths = []
35
- sidecar = File.join(sidecar_dir, "#{component_name}.html.erb")
36
- paths << sidecar if File.exist?(sidecar)
37
- paths.concat(Dir.glob(File.join(sidecar_dir, "#{component_name}.*.html.erb")))
38
- paths
34
+ template_paths(File.join(sidecar_dir, component_name))
39
35
  end
40
36
 
41
- # Extract method calls from an ERB template
42
- # @param template_path [String] Path to the ERB template file
37
+ # Extract method calls from a template
38
+ # @param template_path [String] Path to the template file
43
39
  # @return [Set<Symbol>] Set of method names called in the template
44
40
  def extract_method_calls(template_path)
45
41
  return Set.new unless File.exist?(template_path)
46
42
 
47
- source = File.read(template_path)
48
- ruby_code = Herb.extract_ruby(source)
43
+ ruby_code = extract_template_ruby(template_path)
44
+ return Set.new unless ruby_code
49
45
 
50
- # Parse the extracted Ruby code
51
46
  parse_ruby_for_method_calls(ruby_code)
52
- rescue StandardError => e
47
+ rescue LoadError, StandardError => e
53
48
  # Graceful degradation on parse errors
54
49
  warn "Warning: Failed to parse template #{template_path}: #{e.message}" if ENV["RUBOCOP_DEBUG"]
55
50
  Set.new
@@ -57,6 +52,53 @@ module RuboCop
57
52
 
58
53
  private
59
54
 
55
+ def template_paths(base_path)
56
+ exact_paths = TEMPLATE_EXTENSIONS.flat_map do |extension|
57
+ ["#{base_path}.#{extension}", "#{base_path}.html.#{extension}"].select { |path| File.exist?(path) }
58
+ end
59
+
60
+ exact_paths + Dir.glob("#{base_path}.*.#{TEMPLATE_GLOB}")
61
+ end
62
+
63
+ def extract_template_ruby(template_path)
64
+ source = File.read(template_path)
65
+
66
+ case File.extname(template_path)
67
+ when ".erb"
68
+ Herb.extract_ruby(source)
69
+ when ".haml"
70
+ haml_to_ruby(source)
71
+ when ".slim"
72
+ slim_to_ruby(source)
73
+ end
74
+ end
75
+
76
+ def haml_to_ruby(source)
77
+ require "haml"
78
+ return unless supported_haml?
79
+
80
+ ::Haml::Engine.new.call(source)
81
+ rescue LoadError
82
+ nil
83
+ end
84
+
85
+ def supported_haml?
86
+ return true if ::Haml::VERSION.to_i >= 6
87
+
88
+ if ENV["RUBOCOP_DEBUG"]
89
+ warn "Warning: Haml #{::Haml::VERSION} is not supported; " \
90
+ "Haml 6 or later is required"
91
+ end
92
+ false
93
+ end
94
+
95
+ def slim_to_ruby(source)
96
+ require "slim"
97
+ ::Slim::Engine.new.call(source)
98
+ rescue LoadError
99
+ nil
100
+ end
101
+
60
102
  # Parse Ruby code and extract method calls (send nodes with nil receiver)
61
103
  def parse_ruby_for_method_calls(ruby_code)
62
104
  # Use RuboCop's ProcessedSource to parse Ruby code
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module ViewComponent
5
- VERSION = "0.8.0"
5
+ VERSION = "0.9.0"
6
6
  end
7
7
  end
data/script/verify CHANGED
@@ -114,8 +114,10 @@ def replace_gemfile
114
114
  File.write("Gemfile", <<~RUBY)
115
115
  source "https://rubygems.org"
116
116
 
117
+ gem "haml", ">= 6"
117
118
  gem "rubocop"
118
119
  gem "rubocop-view_component", path: "#{GEM_DIR}"
120
+ gem "slim", ">= 4"
119
121
  gem "view_component"
120
122
  RUBY
121
123
  end
@@ -0,0 +1,2 @@
1
+ .card
2
+ %h1= formatted_title
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HamlMethodComponent < ViewComponent::Base
4
+ def initialize(title)
5
+ @title = title
6
+ end
7
+
8
+ def formatted_title
9
+ @title.upcase
10
+ end
11
+
12
+ def helper_not_used
13
+ "not used"
14
+ end
15
+ end
@@ -0,0 +1,2 @@
1
+ .card
2
+ h1 = formatted_title
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SlimMethodComponent < ViewComponent::Base
4
+ def initialize(title)
5
+ @title = title
6
+ end
7
+
8
+ def formatted_title
9
+ @title.upcase
10
+ end
11
+
12
+ def helper_not_used
13
+ "not used"
14
+ end
15
+ end
@@ -72,6 +72,34 @@ RSpec.describe RuboCop::Cop::ViewComponent::PreferComposition, :config do
72
72
  end
73
73
  RUBY
74
74
  end
75
+
76
+ it "registers an offense for a Haml template" do
77
+ allow(File).to receive(:exist?).and_call_original
78
+ allow(File).to receive(:exist?).with("/test_source.html.haml").and_return(true)
79
+
80
+ expect_offense(<<~RUBY)
81
+ class BaseCardComponent < ViewComponent::Base
82
+ end
83
+
84
+ class UserCardComponent < BaseCardComponent
85
+ ^^^^^^^^^^^^^^^^^ ViewComponent/PreferComposition: Avoid inheriting from another ViewComponent.
86
+ end
87
+ RUBY
88
+ end
89
+
90
+ it "registers an offense for a Slim template" do
91
+ allow(File).to receive(:exist?).and_call_original
92
+ allow(File).to receive(:exist?).with("/test_source.html.slim").and_return(true)
93
+
94
+ expect_offense(<<~RUBY)
95
+ class BaseCardComponent < ViewComponent::Base
96
+ end
97
+
98
+ class UserCardComponent < BaseCardComponent
99
+ ^^^^^^^^^^^^^^^^^ ViewComponent/PreferComposition: Avoid inheriting from another ViewComponent.
100
+ end
101
+ RUBY
102
+ end
75
103
  end
76
104
 
77
105
  context "when the parent is a resolvable component without a template" do
@@ -185,6 +213,26 @@ RSpec.describe RuboCop::Cop::ViewComponent::PreferComposition, :config do
185
213
  RUBY
186
214
  end
187
215
 
216
+ it "flags a parent with a Haml template" do
217
+ File.write(File.join(engine_dir, "app", "components", "engine_card", "component.html.haml"), "%div\n")
218
+
219
+ expect_offense(<<~RUBY)
220
+ class UserCardComponent < EngineCard::Component
221
+ ^^^^^^^^^^^^^^^^^^^^^ Avoid inheriting from another ViewComponent.
222
+ end
223
+ RUBY
224
+ end
225
+
226
+ it "flags a parent with a Slim template" do
227
+ File.write(File.join(engine_dir, "app", "components", "engine_card", "component.html.slim"), "div\n")
228
+
229
+ expect_offense(<<~RUBY)
230
+ class UserCardComponent < EngineCard::Component
231
+ ^^^^^^^^^^^^^^^^^^^^^ Avoid inheriting from another ViewComponent.
232
+ end
233
+ RUBY
234
+ end
235
+
188
236
  it "does not flag a parent resolved through the additional paths without a template" do
189
237
  expect_no_offenses(<<~RUBY)
190
238
  class UserCardComponent < EngineBase::Component
@@ -382,6 +382,56 @@ RSpec.describe RuboCop::Cop::ViewComponent::PreferPrivateMethods, :config do
382
382
  end
383
383
  end
384
384
 
385
+ context "with Haml template files" do
386
+ let(:component_file) { "spec/fixtures/components/haml_method_component.rb" }
387
+
388
+ it "flags methods not called in the Haml template" do
389
+ expect_offense(<<~RUBY, component_file)
390
+ # frozen_string_literal: true
391
+
392
+ class HamlMethodComponent < ViewComponent::Base
393
+ def initialize(title)
394
+ @title = title
395
+ end
396
+
397
+ def formatted_title
398
+ @title.upcase
399
+ end
400
+
401
+ def helper_not_used
402
+ ^^^^^^^^^^^^^^^^^^^ ViewComponent/PreferPrivateMethods: Consider making `helper_not_used` private. Only ViewComponent interface methods should be public.
403
+ "not used"
404
+ end
405
+ end
406
+ RUBY
407
+ end
408
+ end
409
+
410
+ context "with Slim template files" do
411
+ let(:component_file) { "spec/fixtures/components/slim_method_component.rb" }
412
+
413
+ it "flags methods not called in the Slim template" do
414
+ expect_offense(<<~RUBY, component_file)
415
+ # frozen_string_literal: true
416
+
417
+ class SlimMethodComponent < ViewComponent::Base
418
+ def initialize(title)
419
+ @title = title
420
+ end
421
+
422
+ def formatted_title
423
+ @title.upcase
424
+ end
425
+
426
+ def helper_not_used
427
+ ^^^^^^^^^^^^^^^^^^^ ViewComponent/PreferPrivateMethods: Consider making `helper_not_used` private. Only ViewComponent interface methods should be public.
428
+ "not used"
429
+ end
430
+ end
431
+ RUBY
432
+ end
433
+ end
434
+
385
435
  context "with template files" do
386
436
  let(:component_file) { "spec/fixtures/components/template_method_component.rb" }
387
437
 
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ RSpec.describe RuboCop::Cop::ViewComponent::TemplateAnalyzer do
7
+ subject(:analyzer) { Object.new.extend(described_class) }
8
+
9
+ let(:directory) { Dir.mktmpdir("template_analyzer") }
10
+
11
+ after { FileUtils.remove_entry(directory) }
12
+
13
+ describe "#template_paths_for" do
14
+ let(:component_path) { File.join(directory, "example_component.rb") }
15
+
16
+ before do
17
+ File.write(component_path, "")
18
+ %w[
19
+ example_component.erb
20
+ example_component.html.erb
21
+ example_component.en.html.haml
22
+ example_component.html.slim
23
+ ].each do |name|
24
+ File.write(File.join(directory, name), "")
25
+ end
26
+
27
+ sidecar_directory = File.join(directory, "example_component")
28
+ FileUtils.mkdir_p(sidecar_directory)
29
+ File.write(File.join(sidecar_directory, "example_component.html.haml"), "")
30
+ File.write(File.join(sidecar_directory, "unrelated.rb"), "")
31
+ end
32
+
33
+ it "finds sibling and sidecar templates in every supported language" do
34
+ expect(analyzer.template_paths_for(component_path)).to contain_exactly(
35
+ File.join(directory, "example_component.erb"),
36
+ File.join(directory, "example_component.html.erb"),
37
+ File.join(directory, "example_component.en.html.haml"),
38
+ File.join(directory, "example_component.html.slim"),
39
+ File.join(directory, "example_component", "example_component.html.haml")
40
+ )
41
+ end
42
+ end
43
+
44
+ describe "#extract_method_calls" do
45
+ it "extracts method calls from ERB" do
46
+ path = write_template("example.html.erb", "<h1><%= formatted_title %></h1>\n")
47
+
48
+ expect(analyzer.extract_method_calls(path)).to eq(Set[:formatted_title])
49
+ end
50
+
51
+ it "extracts method calls from Haml" do
52
+ path = write_template("example.html.haml", "%h1{title: tooltip_text}= formatted_title\n")
53
+
54
+ expect(analyzer.extract_method_calls(path)).to eq(Set[:tooltip_text, :formatted_title])
55
+ end
56
+
57
+ it "extracts method calls from Slim" do
58
+ path = write_template("example.html.slim", "h1 title=tooltip_text = formatted_title\n")
59
+
60
+ expect(analyzer.extract_method_calls(path)).to eq(Set[:tooltip_text, :formatted_title])
61
+ end
62
+
63
+ it "extracts method calls from Haml control flow and blocks" do
64
+ path = write_template("example.html.haml", <<~HAML)
65
+ .card
66
+ - if show_icon?
67
+ = render IconComponent.new(name: icon_name) { helper_block }
68
+ HAML
69
+
70
+ expect(analyzer.extract_method_calls(path)).to include(
71
+ :show_icon?,
72
+ :icon_name,
73
+ :helper_block
74
+ )
75
+ end
76
+
77
+ it "returns an empty set for an unsupported template language" do
78
+ path = write_template("example.txt", "formatted_title\n")
79
+
80
+ expect(analyzer.extract_method_calls(path)).to eq(Set.new)
81
+ end
82
+
83
+ it "returns an empty set when the template does not exist" do
84
+ expect(analyzer.extract_method_calls(File.join(directory, "missing.html.haml"))).to eq(Set.new)
85
+ end
86
+ end
87
+
88
+ def write_template(name, content)
89
+ path = File.join(directory, name)
90
+ File.write(path, content)
91
+ path
92
+ end
93
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-view_component
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Waite
@@ -132,6 +132,10 @@ files:
132
132
  - spec/expected_govuk_failures.yml
133
133
  - spec/expected_polaris_failures.yml
134
134
  - spec/expected_primer_failures.yml
135
+ - spec/fixtures/components/haml_method_component.html.haml
136
+ - spec/fixtures/components/haml_method_component.rb
137
+ - spec/fixtures/components/slim_method_component.html.slim
138
+ - spec/fixtures/components/slim_method_component.rb
135
139
  - spec/fixtures/components/template_method_component.html.erb
136
140
  - spec/fixtures/components/template_method_component.rb
137
141
  - spec/rubocop/cop/view_component/base_spec.rb
@@ -141,6 +145,7 @@ files:
141
145
  - spec/rubocop/cop/view_component/prefer_composition_spec.rb
142
146
  - spec/rubocop/cop/view_component/prefer_private_methods_spec.rb
143
147
  - spec/rubocop/cop/view_component/prefer_slots_spec.rb
148
+ - spec/rubocop/cop/view_component/template_analyzer_spec.rb
144
149
  - spec/rubocop/cop/view_component/test_rendered_output_spec.rb
145
150
  - spec/spec_helper.rb
146
151
  - spec/support/project_index_helpers.rb