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
@@ -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
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLsp
4
+ module LightServices
5
+ class IndexingEnhancement < RubyIndexer::Enhancement # rubocop:disable Metrics/ClassLength
6
+ # DSL methods that generate getter, predicate, and setter methods
7
+ FIELD_DSL_METHODS = [:arg, :output].freeze
8
+
9
+ # Default type mappings for common dry-types to their underlying Ruby types
10
+ # These can be extended via Light::Services.config.ruby_lsp_type_mappings
11
+ DEFAULT_TYPE_MAPPINGS = {
12
+ "Types::String" => "String",
13
+ "Types::Strict::String" => "String",
14
+ "Types::Coercible::String" => "String",
15
+ "Types::Integer" => "Integer",
16
+ "Types::Strict::Integer" => "Integer",
17
+ "Types::Coercible::Integer" => "Integer",
18
+ "Types::Float" => "Float",
19
+ "Types::Strict::Float" => "Float",
20
+ "Types::Coercible::Float" => "Float",
21
+ "Types::Decimal" => "BigDecimal",
22
+ "Types::Strict::Decimal" => "BigDecimal",
23
+ "Types::Coercible::Decimal" => "BigDecimal",
24
+ "Types::Bool" => "TrueClass | FalseClass",
25
+ "Types::Strict::Bool" => "TrueClass | FalseClass",
26
+ "Types::True" => "TrueClass",
27
+ "Types::Strict::True" => "TrueClass",
28
+ "Types::False" => "FalseClass",
29
+ "Types::Strict::False" => "FalseClass",
30
+ "Types::Array" => "Array",
31
+ "Types::Strict::Array" => "Array",
32
+ "Types::Hash" => "Hash",
33
+ "Types::Strict::Hash" => "Hash",
34
+ "Types::Symbol" => "Symbol",
35
+ "Types::Strict::Symbol" => "Symbol",
36
+ "Types::Coercible::Symbol" => "Symbol",
37
+ "Types::Date" => "Date",
38
+ "Types::Strict::Date" => "Date",
39
+ "Types::DateTime" => "DateTime",
40
+ "Types::Strict::DateTime" => "DateTime",
41
+ "Types::Time" => "Time",
42
+ "Types::Strict::Time" => "Time",
43
+ "Types::Nil" => "NilClass",
44
+ "Types::Strict::Nil" => "NilClass",
45
+ "Types::Any" => "Object",
46
+ }.freeze
47
+
48
+ # ─────────────────────────────────────────────────────────────────────────
49
+ # Public API - Called by Ruby LSP indexer
50
+ # ─────────────────────────────────────────────────────────────────────────
51
+
52
+ # Called when the indexer encounters a method call node
53
+ # Detects `arg` and `output` DSL calls and registers the generated methods
54
+ def on_call_node_enter(node)
55
+ return unless @listener.current_owner
56
+ return unless FIELD_DSL_METHODS.include?(node.name)
57
+
58
+ field_name = extract_field_name(node)
59
+ return unless field_name
60
+
61
+ ruby_type = extract_ruby_type(node)
62
+ register_field_methods(field_name, node.location, ruby_type)
63
+ end
64
+
65
+ def on_call_node_leave(node); end
66
+
67
+ private
68
+
69
+ # ─────────────────────────────────────────────────────────────────────────
70
+ # Field Extraction
71
+ # ─────────────────────────────────────────────────────────────────────────
72
+
73
+ # Extract the field name from the first argument (symbol)
74
+ # Example: `arg :user` → "user"
75
+ def extract_field_name(node)
76
+ arguments = node.arguments&.arguments
77
+ return unless arguments&.any?
78
+
79
+ first_arg = arguments.first
80
+ return unless first_arg.is_a?(Prism::SymbolNode)
81
+
82
+ first_arg.value
83
+ end
84
+
85
+ # ─────────────────────────────────────────────────────────────────────────
86
+ # Type Resolution
87
+ # ─────────────────────────────────────────────────────────────────────────
88
+
89
+ # Extract and resolve the Ruby type from the `type:` keyword argument
90
+ # Returns the mapped Ruby type string or the original type if no mapping exists
91
+ def extract_ruby_type(node)
92
+ type_node = find_type_value_node(node)
93
+ return unless type_node
94
+
95
+ resolve_to_ruby_type(type_node)
96
+ end
97
+
98
+ # Find the value node for the `type:` keyword argument
99
+ def find_type_value_node(node)
100
+ arguments = node.arguments&.arguments
101
+ return unless arguments
102
+
103
+ keyword_hash = arguments.find { |arg| arg.is_a?(Prism::KeywordHashNode) }
104
+ return unless keyword_hash
105
+
106
+ # NOTE: Prism's SymbolNode#value returns a String, not a Symbol
107
+ type_element = keyword_hash.elements.find do |element|
108
+ element.is_a?(Prism::AssocNode) &&
109
+ element.key.is_a?(Prism::SymbolNode) &&
110
+ element.key.value == "type"
111
+ end
112
+
113
+ type_element&.value
114
+ end
115
+
116
+ # Resolve a Prism type node to a Ruby type string
117
+ # Handles constants, constant paths, and method chains
118
+ def resolve_to_ruby_type(node)
119
+ type_string = node_to_constant_string(node)
120
+ return unless type_string
121
+
122
+ map_to_ruby_type(type_string) || type_string
123
+ end
124
+
125
+ # Convert a Prism node to its constant string representation
126
+ def node_to_constant_string(node)
127
+ case node
128
+ when Prism::ConstantReadNode
129
+ node.name.to_s
130
+ when Prism::ConstantPathNode
131
+ build_constant_path(node)
132
+ when Prism::CallNode
133
+ # Handle method chains like Types::String.constrained(...) or Types::Array.of(...)
134
+ extract_receiver_constant(node)
135
+ end
136
+ end
137
+
138
+ # Build a full constant path string from nested ConstantPathNodes
139
+ # Example: Types::Strict::String
140
+ def build_constant_path(node)
141
+ parts = []
142
+ current = node
143
+
144
+ while current.is_a?(Prism::ConstantPathNode)
145
+ parts.unshift(current.name.to_s)
146
+ current = current.parent
147
+ end
148
+
149
+ parts.unshift(current.name.to_s) if current.is_a?(Prism::ConstantReadNode)
150
+ parts.join("::")
151
+ end
152
+
153
+ # Extract the receiver constant from a method call chain
154
+ # Example: Types::String.constrained(format: /@/) → "Types::String"
155
+ def extract_receiver_constant(node)
156
+ receiver = node.receiver
157
+ return unless receiver
158
+
159
+ case receiver
160
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
161
+ node_to_constant_string(receiver)
162
+ when Prism::CallNode
163
+ extract_receiver_constant(receiver)
164
+ end
165
+ end
166
+
167
+ # ─────────────────────────────────────────────────────────────────────────
168
+ # Type Mapping
169
+ # ─────────────────────────────────────────────────────────────────────────
170
+
171
+ # Map a type string to its corresponding Ruby type
172
+ # Uses custom mappings from config (if available) merged with defaults
173
+ def map_to_ruby_type(type_string)
174
+ mappings = effective_type_mappings
175
+
176
+ # Direct mapping lookup (custom mappings take precedence)
177
+ return mappings[type_string] if mappings.key?(type_string)
178
+
179
+ # Handle parameterized types: Types::Array.of(...) → Types::Array
180
+ base_type = type_string.split(".").first
181
+ mappings[base_type]
182
+ end
183
+
184
+ # Returns the effective type mappings (defaults + custom from config)
185
+ # Custom mappings take precedence over defaults
186
+ def effective_type_mappings
187
+ return DEFAULT_TYPE_MAPPINGS unless defined?(Light::Services)
188
+ return DEFAULT_TYPE_MAPPINGS unless Light::Services.respond_to?(:config)
189
+
190
+ custom_mappings = Light::Services.config&.ruby_lsp_type_mappings
191
+ return DEFAULT_TYPE_MAPPINGS if custom_mappings.nil? || custom_mappings.empty?
192
+
193
+ DEFAULT_TYPE_MAPPINGS.merge(custom_mappings)
194
+ rescue NoMethodError
195
+ DEFAULT_TYPE_MAPPINGS
196
+ end
197
+
198
+ # ─────────────────────────────────────────────────────────────────────────
199
+ # Method Registration
200
+ # ─────────────────────────────────────────────────────────────────────────
201
+
202
+ # Register all three generated methods for a field (getter, predicate, setter)
203
+ def register_field_methods(field_name, location, ruby_type)
204
+ register_getter(field_name, location, ruby_type)
205
+ register_predicate(field_name, location)
206
+ register_setter(field_name, location, ruby_type)
207
+ end
208
+
209
+ def register_getter(field_name, location, ruby_type)
210
+ @listener.add_method(
211
+ field_name.to_s,
212
+ location,
213
+ no_params_signature,
214
+ comments: return_type_comment(ruby_type),
215
+ )
216
+ end
217
+
218
+ def register_predicate(field_name, location)
219
+ @listener.add_method(
220
+ "#{field_name}?",
221
+ location,
222
+ no_params_signature,
223
+ comments: "@return [Boolean]",
224
+ )
225
+ end
226
+
227
+ def register_setter(field_name, location, ruby_type)
228
+ @listener.add_method(
229
+ "#{field_name}=",
230
+ location,
231
+ value_param_signature,
232
+ comments: setter_comment(ruby_type),
233
+ )
234
+ end
235
+
236
+ # ─────────────────────────────────────────────────────────────────────────
237
+ # Signatures & Comments
238
+ # ─────────────────────────────────────────────────────────────────────────
239
+
240
+ def no_params_signature
241
+ [RubyIndexer::Entry::Signature.new([])]
242
+ end
243
+
244
+ def value_param_signature
245
+ [RubyIndexer::Entry::Signature.new([
246
+ RubyIndexer::Entry::RequiredParameter.new(name: :value),
247
+ ])]
248
+ end
249
+
250
+ def return_type_comment(ruby_type)
251
+ return nil unless ruby_type
252
+
253
+ "@return [#{ruby_type}]"
254
+ end
255
+
256
+ def setter_comment(ruby_type)
257
+ return "@param value the value to set" unless ruby_type
258
+
259
+ "@param value [#{ruby_type}] the value to set\n@return [#{ruby_type}]"
260
+ end
261
+ end
262
+ end
263
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: light-services
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 3.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kodkod
@@ -34,6 +34,8 @@ files:
34
34
  - Rakefile
35
35
  - bin/console
36
36
  - bin/setup
37
+ - docs/README.md
38
+ - docs/SUMMARY.md
37
39
  - docs/arguments.md
38
40
  - docs/best-practices.md
39
41
  - docs/callbacks.md
@@ -46,11 +48,11 @@ files:
46
48
  - docs/outputs.md
47
49
  - docs/pundit-authorization.md
48
50
  - docs/quickstart.md
49
- - docs/readme.md
50
51
  - docs/recipes.md
52
+ - docs/rubocop.md
53
+ - docs/ruby-lsp.md
51
54
  - docs/service-rendering.md
52
55
  - docs/steps.md
53
- - docs/summary.md
54
56
  - docs/testing.md
55
57
  - lib/generators/light_services/install/USAGE
56
58
  - lib/generators/light_services/install/install_generator.rb
@@ -86,10 +88,22 @@ files:
86
88
  - lib/light/services/rspec/matchers/have_error_on.rb
87
89
  - lib/light/services/rspec/matchers/have_warning_on.rb
88
90
  - lib/light/services/rspec/matchers/trigger_callback.rb
91
+ - lib/light/services/rubocop.rb
92
+ - lib/light/services/rubocop/cop/light_services/argument_type_required.rb
93
+ - lib/light/services/rubocop/cop/light_services/condition_method_exists.rb
94
+ - lib/light/services/rubocop/cop/light_services/deprecated_methods.rb
95
+ - lib/light/services/rubocop/cop/light_services/dsl_order.rb
96
+ - lib/light/services/rubocop/cop/light_services/missing_private_keyword.rb
97
+ - lib/light/services/rubocop/cop/light_services/no_direct_instantiation.rb
98
+ - lib/light/services/rubocop/cop/light_services/output_type_required.rb
99
+ - lib/light/services/rubocop/cop/light_services/step_method_exists.rb
89
100
  - lib/light/services/settings/field.rb
90
101
  - lib/light/services/settings/step.rb
91
102
  - lib/light/services/utils.rb
92
103
  - lib/light/services/version.rb
104
+ - lib/ruby_lsp/light_services/addon.rb
105
+ - lib/ruby_lsp/light_services/definition.rb
106
+ - lib/ruby_lsp/light_services/indexing_enhancement.rb
93
107
  - light-services.gemspec
94
108
  homepage: https://light-services-docs.vercel.app/
95
109
  licenses: