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,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
|