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
@@ -3,17 +3,73 @@
3
3
  module Light
4
4
  module Services
5
5
  class << self
6
+ # Configure Light::Services with a block.
7
+ #
8
+ # @yield [Config] the configuration object
9
+ # @return [void]
10
+ #
11
+ # @example
12
+ # Light::Services.configure do |config|
13
+ # config.require_type = true
14
+ # config.use_transactions = false
15
+ # end
6
16
  def configure
7
17
  yield config
8
18
  end
9
19
 
20
+ # Get the global configuration object.
21
+ #
22
+ # @return [Config] the configuration instance
10
23
  def config
11
24
  @config ||= Config.new
12
25
  end
13
26
  end
14
27
 
28
+ # Configuration class for Light::Services global settings.
29
+ #
30
+ # @example Accessing configuration
31
+ # Light::Services.config.require_type # => true
32
+ #
33
+ # @example Modifying configuration
34
+ # Light::Services.config.use_transactions = false
15
35
  class Config
36
+ # @return [Boolean] whether arguments and outputs must have a type specified
37
+ attr_reader :require_type
38
+
39
+ # @return [Boolean] whether to wrap service execution in a database transaction
40
+ attr_reader :use_transactions
41
+
42
+ # @return [Boolean] whether to copy errors to parent service in chain
43
+ attr_reader :load_errors
44
+
45
+ # @return [Boolean] whether to stop executing steps when an error is added
46
+ attr_reader :break_on_error
47
+
48
+ # @return [Boolean] whether to raise Light::Services::Error when service fails
49
+ attr_reader :raise_on_error
50
+
51
+ # @return [Boolean] whether to rollback the transaction when an error is added
52
+ attr_reader :rollback_on_error
53
+
54
+ # @return [Boolean] whether to copy warnings to parent service in chain
55
+ attr_reader :load_warnings
56
+
57
+ # @return [Boolean] whether to stop executing steps when a warning is added
58
+ attr_reader :break_on_warning
59
+
60
+ # @return [Boolean] whether to raise Light::Services::Error when service has warnings
61
+ attr_reader :raise_on_warning
62
+
63
+ # @return [Boolean] whether to rollback the transaction when a warning is added
64
+ attr_reader :rollback_on_warning
65
+
66
+ # @return [Hash{String => String}] custom type mappings for Ruby LSP addon.
67
+ # Maps dry-types or custom types to Ruby types for hover/completion.
68
+ # @example { "Types::UUID" => "String", "CustomTypes::Money" => "BigDecimal" }
69
+ attr_reader :ruby_lsp_type_mappings
70
+
16
71
  DEFAULTS = {
72
+ require_type: true,
17
73
  use_transactions: true,
18
74
 
19
75
  load_errors: true,
@@ -25,22 +81,46 @@ module Light
25
81
  break_on_warning: false,
26
82
  raise_on_warning: false,
27
83
  rollback_on_warning: false,
84
+
85
+ ruby_lsp_type_mappings: {}.freeze,
28
86
  }.freeze
29
87
 
30
- attr_accessor(*DEFAULTS.keys)
88
+ DEFAULTS.each_key do |name|
89
+ define_method(:"#{name}=") do |value|
90
+ instance_variable_set(:"@#{name}", value)
91
+ @to_h = nil # Invalidate memoized hash
92
+ end
93
+ end
31
94
 
95
+ # Initialize configuration with default values.
32
96
  def initialize
33
97
  reset_to_defaults!
34
98
  end
35
99
 
100
+ # Reset all configuration options to their default values.
101
+ #
102
+ # @return [void]
36
103
  def reset_to_defaults!
37
- DEFAULTS.each { |key, value| public_send(:"#{key}=", value) }
104
+ DEFAULTS.each do |key, value|
105
+ instance_variable_set(:"@#{key}", value)
106
+ end
107
+
108
+ @to_h = nil # Invalidate memoized hash
38
109
  end
39
110
 
111
+ # Convert configuration to a hash.
112
+ #
113
+ # @return [Hash{Symbol => Object}] all configuration options as a hash
40
114
  def to_h
41
- DEFAULTS.keys.to_h { |key| [key, public_send(key)] }
115
+ @to_h ||= DEFAULTS.keys.to_h do |key|
116
+ [key, public_send(key)]
117
+ end
42
118
  end
43
119
 
120
+ # Merge configuration with additional options.
121
+ #
122
+ # @param config [Hash] options to merge
123
+ # @return [Hash] merged configuration hash
44
124
  def merge(config)
45
125
  to_h.merge(config)
46
126
  end
@@ -31,6 +31,9 @@ module Light
31
31
  :failed?,
32
32
  :errors?,
33
33
  :warnings?,
34
+ :stop!,
35
+ :stopped?,
36
+ :stop_immediately!,
34
37
  :done!,
35
38
  :done?,
36
39
  :call,
@@ -41,6 +41,7 @@ module Light
41
41
  Validation.validate_symbol_name!(name, :argument, self)
42
42
  Validation.validate_reserved_name!(name, :argument, self)
43
43
  Validation.validate_name_conflicts!(name, :argument, self)
44
+ Validation.validate_type_required!(name, :argument, self, opts)
44
45
 
45
46
  own_arguments[name] = Settings::Field.new(name, self, opts.merge(field_type: FieldTypes::ARGUMENT))
46
47
  @arguments = nil # Clear memoized arguments since we're modifying them
@@ -37,6 +37,7 @@ module Light
37
37
  Validation.validate_symbol_name!(name, :output, self)
38
38
  Validation.validate_reserved_name!(name, :output, self)
39
39
  Validation.validate_name_conflicts!(name, :output, self)
40
+ Validation.validate_type_required!(name, :output, self, opts)
40
41
 
41
42
  own_outputs[name] = Settings::Field.new(name, self, opts.merge(field_type: FieldTypes::OUTPUT))
42
43
  @outputs = nil # Clear memoized outputs since we're modifying them
@@ -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