operandi 5.0.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 +7 -0
- data/.cursor/rules/services/RULE.md +269 -0
- data/.cursor/rules/services-rspec/RULE.md +354 -0
- data/.github/dependabot.yml +11 -0
- data/.github/workflows/ci.yml +77 -0
- data/.gitignore +25 -0
- data/.rspec +3 -0
- data/.rubocop.yml +134 -0
- data/.ruby-version +1 -0
- data/.vscode/cspell.json +18 -0
- data/.vscode/project-words.txt +12 -0
- data/AGENTS.md +139 -0
- data/CHANGELOG.md +111 -0
- data/CLAUDE.md +139 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +28 -0
- data/Gemfile.lock +149 -0
- data/LICENSE.txt +21 -0
- data/README.md +172 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/config/default.yml +57 -0
- data/docs/README.md +105 -0
- data/docs/SUMMARY.md +31 -0
- data/docs/arguments.md +275 -0
- data/docs/best-practices.md +153 -0
- data/docs/callbacks.md +475 -0
- data/docs/concepts.md +79 -0
- data/docs/configuration.md +218 -0
- data/docs/context.md +128 -0
- data/docs/crud.md +525 -0
- data/docs/errors.md +331 -0
- data/docs/generators.md +250 -0
- data/docs/outputs.md +150 -0
- data/docs/pundit-authorization.md +320 -0
- data/docs/quickstart.md +133 -0
- data/docs/recipes.md +14 -0
- data/docs/rubocop.md +430 -0
- data/docs/ruby-lsp.md +121 -0
- data/docs/service-rendering.md +222 -0
- data/docs/sorbet-runtime.md +283 -0
- data/docs/steps.md +438 -0
- data/docs/tapioca.md +188 -0
- data/docs/testing.md +548 -0
- data/lib/generators/operandi/install/USAGE +15 -0
- data/lib/generators/operandi/install/install_generator.rb +45 -0
- data/lib/generators/operandi/install/templates/application_service.rb.tt +8 -0
- data/lib/generators/operandi/install/templates/application_service_spec.rb.tt +7 -0
- data/lib/generators/operandi/install/templates/initializer.rb.tt +30 -0
- data/lib/generators/operandi/service/USAGE +21 -0
- data/lib/generators/operandi/service/service_generator.rb +80 -0
- data/lib/generators/operandi/service/templates/service.rb.tt +48 -0
- data/lib/generators/operandi/service/templates/service_spec.rb.tt +40 -0
- data/lib/operandi/base.rb +230 -0
- data/lib/operandi/base_with_context.rb +57 -0
- data/lib/operandi/callbacks.rb +353 -0
- data/lib/operandi/collection.rb +166 -0
- data/lib/operandi/concerns/execution.rb +80 -0
- data/lib/operandi/concerns/parent_service.rb +32 -0
- data/lib/operandi/concerns/state_management.rb +34 -0
- data/lib/operandi/config.rb +142 -0
- data/lib/operandi/constants.rb +96 -0
- data/lib/operandi/dsl/arguments_dsl.rb +83 -0
- data/lib/operandi/dsl/outputs_dsl.rb +79 -0
- data/lib/operandi/dsl/steps_dsl.rb +206 -0
- data/lib/operandi/dsl/validation.rb +171 -0
- data/lib/operandi/exceptions.rb +66 -0
- data/lib/operandi/message.rb +52 -0
- data/lib/operandi/messages.rb +185 -0
- data/lib/operandi/rspec/matchers/define_argument.rb +172 -0
- data/lib/operandi/rspec/matchers/define_output.rb +145 -0
- data/lib/operandi/rspec/matchers/define_step.rb +223 -0
- data/lib/operandi/rspec/matchers/execute_step.rb +228 -0
- data/lib/operandi/rspec/matchers/have_error_on.rb +144 -0
- data/lib/operandi/rspec/matchers/have_warning_on.rb +146 -0
- data/lib/operandi/rspec/matchers/trigger_callback.rb +136 -0
- data/lib/operandi/rspec.rb +15 -0
- data/lib/operandi/rubocop/cop/operandi/argument_type_required.rb +52 -0
- data/lib/operandi/rubocop/cop/operandi/condition_method_exists.rb +173 -0
- data/lib/operandi/rubocop/cop/operandi/deprecated_accessors.rb +113 -0
- data/lib/operandi/rubocop/cop/operandi/deprecated_methods.rb +113 -0
- data/lib/operandi/rubocop/cop/operandi/dsl_order.rb +181 -0
- data/lib/operandi/rubocop/cop/operandi/missing_private_keyword.rb +102 -0
- data/lib/operandi/rubocop/cop/operandi/no_direct_instantiation.rb +66 -0
- data/lib/operandi/rubocop/cop/operandi/no_hash_argument.rb +101 -0
- data/lib/operandi/rubocop/cop/operandi/output_type_required.rb +52 -0
- data/lib/operandi/rubocop/cop/operandi/prefer_fail_method.rb +112 -0
- data/lib/operandi/rubocop/cop/operandi/prefer_optional_over_default_nil.rb +124 -0
- data/lib/operandi/rubocop/cop/operandi/redundant_optional.rb +103 -0
- data/lib/operandi/rubocop/cop/operandi/reserved_name.rb +56 -0
- data/lib/operandi/rubocop/cop/operandi/step_method_exists.rb +134 -0
- data/lib/operandi/rubocop.rb +22 -0
- data/lib/operandi/settings/field.rb +147 -0
- data/lib/operandi/settings/step.rb +105 -0
- data/lib/operandi/utils.rb +36 -0
- data/lib/operandi/version.rb +5 -0
- data/lib/operandi.rb +11 -0
- data/lib/ruby_lsp/operandi/addon.rb +37 -0
- data/lib/ruby_lsp/operandi/definition.rb +134 -0
- data/lib/ruby_lsp/operandi/indexing_enhancement.rb +224 -0
- data/lib/tapioca/dsl/compilers/operandi.rb +378 -0
- data/operandi.gemspec +33 -0
- data/rbi/operandi.rbi +197 -0
- data/sorbet/cache/data.mdb +0 -0
- data/sorbet/cache/lock.mdb +0 -0
- metadata +151 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Operandi
|
|
4
|
+
module Settings
|
|
5
|
+
# Stores configuration for a single argument or output field.
|
|
6
|
+
# Created automatically when using the `arg` or `output` DSL methods.
|
|
7
|
+
class Field
|
|
8
|
+
# @return [Symbol] the field name
|
|
9
|
+
attr_reader :name
|
|
10
|
+
|
|
11
|
+
# @return [Boolean] true if a default value was specified
|
|
12
|
+
attr_reader :default_exists
|
|
13
|
+
|
|
14
|
+
# @return [Object, Proc, nil] the default value or proc
|
|
15
|
+
attr_reader :default
|
|
16
|
+
|
|
17
|
+
# @return [Boolean, nil] true if this is a context argument
|
|
18
|
+
attr_reader :context
|
|
19
|
+
|
|
20
|
+
# @return [Boolean, nil] true if nil values are allowed
|
|
21
|
+
attr_reader :optional
|
|
22
|
+
|
|
23
|
+
# Initialize a new field definition.
|
|
24
|
+
#
|
|
25
|
+
# @param name [Symbol] the field name
|
|
26
|
+
# @param service_class [Class] the service class this field belongs to
|
|
27
|
+
# @param opts [Hash] field options
|
|
28
|
+
# @option opts [Class, Array<Class>] :type type(s) to validate against
|
|
29
|
+
# @option opts [Boolean] :optional whether nil is allowed
|
|
30
|
+
# @option opts [Object, Proc] :default default value or proc
|
|
31
|
+
# @option opts [Boolean] :context whether to pass to child services
|
|
32
|
+
# @option opts [Symbol] :field_type :argument or :output
|
|
33
|
+
def initialize(name, service_class, opts = {})
|
|
34
|
+
@name = name
|
|
35
|
+
@service_class = service_class
|
|
36
|
+
@field_type = opts.delete(:field_type) || :argument
|
|
37
|
+
|
|
38
|
+
@type = opts.delete(:type)
|
|
39
|
+
@context = opts.delete(:context)
|
|
40
|
+
@default_exists = opts.key?(:default)
|
|
41
|
+
@default = opts.delete(:default)
|
|
42
|
+
@optional = opts.delete(:optional)
|
|
43
|
+
|
|
44
|
+
define_methods
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Validate a value against the field's type definition.
|
|
48
|
+
# Supports Ruby class types and Sorbet runtime types.
|
|
49
|
+
#
|
|
50
|
+
# @param value [Object] the value to validate
|
|
51
|
+
# @return [Object] the validated value
|
|
52
|
+
# @raise [ArgTypeError] if the value doesn't match the expected type
|
|
53
|
+
def validate_type!(value)
|
|
54
|
+
return value unless @type
|
|
55
|
+
|
|
56
|
+
if sorbet_type?(@type) || (sorbet_available? && plain_class_type?(@type))
|
|
57
|
+
validate_sorbet_type!(value)
|
|
58
|
+
else
|
|
59
|
+
validate_ruby_type!(value)
|
|
60
|
+
value
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
FIELD_TYPE_TO_IVAR = {
|
|
65
|
+
FieldTypes::ARGUMENT => :@arg,
|
|
66
|
+
FieldTypes::OUTPUT => :@output,
|
|
67
|
+
}.freeze
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Check if sorbet-runtime is available
|
|
72
|
+
def sorbet_available?
|
|
73
|
+
defined?(T::Types::Base)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Check if the type is a plain Ruby class (not a Sorbet type)
|
|
77
|
+
def plain_class_type?(type)
|
|
78
|
+
type.is_a?(Class) || type.is_a?(Module)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Check if the type is a Sorbet runtime type
|
|
82
|
+
def sorbet_type?(type)
|
|
83
|
+
return false unless defined?(T::Types::Base)
|
|
84
|
+
|
|
85
|
+
type.is_a?(T::Types::Base)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Validate value against Sorbet runtime types
|
|
89
|
+
# Note: Sorbet types only validate, they do not coerce values
|
|
90
|
+
# Automatically coerces plain Ruby classes to Sorbet types when needed
|
|
91
|
+
# @return [Object] the original value if valid
|
|
92
|
+
# @raise [ArgTypeError] if the value doesn't match the expected type
|
|
93
|
+
def validate_sorbet_type!(value)
|
|
94
|
+
sorbet_type = sorbet_type?(@type) ? @type : T::Utils.coerce(@type)
|
|
95
|
+
return value if sorbet_type.valid?(value)
|
|
96
|
+
|
|
97
|
+
enum_value = deserialize_enum_argument(value)
|
|
98
|
+
return enum_value if enum_value && sorbet_type.valid?(enum_value)
|
|
99
|
+
|
|
100
|
+
message = "#{@service_class} #{@field_type} `#{@name}` expected #{sorbet_type.name}, " \
|
|
101
|
+
"but got #{value.class} with value: #{value.inspect}"
|
|
102
|
+
|
|
103
|
+
raise Operandi::ArgTypeError.new(message, service_class: @service_class)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def deserialize_enum_argument(value)
|
|
107
|
+
return nil unless @field_type == FieldTypes::ARGUMENT
|
|
108
|
+
return nil unless sorbet_enum_class?(@type)
|
|
109
|
+
|
|
110
|
+
@type.try_deserialize(value)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def sorbet_enum_class?(type)
|
|
114
|
+
defined?(T::Enum) && type.is_a?(Class) && type < T::Enum
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Validate value against Ruby class types
|
|
118
|
+
def validate_ruby_type!(value)
|
|
119
|
+
return if [*@type].any? { |type| value.is_a?(type) }
|
|
120
|
+
|
|
121
|
+
raise Operandi::ArgTypeError.new(type_error_message(value), service_class: @service_class)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def type_error_message(value)
|
|
125
|
+
expected_types = [*@type].map(&:to_s).join(" or ")
|
|
126
|
+
"#{@service_class} #{@field_type} `#{@name}` must be #{expected_types}, \" \\
|
|
127
|
+
\"but got #{value.class} with value: #{value.inspect}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def define_methods
|
|
131
|
+
name = @name
|
|
132
|
+
collection_instance_var = FIELD_TYPE_TO_IVAR.fetch(@field_type)
|
|
133
|
+
|
|
134
|
+
@service_class.define_method(@name) { instance_variable_get(collection_instance_var).get(name) }
|
|
135
|
+
@service_class.define_method(:"#{@name}?") { !!instance_variable_get(collection_instance_var).get(name) }
|
|
136
|
+
@service_class.define_method(:"#{@name}=") do |value|
|
|
137
|
+
instance_variable_get(collection_instance_var).set(name, value)
|
|
138
|
+
end
|
|
139
|
+
@service_class.send(:private, "#{@name}=")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Aliases for backwards compatibility
|
|
144
|
+
Argument = Field
|
|
145
|
+
Output = Field
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Operandi
|
|
4
|
+
module Settings
|
|
5
|
+
# Stores configuration for a single service step.
|
|
6
|
+
# Created automatically when using the `step` DSL method.
|
|
7
|
+
class Step
|
|
8
|
+
# @return [Symbol] the step name (method to call)
|
|
9
|
+
attr_reader :name
|
|
10
|
+
|
|
11
|
+
# @return [Boolean, nil] true if step runs even after errors/warnings
|
|
12
|
+
attr_reader :always
|
|
13
|
+
|
|
14
|
+
# Initialize a new step definition.
|
|
15
|
+
#
|
|
16
|
+
# @param name [Symbol] the step name (must match a method)
|
|
17
|
+
# @param service_class [Class] the service class this step belongs to
|
|
18
|
+
# @param opts [Hash] step options
|
|
19
|
+
# @option opts [Symbol, Proc] :if condition to run the step
|
|
20
|
+
# @option opts [Symbol, Proc] :unless condition to skip the step
|
|
21
|
+
# @option opts [Boolean] :always run even after errors/warnings
|
|
22
|
+
# @raise [Error] if both :if and :unless are specified
|
|
23
|
+
def initialize(name, service_class, opts = {})
|
|
24
|
+
@name = name
|
|
25
|
+
@service_class = service_class
|
|
26
|
+
|
|
27
|
+
@if = opts[:if]
|
|
28
|
+
@unless = opts[:unless]
|
|
29
|
+
@always = opts[:always]
|
|
30
|
+
|
|
31
|
+
if @if && @unless
|
|
32
|
+
raise Operandi::Error, "#{service_class} `if` and `unless` cannot be specified " \
|
|
33
|
+
"for the step `#{name}` at the same time"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Execute the step on the given service instance.
|
|
38
|
+
#
|
|
39
|
+
# @param instance [Base] the service instance
|
|
40
|
+
# @return [Boolean] true if the step was executed, false if skipped
|
|
41
|
+
# @raise [RuntimeError] if the step method is not defined
|
|
42
|
+
def run(instance) # rubocop:disable Naming/PredicateMethod
|
|
43
|
+
return false unless run?(instance)
|
|
44
|
+
|
|
45
|
+
unless instance.respond_to?(name, true)
|
|
46
|
+
available_steps = @service_class.steps.keys.join(", ")
|
|
47
|
+
message = "Step method `#{name}` is not defined in #{@service_class}. " \
|
|
48
|
+
"Defined steps: [#{available_steps}]"
|
|
49
|
+
raise Operandi::RuntimeError.new(message, service: instance)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
execute_with_callbacks(instance)
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def execute_with_callbacks(instance)
|
|
59
|
+
errors_count_before = instance.errors.count
|
|
60
|
+
|
|
61
|
+
instance.run_callbacks(:before_step_run, instance, name)
|
|
62
|
+
|
|
63
|
+
instance.run_callbacks(:around_step_run, instance, name) do
|
|
64
|
+
instance.send(name)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
instance.run_callbacks(:after_step_run, instance, name)
|
|
68
|
+
|
|
69
|
+
if instance.errors.count > errors_count_before
|
|
70
|
+
instance.run_callbacks(:on_step_failure, instance, name)
|
|
71
|
+
else
|
|
72
|
+
instance.run_callbacks(:on_step_success, instance, name)
|
|
73
|
+
end
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
instance.run_callbacks(:on_step_crash, instance, name, e)
|
|
76
|
+
raise e
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def run?(instance)
|
|
80
|
+
return false if instance.stopped?
|
|
81
|
+
|
|
82
|
+
if @if
|
|
83
|
+
check_condition(@if, instance)
|
|
84
|
+
elsif @unless
|
|
85
|
+
!check_condition(@unless, instance)
|
|
86
|
+
else
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def check_condition(condition, instance)
|
|
92
|
+
case condition
|
|
93
|
+
when Symbol
|
|
94
|
+
instance.send(condition)
|
|
95
|
+
when Proc
|
|
96
|
+
instance.instance_exec(&condition)
|
|
97
|
+
else
|
|
98
|
+
message = "#{@service_class} condition should be a Symbol or Proc " \
|
|
99
|
+
"for the step `#{@name}` (currently: #{condition.class})"
|
|
100
|
+
raise Operandi::RuntimeError.new(message, service: instance)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Operandi
|
|
4
|
+
# Utility module providing helper methods for the Operandi library
|
|
5
|
+
module Utils
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Creates a deep copy of an object to prevent mutation of shared references.
|
|
9
|
+
#
|
|
10
|
+
# @param object [Object] the object to duplicate
|
|
11
|
+
# @return [Object] a deep copy of the object
|
|
12
|
+
#
|
|
13
|
+
# @example Deep duping a hash
|
|
14
|
+
# original = { a: { b: 1 } }
|
|
15
|
+
# copy = Utils.deep_dup(original)
|
|
16
|
+
# copy[:a][:b] = 2
|
|
17
|
+
# original[:a][:b] # => 1
|
|
18
|
+
#
|
|
19
|
+
# @example Deep duping an array
|
|
20
|
+
# original = [[1, 2], [3, 4]]
|
|
21
|
+
# copy = Utils.deep_dup(original)
|
|
22
|
+
# copy[0] << 5
|
|
23
|
+
# original[0] # => [1, 2]
|
|
24
|
+
#
|
|
25
|
+
def deep_dup(object)
|
|
26
|
+
# Use ActiveSupport's deep_dup if available (preferred for Rails apps)
|
|
27
|
+
return object.deep_dup if object.respond_to?(:deep_dup)
|
|
28
|
+
|
|
29
|
+
# Fallback to Marshal for objects that support serialization
|
|
30
|
+
Marshal.load(Marshal.dump(object))
|
|
31
|
+
rescue TypeError
|
|
32
|
+
# Last resort: use dup if available, otherwise return original
|
|
33
|
+
object.respond_to?(:dup) ? object.dup : object
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/operandi.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../operandi/version"
|
|
4
|
+
require_relative "indexing_enhancement"
|
|
5
|
+
require_relative "definition"
|
|
6
|
+
|
|
7
|
+
# Declare version compatibility without runtime dependency on ruby-lsp
|
|
8
|
+
RubyLsp::Addon.depend_on_ruby_lsp!("~> 0.26")
|
|
9
|
+
|
|
10
|
+
module RubyLsp
|
|
11
|
+
module Operandi
|
|
12
|
+
class Addon < ::RubyLsp::Addon
|
|
13
|
+
def activate(global_state, message_queue)
|
|
14
|
+
@global_state = global_state
|
|
15
|
+
@message_queue = message_queue
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def deactivate; end
|
|
19
|
+
|
|
20
|
+
def name
|
|
21
|
+
"Ruby LSP Operandi"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def version
|
|
25
|
+
::Operandi::VERSION
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Called on every "go to definition" request
|
|
29
|
+
# Provides navigation from step DSL symbols to their method definitions
|
|
30
|
+
def create_definition_listener(response_builder, uri, node_context, dispatcher)
|
|
31
|
+
return unless @global_state
|
|
32
|
+
|
|
33
|
+
Definition.new(response_builder, uri, node_context, @global_state.index, dispatcher)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLsp
|
|
4
|
+
module Operandi
|
|
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?(
|
|
51
|
+
first_arg,
|
|
52
|
+
symbol_node,
|
|
53
|
+
)
|
|
54
|
+
# Verify the symbol node location matches (same node, not just same value)
|
|
55
|
+
return symbol_value.to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check if this is a condition option (if: or unless:)
|
|
59
|
+
keyword_hash = find_keyword_hash(call_node)
|
|
60
|
+
return unless keyword_hash
|
|
61
|
+
|
|
62
|
+
CONDITION_OPTIONS.each do |option_name|
|
|
63
|
+
condition_symbol = find_symbol_option(keyword_hash, option_name)
|
|
64
|
+
next unless condition_symbol
|
|
65
|
+
next unless same_location?(condition_symbol, symbol_node)
|
|
66
|
+
|
|
67
|
+
return condition_symbol.value.to_s
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Check if two nodes have the same location (are the same node)
|
|
74
|
+
def same_location?(node1, node2)
|
|
75
|
+
node1.location.start_offset == node2.location.start_offset &&
|
|
76
|
+
node1.location.end_offset == node2.location.end_offset
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Find the keyword hash in call arguments
|
|
80
|
+
def find_keyword_hash(node)
|
|
81
|
+
node.arguments&.arguments&.find { |arg| arg.is_a?(Prism::KeywordHashNode) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Find a symbol value for a specific option in the keyword hash
|
|
85
|
+
# Returns the SymbolNode if found and value is a symbol, nil otherwise
|
|
86
|
+
def find_symbol_option(keyword_hash, option_name)
|
|
87
|
+
element = keyword_hash.elements.find do |elem|
|
|
88
|
+
elem.is_a?(Prism::AssocNode) &&
|
|
89
|
+
elem.key.is_a?(Prism::SymbolNode) &&
|
|
90
|
+
elem.key.value.to_sym == option_name
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
return unless element
|
|
94
|
+
return unless element.value.is_a?(Prism::SymbolNode)
|
|
95
|
+
|
|
96
|
+
element.value
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Find method definition in index and append location to response
|
|
100
|
+
def find_and_append_method_location(method_name)
|
|
101
|
+
owner_name = @node_context.nesting.join("::")
|
|
102
|
+
return if owner_name.empty?
|
|
103
|
+
|
|
104
|
+
# Look up method entries in the index
|
|
105
|
+
method_entries = @index.resolve_method(method_name, owner_name)
|
|
106
|
+
return unless method_entries&.any?
|
|
107
|
+
|
|
108
|
+
method_entries.each { |entry| append_location(entry) }
|
|
109
|
+
|
|
110
|
+
true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def append_location(entry)
|
|
114
|
+
@response_builder << Interface::Location.new(
|
|
115
|
+
uri: URI::Generic.from_path(path: entry.file_path).to_s,
|
|
116
|
+
range: build_range(entry.location),
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def build_range(location)
|
|
121
|
+
Interface::Range.new(
|
|
122
|
+
start: Interface::Position.new(
|
|
123
|
+
line: location.start_line - 1,
|
|
124
|
+
character: location.start_column,
|
|
125
|
+
),
|
|
126
|
+
end: Interface::Position.new(
|
|
127
|
+
line: location.end_line - 1,
|
|
128
|
+
character: location.end_column,
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|