rubocop-vicenzo 0.2.0 → 0.4.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: b430ab160562cd140cd498e015dbf4e2c03a6bd99189ae24adc00881a3bbab48
4
- data.tar.gz: 3757afc640413d2ed6548319003ecf62be4cc84658673d430e5ff8236936f2d6
3
+ metadata.gz: 2caba14e7971e328ef6fc5c5fb203fe11a26cbd1f78f534f223f01f9d99c240f
4
+ data.tar.gz: 2eb689487018dfa035aff365831cc219cf71dfce583885639656f314fbcffc82
5
5
  SHA512:
6
- metadata.gz: 4b2cbdc348b8faa2dd97e722e78e5c8956b67da26df8541314990331a645b156c758540d6602960e8f96340a6294bf0a35a833b4f2d5a1a237349bbb91f58ba6
7
- data.tar.gz: 88b77a6119a722404ad6d263f61ed53423e4991ae216bd8ea2295712fa72c5536b902948bea8c61e2f32e832444e356c2a0b16ae2f4a8727993acd063db40130
6
+ metadata.gz: e1c048efe4a3c8b7f7de35d8fcbe76908efcceb50bf053df761e59d53ea8fa9d74f779f223bb0d6e6ff1274e4ec2c83daf9a561084f970cdd5a48c8b7d4f7dce
7
+ data.tar.gz: f8c2f91bd54d5845ae68928a5ff783f6b161a47ca0e2e1d0afdc177473761416487d2e0d5c984f9b6d064a98ef3cc64db98baf3454f3989be19bb146172a3620
data/.rubocop.yml CHANGED
@@ -6,8 +6,20 @@ plugins:
6
6
  - rubocop-internal_affairs
7
7
 
8
8
  AllCops:
9
+ Exclude:
10
+ - 'bin/**/*'
11
+ - 'vendor/**/*'
9
12
  NewCops: enable
10
13
 
14
+ InternalAffairs/UndefinedConfig: # False positive because conflicts with Rubocop Layout namespace
15
+ Exclude:
16
+ - 'lib/rubocop/cop/vicenzo/layout/**/*'
17
+
18
+ Metrics/BlockLength:
19
+ Exclude:
20
+ - 'rakelib/**/*'
21
+ - 'Rakefile'
22
+
11
23
  Naming/FileName:
12
24
  Exclude:
13
25
  - lib/rubocop-vicenzo.rb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-03-28
4
+
5
+ - Add RuboCop::Cop::Vicenzo::RSpec::ConditionalInSpec #19;
6
+ - Add RuboCop::Cop::Vicenzo::RSpec::DynamicExampleGeneration #19;
7
+ - Add RuboCop::Cop::Vicenzo::RSpec::IterationInsideExample #19;
8
+
9
+ ## [0.3.0] - 2025-12-17
10
+
11
+ - Add RuboCop::Cop::Vicenzo::Layout::MultilineMethodCallLineBreaks #12;
12
+ - Add RuboCop::Cop::Vicenzo::Style::MultilineMethodCallParentheses #13;
13
+ - Add `AllowedMethods` configuration to `Vicenzo/Style/MultilineMethodCallParentheses` to allow excluding specific methods (e.g., RSpec DSLs like `to` and `change`) from the rule #15;
14
+
3
15
  ## [0.2.0] - 2025-11-27
4
16
 
5
17
  - Remove RuboCop::Cop::Vicenzo::RSpec::MixedExampleGroups in favor of InconsistentSiblingStructure #10;
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  [![Ruby](https://github.com/bvicenzo/rubocop-vicenzo/actions/workflows/main.yml/badge.svg)](https://github.com/bvicenzo/rubocop-vicenzo/actions/workflows/main.yml)
4
4
 
5
+ šŸ“– **[Documentation](https://bvicenzo.github.io/rubocop-vicenzo)**
6
+
5
7
  ## Installation
6
8
 
7
9
  Install the gem and add to the application's Gemfile by executing:
@@ -55,6 +57,33 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
55
57
 
56
58
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
57
59
 
60
+ ### Documentation
61
+
62
+ The documentation site is built with [Antora](https://antora.org) and published automatically to GitHub Pages on every new release.
63
+
64
+ To build it locally, you will need [Node.js](https://nodejs.org) (v20+) installed. Then install Antora:
65
+
66
+ ```bash
67
+ npm install -g @antora/cli @antora/site-generator
68
+ ```
69
+
70
+ Generate the AsciiDoc pages from the cop sources and build the site:
71
+
72
+ ```bash
73
+ bundle exec rake docs:generate
74
+ antora antora-playbook.yml
75
+ ```
76
+
77
+ The site will be available at `build/site/index.html`.
78
+
79
+ ### Generate binstubs
80
+
81
+ If you want is possible change the command `bundle exec something` by `bin/something` generating binstubs
82
+
83
+ ```bash
84
+ bundle binstubs rake rspec-core rubocop
85
+ ```
86
+
58
87
  ### Creating a new cop
59
88
 
60
89
  ```bash
@@ -0,0 +1,15 @@
1
+ site:
2
+ title: RuboCop Vicenzo
3
+ url: https://bvicenzo.github.io/rubocop-vicenzo
4
+
5
+ content:
6
+ sources:
7
+ - url: .
8
+ branches: HEAD
9
+ start_path: docs
10
+
11
+ ui:
12
+ bundle:
13
+ url: https://gitlab.com/antora/antora-ui-default/-/jobs/artifacts/HEAD/raw/build/ui-bundle.zip?job=bundle-stable
14
+ snapshot: true
15
+ supplemental_files: ./docs/supplemental-ui
data/config/default.yml CHANGED
@@ -1,15 +1,45 @@
1
+ Vicenzo/Layout/MultilineMethodCallLineBreaks:
2
+ Description: 'Enforces that method calls in a multiline chain must each be on their own line.'
3
+ Enabled: true
4
+ Severity: convention
5
+ IndentationWidth: 2
6
+ VersionAdded: '0.3.0'
7
+
1
8
  Vicenzo/Rails/EnumInclusionOfValidation:
2
9
  Description: 'Check if the enum has the inclusion of validation defined.'
3
10
  Enabled: true
4
11
  Severity: convention
5
12
  VersionAdded: '0.1.0'
6
13
 
14
+ Vicenzo/RSpec/ConditionalInSpec:
15
+ Description: 'Do not use conditional logic in specs. Extract each branch into an explicit context instead.'
16
+ Enabled: true
17
+ Severity: warning
18
+ Include:
19
+ - '**/spec/**/*_spec.rb'
20
+ Exclude:
21
+ - '**/spec/support/**/*'
22
+ - '**/spec/factories/**/*'
23
+ VersionAdded: '0.4.0'
24
+
25
+ Vicenzo/RSpec/DynamicExampleGeneration:
26
+ Description: 'Do not use iteration to dynamically generate example groups or examples.'
27
+ Enabled: true
28
+ Severity: warning
29
+ VersionAdded: '0.4.0'
30
+
7
31
  Vicenzo/RSpec/InconsistentSiblingStructure:
8
32
  Description: 'Enforces strict structural consistency (e.g. prevents mixing describe with context or examples with groups).'
9
33
  Enabled: true
10
34
  Severity: warning
11
35
  VersionAdded: '0.2.0'
12
36
 
37
+ Vicenzo/RSpec/IterationInsideExample:
38
+ Description: 'Do not call `expect` inside an iteration within an example.'
39
+ Enabled: true
40
+ Severity: warning
41
+ VersionAdded: '0.4.0'
42
+
13
43
  Vicenzo/RSpec/NestedContextImproperStart:
14
44
  Description: 'Check if the nested context does not start as a root one.'
15
45
  Enabled: true
@@ -38,3 +68,9 @@ Vicenzo/RSpec/LeakyDefinition:
38
68
  Exclude:
39
69
  - '**/spec/support/**/*'
40
70
  - '**/spec/factories/**/*'
71
+
72
+ Vicenzo/Style/MultilineMethodCallParentheses:
73
+ Description: 'Enforces parentheses for method calls with arguments that span multiple lines.'
74
+ Enabled: true
75
+ AllowedMethods: []
76
+ VersionAdded: '0.3.0'
data/docs/antora.yml ADDED
@@ -0,0 +1,5 @@
1
+ name: rubocop-vicenzo
2
+ version: ~
3
+ title: RuboCop Vicenzo
4
+ nav:
5
+ - modules/ROOT/nav.adoc
@@ -0,0 +1,14 @@
1
+ <header class="header">
2
+ <nav class="navbar">
3
+ <div class="navbar-brand">
4
+ <a class="navbar-item" href="{{{or site.url siteRootPath}}}">{{site.title}}</a>
5
+ {{#if env.SITE_SEARCH_PROVIDER}}
6
+ <div class="navbar-item search hide-for-print">
7
+ <div id="search-field" class="field">
8
+ <input id="search-input" type="text" placeholder="Search the docs"{{#if page.home}} autofocus{{/if}}>
9
+ </div>
10
+ </div>
11
+ {{/if}}
12
+ </div>
13
+ </nav>
14
+ </header>
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Vicenzo
6
+ module Layout
7
+ # Enforces that method calls in a multiline chain are each on their own line.
8
+ #
9
+ # If a method chain spans more than one line, this cop ensures that every
10
+ # call in the chain is placed on a new line. It prevents "mixed" styles
11
+ # where some methods are on the same line as the receiver while others are broken.
12
+ #
13
+ # @example
14
+ # # bad
15
+ # object.method_one
16
+ # .method_two
17
+ #
18
+ # # bad
19
+ # object
20
+ # .method_one.method_two
21
+ # .method_three
22
+ #
23
+ # # good - single line chain
24
+ # object.method_one.method_two
25
+ #
26
+ # # good - multiline chain
27
+ # object
28
+ # .method_one
29
+ # .method_two
30
+ #
31
+ # # good - arguments causing the break (if configured implicitly)
32
+ # object.method_one(
33
+ # arg1,
34
+ # arg2
35
+ # ).method_two
36
+ #
37
+ # ## Configuration
38
+ #
39
+ # This cop allows you to customize the indentation width used during auto-correction.
40
+ # The default width is 2 spaces relative to the previous line.
41
+ #
42
+ # ```yaml
43
+ # CustomCops/MultilineMethodCallLineBreaks:
44
+ # IndentationWidth: 4 # (default is 2)
45
+ # ```
46
+ #
47
+ class MultilineMethodCallLineBreaks < Base
48
+ extend RuboCop::Cop::AutoCorrector
49
+
50
+ MSG = 'Method calls in a multiline chain must each be on their own line.'
51
+ DEFAULT_INDENTATION_WIDTH = 2
52
+ LEADING_SPACES_PATTERN = /\A */
53
+ CHAIN_START_PATTERN = /\A\s*&?\./
54
+
55
+ OPERATOR_METHODS = %i[[] []= + - * / % ** << >>].freeze
56
+
57
+ def on_send(node)
58
+ check_node(node)
59
+ end
60
+ alias on_csend on_send
61
+
62
+ private
63
+
64
+ def check_node(node)
65
+ return if part_of_larger_chain?(node)
66
+ return if single_line_chain?(node)
67
+
68
+ check_chain_structure(node)
69
+ end
70
+
71
+ def check_chain_structure(node)
72
+ current = node
73
+
74
+ while current.call_type?
75
+ receiver = current.receiver
76
+ break unless receiver
77
+
78
+ check_violation(current, receiver)
79
+ current = receiver
80
+ end
81
+ end
82
+
83
+ def check_violation(node, receiver)
84
+ return unless same_line?(receiver, node)
85
+ return if valid_same_line_exception?(node)
86
+
87
+ add_offense(offense_range(node)) do |corrector|
88
+ break_line_before_dot(corrector, node, receiver)
89
+ end
90
+ end
91
+
92
+ def valid_same_line_exception?(node)
93
+ arguments_cause_multiline?(node) || operator_method?(node)
94
+ end
95
+
96
+ def operator_method?(node)
97
+ OPERATOR_METHODS.include?(node.method_name)
98
+ end
99
+
100
+ def part_of_larger_chain?(node)
101
+ parent = node.parent
102
+ parent&.call_type? && parent.receiver == node
103
+ end
104
+
105
+ def single_line_chain?(node)
106
+ root = root_node(node)
107
+ root.loc.last_line == node.loc.last_line
108
+ end
109
+
110
+ def root_node(node)
111
+ current = node
112
+ current = current.receiver while current.respond_to?(:receiver) && current.receiver
113
+ current
114
+ end
115
+
116
+ def same_line?(receiver, node)
117
+ receiver.loc.last_line == call_start_line(node)
118
+ end
119
+
120
+ def call_start_line(node)
121
+ node.loc.dot ? node.loc.dot.line : node.loc.selector.line
122
+ end
123
+
124
+ def arguments_cause_multiline?(node)
125
+ return false if node.arguments.empty?
126
+ return false if node.receiver.loc.last_line != call_start_line(node)
127
+
128
+ return false if node.receiver.call_type? && node.receiver.loc.dot
129
+
130
+ node.multiline?
131
+ end
132
+
133
+ def offense_range(node)
134
+ return node.loc.selector unless node.loc.dot
135
+ return node.loc.dot unless node.loc.selector
136
+
137
+ node.loc.dot.join(node.loc.selector)
138
+ end
139
+
140
+ def break_line_before_dot(corrector, node, receiver)
141
+ dot = node.loc.dot
142
+ return unless dot
143
+
144
+ last_line_index = receiver.loc.last_line - 1
145
+ last_line_source = processed_source.lines[last_line_index]
146
+
147
+ current_indentation = last_line_source[LEADING_SPACES_PATTERN].length
148
+
149
+ previous_line_is_chain = last_line_source.match?(CHAIN_START_PATTERN)
150
+ extra_indent = previous_line_is_chain ? 0 : indentation_width
151
+
152
+ indentation = ' ' * (current_indentation + extra_indent)
153
+
154
+ corrector.insert_before(dot, "\n#{indentation}")
155
+ end
156
+
157
+ def indentation_width
158
+ cop_config.fetch('IndentationWidth', DEFAULT_INDENTATION_WIDTH)
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -7,27 +7,20 @@ module RuboCop
7
7
  # Ensures that enums using the new syntax include the
8
8
  # `validate: { allow_nil: true }` option.
9
9
  #
10
- # ## Bad usage
10
+ # Old-style enums (keyword argument syntax) are ignored.
11
11
  #
12
- # ```ruby
13
- # enum :status, { active: 1, inactive: 0 }, suffix: true
14
- # ```
12
+ # @example
13
+ # # bad — missing validate option
14
+ # enum :status, { active: 1, inactive: 0 }, suffix: true
15
15
  #
16
- # ```ruby
17
- # enum :status, { active: 1, inactive: 0 }, validate: true, suffix: true
18
- # ```
16
+ # # bad — validate option present but incorrect
17
+ # enum :status, { active: 1, inactive: 0 }, validate: true, suffix: true
19
18
  #
20
- # ## Good usage
19
+ # # good
20
+ # enum :status, { active: 1, inactive: 0 }, validate: { allow_nil: true }, suffix: true
21
21
  #
22
- # ```ruby
23
- # enum :status, { active: 1, inactive: 0 }, validate: { allow_nil: true }, suffix: true
24
- # ```
25
- #
26
- # This cop does not enforce validation on enums using the old syntax:
27
- #
28
- # ```ruby
29
- # enum status: { active: 1, inactive: 0 }
30
- # ```
22
+ # # ignored — old-style enum syntax
23
+ # enum status: { active: 1, inactive: 0 }
31
24
  class EnumInclusionOfValidation < Base
32
25
  extend AutoCorrector
33
26
 
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Vicenzo
6
+ module RSpec
7
+ # Do not use conditional logic in spec files.
8
+ #
9
+ # Any `if`, `unless`, or ternary expression in a spec represents a hidden
10
+ # context. Each branch should be an explicit `context` block so that the
11
+ # conditions and expectations are always clear and unconditional.
12
+ #
13
+ # @example
14
+ # # bad — hidden context inside an example
15
+ #
16
+ # it 'grants or denies access' do
17
+ # if user.admin?
18
+ # expect(result).to eq(:granted)
19
+ # else
20
+ # expect(result).to eq(:denied)
21
+ # end
22
+ # end
23
+ #
24
+ # # bad — hidden context inside a let
25
+ #
26
+ # let(:user) { admin? ? create(:admin) : create(:client) }
27
+ #
28
+ # # bad — hidden context inside a before hook
29
+ #
30
+ # before { setup_thing if feature_enabled? }
31
+ #
32
+ # # bad — hidden context at the example group level using unless
33
+ #
34
+ # unless legacy_mode?
35
+ # it 'uses the new behaviour' do
36
+ # ...
37
+ # end
38
+ # end
39
+ #
40
+ # # good
41
+ #
42
+ # context 'when user is admin' do
43
+ # let(:user) { create(:admin) }
44
+ #
45
+ # it 'grants access' do
46
+ # expect(result).to eq(:granted)
47
+ # end
48
+ # end
49
+ #
50
+ # context 'when user is not admin' do
51
+ # let(:user) { create(:client) }
52
+ #
53
+ # it 'denies access' do
54
+ # expect(result).to eq(:denied)
55
+ # end
56
+ # end
57
+ class ConditionalInSpec < RuboCop::Cop::RSpec::Base
58
+ MSG = 'Do not use conditional logic in specs. ' \
59
+ 'Extract each branch into an explicit context instead.'
60
+
61
+ # Both `if` and `unless` are represented as `if` nodes in the AST,
62
+ # so this single hook covers all conditional forms: `if`, `unless`,
63
+ # modifier `if`/`unless`, and ternary `?:`.
64
+ def on_if(node)
65
+ offense_location = node.ternary? ? node : node.loc.keyword
66
+ add_offense(offense_location)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Vicenzo
6
+ module RSpec
7
+ # Do not use iteration to dynamically generate example groups or examples.
8
+ #
9
+ # Dynamic generation makes tests hard to find, hard to read, and creates
10
+ # pressure to add conditional logic (e.g. `if variable == :x`) when not
11
+ # all iterations share the same conditions. Write explicit, static contexts
12
+ # instead — one context per case.
13
+ #
14
+ # @example
15
+ # # bad
16
+ #
17
+ # [:admin, :driver].each do |role|
18
+ # context "when role is #{role}" do
19
+ # it 'does something' do
20
+ # ...
21
+ # end
22
+ # end
23
+ # end
24
+ #
25
+ # # good
26
+ #
27
+ # context 'when role is admin' do
28
+ # let(:role) { :admin }
29
+ #
30
+ # it 'does something' do
31
+ # ...
32
+ # end
33
+ # end
34
+ #
35
+ # context 'when role is driver' do
36
+ # let(:role) { :driver }
37
+ #
38
+ # it 'does something' do
39
+ # ...
40
+ # end
41
+ # end
42
+ class DynamicExampleGeneration < RuboCop::Cop::RSpec::Base
43
+ MSG = 'Do not use iteration to dynamically generate example groups or examples. ' \
44
+ 'Write explicit, static contexts instead.'
45
+
46
+ ENUMERATION_METHODS = %i[each each_with_index each_with_object map flat_map].freeze
47
+
48
+ EXAMPLE_GROUP_DSL = %i[
49
+ context describe feature experiment
50
+ it specify example scenario focus
51
+ let let! subject subject! before after around
52
+ shared_examples shared_context shared_examples_for
53
+ ].freeze
54
+
55
+ # @!method enumeration_block?(node)
56
+ def_node_matcher :enumeration_block?, <<~PATTERN
57
+ (block
58
+ (send _ {#{ENUMERATION_METHODS.map(&:inspect).join(' ')}} ...)
59
+ ...)
60
+ PATTERN
61
+
62
+ # @!method example_group_dsl_call?(node)
63
+ def_node_matcher :example_group_dsl_call?, <<~PATTERN
64
+ (block (send nil? {#{EXAMPLE_GROUP_DSL.map(&:inspect).join(' ')}} ...) ...)
65
+ PATTERN
66
+
67
+ def on_block(node)
68
+ return unless enumeration_block?(node)
69
+ return unless contains_example_group_dsl?(node)
70
+
71
+ add_offense(node.send_node)
72
+ end
73
+
74
+ alias on_numblock on_block
75
+
76
+ private
77
+
78
+ def contains_example_group_dsl?(node)
79
+ node.body&.each_node(:block) do |child|
80
+ return true if example_group_dsl_call?(child)
81
+ end
82
+
83
+ false
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Vicenzo
6
+ module RSpec
7
+ # Do not use `expect` inside an iteration within an example.
8
+ #
9
+ # Placing `expect` calls inside an iteration block (e.g. `each`) makes
10
+ # tests implicit and hard to debug: when the assertion fails it is unclear
11
+ # which element caused the failure, and not all elements may represent the
12
+ # same condition. Using iteration to build or transform data before calling
13
+ # `expect` is fine; the problem is calling `expect` inside the iteration.
14
+ # Write explicit assertions for each relevant case instead.
15
+ #
16
+ # @example
17
+ # # bad — expect is called inside the iteration
18
+ #
19
+ # it 'returns vehicle costs general values' do
20
+ # response_body[:vehicle_costs].first.each do |attribute, value|
21
+ # expect(value).to eq(vehicle_cost.send(attribute).to_s)
22
+ # end
23
+ # end
24
+ #
25
+ # # good — iteration builds data, expect is called once outside
26
+ #
27
+ # it 'returns the expected column names' do
28
+ # columns = VehicleCost.column_names.map { |column| column.gsub('_centavos', '') }
29
+ # expect(response_body[:vehicle_costs].first.keys).to match_array(columns.map(&:to_sym))
30
+ # end
31
+ #
32
+ # # good — each attribute has an explicit example
33
+ #
34
+ # it 'returns the correct name' do
35
+ # expect(response_body[:vehicle_costs].first[:name]).to eq(vehicle_cost.name)
36
+ # end
37
+ #
38
+ # it 'returns the correct value' do
39
+ # expect(response_body[:vehicle_costs].first[:value]).to eq(vehicle_cost.value.to_s)
40
+ # end
41
+ class IterationInsideExample < RuboCop::Cop::RSpec::Base
42
+ MSG = 'Do not call `expect` inside an iteration. ' \
43
+ 'Write explicit assertions instead.'
44
+
45
+ ENUMERATION_METHODS = %i[each each_with_index each_with_object].freeze
46
+
47
+ # @!method enumeration_block?(node)
48
+ def_node_matcher :enumeration_block?, <<~PATTERN
49
+ (block
50
+ (send _ {#{ENUMERATION_METHODS.map(&:inspect).join(' ')}} ...)
51
+ ...)
52
+ PATTERN
53
+
54
+ def on_block(node)
55
+ return unless example?(node)
56
+
57
+ find_iterations_with_assertions(node.body)
58
+ end
59
+
60
+ alias on_numblock on_block
61
+
62
+ private
63
+
64
+ def find_iterations_with_assertions(body)
65
+ return unless body
66
+
67
+ body.each_node(:block) do |iteration|
68
+ next unless enumeration_block?(iteration)
69
+ next unless contains_expectation?(iteration.body)
70
+
71
+ add_offense(iteration.send_node)
72
+ end
73
+ end
74
+
75
+ def contains_expectation?(node)
76
+ return false unless node
77
+
78
+ node.each_node(:send).any? { |send_node| send_node.method?(:expect) }
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Vicenzo
6
+ module Style
7
+ # Enforces parentheses for method calls with arguments that span multiple lines.
8
+ # Single-line calls are ignored (parentheses are optional).
9
+ #
10
+ # This cop accepts an `AllowedMethods` configuration to exempt specific methods
11
+ # from this rule. This is particularly useful for Fluent DSLs (like RSpec's
12
+ # `to`, `change`, etc.) where parentheses might hurt readability or conflict
13
+ # with layout rules.
14
+ #
15
+ # @example
16
+ # # bad
17
+ # method_name arg1,
18
+ # arg2
19
+ #
20
+ # # good
21
+ # method_name(arg1,
22
+ # arg2)
23
+ #
24
+ # # good (single line is always allowed)
25
+ # method_name arg1, arg2
26
+ #
27
+ # @example AllowedMethods: ['to']
28
+ # # good (allowed by configuration)
29
+ # expect { action }.to change {
30
+ # model.attribute
31
+ # }
32
+ #
33
+ class MultilineMethodCallParentheses < Base
34
+ extend RuboCop::Cop::AutoCorrector
35
+ include RuboCop::Cop::RangeHelp
36
+
37
+ MSG = 'Use parentheses for method calls with arguments that span multiple lines.'
38
+
39
+ def on_send(node)
40
+ check_node(node)
41
+ end
42
+ alias on_csend on_send
43
+
44
+ private
45
+
46
+ def check_node(node)
47
+ return unless node.arguments?
48
+ return unless node.multiline?
49
+ return if node.parenthesized? || node.operator_method? || node.setter_method? || allowed_method?(node)
50
+
51
+ add_offense(node) do |corrector|
52
+ autocorrect(corrector, node)
53
+ end
54
+ end
55
+
56
+ def allowed_method?(node)
57
+ allowed_methods.include?(node.method_name.to_s)
58
+ end
59
+
60
+ def allowed_methods
61
+ cop_config.fetch('AllowedMethods', [])
62
+ end
63
+
64
+ def autocorrect(corrector, node)
65
+ if node.loc.selector
66
+ gap_range = range_between(node.loc.selector.end_pos, node.first_argument.source_range.begin_pos)
67
+ corrector.replace(gap_range, '(')
68
+ else
69
+ corrector.insert_before(node.first_argument, '(')
70
+ end
71
+
72
+ corrector.insert_after(node.last_argument, ')')
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -1,8 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'vicenzo/rspec/conditional_in_spec'
4
+ require_relative 'vicenzo/rspec/dynamic_example_generation'
3
5
  require_relative 'vicenzo/rspec/inconsistent_sibling_structure'
6
+ require_relative 'vicenzo/rspec/iteration_inside_example'
4
7
  require_relative 'vicenzo/rspec/nested_context_improper_start'
5
8
  require_relative 'vicenzo/rspec/nested_let_redefinition'
6
9
  require_relative 'vicenzo/rspec/nested_subject_redefinition'
7
10
  require_relative 'vicenzo/rspec/leaky_definition'
8
11
  require_relative 'vicenzo/rails/enum_inclusion_of_validation'
12
+ require_relative 'vicenzo/layout/multiline_method_call_line_breaks'
13
+ require_relative 'vicenzo/style/multiline_method_call_parentheses'
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module Vicenzo
5
- VERSION = '0.2.0'
5
+ VERSION = '0.4.0'
6
6
  end
7
7
  end
data/rakelib/docs.rake ADDED
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'fileutils'
5
+
6
+ DOCS_PAGES_DIR = 'docs/modules/ROOT/pages'
7
+ DOCS_NAV_FILE = 'docs/modules/ROOT/nav.adoc'
8
+
9
+ CAMEL_BOUNDARIES_UPPER_PATTERN = /(?<leading_caps>[A-Z]+)(?<next_word>[A-Z][a-z])/
10
+ CAMEL_BOUNDARIES_LOWER_PATTERN = /(?<word_end>[a-z\d])(?<word_start>[A-Z])/
11
+ CLASS_DECLARATION_PATTERN = /^\s+class\s/
12
+ COMMENT_LINE_PATTERN = /^\s+#/
13
+ EXAMPLE_TAG_PATTERN = /^\s+#\s+@example(.*)/
14
+ EXAMPLE_CODE_INDENT_PATTERN = /^\s+#\s{0,3}/
15
+ ANCHOR_INVALID_CHARS_PATTERN = /[^a-z0-9-]/
16
+
17
+ namespace :docs do
18
+ desc 'Generate AsciiDoc documentation for all Vicenzo cops'
19
+ task :generate do
20
+ FileUtils.mkdir_p(DOCS_PAGES_DIR)
21
+
22
+ default_config = YAML.load_file('config/default.yml')
23
+ cop_data = default_config.keys.map { |name| build_cop_data(name, default_config) }
24
+
25
+ cops_by_dept = cop_data.group_by { |cop| cop[:department] }
26
+
27
+ cops_by_dept.sort.each do |department, cops|
28
+ content = render_department_page(department, cops.sort_by { |cop| cop[:name] })
29
+ filename = "cops_#{department.downcase}.adoc"
30
+ File.write(File.join(DOCS_PAGES_DIR, filename), content)
31
+ puts " Generated: #{filename} (#{cops.size} cop#{'s' if cops.size != 1})"
32
+ end
33
+
34
+ write_index(cop_data)
35
+ write_nav(cops_by_dept)
36
+ puts "\nDone. #{cop_data.size} cops documented across #{cops_by_dept.size} departments."
37
+ end
38
+ end
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Data extraction
42
+ # ---------------------------------------------------------------------------
43
+
44
+ def build_cop_data(cop_name, default_config)
45
+ config = default_config[cop_name] || {}
46
+ source = read_cop_source(cop_name)
47
+ cop_data_hash(cop_name, config, source)
48
+ end
49
+
50
+ def cop_data_hash(cop_name, config, source)
51
+ {
52
+ name: cop_name,
53
+ department: cop_name.split('/')[1],
54
+ description: config['Description'] || '',
55
+ version: config['VersionAdded'] || '-',
56
+ enabled: config.fetch('Enabled', true),
57
+ autocorrect: source ? autocorrect?(source) : false,
58
+ examples: source ? extract_examples(source) : [],
59
+ config_keys: extra_config_keys(config)
60
+ }
61
+ end
62
+
63
+ def read_cop_source(cop_name)
64
+ path = cop_name_to_path(cop_name)
65
+ File.exist?(path) ? File.read(path) : nil
66
+ end
67
+
68
+ def cop_name_to_path(cop_name)
69
+ parts = cop_name.split('/')
70
+ dept = parts[1].downcase
71
+ klass = underscore(parts[2])
72
+ "lib/rubocop/cop/vicenzo/#{dept}/#{klass}.rb"
73
+ end
74
+
75
+ def underscore(str)
76
+ str
77
+ .gsub(CAMEL_BOUNDARIES_UPPER_PATTERN, '\k<leading_caps>_\k<next_word>')
78
+ .gsub(CAMEL_BOUNDARIES_LOWER_PATTERN, '\k<word_end>_\k<word_start>')
79
+ .downcase
80
+ end
81
+
82
+ def autocorrect?(source)
83
+ source.include?('AutoCorrector')
84
+ end
85
+
86
+ def extract_examples(source)
87
+ docstring = extract_docstring(source)
88
+ parse_examples(docstring)
89
+ end
90
+
91
+ def extract_docstring(source)
92
+ lines = []
93
+ source.each_line do |line|
94
+ break if CLASS_DECLARATION_PATTERN.match?(line)
95
+
96
+ lines << line if COMMENT_LINE_PATTERN.match?(line)
97
+ end
98
+ lines
99
+ end
100
+
101
+ def parse_examples(docstring_lines)
102
+ examples = []
103
+ current = nil
104
+ docstring_lines.each do |line|
105
+ examples, current = update_examples(line, examples, current)
106
+ end
107
+ finalize_examples(examples, current)
108
+ end
109
+
110
+ def update_examples(line, examples, current)
111
+ if line =~ EXAMPLE_TAG_PATTERN
112
+ examples << current if current
113
+ [examples, { title: Regexp.last_match(1).strip, code: [] }]
114
+ elsif current
115
+ current[:code] << line.sub(EXAMPLE_CODE_INDENT_PATTERN, '').rstrip
116
+ [examples, current]
117
+ else
118
+ [examples, current]
119
+ end
120
+ end
121
+
122
+ def finalize_examples(examples, current)
123
+ examples << current if current
124
+ examples.each { |example| example[:code].pop while example[:code].last&.empty? }
125
+ examples
126
+ end
127
+
128
+ def extra_config_keys(config)
129
+ skip = %w[Description Enabled Severity VersionAdded Include Exclude Safe]
130
+ config.except(*skip)
131
+ end
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # AsciiDoc rendering
135
+ # ---------------------------------------------------------------------------
136
+
137
+ def render_department_page(department, cops)
138
+ lines = ["= Vicenzo/#{department}", ':toc: left', ':toc-title: Cops', ':toclevels: 1', '']
139
+ cops.each { |cop| lines.concat(render_cop(cop)) }
140
+ lines.join("\n")
141
+ end
142
+
143
+ def render_cop(cop)
144
+ lines = ["== #{cop[:name]}", '', cop[:description], '']
145
+ lines.concat(render_metadata_table(cop))
146
+ lines.concat(render_all_examples(cop[:examples]))
147
+ lines.concat(render_config_keys(cop[:config_keys])) unless cop[:config_keys].empty?
148
+ lines.push("'''", '')
149
+ end
150
+
151
+ def render_all_examples(examples)
152
+ examples.each_with_index.flat_map { |example, index| render_example(example, index) }
153
+ end
154
+
155
+ def render_metadata_table(cop)
156
+ enabled = cop[:enabled] ? 'Enabled' : 'Disabled'
157
+ autocorrect = cop[:autocorrect] ? 'Yes' : 'No'
158
+ ['[cols="1,1,1,1"]', '|===', '| Enabled by default | Safe | Supports autocorrection | Version Added',
159
+ '', "| #{enabled}", '| Yes', "| #{autocorrect}", "| #{cop[:version]}", '|===', '']
160
+ end
161
+
162
+ def render_example(example, index)
163
+ title = if example[:title].empty?
164
+ index.zero? ? 'Example' : "Example #{index + 1}"
165
+ else
166
+ example[:title]
167
+ end
168
+ ["=== #{title}", '', '[source,ruby]', '----', *example[:code], '----', '']
169
+ end
170
+
171
+ def render_config_keys(config_keys)
172
+ lines = ['=== Configurable attributes', '', '[cols="1,1"]', '|===', '| Name | Default value', '']
173
+ config_keys.each { |key, value| lines.push("| #{key}", "| `#{value.inspect}`", '') }
174
+ lines.push('|===', '')
175
+ end
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Index and navigation
179
+ # ---------------------------------------------------------------------------
180
+
181
+ def write_index(cop_data)
182
+ lines = index_header_lines + index_cops_table_lines(cop_data)
183
+ File.write(File.join(DOCS_PAGES_DIR, 'index.adoc'), lines.join("\n"))
184
+ puts ' Generated: index.adoc'
185
+ end
186
+
187
+ def index_header_lines
188
+ ['= RuboCop Vicenzo', ':toc: left', ''] +
189
+ ['Custom RuboCop cops for enforcing conventions adopted by Vicenzo projects.', ''] +
190
+ installation_section_lines +
191
+ ['== Cops', '', '[cols="2,1,1"]', '|===', '| Cop | Department | Version Added', '']
192
+ end
193
+
194
+ def installation_section_lines
195
+ ['== Installation', ''] +
196
+ gemfile_installation_lines +
197
+ rubocop_yml_installation_lines
198
+ end
199
+
200
+ def gemfile_installation_lines
201
+ ['Add to your `Gemfile`:', '', '[source,ruby]', '----',
202
+ "gem 'rubocop-vicenzo', require: false", '----', '']
203
+ end
204
+
205
+ def rubocop_yml_installation_lines
206
+ ['Then add to your `.rubocop.yml`:', '', '[source,yaml]', '----',
207
+ 'plugins:', ' - rubocop-vicenzo', '----', '']
208
+ end
209
+
210
+ def index_cops_table_lines(cop_data)
211
+ lines = cop_data.sort_by { |cop| cop[:name] }.map do |cop|
212
+ dept = cop[:department]
213
+ filename = "cops_#{dept.downcase}.adoc"
214
+ anchor = cop[:name].downcase.tr('/', '-').gsub(ANCHOR_INVALID_CHARS_PATTERN, '')
215
+ "| xref:#{filename}##{anchor}[#{cop[:name]}] | #{dept} | #{cop[:version]}"
216
+ end
217
+ lines.push('|===', '')
218
+ end
219
+
220
+ def write_nav(cops_by_dept)
221
+ lines = ['* xref:index.adoc[Home]', '* Cops']
222
+ cops_by_dept
223
+ .sort
224
+ .each { |department, _cops| lines << "** xref:cops_#{department.downcase}.adoc[#{department}]" }
225
+ File.write(DOCS_NAV_FILE, "#{lines.join("\n")}\n")
226
+ puts ' Generated: nav.adoc'
227
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ desc 'Prepare release: update config, version file, and changelog'
6
+ task :cut_release, [:version] do |_t, args|
7
+ version = args[:version]
8
+
9
+ # 1. Validation
10
+ abort 'Error: You must provide a version. Example: rake cut_release[0.3.0]' unless version
11
+
12
+ config_file = 'config/default.yml'
13
+ version_file = 'lib/rubocop/vicenzo/version.rb'
14
+ changelog_file = 'CHANGELOG.md'
15
+
16
+ # Check if files exist
17
+ [config_file, version_file, changelog_file].each do |file|
18
+ abort "Error: File not found at #{file}" unless File.exist?(file)
19
+ end
20
+
21
+ puts "āœ‚ļø Cutting release for version #{version}..."
22
+
23
+ # -------------------------------------------------------
24
+ # 2. Update config/default.yml (<<next>> -> version)
25
+ # -------------------------------------------------------
26
+ config_content = File.read(config_file)
27
+ if config_content.include?("'<<next>>'")
28
+ updated_config = config_content.gsub("'<<next>>'", "'#{version}'")
29
+ File.write(config_file, updated_config)
30
+ puts " āœ… Updated 'VersionAdded' in #{config_file}"
31
+ else
32
+ puts " āš ļø No '<<next>>' found in #{config_file} (skipping)"
33
+ end
34
+
35
+ # -------------------------------------------------------
36
+ # 3. Update lib/rubocop/vicenzo/version.rb
37
+ # -------------------------------------------------------
38
+ version_content = File.read(version_file)
39
+ # Regex looks for: VERSION = '...' or VERSION = "..."
40
+ if version_content.match?(/VERSION\s*=\s*['"](.+)['"]/)
41
+ updated_version = version_content.gsub(/VERSION\s*=\s*['"](.+)['"]/, "VERSION = '#{version}'")
42
+ File.write(version_file, updated_version)
43
+ puts " āœ… Updated VERSION constant in #{version_file}"
44
+ else
45
+ puts " āŒ Could not find VERSION constant in #{version_file}"
46
+ end
47
+
48
+ # -------------------------------------------------------
49
+ # 4. Update CHANGELOG.md
50
+ # -------------------------------------------------------
51
+ changelog_content = File.read(changelog_file)
52
+ unreleased_header = '## [Unreleased]'
53
+ date = Date.today.to_s # YYYY-MM-DD
54
+
55
+ # We replace "## [Unreleased]" with:
56
+ # ## [Unreleased]
57
+ #
58
+ # ## [version] - date
59
+ #
60
+ # This pushes the existing unreleased items down under the new version header.
61
+ new_entry_header = "#{unreleased_header}\n\n## [#{version}] - #{date}"
62
+
63
+ if changelog_content.include?(unreleased_header)
64
+ # We use 'sub' to replace only the first occurrence (the top one)
65
+ updated_changelog = changelog_content.sub(unreleased_header, new_entry_header)
66
+ File.write(changelog_file, updated_changelog)
67
+ puts " āœ… Updated #{changelog_file} (moved items to [#{version}])"
68
+ else
69
+ puts " āŒ Could not find '#{unreleased_header}' in #{changelog_file}"
70
+ end
71
+
72
+ puts "\nšŸŽ‰ Release preparation complete! Don't forget to commit."
73
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-vicenzo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Vicenzo
@@ -66,18 +66,28 @@ files:
66
66
  - LICENSE.txt
67
67
  - README.md
68
68
  - Rakefile
69
+ - antora-playbook.yml
69
70
  - config/default.yml
71
+ - docs/antora.yml
72
+ - docs/supplemental-ui/partials/header-content.hbs
70
73
  - lib/rubocop-vicenzo.rb
74
+ - lib/rubocop/cop/vicenzo/layout/multiline_method_call_line_breaks.rb
71
75
  - lib/rubocop/cop/vicenzo/rails/enum_inclusion_of_validation.rb
76
+ - lib/rubocop/cop/vicenzo/rspec/conditional_in_spec.rb
77
+ - lib/rubocop/cop/vicenzo/rspec/dynamic_example_generation.rb
72
78
  - lib/rubocop/cop/vicenzo/rspec/inconsistent_sibling_structure.rb
79
+ - lib/rubocop/cop/vicenzo/rspec/iteration_inside_example.rb
73
80
  - lib/rubocop/cop/vicenzo/rspec/leaky_definition.rb
74
81
  - lib/rubocop/cop/vicenzo/rspec/nested_context_improper_start.rb
75
82
  - lib/rubocop/cop/vicenzo/rspec/nested_let_redefinition.rb
76
83
  - lib/rubocop/cop/vicenzo/rspec/nested_subject_redefinition.rb
84
+ - lib/rubocop/cop/vicenzo/style/multiline_method_call_parentheses.rb
77
85
  - lib/rubocop/cop/vicenzo_cops.rb
78
86
  - lib/rubocop/vicenzo.rb
79
87
  - lib/rubocop/vicenzo/plugin.rb
80
88
  - lib/rubocop/vicenzo/version.rb
89
+ - rakelib/docs.rake
90
+ - rakelib/release.rake
81
91
  - sig/rubocop/vicenzo.rbs
82
92
  homepage: https://github.com/bvicenzo/rubocop-vicenzo
83
93
  licenses:
@@ -103,7 +113,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
103
113
  - !ruby/object:Gem::Version
104
114
  version: '0'
105
115
  requirements: []
106
- rubygems_version: 3.7.2
116
+ rubygems_version: 4.0.3
107
117
  specification_version: 4
108
118
  summary: Cops of Bruno Vicenzo
109
119
  test_files: []