light-services 3.0.0 → 3.1.1

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 +21 -0
  4. data/CLAUDE.md +1 -1
  5. data/Gemfile.lock +1 -1
  6. data/README.md +11 -11
  7. data/docs/{readme.md → README.md} +12 -11
  8. data/docs/{summary.md → SUMMARY.md} +11 -1
  9. data/docs/arguments.md +23 -0
  10. data/docs/concepts.md +19 -19
  11. data/docs/configuration.md +36 -0
  12. data/docs/errors.md +31 -1
  13. data/docs/outputs.md +23 -0
  14. data/docs/quickstart.md +1 -1
  15. data/docs/rubocop.md +285 -0
  16. data/docs/ruby-lsp.md +133 -0
  17. data/docs/steps.md +62 -8
  18. data/docs/testing.md +1 -1
  19. data/lib/light/services/base.rb +110 -7
  20. data/lib/light/services/base_with_context.rb +23 -1
  21. data/lib/light/services/callbacks.rb +293 -41
  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 +17 -3
@@ -126,6 +126,36 @@ module Light
126
126
  # Check inherited steps
127
127
  (service_class.superclass.respond_to?(:steps) && service_class.superclass.steps.key?(name_sym))
128
128
  end
129
+
130
+ # Validate that the type option is provided when require_type is enabled
131
+ #
132
+ # @param name [Symbol] the field name
133
+ # @param field_type [Symbol] the type of field (:argument, :output)
134
+ # @param service_class [Class] the service class for error messages
135
+ # @param opts [Hash] the options hash to check for type
136
+ def self.validate_type_required!(name, field_type, service_class, opts)
137
+ return if opts.key?(:type)
138
+ return unless require_type_enabled?(service_class)
139
+
140
+ raise Light::Services::MissingTypeError,
141
+ "#{field_type.to_s.capitalize} `#{name}` in #{service_class} must have a type specified " \
142
+ "(require_type is enabled)"
143
+ end
144
+
145
+ # Check if require_type is enabled for the service class
146
+ def self.require_type_enabled?(service_class)
147
+ # Check class-level config in the inheritance chain, then fall back to global config
148
+ klass = service_class
149
+ while klass.respond_to?(:class_config)
150
+ class_config = klass.class_config
151
+
152
+ return class_config[:require_type] if class_config&.key?(:require_type)
153
+
154
+ klass = klass.superclass
155
+ end
156
+
157
+ Light::Services.config.require_type
158
+ end
129
159
  end
130
160
  end
131
161
  end
@@ -2,14 +2,32 @@
2
2
 
3
3
  module Light
4
4
  module Services
5
+ # Base exception class for all Light::Services errors.
5
6
  class Error < StandardError; end
7
+
8
+ # Raised when an argument or output value doesn't match the expected type.
6
9
  class ArgTypeError < Error; end
10
+
11
+ # Raised when using a reserved name for an argument, output, or step.
7
12
  class ReservedNameError < Error; end
13
+
14
+ # Raised when a name is invalid (e.g., not a Symbol).
8
15
  class InvalidNameError < Error; end
16
+
17
+ # Raised when a service has no steps defined and no run method.
9
18
  class NoStepsError < Error; end
10
19
 
11
- # Backwards compatibility aliases (deprecated)
20
+ # Raised when type is required but not specified for an argument or output.
21
+ class MissingTypeError < Error; end
22
+
23
+ # Control flow exception for stop_immediately!
24
+ # Not an error - used to halt execution gracefully.
25
+ class StopExecution < StandardError; end
26
+
27
+ # @deprecated Use {Error} instead
12
28
  NoStepError = Error
29
+
30
+ # @deprecated Use {Error} instead
13
31
  TwoConditions = Error
14
32
  end
15
33
  end
@@ -1,26 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # This class stores errors and warnings
4
3
  module Light
5
4
  module Services
5
+ # Represents a single error or warning message.
6
+ #
7
+ # @example Creating a message
8
+ # message = Message.new(:name, "can't be blank", break: true)
9
+ # message.key # => :name
10
+ # message.text # => "can't be blank"
11
+ # message.break? # => true
6
12
  class Message
7
- # Getters
8
- attr_reader :key, :text
13
+ # @return [Symbol] the key/field this message belongs to
14
+ attr_reader :key
9
15
 
16
+ # @return [String] the message text
17
+ attr_reader :text
18
+
19
+ # Create a new message.
20
+ #
21
+ # @param key [Symbol] the key/field this message belongs to
22
+ # @param text [String] the message text
23
+ # @param opts [Hash] additional options
24
+ # @option opts [Boolean] :break whether to stop step execution
25
+ # @option opts [Boolean] :rollback whether to rollback the transaction
10
26
  def initialize(key, text, opts = {})
11
27
  @key = key
12
28
  @text = text
13
29
  @opts = opts
14
30
  end
15
31
 
32
+ # Check if this message should stop step execution.
33
+ #
34
+ # @return [Boolean] true if break option was set
16
35
  def break?
17
36
  @opts[:break]
18
37
  end
19
38
 
39
+ # Check if this message should trigger a transaction rollback.
40
+ #
41
+ # @return [Boolean] true if rollback option was set
20
42
  def rollback?
21
43
  @opts[:rollback]
22
44
  end
23
45
 
46
+ # Return the message text.
47
+ #
48
+ # @return [String] the message text
24
49
  def to_s
25
50
  text
26
51
  end
@@ -1,25 +1,79 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # This class stores errors and warnings
4
3
  module Light
5
4
  module Services
5
+ # Collection of error or warning messages, organized by key.
6
+ #
7
+ # @example Adding and accessing errors
8
+ # errors.add(:name, "can't be blank")
9
+ # errors.add(:email, "is invalid")
10
+ # errors[:name] # => [#<Message key: :name, text: "can't be blank">]
11
+ # errors.to_h # => { name: ["can't be blank"], email: ["is invalid"] }
6
12
  class Messages
7
13
  extend Forwardable
8
14
 
15
+ # @!method [](key)
16
+ # Get messages for a specific key.
17
+ # @param key [Symbol] the key to look up
18
+ # @return [Array<Message>, nil] array of messages or nil
19
+
20
+ # @!method any?
21
+ # Check if there are any messages.
22
+ # @return [Boolean] true if messages exist
23
+
24
+ # @!method empty?
25
+ # Check if the collection is empty.
26
+ # @return [Boolean] true if no messages
27
+
28
+ # @!method size
29
+ # Get number of keys with messages.
30
+ # @return [Integer] number of keys
31
+
32
+ # @!method keys
33
+ # Get all keys with messages.
34
+ # @return [Array<Symbol>] array of keys
35
+
36
+ # @!method key?(key)
37
+ # Check if a key has messages.
38
+ # @param key [Symbol] the key to check
39
+ # @return [Boolean] true if key has messages
9
40
  def_delegators :@messages, :[], :any?, :empty?, :size, :keys, :values, :each, :each_with_index, :key?
10
41
  alias has_key? key?
11
42
 
43
+ # Initialize a new messages collection.
44
+ #
45
+ # @param config [Hash] configuration options
46
+ # @option config [Boolean] :break_on_add stop execution when message added
47
+ # @option config [Boolean] :raise_on_add raise exception when message added
48
+ # @option config [Boolean] :rollback_on_add rollback transaction when message added
12
49
  def initialize(config)
13
50
  @break = false
14
51
  @config = config
15
52
  @messages = {}
16
53
  end
17
54
 
18
- # Returns total count of all messages across all keys
55
+ # Get total count of all messages across all keys.
56
+ #
57
+ # @return [Integer] total number of messages
19
58
  def count
20
59
  @messages.values.sum(&:size)
21
60
  end
22
61
 
62
+ # Add a message to the collection.
63
+ #
64
+ # @param key [Symbol] the key/field for this message
65
+ # @param texts [String, Array<String>, Message] the message text(s) to add
66
+ # @param opts [Hash] additional options
67
+ # @option opts [Boolean] :break override break behavior for this message
68
+ # @option opts [Boolean] :rollback override rollback behavior for this message
69
+ # @return [void]
70
+ # @raise [Error] if text is nil or empty
71
+ #
72
+ # @example Add a single error
73
+ # errors.add(:name, "can't be blank")
74
+ #
75
+ # @example Add multiple errors
76
+ # errors.add(:email, ["is invalid", "is already taken"])
23
77
  def add(key, texts, opts = {})
24
78
  raise Light::Services::Error, "Error must be a non-empty string" unless texts
25
79
 
@@ -39,10 +93,25 @@ module Light
39
93
  rollback!(opts.key?(:rollback) ? opts[:rollback] : message.rollback?) if !opts.key?(:last) || opts[:last]
40
94
  end
41
95
 
96
+ # Check if step execution should stop.
97
+ #
98
+ # @return [Boolean] true if a message triggered a break
42
99
  def break?
43
100
  @break
44
101
  end
45
102
 
103
+ # Copy messages from another source.
104
+ #
105
+ # @param entity [ActiveRecord::Base, Base, Hash, #each] source to copy from
106
+ # @param opts [Hash] options to pass to each added message
107
+ # @return [void]
108
+ # @raise [Error] if entity type is not supported
109
+ #
110
+ # @example Copy from ActiveRecord model
111
+ # errors.copy_from(user) # copies user.errors
112
+ #
113
+ # @example Copy from another service
114
+ # errors.copy_from(child_service)
46
115
  def copy_from(entity, opts = {})
47
116
  if defined?(ActiveRecord::Base) && entity.is_a?(ActiveRecord::Base)
48
117
  copy_from(entity.errors.messages, opts)
@@ -60,6 +129,9 @@ module Light
60
129
  end
61
130
  alias from_record copy_from
62
131
 
132
+ # Convert messages to a hash with string values.
133
+ #
134
+ # @return [Hash{Symbol => Array<String>}] messages as hash
63
135
  def to_h
64
136
  @messages.to_h.transform_values { |value| value.map(&:to_s) }
65
137
  end
@@ -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