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 `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"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Light
|
|
4
|
+
module Services
|
|
5
|
+
module Settings
|
|
6
|
+
# Stores configuration for a single argument or output field.
|
|
7
|
+
# Created automatically when using the `arg` or `output` DSL methods.
|
|
8
|
+
class Field
|
|
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
|
|
34
|
+
def initialize(name, service_class, opts = {})
|
|
35
|
+
@name = name
|
|
36
|
+
@service_class = service_class
|
|
37
|
+
@field_type = opts.delete(:field_type) || :argument
|
|
38
|
+
|
|
39
|
+
@type = opts.delete(:type)
|
|
40
|
+
@context = opts.delete(:context)
|
|
41
|
+
@default_exists = opts.key?(:default)
|
|
42
|
+
@default = opts.delete(:default)
|
|
43
|
+
@optional = opts.delete(:optional)
|
|
44
|
+
|
|
45
|
+
define_methods
|
|
46
|
+
end
|
|
47
|
+
|
|
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
|
|
54
|
+
def validate_type!(value)
|
|
55
|
+
return value unless @type
|
|
56
|
+
|
|
57
|
+
if dry_type?(@type)
|
|
58
|
+
coerce_and_validate_dry_type!(value)
|
|
59
|
+
else
|
|
60
|
+
validate_ruby_type!(value)
|
|
61
|
+
value
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Check if the type is a dry-types type
|
|
68
|
+
def dry_type?(type)
|
|
69
|
+
return false unless defined?(Dry::Types::Type)
|
|
70
|
+
|
|
71
|
+
type.is_a?(Dry::Types::Type)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Validate and coerce value against dry-types
|
|
75
|
+
# Returns the coerced value
|
|
76
|
+
def coerce_and_validate_dry_type!(value)
|
|
77
|
+
@type[value]
|
|
78
|
+
rescue Dry::Types::ConstraintError, Dry::Types::CoercionError => e
|
|
79
|
+
raise Light::Services::ArgTypeError,
|
|
80
|
+
"#{@service_class} #{@field_type} `#{@name}` #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Validate value against Ruby class types
|
|
84
|
+
def validate_ruby_type!(value)
|
|
85
|
+
return if [*@type].any? { |type| value.is_a?(type) }
|
|
86
|
+
|
|
87
|
+
raise Light::Services::ArgTypeError, type_error_message(value)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def type_error_message(value)
|
|
91
|
+
expected_types = [*@type].map(&:to_s).join(" or ")
|
|
92
|
+
"#{@service_class} #{@field_type} `#{@name}` must be #{expected_types}, \" \\
|
|
93
|
+
\"but got #{value.class} with value: #{value.inspect}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def define_methods
|
|
97
|
+
name = @name
|
|
98
|
+
collection_instance_var = :"@#{@field_type}s"
|
|
99
|
+
|
|
100
|
+
@service_class.define_method(@name) { instance_variable_get(collection_instance_var).get(name) }
|
|
101
|
+
@service_class.define_method(:"#{@name}?") { !!instance_variable_get(collection_instance_var).get(name) }
|
|
102
|
+
@service_class.define_method(:"#{@name}=") do |value|
|
|
103
|
+
instance_variable_get(collection_instance_var).set(name, value)
|
|
104
|
+
end
|
|
105
|
+
@service_class.send(:private, "#{@name}=")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Aliases for backwards compatibility
|
|
110
|
+
Argument = Field
|
|
111
|
+
Output = Field
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# This class defines settings for step
|
|
4
3
|
module Light
|
|
5
4
|
module Services
|
|
6
5
|
module Settings
|
|
6
|
+
# Stores configuration for a single service step.
|
|
7
|
+
# Created automatically when using the `step` DSL method.
|
|
7
8
|
class Step
|
|
8
|
-
#
|
|
9
|
-
attr_reader :name
|
|
9
|
+
# @return [Symbol] the step name (method to call)
|
|
10
|
+
attr_reader :name
|
|
10
11
|
|
|
12
|
+
# @return [Boolean, nil] true if step runs even after errors/warnings
|
|
13
|
+
attr_reader :always
|
|
14
|
+
|
|
15
|
+
# Initialize a new step definition.
|
|
16
|
+
#
|
|
17
|
+
# @param name [Symbol] the step name (must match a method)
|
|
18
|
+
# @param service_class [Class] the service class this step belongs to
|
|
19
|
+
# @param opts [Hash] step options
|
|
20
|
+
# @option opts [Symbol, Proc] :if condition to run the step
|
|
21
|
+
# @option opts [Symbol, Proc] :unless condition to skip the step
|
|
22
|
+
# @option opts [Boolean] :always run even after errors/warnings
|
|
23
|
+
# @raise [Error] if both :if and :unless are specified
|
|
11
24
|
def initialize(name, service_class, opts = {})
|
|
12
25
|
@name = name
|
|
13
26
|
@service_class = service_class
|
|
@@ -17,35 +30,55 @@ module Light
|
|
|
17
30
|
@always = opts[:always]
|
|
18
31
|
|
|
19
32
|
if @if && @unless
|
|
20
|
-
raise Light::Services::
|
|
21
|
-
|
|
33
|
+
raise Light::Services::Error, "#{service_class} `if` and `unless` cannot be specified " \
|
|
34
|
+
"for the step `#{name}` at the same time"
|
|
22
35
|
end
|
|
23
36
|
end
|
|
24
37
|
|
|
25
|
-
|
|
38
|
+
# Execute the step on the given service instance.
|
|
39
|
+
#
|
|
40
|
+
# @param instance [Base] the service instance
|
|
41
|
+
# @return [Boolean] true if the step was executed, false if skipped
|
|
42
|
+
# @raise [Error] if the step method is not defined
|
|
43
|
+
def run(instance) # rubocop:disable Naming/PredicateMethod
|
|
26
44
|
return false unless run?(instance)
|
|
27
45
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
46
|
+
unless instance.respond_to?(name, true)
|
|
47
|
+
available_steps = @service_class.steps.keys.join(", ")
|
|
48
|
+
raise Light::Services::Error,
|
|
49
|
+
"Step method `#{name}` is not defined in #{@service_class}. " \
|
|
50
|
+
"Defined steps: [#{available_steps}]"
|
|
51
|
+
end
|
|
33
52
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
end
|
|
53
|
+
execute_with_callbacks(instance)
|
|
54
|
+
true
|
|
55
|
+
end
|
|
38
56
|
|
|
39
|
-
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def execute_with_callbacks(instance)
|
|
60
|
+
errors_count_before = instance.errors.count
|
|
61
|
+
|
|
62
|
+
instance.run_callbacks(:before_step_run, instance, name)
|
|
63
|
+
|
|
64
|
+
instance.run_callbacks(:around_step_run, instance, name) do
|
|
65
|
+
instance.send(name)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
instance.run_callbacks(:after_step_run, instance, name)
|
|
69
|
+
|
|
70
|
+
if instance.errors.count > errors_count_before
|
|
71
|
+
instance.run_callbacks(:on_step_failure, instance, name)
|
|
40
72
|
else
|
|
41
|
-
|
|
73
|
+
instance.run_callbacks(:on_step_success, instance, name)
|
|
42
74
|
end
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
instance.run_callbacks(:on_step_crash, instance, name, e)
|
|
77
|
+
raise e
|
|
43
78
|
end
|
|
44
79
|
|
|
45
|
-
private
|
|
46
|
-
|
|
47
80
|
def run?(instance)
|
|
48
|
-
return false if instance.
|
|
81
|
+
return false if instance.stopped?
|
|
49
82
|
|
|
50
83
|
if @if
|
|
51
84
|
check_condition(@if, instance)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Light
|
|
4
|
+
module Services
|
|
5
|
+
# Utility module providing helper methods for the Light Services library
|
|
6
|
+
module Utils
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Creates a deep copy of an object to prevent mutation of shared references.
|
|
10
|
+
#
|
|
11
|
+
# @param object [Object] the object to duplicate
|
|
12
|
+
# @return [Object] a deep copy of the object
|
|
13
|
+
#
|
|
14
|
+
# @example Deep duping a hash
|
|
15
|
+
# original = { a: { b: 1 } }
|
|
16
|
+
# copy = Utils.deep_dup(original)
|
|
17
|
+
# copy[:a][:b] = 2
|
|
18
|
+
# original[:a][:b] # => 1
|
|
19
|
+
#
|
|
20
|
+
# @example Deep duping an array
|
|
21
|
+
# original = [[1, 2], [3, 4]]
|
|
22
|
+
# copy = Utils.deep_dup(original)
|
|
23
|
+
# copy[0] << 5
|
|
24
|
+
# original[0] # => [1, 2]
|
|
25
|
+
#
|
|
26
|
+
def deep_dup(object)
|
|
27
|
+
# Use ActiveSupport's deep_dup if available (preferred for Rails apps)
|
|
28
|
+
return object.deep_dup if object.respond_to?(:deep_dup)
|
|
29
|
+
|
|
30
|
+
# Fallback to Marshal for objects that support serialization
|
|
31
|
+
Marshal.load(Marshal.dump(object))
|
|
32
|
+
rescue TypeError
|
|
33
|
+
# Last resort: use dup if available, otherwise return original
|
|
34
|
+
object.respond_to?(:dup) ? object.dup : object
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/light/services.rb
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "indexing_enhancement"
|
|
4
|
+
require_relative "definition"
|
|
5
|
+
|
|
6
|
+
# Declare version compatibility without runtime dependency on ruby-lsp
|
|
7
|
+
RubyLsp::Addon.depend_on_ruby_lsp!("~> 0.26")
|
|
8
|
+
|
|
9
|
+
module RubyLsp
|
|
10
|
+
module LightServices
|
|
11
|
+
class Addon < ::RubyLsp::Addon
|
|
12
|
+
def activate(global_state, message_queue)
|
|
13
|
+
@global_state = global_state
|
|
14
|
+
@message_queue = message_queue
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def deactivate; end
|
|
18
|
+
|
|
19
|
+
def name
|
|
20
|
+
"Ruby LSP Light Services"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def version
|
|
24
|
+
Light::Services::VERSION
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Called on every "go to definition" request
|
|
28
|
+
# Provides navigation from step DSL symbols to their method definitions
|
|
29
|
+
def create_definition_listener(response_builder, uri, node_context, dispatcher)
|
|
30
|
+
return unless @global_state
|
|
31
|
+
|
|
32
|
+
Definition.new(response_builder, uri, node_context, @global_state.index, dispatcher)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLsp
|
|
4
|
+
module LightServices
|
|
5
|
+
class Definition
|
|
6
|
+
# Condition options that reference methods
|
|
7
|
+
CONDITION_OPTIONS = [:if, :unless].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(response_builder, uri, node_context, index, dispatcher)
|
|
10
|
+
@response_builder = response_builder
|
|
11
|
+
@uri = uri
|
|
12
|
+
@node_context = node_context
|
|
13
|
+
@index = index
|
|
14
|
+
|
|
15
|
+
# Register for symbol nodes - this is what gets triggered when user clicks on :method_name
|
|
16
|
+
dispatcher.register(self, :on_symbol_node_enter)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Called when cursor is on a symbol node (e.g., :validate in `step :validate`)
|
|
20
|
+
def on_symbol_node_enter(node)
|
|
21
|
+
# Check if this symbol is part of a step call by examining the call context
|
|
22
|
+
call_node = find_parent_step_call
|
|
23
|
+
return unless call_node
|
|
24
|
+
|
|
25
|
+
method_name = determine_method_name(node, call_node)
|
|
26
|
+
return unless method_name
|
|
27
|
+
|
|
28
|
+
find_and_append_method_location(method_name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Find the parent step call node from the node context
|
|
34
|
+
# The node_context.call_node returns the enclosing call if cursor is on an argument
|
|
35
|
+
def find_parent_step_call
|
|
36
|
+
call_node = @node_context.call_node
|
|
37
|
+
return unless call_node
|
|
38
|
+
return unless call_node.name == :step
|
|
39
|
+
|
|
40
|
+
call_node
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Determine which method name to look up based on where the symbol appears
|
|
44
|
+
# Returns nil if this symbol is not a method reference we should handle
|
|
45
|
+
def determine_method_name(symbol_node, call_node)
|
|
46
|
+
symbol_value = symbol_node.value.to_sym
|
|
47
|
+
|
|
48
|
+
# Check if this is the first argument (step method name)
|
|
49
|
+
first_arg = call_node.arguments&.arguments&.first
|
|
50
|
+
if first_arg.is_a?(Prism::SymbolNode) && first_arg.value.to_sym == symbol_value && same_location?(first_arg,
|
|
51
|
+
symbol_node)
|
|
52
|
+
# Verify the symbol node location matches (same node, not just same value)
|
|
53
|
+
return symbol_value.to_s
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Check if this is a condition option (if: or unless:)
|
|
57
|
+
keyword_hash = find_keyword_hash(call_node)
|
|
58
|
+
return unless keyword_hash
|
|
59
|
+
|
|
60
|
+
CONDITION_OPTIONS.each do |option_name|
|
|
61
|
+
condition_symbol = find_symbol_option(keyword_hash, option_name)
|
|
62
|
+
next unless condition_symbol
|
|
63
|
+
next unless same_location?(condition_symbol, symbol_node)
|
|
64
|
+
|
|
65
|
+
return condition_symbol.value.to_s
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Check if two nodes have the same location (are the same node)
|
|
72
|
+
def same_location?(node1, node2)
|
|
73
|
+
node1.location.start_offset == node2.location.start_offset &&
|
|
74
|
+
node1.location.end_offset == node2.location.end_offset
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Find the keyword hash in call arguments
|
|
78
|
+
def find_keyword_hash(node)
|
|
79
|
+
node.arguments&.arguments&.find { |arg| arg.is_a?(Prism::KeywordHashNode) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Find a symbol value for a specific option in the keyword hash
|
|
83
|
+
# Returns the SymbolNode if found and value is a symbol, nil otherwise
|
|
84
|
+
def find_symbol_option(keyword_hash, option_name)
|
|
85
|
+
element = keyword_hash.elements.find do |elem|
|
|
86
|
+
elem.is_a?(Prism::AssocNode) &&
|
|
87
|
+
elem.key.is_a?(Prism::SymbolNode) &&
|
|
88
|
+
elem.key.value.to_sym == option_name
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
return unless element
|
|
92
|
+
return unless element.value.is_a?(Prism::SymbolNode)
|
|
93
|
+
|
|
94
|
+
element.value
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Find method definition in index and append location to response
|
|
98
|
+
def find_and_append_method_location(method_name)
|
|
99
|
+
owner_name = @node_context.nesting.join("::")
|
|
100
|
+
return if owner_name.empty?
|
|
101
|
+
|
|
102
|
+
# Look up method entries in the index
|
|
103
|
+
method_entries = @index.resolve_method(method_name, owner_name)
|
|
104
|
+
return unless method_entries&.any?
|
|
105
|
+
|
|
106
|
+
method_entries.each { |entry| append_location(entry) }
|
|
107
|
+
|
|
108
|
+
true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def append_location(entry)
|
|
112
|
+
@response_builder << Interface::Location.new(
|
|
113
|
+
uri: URI::Generic.from_path(path: entry.file_path).to_s,
|
|
114
|
+
range: build_range(entry.location),
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_range(location)
|
|
119
|
+
Interface::Range.new(
|
|
120
|
+
start: Interface::Position.new(
|
|
121
|
+
line: location.start_line - 1,
|
|
122
|
+
character: location.start_column,
|
|
123
|
+
),
|
|
124
|
+
end: Interface::Position.new(
|
|
125
|
+
line: location.end_line - 1,
|
|
126
|
+
character: location.end_column,
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|