light-services 3.0.0 → 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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +7 -1
  3. data/CHANGELOG.md +15 -0
  4. data/CLAUDE.md +1 -1
  5. data/Gemfile.lock +1 -1
  6. data/README.md +11 -11
  7. data/docs/arguments.md +23 -0
  8. data/docs/concepts.md +2 -2
  9. data/docs/configuration.md +36 -0
  10. data/docs/errors.md +31 -1
  11. data/docs/outputs.md +23 -0
  12. data/docs/quickstart.md +1 -1
  13. data/docs/readme.md +12 -11
  14. data/docs/rubocop.md +285 -0
  15. data/docs/ruby-lsp.md +133 -0
  16. data/docs/steps.md +62 -8
  17. data/docs/summary.md +2 -0
  18. data/docs/testing.md +1 -1
  19. data/lib/light/services/base.rb +109 -7
  20. data/lib/light/services/base_with_context.rb +23 -1
  21. data/lib/light/services/callbacks.rb +59 -5
  22. data/lib/light/services/collection.rb +50 -2
  23. data/lib/light/services/concerns/execution.rb +3 -0
  24. data/lib/light/services/config.rb +83 -3
  25. data/lib/light/services/constants.rb +3 -0
  26. data/lib/light/services/dsl/arguments_dsl.rb +1 -0
  27. data/lib/light/services/dsl/outputs_dsl.rb +1 -0
  28. data/lib/light/services/dsl/validation.rb +30 -0
  29. data/lib/light/services/exceptions.rb +19 -1
  30. data/lib/light/services/message.rb +28 -3
  31. data/lib/light/services/messages.rb +74 -2
  32. data/lib/light/services/rubocop/cop/light_services/argument_type_required.rb +52 -0
  33. data/lib/light/services/rubocop/cop/light_services/condition_method_exists.rb +173 -0
  34. data/lib/light/services/rubocop/cop/light_services/deprecated_methods.rb +113 -0
  35. data/lib/light/services/rubocop/cop/light_services/dsl_order.rb +176 -0
  36. data/lib/light/services/rubocop/cop/light_services/missing_private_keyword.rb +102 -0
  37. data/lib/light/services/rubocop/cop/light_services/no_direct_instantiation.rb +66 -0
  38. data/lib/light/services/rubocop/cop/light_services/output_type_required.rb +52 -0
  39. data/lib/light/services/rubocop/cop/light_services/step_method_exists.rb +109 -0
  40. data/lib/light/services/rubocop.rb +12 -0
  41. data/lib/light/services/settings/field.rb +33 -5
  42. data/lib/light/services/settings/step.rb +23 -5
  43. data/lib/light/services/version.rb +1 -1
  44. data/lib/ruby_lsp/light_services/addon.rb +36 -0
  45. data/lib/ruby_lsp/light_services/definition.rb +132 -0
  46. data/lib/ruby_lsp/light_services/indexing_enhancement.rb +263 -0
  47. metadata +15 -1
@@ -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
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module LightServices
6
+ # Ensures that all `output` declarations in Light::Services include a `type:` option.
7
+ #
8
+ # @example
9
+ # # bad
10
+ # output :result
11
+ # output :data, optional: true
12
+ # output :count, default: 0
13
+ #
14
+ # # good
15
+ # output :result, type: Hash
16
+ # output :data, type: Hash, optional: true
17
+ # output :count, type: Integer, default: 0
18
+ #
19
+ class OutputTypeRequired < Base
20
+ MSG = "Output `%<name>s` must have a `type:` option."
21
+
22
+ RESTRICT_ON_SEND = [:output].freeze
23
+
24
+ # @!method output_call?(node)
25
+ def_node_matcher :output_call?, <<~PATTERN
26
+ (send nil? :output (sym $_) ...)
27
+ PATTERN
28
+
29
+ def on_send(node)
30
+ output_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
+ # output :name (no options)
41
+ return false if node.arguments.size == 1
42
+
43
+ # output :name, type: Foo or output :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,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module LightServices
6
+ # Ensures that all `step` declarations have a corresponding method defined in the same class.
7
+ #
8
+ # Note: This cop only checks for methods defined in the same file/class. It cannot detect
9
+ # methods inherited from parent classes. Use the `ExcludedSteps` option or `rubocop:disable`
10
+ # comments for inherited steps.
11
+ #
12
+ # @example
13
+ # # bad
14
+ # class MyService < ApplicationService
15
+ # step :validate
16
+ # step :process
17
+ #
18
+ # private
19
+ #
20
+ # def validate
21
+ # # only validate is defined, process is missing
22
+ # end
23
+ # end
24
+ #
25
+ # # good
26
+ # class MyService < ApplicationService
27
+ # step :validate
28
+ # step :process
29
+ #
30
+ # private
31
+ #
32
+ # def validate
33
+ # # validation logic
34
+ # end
35
+ #
36
+ # def process
37
+ # # processing logic
38
+ # end
39
+ # end
40
+ #
41
+ # @example ExcludedSteps: ['initialize_entity', 'assign_attributes'] (default: [])
42
+ # # good - these steps are excluded from checking
43
+ # class User::Create < CreateService
44
+ # step :initialize_entity
45
+ # step :assign_attributes
46
+ # step :send_welcome_email
47
+ #
48
+ # private
49
+ #
50
+ # def send_welcome_email
51
+ # # only this method needs to be defined
52
+ # end
53
+ # end
54
+ #
55
+ class StepMethodExists < Base
56
+ MSG = "Step `%<name>s` has no corresponding method. " \
57
+ "For inherited steps, disable this line or add to ExcludedSteps."
58
+
59
+ def on_class(_node)
60
+ @step_calls = []
61
+ @defined_methods = []
62
+ end
63
+
64
+ def on_send(node)
65
+ return unless step_call?(node)
66
+
67
+ step_name = node.arguments.first&.value
68
+ return unless step_name
69
+
70
+ @step_calls ||= []
71
+ @step_calls << { name: step_name, node: node }
72
+ end
73
+
74
+ def on_def(node)
75
+ @defined_methods ||= []
76
+ @defined_methods << node.method_name
77
+ end
78
+
79
+ def after_class(_node)
80
+ return unless @step_calls&.any?
81
+
82
+ @step_calls.each do |step|
83
+ next if @defined_methods&.include?(step[:name])
84
+ next if excluded_step?(step[:name])
85
+
86
+ add_offense(step[:node], message: format(MSG, name: step[:name]))
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def step_call?(node)
93
+ node.send_type? &&
94
+ node.method_name == :step &&
95
+ node.receiver.nil? &&
96
+ node.arguments.first&.sym_type?
97
+ end
98
+
99
+ def excluded_step?(step_name)
100
+ excluded_steps.include?(step_name.to_s)
101
+ end
102
+
103
+ def excluded_steps
104
+ cop_config.fetch("ExcludedSteps", [])
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ require_relative "rubocop/cop/light_services/argument_type_required"
6
+ require_relative "rubocop/cop/light_services/condition_method_exists"
7
+ require_relative "rubocop/cop/light_services/deprecated_methods"
8
+ require_relative "rubocop/cop/light_services/dsl_order"
9
+ require_relative "rubocop/cop/light_services/missing_private_keyword"
10
+ require_relative "rubocop/cop/light_services/no_direct_instantiation"
11
+ require_relative "rubocop/cop/light_services/output_type_required"
12
+ require_relative "rubocop/cop/light_services/step_method_exists"
@@ -1,12 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Unified settings class for arguments and outputs
4
3
  module Light
5
4
  module Services
6
5
  module Settings
6
+ # Stores configuration for a single argument or output field.
7
+ # Created automatically when using the `arg` or `output` DSL methods.
7
8
  class Field
8
- attr_reader :name, :default_exists, :default, :context, :optional
9
-
9
+ # @return [Symbol] the field name
10
+ attr_reader :name
11
+
12
+ # @return [Boolean] true if a default value was specified
13
+ attr_reader :default_exists
14
+
15
+ # @return [Object, Proc, nil] the default value or proc
16
+ attr_reader :default
17
+
18
+ # @return [Boolean, nil] true if this is a context argument
19
+ attr_reader :context
20
+
21
+ # @return [Boolean, nil] true if nil values are allowed
22
+ attr_reader :optional
23
+
24
+ # Initialize a new field definition.
25
+ #
26
+ # @param name [Symbol] the field name
27
+ # @param service_class [Class] the service class this field belongs to
28
+ # @param opts [Hash] field options
29
+ # @option opts [Class, Array<Class>] :type type(s) to validate against
30
+ # @option opts [Boolean] :optional whether nil is allowed
31
+ # @option opts [Object, Proc] :default default value or proc
32
+ # @option opts [Boolean] :context whether to pass to child services
33
+ # @option opts [Symbol] :field_type :argument or :output
10
34
  def initialize(name, service_class, opts = {})
11
35
  @name = name
12
36
  @service_class = service_class
@@ -21,8 +45,12 @@ module Light
21
45
  define_methods
22
46
  end
23
47
 
24
- # Validate and optionally coerce the value
25
- # Returns the (possibly coerced) value
48
+ # Validate a value against the field's type definition.
49
+ # Supports both Ruby class types and dry-types.
50
+ #
51
+ # @param value [Object] the value to validate
52
+ # @return [Object] the value (possibly coerced by dry-types)
53
+ # @raise [ArgTypeError] if the value doesn't match the expected type
26
54
  def validate_type!(value)
27
55
  return value unless @type
28
56