light-services 2.2.1 → 3.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.
- checksums.yaml +4 -4
- data/.github/config/rubocop_linter_action.yml +4 -4
- data/.github/workflows/ci.yml +12 -12
- data/.gitignore +1 -0
- data/.rubocop.yml +83 -7
- data/CHANGELOG.md +38 -0
- data/CLAUDE.md +139 -0
- data/Gemfile +16 -11
- data/Gemfile.lock +53 -27
- data/README.md +84 -21
- data/docs/arguments.md +290 -0
- data/docs/best-practices.md +153 -0
- data/docs/callbacks.md +476 -0
- data/docs/concepts.md +80 -0
- data/docs/configuration.md +204 -0
- data/docs/context.md +128 -0
- data/docs/crud.md +525 -0
- data/docs/errors.md +280 -0
- data/docs/generators.md +250 -0
- data/docs/outputs.md +158 -0
- data/docs/pundit-authorization.md +320 -0
- data/docs/quickstart.md +134 -0
- data/docs/readme.md +101 -0
- data/docs/recipes.md +14 -0
- data/docs/rubocop.md +285 -0
- data/docs/ruby-lsp.md +133 -0
- data/docs/service-rendering.md +222 -0
- data/docs/steps.md +391 -0
- data/docs/summary.md +21 -0
- data/docs/testing.md +549 -0
- data/lib/generators/light_services/install/USAGE +15 -0
- data/lib/generators/light_services/install/install_generator.rb +41 -0
- data/lib/generators/light_services/install/templates/application_service.rb.tt +8 -0
- data/lib/generators/light_services/install/templates/application_service_spec.rb.tt +7 -0
- data/lib/generators/light_services/install/templates/initializer.rb.tt +30 -0
- data/lib/generators/light_services/service/USAGE +21 -0
- data/lib/generators/light_services/service/service_generator.rb +68 -0
- data/lib/generators/light_services/service/templates/service.rb.tt +48 -0
- data/lib/generators/light_services/service/templates/service_spec.rb.tt +40 -0
- data/lib/light/services/base.rb +134 -122
- data/lib/light/services/base_with_context.rb +23 -1
- data/lib/light/services/callbacks.rb +157 -0
- data/lib/light/services/collection.rb +145 -0
- data/lib/light/services/concerns/execution.rb +79 -0
- data/lib/light/services/concerns/parent_service.rb +34 -0
- data/lib/light/services/concerns/state_management.rb +30 -0
- data/lib/light/services/config.rb +82 -16
- data/lib/light/services/constants.rb +100 -0
- data/lib/light/services/dsl/arguments_dsl.rb +85 -0
- data/lib/light/services/dsl/outputs_dsl.rb +81 -0
- data/lib/light/services/dsl/steps_dsl.rb +205 -0
- data/lib/light/services/dsl/validation.rb +162 -0
- data/lib/light/services/exceptions.rb +25 -2
- data/lib/light/services/message.rb +28 -3
- data/lib/light/services/messages.rb +92 -32
- data/lib/light/services/rspec/matchers/define_argument.rb +174 -0
- data/lib/light/services/rspec/matchers/define_output.rb +147 -0
- data/lib/light/services/rspec/matchers/define_step.rb +225 -0
- data/lib/light/services/rspec/matchers/execute_step.rb +230 -0
- data/lib/light/services/rspec/matchers/have_error_on.rb +148 -0
- data/lib/light/services/rspec/matchers/have_warning_on.rb +148 -0
- data/lib/light/services/rspec/matchers/trigger_callback.rb +138 -0
- data/lib/light/services/rspec.rb +15 -0
- data/lib/light/services/rubocop/cop/light_services/argument_type_required.rb +52 -0
- data/lib/light/services/rubocop/cop/light_services/condition_method_exists.rb +173 -0
- data/lib/light/services/rubocop/cop/light_services/deprecated_methods.rb +113 -0
- data/lib/light/services/rubocop/cop/light_services/dsl_order.rb +176 -0
- data/lib/light/services/rubocop/cop/light_services/missing_private_keyword.rb +102 -0
- data/lib/light/services/rubocop/cop/light_services/no_direct_instantiation.rb +66 -0
- data/lib/light/services/rubocop/cop/light_services/output_type_required.rb +52 -0
- data/lib/light/services/rubocop/cop/light_services/step_method_exists.rb +109 -0
- data/lib/light/services/rubocop.rb +12 -0
- data/lib/light/services/settings/field.rb +114 -0
- data/lib/light/services/settings/step.rb +53 -20
- data/lib/light/services/utils.rb +38 -0
- data/lib/light/services/version.rb +1 -1
- data/lib/light/services.rb +2 -0
- data/lib/ruby_lsp/light_services/addon.rb +36 -0
- data/lib/ruby_lsp/light_services/definition.rb +132 -0
- data/lib/ruby_lsp/light_services/indexing_enhancement.rb +263 -0
- data/light-services.gemspec +6 -8
- metadata +68 -26
- data/lib/light/services/class_based_collection/base.rb +0 -86
- data/lib/light/services/class_based_collection/mount.rb +0 -33
- data/lib/light/services/collection/arguments.rb +0 -34
- data/lib/light/services/collection/base.rb +0 -59
- data/lib/light/services/collection/outputs.rb +0 -16
- data/lib/light/services/settings/argument.rb +0 -68
- data/lib/light/services/settings/output.rb +0 -34
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module LightServices
|
|
6
|
+
# Ensures that all `arg` declarations in Light::Services include a `type:` option.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# # bad
|
|
10
|
+
# arg :user_id
|
|
11
|
+
# arg :params, default: {}
|
|
12
|
+
# arg :name, optional: true
|
|
13
|
+
#
|
|
14
|
+
# # good
|
|
15
|
+
# arg :user_id, type: Integer
|
|
16
|
+
# arg :params, type: Hash, default: {}
|
|
17
|
+
# arg :name, type: String, optional: true
|
|
18
|
+
#
|
|
19
|
+
class ArgumentTypeRequired < Base
|
|
20
|
+
MSG = "Argument `%<name>s` must have a `type:` option."
|
|
21
|
+
|
|
22
|
+
RESTRICT_ON_SEND = [:arg].freeze
|
|
23
|
+
|
|
24
|
+
# @!method arg_call?(node)
|
|
25
|
+
def_node_matcher :arg_call?, <<~PATTERN
|
|
26
|
+
(send nil? :arg (sym $_) ...)
|
|
27
|
+
PATTERN
|
|
28
|
+
|
|
29
|
+
def on_send(node)
|
|
30
|
+
arg_call?(node) do |name|
|
|
31
|
+
return if has_type_option?(node)
|
|
32
|
+
|
|
33
|
+
add_offense(node, message: format(MSG, name: name))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def has_type_option?(node)
|
|
40
|
+
# arg :name (no options)
|
|
41
|
+
return false if node.arguments.size == 1
|
|
42
|
+
|
|
43
|
+
# arg :name, type: Foo or arg :name, { type: Foo }
|
|
44
|
+
opts_node = node.arguments[1]
|
|
45
|
+
return false unless opts_node&.hash_type?
|
|
46
|
+
|
|
47
|
+
opts_node.pairs.any? { |pair| pair.key.sym_type? && pair.key.value == :type }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module LightServices
|
|
6
|
+
# Ensures that symbol conditions in `step` declarations (`:if` and `:unless`)
|
|
7
|
+
# have corresponding methods defined in the same class.
|
|
8
|
+
#
|
|
9
|
+
# This cop automatically recognizes:
|
|
10
|
+
# - Methods defined with `def`
|
|
11
|
+
# - Predicate methods from `arg` and `output` (e.g., `arg :user` creates `user` and `user?`)
|
|
12
|
+
# - Methods from `attr_reader`, `attr_accessor`, and `attr_writer`
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# # bad
|
|
16
|
+
# class MyService < ApplicationService
|
|
17
|
+
# step :process, if: :should_process?
|
|
18
|
+
#
|
|
19
|
+
# private
|
|
20
|
+
#
|
|
21
|
+
# def process
|
|
22
|
+
# # should_process? is missing
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# # good - explicit method
|
|
27
|
+
# class MyService < ApplicationService
|
|
28
|
+
# step :process, if: :should_process?
|
|
29
|
+
#
|
|
30
|
+
# private
|
|
31
|
+
#
|
|
32
|
+
# def process; end
|
|
33
|
+
# def should_process?; true; end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# # good - predicate from arg
|
|
37
|
+
# class MyService < ApplicationService
|
|
38
|
+
# arg :user, type: User, optional: true
|
|
39
|
+
#
|
|
40
|
+
# step :greet, if: :user? # user? is auto-generated by arg :user
|
|
41
|
+
#
|
|
42
|
+
# private
|
|
43
|
+
#
|
|
44
|
+
# def greet; end
|
|
45
|
+
# end
|
|
46
|
+
#
|
|
47
|
+
# # good - attr_reader
|
|
48
|
+
# class MyService < ApplicationService
|
|
49
|
+
# attr_reader :enabled
|
|
50
|
+
#
|
|
51
|
+
# step :process, if: :enabled
|
|
52
|
+
#
|
|
53
|
+
# private
|
|
54
|
+
#
|
|
55
|
+
# def process; end
|
|
56
|
+
# end
|
|
57
|
+
#
|
|
58
|
+
# @example ExcludedMethods: ['admin?', 'guest?'] (default: [])
|
|
59
|
+
# # good - these methods are excluded from checking
|
|
60
|
+
# class MyService < ApplicationService
|
|
61
|
+
# step :admin_action, if: :admin? # excluded via config
|
|
62
|
+
# end
|
|
63
|
+
#
|
|
64
|
+
class ConditionMethodExists < Base
|
|
65
|
+
MSG = "Condition method `%<name>s` has no corresponding method. " \
|
|
66
|
+
"For inherited methods, disable this line or add to ExcludedMethods."
|
|
67
|
+
|
|
68
|
+
CONDITION_KEYS = [:if, :unless].freeze
|
|
69
|
+
ATTR_METHODS = [:attr_reader, :attr_accessor, :attr_writer].freeze
|
|
70
|
+
|
|
71
|
+
def on_class(_node)
|
|
72
|
+
@condition_methods = []
|
|
73
|
+
@defined_methods = []
|
|
74
|
+
@dsl_predicates = []
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def on_send(node)
|
|
78
|
+
if step_call?(node)
|
|
79
|
+
collect_condition_methods(node)
|
|
80
|
+
elsif arg_or_output_call?(node)
|
|
81
|
+
collect_dsl_predicate(node)
|
|
82
|
+
elsif attr_method_call?(node)
|
|
83
|
+
collect_attr_methods(node)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def on_def(node)
|
|
88
|
+
@defined_methods ||= []
|
|
89
|
+
@defined_methods << node.method_name
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def after_class(_node)
|
|
93
|
+
return unless @condition_methods&.any?
|
|
94
|
+
|
|
95
|
+
@condition_methods.each do |condition|
|
|
96
|
+
next if method_available?(condition[:name])
|
|
97
|
+
next if excluded_method?(condition[:name])
|
|
98
|
+
|
|
99
|
+
add_offense(condition[:node], message: format(MSG, name: condition[:name]))
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def step_call?(node)
|
|
106
|
+
node.send_type? &&
|
|
107
|
+
node.method_name == :step &&
|
|
108
|
+
node.receiver.nil? &&
|
|
109
|
+
node.arguments.first&.sym_type?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def arg_or_output_call?(node)
|
|
113
|
+
node.send_type? &&
|
|
114
|
+
[:arg, :output].include?(node.method_name) &&
|
|
115
|
+
node.receiver.nil? &&
|
|
116
|
+
node.arguments.first&.sym_type?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def attr_method_call?(node)
|
|
120
|
+
node.send_type? &&
|
|
121
|
+
ATTR_METHODS.include?(node.method_name) &&
|
|
122
|
+
node.receiver.nil?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def collect_condition_methods(node)
|
|
126
|
+
@condition_methods ||= []
|
|
127
|
+
|
|
128
|
+
opts_node = node.arguments[1]
|
|
129
|
+
return unless opts_node&.hash_type?
|
|
130
|
+
|
|
131
|
+
opts_node.pairs.each do |pair|
|
|
132
|
+
key = pair.key
|
|
133
|
+
value = pair.value
|
|
134
|
+
|
|
135
|
+
next unless key.sym_type? && CONDITION_KEYS.include?(key.value)
|
|
136
|
+
next unless value.sym_type?
|
|
137
|
+
|
|
138
|
+
@condition_methods << { name: value.value, node: value }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def collect_dsl_predicate(node)
|
|
143
|
+
@dsl_predicates ||= []
|
|
144
|
+
|
|
145
|
+
field_name = node.arguments.first.value
|
|
146
|
+
@dsl_predicates += [field_name, :"#{field_name}?"]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def collect_attr_methods(node)
|
|
150
|
+
@defined_methods ||= []
|
|
151
|
+
|
|
152
|
+
node.arguments.each do |arg|
|
|
153
|
+
next unless arg.sym_type?
|
|
154
|
+
|
|
155
|
+
@defined_methods << arg.value
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def method_available?(method_name)
|
|
160
|
+
@defined_methods&.include?(method_name) || @dsl_predicates&.include?(method_name)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def excluded_method?(method_name)
|
|
164
|
+
excluded_methods.include?(method_name.to_s)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def excluded_methods
|
|
168
|
+
cop_config.fetch("ExcludedMethods", [])
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module LightServices
|
|
6
|
+
# Detects deprecated `done!` and `done?` method calls and suggests
|
|
7
|
+
# using `stop!` and `stopped?` instead.
|
|
8
|
+
#
|
|
9
|
+
# This cop checks calls inside service classes that inherit from
|
|
10
|
+
# Light::Services::Base or any configured base service classes.
|
|
11
|
+
#
|
|
12
|
+
# @safety
|
|
13
|
+
# This cop's autocorrection is safe as `done!` and `done?` are
|
|
14
|
+
# direct aliases for `stop!` and `stopped?`.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# # bad
|
|
18
|
+
# class User::Create < ApplicationService
|
|
19
|
+
# step :process
|
|
20
|
+
#
|
|
21
|
+
# private
|
|
22
|
+
#
|
|
23
|
+
# def process
|
|
24
|
+
# done! if condition_met?
|
|
25
|
+
# return if done?
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# # good
|
|
30
|
+
# class User::Create < ApplicationService
|
|
31
|
+
# step :process
|
|
32
|
+
#
|
|
33
|
+
# private
|
|
34
|
+
#
|
|
35
|
+
# def process
|
|
36
|
+
# stop! if condition_met?
|
|
37
|
+
# return if stopped?
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
class DeprecatedMethods < Base
|
|
42
|
+
extend AutoCorrector
|
|
43
|
+
|
|
44
|
+
MSG_DONE_BANG = "Use `stop!` instead of deprecated `done!`."
|
|
45
|
+
MSG_DONE_QUERY = "Use `stopped?` instead of deprecated `done?`."
|
|
46
|
+
|
|
47
|
+
RESTRICT_ON_SEND = [:done!, :done?].freeze
|
|
48
|
+
|
|
49
|
+
REPLACEMENTS = {
|
|
50
|
+
done!: :stop!,
|
|
51
|
+
done?: :stopped?,
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
DEFAULT_BASE_CLASSES = ["ApplicationService"].freeze
|
|
55
|
+
|
|
56
|
+
def on_class(node)
|
|
57
|
+
@in_service_class = service_class?(node)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def after_class(_node)
|
|
61
|
+
@in_service_class = false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def on_send(node)
|
|
65
|
+
return unless @in_service_class
|
|
66
|
+
return unless RESTRICT_ON_SEND.include?(node.method_name)
|
|
67
|
+
return if node.receiver && !self_receiver?(node)
|
|
68
|
+
|
|
69
|
+
message = node.method_name == :done! ? MSG_DONE_BANG : MSG_DONE_QUERY
|
|
70
|
+
replacement = REPLACEMENTS[node.method_name]
|
|
71
|
+
|
|
72
|
+
add_offense(node, message: message) do |corrector|
|
|
73
|
+
if node.receiver
|
|
74
|
+
corrector.replace(node, "self.#{replacement}")
|
|
75
|
+
else
|
|
76
|
+
corrector.replace(node, replacement.to_s)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def service_class?(node)
|
|
84
|
+
return false unless node.parent_class
|
|
85
|
+
|
|
86
|
+
parent_class_name = extract_class_name(node.parent_class)
|
|
87
|
+
return false unless parent_class_name
|
|
88
|
+
|
|
89
|
+
# Check for direct Light::Services::Base inheritance
|
|
90
|
+
return true if parent_class_name == "Light::Services::Base"
|
|
91
|
+
|
|
92
|
+
# Check against configured base service classes
|
|
93
|
+
base_classes = cop_config.fetch("BaseServiceClasses", DEFAULT_BASE_CLASSES)
|
|
94
|
+
base_classes.include?(parent_class_name)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def extract_class_name(node)
|
|
98
|
+
case node.type
|
|
99
|
+
when :const
|
|
100
|
+
node.const_name
|
|
101
|
+
when :send
|
|
102
|
+
# For namespaced constants like Light::Services::Base
|
|
103
|
+
node.source
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self_receiver?(node)
|
|
108
|
+
node.receiver&.self_type?
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module LightServices
|
|
6
|
+
# Enforces a consistent order for DSL declarations in service classes.
|
|
7
|
+
#
|
|
8
|
+
# The expected order is: `config` → `arg` → `step` → `output`
|
|
9
|
+
#
|
|
10
|
+
# @safety
|
|
11
|
+
# This cop's autocorrection is safe but may change the visual grouping of your code.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# # bad
|
|
15
|
+
# class MyService < ApplicationService
|
|
16
|
+
# step :process
|
|
17
|
+
# arg :name, type: String
|
|
18
|
+
# output :result, type: Hash
|
|
19
|
+
# config raise_on_error: true
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# # good
|
|
23
|
+
# class MyService < ApplicationService
|
|
24
|
+
# config raise_on_error: true
|
|
25
|
+
#
|
|
26
|
+
# arg :name, type: String
|
|
27
|
+
#
|
|
28
|
+
# step :process
|
|
29
|
+
#
|
|
30
|
+
# output :result, type: Hash
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
class DslOrder < Base
|
|
34
|
+
extend AutoCorrector
|
|
35
|
+
|
|
36
|
+
MSG = "`%<current>s` should come before `%<previous>s`. " \
|
|
37
|
+
"Expected order: config → arg → step → output."
|
|
38
|
+
|
|
39
|
+
DSL_METHODS = [:config, :arg, :step, :output].freeze
|
|
40
|
+
DSL_ORDER = { config: 0, arg: 1, step: 2, output: 3 }.freeze
|
|
41
|
+
|
|
42
|
+
def on_class(node)
|
|
43
|
+
@dsl_calls = []
|
|
44
|
+
@class_node = node
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def on_send(node)
|
|
48
|
+
return unless dsl_call?(node)
|
|
49
|
+
|
|
50
|
+
@dsl_calls ||= []
|
|
51
|
+
@dsl_calls << { method: node.method_name, node: node }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def after_class(_node)
|
|
55
|
+
return unless @dsl_calls&.any?
|
|
56
|
+
|
|
57
|
+
check_order
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def dsl_call?(node)
|
|
63
|
+
node.send_type? &&
|
|
64
|
+
node.receiver.nil? &&
|
|
65
|
+
DSL_METHODS.include?(node.method_name)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def check_order
|
|
69
|
+
highest_order_seen = -1
|
|
70
|
+
highest_method_seen = nil
|
|
71
|
+
has_offense = false
|
|
72
|
+
|
|
73
|
+
@dsl_calls.each do |call|
|
|
74
|
+
current_order = DSL_ORDER[call[:method]]
|
|
75
|
+
|
|
76
|
+
if current_order < highest_order_seen
|
|
77
|
+
has_offense = true
|
|
78
|
+
add_offense(
|
|
79
|
+
call[:node],
|
|
80
|
+
message: format(MSG, current: call[:method], previous: highest_method_seen),
|
|
81
|
+
) do |corrector|
|
|
82
|
+
reorder_dsl_declarations(corrector) unless @corrected
|
|
83
|
+
@corrected = true
|
|
84
|
+
end
|
|
85
|
+
elsif current_order > highest_order_seen
|
|
86
|
+
highest_order_seen = current_order
|
|
87
|
+
highest_method_seen = call[:method]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
@corrected = false if has_offense
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def reorder_dsl_declarations(corrector) # rubocop:disable Metrics/AbcSize
|
|
95
|
+
# Collect all DSL nodes with their source including leading comments
|
|
96
|
+
dsl_sources = @dsl_calls.map do |call|
|
|
97
|
+
{
|
|
98
|
+
method: call[:method],
|
|
99
|
+
node: call[:node],
|
|
100
|
+
source: source_with_leading_comment(call[:node]),
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Sort by expected order
|
|
105
|
+
sorted_sources = dsl_sources.sort_by { |item| DSL_ORDER[item[:method]] }
|
|
106
|
+
|
|
107
|
+
# Group by type to add blank lines between groups
|
|
108
|
+
grouped_source = build_grouped_source(sorted_sources)
|
|
109
|
+
|
|
110
|
+
# Calculate the range to replace (from first DSL to last DSL)
|
|
111
|
+
first_node = @dsl_calls.min_by { |c| c[:node].loc.expression.begin_pos }[:node]
|
|
112
|
+
last_node = @dsl_calls.max_by { |c| c[:node].loc.expression.end_pos }[:node]
|
|
113
|
+
|
|
114
|
+
# Get the range including leading comments of first node
|
|
115
|
+
start_pos = first_node.loc.expression.begin_pos
|
|
116
|
+
leading_comment = leading_comment_for(first_node)
|
|
117
|
+
start_pos = leading_comment.loc.expression.begin_pos if leading_comment
|
|
118
|
+
|
|
119
|
+
# Find the beginning of the line for proper replacement
|
|
120
|
+
start_pos = beginning_of_line(start_pos)
|
|
121
|
+
end_pos = end_of_line(last_node.loc.expression.end_pos)
|
|
122
|
+
|
|
123
|
+
range = range_between(start_pos, end_pos)
|
|
124
|
+
corrector.replace(range, grouped_source)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def source_with_leading_comment(node)
|
|
128
|
+
comment = leading_comment_for(node)
|
|
129
|
+
indent = " " * node.loc.column
|
|
130
|
+
|
|
131
|
+
if comment
|
|
132
|
+
"#{indent}#{comment.text}\n#{indent}#{node.source}"
|
|
133
|
+
else
|
|
134
|
+
"#{indent}#{node.source}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def leading_comment_for(node)
|
|
139
|
+
processed_source.comments.find do |comment|
|
|
140
|
+
comment.loc.line == node.loc.line - 1
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def build_grouped_source(sorted_sources)
|
|
145
|
+
result = []
|
|
146
|
+
current_type = nil
|
|
147
|
+
|
|
148
|
+
sorted_sources.each do |item|
|
|
149
|
+
# Add blank line when switching to a new DSL type
|
|
150
|
+
result << "" if current_type && current_type != item[:method]
|
|
151
|
+
current_type = item[:method]
|
|
152
|
+
result << item[:source]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
result.join("\n")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def beginning_of_line(pos)
|
|
159
|
+
source = processed_source.buffer.source
|
|
160
|
+
pos -= 1 while pos > 0 && source[pos - 1] != "\n"
|
|
161
|
+
pos
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def end_of_line(pos)
|
|
165
|
+
source = processed_source.buffer.source
|
|
166
|
+
pos += 1 while pos < source.length && source[pos] != "\n"
|
|
167
|
+
pos
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def range_between(start_pos, end_pos)
|
|
171
|
+
Parser::Source::Range.new(processed_source.buffer, start_pos, end_pos)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module LightServices
|
|
6
|
+
# Ensures that step methods are defined as private.
|
|
7
|
+
# Step methods are implementation details and should not be part of the public API.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# # bad
|
|
11
|
+
# class MyService < ApplicationService
|
|
12
|
+
# step :process
|
|
13
|
+
# step :notify
|
|
14
|
+
#
|
|
15
|
+
# def process
|
|
16
|
+
# # This should be private
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# def notify
|
|
20
|
+
# # This should be private
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# # good
|
|
25
|
+
# class MyService < ApplicationService
|
|
26
|
+
# step :process
|
|
27
|
+
# step :notify
|
|
28
|
+
#
|
|
29
|
+
# private
|
|
30
|
+
#
|
|
31
|
+
# def process
|
|
32
|
+
# # Now private
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# def notify
|
|
36
|
+
# # Now private
|
|
37
|
+
# end
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
class MissingPrivateKeyword < Base
|
|
41
|
+
MSG = "Step method `%<name>s` should be private."
|
|
42
|
+
|
|
43
|
+
def on_class(_node)
|
|
44
|
+
@step_names = []
|
|
45
|
+
@private_section_started = false
|
|
46
|
+
@public_step_methods = []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def on_send(node)
|
|
50
|
+
if step_call?(node)
|
|
51
|
+
step_name = node.arguments.first&.value
|
|
52
|
+
@step_names ||= []
|
|
53
|
+
@step_names << step_name if step_name
|
|
54
|
+
elsif private_declaration?(node)
|
|
55
|
+
@private_section_started = true
|
|
56
|
+
elsif public_declaration?(node)
|
|
57
|
+
@private_section_started = false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def on_def(node)
|
|
62
|
+
return unless @step_names&.include?(node.method_name)
|
|
63
|
+
return if @private_section_started
|
|
64
|
+
|
|
65
|
+
@public_step_methods ||= []
|
|
66
|
+
@public_step_methods << node
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def after_class(_node)
|
|
70
|
+
return unless @public_step_methods&.any?
|
|
71
|
+
|
|
72
|
+
@public_step_methods.each do |node|
|
|
73
|
+
add_offense(node, message: format(MSG, name: node.method_name))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def step_call?(node)
|
|
80
|
+
node.send_type? &&
|
|
81
|
+
node.method_name == :step &&
|
|
82
|
+
node.receiver.nil? &&
|
|
83
|
+
node.arguments.first&.sym_type?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def private_declaration?(node)
|
|
87
|
+
node.send_type? &&
|
|
88
|
+
node.method_name == :private &&
|
|
89
|
+
node.receiver.nil? &&
|
|
90
|
+
node.arguments.empty?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def public_declaration?(node)
|
|
94
|
+
node.send_type? &&
|
|
95
|
+
node.method_name == :public &&
|
|
96
|
+
node.receiver.nil? &&
|
|
97
|
+
node.arguments.empty?
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module LightServices
|
|
6
|
+
# Prevents direct instantiation of service classes with `.new`.
|
|
7
|
+
# Services should be called using `.run`, `.run!`, or `.call`.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# # bad
|
|
11
|
+
# UserService.new(name: "John")
|
|
12
|
+
# User::Create.new(params: {})
|
|
13
|
+
#
|
|
14
|
+
# # good
|
|
15
|
+
# UserService.run(name: "John")
|
|
16
|
+
# UserService.run!(name: "John")
|
|
17
|
+
# UserService.call(name: "John")
|
|
18
|
+
# User::Create.run(params: {})
|
|
19
|
+
#
|
|
20
|
+
# @example ServicePattern: 'Service$' (default)
|
|
21
|
+
# # Matches class names ending with "Service"
|
|
22
|
+
# UserService.new # offense
|
|
23
|
+
# UserCreator.new # no offense (doesn't match pattern)
|
|
24
|
+
#
|
|
25
|
+
# @example ServicePattern: '(Service|Creator)$'
|
|
26
|
+
# # Matches class names ending with "Service" or "Creator"
|
|
27
|
+
# UserService.new # offense
|
|
28
|
+
# UserCreator.new # offense
|
|
29
|
+
#
|
|
30
|
+
class NoDirectInstantiation < Base
|
|
31
|
+
MSG = "Use `.run`, `.run!`, or `.call` instead of `.new` for service classes."
|
|
32
|
+
|
|
33
|
+
RESTRICT_ON_SEND = [:new].freeze
|
|
34
|
+
|
|
35
|
+
def on_send(node)
|
|
36
|
+
return unless node.method_name == :new
|
|
37
|
+
return unless service_class?(node.receiver)
|
|
38
|
+
|
|
39
|
+
add_offense(node)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def service_class?(node)
|
|
45
|
+
return false unless node
|
|
46
|
+
|
|
47
|
+
class_name = extract_class_name(node)
|
|
48
|
+
return false unless class_name
|
|
49
|
+
|
|
50
|
+
pattern = cop_config.fetch("ServicePattern", "Service$")
|
|
51
|
+
class_name.match?(Regexp.new(pattern))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def extract_class_name(node)
|
|
55
|
+
case node.type
|
|
56
|
+
when :const
|
|
57
|
+
node.const_name
|
|
58
|
+
when :send
|
|
59
|
+
# For chained constants like User::Create
|
|
60
|
+
node.source
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|