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.
Files changed (107) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/rules/services/RULE.md +269 -0
  3. data/.cursor/rules/services-rspec/RULE.md +354 -0
  4. data/.github/dependabot.yml +11 -0
  5. data/.github/workflows/ci.yml +77 -0
  6. data/.gitignore +25 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +134 -0
  9. data/.ruby-version +1 -0
  10. data/.vscode/cspell.json +18 -0
  11. data/.vscode/project-words.txt +12 -0
  12. data/AGENTS.md +139 -0
  13. data/CHANGELOG.md +111 -0
  14. data/CLAUDE.md +139 -0
  15. data/CODE_OF_CONDUCT.md +74 -0
  16. data/Gemfile +28 -0
  17. data/Gemfile.lock +149 -0
  18. data/LICENSE.txt +21 -0
  19. data/README.md +172 -0
  20. data/Rakefile +8 -0
  21. data/bin/console +15 -0
  22. data/bin/setup +8 -0
  23. data/config/default.yml +57 -0
  24. data/docs/README.md +105 -0
  25. data/docs/SUMMARY.md +31 -0
  26. data/docs/arguments.md +275 -0
  27. data/docs/best-practices.md +153 -0
  28. data/docs/callbacks.md +475 -0
  29. data/docs/concepts.md +79 -0
  30. data/docs/configuration.md +218 -0
  31. data/docs/context.md +128 -0
  32. data/docs/crud.md +525 -0
  33. data/docs/errors.md +331 -0
  34. data/docs/generators.md +250 -0
  35. data/docs/outputs.md +150 -0
  36. data/docs/pundit-authorization.md +320 -0
  37. data/docs/quickstart.md +133 -0
  38. data/docs/recipes.md +14 -0
  39. data/docs/rubocop.md +430 -0
  40. data/docs/ruby-lsp.md +121 -0
  41. data/docs/service-rendering.md +222 -0
  42. data/docs/sorbet-runtime.md +283 -0
  43. data/docs/steps.md +438 -0
  44. data/docs/tapioca.md +188 -0
  45. data/docs/testing.md +548 -0
  46. data/lib/generators/operandi/install/USAGE +15 -0
  47. data/lib/generators/operandi/install/install_generator.rb +45 -0
  48. data/lib/generators/operandi/install/templates/application_service.rb.tt +8 -0
  49. data/lib/generators/operandi/install/templates/application_service_spec.rb.tt +7 -0
  50. data/lib/generators/operandi/install/templates/initializer.rb.tt +30 -0
  51. data/lib/generators/operandi/service/USAGE +21 -0
  52. data/lib/generators/operandi/service/service_generator.rb +80 -0
  53. data/lib/generators/operandi/service/templates/service.rb.tt +48 -0
  54. data/lib/generators/operandi/service/templates/service_spec.rb.tt +40 -0
  55. data/lib/operandi/base.rb +230 -0
  56. data/lib/operandi/base_with_context.rb +57 -0
  57. data/lib/operandi/callbacks.rb +353 -0
  58. data/lib/operandi/collection.rb +166 -0
  59. data/lib/operandi/concerns/execution.rb +80 -0
  60. data/lib/operandi/concerns/parent_service.rb +32 -0
  61. data/lib/operandi/concerns/state_management.rb +34 -0
  62. data/lib/operandi/config.rb +142 -0
  63. data/lib/operandi/constants.rb +96 -0
  64. data/lib/operandi/dsl/arguments_dsl.rb +83 -0
  65. data/lib/operandi/dsl/outputs_dsl.rb +79 -0
  66. data/lib/operandi/dsl/steps_dsl.rb +206 -0
  67. data/lib/operandi/dsl/validation.rb +171 -0
  68. data/lib/operandi/exceptions.rb +66 -0
  69. data/lib/operandi/message.rb +52 -0
  70. data/lib/operandi/messages.rb +185 -0
  71. data/lib/operandi/rspec/matchers/define_argument.rb +172 -0
  72. data/lib/operandi/rspec/matchers/define_output.rb +145 -0
  73. data/lib/operandi/rspec/matchers/define_step.rb +223 -0
  74. data/lib/operandi/rspec/matchers/execute_step.rb +228 -0
  75. data/lib/operandi/rspec/matchers/have_error_on.rb +144 -0
  76. data/lib/operandi/rspec/matchers/have_warning_on.rb +146 -0
  77. data/lib/operandi/rspec/matchers/trigger_callback.rb +136 -0
  78. data/lib/operandi/rspec.rb +15 -0
  79. data/lib/operandi/rubocop/cop/operandi/argument_type_required.rb +52 -0
  80. data/lib/operandi/rubocop/cop/operandi/condition_method_exists.rb +173 -0
  81. data/lib/operandi/rubocop/cop/operandi/deprecated_accessors.rb +113 -0
  82. data/lib/operandi/rubocop/cop/operandi/deprecated_methods.rb +113 -0
  83. data/lib/operandi/rubocop/cop/operandi/dsl_order.rb +181 -0
  84. data/lib/operandi/rubocop/cop/operandi/missing_private_keyword.rb +102 -0
  85. data/lib/operandi/rubocop/cop/operandi/no_direct_instantiation.rb +66 -0
  86. data/lib/operandi/rubocop/cop/operandi/no_hash_argument.rb +101 -0
  87. data/lib/operandi/rubocop/cop/operandi/output_type_required.rb +52 -0
  88. data/lib/operandi/rubocop/cop/operandi/prefer_fail_method.rb +112 -0
  89. data/lib/operandi/rubocop/cop/operandi/prefer_optional_over_default_nil.rb +124 -0
  90. data/lib/operandi/rubocop/cop/operandi/redundant_optional.rb +103 -0
  91. data/lib/operandi/rubocop/cop/operandi/reserved_name.rb +56 -0
  92. data/lib/operandi/rubocop/cop/operandi/step_method_exists.rb +134 -0
  93. data/lib/operandi/rubocop.rb +22 -0
  94. data/lib/operandi/settings/field.rb +147 -0
  95. data/lib/operandi/settings/step.rb +105 -0
  96. data/lib/operandi/utils.rb +36 -0
  97. data/lib/operandi/version.rb +5 -0
  98. data/lib/operandi.rb +11 -0
  99. data/lib/ruby_lsp/operandi/addon.rb +37 -0
  100. data/lib/ruby_lsp/operandi/definition.rb +134 -0
  101. data/lib/ruby_lsp/operandi/indexing_enhancement.rb +224 -0
  102. data/lib/tapioca/dsl/compilers/operandi.rb +378 -0
  103. data/operandi.gemspec +33 -0
  104. data/rbi/operandi.rbi +197 -0
  105. data/sorbet/cache/data.mdb +0 -0
  106. data/sorbet/cache/lock.mdb +0 -0
  107. metadata +151 -0
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLsp
4
+ module Operandi
5
+ class IndexingEnhancement < RubyIndexer::Enhancement
6
+ # DSL methods that generate getter, predicate, and setter methods
7
+ FIELD_DSL_METHODS = [:arg, :output].freeze
8
+
9
+ # ─────────────────────────────────────────────────────────────────────────
10
+ # Public API - Called by Ruby LSP indexer
11
+ # ─────────────────────────────────────────────────────────────────────────
12
+
13
+ # Called when the indexer encounters a method call node
14
+ # Detects `arg` and `output` DSL calls and registers the generated methods
15
+ def on_call_node_enter(node)
16
+ return unless @listener.current_owner
17
+ return unless FIELD_DSL_METHODS.include?(node.name)
18
+
19
+ field_name = extract_field_name(node)
20
+ return unless field_name
21
+
22
+ ruby_type = extract_ruby_type(node)
23
+ register_field_methods(field_name, node.location, ruby_type)
24
+ end
25
+
26
+ def on_call_node_leave(node); end
27
+
28
+ private
29
+
30
+ # ─────────────────────────────────────────────────────────────────────────
31
+ # Field Extraction
32
+ # ─────────────────────────────────────────────────────────────────────────
33
+
34
+ # Extract the field name from the first argument (symbol)
35
+ # Example: `arg :user` → "user"
36
+ def extract_field_name(node)
37
+ arguments = node.arguments&.arguments
38
+ return unless arguments&.any?
39
+
40
+ first_arg = arguments.first
41
+ return unless first_arg.is_a?(Prism::SymbolNode)
42
+
43
+ first_arg.value
44
+ end
45
+
46
+ # ─────────────────────────────────────────────────────────────────────────
47
+ # Type Resolution
48
+ # ─────────────────────────────────────────────────────────────────────────
49
+
50
+ # Extract and resolve the Ruby type from the `type:` keyword argument
51
+ # Returns the mapped Ruby type string or the original type if no mapping exists
52
+ def extract_ruby_type(node)
53
+ type_node = find_type_value_node(node)
54
+ return unless type_node
55
+
56
+ resolve_to_ruby_type(type_node)
57
+ end
58
+
59
+ # Find the value node for the `type:` keyword argument
60
+ def find_type_value_node(node)
61
+ arguments = node.arguments&.arguments
62
+ return unless arguments
63
+
64
+ keyword_hash = arguments.find { |arg| arg.is_a?(Prism::KeywordHashNode) }
65
+ return unless keyword_hash
66
+
67
+ # NOTE: Prism's SymbolNode#value returns a String, not a Symbol
68
+ type_element = keyword_hash.elements.find do |element|
69
+ element.is_a?(Prism::AssocNode) &&
70
+ element.key.is_a?(Prism::SymbolNode) &&
71
+ element.key.value == "type"
72
+ end
73
+
74
+ type_element&.value
75
+ end
76
+
77
+ # Resolve a Prism type node to a Ruby type string
78
+ # Handles constants, constant paths, and method chains
79
+ def resolve_to_ruby_type(node)
80
+ type_string = node_to_constant_string(node)
81
+ return unless type_string
82
+
83
+ map_to_ruby_type(type_string) || type_string
84
+ end
85
+
86
+ # Convert a Prism node to its constant string representation
87
+ def node_to_constant_string(node)
88
+ case node
89
+ when Prism::ConstantReadNode
90
+ node.name.to_s
91
+ when Prism::ConstantPathNode
92
+ build_constant_path(node)
93
+ when Prism::CallNode
94
+ # Handle method chains like Types::String.constrained(...) or Types::Array.of(...)
95
+ extract_receiver_constant(node)
96
+ end
97
+ end
98
+
99
+ # Build a full constant path string from nested ConstantPathNodes
100
+ # Example: MyApp::Config
101
+ def build_constant_path(node)
102
+ parts = []
103
+ current = node
104
+
105
+ while current.is_a?(Prism::ConstantPathNode)
106
+ parts.unshift(current.name.to_s)
107
+ current = current.parent
108
+ end
109
+
110
+ parts.unshift(current.name.to_s) if current.is_a?(Prism::ConstantReadNode)
111
+ parts.join("::")
112
+ end
113
+
114
+ # Extract the receiver constant from a method call chain
115
+ # Example: SomeClass.method(...) → "SomeClass"
116
+ def extract_receiver_constant(node)
117
+ receiver = node.receiver
118
+ return unless receiver
119
+
120
+ case receiver
121
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
122
+ node_to_constant_string(receiver)
123
+ when Prism::CallNode
124
+ extract_receiver_constant(receiver)
125
+ end
126
+ end
127
+
128
+ # ─────────────────────────────────────────────────────────────────────────
129
+ # Type Mapping
130
+ # ─────────────────────────────────────────────────────────────────────────
131
+
132
+ # Map a type string to its corresponding Ruby type
133
+ # Uses custom mappings from config if available
134
+ def map_to_ruby_type(type_string)
135
+ mappings = effective_type_mappings
136
+ return nil if mappings.empty?
137
+
138
+ # Direct mapping lookup (custom mappings take precedence)
139
+ return mappings[type_string] if mappings.key?(type_string)
140
+
141
+ # Handle parameterized types
142
+ base_type = type_string.split(".").first
143
+ mappings[base_type]
144
+ end
145
+
146
+ # Returns the effective type mappings from config
147
+ def effective_type_mappings
148
+ return {} unless defined?(::Operandi)
149
+ return {} unless ::Operandi.respond_to?(:config)
150
+
151
+ custom_mappings = ::Operandi.config&.ruby_lsp_type_mappings
152
+ return {} if custom_mappings.nil?
153
+
154
+ custom_mappings
155
+ rescue NoMethodError
156
+ {}
157
+ end
158
+
159
+ # ─────────────────────────────────────────────────────────────────────────
160
+ # Method Registration
161
+ # ─────────────────────────────────────────────────────────────────────────
162
+
163
+ # Register all three generated methods for a field (getter, predicate, setter)
164
+ def register_field_methods(field_name, location, ruby_type)
165
+ register_getter(field_name, location, ruby_type)
166
+ register_predicate(field_name, location)
167
+ register_setter(field_name, location, ruby_type)
168
+ end
169
+
170
+ def register_getter(field_name, location, ruby_type)
171
+ @listener.add_method(
172
+ field_name.to_s,
173
+ location,
174
+ no_params_signature,
175
+ comments: return_type_comment(ruby_type),
176
+ )
177
+ end
178
+
179
+ def register_predicate(field_name, location)
180
+ @listener.add_method(
181
+ "#{field_name}?",
182
+ location,
183
+ no_params_signature,
184
+ comments: "@return [Boolean]",
185
+ )
186
+ end
187
+
188
+ def register_setter(field_name, location, ruby_type)
189
+ @listener.add_method(
190
+ "#{field_name}=",
191
+ location,
192
+ value_param_signature,
193
+ comments: setter_comment(ruby_type),
194
+ )
195
+ end
196
+
197
+ # ─────────────────────────────────────────────────────────────────────────
198
+ # Signatures & Comments
199
+ # ─────────────────────────────────────────────────────────────────────────
200
+
201
+ def no_params_signature
202
+ [RubyIndexer::Entry::Signature.new([])]
203
+ end
204
+
205
+ def value_param_signature
206
+ [RubyIndexer::Entry::Signature.new([
207
+ RubyIndexer::Entry::RequiredParameter.new(name: :value),
208
+ ])]
209
+ end
210
+
211
+ def return_type_comment(ruby_type)
212
+ return nil unless ruby_type
213
+
214
+ "@return [#{ruby_type}]"
215
+ end
216
+
217
+ def setter_comment(ruby_type)
218
+ return "@param value the value to set" unless ruby_type
219
+
220
+ "@param value [#{ruby_type}] the value to set\n@return [#{ruby_type}]"
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,378 @@
1
+ # frozen_string_literal: true
2
+
3
+ return unless defined?(Tapioca::Dsl::Compiler)
4
+
5
+ module Tapioca
6
+ module Dsl
7
+ module Compilers
8
+ # Tapioca DSL compiler for Operandi
9
+ #
10
+ # Generates RBI signatures for methods automatically defined by the
11
+ # `arg`/`argument` and `output` DSL macros in operandi.
12
+ #
13
+ # For each argument and output, three methods are generated:
14
+ # - Getter: `def name` - returns the value
15
+ # - Predicate: `def name?` - returns boolean
16
+ # - Setter: `def name=` (private) - sets the value
17
+ #
18
+ # Additionally, typed inner classes are generated:
19
+ # - `Arguments` - T::Struct representing all service arguments
20
+ # - `Outputs` - T::Struct representing all service outputs
21
+ #
22
+ # @example Service definition
23
+ # class CreateUser < Operandi::Base
24
+ # arg :name, type: String
25
+ # arg :email, type: String, optional: true
26
+ # arg :role, type: [Symbol, String]
27
+ #
28
+ # output :user, type: User
29
+ # end
30
+ #
31
+ # @example Generated RBI
32
+ # class CreateUser
33
+ # class Arguments < T::Struct
34
+ # prop :name, ::String
35
+ # prop :email, T.nilable(::String), default: nil
36
+ # prop :role, T.any(::Symbol, ::String)
37
+ # end
38
+ #
39
+ # class Outputs < T::Struct
40
+ # prop :user, ::User
41
+ # end
42
+ #
43
+ # sig { returns(Arguments) }
44
+ # def arg; end
45
+ #
46
+ # sig { returns(Outputs) }
47
+ # def output; end
48
+ #
49
+ # sig { returns(String) }
50
+ # def name; end
51
+ #
52
+ # sig { returns(T::Boolean) }
53
+ # def name?; end
54
+ #
55
+ # sig { returns(T.nilable(String)) }
56
+ # def email; end
57
+ #
58
+ # sig { returns(T::Boolean) }
59
+ # def email?; end
60
+ #
61
+ # sig { returns(T.any(Symbol, String)) }
62
+ # def role; end
63
+ #
64
+ # sig { returns(T::Boolean) }
65
+ # def role?; end
66
+ #
67
+ # sig { returns(User) }
68
+ # def user; end
69
+ #
70
+ # sig { returns(T::Boolean) }
71
+ # def user?; end
72
+ #
73
+ # private
74
+ #
75
+ # sig { params(value: String).returns(String) }
76
+ # def name=(value); end
77
+ #
78
+ # # ... other setters
79
+ # end
80
+ class Operandi < Compiler # rubocop:disable Metrics/ClassLength
81
+ extend T::Sig
82
+
83
+ ConstantType = type_member { { fixed: T.class_of(::Operandi::Base) } }
84
+ CONFIG_TYPE = "T::Hash[T.any(::String, ::Symbol), T.untyped]"
85
+ SERVICE_OR_CONFIG_TYPE = "T.any(::Operandi::Base, #{CONFIG_TYPE})".freeze
86
+
87
+ class << self
88
+ extend T::Sig
89
+
90
+ sig { override.returns(T::Enumerable[Module]) }
91
+ def gather_constants
92
+ all_classes.select do |klass|
93
+ klass < ::Operandi::Base && klass.name && klass != ::Operandi::Base
94
+ end
95
+ end
96
+ end
97
+
98
+ sig { override.void }
99
+ def decorate
100
+ root.create_path(constant) do |klass|
101
+ # Generate class methods (.run, .run!, .with)
102
+ generate_class_methods(klass)
103
+
104
+ # Generate typed inner classes for arguments and outputs
105
+ generate_arguments_type(klass)
106
+ generate_outputs_type(klass)
107
+
108
+ # Generate argument methods
109
+ constant.arguments.each_value do |field|
110
+ generate_field_methods(klass, field)
111
+ end
112
+
113
+ # Generate output methods
114
+ constant.outputs.each_value do |field|
115
+ generate_field_methods(klass, field)
116
+ end
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ sig { params(klass: RBI::Scope).void }
123
+ def generate_class_methods(klass)
124
+ generate_run_method(klass)
125
+ generate_run_bang_method(klass)
126
+ generate_with_method(klass)
127
+ end
128
+
129
+ sig { params(klass: RBI::Scope).void }
130
+ def generate_arguments_type(klass)
131
+ return if constant.arguments.empty?
132
+
133
+ generate_struct_class(klass, "Arguments", constant.arguments)
134
+ klass.create_method("arg", return_type: "Arguments")
135
+ end
136
+
137
+ sig { params(klass: RBI::Scope).void }
138
+ def generate_outputs_type(klass)
139
+ return if constant.outputs.empty?
140
+
141
+ generate_struct_class(klass, "Outputs", constant.outputs)
142
+ klass.create_method("output", return_type: "Outputs")
143
+ end
144
+
145
+ sig { params(klass: RBI::Scope).void }
146
+ def generate_run_method(klass)
147
+ klass.create_method(
148
+ "run",
149
+ parameters: generate_argument_parameters,
150
+ return_type: "T.attached_class",
151
+ class_method: true,
152
+ )
153
+ end
154
+
155
+ sig { params(klass: RBI::Scope).void }
156
+ def generate_run_bang_method(klass)
157
+ klass.create_method(
158
+ "run!",
159
+ parameters: generate_argument_parameters,
160
+ return_type: "T.attached_class",
161
+ class_method: true,
162
+ )
163
+ end
164
+
165
+ sig { params(klass: RBI::Scope).void }
166
+ def generate_with_method(klass)
167
+ klass.create_method(
168
+ "with",
169
+ parameters: [create_param("service_or_config", type: SERVICE_OR_CONFIG_TYPE),
170
+ create_opt_param("config", type: CONFIG_TYPE, default: "{}"),],
171
+ return_type: "T.self_type",
172
+ class_method: true,
173
+ )
174
+ end
175
+
176
+ sig { returns(T::Array[T.untyped]) }
177
+ def generate_argument_parameters
178
+ # Sort required params before optional (Sorbet requirement)
179
+ constant.arguments
180
+ .sort_by { |_, field| field.optional || field.default_exists ? 1 : 0 }
181
+ .map { |name, field| create_argument_param(name, field) }
182
+ end
183
+
184
+ sig { params(name: Symbol, field: ::Operandi::Settings::Field).returns(T.untyped) }
185
+ def create_argument_param(name, field)
186
+ ruby_type = resolve_argument_input_type(field)
187
+ return create_kw_param(name.to_s, type: ruby_type) unless field.optional || field.default_exists
188
+
189
+ param_type = field.optional ? as_nilable_type(ruby_type) : ruby_type
190
+ create_kw_opt_param(name.to_s, type: param_type, default: format_default_value(field))
191
+ end
192
+
193
+ sig { params(field: ::Operandi::Settings::Field).returns(String) }
194
+ def format_default_value(field)
195
+ return "nil" if field.optional && !field.default_exists
196
+ return "T.unsafe(nil)" unless field.default_exists
197
+
198
+ format_literal_default(field.default)
199
+ end
200
+
201
+ sig { params(default: T.untyped).returns(String) }
202
+ def format_literal_default(default)
203
+ case default
204
+ when String, Symbol then default.inspect
205
+ when Numeric, TrueClass, FalseClass then default.to_s
206
+ when NilClass then "nil"
207
+ when Hash then default.empty? ? "{}" : "T.unsafe(nil)"
208
+ when Array then default.empty? ? "[]" : "T.unsafe(nil)"
209
+ else "T.unsafe(nil)" # Proc and other complex types
210
+ end
211
+ end
212
+
213
+ sig { params(field: ::Operandi::Settings::Field).returns(String) }
214
+ def resolve_argument_input_type(field)
215
+ type = field.instance_variable_get(:@type)
216
+ return resolve_enum_argument_input_type(type) if sorbet_enum_class?(type)
217
+
218
+ resolve_type(field)
219
+ end
220
+
221
+ sig { params(type: T.class_of(T::Enum)).returns(String) }
222
+ def resolve_enum_argument_input_type(type)
223
+ any_type_for([ruby_type_for_class(type), *enum_serialized_types(type)])
224
+ end
225
+
226
+ sig { params(type: T.class_of(T::Enum)).returns(T::Array[String]) }
227
+ def enum_serialized_types(type)
228
+ type.values.map { |value| ruby_type_for_class(value.serialize.class) }.uniq
229
+ rescue StandardError
230
+ []
231
+ end
232
+
233
+ sig { params(types: T::Array[String]).returns(String) }
234
+ def any_type_for(types)
235
+ resolved_types = types.uniq
236
+ return resolved_types.first if resolved_types.size == 1
237
+
238
+ "T.any(#{resolved_types.join(', ')})"
239
+ end
240
+
241
+ sig { params(klass: RBI::Scope, field: ::Operandi::Settings::Field).void }
242
+ def generate_field_methods(klass, field)
243
+ name = field.name.to_s
244
+ ruby_type = resolve_type(field)
245
+ return_type = field.optional ? as_nilable_type(ruby_type) : ruby_type
246
+
247
+ # Getter
248
+ klass.create_method(name, return_type: return_type)
249
+
250
+ # Predicate
251
+ klass.create_method("#{name}?", return_type: "T::Boolean")
252
+
253
+ # Setter (private)
254
+ klass.create_method(
255
+ "#{name}=",
256
+ parameters: [create_param("value", type: return_type)],
257
+ return_type: return_type,
258
+ visibility: RBI::Private.new,
259
+ )
260
+ end
261
+
262
+ sig { params(field: ::Operandi::Settings::Field).returns(String) }
263
+ def resolve_type(field)
264
+ type = field.instance_variable_get(:@type)
265
+ return "T.untyped" unless type
266
+
267
+ if type.is_a?(Array)
268
+ resolve_array_type(type)
269
+ elsif sorbet_type?(type)
270
+ resolve_sorbet_type(type)
271
+ elsif type.is_a?(Class) || type.is_a?(Module)
272
+ ruby_type_for_class(type)
273
+ else
274
+ "T.untyped"
275
+ end
276
+ end
277
+
278
+ sig { params(types: T::Array[T.untyped]).returns(String) }
279
+ def resolve_array_type(types)
280
+ resolved_types = types.map do |t|
281
+ if t.is_a?(Class) || t.is_a?(Module)
282
+ ruby_type_for_class(t)
283
+ else
284
+ "T.untyped"
285
+ end
286
+ end.uniq
287
+
288
+ return resolved_types.first if resolved_types.size == 1
289
+
290
+ # Check if this is a boolean type (TrueClass + FalseClass)
291
+ if resolved_types.sort == ["::FalseClass", "::TrueClass"]
292
+ "T::Boolean"
293
+ else
294
+ "T.any(#{resolved_types.join(', ')})"
295
+ end
296
+ end
297
+
298
+ sig { params(klass: T.any(Class, Module)).returns(String) }
299
+ def ruby_type_for_class(klass)
300
+ name = klass.name
301
+ return "T.untyped" unless name
302
+
303
+ # Handle boolean types specially
304
+ if klass == TrueClass
305
+ "::TrueClass"
306
+ elsif klass == FalseClass
307
+ "::FalseClass"
308
+ else
309
+ "::#{name}"
310
+ end
311
+ end
312
+
313
+ sig { params(type: T.untyped).returns(T::Boolean) }
314
+ def sorbet_type?(type)
315
+ defined?(T::Types::Base) && type.is_a?(T::Types::Base)
316
+ end
317
+
318
+ sig { params(type: T.untyped).returns(T::Boolean) }
319
+ def sorbet_enum_class?(type)
320
+ return false unless type.is_a?(Class)
321
+
322
+ (defined?(T::Enum) && type < T::Enum) || serializable_enum_class?(type)
323
+ end
324
+
325
+ sig { params(type: Class).returns(T::Boolean) }
326
+ def serializable_enum_class?(type)
327
+ type.respond_to?(:try_deserialize) &&
328
+ type.respond_to?(:values) &&
329
+ type.values.any? &&
330
+ type.values.all? { |value| value.respond_to?(:serialize) }
331
+ rescue StandardError
332
+ false
333
+ end
334
+
335
+ sig { params(type: T.untyped).returns(String) }
336
+ def resolve_sorbet_type(type)
337
+ type.name || "T.untyped"
338
+ end
339
+
340
+ sig { params(type: String).returns(String) }
341
+ def as_nilable_type(type)
342
+ # Don't double-wrap nilable types
343
+ return type if type.start_with?("T.nilable(")
344
+ return type if type == "T.untyped"
345
+
346
+ "T.nilable(#{type})"
347
+ end
348
+
349
+ sig do
350
+ params(
351
+ klass: RBI::Scope,
352
+ class_name: String,
353
+ fields: T::Hash[Symbol, ::Operandi::Settings::Field],
354
+ ).void
355
+ end
356
+ def generate_struct_class(klass, class_name, fields)
357
+ return if fields.empty?
358
+
359
+ klass.create_class(class_name, superclass_name: "T::Struct") do |struct_klass|
360
+ fields.each_value do |field|
361
+ generate_struct_prop(struct_klass, field)
362
+ end
363
+ end
364
+ end
365
+
366
+ sig { params(struct_klass: RBI::Scope, field: ::Operandi::Settings::Field).void }
367
+ def generate_struct_prop(struct_klass, field)
368
+ name = field.name.to_s
369
+ ruby_type = resolve_type(field)
370
+ prop_type = field.optional ? as_nilable_type(ruby_type) : ruby_type
371
+ default_value = format_default_value(field) if field.optional || field.default_exists
372
+
373
+ struct_klass << RBI::TStructProp.new(name, prop_type, default: default_value)
374
+ end
375
+ end
376
+ end
377
+ end
378
+ end
data/operandi.gemspec ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/operandi/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "operandi"
7
+ spec.version = Operandi::VERSION
8
+ spec.authors = ["Andrew Kodkod"]
9
+ spec.email = ["andrew@kodkod.me"]
10
+
11
+ spec.summary = "Robust service architecture for Ruby/Rails applications"
12
+ spec.description = "Operandi is a simple yet powerful way to organize business logic in Ruby applications. Build services that are easy to test, maintain, and understand." # rubocop:disable Layout/LineLength
13
+ spec.homepage = "https://operandi-docs.vercel.app/"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.1.0")
16
+
17
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = "https://github.com/akodkod/operandi"
21
+ spec.metadata["changelog_uri"] = "https://github.com/akodkod/operandi/blob/master/CHANGELOG.md"
22
+
23
+ # Specify which files should be added to the gem when it is released.
24
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
26
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
27
+ end
28
+
29
+ spec.bindir = "exe"
30
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ["lib"]
32
+ spec.metadata["rubygems_mfa_required"] = "true"
33
+ end